From c01da3512efa24f856904bc42c4debb458612ce8 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Thu, 10 Sep 2026 12:21:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(clinical):=20=EC=A0=95=EB=A0=AC=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=EC=9D=84=20**=EC=9E=B0=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=20=EC=A0=84=EB=B6=80**=EB=A1=9C=20=E2=80=94=200~4cm=20?= =?UTF-8?q?=EB=8B=A4=20=EC=9E=AC=EA=B3=A0=20=ED=8C=90=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 알고리즘 설계자가 물었다 — "0cm·1cm 만 쟀는데 0cm 가 최적으로 나왔다. 3cm 가 최적이면?" 앞 커밋은 전수 측정만 하고 판정은 단일 pass 로 뒀는데, 원래 의도는 **다 재고 판단**이었다. ## 무엇이 바뀌고 무엇이 그대로인가 측정 종료 조건에서 중단 → 0~4cm 무조건 전부 후보 종료까지의 위치 → 잰 위치 전부 ← 바뀜 선택 규칙 자격→nch→cap→낮은 cm → 그대로 부착 best + 1cm 단순 덧셈 → 그대로 종료 조건 판정을 끝냄 → 진단용 (decided_at_cm) 선택 규칙 `AnchorSelection.selectBest` 는 Python `select_supine_anchor` 와 1:1 이고 200 케이스로 대조돼 있다 — **손대지 않았다.** 바뀐 것은 후보 집합뿐이고, Python 의 회고 분석 경로가 이미 전 위치로 판정하므로 같은 방식으로 재현·대조할 수 있다. 다만 **최종 보고서의 "단일 pass" 기술은 갱신이 필요하다.** ## 왜 단일 pass 는 지표가 단봉이라고 가정해 나빠지는 순간 멈춘다. 장내 가스·접촉 불량·호흡으로 **한 위치만** 일시적으로 나빠져도 멈추므로 더 위를 놓친다. 실측에 징후가 있었다 — 2026-09-09 `kai` 는 0cm ch3=X → 1cm ch3=O 91% 로 지표가 **올라갔다.** 같은 날 `181128` 은 1cm 에서 nch 2→0, ch3 100%→0% 로 best=0cm 으로 끝나 2cm 이후를 몰랐다. ## 화면 단계를 화면이 직접 몰고 간다 종전에는 `AnchorGuide` 의 STOP(`last.done`)이 확정 신호였다. 이제 guide 의 단일 pass 답과 앱의 부착 위치가 다를 수 있어 그걸 쓸 수 없다. `confirmTarget`/`confirmDone` 으로 바꿨다: 수집 중 → ↑ 1cm 올리고 측정 (판정보다 우선) 수집 끝 → ↓/↑ N cm 로 이동해 확인 측정 확인 측정 완료 → ↑ 1cm 올리세요 (초록) → 좌우 후보 없음 → ↓ 더 아래에 다시 붙이세요 우선순위를 테스트로 고정했다 — 확정이 수집·재부착보다 먼저, 수집이 판정보다 먼저. 뒤바뀌면 4cm 까지 못 가거나 끝난 단계를 되돌린다. ## 감사 가능하게 둘 다 기록 "selection_scope": "full_sweep" "best_cm": 3 앱이 쓴 값 = 전 위치 판정 "best_cm_single_pass": 0 옛 방식이 냈을 답 "single_pass_disagrees": true "decided_at_cm": 1 종료 조건이 걸린 위치 (이제 진단값) 갈리면 화면에도 참고 문구가 뜬다. 이 빈도가 곧 "단봉 가정이 얼마나 깨지는가"의 지표다. ## 같이 정리 AnchorBasis 에 FULL_SWEEP 을 만들려다 **만들지 않았다** — 전 위치 판정이 이제 앱의 기본 규칙이므로 ALGORITHM 이 그 뜻이다. 항목을 늘리면 정확도 검증 모집단을 가르는 기준이 흐려진다. 테스트 143개 통과(AlignInstructionTest 13개 전면 재작성). Co-Authored-By: Claude Opus 5 --- .../ui/views/clinical/AnchorAlignView.kt | 189 ++++++++++-------- .../ui/views/clinical/AlignInstructionTest.kt | 168 ++++++++++------ docs/CLINICAL_ALGORITHM.md | 112 +++++------ 3 files changed, 276 insertions(+), 193 deletions(-) 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 793d7b8..ce81d25 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 @@ -119,8 +119,16 @@ fun AnchorAlignView(appState: AppState) { * 그 경계를 남겨야 나중에 Python 으로 다시 판정할 때 같은 답이 나온다. */ var decidedAtCm by remember { mutableStateOf(null) } - /** 수집 단계를 사람이 중단했는가. 중단하면 바로 부착 위치 안내로 넘어간다. */ + /** 수집 단계를 사람이 중단했는가. 중단하면 바로 그 시점까지로 판정한다. */ var sweepStopped by remember { mutableStateOf(false) } + /** + * 부착 위치에서 **확인 측정까지 끝냈는가.** 이게 true 가 되면 좌우 단계로 넘어간다. + * + * 종전에는 `AnchorGuide` 의 STOP(`last.done`)이 이 역할을 했다. 이제 판정을 전 위치로 + * 하므로 guide 의 STOP 과 앱의 부착 위치가 다를 수 있어(guide 는 단일 pass 답을 들고 + * 있다) 화면이 직접 들고 있어야 한다. + */ + var confirmDone by remember { mutableStateOf(false) } /** * 방금 받은 6채널 원본. 아래쪽 파형이 이걸 그린다. * @@ -188,7 +196,27 @@ fun AnchorAlignView(appState: AppState) { } val saveName = appState.hospitalPatient.ifBlank { fallbackName } - val done = last?.done == true + // ── 판정: **잰 위치 전부**로 고른다 ──────────────────────────────────── + // 2026-09-10 결정. 종전에는 AnchorGuide 의 단일 pass 답(종료 시점까지의 후보)을 썼다. + // 단일 pass 는 지표가 cm 에 대해 단봉이라고 가정해 나빠지는 순간 멈추는데, 장내 + // 가스·접촉 불량·호흡으로 **한 위치만** 일시적으로 나빠져도 멈춘다 — 더 위가 진짜 + // 좋았는데 놓친다(알고리즘 팀 질의). 실측에도 징후가 있었다: 2026-09-09 `kai` 는 + // 0cm ch3=X → 1cm ch3=O 91% 로 지표가 **올라갔다.** + // + // **선택 규칙은 그대로다.** [AnchorSelection.selectBest] 는 Python + // `select_supine_anchor` 와 1:1 이고 200 케이스로 대조돼 있다(AnchorSelectionParityTest). + // 바뀌는 것은 **후보 집합**뿐이고, Python 의 회고 분석 경로가 이미 전 위치로 판정하므로 + // 같은 방식으로 재현·대조할 수 있다. + // + // 종료 조건(붕괴·ch3 소실·상한)은 **진단용**으로 남는다 — 지표가 어디서 무너졌는지는 + // 여전히 알아야 한다. `decided_at_cm` 으로 기록에 남는다. + val selectable = records.filter { it.capFrac.isFinite() } + val finalBest = AnchorSelection.selectBest(selectable).bestCm + /** 단일 pass 가 냈을 답. **비교·감사용**이고 판정에는 쓰지 않는다. */ + val singlePassBest = last?.bestCm + /** 잰 위치가 하나도 후보가 못 되면 재부착이다. */ + val needReattach = records.isNotEmpty() && finalBest == null + val anchorTarget = finalBest?.let { it + AnchorConfig.FINAL_OFFSET_CM } // ── 연결이 끊기면 기기에서 온 값을 비운다 ─────────────────────────────── // 병원 모드와 같은 이유다 — 끊긴 프로브의 BV·파형·접촉이 새 프로브의 것으로 읽힌다. @@ -346,22 +374,19 @@ fun AnchorAlignView(appState: AppState) { decidedAtCm = decidedAtCm), ) // ── 다음 위치 ────────────────────────────────────────────────── - // **수집 단계에서는 판정과 무관하게 1cm 씩 올린다.** 병원 임상은 위치를 - // 고르는 것만이 목적이 아니라 위치별 원신호를 모으는 것도 목적이다 - // (2026-09-10 결정). 조기 종료하면 2~3 위치만 남아 재분석할 거리가 없다. - // - // **판정은 이것 때문에 바뀌지 않는다.** AnchorGuide 는 `searching` 이 false - // 로 넘어가는 순간 bestCm 을 한 번만 계산하고, 그 뒤 step() 은 recs 에만 - // 쌓는다 — 후보 집합이 늘어도 best 를 다시 고르지 않는다. 그 성질이 깨지면 - // 전부 측정한 세션의 답이 레퍼런스와 달라지므로 `AnchorSweepTest` 가 고정한다. + // **0~SEARCH_MAX_CM 를 무조건 다 잰다.** guide 의 안내(MOVE_UP/MOVE_DOWN)는 + // 여기서 보지 않는다 — 판정을 전 위치로 하므로 중간 안내가 의미가 없다. + // 다 재고 나면 판정된 위치로 내려가 확인 측정 한 번, 그 뒤 +1cm 에서 좌우. val swept = guide.records.maxOfOrNull { it.cm } ?: cm if (!sweepStopped && swept < AnchorConfig.SEARCH_MAX_CM) { cm = swept + 1 } else { - when (step.action) { - AnchorAction.MOVE_UP -> step.bestCm?.let { cm = it } ?: run { cm += 1 } - AnchorAction.MOVE_DOWN -> step.bestCm?.let { cm = it } - else -> Unit + // 수집 끝 — 전 위치로 판정한 자리로 간다. + val target = AnchorSelection.selectBest( + guide.records.filter { it.capFrac.isFinite() }).bestCm + if (target != null) { + // 방금 잰 자리가 곧 판정된 자리면 그 측정이 확인 측정이다. + if (cm == target) confirmDone = true else cm = target } } } finally { @@ -579,24 +604,11 @@ fun AnchorAlignView(appState: AppState) { // 붙여야 하는지 매번 헷갈렸다(2026-09-09 현장 피드백). 실제로 필요한 것은 // **지금 할 동작 하나**다 — 1cm 올려서 재라, 2cm 내려서 재라, 1cm 올리고 // 좌우로 넘어가라. 절대 위치는 기록의 몫이고 사람이 셀 일이 아니다. - // ── 전수 판정과 비교 ──────────────────────────────────────────── - // 단일 pass 는 **지표가 cm 에 대해 단봉**이라고 가정한다(Python 도 같다). - // 올라가다 나빠지면 봉우리를 지난 것으로 보고 멈춘다. - // - // 그 가정이 깨지는 경우가 있다 — 장내 가스·접촉 불량·호흡으로 **한 위치만** - // 일시적으로 나빠지면 조기 종료하고, 더 위가 진짜 좋았는데 놓친다. - // 알고리즘 설계자가 제기한 우려이고(2026-09-10), 실측에도 징후가 있다: - // 2026-09-09 `kai` 는 0cm ch3=X → 1cm ch3=O 91% 로 **올라갔다**. - // - // **판정은 바꾸지 않는다.** 레퍼런스 대조가 단일 pass 기준이라, 규칙을 바꾸면 - // 검증을 다시 해야 한다. 대신 전 위치로 고르면 어디가 뽑히는지 **같이 계산해 - // 보여 주고 기록한다** — 몇십 세션 쌓이면 "실제로 갈리는가"에 답이 나온다. - // 규칙을 바꿀지는 그 숫자를 보고 알고리즘 팀이 정한다. - val fullSweepBest = remember(records) { - AnchorSelection.selectBest(records.filter { it.capFrac.isFinite() }).bestCm - } - val sweepDisagrees = last?.bestCm != null && fullSweepBest != null && - fullSweepBest != last?.bestCm + // ── 단일 pass 와 비교 (감사용) ────────────────────────────────── + // 앱이 쓰는 값은 전수 판정이다. 단일 pass 가 냈을 답을 같이 보여 주면 + // "단봉 가정이 얼마나 깨지는가"를 현장에서도 세어 볼 수 있다. + val singlePassDisagrees = finalBest != null && singlePassBest != null && + finalBest != singlePassBest // 수집 단계인가 — 판정이 났어도 SEARCH_MAX_CM 까지는 계속 올린다. val sweptTo = records.maxOfOrNull { it.cm } ?: -1 @@ -604,6 +616,9 @@ fun AnchorAlignView(appState: AppState) { val instr = instructionOf( last, cm, AnchorConfig.FINAL_OFFSET_CM, sweeping = sweeping, decidedAtCm = decidedAtCm, + confirmTarget = if (sweeping) null else finalBest, + confirmDone = confirmDone, + needReattach = needReattach && !sweeping, ) Box( Modifier.fillMaxWidth() @@ -657,12 +672,11 @@ fun AnchorAlignView(appState: AppState) { // 전수 판정이 다르면 사람에게 알린다. 앱은 단일 pass 답을 쓰지만, 그 // 자리에서 "더 위가 좋아 보인다"를 알면 다시 정렬을 택할 수 있다. - if (sweepDisagrees) { + if (singlePassDisagrees && !sweeping) { Text( - "참고 — 잰 위치 전부로 고르면 ${fullSweepBest}cm 이 뽑힙니다 " + - "(앱 판정은 ${last?.bestCm}cm). 앱은 검증된 단일 pass 규칙을 " + - "그대로 씁니다. 이 차이가 계속 보이면 알고리즘 팀에 알려 주세요 — " + - "기록에도 남습니다.", + "참고 — 중간에 지표가 나빠진 적이 있습니다. 거기서 멈추는 옛 방식이면 " + + "${singlePassBest}cm 이 뽑혔을 자리입니다. 지금은 잰 위치 전부로 " + + "골라 ${finalBest}cm 입니다. 기록에 둘 다 남습니다.", fontSize = 12.sp, color = MlPrimary, modifier = Modifier.fillMaxWidth() .background(MlPrimary.copy(alpha = 0.08f), RoundedCornerShape(8.dp)) @@ -692,7 +706,7 @@ fun AnchorAlignView(appState: AppState) { // (2026-09-10 피드백). 지시(카드) 바로 아래 누를 것을 둔다. // // 아래쪽은 **근거**다 — BV·접촉·파형은 "왜 이 값인가"를 볼 때만 내려다본다. - if (!done) { + if (!confirmDone) { Button( onClick = { measuring = true }, enabled = isConnected && !measuring, @@ -733,7 +747,7 @@ fun AnchorAlignView(appState: AppState) { // ── 2단계: 좌우 정렬 ─────────────────────────────────────── // 상하가 정해진 자리에서 프로브를 좌우로만 움직여 CH4·CH5 를 맞춘다. LateralSection( - anchorCm = last?.anchorCm, + anchorCm = anchorTarget, running = lrRunning, commit = lrCommit, accumulated = lrAccum, @@ -785,10 +799,15 @@ fun AnchorAlignView(appState: AppState) { // 대신 **근거를 같이 들고 간다**([AnchorBasis]). 사람이 고른 위치와 알고리즘이 // 확정한 위치가 기록에서 구분되지 않으면, 정확도 검증의 모집단을 나중에 // 가려낼 수 없어 데이터 전체가 주장을 받치지 못한다. - val basis = AnchorBasis.of(last?.action) + // 근거. 확인 측정까지 마쳤으면 앱 규칙이 정한 값이다. + val basis = when { + confirmDone -> AnchorBasis.ALGORITHM + needReattach -> AnchorBasis.REATTACH_OVERRIDE + else -> AnchorBasis.MANUAL + } // 확정됐으면 그 값(best+offset), 아니면 지금 화면의 위치. 둘 다 "프로브가 // 지금 있는 자리"라는 뜻이라 간호사가 보는 숫자와 기록이 어긋나지 않는다. - val proceedCm = last?.anchorCm ?: cm + val proceedCm = if (confirmDone) anchorTarget ?: cm else cm Button( onClick = { appState.anchorCm = proceedCm @@ -832,7 +851,7 @@ fun AnchorAlignView(appState: AppState) { Text(note, fontSize = 12.sp, color = MlSecondaryText) } - if (done) { + if (confirmDone || needReattach) { Spacer(Modifier.height(8.dp)) TextButton( onClick = { @@ -840,6 +859,7 @@ fun AnchorAlignView(appState: AppState) { lateral.reset(); lrCommit = null; lrAccum = 0 lrConfirmed = false; lrError = null; lrRunning = false disconnectedMidRun = false + decidedAtCm = null; sweepStopped = false; confirmDone = false }, enabled = !lrRunning, modifier = Modifier.fillMaxWidth(), @@ -1153,52 +1173,57 @@ internal fun instructionOf( step: AnchorStep?, nextCm: Int, offsetCm: Int, - /** 수집 단계인가 — 판정이 났어도 SEARCH_MAX_CM 까지 올린다. */ + /** 수집 단계인가 — 판정과 무관하게 SEARCH_MAX_CM 까지 올린다. */ sweeping: Boolean = false, - /** 판정이 내려진 위치. 수집 단계에서 "이미 답은 나왔다"를 알려 주는 데 쓴다. */ + /** 단일 pass 가 멈춘 위치. 수집 중 "옛 방식이면 여기서 끝났다"를 알려 주는 데 쓴다. */ decidedAtCm: Int? = null, + /** 수집이 끝나고 확인 측정할 위치(= 전 위치 판정 결과). null = 아직 수집 중이거나 후보 없음. */ + confirmTarget: Int? = null, + /** 확인 측정을 마쳤는가. true 면 "+offset 올리고 좌우로". */ + confirmDone: Boolean = false, + /** 후보가 하나도 없어 재부착이 필요한가. */ + needReattach: Boolean = false, ): AlignInstruction = when { step == null -> AlignInstruction( "●", "치골 바로 위에 붙이고 측정", "젤을 바르고, 프로브를 치골 바로 위에 밀착시킵니다.") - step.action == AnchorAction.REATTACH -> AlignInstruction( - "↓", "더 아래에 다시 붙이세요", - "치골 쪽으로 내려 다시 부착한 뒤 처음부터 측정합니다. " + - (step.stopReason ?: "CH3 가 잡히는 위치가 없었습니다.")) - - // 수집 단계에서는 판정이 뭐라 하든 계속 올린다. 단 **이미 답이 나왔다는 사실은 - // 말해 준다** — 안 그러면 "왜 계속 재라고 하지?"가 되고, 중단 버튼을 못 찾는다. - sweeping && step.searching -> AlignInstruction( - "↑", "1cm 올리고 측정", - "${AnchorConfig.SEARCH_MAX_CM}cm 까지 전부 잽니다.") - - sweeping -> AlignInstruction( - "↑", "1cm 올리고 측정", - decidedAtCm?.let { - "부착 위치는 이미 정해졌습니다(${it}cm 에서 판정). " + - "${AnchorConfig.SEARCH_MAX_CM}cm 까지는 데이터 수집용으로 잽니다 — " + - "여기서 멈춰도 됩니다." - } ?: "${AnchorConfig.SEARCH_MAX_CM}cm 까지 전부 잽니다.") - - step.action == AnchorAction.STOP -> AlignInstruction( + // 확인 측정까지 끝났다 — 올리고 좌우로. 이 자리는 재지 않는다. + confirmDone -> AlignInstruction( "↑", "${offsetCm}cm 올리세요", "여기서는 측정하지 않습니다. 올린 뒤 좌우 맞추기로 넘어갑니다.", done = true) - else -> { - // 지금 있는 자리는 방금 잰 위치다. 다음 자리는 호출부가 이미 정해 두었다. - val from = step.record?.cm ?: nextCm - val delta = nextCm - from + // 수집 중에는 판정이 뭐라 하든 계속 올린다. 판정은 다 재고 나서 한다. + sweeping -> AlignInstruction( + "↑", "1cm 올리고 측정", + decidedAtCm?.let { + "${AnchorConfig.SEARCH_MAX_CM}cm 까지 전부 잽니다. " + + "(옛 방식이면 ${it}cm 에서 멈췄을 자리입니다 — 지금은 다 재고 판정합니다.)" + } ?: "${AnchorConfig.SEARCH_MAX_CM}cm 까지 전부 잽니다.") + + // 다 쟀는데 후보가 하나도 없다. + needReattach -> AlignInstruction( + "↓", "더 아래에 다시 붙이세요", + "잰 위치 중 CH3 이 제대로 잡힌 곳이 없습니다. 치골 쪽으로 내려 다시 부착한 뒤 " + + "[처음부터 다시 정렬] 을 눌러 주세요.") + + // 판정된 자리로 이동 → 확인 측정. + confirmTarget != null -> { + val from = step.record?.cm ?: confirmTarget + val delta = confirmTarget - from when { - delta > 0 -> AlignInstruction("↑", "${delta}cm 올리고 측정", null) - delta < 0 -> AlignInstruction("↓", "${-delta}cm 내리고 측정", null) - // 이미 그 자리다 — 확인 측정 한 번이 남았다. 이 화면에서 제일 많이 나오는 - // 질문이 "왜 방금 잰 자리를 또 재냐"라, 이유를 같이 적는다. + delta > 0 -> AlignInstruction("↑", "${delta}cm 올리고 측정", + "잰 위치 전부로 고른 자리입니다. 여기서 확인 측정 한 번 합니다.") + delta < 0 -> AlignInstruction("↓", "${-delta}cm 내리고 측정", + "잰 위치 전부로 고른 자리입니다. 여기서 확인 측정 한 번 합니다.") else -> AlignInstruction("●", "여기서 한 번 더 측정", "옮겨 온 자리의 상태를 확인하는 마지막 측정입니다.") } } + + // 여기까지 오면 아직 한 위치도 못 쟀거나 상태가 어긋난 것이다. + else -> AlignInstruction("↑", "1cm 올리고 측정", null) } /** @@ -1254,12 +1279,18 @@ private fun buildAlignSummary( */ decidedAtCm: Int? = null, ): JSONObject = JSONObject().apply { - // 잰 위치 **전부**로 고르면 어디가 뽑히는가. 앱이 쓰는 값은 `best_cm`(단일 pass)이고 - // 이것은 **비교용**이다. 단일 pass 가 가정하는 단봉성이 실제로 성립하는지 세션마다 - // 세어 보려고 남긴다(2026-09-10 알고리즘 팀 질의). + // 앱이 쓴 값 = **잰 위치 전부**로 고른 것. 선택 규칙은 Python `select_supine_anchor` + // 그대로이고(AnchorSelectionParityTest 200케이스), 바뀐 것은 후보 집합뿐이다. val fullBest = AnchorSelection.selectBest(records.filter { it.capFrac.isFinite() }).bestCm - put("best_cm_full_sweep", fullBest ?: JSONObject.NULL) - put("sweep_disagrees", step.bestCm != null && fullBest != null && fullBest != step.bestCm) + + // ── 판정 범위 ────────────────────────────────────────────────────────── + // 2026-09-10 부터 앱은 **잰 위치 전부**로 판정한다. `best_cm`/`anchor_cm` 은 그 값이다. + // 단일 pass 가 냈을 답을 같이 남긴다 — 변경을 감사할 수 있어야 하고, 그 빈도가 + // "단봉 가정이 얼마나 깨지는가"의 지표다. + put("selection_scope", "full_sweep") + put("best_cm_single_pass", step.bestCm ?: JSONObject.NULL) + put("single_pass_disagrees", + step.bestCm != null && fullBest != null && fullBest != step.bestCm) put("patient", patient) put("save_name", saveName) // 이름 없이 잰 데이터인지 파일만 봐도 알 수 있어야 나중에 짝을 맞출 수 있다. @@ -1276,8 +1307,8 @@ private fun buildAlignSummary( put("samples", HospitalFixedParams.SAMPLES) put("ch3_hit_min", AnchorConfig.CH3_HIT_MIN) put("final_offset_cm", AnchorConfig.FINAL_OFFSET_CM) - put("best_cm", step.bestCm ?: JSONObject.NULL) - put("anchor_cm", step.anchorCm ?: JSONObject.NULL) + put("best_cm", fullBest ?: JSONObject.NULL) + put("anchor_cm", fullBest?.let { it + AnchorConfig.FINAL_OFFSET_CM } ?: JSONObject.NULL) put("action", step.action.name) // 판정 경계. positions[].in_decision 과 같은 사실을 세션 수준에서도 적는다. put("decided_at_cm", decidedAtCm ?: JSONObject.NULL) diff --git a/app/src/test/java/com/medithings/vesiscan/ui/views/clinical/AlignInstructionTest.kt b/app/src/test/java/com/medithings/vesiscan/ui/views/clinical/AlignInstructionTest.kt index 9433cbb..19a8319 100644 --- a/app/src/test/java/com/medithings/vesiscan/ui/views/clinical/AlignInstructionTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/ui/views/clinical/AlignInstructionTest.kt @@ -1,6 +1,7 @@ package com.medithings.vesiscan.ui.views.clinical import com.medithings.vesiscan.managers.AnchorAction +import com.medithings.vesiscan.managers.AnchorConfig import com.medithings.vesiscan.managers.AnchorPosRecord import com.medithings.vesiscan.managers.AnchorStep import org.junit.Assert.assertEquals @@ -10,14 +11,21 @@ import org.junit.Assert.assertTrue import org.junit.Test /** - * 정렬 화면 지시문 — **위치 이름이 화면에 나오면 안 된다.** + * 정렬 화면 지시문. * - * 시험하는 사람이 "최적 위치"와 "부착 위치"를 계속 헷갈렸다(2026-09-09 현장). 둘 다 - * "위치"라는 이름의 숫자이고, 화면이 단계마다 다른 쪽을 크게 보여 줬기 때문이다. - * 프로브를 잡은 사람에게 필요한 것은 지금 할 동작 하나다. + * ## 두 가지를 고정한다 + * **① 위치 이름이 화면에 나오면 안 된다.** 시험하는 사람이 "최적 위치"와 "부착 위치"를 + * 계속 헷갈렸다(2026-09-09 현장) — 둘 다 "위치"라는 이름의 숫자이고 화면이 단계마다 다른 + * 쪽을 크게 보여 줬기 때문이다. 여기서는 **헷갈림 자체가 고치려던 결함**이라 문구가 + * 요구사항이다. * - * 그래서 이 파일은 문구를 고정한다. 화면 문구 시험은 보통 과하지만, 여기서는 **헷갈림 - * 자체가 고치려던 결함**이라 문구가 요구사항이다. + * **② 단계 순서.** 2026-09-10 부터 흐름이 바뀌었다: + * + * 0~4cm 전부 측정 → 전 위치로 판정 → 그 자리로 이동해 확인 측정 → +1cm → 좌우 + * + * 종전에는 `AnchorGuide` 의 STOP 이 확정 신호였다. 이제 판정을 전 위치로 하므로 guide 의 + * STOP 과 앱의 부착 위치가 다를 수 있어, 화면이 `confirmTarget`/`confirmDone` 으로 + * 직접 단계를 몰고 간다. 그 계약을 여기서 고정한다. */ class AlignInstructionTest { @@ -26,28 +34,23 @@ class AlignInstructionTest { capFrac = 0.64, nchTrace = 2.0, nchSd = 0.0, ) - private fun step( - action: AnchorAction, - atCm: Int, - bestCm: Int? = null, - searching: Boolean = true, - stopReason: String? = null, - ) = AnchorStep( - action = action, message = "", record = rec(atCm), bestCm = bestCm, + private fun step(atCm: Int, searching: Boolean = true, bestCm: Int? = null) = AnchorStep( + action = if (searching) AnchorAction.MOVE_UP else AnchorAction.MOVE_DOWN, + message = "", record = rec(atCm), bestCm = bestCm, anchorCm = bestCm?.let { it + 1 }, maxNch = 2.0, searching = searching, - stopReason = stopReason, + stopReason = if (searching) null else "ch3 소실 0%<80%", ) /** 화면에 절대 나오면 안 되는 말. */ - private val banned = listOf("최적", "부착 위치", "anchor") - private fun assertNoPositionWords(i: AlignInstruction) { val all = listOf(i.headline, i.sub ?: "").joinToString(" ") - banned.forEach { w -> + listOf("최적", "부착 위치", "anchor").forEach { w -> assertFalse("\"$w\" 가 지시문에 있으면 안 된다 — $all", all.contains(w)) } } + // ── 첫 측정 ───────────────────────────────────────────────────────────── + @Test fun `첫 측정 전에는 붙일 자리를 말한다`() { val i = instructionOf(null, nextCm = 0, offsetCm = 1) assertEquals("●", i.arrow) @@ -56,83 +59,132 @@ class AlignInstructionTest { assertNoPositionWords(i) } - @Test fun `탐색 중에는 올리고 측정하라고만 한다`() { - // 0cm 을 재고 다음은 1cm. 절대 숫자가 아니라 **차이**를 말해야 한다. - val i = instructionOf(step(AnchorAction.MOVE_UP, atCm = 0), nextCm = 1, offsetCm = 1) + // ── 수집 단계 ─────────────────────────────────────────────────────────── + + @Test fun `수집 중에는 계속 올리라고만 한다`() { + val i = instructionOf(step(0), nextCm = 1, offsetCm = 1, sweeping = true) assertEquals("↑", i.arrow) assertEquals("1cm 올리고 측정", i.headline) + assertTrue("어디까지 재는지 말해야 멈추지 않는다", + i.sub!!.contains("${AnchorConfig.SEARCH_MAX_CM}cm")) assertFalse(i.done) assertNoPositionWords(i) } - @Test fun `지나쳤으면 내리고 측정하라고 한다`() { - // 3cm 에서 종료가 걸리고 best=1 이면 2cm 내려야 한다. - val i = instructionOf( - step(AnchorAction.MOVE_DOWN, atCm = 3, bestCm = 1, searching = false), - nextCm = 1, offsetCm = 1, - ) + @Test fun `수집 중 옛 방식이면 멈췄을 자리를 알려 준다`() { + // 판정이 전 위치로 바뀌었다는 사실을 현장에서도 알 수 있어야, "왜 계속 재냐"가 + // 안 나온다. + val i = instructionOf(step(2, searching = false, bestCm = 0), + nextCm = 3, offsetCm = 1, sweeping = true, decidedAtCm = 1) + assertEquals("1cm 올리고 측정", i.headline) + assertTrue(i.sub!!.contains("1cm")) + assertTrue(i.sub!!.contains("옛 방식")) + assertNoPositionWords(i) + } + + @Test fun `수집은 판정보다 우선한다`() { + // guide 가 MOVE_DOWN 을 내도 수집 중이면 계속 올린다. 이게 뒤바뀌면 4cm 까지 + // 못 가고 데이터가 비어 버린다. + val i = instructionOf(step(2, searching = false, bestCm = 0), + nextCm = 3, offsetCm = 1, sweeping = true, confirmTarget = 0) + assertEquals("↑", i.arrow) + assertEquals("1cm 올리고 측정", i.headline) + } + + // ── 확인 측정 단계 ────────────────────────────────────────────────────── + + @Test fun `판정된 자리로 내려가 확인 측정하라고 한다`() { + // 4cm 까지 다 재고, 전 위치 판정이 1cm 이면 3cm 내려야 한다. + val i = instructionOf(step(4, searching = false, bestCm = 0), + nextCm = 1, offsetCm = 1, confirmTarget = 1) assertEquals("↓", i.arrow) - assertEquals("2cm 내리고 측정", i.headline) + assertEquals("3cm 내리고 측정", i.headline) + assertTrue("왜 그 자리인지 말해야 한다", i.sub!!.contains("전부")) + assertNoPositionWords(i) + } + + @Test fun `판정된 자리가 위면 올리라고 한다`() { + val i = instructionOf(step(0, searching = false, bestCm = 0), + nextCm = 3, offsetCm = 1, confirmTarget = 3) + assertEquals("↑", i.arrow) + assertEquals("3cm 올리고 측정", i.headline) assertNoPositionWords(i) } @Test fun `같은 자리면 한 번 더 재라고 하고 이유를 댄다`() { // 이 화면에서 제일 많이 나오는 질문이 "왜 방금 잰 자리를 또 재냐"다. - val i = instructionOf( - step(AnchorAction.MOVE_DOWN, atCm = 2, bestCm = 2, searching = false), - nextCm = 2, offsetCm = 1, - ) + val i = instructionOf(step(2, searching = false, bestCm = 2), + nextCm = 2, offsetCm = 1, confirmTarget = 2) assertEquals("●", i.arrow) assertTrue(i.headline.contains("한 번 더")) assertNotNull("이유가 없으면 또 물어본다", i.sub) assertNoPositionWords(i) } - @Test fun `확정되면 올리고 재지 말라고 한다`() { + // ── 확정 ──────────────────────────────────────────────────────────────── + + @Test fun `확인 측정을 마치면 올리고 재지 말라고 한다`() { // 여기가 제일 중요하다. 올린 자리를 측정하면 설계상 "미달"이 떠서 되돌아간다. - val i = instructionOf( - step(AnchorAction.STOP, atCm = 2, bestCm = 2, searching = false), - nextCm = 2, offsetCm = 1, - ) + val i = instructionOf(step(2, searching = false, bestCm = 2), + nextCm = 2, offsetCm = 1, confirmTarget = 2, confirmDone = true) assertEquals("↑", i.arrow) assertEquals("1cm 올리세요", i.headline) - assertTrue("측정하지 말라는 말이 빠지면 안 된다", - i.sub!!.contains("측정하지 않습니다")) + assertTrue("측정하지 말라는 말이 빠지면 안 된다", i.sub!!.contains("측정하지 않습니다")) assertTrue("다음 단계를 말해야 멈추지 않는다", i.sub!!.contains("좌우")) assertTrue("확정은 초록이다", i.done) assertNoPositionWords(i) } + @Test fun `확정은 수집이나 재부착보다 우선한다`() { + // 상태가 겹쳐도 확정이 먼저다 — 이미 끝난 단계를 되돌리면 안 된다. + val i = instructionOf(step(2, searching = false, bestCm = 2), + nextCm = 2, offsetCm = 1, sweeping = true, confirmTarget = 2, + confirmDone = true, needReattach = true) + assertTrue(i.done) + assertEquals("1cm 올리세요", i.headline) + } + @Test fun `오프셋이 바뀌면 문구도 따라간다`() { // FINAL_OFFSET_CM 을 2 로 바꾸면 화면도 2cm 이라고 말해야 한다. 박아 두면 거짓이 된다. - val i = instructionOf( - step(AnchorAction.STOP, atCm = 1, bestCm = 1, searching = false), - nextCm = 1, offsetCm = 2, - ) + val i = instructionOf(step(1, searching = false, bestCm = 1), + nextCm = 1, offsetCm = 2, confirmTarget = 1, confirmDone = true) assertEquals("2cm 올리세요", i.headline) } - @Test fun `재부착은 아래로 내리라고 하고 사유를 붙인다`() { - val i = instructionOf( - step(AnchorAction.REATTACH, atCm = 3, searching = false, - stopReason = "탐색 상한 4cm"), - nextCm = 3, offsetCm = 1, - ) + // ── 재부착 ────────────────────────────────────────────────────────────── + + @Test fun `후보가 없으면 아래로 내려 다시 붙이라고 한다`() { + val i = instructionOf(step(4, searching = false, bestCm = null), + nextCm = 4, offsetCm = 1, needReattach = true) assertEquals("↓", i.arrow) assertTrue(i.headline.contains("아래")) - assertTrue("사유가 없으면 같은 실수를 반복한다", i.sub!!.contains("탐색 상한")) + assertTrue("무엇을 눌러야 하는지 말해야 한다", i.sub!!.contains("처음부터")) assertFalse(i.done) assertNoPositionWords(i) } - @Test fun `모든 동작에 지시문이 있다`() { - // action 이 늘어났는데 여기를 안 고치면 조용히 "올리고 측정"으로 떨어진다. - assertEquals(4, AnchorAction.entries.size) - AnchorAction.entries.forEach { a -> - val i = instructionOf(step(a, atCm = 1, bestCm = 1, searching = false), - nextCm = 2, offsetCm = 1) - assertTrue("$a 에 문구가 없다", i.headline.isNotBlank()) - assertNoPositionWords(i) + // ── 전 조합 ───────────────────────────────────────────────────────────── + + @Test fun `어떤 조합에서도 문구가 비지 않는다`() { + val targets = listOf(null, 0, 2, 4) + for (sw in listOf(true, false)) { + for (cd in listOf(true, false)) { + for (nr in listOf(true, false)) { + for (t in targets) { + val i = instructionOf( + step(2, searching = false, bestCm = 0), + nextCm = 3, offsetCm = 1, + sweeping = sw, decidedAtCm = 1, + confirmTarget = t, confirmDone = cd, needReattach = nr, + ) + assertTrue( + "sweeping=$sw confirmDone=$cd reattach=$nr target=$t 에 문구가 없다", + i.headline.isNotBlank(), + ) + assertNoPositionWords(i) + } + } + } } } } diff --git a/docs/CLINICAL_ALGORITHM.md b/docs/CLINICAL_ALGORITHM.md index 8657c83..d12383d 100644 --- a/docs/CLINICAL_ALGORITHM.md +++ b/docs/CLINICAL_ALGORITHM.md @@ -1,6 +1,6 @@ # 병원 임상 측정 — 알고리즘 상세 -> 대상: `demo-final` 브랜치, 2026-09-10 (`af16541`) 기준. +> 대상: `demo-final` 브랜치, 2026-09-10 (`2e5a1f1` 이후) 기준. > 이 문서는 **코드를 읽고 쓴 것**이고, 각 항목에 파일·라인을 달았습니다. 값이 의심되면 > 해당 파일을 보는 것이 이 문서보다 우선입니다. @@ -345,6 +345,9 @@ eligible = ch3Mean && ch3Rate >= 0.80 `AnchorSelection.selectBest()` (`AnchorGuide.kt:174`) +> **후보는 잰 위치 전부입니다** (2026-09-10 변경). 종전에는 "종료 조건이 걸릴 때까지"의 +> 위치만 후보였습니다. 자세히는 [3.6](#36-실제-진행-순서). + ``` ① 후보 자격 ch3 == "O" AND ch3_rate ≥ 0.8 → 통과가 0개면 → REATTACH (재부착) @@ -369,81 +372,78 @@ val anchorCm: Int? get() = bestCm?.let { it + offsetCm } // offsetCm = 1 **오프셋 위치는 측정하지 않습니다.** 설계상 그 자리는 지표가 나쁜 쪽이라, 재서 문턱으로 쓰면 "미달"이 떠서 도달 전에 멈춥니다. -### 3.6 실제 진행 순서 (2026-09-10 변경) - -병원 임상은 위치를 고르는 것만이 목적이 아니라 **위치별 원신호를 모으는 것**도 목적입니다. -그래서 종료 조건이 걸려도 **4cm 까지 전부 잽니다.** +### 3.6 실제 진행 순서 ``` 0cm 측정 → ↑ 1cm 올리고 측정 -1cm 측정 → 종료 조건 걸림 (판정 완료, best 확정) - ↑ 1cm 올리고 측정 - "부착 위치는 이미 정해졌습니다(1cm 에서 판정). - 4cm 까지는 데이터 수집용으로 잽니다 — 여기서 멈춰도 됩니다." -2·3·4cm 측정 (수집) -→ ↓ N cm 내리고 측정 (부착 위치로) -→ ● 여기서 한 번 더 측정 ← 확정 판정의 입력 -→ ↑ 1cm 올리세요 (초록 · 여기서는 측정하지 않음) -→ 좌우 정렬 +1cm 측정 → ↑ 1cm 올리고 측정 (판정이 나빠져도 멈추지 않습니다) +2·3·4cm 측정 + ↓ + 잰 위치 전부로 판정 → best = N + ↓ +↓/↑ N cm 로 이동 → ● 여기서 한 번 더 측정 (확인 측정 = 확정 신호) + ↓ +↑ 1cm 올리세요 (초록 · 여기서는 측정하지 않음) + ↓ +좌우 정렬 ``` -#### ⚠ 판정은 수집 때문에 바뀌지 않습니다 +[여기까지만 재고 판정하기] 로 수집을 중단할 수 있습니다 — 그 시점까지의 위치로 판정합니다. -`selectBest` 는 **넘긴 레코드 전부**를 후보로 봅니다. 후보가 늘면 답이 뒤집힐 수 있습니다: +#### 판정 범위가 바뀌었습니다 (2026-09-10) -``` -0cm eligible nch=2 · 1cm ch3 소실 · 2cm eligible nch=4 - 조기 종료 후보 {0,1} → best 0cm - 전부 넣으면 → best 2cm ← 다른 답 -``` +| | 종전 | 현재 | +|---|---|---| +| 측정 | 종료 조건 걸리면 중단 | **0~4cm 무조건 전부** | +| 후보 | 종료까지의 위치 | **잰 위치 전부** | +| 선택 규칙 | 3단 규칙 | **그대로** | +| 부착 | best + 1cm 단순 덧셈 | **그대로** | +| 종료 조건 | 판정을 끝냄 | **진단용** (`decided_at_cm`) | -안전한 이유는 `AnchorGuide` 가 `searching` 이 꺾이는 **그 순간 한 번만** `bestCm` 을 -계산하고, 이후 `step()` 은 `recs` 에만 쌓기 때문입니다. `AnchorSweepTest` 가 이 성질을 -고정합니다. +**왜 바꿨나.** 단일 pass 는 지표가 cm 에 대해 **단봉(unimodal)** 이라고 가정해, 나빠지는 +순간 봉우리를 지난 것으로 보고 멈춥니다. 그런데 장내 가스·접촉 불량·호흡으로 **한 위치만** +일시적으로 나빠져도 멈추므로, 더 위가 진짜 좋았는데 놓칠 수 있습니다(알고리즘 팀 질의). -#### 단일 pass 가 가정하는 것 — 그리고 그 가정을 측정하는 방법 +실측에 징후가 있었습니다. 2026-09-09 `kai` 는 **0cm `ch3=X` → 1cm `ch3=O 91%`** 로 지표가 +**올라갔습니다.** 시작 쪽은 종료 조건이 "앞에 eligible 이 있어야" 걸리게 막혀 있지만, +**중간에서는 한 칸만 나빠도 끝났습니다.** 같은 날 `181128` 이 정확히 그 경우로, 1cm 에서 +`nch 2→0`·`ch3 100%→0%` 가 되어 `best=0cm` 으로 끝났고 2cm 이후는 알 수 없었습니다. -단일 pass 는 **지표가 cm 에 대해 단봉(unimodal)** 이라고 가정합니다. 올라가다 나빠지면 -봉우리를 지난 것으로 보고 멈춥니다. Python 도 같습니다 — *"수직 → 좌우 순서로 한 번만 -가고 되돌아가지 않는다."* +**레퍼런스는 어떻게 되나.** 선택 규칙 `selectBest` 는 Python `select_supine_anchor` 와 +1:1 이고 200 케이스로 대조돼 있습니다(`AnchorSelectionParityTest`) — **손대지 않았습니다.** +바뀐 것은 후보 집합뿐이고, **Python 의 회고 분석 경로가 이미 전 위치로 판정**하므로 같은 +방식으로 재현·대조할 수 있습니다. 다만 최종 보고서의 "단일 pass" 기술은 갱신이 필요합니다. -물리적 근거는 있습니다. CH3 은 제일 기울어진 채널로 **방광 아래쪽**을 보므로, 위로 -올라가면 점점 방광을 벗어납니다. - -**깨질 수 있는 경우**는 *한 위치만* 일시적으로 나빠지는 것입니다 — 장내 가스가 CH3 을 -가림, 그 위치에서 접촉이 떴음, 호흡으로 방광이 움직임. 그러면 더 위가 진짜 좋았는데 -조기 종료합니다. - -실측에 징후가 있습니다. 2026-09-09 `kai` 세션은 **0cm `ch3=X` → 1cm `ch3=O 91%`** 로 -지표가 **올라갔습니다.** 시작 쪽은 종료 조건이 "앞에 eligible 이 있어야" 걸리게 막혀 -있지만, **중간에서는 한 칸만 나빠도 끝납니다.** - -전수 측정이 이 질문에 답을 냅니다. 세션마다 두 값을 같이 기록합니다: +**감사 가능하게 둘 다 기록합니다:** ```json -"best_cm": 0, // 앱이 쓰는 값 (단일 pass · 레퍼런스) -"best_cm_full_sweep": 3, // 잰 위치 전부로 고르면 -"sweep_disagrees": true // 갈렸다 +"selection_scope": "full_sweep", +"best_cm": 3, // 앱이 쓴 값 = 전 위치 판정 +"anchor_cm": 4, // best + 1 +"best_cm_single_pass": 0, // 옛 단일 pass 가 냈을 답 +"single_pass_disagrees": true, +"decided_at_cm": 1, // 종료 조건이 걸린 위치 (진단용) +"swept_to_cm": 4 ``` -갈리면 화면에도 참고 문구가 뜹니다. **판정은 바꾸지 않습니다** — 레퍼런스 대조가 단일 -pass 기준이라 규칙을 바꾸면 검증을 다시 해야 합니다. 몇십 세션의 `sweep_disagrees` 빈도를 -보고 규칙을 바꿀지는 알고리즘 팀이 정합니다. +갈리면 화면에도 참고 문구가 뜹니다: + +> 참고 — 중간에 지표가 나빠진 적이 있습니다. 거기서 멈추는 옛 방식이면 **0cm** 이 뽑혔을 +> 자리입니다. 지금은 잰 위치 전부로 골라 **3cm** 입니다. 기록에 둘 다 남습니다. + +`single_pass_disagrees` 빈도가 곧 **"단봉 가정이 얼마나 깨지는가"** 의 지표입니다. #### 확인 측정은 판정 데이터를 덮지 않습니다 -부착 위치로 내려가 한 번 더 재는 측정은 `align_2cm_confirm.csv` 로 따로 저장합니다. -같은 이름에 쓰면 **판정에 쓰인 데이터가 확인 측정으로 덮여**, 파일로 재판정하면 앱과 -답이 갈릴 수 있습니다. 전수 측정으로 바뀐 뒤 이 재측정이 **항상** 일어나므로 중요합니다. +판정된 자리로 내려가 다시 재는 측정은 `align_2cm_confirm.csv` 로 따로 저장합니다. 같은 +이름에 쓰면 **판정에 쓰인 데이터가 확인 측정으로 덮여**, 파일로 재판정하면 앱과 답이 +갈릴 수 있습니다. 전수 측정으로 바뀐 뒤 이 재측정이 **항상** 일어나므로 중요합니다. -**기록에 경계를 남깁니다** — 안 남기면 재분석하는 쪽이 전 위치를 후보로 넣어 다시 판정하고, -다른 best 가 나와 앱이 틀린 것으로 읽힙니다: +#### 기록에 후보 경계를 남깁니다 -```json -"decided_at_cm": 1, -"swept_to_cm": 4, -"positions": [{ "align_cm": 2, "in_decision": false, ... }] -``` +`decided_at_cm` 은 이제 **판정 경계가 아니라 진단값**입니다 — 종료 조건이 어디서 걸렸는지 +(= 지표가 어디서 무너졌는지)를 말합니다. `positions[].in_decision` 도 같은 기준이라, +**현재 판정은 `in_decision` 과 무관하게 전 위치를 씁니다.** ### 3.7 좌우 정렬 — 부착 높이에서