From 0a93aa1ab3941ffc9e4feba8b7d65acd57f60ba6 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 13 Oct 2026 17:04:37 +0900 Subject: [PATCH] =?UTF-8?q?fix(align):=20CH3=20=EC=A0=84=EB=AC=B4=20?= =?UTF-8?q?=ED=8F=B4=EB=B0=B1=EC=9C=BC=EB=A1=9C=20=EA=B3=A0=EB=A5=B8=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=EC=97=90=EC=84=9C=EB=8A=94=20=EC=A2=8C?= =?UTF-8?q?=EC=9A=B0=20=EC=A0=95=EB=A0=AC=EC=9D=B4=20CH3=20=EB=A5=BC=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-10-13 임상(e.yu): supine 에서 CH3 가 끝내 안 잡혀 폴백(nch 최대)으로 자리를 골랐는데, 좌우 단계가 "↑ 머리방향으로 이동 (ch3 미검출)" 만 내서 끝나지 않았다. 좌우 advisor 는 Python `alignment_advice` 와 1:1 로 CH3 없음 → 무조건 MOVE_UP 이다. 폴백 자리는 CH3 가 없는 걸 알고 고른 자리라 그 조건이 모순이다. - AlignmentAdvice.advise(..., requireCh3 = true): false 면 CH3 없이 CH4·CH5 균형만 본다. STOP 문구에 "ch3 없음(폴백 위치)"를 붙여 화면·기록에 남긴다. 기본값은 종전 그대로(Python 1:1). - LateralGuide.requireCh3 — 좌우를 시작할 때 정렬 화면이 전 위치 판정의 select_rule 을 보고 `no_ch3_max_nch` 면 끈다. - 테스트 LateralNoCh3FallbackTest 5건. 기존 LateralAlignParityTest 는 그대로(기본값 경로). Co-Authored-By: Claude Fable 5.1 --- .../vesiscan/managers/AlignmentAdvisorV2.kt | 19 ++++++-- .../vesiscan/managers/LateralGuide.kt | 9 +++- .../ui/views/clinical/AnchorAlignView.kt | 5 ++ .../managers/LateralNoCh3FallbackTest.kt | 48 +++++++++++++++++++ 4 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/com/medithings/vesiscan/managers/LateralNoCh3FallbackTest.kt 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) + } +}