From 346f647857efdc6121466daf241815597e5fb7dd Mon Sep 17 00:00:00 2001 From: jjangddu Date: Thu, 1 Oct 2026 09:43:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(hospital):=20Supine=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=20=EC=B8=A1=EC=A0=95=EC=9D=98=20IMU=20=ED=8F=89=EA=B7=A0?= =?UTF-8?q?=EC=9D=84=20=EA=B8=B0=EC=A4=80=EA=B0=81=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=E2=80=94=20Sitting=20=EC=9A=94=EA=B5=AC=20=EB=B2=94=EC=9C=84?= =?UTF-8?q?=EB=A5=BC=2035+m~55+m=20=EB=A1=9C=20=EB=AF=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 35°~55° 는 Supine 에서 프로브가 0° 라는 가정이다. 배가 나온 환자는 누워서도 20° 쯤 나오고, 그러면 Sitting 최적 구간은 55°~75° 다(2026-10-01 제안). · Supine 정렬의 확인 측정(N cm, 20 cycle)에서 IMU 평균 기울기 m 을 재 AppState.supineTiltBaseline 에 두고, 요약 align_result.json 에 confirm_tilt_deg_mean 으로 남긴다(어느 자세든). 앱을 다시 켜면 그날 Supine 정렬 요약에서 되살린다. · PostureTilt.rangeFor/status 가 기준각을 받아 Sitting 범위를 (35+m)..(55+m) 로 민다. 기준각이 없으면 절대 각도 그대로. · 카드: Sitting 에서 "IMU 기울기 n° · Supine 대비 +k°", 띠는 밀린 범위, 문구 "Sitting 측정 가능 각도 55°~75° (Supine 20° + 35~55)". 기준각이 없으면 "Supine 기준각 없음" 을 적는다. 다른 자세에서는 "Supine 기준각 20° 저장됨 (Sitting 범위 55°~75°)". · run 매니페스트: tilt_deg_required 는 밀린 값, tilt_baseline_supine 추가. · 환자가 바뀌면 기준각도 비운다. 시험 PostureTiltTest 기준각 1건 추가 — 전체 191건 · failures 0. Co-Authored-By: Claude Fable 5.1 --- .../java/com/medithings/vesiscan/AppState.kt | 13 +++++++- .../vesiscan/models/HospitalProtocol.kt | 27 ++++++++++++---- .../vesiscan/services/HospitalRunStore.kt | 13 ++++++++ .../ui/views/clinical/AnchorAlignView.kt | 22 ++++++++++--- .../ui/views/clinical/HospitalModeView.kt | 12 ++++++- .../vesiscan/ui/views/clinical/ImuTiltCard.kt | 31 +++++++++++++++++-- .../vesiscan/models/PostureTiltTest.kt | 17 ++++++++++ docs/LABDB_DATATYPES.md | 1 + 8 files changed, 122 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/medithings/vesiscan/AppState.kt b/app/src/main/java/com/medithings/vesiscan/AppState.kt index 14cf7b1..ddee70a 100644 --- a/app/src/main/java/com/medithings/vesiscan/AppState.kt +++ b/app/src/main/java/com/medithings/vesiscan/AppState.kt @@ -88,8 +88,19 @@ class AppState(private val context: Context) { /** 지금 자세의 부착 위치와 딸린 것을 비운다. */ fun clearAnchor() { anchors.remove(clinicalPosture) } + /** + * Supine 정렬 확인 측정의 IMU 평균 기울기(도) — Sitting 요구 범위의 기준각 m + * ([com.medithings.vesiscan.models.PostureTilt] 참조, 2026-10-01). null = 아직 없음. + * 환자가 바뀌면 비운다. 앱을 다시 켜면 그날 Supine 정렬 요약(`confirm_tilt_deg_mean`)에서 + * 되살린다([com.medithings.vesiscan.services.HospitalRunStore.readConfirmTilt]). + */ + var supineTiltBaseline: Float? by mutableStateOf(null) + /** 전 자세를 비운다 — 환자가 바뀌거나 프로브가 바뀔 때(빔 각도가 달라 정렬이 무효). */ - fun clearAllAnchors() { anchors.clear() } + fun clearAllAnchors() { + anchors.clear() + supineTiltBaseline = null + } /** * 병원 임상 자세. **정렬 화면과 병원 모드가 같은 값을 봐야 한다.** 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 7ce328c..4822173 100644 --- a/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt +++ b/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt @@ -174,6 +174,13 @@ object HospitalFixedParams { * Sitting 정확도 개선을 위해 35°~55° 구간의 데이터가 필요하다는 요청이다. 다른 자세는 * 아직 범위가 정해지지 않았다 — 정해지면 [rangeFor] 에 한 줄 더 넣는다. * + * ## Supine 기준각 (2026-10-01) + * 35°~55° 는 **Supine 에서 프로브가 0°** 라는 가정이다. 배가 나온 환자는 누워서도 20° 쯤 + * 나오고, 그러면 Sitting 의 최적 구간은 55°~75° 다. 그래서 Supine 정렬의 **확인 측정** + * (N cm, 20 cycle)에서 IMU 평균 기울기 m 을 재 두고, Sitting 요구 범위를 `35+m ~ 55+m` 로 + * 민다 — 화면이 보는 것은 `n − m` 이 35~55 안인가다. 기준각이 없으면(Supine 정렬을 안 했거나 + * 옛 앱) 절대 각도 35~55 그대로이고 화면이 그 사실을 적는다. + * * ## 안내지 차단이 아니다 * 범위 밖이어도 측정은 막지 않는다. 요청이 "안내(표시)"이고, 막으면 범위를 벗어난 * 대조 데이터를 일부러 받을 길이 없어진다. 대신 기울기는 회차마다 `*_imu.csv` 에, @@ -198,15 +205,23 @@ object PostureTilt { 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 - } + /** + * 자세의 요구 범위(절대 각도). [supineBaseline] 이 있으면 Sitting 범위를 그만큼 민다 — + * `(35+m)..(55+m)`. 없으면 `35..55` 그대로. + */ + fun rangeFor(posture: ClinicalPosture, supineBaseline: Float? = null): ClosedFloatingPointRange? = + when (posture) { + ClinicalPosture.SITTING -> { + val m = supineBaseline ?: 0f + (SITTING_DEG.start + m)..(SITTING_DEG.endInclusive + m) + } + 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 + fun status(tiltDeg: Float, posture: ClinicalPosture, supineBaseline: Float? = null): Status { + val r = rangeFor(posture, supineBaseline) ?: return Status.NO_RANGE return when { tiltDeg < r.start -> Status.BELOW tiltDeg > r.endInclusive -> Status.ABOVE diff --git a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt index 62f738a..b30db15 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt @@ -497,6 +497,19 @@ object HospitalRunStore { ?.sortedBy { it.name } ?: emptyList() + /** + * 그날 그 환자의 자세 정렬 요약에서 확인 측정 IMU 평균 기울기(`confirm_tilt_deg_mean`). + * 없으면 null. 앱을 다시 켠 뒤 Supine 기준각을 되살리는 데 쓴다(2026-10-01). + */ + fun readConfirmTilt(patient: String, day: Date, posture: com.medithings.vesiscan.models.ClinicalPosture): Float? { + val run = runDirIfExists(patient, day) ?: return null + val f = File(File(run, alignDirName(posture)), "align_result.json") + if (!f.isFile) return null + val v = runCatching { JSONObject(f.readText(Charsets.UTF_8)).optDouble("confirm_tilt_deg_mean") } + .getOrNull() ?: return null + return if (v.isNaN()) null else v.toFloat() + } + /** 읽기 전용 조회 — [runDir] 과 달리 폴더를 만들지 않는다. */ private fun runDirIfExists(patient: String, day: Date): File? = File(File(downloads(), ROOT), "${dayFmt.format(day)}_${safe(patient)}").takeIf { it.isDirectory } 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 42cc2c8..09c9e05 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 @@ -213,6 +213,8 @@ fun AnchorAlignView(appState: AppState) { * 지표가 다른 쪽이라 N cm 기준으로 대조하면 멀쩡한 재부착도 "채널 없음"이 될 수 있다. */ var lrRefChannels by remember { mutableStateOf?>(null) } + /** 확인 측정(N cm)의 IMU 평균 기울기. Supine 이면 Sitting 기준각이 된다(2026-10-01). */ + var confirmTiltDeg by remember { mutableStateOf(null) } /** 간호사가 "좌우 확인 완료"를 누른 적이 있는가. 저장·다음 단계 안내에 쓴다. */ var lrConfirmed by remember { mutableStateOf(false) } @@ -405,6 +407,14 @@ fun AnchorAlignView(appState: AppState) { // IMU 는 없을 수 있다(펌웨어·설정). 없다고 오류로 만들지 않는다 — // 파형이 남았으면 측정은 성립한다. HospitalRunStore.writeAlignImu(dir, cm, cyclesImu, kind) + // 확인 측정의 IMU 평균 기울기 — Supine 이면 Sitting 요구 범위의 기준각 m 이 된다 + // (2026-10-01, PostureTilt 참조). 요약에도 남겨 앱을 다시 켜도 되살린다. + if (isConfirm) { + PostureTilt.degrees(cyclesImu.flatten())?.let { t -> + confirmTiltDeg = t + if (appState.clinicalPosture == ClinicalPosture.SUPINE) appState.supineTiltBaseline = t + } + } // 판정과 **같은 mean-scan** 으로 BV 를 낸다. 따로 재면 그 위치의 지표와 // 용적이 다른 데이터에서 나온 값이 되어 대조가 성립하지 않는다. @@ -432,7 +442,7 @@ fun AnchorAlignView(appState: AppState) { dir, buildAlignSummary(saveName, appState.hospitalPatient, deviceName, bleManager.firmwareVersion.value, guide.records, step, - decidedAtCm = decidedAtCm, startedAt = runStartedAt, posture = appState.clinicalPosture.label), + decidedAtCm = decidedAtCm, startedAt = runStartedAt, posture = appState.clinicalPosture.label, confirmTiltDeg = confirmTiltDeg), ) // ── 다음 위치 ────────────────────────────────────────────────── // **0~SEARCH_MAX_CM 를 무조건 다 잰다.** guide 의 안내(MOVE_UP/MOVE_DOWN)는 @@ -651,6 +661,7 @@ fun AnchorAlignView(appState: AppState) { posture = appState.clinicalPosture, connected = isConnected, running = measuring || lrRunning, + supineBaseline = appState.supineTiltBaseline, ) Spacer(Modifier.height(10.dp)) @@ -933,7 +944,7 @@ fun AnchorAlignView(appState: AppState) { saveName, appState.hospitalPatient, deviceName, bleManager.firmwareVersion.value, guide.records, s, lrCommit, lateralConfirmed = true, - decidedAtCm = decidedAtCm, startedAt = runStartedAt, posture = appState.clinicalPosture.label, + decidedAtCm = decidedAtCm, startedAt = runStartedAt, posture = appState.clinicalPosture.label, confirmTiltDeg = confirmTiltDeg, ), ) } @@ -982,7 +993,7 @@ fun AnchorAlignView(appState: AppState) { saveName, appState.hospitalPatient, deviceName, bleManager.firmwareVersion.value, guide.records, s, lrCommit, lrConfirmed, basis, proceedCm, decidedAtCm, - startedAt = runStartedAt, posture = appState.clinicalPosture.label, + startedAt = runStartedAt, posture = appState.clinicalPosture.label, confirmTiltDeg = confirmTiltDeg, confirmChannels = appState.anchorRefChannels, ), ) @@ -1026,7 +1037,7 @@ fun AnchorAlignView(appState: AppState) { runStartedAt = Date() guide.reset(); cm = 0; last = null; records = emptyList(); error = null lateral.reset(); lrCommit = null; lrAccum = 0; lrRecent.clear(); lrAttempt++ - lrRefChannels = null + lrRefChannels = null; confirmTiltDeg = null lrConfirmed = false; lrError = null; lrRunning = false disconnectedMidRun = false decidedAtCm = null; sweepStopped = false; confirmDone = false @@ -1499,8 +1510,11 @@ private fun buildAlignSummary( confirmChannels: List? = null, /** 이 정렬의 자세(라벨). 자세마다 정렬이 따로라 요약에도 박는다(2026-10-01). */ posture: String? = null, + /** 확인 측정의 IMU 평균 기울기(도). Supine 이면 Sitting 기준각. 앱 재시작 뒤 복구에 쓴다. */ + confirmTiltDeg: Float? = null, ): JSONObject = JSONObject().apply { posture?.let { put("posture", it) } + confirmTiltDeg?.let { put("confirm_tilt_deg_mean", it.toDouble()) } startedAt?.let { put("started_at", SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(it)) } confirmChannels?.let { put("confirm_channels", JSONArray(it)) } // 앱이 쓴 값 = **잰 위치 전부**로 고른 것. 선택 규칙은 Python `select_supine_anchor` 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 c578122..83e881f 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 @@ -202,6 +202,12 @@ fun HospitalModeView(appState: AppState) { runCatching { HospitalRunStore.scanProgress(patient, Date(), thickness.expectedKeys) } .getOrDefault(emptyMap()) } + // Supine 기준각 — 앱을 다시 켰으면 그날 Supine 정렬 요약에서 되살린다(2026-10-01). + if (appState.supineTiltBaseline == null && patient.isNotBlank()) { + withContext(Dispatchers.IO) { + runCatching { HospitalRunStore.readConfirmTilt(patient, Date(), ClinicalPosture.SUPINE) }.getOrNull() + }?.let { appState.supineTiltBaseline = it } + } } // 자세 칩은 그 자세의 6단계가 전부 끝났을 때만 완료로 본다. fun postureProgress(p: ClinicalPosture): HospitalRunStore.RunProgress { @@ -400,9 +406,12 @@ fun HospitalModeView(appState: AppState) { put("tilt_deg_max", runTilts.max()) put("tilt_deg_n", runTilts.size) } - PostureTilt.rangeFor(posture)?.let { r -> + // 요구 범위는 Supine 기준각이 있으면 밀린 값(55-75 식)이다. 기준각도 같이 남겨 + // 분석 쪽이 n − m 을 되살릴 수 있게 한다(2026-10-01). + PostureTilt.rangeFor(posture, appState.supineTiltBaseline)?.let { r -> put("tilt_deg_required", "${r.start}-${r.endInclusive}") } + appState.supineTiltBaseline?.let { put("tilt_baseline_supine", it.toDouble()) } put("fill_pct", fill.percent) put("repeats", repeats) // 돌 조합을 이것이 정했다. 조합만 기록하면 "왜 1개만 돌았나"를 나중에 @@ -598,6 +607,7 @@ fun HospitalModeView(appState: AppState) { posture = posture, connected = isConnected, running = running, + supineBaseline = appState.supineTiltBaseline, ) Spacer(Modifier.height(12.dp)) 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 d1b77ac..d3fdbea 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 @@ -50,9 +50,14 @@ fun ImuTiltCard( connected: Boolean, running: Boolean, modifier: Modifier = Modifier, + /** + * Supine 정렬 확인 측정의 기울기 m (2026-10-01). 있으면 Sitting 범위가 `35+m~55+m` 로 + * 밀리고 카드가 `n − m` 을 같이 보인다. 없으면 절대 각도 기준이고 그 사실을 적는다. + */ + supineBaseline: Float? = null, ) { - val range = PostureTilt.rangeFor(posture) - val status = tiltDeg?.let { PostureTilt.status(it, posture) } + val range = PostureTilt.rangeFor(posture, supineBaseline) + val status = tiltDeg?.let { PostureTilt.status(it, posture, supineBaseline) } val accent = when (status) { PostureTilt.Status.IN_RANGE -> InRange PostureTilt.Status.BELOW, PostureTilt.Status.ABOVE -> OutRange @@ -72,6 +77,14 @@ fun ImuTiltCard( if (tiltDeg == null) "—" else String.format(Locale.US, "%.0f°", tiltDeg), fontSize = 26.sp, fontWeight = FontWeight.Bold, color = accent, ) + // Sitting 에 기준각이 있으면 "Supine 대비 몇 도" — 판정이 보는 숫자가 이것이다. + if (tiltDeg != null && supineBaseline != null && posture == ClinicalPosture.SITTING) { + Spacer(Modifier.width(8.dp)) + Text( + String.format(Locale.US, "Supine 대비 %+.0f°", tiltDeg - supineBaseline), + fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = accent, + ) + } Spacer(Modifier.weight(1f)) Text( when { @@ -83,6 +96,14 @@ fun ImuTiltCard( fontSize = 11.sp, color = Neutral, ) } + // 기준각은 어느 자세에서든 작게 — Supine 정렬 뒤에 "저장됐다"가 보여야 한다. + if (supineBaseline != null && posture != ClinicalPosture.SITTING) { + Text( + String.format(Locale.US, "Supine 기준각 %.0f° 저장됨 (Sitting 범위 %.0f°~%.0f°)", + supineBaseline, PostureTilt.SITTING_DEG.start + supineBaseline, PostureTilt.SITTING_DEG.endInclusive + supineBaseline), + fontSize = 11.sp, color = Neutral, modifier = Modifier.padding(top = 2.dp), + ) + } if (range != null) { Spacer(Modifier.height(6.dp)) @@ -93,6 +114,12 @@ fun ImuTiltCard( append(posture.label) append(" 측정 가능 각도 ") append(String.format(Locale.US, "%.0f°~%.0f°", range.start, range.endInclusive)) + if (supineBaseline != null) { + append(String.format(Locale.US, " (Supine %.0f° + %.0f~%.0f)", + supineBaseline, PostureTilt.SITTING_DEG.start, PostureTilt.SITTING_DEG.endInclusive)) + } else { + append(" · Supine 기준각 없음") + } when (status) { PostureTilt.Status.IN_RANGE -> append(" · 범위 안") PostureTilt.Status.BELOW -> 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 1b8cfe1..0574d47 100644 --- a/app/src/test/java/com/medithings/vesiscan/models/PostureTiltTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/models/PostureTiltTest.kt @@ -53,6 +53,23 @@ class PostureTiltTest { assertNull(PostureTilt.degrees(emptyList())) } + @Test + fun `Supine 기준각이 있으면 Sitting 범위가 그만큼 밀린다`() { + // 2026-10-01: 배가 나와 Supine 에서 20° 면 Sitting 최적 구간은 55~75 다. + val r = PostureTilt.rangeFor(ClinicalPosture.SITTING, 20f)!! + assertEquals(55f, r.start, 1e-4f) + assertEquals(75f, r.endInclusive, 1e-4f) + assertEquals(PostureTilt.Status.BELOW, PostureTilt.status(50f, ClinicalPosture.SITTING, 20f)) + assertEquals(PostureTilt.Status.IN_RANGE, PostureTilt.status(55f, ClinicalPosture.SITTING, 20f)) + assertEquals(PostureTilt.Status.IN_RANGE, PostureTilt.status(75f, ClinicalPosture.SITTING, 20f)) + assertEquals(PostureTilt.Status.ABOVE, PostureTilt.status(76f, ClinicalPosture.SITTING, 20f)) + // 기준각이 없으면 절대 각도 그대로. + assertEquals(35f, PostureTilt.rangeFor(ClinicalPosture.SITTING, null)!!.start, 0f) + // 다른 자세는 기준각이 있어도 범위가 없다. + assertNull(PostureTilt.rangeFor(ClinicalPosture.SUPINE, 20f)) + assertNull(PostureTilt.rangeFor(ClinicalPosture.STANDING, 20f)) + } + @Test fun `다른 자세에는 범위가 없다`() { assertNull(PostureTilt.rangeFor(ClinicalPosture.SUPINE)) diff --git a/docs/LABDB_DATATYPES.md b/docs/LABDB_DATATYPES.md index 8073c09..fe0e3c2 100644 --- a/docs/LABDB_DATATYPES.md +++ b/docs/LABDB_DATATYPES.md @@ -179,6 +179,7 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다( | `reattach_frames` | int | 재부착 확인 측정 cycle 수 (2026-09-30~). 0 = 안 했음 | | `confirm_channels` | bool[4] | 정렬 확인 측정에서 벽이 잡힌 채널 CH0~3 (2026-09-30~). 재부착 확인의 기준 | | `posture` | string | 이 정렬의 자세 `Supine`/`Sitting`/`Standing` (2026-10-01~). 정렬은 자세마다 따로 합니다 | +| `confirm_tilt_deg_mean` | number | 확인 측정(N cm, 20 cycle)의 IMU 평균 기울기(도, 0~180). Supine 의 값이 Sitting 요구 범위의 기준각 m 입니다 — Sitting 범위 = `35+m ~ 55+m` (2026-10-01~) | | `reattach` | object | 재부착 확인 판정 `{ok, missing[], detached[], channels[4], ref_channels[4]\|null, bv_ml, at}` — 정렬 뒤 뗐다 붙인 자리에서 정렬 때 잡힌 채널이 빠졌는지 | | `cycles_total` | int | = `recordCount` | | `upload_reason` | string | `"nurse_review"` — 사람이 올린 세션 표시 |