feat(hospital): 프로브 기울기를 0°~180° 로 편다

지금까지는 앱의 자세 판정과 같은 atan(√(ax²+ay²)/|az|) 로 0°~90° 였다 — |az| 라서 90° 를
넘으면 접혀 100° 가 80° 로 보였다. 요청(2026-09-30)으로 atan2(√(ax²+ay²), az) 로
0°~180° 로 편다: 0° 평평(배 위, az≈+1g) · 90° 세로 · 180° 뒤집힘.

  · PostureTilt.degrees(samples) — 병원·정렬 화면의 기울기(폴링·회차·요약 tilt_deg_*)가
    전부 이것을 쓴다. 샘플이 없으면 null(화면은 지난 값 유지).
  · 자세 판정(ImuPostureClassifier, 낙상 감지 문턱)은 건드리지 않는다.
  · 0°~90° 구간은 두 정의가 같으므로 Sitting 35°~55° 와 그동안의 기록은 그대로다.
  · 카드의 띠 축 90 → 180.

시험 PostureTiltTest 5건(0/45/90/135/180 · 평균 · 빈 샘플).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 15:17:17 +09:00
parent 8ef7c64ba6
commit e939159026
6 changed files with 60 additions and 15 deletions
@@ -160,10 +160,14 @@ object HospitalFixedParams {
* 자세별 **프로브 기울기** 요구 범위 (2026-09-28 요청 · 사내 임상 데이터 분석 기준).
*
* ## 무엇을 재나
* 기울기 = [com.medithings.vesiscan.managers.ImuPostureClassifier.classify] 의 `pitchDeg`.
* IMU 15 샘플 평균으로 `atan(√(ax²+ay²) / |az|)` — 프로브 Z축이 중력과 벌어진 각도다.
* 0° = 평평(누워서 배 위) · 90° = 세로. 앱이 자세 판정에 쓰는 것과 **같은 정의**라,
* 저장된 `*_imu.csv` 의 원시값에서 분석팀이 다시 계산해도 같은 숫자가 나온다.
* 기울기 = [degrees]. IMU 15 샘플 평균으로 `atan2(√(ax²+ay²), az)` — 프로브 Z축이 중력과
* 벌어진 각도, **0°~180°**. 0° = 평평(누워서 배 위, az≈+1g) · 90° = 세로 · 180° = 뒤집힘
* (az≈−1g). 저장된 `*_imu.csv` 의 원시값에서 분석팀이 다시 계산해도 같은 숫자가 나온다.
*
* 2026-09-30 까지는 앱의 자세 판정([com.medithings.vesiscan.managers.ImuPostureClassifier])
* 과 같은 `atan(√(ax²+ay²) / |az|)` 로 0°~90° 였다 — |az| 라서 90° 를 넘으면 접혀 100° 가
* 80° 로 보였다. 요청으로 0°~180° 로 편다. 0°~90° 구간은 두 정의가 같으므로 Sitting
* 35°~55° 와 그동안의 기록은 그대로다. 자세 판정 쪽은 건드리지 않는다(낙상 감지 문턱).
* **2026-09-28 요청자 확인: 35°~55° 는 이 정의로 낸 값이 맞다.**
*
* ## 왜 Sitting 만 있나
@@ -179,6 +183,21 @@ object PostureTilt {
/** Sitting 측정 가능 기울기 (도). */
val SITTING_DEG: ClosedFloatingPointRange<Float> = 35f..55f
/**
* 프로브 기울기 0°~180°. 샘플 평균의 `atan2(√(ax²+ay²), az)`. 샘플이 없으면 null —
* 화면은 지난 값을 유지한다(0 으로 떨어뜨리면 "평평해졌다"로 읽힌다).
*/
fun degrees(samples: List<com.medithings.vesiscan.ble.ImuSample>): Float? {
if (samples.isEmpty()) return null
var ax = 0.0; var ay = 0.0; var az = 0.0
for (s in samples) { ax += s.ax; ay += s.ay; az += s.az }
val n = samples.size
ax /= n; ay /= n; az /= n
val horizontal = kotlin.math.hypot(ax, ay)
if (horizontal < 1e-6 && kotlin.math.abs(az) < 1e-6) return null // 무중력·불량 샘플
return Math.toDegrees(kotlin.math.atan2(horizontal, az)).toFloat()
}
fun rangeFor(posture: ClinicalPosture): ClosedFloatingPointRange<Float>? = when (posture) {
ClinicalPosture.SITTING -> SITTING_DEG
ClinicalPosture.SUPINE, ClinicalPosture.STANDING -> null
@@ -72,10 +72,10 @@ import com.medithings.vesiscan.managers.AnchorSelection
import com.medithings.vesiscan.managers.AnchorStep
import com.medithings.vesiscan.managers.ClinicalBv
import com.medithings.vesiscan.managers.DetachWatcher
import com.medithings.vesiscan.managers.ImuPostureClassifier
import com.medithings.vesiscan.managers.LateralGuide
import com.medithings.vesiscan.managers.ReattachCheck
import com.medithings.vesiscan.models.ClinicalPosture
import com.medithings.vesiscan.models.PostureTilt
import com.medithings.vesiscan.models.HospitalFixedParams
import com.medithings.vesiscan.services.HospitalRunStore
import com.medithings.vesiscan.services.labdb.HospitalLabdbUploader
@@ -368,7 +368,7 @@ fun AnchorAlignView(appState: AppState) {
// IMU 는 piezo 뒤에 온다. 없으면 빈 목록 — 측정을 실패로 돌리지 않는다.
val imu = imuSide.await() ?: emptyList()
cyclesImu.add(imu)
if (imu.isNotEmpty()) liveTilt = ImuPostureClassifier.classify(imu).pitchDeg
PostureTilt.degrees(imu)?.let { liveTilt = it }
android.util.Log.d("AnchorAlign", "cycle $i/${AnchorConfig.ALIGN_CYCLES} imu=${imu.size}")
val sorted = got.sortedBy { it.channel }
liveChannels = sorted // 화면 파형 — 저장·판정에 쓰는 것과 같은 데이터
@@ -515,7 +515,7 @@ fun AnchorAlignView(appState: AppState) {
continue
}
val imu = lrImu.await()?.takeIf { it.isNotEmpty() }
imu?.let { liveTilt = ImuPostureClassifier.classify(it).pitchDeg }
imu?.let { PostureTilt.degrees(it)?.let { t -> liveTilt = t } }
val sorted = got.sortedBy { it.channel }
liveChannels = sorted
detachWatcher.push(sorted.map { it.buffer })?.let { d -> detachSnapshot = d }
@@ -52,7 +52,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.managers.AnchorBasis
import com.medithings.vesiscan.managers.ClinicalBv
import com.medithings.vesiscan.managers.ImuPostureClassifier
import com.medithings.vesiscan.ui.components.ChannelDetachPanel
import com.medithings.vesiscan.ui.components.LabdbStatusCard
import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid
@@ -345,9 +344,10 @@ fun HospitalModeView(appState: AppState) {
imuSide.await()?.let { imu ->
HospitalRunStore.appendImuSamples(file, sid, r, imu)
// 측정 중에도 화면의 기울기가 살아 있게 — 이 회차의 자세다.
val tilt = ImuPostureClassifier.classify(imu).pitchDeg
liveTilt = tilt
runTilts += tilt
PostureTilt.degrees(imu)?.let { tilt ->
liveTilt = tilt
runTilts += tilt
}
}
if (saved) { ok++; okCount++ } else { fail++; failCount++ }
lastFile = file.name
@@ -108,10 +108,10 @@ fun ImuTiltCard(
}
}
/** 0°~90° 축 위에 요구 범위 띠와 현재 위치 눈금을 그린다. */
/** 0°~180° 축 위에 요구 범위 띠와 현재 위치 눈금을 그린다(2026-09-30 까지는 0°~90°). */
@Composable
private fun TiltBand(tiltDeg: Float?, range: ClosedFloatingPointRange<Float>, accent: Color) {
val axisMax = 90f
val axisMax = 180f
Box(
Modifier
.fillMaxWidth()
@@ -12,7 +12,7 @@
package com.medithings.vesiscan.ui.views.clinical
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.managers.ImuPostureClassifier
import com.medithings.vesiscan.models.PostureTilt
import kotlinx.coroutines.delay
/**
@@ -35,7 +35,7 @@ suspend fun pollLiveTilt(ble: BleManager, onTilt: (Float) -> Unit) {
side.drain()
ble.sendImuFifoQuery()
side.await(IDLE_TILT_POLL_MS + 300)?.let { samples ->
onTilt(ImuPostureClassifier.classify(samples).pitchDeg)
PostureTilt.degrees(samples)?.let(onTilt)
}
delay(IDLE_TILT_POLL_MS)
}
@@ -8,6 +8,7 @@
*/
package com.medithings.vesiscan.models
import com.medithings.vesiscan.ble.ImuSample
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
@@ -27,6 +28,31 @@ class PostureTiltTest {
assertEquals(PostureTilt.Status.ABOVE, PostureTilt.status(55.1f, ClinicalPosture.SITTING))
}
private fun acc(ax: Float, ay: Float, az: Float) = ImuSample(ax, ay, az, 0f, 0f, 0f)
@Test
fun `기울기는 0에서 180 이고 90 을 넘으면 접히지 않는다`() {
// 2026-09-30 요청: 0~180. 옛 정의(|az|)는 100° 를 80° 로 보였다.
assertEquals(0f, PostureTilt.degrees(listOf(acc(0f, 0f, 1f)))!!, 0.01f) // 평평(배 위)
assertEquals(90f, PostureTilt.degrees(listOf(acc(1f, 0f, 0f)))!!, 0.01f) // 세로
assertEquals(90f, PostureTilt.degrees(listOf(acc(0f, 1f, 0f)))!!, 0.01f) // 어느 축이 세로든 같다
assertEquals(45f, PostureTilt.degrees(listOf(acc(1f, 0f, 1f)))!!, 0.01f)
assertEquals(135f, PostureTilt.degrees(listOf(acc(1f, 0f, -1f)))!!, 0.01f) // 90 넘어 기울임
assertEquals(180f, PostureTilt.degrees(listOf(acc(0f, 0f, -1f)))!!, 0.01f) // 뒤집힘
}
@Test
fun `샘플 평균으로 낸다 — 한 샘플이 튀어도 15개 평균이다`() {
val flat = List(14) { acc(0f, 0f, 1f) } + acc(1f, 0f, 1f)
val d = PostureTilt.degrees(flat)!!
assert(d in 3f..5f) { "평균 ax=1/15 → 약 3.8° 인데 $d" }
}
@Test
fun `샘플이 없으면 null — 0 으로 떨어뜨리지 않는다`() {
assertNull(PostureTilt.degrees(emptyList()))
}
@Test
fun `다른 자세에는 범위가 없다`() {
assertNull(PostureTilt.rangeFor(ClinicalPosture.SUPINE))