diff --git a/app/src/main/java/com/medithings/vesiscan/managers/AlignmentAdvisorV2.kt b/app/src/main/java/com/medithings/vesiscan/managers/AlignmentAdvisorV2.kt index 57a09f0..87fddd3 100644 --- a/app/src/main/java/com/medithings/vesiscan/managers/AlignmentAdvisorV2.kt +++ b/app/src/main/java/com/medithings/vesiscan/managers/AlignmentAdvisorV2.kt @@ -96,13 +96,24 @@ object AlignmentConstants { const val VERTICAL_SOFT_HINT_AFTER = 12 } -/** stateless 단일 스냅샷 advisor — alignment.py:alignment_advice(). */ +/** + * stateless 단일 스냅샷 advisor — alignment.py:alignment_advice(). + * + * [requireCh3] (2026-10-13): 상하 단계가 **CH3 전무 폴백**(`no_ch3_max_nch`)으로 자리를 골랐으면 + * 좌우 단계에서 CH3 를 요구하면 안 된다 — 그 자리는 CH3 가 없는 걸 알고 고른 자리라 + * "↑ 머리방향(ch3 미검출)"이 영영 끝나지 않는다(2026-10-13 임상, supine 에서 실제로 그랬다). + * 그때는 CH4·CH5 균형만 본다. 기본값 true 는 Python 과 1:1 인 종전 동작이다. + */ object AlignmentAdvice { - fun advise(ch3: Boolean, u4: Int?, u5: Int?, latTol: Int = AlignmentConstants.LAT_TOL): Pair { - if (!ch3) return Pair(AlignAction.MOVE_UP, "↑ 머리방향으로 이동 (ch3 미검출)") + fun advise( + ch3: Boolean, u4: Int?, u5: Int?, latTol: Int = AlignmentConstants.LAT_TOL, + requireCh3: Boolean = true, + ): Pair { + if (!ch3 && requireCh3) return Pair(AlignAction.MOVE_UP, "↑ 머리방향으로 이동 (ch3 미검출)") + val note = if (!ch3) " · ch3 없음(폴백 위치)" else "" if (u4 != null && u5 != null) { val d = abs(u4 - u5) - if (d <= latTol) return Pair(AlignAction.STOP, "■ 정렬 완료 — 정지 (|Δ|=$d)") + if (d <= latTol) return Pair(AlignAction.STOP, "■ 정렬 완료 — 정지 (|Δ|=$d)$note") return if (u4 < u5) Pair(AlignAction.MOVE_RIGHT, "→ 우측으로 이동 (ch4 $u4 < ch5 $u5, |Δ|=$d)") else diff --git a/app/src/main/java/com/medithings/vesiscan/managers/LateralGuide.kt b/app/src/main/java/com/medithings/vesiscan/managers/LateralGuide.kt index 4f6f4b4..ca6b290 100644 --- a/app/src/main/java/com/medithings/vesiscan/managers/LateralGuide.kt +++ b/app/src/main/java/com/medithings/vesiscan/managers/LateralGuide.kt @@ -61,6 +61,13 @@ class LateralGuide( private val buf = ArrayDeque>() + /** + * 좌우 판정에서 CH3 를 요구할지(2026-10-13). 상하 단계가 CH3 전무 폴백으로 자리를 골랐으면 + * false — 그 자리엔 CH3 가 없는 게 전제라 "머리방향으로 올리라"가 끝나지 않는다. 호출부가 + * 좌우를 시작하기 전에 상하 판정의 `select_rule` 을 보고 정한다. + */ + var requireCh3: Boolean = true + /** 마지막 판정. 아직 한 번도 못 채웠으면 null. */ var committed: Commit? = null private set @@ -96,7 +103,7 @@ class LateralGuide( val ch3 = walls.size > CH3_INDEX && walls[CH3_INDEX] != null val u4 = walls.getOrNull(CH4_INDEX)?.urineLen val u5 = walls.getOrNull(CH5_INDEX)?.urineLen - val (action, msg) = AlignmentAdvice.advise(ch3, u4, u5, latTol) + val (action, msg) = AlignmentAdvice.advise(ch3, u4, u5, latTol, requireCh3) return Commit( action = action, 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 7ef824e..f9b76e0 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 @@ -975,6 +975,11 @@ fun AnchorAlignView(appState: AppState) { // 평균에 섞이면 첫 판정이 엉뚱한 방향을 가리킨다. lateral.reset(); lrCommit = null; lrAccum = 0; lrConfirmed = false lrRecent.clear() + // 상하가 CH3 전무 폴백으로 정해졌으면 좌우에서 CH3 를 요구하지 않는다 + // (2026-10-13 임상: 폴백 자리에서 "↑ 머리방향(ch3 미검출)"이 끝나지 않았다). + lateral.requireCh3 = AnchorSelection + .selectBest(guide.records.filter { it.capFrac.isFinite() }) + .rule != AnchorSelectRule.NO_CH3_MAX_NCH lrRunning = true } }, diff --git a/app/src/test/java/com/medithings/vesiscan/managers/LateralNoCh3FallbackTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/LateralNoCh3FallbackTest.kt new file mode 100644 index 0000000..6f85060 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/LateralNoCh3FallbackTest.kt @@ -0,0 +1,48 @@ +package com.medithings.vesiscan.managers + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 좌우 정렬의 CH3 조건 끄기 (2026-10-13). + * + * 상하 단계가 CH3 전무 폴백(`no_ch3_max_nch`)으로 자리를 골랐으면 그 자리에 CH3 가 없는 게 + * 전제다. 그런데 좌우 advisor 는 CH3 가 없으면 무조건 "↑ 머리방향(ch3 미검출)"을 내서 끝나지 + * 않았다(2026-10-13 임상 supine). `requireCh3=false` 면 CH4·CH5 균형만 본다. 기본값은 종전 그대로. + */ +class LateralNoCh3FallbackTest { + + @Test + fun `default still demands CH3 - Python parity untouched`() { + assertEquals(AlignAction.MOVE_UP, AlignmentAdvice.advise(ch3 = false, u4 = 50, u5 = 50).first) + } + + @Test + fun `without the CH3 requirement a balanced pair stops`() { + val (a, msg) = AlignmentAdvice.advise(ch3 = false, u4 = 50, u5 = 50, requireCh3 = false) + assertEquals(AlignAction.STOP, a) + assertTrue(msg.contains("ch3 없음")) // 폴백 자리라는 걸 화면에 남긴다 + } + + @Test + fun `without the CH3 requirement imbalance still steers left or right`() { + assertEquals(AlignAction.MOVE_RIGHT, AlignmentAdvice.advise(false, 10, 90, requireCh3 = false).first) + assertEquals(AlignAction.MOVE_LEFT, AlignmentAdvice.advise(false, 90, 10, requireCh3 = false).first) + assertEquals(AlignAction.PROBE_LR, AlignmentAdvice.advise(false, null, null, requireCh3 = false).first) + } + + @Test + fun `with CH3 present the flag changes nothing`() { + assertEquals(AlignAction.STOP, AlignmentAdvice.advise(true, 50, 50, requireCh3 = false).first) + assertEquals(AlignAction.STOP, AlignmentAdvice.advise(true, 50, 50, requireCh3 = true).first) + } + + @Test + fun `LateralGuide forwards the flag`() { + val g = LateralGuide() + assertTrue(g.requireCh3) + g.requireCh3 = false + assertEquals(false, g.requireCh3) + } +}