diff --git a/app/src/main/java/com/medithings/vesiscan/managers/AnchorGuide.kt b/app/src/main/java/com/medithings/vesiscan/managers/AnchorGuide.kt index 97835ae..518db5a 100644 --- a/app/src/main/java/com/medithings/vesiscan/managers/AnchorGuide.kt +++ b/app/src/main/java/com/medithings/vesiscan/managers/AnchorGuide.kt @@ -17,6 +17,7 @@ * | nch=4 다수 | — | **3곳 이상이면 가운데** (2026-10-07 · Python 에 아직 없음) | * | 오프셋 | 없음 | **best + 1cm** | * | 종료 | 없음(5위치 전수) | nch 붕괴 / ch3 소실 / 상한 | + * | CH3 전무 | 재부착 | **nch 최대 → cap_frac 최소** 폴백 (2026-10-13 · Python 에 아직 없음) | * * V3 는 호출처가 없어 그대로 둔다(대조용). 제품 경로는 이 파일이다. * @@ -171,7 +172,14 @@ enum class AnchorSelectRule { NCH4_CENTER, /** 종전 규칙: nch 최대 → cap_frac 최소 → 낮은 cm. Python `select_supine_anchor` 와 1:1. */ - MAX_NCH_MIN_CAP; + MAX_NCH_MIN_CAP, + + /** + * 0~4cm 어디에도 CH3 가 없을 때의 폴백: nch 최대 → cap_frac 최소 → 낮은 cm (2026-10-13, + * ej.won). 재부착 대신 "그나마 벽이 잡힌 자리"를 안내한다. nch 가 전부 0 이면 고를 근거가 + * 없으므로 여전히 [NONE](재부착). Python 레퍼런스에 **없는** 가지다. + */ + NO_CH3_MAX_NCH; /** 기록에 쓰는 값. 분석 스크립트가 문자열로 비교하므로 고정이다. */ val wire: String get() = name.lowercase() @@ -216,11 +224,18 @@ object AnchorSelection { * 이 가지로 고른 세션은 요약의 `select_rule: nch4_center` 로 가른다. * * `cap_frac` 이 유한하지 않으면 +∞ 로 취급해 맨 뒤로 보낸다(Python `_tie_val`). + * + * ⓪ CH3 전무 폴백 (2026-10-13, ej.won 확정): 후보(①)가 하나도 없으면 재부착 대신 + * **nch 최대 → cap_frac 최소 → 낮은 cm** 로 고른다. 기본 규칙은 그대로고, CH3 가 한 자리라도 + * 있으면 이 가지는 타지 않는다. 2026-10-12 P8 은 세 자세 23회 측정 전부 CH3 0/11 이라 이 + * 가지가 없으면 세 번 다 재부착으로 끝났다(supine 은 0cm nch 3 이 있었는데도). + * nch 가 전부 0 이면 고를 근거가 없어 여전히 재부착이다. Python 에 아직 없는 가지라 + * `select_rule: no_ch3_max_nch` 로 가르고, 그 세션은 레퍼런스 대조 미완으로 적는다. */ fun selectBest(records: List): AnchorDecision { if (records.isEmpty()) return AnchorDecision(null, 0, null) val cand = eligibleCh3(records) - if (cand.isEmpty()) return AnchorDecision(null, 0, null) + if (cand.isEmpty()) return fallbackNoCh3(records) val full = cand.filter { it.nch == AnchorConfig.NCH_FULL }.sortedBy { it.cm } if (full.size >= AnchorConfig.NCH4_CENTER_MIN) { val mid = full[(full.size - 1) / 2] // 짝수면 가운데 둘 중 아래쪽(작은 cm) @@ -236,6 +251,20 @@ object AnchorSelection { ) return AnchorDecision(best?.cm, cand.size, best, AnchorSelectRule.MAX_NCH_MIN_CAP) } + + /** ⓪ CH3 전무 폴백. 호출부가 "CH3 후보 없음"을 이미 확인했다. */ + private fun fallbackNoCh3(records: List): AnchorDecision { + val maxNch = records.maxOf { it.nch } + if (maxNch <= 0) return AnchorDecision(null, 0, null) + val top = records.filter { it.nch == maxNch } + val best = top.minWithOrNull( + compareBy( + { if (it.capFrac.isFinite()) it.capFrac else Double.POSITIVE_INFINITY }, + { it.cm }, + ) + ) + return AnchorDecision(best?.cm, top.size, best, AnchorSelectRule.NO_CH3_MAX_NCH) + } } /** [AnchorGuide.step] 반환값. */ @@ -379,7 +408,7 @@ class AnchorGuide( if (bestCm == null) { return out( AnchorAction.REATTACH, - "CH3 가 잡히는 위치가 없습니다. 더 치골 쪽으로 다시 부착하세요.\n($reason)", + "벽이 잡힌 위치가 하나도 없습니다 (CH3 도, 다른 중앙 채널도). 더 치골 쪽으로 다시 부착하세요.\n($reason)", rec, ) } @@ -402,7 +431,7 @@ class AnchorGuide( val best = bestCm ?: return out( AnchorAction.REATTACH, - "CH3 가 잡히는 위치가 없습니다. 더 치골 쪽으로 다시 부착하세요.", null) + "벽이 잡힌 위치가 하나도 없습니다 (CH3 도, 다른 중앙 채널도). 더 치골 쪽으로 다시 부착하세요.", null) val last = recs.keys.maxOrNull() ?: best return guideToBest(recs[last]) } @@ -457,6 +486,8 @@ class AnchorGuide( (if (bestRule == AnchorSelectRule.NCH4_CENTER) { val n = selectableRecords().count { it.eligible && it.nch == AnchorConfig.NCH_FULL } "\n채널 4/4 위치가 ${n}곳 — 가운데를 골랐습니다." + } else if (bestRule == AnchorSelectRule.NO_CH3_MAX_NCH) { + "\nCH3 가 잡힌 위치가 없어 중앙 채널이 가장 많이 잡힌 자리로 골랐습니다 (폴백)." } else "") + (if (offsetCm == 0) "\n이 자리(${anchorCm}cm)에 그대로 부착합니다." else "\n여기서 ${offsetCm}cm 더 올려 ${anchorCm}cm 에 부착하세요."), 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 44e31e4..6b82771 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 @@ -260,7 +260,8 @@ fun AnchorAlignView(appState: AppState) { // 바뀌는 것은 **후보 집합**뿐이고, Python 의 회고 분석 경로가 이미 전 위치로 판정하므로 // 같은 방식으로 재현·대조할 수 있다. // (2026-10-07: nch=4 후보 3곳 이상이면 가운데 — Python 에 아직 없는 가지다. 요약의 - // `select_rule` 로 가르고, 그 세션은 `selection_verified_against_reference=false` 다.) + // `select_rule` 로 가르고, 그 세션은 `selection_verified_against_reference=false` 다. + // 2026-10-13: CH3 전무 → nch 최대 폴백(`no_ch3_max_nch`)도 같은 취급이다.) // // 종료 조건(붕괴·ch3 소실·상한)은 **진단용**으로 남는다 — 지표가 어디서 무너졌는지는 // 여전히 알아야 한다. `decided_at_cm` 으로 기록에 남는다. @@ -1779,8 +1780,12 @@ private fun buildAlignSummary( // 앱 검출이 Python 레퍼런스와 불일치인 상태로 낸 값이라는 사실을 데이터와 함께 남긴다. // 2026-09-03 대조 완료: 검출·BV·선택 규칙이 Python 레퍼런스와 일치. // 2026-10-07: nch4_center 가지는 Python 레퍼런스에 아직 없다 — 그 가지로 고른 세션은 대조 미완이다. + // 2026-10-13: no_ch3_max_nch(CH3 전무 폴백)도 Python 에 아직 없다 — 같은 취급. put("select_rule", fullDecision.rule.wire) - put("selection_verified_against_reference", fullDecision.rule != AnchorSelectRule.NCH4_CENTER) + put( + "selection_verified_against_reference", + fullDecision.rule != AnchorSelectRule.NCH4_CENTER && fullDecision.rule != AnchorSelectRule.NO_CH3_MAX_NCH, + ) // 좌우 정렬 (SPEC §8 "좌우(LR) 정렬" · LateralGuide). // done=false 는 "안 맞췄다"가 아니라 "확인 단계를 거치지 않았다"는 뜻이다. // lateral 이 끝내 안 잡히는 환자도 있어 진행을 막지 않기 때문에 둘을 구분해야 한다. diff --git a/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionCenterRuleTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionCenterRuleTest.kt index 2940023..89b6d4b 100644 --- a/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionCenterRuleTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionCenterRuleTest.kt @@ -60,9 +60,14 @@ class AnchorSelectionCenterRuleTest { } @Test - fun `no candidate stays a reattach`() { - val d = AnchorSelection.selectBest(listOf(rec(0, nch = 2, rate = 0.0).copy(ch3Mean = false))) + fun `no candidate and nothing detected stays a reattach`() { + // 2026-10-13 부터 CH3 가 없어도 nch ≥ 1 이면 ⓪ 폴백이 자리를 고른다(AnchorSelectionNoCh3FallbackTest). + // 재부착으로 남는 것은 벽이 하나도 안 잡힌 경우뿐이다. + val d = AnchorSelection.selectBest(listOf(rec(0, nch = 0, rate = 0.0).copy(ch3Mean = false))) assertEquals(null, d.bestCm) assertEquals(AnchorSelectRule.NONE, d.rule) + val f = AnchorSelection.selectBest(listOf(rec(0, nch = 2, rate = 0.0).copy(ch3Mean = false))) + assertEquals(0, f.bestCm) + assertEquals(AnchorSelectRule.NO_CH3_MAX_NCH, f.rule) } } diff --git a/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionNoCh3FallbackTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionNoCh3FallbackTest.kt new file mode 100644 index 0000000..cba78bc --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionNoCh3FallbackTest.kt @@ -0,0 +1,71 @@ +package com.medithings.vesiscan.managers + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * ⓪ CH3 전무 폴백 (2026-10-13, ej.won 확정). + * + * 기본은 기존 알고리즘 그대로. 0~4cm 어디에도 CH3 가 없을 때만 **nch 최대인 위치들 중 cap_frac + * 최소**(동률이면 낮은 cm)를 고른다. nch 가 전부 0 이면 고를 근거가 없어 재부착 그대로다. + * + * 아래 수치는 2026-10-12 P8 세션(labdb 601)에서 가져왔다 — 그날 세 자세 모두 CH3 0/11 이었다. + */ +class AnchorSelectionNoCh3FallbackTest { + + private fun rec(cm: Int, nch: Int, cap: Double = 1.0, ch3: Boolean = false, rate: Double = 0.0) = AnchorPosRecord( + cm = cm, nTrace = 11, nch = nch, ch3Mean = ch3, ch3Rate = rate, capFrac = cap, + ) + + @Test + fun `P8 supine after reattach - no CH3 but nch 3 at 0cm - picks 0cm`() { + // 11:27 세션: 0cm nch 3 cap 0.459 (ch3 X), 1~4cm nch 1·0·0·0. + val d = AnchorSelection.selectBest(listOf(rec(0, 3, 0.459), rec(1, 1), rec(2, 0), rec(3, 0), rec(4, 0))) + assertEquals(0, d.bestCm) + assertEquals(AnchorSelectRule.NO_CH3_MAX_NCH, d.rule) + } + + @Test + fun `P8 sitting - single channel at 1cm - picks 1cm`() { + val d = AnchorSelection.selectBest(listOf(rec(0, 0), rec(1, 1), rec(2, 0), rec(3, 0), rec(4, 0))) + assertEquals(1, d.bestCm) + assertEquals(AnchorSelectRule.NO_CH3_MAX_NCH, d.rule) + } + + @Test + fun `P8 standing - nothing detected anywhere - still reattach`() { + val d = AnchorSelection.selectBest((0..3).map { rec(it, 0) }) + assertNull(d.bestCm) + assertEquals(AnchorSelectRule.NONE, d.rule) + } + + @Test + fun `tie on nch breaks by smaller cap then lower cm`() { + val d = AnchorSelection.selectBest(listOf(rec(0, 2, 0.70), rec(1, 2, 0.45), rec(2, 2, 0.45), rec(3, 1, 0.10))) + assertEquals(1, d.bestCm) // nch 2 중 cap 0.45 동률 → 낮은 cm + assertEquals(3, d.nCandidate) // nch 최대 집단 크기 + } + + @Test + fun `non-finite cap sorts last inside the fallback`() { + val d = AnchorSelection.selectBest(listOf(rec(0, 2, Double.NaN), rec(1, 2, 0.90))) + assertEquals(1, d.bestCm) + } + + @Test + fun `one CH3 position anywhere keeps the default rule even if a no-CH3 position has more channels`() { + // 0cm 은 CH3 없이 3채널, 2cm 은 CH3 포함 2채널(검출률 통과) — 기본 규칙이 2cm 을 고른다. + val d = AnchorSelection.selectBest(listOf(rec(0, 3, 0.30), rec(1, 0), rec(2, 2, 0.60, ch3 = true, rate = 0.91))) + assertEquals(2, d.bestCm) + assertEquals(AnchorSelectRule.MAX_NCH_MIN_CAP, d.rule) + } + + @Test + fun `CH3 seen but below the rate gate still counts as a CH3 candidate - not the fallback`() { + // eligibleCh3 의 완화 규칙(임계 통과 0개 → ch3='O' 전체)이 폴백보다 먼저다. + val d = AnchorSelection.selectBest(listOf(rec(0, 3, 0.30), rec(1, 1, 0.50, ch3 = true, rate = 0.45))) + assertEquals(1, d.bestCm) + assertEquals(AnchorSelectRule.MAX_NCH_MIN_CAP, d.rule) + } +} diff --git a/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionParityTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionParityTest.kt index 9a89eb9..0c1b2e4 100644 --- a/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionParityTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionParityTest.kt @@ -53,9 +53,21 @@ class AnchorSelectionParityTest { var nullBest = 0 lines.forEachIndexed { i, line -> val (want, recs) = parse(line) - val got = AnchorSelection.selectBest(recs).bestCm - assertEquals("case #$i best (records=$line)", want, got) - if (want == null) nullBest++ + val d = AnchorSelection.selectBest(recs) + if (want == null) { + // 2026-10-13 임시: Python 정답은 "재부착"이지만 앱은 ⓪ CH3 전무 폴백(nch 최대 → + // cap 최소 → 낮은 cm)을 탄다. Python 에 같은 폴백이 들어가 이 파일을 다시 뽑으면 + // 이 분기를 지우고 위 assertEquals 하나로 돌아간다. + val maxNch = recs.maxOf { it.nch } + val expect = if (maxNch <= 0) null else recs.filter { it.nch == maxNch } + .minWith(compareBy({ if (it.capFrac.isFinite()) it.capFrac else Double.POSITIVE_INFINITY }, { it.cm })) + .cm + assertEquals("case #$i no-CH3 fallback (records=$line)", expect, d.bestCm) + assertEquals("case #$i rule", if (expect == null) AnchorSelectRule.NONE else AnchorSelectRule.NO_CH3_MAX_NCH, d.rule) + nullBest++ + } else { + assertEquals("case #$i best (records=$line)", want, d.bestCm) + } } assertEquals("케이스 수", 200, lines.size) // 2026-10-07 ①′(nch=4 후보 3곳 이상 → 가운데)는 Python 에 아직 없다. 이 파일에는 그 가지를