diff --git a/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt b/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt index 162168f..7ce328c 100644 --- a/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt +++ b/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt @@ -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 = 35f..55f + /** + * 프로브 기울기 0°~180°. 샘플 평균의 `atan2(√(ax²+ay²), az)`. 샘플이 없으면 null — + * 화면은 지난 값을 유지한다(0 으로 떨어뜨리면 "평평해졌다"로 읽힌다). + */ + fun degrees(samples: List): 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? = when (posture) { ClinicalPosture.SITTING -> SITTING_DEG ClinicalPosture.SUPINE, ClinicalPosture.STANDING -> null diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt index 20e8f94..1e0f1fb 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt @@ -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 } diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt index 61399ed..960aada 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt @@ -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 diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ImuTiltCard.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ImuTiltCard.kt index dd11ba7..d1b77ac 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ImuTiltCard.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ImuTiltCard.kt @@ -108,10 +108,10 @@ fun ImuTiltCard( } } -/** 0°~90° 축 위에 요구 범위 띠와 현재 위치 눈금을 그린다. */ +/** 0°~180° 축 위에 요구 범위 띠와 현재 위치 눈금을 그린다(2026-09-30 까지는 0°~90°). */ @Composable private fun TiltBand(tiltDeg: Float?, range: ClosedFloatingPointRange, accent: Color) { - val axisMax = 90f + val axisMax = 180f Box( Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/LiveTilt.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/LiveTilt.kt index e26297a..5e167d0 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/LiveTilt.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/LiveTilt.kt @@ -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) } diff --git a/app/src/test/java/com/medithings/vesiscan/models/PostureTiltTest.kt b/app/src/test/java/com/medithings/vesiscan/models/PostureTiltTest.kt index 24f5999..1b8cfe1 100644 --- a/app/src/test/java/com/medithings/vesiscan/models/PostureTiltTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/models/PostureTiltTest.kt @@ -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))