From 64026b122d5856acef406197bbdf2fe5d3310192 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 13 Oct 2026 11:41:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(clinical):=20standing=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=20=EC=B8=A1=EC=A0=95=20=E2=80=94=20supine=20=EB=B6=80=EC=B0=A9?= =?UTF-8?q?=20=EC=9C=84=EC=B9=98=EB=A5=BC=20standing=20=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=EC=8D=A8=EB=8F=84=20=EB=90=98?= =?UTF-8?q?=EB=8A=94=EC=A7=80=20=EB=91=90=20=EC=A1=B0=EA=B1=B4=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=80=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 알고리즘 팀 스펙(ej.won, 2026-10-13 11:29). 임상 순서 supine → standing → sitting. supine 100% 뒤 자세를 Standing 으로 바꾸면 병원 화면 정렬 카드 위에 확인 카드가 뜬다. supine 부착 위치에서 정렬 조건(2.3MHz · c3 · 20 cycle)으로 한 번 재고 순서대로 본다: ① CH3 가 방광 영역을 잡는가 — 정렬 후보 자격과 같은 2단 게이트(mean-scan O + 검출률 ≥ 0.8) ② 지금 nch ≥ supine 정렬 nch − 1 둘 다 OK → standing 부착 위치를 supine 것으로 승계(정렬 건너뜀). 아니면 standing 정렬로 안내. 막지 않는다 — 안내만. sitting 은 해당 없음(ej.won: 각도 때문에 논외). - StandingConfirm.judge(rec, supineNch, channels, bv) — 순수 판정. nch·CH3·검출률은 정렬과 같은 AnchorGuide.measure 로 낸다(비교 대상이 정렬 nch 라 다른 식이면 안 된다). - 기준 nch: 정렬이 "임상 측정으로 넘어가기"에서 기록하는 그 자리에서 잰 nch 를 AnchorState.nch 로 같이 넘긴다(알고리즘·폴백·이대로 진행 모두 같다). 기준이 없으면 승계하지 않는다. - 승계하면 AnchorState(cm=supine, basis=supine 것, refChannels=지금 채널, carriedFrom=SUPINE). 실패하면 위치를 비워 정렬 카드가 "미정렬"로 보이고 판정만 남긴다. - 기록: standing 정렬 폴더 align_{cm}cm_standing_confirm.csv + IMU(시도마다 _rN), align_result.json `standing_confirm`(+승계면 anchor_cm·carried_from), 601 세션 phase=standing_confirm (standing_confirm_frames), run 매니페스트 anchor_nch · anchor_carried_from · anchor_standing_confirm. 정렬 요약이 통째로 덮어쓸 때 standing_confirm 은 보존한다("확인 안 돼서 정렬했다"는 맥락). - 테스트: StandingConfirmTest 6건. 전체 235건 통과. Co-Authored-By: Claude Fable 5.1 --- .../java/com/medithings/vesiscan/AppState.kt | 27 ++ .../vesiscan/managers/StandingConfirm.kt | 105 +++++++ .../vesiscan/services/HospitalRunStore.kt | 15 +- .../services/labdb/AlignLabdbPayload.kt | 12 +- .../ui/views/clinical/AnchorAlignView.kt | 5 + .../ui/views/clinical/HospitalModeView.kt | 27 +- .../ui/views/clinical/StandingConfirmCard.kt | 257 ++++++++++++++++++ .../vesiscan/managers/StandingConfirmTest.kt | 61 +++++ 8 files changed, 503 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/medithings/vesiscan/managers/StandingConfirm.kt create mode 100644 app/src/main/java/com/medithings/vesiscan/ui/views/clinical/StandingConfirmCard.kt create mode 100644 app/src/test/java/com/medithings/vesiscan/managers/StandingConfirmTest.kt diff --git a/app/src/main/java/com/medithings/vesiscan/AppState.kt b/app/src/main/java/com/medithings/vesiscan/AppState.kt index 0166a3b..db2d6a7 100644 --- a/app/src/main/java/com/medithings/vesiscan/AppState.kt +++ b/app/src/main/java/com/medithings/vesiscan/AppState.kt @@ -57,6 +57,12 @@ class AppState(private val context: Context) { val basis: com.medithings.vesiscan.managers.AnchorBasis? = null, val refChannels: List? = null, val reattach: com.medithings.vesiscan.managers.ReattachCheck.Result? = null, + /** 정렬이 기록에 남긴 그 자리에서 잰 중앙 채널 수(nch) — standing 확인 측정의 기준(2026-10-13). */ + val nch: Int? = null, + /** 이 자세의 위치가 다른 자세에서 승계된 것이면 그 자세(standing ← supine). null = 직접 정렬. */ + val carriedFrom: com.medithings.vesiscan.models.ClinicalPosture? = null, + /** standing 확인 측정 결과([com.medithings.vesiscan.managers.StandingConfirm]). null = 아직. */ + val standingConfirm: com.medithings.vesiscan.managers.StandingConfirm.Result? = null, ) /** 자세별 부착 위치. [clinicalPosture] 가 가리키는 항목을 아래 접근자가 읽고 쓴다. */ @@ -86,6 +92,27 @@ class AppState(private val context: Context) { get() = anchorNow().reattach set(v) = updateAnchor { it.copy(reattach = v) } + /** 지금 자세의 [AnchorState.nch]. */ + var anchorNch: Int? + get() = anchorNow().nch + set(v) = updateAnchor { it.copy(nch = v) } + + /** 지금 자세의 [AnchorState.carriedFrom]. */ + var anchorCarriedFrom: com.medithings.vesiscan.models.ClinicalPosture? + get() = anchorNow().carriedFrom + set(v) = updateAnchor { it.copy(carriedFrom = v) } + + /** 지금 자세의 [AnchorState.standingConfirm]. */ + var anchorStandingConfirm: com.medithings.vesiscan.managers.StandingConfirm.Result? + get() = anchorNow().standingConfirm + set(v) = updateAnchor { it.copy(standingConfirm = v) } + + /** 다른 자세의 부착 상태 — standing 확인 측정이 supine 것을 읽는다(2026-10-13). */ + fun anchorOf(p: com.medithings.vesiscan.models.ClinicalPosture): AnchorState = anchors[p] ?: AnchorState() + + /** 다른 자세의 부착 상태를 통째로 쓴다 — 승계(standing ← supine)에 쓴다. */ + fun setAnchor(p: com.medithings.vesiscan.models.ClinicalPosture, s: AnchorState) { anchors[p] = s } + /** 지금 자세의 부착 위치와 딸린 것을 비운다. */ fun clearAnchor() { anchors.remove(clinicalPosture) } diff --git a/app/src/main/java/com/medithings/vesiscan/managers/StandingConfirm.kt b/app/src/main/java/com/medithings/vesiscan/managers/StandingConfirm.kt new file mode 100644 index 0000000..6d0bd73 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/managers/StandingConfirm.kt @@ -0,0 +1,105 @@ +/* + * StandingConfirm — supine 정렬 위치를 standing 에서 그대로 써도 되는지 (2026-10-13, ej.won). + * + * 임상 순서는 supine → standing → sitting 이다. supine 100% 측정이 끝나면 환자를 세우고, supine + * 부착 위치에서 정렬 조건(2.3MHz · c3 · 20 cycle)으로 한 번 재서 두 가지를 **순서대로** 본다. + * + * ① CH3 가 방광 영역을 잡는가 — 정렬 후보 자격과 같은 2단 게이트 + * (mean-scan 에서 CH3 O **그리고** trace 검출률 ≥ [AnchorConfig.CH3_HIT_MIN]). + * ② 지금 nch ≥ supine 정렬 nch − [NCH_SLACK]. + * + * 둘 다 맞으면 OK — standing 은 supine 위치를 **승계**하고 정렬을 건너뛴다. 하나라도 아니면 + * standing 정렬을 진행하라고 안내한다(막지 않는다 — 안내만). sitting 은 해당 없다(앉은 자세는 + * 각도 때문에 CH3 가 잘 안 잡혀 논외 · ej.won). + * + * 기준이 되는 "supine 정렬 nch" 는 supine 정렬이 **기록에 남긴 그 자리**에서 잰 nch 다 + * ([com.medithings.vesiscan.AppState.AnchorState.nch]). 알고리즘이 고른 자리든, 폴백이든, + * 간호사가 "이대로 진행"한 자리든 같다 — 그 자리에서 실제로 잰 값이다. 기준이 없으면 + * (옛 앱으로 정렬 · 한 자리도 안 재고 진행) 승계를 허락하지 않는다 — 기준 없이 "같다"고 + * 말하지 않는다. + * + * 결과는 standing 정렬 폴더의 `align_{cm}cm_standing_confirm.csv` + IMU, `align_result.json` + * 의 `standing_confirm`, 정렬 세션(601)의 phase=standing_confirm, run 매니페스트의 + * `anchor_standing_confirm` / `anchor_carried_from` 에 남는다. + */ +package com.medithings.vesiscan.managers + +import org.json.JSONArray +import org.json.JSONObject + +object StandingConfirm { + + /** ② 의 여유. supine nch 가 3 이면 standing 에서 2 까지 OK. */ + const val NCH_SLACK = 1 + + data class Result( + val ok: Boolean, + /** ① CH3 2단 게이트 통과. */ + val ch3Ok: Boolean, + val ch3Rate: Double, + /** 지금 잰 중앙 채널 수(CH0~3, mean-scan). */ + val nch: Int, + /** 기준 — supine 정렬 자리에서 잰 nch. null = 기준 없음(승계 불가). */ + val supineNch: Int?, + /** ② nch ≥ supineNch − NCH_SLACK. 기준이 없으면 false. */ + val nchOk: Boolean, + /** 지금 잡힌 채널(CH0~3). 승계 시 재부착 확인의 기준이 된다. */ + val channels: List, + val bvMl: Double?, + /** 잰 자리 = supine 부착 위치. */ + val cm: Int, + val at: Long, + ) { + /** 화면과 기록이 같은 말을 쓴다. */ + val label: String get() = when { + ok -> "OK — supine 위치 ${cm}cm 그대로 측정 (CH3 ${pct(ch3Rate)} · 채널 $nch/${supineNch})" + supineNch == null -> "supine 정렬 기준(nch)이 없어 승계 불가 — standing 정렬 진행" + !ch3Ok -> "CH3 안 잡힘 (${pct(ch3Rate)}) — standing 정렬 진행" + else -> "채널 ${nch}개 < supine ${supineNch}−$NCH_SLACK — standing 정렬 진행" + } + + fun toJson(): JSONObject = JSONObject().apply { + put("ok", ok) + put("ch3_ok", ch3Ok) + put("ch3_rate", ch3Rate) + put("nch", nch) + put("supine_nch", supineNch ?: JSONObject.NULL) + put("nch_ok", nchOk) + put("nch_slack", NCH_SLACK) + put("channels", JSONArray(channels)) + put("bv_ml", bvMl ?: JSONObject.NULL) + put("cm", cm) + put("at", at) + } + } + + /** + * @param rec standing 자세에서 supine 부착 위치를 정렬 조건으로 잰 기록([AnchorGuide.measure]). + * @param supineNch supine 정렬이 그 자리에서 잰 nch. null 이면 승계하지 않는다. + * @param channels 지금 잡힌 채널(CH0~3 이상이면 앞 4개만 본다). + */ + fun judge( + rec: AnchorPosRecord, + supineNch: Int?, + channels: List, + bvMl: Double?, + at: Long = System.currentTimeMillis(), + ): Result { + val ch3Ok = rec.eligible + val nchOk = supineNch != null && rec.nch >= supineNch - NCH_SLACK + return Result( + ok = ch3Ok && nchOk, + ch3Ok = ch3Ok, + ch3Rate = rec.ch3Rate, + nch = rec.nch, + supineNch = supineNch, + nchOk = nchOk, + channels = channels.take(ReattachCheck.CENTER_CHANNELS), + bvMl = bvMl, + cm = rec.cm, + at = at, + ) + } + + private fun pct(rate: Double): String = "%.0f%%".format(rate * 100) +} 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 7867a5b..d871e5f 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt @@ -261,6 +261,12 @@ object HospitalRunStore { * 20 cycle 재고 정렬 확인 측정과 채널별로 대조한 것. cm 은 부착 위치. `align_3cm_reattach.csv` */ REATTACH("_reattach"), + /** + * standing 확인 측정(2026-10-13). supine 100% 뒤 standing 자세에서 supine 부착 위치를 + * 20 cycle 재고 ① CH3 ② nch ≥ supine−1 을 본 것. cm 은 supine 부착 위치. + * standing 정렬 폴더에 `align_2cm_standing_confirm.csv`. + */ + STANDING_CONFIRM("_standing_confirm"), } /** `align_result.json` 을 읽어 고쳐 쓴다. 없으면 빈 객체에서 시작한다(옛 앱으로 정렬한 폴더). */ @@ -397,7 +403,14 @@ object HospitalRunStore { /** 정렬 결과 요약(위치별 지표 + 선택). 판정 근거를 남겨 두어야 나중에 대조할 수 있다. */ fun writeAlignSummary(dir: File, json: JSONObject) { runCatching { - File(dir, "align_result.json").writeText(json.toString(2), Charsets.UTF_8) + val f = File(dir, "align_result.json") + // standing 확인 판정(2026-10-13)은 정렬 **앞**에 일어난다 — "확인이 안 돼서 정렬했다"는 + // 맥락이라 정렬 요약이 통째로 덮어쓸 때 같이 보존한다. 새 요약에 이미 있으면 그대로. + if (!json.has("standing_confirm") && f.exists()) { + runCatching { JSONObject(f.readText(Charsets.UTF_8)) }.getOrNull() + ?.opt("standing_confirm")?.let { json.put("standing_confirm", it) } + } + f.writeText(json.toString(2), Charsets.UTF_8) } } diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt index fce51ba..29d46de 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt @@ -87,21 +87,26 @@ object AlignLabdbPayload { * 확인 측정 파일이 하나도 안 잡혔다. */ private val NAME_RE = - Regex("""^align_(-?\d+)cm(_confirm|_lateral_stream|_lateral|_reattach)?(_r(\d+))?\.csv$""", RegexOption.IGNORE_CASE) + Regex("""^align_(-?\d+)cm(_confirm|_lateral_stream|_lateral|_reattach|_standing_confirm)?(_r(\d+))?\.csv$""", RegexOption.IGNORE_CASE) /** `_r3` → 3. 없으면 1(첫 측정). 같은 위치를 다시 잰 것(2026-10-01)은 뒤에 붙는다. */ private fun attemptOf(m: MatchResult): Int = m.groupValues[4].toIntOrNull() ?: 1 - /** 파일명 접미사 → phase. 순서는 실제 측정 순서(탐색 → 확인 → 좌우 → 좌우 흐름 → 재부착)다. */ + /** + * 파일명 접미사 → phase. 순서는 실제 측정 순서(탐색 → 확인 → 좌우 → 좌우 흐름 → 재부착)다. + * standing_confirm(2026-10-13)은 standing 정렬 폴더에서 정렬보다 **먼저** 일어나지만, 정렬 없이 + * 승계로 끝나는 세션이 대부분이라 맨 뒤에 둔다 — 받는 쪽은 phase 로 가른다. + */ private fun phaseOf(suffix: String): String = when (suffix.lowercase()) { "" -> "sweep" "_confirm" -> "confirm" "_lateral" -> "lateral" "_lateral_stream" -> "lateral_stream" + "_standing_confirm" -> "standing_confirm" else -> "reattach" } private fun phaseRank(suffix: String): Int = when (phaseOf(suffix)) { - "sweep" -> 0; "confirm" -> 1; "lateral" -> 2; "lateral_stream" -> 3; else -> 4 + "sweep" -> 0; "confirm" -> 1; "lateral" -> 2; "lateral_stream" -> 3; "reattach" -> 4; else -> 5 } private val isoOut: SimpleDateFormat @@ -235,6 +240,7 @@ object AlignLabdbPayload { put("lateral_frames", countPhase("lateral")) put("lateral_stream_frames", countPhase("lateral_stream")) put("reattach_frames", countPhase("reattach")) + put("standing_confirm_frames", countPhase("standing_confirm")) put("cycles_total", records.length()) // 판정 근거를 **통째로** 넣는다. 개발자가 답해야 할 질문이 "왜 이 위치인가"라, // 지표(nch·ch3·cap_frac)와 선택 결과가 파형과 같이 있어야 한다. 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 6b82771..7ef824e 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 @@ -1048,6 +1048,11 @@ fun AnchorAlignView(appState: AppState) { onClick = { appState.anchorCm = proceedCm appState.anchorBasis = basis + // standing 확인 측정의 기준(2026-10-13): 기록에 남는 그 자리에서 잰 nch. 직접 + // 정렬한 위치이므로 승계 표시와 standing 확인 결과는 비운다. + appState.anchorNch = guide.records.firstOrNull { it.cm == proceedCm }?.nch + appState.anchorCarriedFrom = null + appState.anchorStandingConfirm = null // 재부착 확인의 기준 — 확인 측정에서 잡힌 채널(CH0~3). 확인 측정 없이 // 진행하면 기준 없음(null)이다. 새 위치이므로 지난 재부착 결과는 비운다. // 기준은 좌우 확인 창(부착 위치와 같은 높이)이 우선, 없으면 확인 측정(N cm). 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 83e881f..2c32622 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 @@ -437,6 +437,10 @@ fun HospitalModeView(appState: AppState) { // 재부착 확인(2026-09-30). null = 안 했다. ok=false 인 채 시작한 세션은 그 // 채널 없이 잰 것이므로 분석에서 걸러야 한다. put("anchor_reattach", appState.anchorReattach?.toJson() ?: JSONObject.NULL) + // standing 확인(2026-10-13): 이 자리의 정렬 nch, 승계 출처(supine), 확인 판정. null = 해당 없음. + put("anchor_nch", appState.anchorNch ?: JSONObject.NULL) + put("anchor_carried_from", appState.anchorCarriedFrom?.label?.lowercase() ?: JSONObject.NULL) + put("anchor_standing_confirm", appState.anchorStandingConfirm?.toJson() ?: JSONObject.NULL) put("combinations", results) }, startedAt) // 정렬(601)은 정렬 화면을 떠나는 순간 한 번만 올리고, 조합 CSV 와 달리 뒤에 @@ -576,12 +580,27 @@ fun HospitalModeView(appState: AppState) { progressOf = { postureProgress(it) }) { it.label } Spacer(Modifier.height(12.dp)) + // 4-1. standing 확인 측정(2026-10-13, ej.won) — supine 100% 뒤 standing 으로 바꾸면 + // supine 부착 위치에서 한 번 재서 ① CH3 ② nch ≥ supine−1 을 본다. OK 면 standing 은 + // supine 위치를 승계하고 정렬을 건너뛴다. supine 부착 위치가 있을 때만 그린다. + if (posture == ClinicalPosture.STANDING && appState.anchorOf(ClinicalPosture.SUPINE).cm != null) { + StandingConfirmCard( + appState = appState, + bleManager = bleManager, + patient = patient, + connected = isConnected, + enabled = !running, + ) + Spacer(Modifier.height(8.dp)) + } + // 5. 부착 위치 정렬 — **지금 고른 자세의** 정렬. 연결 여부로 막지 않는다 // (2026-09-28). 정렬 화면에 [기기 연결] 이 있고 측정 버튼은 거기서 연결을 본다. AnchorCard( anchorCm = appState.anchorCm, basis = appState.anchorBasis, posture = posture, + carriedFrom = appState.anchorCarriedFrom, enabled = true, onAlign = { appState.currentScreen = AppScreen.ANCHOR_ALIGN }, ) @@ -1060,6 +1079,8 @@ private fun AnchorCard( posture: ClinicalPosture, enabled: Boolean, onAlign: () -> Unit, + /** 다른 자세에서 승계한 위치면 그 자세(standing ← supine, 2026-10-13). */ + carriedFrom: ClinicalPosture? = null, ) { val set = anchorCm != null // 알고리즘이 확정한 위치만 초록이다. 사람이 고른 위치를 같은 색으로 두면 간호사가 @@ -1077,8 +1098,10 @@ private fun AnchorCard( !set -> "${posture.label} 부착 위치 미정렬" // 정렬을 마치고 넘어온 직후다. 체크 표시가 없으면 옆의 [위치 다시 // 찾기] 버튼이 "다시 해야 한다"는 지시로 읽힌다(2026-09-10 피드백). - basis?.isAlgorithm == true -> "✓ ${posture.label} 부착 위치 ${anchorCm}cm 확정" - else -> "✓ ${posture.label} 부착 위치 ${anchorCm}cm · ${basis?.label ?: "근거 불명"}" + basis?.isAlgorithm == true -> "✓ ${posture.label} 부착 위치 ${anchorCm}cm 확정" + + (carriedFrom?.let { " · ${it.label} 승계" } ?: "") + else -> "✓ ${posture.label} 부착 위치 ${anchorCm}cm · ${basis?.label ?: "근거 불명"}" + + (carriedFrom?.let { " · ${it.label} 승계" } ?: "") }, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = tone, modifier = Modifier.weight(1f), diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/StandingConfirmCard.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/StandingConfirmCard.kt new file mode 100644 index 0000000..9fbeff9 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/StandingConfirmCard.kt @@ -0,0 +1,257 @@ +/* + * StandingConfirmCard — 병원 임상 측정 화면의 **standing 확인 측정** (2026-10-13, ej.won). + * + * supine → standing → sitting. supine 100% 가 끝나 자세 칩을 Standing 으로 바꾸면 이 카드가 + * 정렬 카드 위에 뜬다. supine 부착 위치에서 정렬 조건(2.3MHz · c3 · 20 cycle)으로 한 번 재고 + * [StandingConfirm.judge] 로 ① CH3 → ② nch ≥ supine nch − 1 을 본다. OK 면 standing 부착 + * 위치를 supine 것으로 **승계**하고(정렬 건너뜀), 아니면 standing 정렬로 안내한다. + * + * 측정 루프는 재부착 확인([measureForReattach])과 같다 — 조건이 같아야 nch·CH3 가 정렬과 + * 같은 식으로 나온다. nch·CH3 판정 자체는 정렬과 똑같이 [AnchorGuide.measure] 로 낸다. + * + * 저장: standing 정렬 폴더에 `align_{n}cm_standing_confirm.csv` + IMU, `align_result.json` + * 의 `standing_confirm`(+승계면 `anchor_cm`·`carried_from`), 정렬 세션(601)에 + * phase=standing_confirm 으로 올라간다. run 매니페스트에는 병원 화면이 + * `anchor_standing_confirm` / `anchor_carried_from` 으로 넣는다. + */ +package com.medithings.vesiscan.ui.views.clinical + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +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.shape.RoundedCornerShape +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.AppState +import com.medithings.vesiscan.ble.BleManager +import com.medithings.vesiscan.managers.AnchorConfig +import com.medithings.vesiscan.managers.AnchorGuide +import com.medithings.vesiscan.managers.ReattachCheck +import com.medithings.vesiscan.managers.StandingConfirm +import com.medithings.vesiscan.models.ClinicalPosture +import com.medithings.vesiscan.services.HospitalRunStore +import com.medithings.vesiscan.services.labdb.HospitalLabdbUploader +import com.medithings.vesiscan.ui.theme.MlCritical +import com.medithings.vesiscan.ui.theme.MlPrimary +import com.medithings.vesiscan.ui.theme.MlSecondaryText +import com.medithings.vesiscan.ui.theme.MlSuccess +import com.medithings.vesiscan.ui.theme.MlWarning +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * 자세가 Standing 이고 supine 부착 위치가 있을 때만 그린다. standing 부착 위치가 이미 승계돼 + * 있으면 결과를 보여 주고 [다시 확인] 만 남긴다. + */ +@Composable +fun StandingConfirmCard( + appState: AppState, + bleManager: BleManager, + patient: String, + connected: Boolean, + enabled: Boolean, +) { + val supine = appState.anchorOf(ClinicalPosture.SUPINE) + val supineCm = supine.cm ?: return + val supineNch = supine.nch + val standing = appState.anchorOf(ClinicalPosture.STANDING) + val result = standing.standingConfirm + var running by remember { mutableStateOf(false) } + var progress by remember { mutableIntStateOf(0) } + var error by remember { mutableStateOf(null) } + + LaunchedEffect(running) { + if (!running) return@LaunchedEffect + error = null + progress = 0 + // standing 이므로 supine 분기는 끈다 — 정렬 화면이 standing 자세로 재는 것과 같다. + val (m, err) = measureForReattach(bleManager, supine = false) { progress = it } + if (m == null) { + error = err + running = false + return@LaunchedEffect + } + // nch·CH3·검출률은 정렬과 같은 계산으로 — 정렬 nch 와 비교하는 값이라 다른 식이면 안 된다. + val rec = AnchorGuide().measure(m.cycles, supineCm) + val channels = m.outcome.walls.take(ReattachCheck.CENTER_CHANNELS).map { it != null } + val r = StandingConfirm.judge(rec, supineNch, channels, m.outcome.volumeMl) + if (r.ok) { + // 승계: 위치·근거는 supine 것, 재부착 기준은 지금 잡힌 채널, nch 는 지금 잰 값. + appState.setAnchor( + ClinicalPosture.STANDING, + AppState.AnchorState( + cm = supineCm, basis = supine.basis, refChannels = channels, reattach = null, + nch = rec.nch, carriedFrom = ClinicalPosture.SUPINE, standingConfirm = r, + ), + ) + } else { + // 승계 불가 — 위치를 비워 정렬 카드가 "미정렬"로 보이게 한다. 판정은 남긴다. + appState.setAnchor(ClinicalPosture.STANDING, AppState.AnchorState(standingConfirm = r)) + } + // standing 정렬 폴더에 남긴다 — 정렬 세션(601)에 phase=standing_confirm 으로 올라간다. + runCatching { + val dir = HospitalRunStore.alignDir(patient.ifBlank { "unnamed" }, Date(), ClinicalPosture.STANDING) + val attempt = (dir.listFiles()?.count { + it.name.startsWith("align_${supineCm}cm_standing_confirm") && it.name.endsWith(".csv") && + !it.name.endsWith("_imu.csv") + } ?: 0) + 1 + HospitalRunStore.writeAlignCycles(dir, supineCm, m.cycles, HospitalRunStore.AlignFileKind.STANDING_CONFIRM, attempt) + HospitalRunStore.writeAlignImu(dir, supineCm, m.cyclesImu, HospitalRunStore.AlignFileKind.STANDING_CONFIRM, attempt) + HospitalRunStore.updateAlignSummary(dir) { j -> + if (!j.has("started_at")) { + j.put("started_at", SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date())) + } + if (!j.has("patient")) j.put("patient", appState.hospitalPatient) + if (!j.has("save_name")) j.put("save_name", patient.ifBlank { "unnamed" }) + j.put("posture", ClinicalPosture.STANDING.label) + j.put("standing_confirm", r.toJson()) + if (r.ok) { + j.put("anchor_cm", supineCm) + j.put("anchor_basis", supine.basis?.wire ?: JSONObject.NULL) + j.put("carried_from", ClinicalPosture.SUPINE.label.lowercase()) + } + } + HospitalLabdbUploader.uploadAlignAsync(dir) + } + running = false + } + + val tone = when { + running -> MlPrimary + result == null -> MlWarning + result.ok -> MlSuccess + else -> MlCritical + } + val canMeasure = connected && enabled && !running && supineNch != null + Column( + Modifier.fillMaxWidth() + .background(tone.copy(alpha = 0.08f), RoundedCornerShape(12.dp)) + .border(1.dp, tone.copy(alpha = 0.5f), RoundedCornerShape(12.dp)) + .padding(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + when { + running -> "Standing 확인 측정 중" + result == null -> "Standing 확인 측정 필요" + result.ok -> "✓ Standing 확인 — ${result.label}" + else -> "⚠ Standing 확인 — ${result.label}" + }, + fontSize = 15.sp, fontWeight = FontWeight.Bold, color = tone, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { running = true }, enabled = canMeasure) { + Text( + if (result == null) "확인 측정" else "다시 확인", + color = if (canMeasure) MlPrimary else Color.Unspecified, + ) + } + } + when { + running -> { + Spacer(Modifier.height(6.dp)) + LinearProgressIndicator( + progress = { progress / AnchorConfig.ALIGN_CYCLES.toFloat() }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(4.dp)) + Text( + "supine 부착 위치 ${supineCm}cm 에서 측정 중 $progress/${AnchorConfig.ALIGN_CYCLES}", + fontSize = 12.sp, color = MlSecondaryText, + ) + } + result != null -> { + Spacer(Modifier.height(8.dp)) + ConditionRow(result) + Spacer(Modifier.height(6.dp)) + Text( + buildString { + result.bvMl?.let { append("BV %.0f mL · ".format(it)) } + append( + if (result.ok) "supine 위치를 승계했습니다. 재부착 확인 뒤 standing 측정을 시작하세요." + else "supine 위치를 쓸 수 없습니다. 아래 [정렬 시작]으로 standing 정렬을 진행하세요.", + ) + }, + fontSize = 12.sp, color = MlSecondaryText, + ) + } + supineNch == null -> { + Spacer(Modifier.height(4.dp)) + Text( + "supine 정렬이 이 자리에서 잰 채널 수(nch)가 없어 확인 측정을 할 수 없습니다 " + + "(옛 앱 정렬이거나 재지 않고 진행). standing 정렬을 진행하세요.", + fontSize = 12.sp, color = MlSecondaryText, + ) + } + else -> { + Spacer(Modifier.height(4.dp)) + Text( + "환자를 세운 뒤 supine 부착 위치 ${supineCm}cm 에서 ${AnchorConfig.ALIGN_CYCLES}회 재서 " + + "① CH3 가 잡히는지 ② 채널 수가 supine(${supineNch}) − ${StandingConfirm.NCH_SLACK} 이상인지 봅니다. " + + "둘 다 맞으면 정렬 없이 그 자리에서 standing 을 측정합니다.", + fontSize = 12.sp, color = MlSecondaryText, + ) + } + } + error?.let { + Spacer(Modifier.height(6.dp)) + Text(it, fontSize = 12.sp, color = MlCritical) + } + } +} + +/** ① CH3 · ② nch 두 조건을 나란히 — 통과는 초록, 실패는 빨강. */ +@Composable +private fun ConditionRow(r: StandingConfirm.Result) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + ConditionBox( + Modifier.weight(1f), r.ch3Ok, + "① CH3 " + (if (r.ch3Ok) "✓" else "✗"), + "검출률 %.0f%% (기준 %.0f%%)".format(r.ch3Rate * 100, AnchorConfig.CH3_HIT_MIN * 100), + ) + ConditionBox( + Modifier.weight(1f), r.nchOk, + "② 채널 ${r.nch}개 " + (if (r.nchOk) "✓" else "✗"), + r.supineNch?.let { "기준 supine $it − ${StandingConfirm.NCH_SLACK}" } ?: "supine 기준 없음", + ) + } +} + +@Composable +private fun ConditionBox(modifier: Modifier, ok: Boolean, title: String, sub: String) { + val c = if (ok) MlSuccess else MlCritical + Box( + modifier + .background(c.copy(alpha = 0.12f), RoundedCornerShape(8.dp)) + .padding(vertical = 6.dp, horizontal = 8.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = c) + Text(sub, fontSize = 10.sp, color = MlSecondaryText) + } + } +} diff --git a/app/src/test/java/com/medithings/vesiscan/managers/StandingConfirmTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/StandingConfirmTest.kt new file mode 100644 index 0000000..6cb9782 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/StandingConfirmTest.kt @@ -0,0 +1,61 @@ +package com.medithings.vesiscan.managers + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * standing 확인 측정 판정 (2026-10-13, ej.won): ① CH3 2단 게이트 → ② nch ≥ supine nch − 1. + */ +class StandingConfirmTest { + + private fun rec(nch: Int, ch3: Boolean, rate: Double, cm: Int = 2) = AnchorPosRecord( + cm = cm, nTrace = 11, nch = nch, ch3Mean = ch3, ch3Rate = rate, capFrac = 0.5, + ) + private val four = listOf(true, true, true, true) + + @Test + fun `both conditions pass - carry supine position`() { + val r = StandingConfirm.judge(rec(3, ch3 = true, rate = 0.91), supineNch = 4, channels = four, bvMl = 210.0) + assertTrue(r.ok); assertTrue(r.ch3Ok); assertTrue(r.nchOk) + assertEquals(2, r.cm) + assertTrue(r.label.startsWith("OK")) + } + + @Test + fun `nch may drop by exactly one but not two`() { + assertTrue(StandingConfirm.judge(rec(2, true, 1.0), 3, four, null).ok) + assertFalse(StandingConfirm.judge(rec(1, true, 1.0), 3, four, null).ok) + } + + @Test + fun `CH3 gate is checked first - mean-scan O but rate below 80 percent fails`() { + val r = StandingConfirm.judge(rec(4, ch3 = true, rate = 0.72), supineNch = 4, channels = four, bvMl = null) + assertFalse(r.ok); assertFalse(r.ch3Ok); assertTrue(r.nchOk) + assertTrue(r.label.contains("CH3")) + } + + @Test + fun `CH3 missing in mean-scan fails even with a high trace rate`() { + assertFalse(StandingConfirm.judge(rec(3, ch3 = false, rate = 0.85), 3, four, null).ch3Ok) + } + + @Test + fun `no supine reference means no carry-over`() { + val r = StandingConfirm.judge(rec(4, true, 1.0), supineNch = null, channels = four, bvMl = null) + assertFalse(r.ok); assertFalse(r.nchOk) + assertTrue(r.label.contains("기준")) + } + + @Test + fun `json carries the verdict and both inputs`() { + val j = StandingConfirm.judge(rec(2, true, 0.9), 3, listOf(true, false, true, true, true, true), 180.5).toJson() + assertEquals(true, j.getBoolean("ok")) + assertEquals(2, j.getInt("nch")) + assertEquals(3, j.getInt("supine_nch")) + assertEquals(1, j.getInt("nch_slack")) + assertEquals(4, j.getJSONArray("channels").length()) // CH0~3 만 + assertEquals(180.5, j.getDouble("bv_ml"), 1e-9) + } +}