From 812c0a2ae738eed90b878b579636db43efb1b49c Mon Sep 17 00:00:00 2001 From: jjangddu Date: Mon, 28 Sep 2026 10:58:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(hospital):=20=ED=94=84=EB=A1=9C=EB=B8=8C?= =?UTF-8?q?=20=EA=B8=B0=EC=9A=B8=EA=B8=B0=20=EC=83=81=EC=8B=9C=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=C2=B7=20Sitting=2035=C2=B0~55=C2=B0=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=95=20=EA=B0=81=EB=8F=84=20=EC=95=88=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 요청 (2026-09-28 · 임상 데이터 분석) Sitting 정확도 개선을 위해 특정 각도 범위의 데이터가 필요하다. 사내 임상 분석 결과로 Sitting 은 35°~55° 에서 재야 한다. 임상 앱에 ① IMU 각도 상시 표시(+ IMU 저장) ② Sitting 단계에서 그 범위가 측정 가능 각도라는 안내. ## 각도의 정의 — 분석팀 숫자와 같은 식이어야 한다 `ImuPostureClassifier.classify().pitchDeg` 를 그대로 쓴다: IMU 15샘플 평균으로 atan(√(ax²+ay²)/|az|). 0° = 평평(누워서 배 위) · 90° = 세로. 앱이 자세 판정에 쓰는 것과 같은 정의라 저장된 `*_imu.csv` 원시값에서 다시 계산해도 같은 숫자가 나온다. (`PostureTilt` KDoc 에 식을 적어 두었다 — 분석팀이 쓴 정의가 이것과 다르면 여기서 맞춘다.) ## 상시 표시는 어디서 값을 받나 간호사가 프로브를 대고 각도를 맞추는 것은 **측정 전**이다. 측정 중에만 오는 mtb 의 IMU 로는 늦다. 그래서 연결돼 있고 루프가 안 돌 때 `mim`(IMU 만, 무음)을 800ms 마다 보내 기울기를 띄우고, 루프가 돌면 회차마다 오는 mtb 의 IMU 로 갱신한다. 폴링과 루프가 같은 `imuCollector.onComplete` 를 쓰므로 둘 다 ImuSidecar 를 통해 걸고 푼다. `running` 이 켜지면 폴링 효과가 취소되는데, 취소된 코루틴의 finally(restore) 가 루프의 install 뒤에 실행되는 순서가 가능하다 — ImuSidecar.restore 가 "자기 콜백일 때만 되돌림"(00fef4a9)이라 이 순서에서 루프의 훅이 살아남는다. 그 방어가 바로 여기서 쓰인다. ## 안내지 차단이 아니다 범위 밖이어도 측정은 막지 않는다. 요청이 "안내(표시)"이고, 막으면 범위를 벗어난 대조 데이터를 일부러 받을 길이 없어진다. 대신 사후에 걸러낼 수 있게 남긴다: · 회차별 IMU — 이미 `*_imu.csv` (2026-09-21) · 실행 요약 JSON — tilt_deg_mean/min/max/n · tilt_deg_required (신규) ## 화면 `ImuTiltCard` — 연결 상태 줄 바로 아래, 항상. 큰 숫자 + 출처("실시간"/"측정 회차마다 갱신"/"연결 안 됨"). 자세에 요구 범위가 있으면 0~90° 축 위에 띠와 현재 위치 눈금, "범위 안" 또는 "N° 더 세워야/눕혀야 함". 자세 칩 아래에도 한 줄 힌트. ## 시험 PostureTiltTest 2건 — 경계(35·55) 포함, Sitting 외 자세는 범위 없음. 범위 숫자 자체는 분석팀 결정이라 시험이 옳고 그름을 말하지는 않는다. Co-Authored-By: Claude Fable 5.1 --- .../vesiscan/models/HospitalProtocol.kt | 39 +++++ .../ui/views/clinical/HospitalModeView.kt | 71 +++++++++ .../vesiscan/ui/views/clinical/ImuTiltCard.kt | 141 ++++++++++++++++++ .../vesiscan/models/PostureTiltTest.kt | 37 +++++ 4 files changed, 288 insertions(+) create mode 100644 app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ImuTiltCard.kt create mode 100644 app/src/test/java/com/medithings/vesiscan/models/PostureTiltTest.kt 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 6406dd3..91b8435 100644 --- a/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt +++ b/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt @@ -155,3 +155,42 @@ object HospitalFixedParams { /** echo sample 개수 (허용 80~117). 앱의 채널 버퍼가 100 샘플 기준이다. */ const val SAMPLES = 100 } + +/** + * 자세별 **프로브 기울기** 요구 범위 (2026-09-28 요청 · 사내 임상 데이터 분석 기준). + * + * ## 무엇을 재나 + * 기울기 = [com.medithings.vesiscan.managers.ImuPostureClassifier.classify] 의 `pitchDeg`. + * IMU 15 샘플 평균으로 `atan(√(ax²+ay²) / |az|)` — 프로브 Z축이 중력과 벌어진 각도다. + * 0° = 평평(누워서 배 위) · 90° = 세로. 앱이 자세 판정에 쓰는 것과 **같은 정의**라, + * 저장된 `*_imu.csv` 의 원시값에서 분석팀이 다시 계산해도 같은 숫자가 나온다. + * + * ## 왜 Sitting 만 있나 + * Sitting 정확도 개선을 위해 35°~55° 구간의 데이터가 필요하다는 요청이다. 다른 자세는 + * 아직 범위가 정해지지 않았다 — 정해지면 [rangeFor] 에 한 줄 더 넣는다. + * + * ## 안내지 차단이 아니다 + * 범위 밖이어도 측정은 막지 않는다. 요청이 "안내(표시)"이고, 막으면 범위를 벗어난 + * 대조 데이터를 일부러 받을 길이 없어진다. 대신 기울기는 회차마다 `*_imu.csv` 에, + * 실행 평균은 요약 JSON(`tilt_deg_mean`)에 남아 사후에 걸러낼 수 있다. + */ +object PostureTilt { + /** Sitting 측정 가능 기울기 (도). */ + val SITTING_DEG: ClosedFloatingPointRange = 35f..55f + + fun rangeFor(posture: ClinicalPosture): ClosedFloatingPointRange? = when (posture) { + ClinicalPosture.SITTING -> SITTING_DEG + ClinicalPosture.SUPINE, ClinicalPosture.STANDING -> null + } + + enum class Status { NO_RANGE, BELOW, IN_RANGE, ABOVE } + + fun status(tiltDeg: Float, posture: ClinicalPosture): Status { + val r = rangeFor(posture) ?: return Status.NO_RANGE + return when { + tiltDeg < r.start -> Status.BELOW + tiltDeg > r.endInclusive -> Status.ABOVE + else -> Status.IN_RANGE + } + } +} 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 134611c..b46aa49 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 @@ -40,6 +40,7 @@ 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 @@ -75,6 +76,13 @@ private const val COMBINATION_SETTLE_MS = 800L /** 회차 사이 간격 — 기존 임상 라이브(600ms)와 같은 값. */ private const val REPEAT_GAP_MS = 600L +/** + * 쉬는 동안 기울기 표시용 IMU 폴링 주기. `mim` 한 번이 15 샘플·300ms 창이라 이보다 + * 촘촘할 이유가 없고, 워치독의 유휴 `mim`(10초 무응답 시)보다는 훨씬 잦아 그쪽이 끼어들 + * 틈이 없다. 측정 루프 중에는 폴링하지 않는다 — `sendImuFifoQuery` 의 `imuCollector.reset()` + * 이 `mtb` 의 IMU 를 지운다. + */ +private const val IDLE_TILT_POLL_MS = 800L @Composable fun HospitalModeView(appState: AppState) { @@ -107,6 +115,11 @@ fun HospitalModeView(appState: AppState) { var configFailed by remember { mutableStateOf(null) } /** 프로브 설정 복원 결과. 측정이 끝난 뒤 화면에 한 줄로 남긴다. */ var restoreNote by remember { mutableStateOf(null) } + /** + * 프로브 기울기(도) — 상시 표시용 (2026-09-28 요청). 쉬는 동안은 아래 폴링이, + * 측정 중에는 루프가 회차마다 채운다. 정의는 [PostureTilt] 참조. + */ + var liveTilt by remember { mutableStateOf(null) } /** * 방금 받은 6채널 원본. 아래쪽 파형 그래프가 이걸 그린다. * @@ -197,6 +210,34 @@ fun HospitalModeView(appState: AppState) { } } + // ── 기울기 폴링 (쉬는 동안) ────────────────────────────────────────── + // Sitting 은 35°~55° 에서 재야 한다(사내 임상 분석 · 2026-09-28). 간호사가 프로브를 + // 대고 각도를 맞추는 것은 **측정 전**이라, 측정 중에만 오는 mtb 의 IMU 로는 늦다. + // 그래서 연결돼 있고 루프가 안 돌 때 `mim` 으로 IMU 만 받아 기울기를 띄운다. + // `running` 이 켜지면 이 효과는 취소되고 루프가 자기 사이드카를 건다 — 두 사이드카가 + // 겹치는 순서(취소된 쪽의 restore 가 새 install 뒤에 옴)는 restore 의 "자기 콜백일 + // 때만 되돌림" 이 막는다. + LaunchedEffect(isConnected, running) { + if (!isConnected || running) { + if (!isConnected) liveTilt = null + return@LaunchedEffect + } + val side = ImuSidecar(bleManager) + side.install() + try { + while (true) { + side.drain() + bleManager.sendImuFifoQuery() + side.await(IDLE_TILT_POLL_MS + 300)?.let { samples -> + liveTilt = ImuPostureClassifier.classify(samples).pitchDeg + } + delay(IDLE_TILT_POLL_MS) + } + } finally { + side.restore() + } + } + // ── 측정 루프 ──────────────────────────────────────────────────────── LaunchedEffect(running) { if (!running) return@LaunchedEffect @@ -226,6 +267,8 @@ fun HospitalModeView(appState: AppState) { // 가를 근거가 필요하다. val imuSide = ImuSidecar(bleManager) imuSide.install() + // 회차별 기울기 — 실행 요약에 평균·범위를 남겨 사후에 각도로 걸러낼 수 있게. + val runTilts = mutableListOf() try { // 복부 두께가 정한 조합. 순서는 [AbdomenThickness] 가 박아 두었다 — 매번 @@ -311,6 +354,10 @@ fun HospitalModeView(appState: AppState) { // IMU 는 piezo 뒤에 온다. 없어도 측정은 성립하므로 실패로 세지 않는다. imuSide.await()?.let { imu -> HospitalRunStore.appendImuSamples(file, sid, r, imu) + // 측정 중에도 화면의 기울기가 살아 있게 — 이 회차의 자세다. + val tilt = ImuPostureClassifier.classify(imu).pitchDeg + liveTilt = tilt + runTilts += tilt } if (saved) { ok++; okCount++ } else { fail++; failCount++ } lastFile = file.name @@ -354,6 +401,18 @@ fun HospitalModeView(appState: AppState) { HospitalRunStore.writeSummary(dir, JSONObject().apply { put("patient", patient) put("posture", posture.label) + // 프로브 기울기(도) — 회차 IMU 평균의 실행 통계. 회차별 값은 `*_imu.csv` 에. + // Sitting 요구 범위(35~55)는 안내일 뿐 차단하지 않으므로, 범위 밖 실행을 + // 나중에 걸러내려면 여기 숫자가 있어야 한다. + if (runTilts.isNotEmpty()) { + put("tilt_deg_mean", runTilts.average()) + put("tilt_deg_min", runTilts.min()) + put("tilt_deg_max", runTilts.max()) + put("tilt_deg_n", runTilts.size) + } + PostureTilt.rangeFor(posture)?.let { r -> + put("tilt_deg_required", "${r.start}-${r.endInclusive}") + } put("fill_pct", fill.percent) put("repeats", repeats) // 돌 조합을 이것이 정했다. 조합만 기록하면 "왜 1개만 돌았나"를 나중에 @@ -442,6 +501,15 @@ fun HospitalModeView(appState: AppState) { ) } } + Spacer(Modifier.height(8.dp)) + + // 프로브 기울기 — 상시 (2026-09-28 요청). Sitting 을 고르면 요구 범위가 함께 뜬다. + ImuTiltCard( + tiltDeg = liveTilt, + posture = posture, + connected = isConnected, + running = running, + ) Spacer(Modifier.height(12.dp)) OutlinedTextField( @@ -483,6 +551,9 @@ fun HospitalModeView(appState: AppState) { Label("자세") ChipRow(ClinicalPosture.entries, posture, { posture = it }, !running, progressOf = { postureProgress(it) }) { it.label } + PostureTilt.rangeFor(posture)?.let { r -> + Hint("${posture.label} 은 프로브 기울기 ${r.start.toInt()}°~${r.endInclusive.toInt()}° 에서 잰다 — 위 카드로 맞춘다") + } Spacer(Modifier.height(12.dp)) Label("방광 채움 정도 — 간호사가 채워 둔 값") 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 new file mode 100644 index 0000000..4c8a7a1 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ImuTiltCard.kt @@ -0,0 +1,141 @@ +/* + * ImuTiltCard — 병원 임상 화면의 **프로브 기울기 상시 표시** (2026-09-28 요청). + * + * 무엇을 보여주나: + * · 지금 기울기(도). 값이 없으면 "—" 와 이유(연결 안 됨 / 수신 대기). + * · 고른 자세에 요구 범위가 있으면(지금은 Sitting 35°~55°) 그 띠와 현재 위치, + * 범위 안/밖과 얼마나 벗어났는지. + * + * 값의 출처는 화면이 정한다 — 쉬는 동안은 `mim` 폴링, 측정 중에는 회차마다 오는 + * `mtb` 의 IMU. 이 카드는 그리기만 한다. + */ +package com.medithings.vesiscan.ui.views.clinical + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.medithings.vesiscan.models.ClinicalPosture +import com.medithings.vesiscan.models.PostureTilt +import java.util.Locale + +private val InRange = Color(0xFF2E7D32) +private val OutRange = Color(0xFFD32F2F) +private val Neutral = Color(0xFF546E7A) + +/** + * @param tiltDeg 지금 기울기. null 이면 아직 값이 없다. + * @param connected 프로브 연결 여부 — 값이 없을 때 이유를 적기 위해. + * @param running 측정 루프 중인가 — 값의 출처 설명을 바꾼다. + */ +@Composable +fun ImuTiltCard( + tiltDeg: Float?, + posture: ClinicalPosture, + connected: Boolean, + running: Boolean, + modifier: Modifier = Modifier, +) { + val range = PostureTilt.rangeFor(posture) + val status = tiltDeg?.let { PostureTilt.status(it, posture) } + val accent = when (status) { + PostureTilt.Status.IN_RANGE -> InRange + PostureTilt.Status.BELOW, PostureTilt.Status.ABOVE -> OutRange + else -> Neutral + } + + Column( + modifier + .fillMaxWidth() + .background(accent.copy(alpha = 0.08f), RoundedCornerShape(10.dp)) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("IMU 기울기", fontSize = 12.sp, color = Neutral, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.width(10.dp)) + Text( + if (tiltDeg == null) "—" else String.format(Locale.US, "%.0f°", tiltDeg), + fontSize = 26.sp, fontWeight = FontWeight.Bold, color = accent, + ) + Spacer(Modifier.weight(1f)) + Text( + when { + tiltDeg == null && !connected -> "연결 안 됨" + tiltDeg == null -> "수신 대기" + running -> "측정 회차마다 갱신" + else -> "실시간" + }, + fontSize = 11.sp, color = Neutral, + ) + } + + if (range != null) { + Spacer(Modifier.height(6.dp)) + TiltBand(tiltDeg, range, accent) + Spacer(Modifier.height(4.dp)) + Text( + buildString { + append(posture.label) + append(" 측정 가능 각도 ") + append(String.format(Locale.US, "%.0f°~%.0f°", range.start, range.endInclusive)) + when (status) { + PostureTilt.Status.IN_RANGE -> append(" · 범위 안") + PostureTilt.Status.BELOW -> + append(String.format(Locale.US, " · %.0f° 더 세워야 함", range.start - tiltDeg!!)) + PostureTilt.Status.ABOVE -> + append(String.format(Locale.US, " · %.0f° 더 눕혀야 함", tiltDeg!! - range.endInclusive)) + else -> Unit + } + }, + fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = accent, + ) + } + } +} + +/** 0°~90° 축 위에 요구 범위 띠와 현재 위치 눈금을 그린다. */ +@Composable +private fun TiltBand(tiltDeg: Float?, range: ClosedFloatingPointRange, accent: Color) { + val axisMax = 90f + Box( + Modifier + .fillMaxWidth() + .height(10.dp) + .background(Neutral.copy(alpha = 0.15f), RoundedCornerShape(5.dp)), + ) { + // 요구 범위 띠 + Row(Modifier.fillMaxWidth()) { + Spacer(Modifier.weight((range.start / axisMax).coerceAtLeast(0.001f))) + Box( + Modifier + .weight(((range.endInclusive - range.start) / axisMax).coerceAtLeast(0.001f)) + .height(10.dp) + .background(InRange.copy(alpha = 0.35f), RoundedCornerShape(5.dp)), + ) + Spacer(Modifier.weight(((axisMax - range.endInclusive) / axisMax).coerceAtLeast(0.001f))) + } + // 현재 위치 눈금 + if (tiltDeg != null) { + val f = (tiltDeg / axisMax).coerceIn(0f, 1f) + Row(Modifier.fillMaxWidth()) { + if (f > 0f) Spacer(Modifier.weight(f)) + Box(Modifier.width(3.dp).height(10.dp).background(accent, RoundedCornerShape(1.dp))) + if (f < 1f) Spacer(Modifier.weight(1f - f)) + } + } + } +} diff --git a/app/src/test/java/com/medithings/vesiscan/models/PostureTiltTest.kt b/app/src/test/java/com/medithings/vesiscan/models/PostureTiltTest.kt new file mode 100644 index 0000000..24f5999 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/models/PostureTiltTest.kt @@ -0,0 +1,37 @@ +/* + * PostureTiltTest — Sitting 기울기 범위(35°~55°) 판정을 잠근다. + * + * 범위 자체는 분석팀이 정한 숫자라 여기서 "맞다"고 할 수 없다. 잠그는 것은 두 가지다: + * 1. 경계값이 포함이다 — 35.0° 와 55.0° 는 범위 안. 경계에서 색이 뒤집히면 + * 간호사가 "안"인지 "밖"인지 화면마다 다르게 본다. + * 2. Sitting 이외 자세에는 범위가 없다 — 있으면 없는 요구를 화면이 만들어 낸다. + */ +package com.medithings.vesiscan.models + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PostureTiltTest { + + @Test + fun `Sitting 범위는 35에서 55 이고 경계는 포함이다`() { + val r = PostureTilt.rangeFor(ClinicalPosture.SITTING)!! + assertEquals(35f, r.start, 0f) + assertEquals(55f, r.endInclusive, 0f) + + assertEquals(PostureTilt.Status.BELOW, PostureTilt.status(34.9f, ClinicalPosture.SITTING)) + assertEquals(PostureTilt.Status.IN_RANGE, PostureTilt.status(35f, ClinicalPosture.SITTING)) + assertEquals(PostureTilt.Status.IN_RANGE, PostureTilt.status(45f, ClinicalPosture.SITTING)) + assertEquals(PostureTilt.Status.IN_RANGE, PostureTilt.status(55f, ClinicalPosture.SITTING)) + assertEquals(PostureTilt.Status.ABOVE, PostureTilt.status(55.1f, ClinicalPosture.SITTING)) + } + + @Test + fun `다른 자세에는 범위가 없다`() { + assertNull(PostureTilt.rangeFor(ClinicalPosture.SUPINE)) + assertNull(PostureTilt.rangeFor(ClinicalPosture.STANDING)) + assertEquals(PostureTilt.Status.NO_RANGE, PostureTilt.status(45f, ClinicalPosture.SUPINE)) + assertEquals(PostureTilt.Status.NO_RANGE, PostureTilt.status(45f, ClinicalPosture.STANDING)) + } +}