From b71a04374f590459f15e5560b5e60e9cef36e50a Mon Sep 17 00:00:00 2001 From: jjangddu Date: Thu, 10 Sep 2026 12:05:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(clinical):=20=EC=A0=84=EC=88=98=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=EC=9D=84=20=EA=B0=99=EC=9D=B4=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=ED=95=B4=20=EA=B8=B0=EB=A1=9D=20=C2=B7=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EC=B8=A1=EC=A0=95=EC=9D=B4=20=ED=8C=90=EC=A0=95=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EB=8D=AE=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 알고리즘 설계자가 물었다 — "0cm·1cm 만 쟀는데 0cm 가 최적으로 나왔다. 만약 3cm 가 최적이면?" ## 이건 버그가 아니라 설계 가정이다 단일 pass 는 **지표가 cm 에 대해 단봉**이라고 가정한다. 올라가다 나빠지면 봉우리를 지난 것으로 보고 멈춘다. Python 도 같다. 물리적 근거는 있다 — CH3 은 제일 기울어진 채널로 방광 아래쪽을 보므로 위로 가면 점점 벗어난다. 깨질 수 있는 경우는 **한 위치만** 일시적으로 나빠지는 것이다: 장내 가스가 CH3 을 가림, 접촉이 뜸, 호흡으로 방광이 움직임. 그러면 더 위가 진짜 좋았는데 조기 종료한다. 실측에 징후가 있다. 2026-09-09 `kai` 는 **0cm ch3=X → 1cm ch3=O 91%** 로 지표가 올라갔다. 시작 쪽은 종료 조건이 "앞에 eligible 이 있어야" 걸리게 막혀 있지만 **중간에서는 한 칸만 나빠도 끝난다.** 같은 날 `181128` 이 정확히 그 경우다 — 1cm 에서 nch 2→0, ch3 100%→0% 로 두 조건이 동시에 걸려 best=0cm, 2cm 이후는 모른다. ## 판정은 바꾸지 않고 **측정 가능하게** 만든다 규칙을 바꾸면 레퍼런스 대조(단일 pass 기준)를 다시 해야 한다. 그건 알고리즘 팀이 숫자를 보고 정할 일이다. 그래서 전 위치로 고르면 어디가 뽑히는지 **같이 계산해 기록한다**: "best_cm": 0, 앱이 쓰는 값 (단일 pass · 레퍼런스) "best_cm_full_sweep": 3, 잰 위치 전부로 고르면 "sweep_disagrees": true 갈리면 화면에도 참고 문구가 뜬다 — 그 자리에서 "더 위가 좋아 보인다"를 알면 다시 정렬을 택할 수 있다. 어제 전수 측정으로 바꾼 것이 이 답을 내는 전제였다(조기 종료하면 비교할 위치가 없다). ## 확인 측정이 판정 데이터를 덮고 있었다 부착 위치로 내려가 한 번 더 재면 `align_2cm.csv` 를 **덮어썼다.** 화면 판정은 먼저 잰 것 기준인데 파일은 나중 것이라, 파일로 재판정하면 앱과 답이 갈릴 수 있다. 조기 종료 시절에는 드물었지만 전수 측정으로 바꾼 뒤에는 **항상** 일어난다. `align_2cm_confirm.csv` 로 분리했다. 판정 근거가 파일에 그대로 남는다. 테스트 139개 통과. Co-Authored-By: Claude Opus 5 --- .../vesiscan/services/HospitalRunStore.kt | 13 ++++- .../ui/views/clinical/AnchorAlignView.kt | 47 ++++++++++++++++++- docs/CLINICAL_ALGORITHM.md | 35 ++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) 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 f46d0e7..3c84798 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt @@ -174,8 +174,19 @@ object HospitalRunStore { */ fun writeAlignCycles( dir: File, cm: Int, cycles: List>, + /** + * 같은 위치를 다시 잰 것인가(부착 위치 확인 측정). + * + * true 면 `align_2cm_confirm.csv` 로 따로 쓴다. 같은 이름에 쓰면 **판정에 쓰인 + * 데이터가 확인 측정으로 덮인다** — 화면 판정은 먼저 잰 것 기준인데 파일은 + * 나중 것이라, 파일로 재판정하면 앱과 답이 갈릴 수 있다. 전수 측정으로 바뀐 뒤 + * 이 재측정이 **항상** 일어나므로 그냥 두면 안 된다(2026-09-10). + */ + confirm: Boolean = false, ): Boolean = try { - File(dir, "align_%dcm.csv".format(cm)).bufferedWriter().use { w -> + val name = if (confirm) "align_%dcm_confirm.csv".format(cm) + else "align_%dcm.csv".format(cm) + File(dir, name).bufferedWriter().use { w -> cycles.forEachIndexed { ci, chans -> chans.forEachIndexed { ch, buf -> w.write(buildString { 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 68577d5..793d7b8 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 @@ -65,6 +65,7 @@ import com.medithings.vesiscan.managers.AnchorBasis import com.medithings.vesiscan.managers.AnchorConfig import com.medithings.vesiscan.managers.AnchorGuide import com.medithings.vesiscan.managers.AnchorPosRecord +import com.medithings.vesiscan.managers.AnchorSelection import com.medithings.vesiscan.managers.AnchorStep import com.medithings.vesiscan.managers.ClinicalBv import com.medithings.vesiscan.managers.DetachWatcher @@ -309,7 +310,10 @@ fun AnchorAlignView(appState: AppState) { val dir = HospitalRunStore.alignDir(saveName, runStartedAt) hasAlignData = true alignDirty = true - if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles)) { + // 판정이 끝난 뒤 같은 위치를 다시 재는 것 = 부착 위치 확인 측정. + // 판정에 쓰인 파일을 덮지 않도록 별 이름으로 남긴다. + val isConfirm = !guide.searching && records.any { it.cm == cm } + if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles, confirm = isConfirm)) { error = "정렬 원시 데이터 저장 실패 (${cm}cm) — 저장 공간을 확인하세요." } @@ -575,6 +579,25 @@ 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 + // 수집 단계인가 — 판정이 났어도 SEARCH_MAX_CM 까지는 계속 올린다. val sweptTo = records.maxOfOrNull { it.cm } ?: -1 val sweeping = !sweepStopped && sweptTo < AnchorConfig.SEARCH_MAX_CM @@ -632,6 +655,22 @@ fun AnchorAlignView(appState: AppState) { Spacer(Modifier.height(12.dp)) } + // 전수 판정이 다르면 사람에게 알린다. 앱은 단일 pass 답을 쓰지만, 그 + // 자리에서 "더 위가 좋아 보인다"를 알면 다시 정렬을 택할 수 있다. + if (sweepDisagrees) { + Text( + "참고 — 잰 위치 전부로 고르면 ${fullSweepBest}cm 이 뽑힙니다 " + + "(앱 판정은 ${last?.bestCm}cm). 앱은 검증된 단일 pass 규칙을 " + + "그대로 씁니다. 이 차이가 계속 보이면 알고리즘 팀에 알려 주세요 — " + + "기록에도 남습니다.", + fontSize = 12.sp, color = MlPrimary, + modifier = Modifier.fillMaxWidth() + .background(MlPrimary.copy(alpha = 0.08f), RoundedCornerShape(8.dp)) + .padding(10.dp), + ) + Spacer(Modifier.height(10.dp)) + } + // 프로브를 바꿔 끼웠으면 이 세션을 이어 쓸 수 없다. hw_preset 이 한 번만 // 기록되므로 절반이 다른 기기의 데이터인데 한 기기로 라벨링된다. if (disconnectedMidRun && isConnected) { @@ -1215,6 +1254,12 @@ private fun buildAlignSummary( */ decidedAtCm: Int? = null, ): JSONObject = JSONObject().apply { + // 잰 위치 **전부**로 고르면 어디가 뽑히는가. 앱이 쓰는 값은 `best_cm`(단일 pass)이고 + // 이것은 **비교용**이다. 단일 pass 가 가정하는 단봉성이 실제로 성립하는지 세션마다 + // 세어 보려고 남긴다(2026-09-10 알고리즘 팀 질의). + 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) put("patient", patient) put("save_name", saveName) // 이름 없이 잰 데이터인지 파일만 봐도 알 수 있어야 나중에 짝을 맞출 수 있다. diff --git a/docs/CLINICAL_ALGORITHM.md b/docs/CLINICAL_ALGORITHM.md index ce28ba6..8657c83 100644 --- a/docs/CLINICAL_ALGORITHM.md +++ b/docs/CLINICAL_ALGORITHM.md @@ -401,6 +401,41 @@ val anchorCm: Int? get() = bestCm?.let { it + offsetCm } // offsetCm = 1 계산하고, 이후 `step()` 은 `recs` 에만 쌓기 때문입니다. `AnchorSweepTest` 가 이 성질을 고정합니다. +#### 단일 pass 가 가정하는 것 — 그리고 그 가정을 측정하는 방법 + +단일 pass 는 **지표가 cm 에 대해 단봉(unimodal)** 이라고 가정합니다. 올라가다 나빠지면 +봉우리를 지난 것으로 보고 멈춥니다. Python 도 같습니다 — *"수직 → 좌우 순서로 한 번만 +가고 되돌아가지 않는다."* + +물리적 근거는 있습니다. 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 // 갈렸다 +``` + +갈리면 화면에도 참고 문구가 뜹니다. **판정은 바꾸지 않습니다** — 레퍼런스 대조가 단일 +pass 기준이라 규칙을 바꾸면 검증을 다시 해야 합니다. 몇십 세션의 `sweep_disagrees` 빈도를 +보고 규칙을 바꿀지는 알고리즘 팀이 정합니다. + +#### 확인 측정은 판정 데이터를 덮지 않습니다 + +부착 위치로 내려가 한 번 더 재는 측정은 `align_2cm_confirm.csv` 로 따로 저장합니다. +같은 이름에 쓰면 **판정에 쓰인 데이터가 확인 측정으로 덮여**, 파일로 재판정하면 앱과 +답이 갈릴 수 있습니다. 전수 측정으로 바뀐 뒤 이 재측정이 **항상** 일어나므로 중요합니다. + **기록에 경계를 남깁니다** — 안 남기면 재분석하는 쪽이 전 위치를 후보로 넣어 다시 판정하고, 다른 best 가 나와 앱이 틀린 것으로 읽힙니다: