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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user