feat(align): CH3 가 0~4cm 어디에도 없으면 nch 최대 → cap_frac 최소 자리로 안내한다 — 재부착 대신 폴백

알고리즘 팀 확정(ej.won, 2026-10-13): 기본은 기존 규칙 그대로 두고, 0~4cm 전부 CH3 가
안 잡히는 경우에만 중앙 채널(nch)이 가장 많이 잡힌 위치들 중 cap_frac 최소를 고른다.
동률이면 낮은 cm. nch 가 전부 0 이면 고를 근거가 없어 지금처럼 재부착이다.

배경: 2026-10-12 P8 은 세 자세 7세션, 위치 측정 23회 전부 CH3 0/11 이라 세 번 다
"최적 위치 없음"으로 끝났고 간호사가 손으로 골라 "이대로 진행"했다. 폴백을 적용하면
supine 0cm(nch 3), sitting 1cm(nch 1) 이 나오고 standing(전부 nch 0)은 여전히 재부착이다.

- AnchorSelection.selectBest: 후보(CH3) 없음 → fallbackNoCh3. 새 가지 AnchorSelectRule.NO_CH3_MAX_NCH
  (wire `no_ch3_max_nch`) 로 요약 `select_rule` 에서 가른다. Python 레퍼런스에 아직 없는
  가지라 그 세션은 `selection_verified_against_reference=false`.
- 정렬 완료 문구에 "CH3 가 잡힌 위치가 없어 … 폴백" 한 줄. 재부착 문구는 "벽이 잡힌 위치가
  하나도 없습니다"로 — 이제 그 경우에만 온다.
- 테스트: AnchorSelectionNoCh3FallbackTest 7건(P8 10-12 수치 재현 포함). 대조 테스트는 Python
  정답이 재부착인 19케이스를 임시로 폴백 규칙으로 검사한다 — Python 에 폴백이 들어가 정답지를
  다시 뽑으면 그 분기를 지운다. CenterRuleTest 의 "후보 없음" 케이스를 새 규칙에 맞췄다.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 11:15:41 +09:00
parent 25317282d7
commit f0f8f8b9d8
5 changed files with 135 additions and 11 deletions
@@ -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<AnchorPosRecord>): 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<AnchorPosRecord>): 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 에 부착하세요."),
@@ -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 이 끝내 안 잡히는 환자도 있어 진행을 막지 않기 때문에 둘을 구분해야 한다.
@@ -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)
}
}
@@ -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)
}
}
@@ -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 에 아직 없다. 이 파일에는 그 가지를