diff --git a/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt b/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt index 4aa52de..006f979 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt @@ -50,6 +50,9 @@ data class AlignSummary( val chN: Int, /** Per-channel detection (size 6). center=[0,1,2,3], lateral=[4,5]. */ val detected: BooleanArray = BooleanArray(6), + /** 2026-07-02: 각 채널 chord (angle-corrected mm). CENTER_OPTIMIZE CV 계산용. + * chord[ch] = (post - ant) × cos(angle) × distancePerSample. null 이면 미검출. */ + val chords: Array = Array(6) { null }, ) object AlignmentConstants { @@ -65,6 +68,14 @@ object AlignmentConstants { /** Near-field 가드: ant 가 이웃 합의 z 보다 이 값 이상 "얕은" (probe 쪽) 경우 entry 복합체 * 아티팩트로 보고 후보 교체. 깊은(urine 쪽) 이탈은 dome 곡률로 정상이라 허용. 8mm ≈ 4 sample. */ const val ANT_NEARFIELD_TOL_MM = 8.0 + + // 2026-07-02: CENTER_OPTIMIZE phase — CH0~CH3 chord CV 로 최적 vertical 위치 판정. + /** CV 통과 임계 — V1 (computePlacementGuide) GREEN 조건 실증값 참조. */ + const val CV_THRESHOLD_STRICT = 0.15 + /** 방향 힌트 활성 임계 — CV 이 이상이면 사용자에게 up/down 힌트. */ + const val CV_THRESHOLD_LOOSE = 0.20 + /** CENTER_OPTIMIZE 통과 요구 연속 commit 수 (hysteresis). */ + const val CENTER_OPT_CONFIRM_STREAK = 3 } /** stateless 단일 스냅샷 advisor — alignment.py:alignment_advice(). */ @@ -180,7 +191,12 @@ class AlignGuide4Stage( /** FINAL_CONFIRM 누적 프레임 수 — flowchart 의 \"마지막 측정 5회 반복\". */ val finalConfirmTarget: Int = 5, ) { - enum class Phase4 { INITIAL_ACCUM, VERTICAL_CLIMB, CH3_STABILIZE, LR_BALANCE, FINAL_CONFIRM } + // 2026-07-02: CENTER_OPTIMIZE phase 추가 — CH0~CH3 chord CV 로 최적 vertical 위치 판정. + enum class Phase4 { + INITIAL_ACCUM, VERTICAL_CLIMB, CH3_STABILIZE, + CENTER_OPTIMIZE, // ← 신규 + LR_BALANCE, FINAL_CONFIRM + } data class Out( val action: AlignAction?, @@ -205,6 +221,7 @@ class AlignGuide4Stage( private var lateralWorseStreak = 0 private var finalConfirmCount = 0 // FINAL_CONFIRM 누적 카운터 private var lostStreak = 0 // LR_BALANCE 채널 lost hysteresis + private var centerOptimizeStreak = 0 // CENTER_OPTIMIZE CV 통과 연속 카운터 fun reset() { phase = Phase4.INITIAL_ACCUM @@ -217,6 +234,7 @@ class AlignGuide4Stage( lateralWorseStreak = 0 finalConfirmCount = 0 lostStreak = 0 + centerOptimizeStreak = 0 } fun step(s: AlignSummary): Out { @@ -276,10 +294,82 @@ class AlignGuide4Stage( msgKey = R.string.align_hold_position, msgArgs = listOf(stabilizeCount, stabilizeTarget)) } - phase = Phase4.LR_BALANCE + // 2026-07-02: CH3_STABILIZE 통과 → CENTER_OPTIMIZE (CV 로 4채널 균등 확인) + phase = Phase4.CENTER_OPTIMIZE + centerOptimizeStreak = 0 } - // === Phase 4: LR_BALANCE === + // === Phase 4a: CENTER_OPTIMIZE (신규) — CH0~CH3 chord CV 로 최적 vertical 정밀 조정 === + // 전제: CH3 는 이미 안정 검출됨. 4채널 chord CV 가 낮으면 (0.15 이하) 정렬 완료로 인정. + // CV 높으면: chord[3] > chord[0] 이면 probe 가 high → MOVE_DOWN, + // chord[0] > chord[3] 이면 probe 가 low → MOVE_UP. + if (phase == Phase4.CENTER_OPTIMIZE) { + val center4Detected = (0..3).all { it < s.detected.size && s.detected[it] } + if (!s.ch3) { + // CH3 잃으면 CH3_STABILIZE 로 복귀 + phase = Phase4.CH3_STABILIZE + stabilizeCount = 0 + centerOptimizeStreak = 0 + return Out(AlignAction.MOVE_UP, "↑ ch3 재확인 필요", + phase, 0, msgKey = R.string.align_recover_up) + } + if (!center4Detected) { + // CH0~2 중 일부 미검출 → probe 를 위로 올려 CH0 잡기 + centerOptimizeStreak = 0 + val missing = (0..3).filter { !s.detected[it] } + val missingLabel = missing.joinToString(",") { "ch$it" } + return Out(AlignAction.MOVE_UP, + "↑ 조금 더 위로 ($missingLabel 미검출)", phase, 0, + msgKey = R.string.align_move_up) + } + // 4채널 모두 검출됨 → chord CV 계산 (summarize 에서 angle-corrected mm 값 저장됨) + val chords = DoubleArray(4) { ch -> s.chords[ch] ?: 0.0 } + val chordMean = chords.average() + if (chordMean <= 0.1) { + phase = Phase4.VERTICAL_CLIMB + verticalHitStreak = 0 + centerOptimizeStreak = 0 + return Out(AlignAction.MOVE_UP, "↑ 재정렬 필요", phase, 0, + msgKey = R.string.align_move_up) + } + val variance = chords.map { (it - chordMean) * (it - chordMean) }.average() + val chordStd = kotlin.math.sqrt(variance) + val cv = chordStd / chordMean + + if (cv <= AlignmentConstants.CV_THRESHOLD_STRICT) { + centerOptimizeStreak++ + if (centerOptimizeStreak >= AlignmentConstants.CENTER_OPT_CONFIRM_STREAK) { + bestCenterSet = (0..3).toSet() + phase = Phase4.LR_BALANCE + return Out(AlignAction.STOP, + "✓ 최적 vertical 확인 (CV=${"%.2f".format(cv)}) — 좌우 균형 확인", + phase, 0, + msgKey = R.string.align_hold_position, + msgArgs = listOf(centerOptimizeStreak, AlignmentConstants.CENTER_OPT_CONFIRM_STREAK)) + } + return Out(AlignAction.STOP, + "✓ 최적 근접 (CV=${"%.2f".format(cv)}) — 확인 중 " + + "(${centerOptimizeStreak}/${AlignmentConstants.CENTER_OPT_CONFIRM_STREAK})", + phase, centerOptimizeStreak, + msgKey = R.string.align_hold_position, + msgArgs = listOf(centerOptimizeStreak, AlignmentConstants.CENTER_OPT_CONFIRM_STREAK)) + } + // CV 너무 큼 → 방향 힌트 + centerOptimizeStreak = 0 + val ch0Chord = chords[0] + val ch3Chord = chords[3] + return if (ch3Chord > ch0Chord) { + Out(AlignAction.MOVE_DOWN, + "↓ 조금 아래로 (CV=${"%.2f".format(cv)}, CH3 극대)", phase, 0, + msgKey = R.string.align_recover_down_short) + } else { + Out(AlignAction.MOVE_UP, + "↑ 조금 위로 (CV=${"%.2f".format(cv)}, CH0 극대)", phase, 0, + msgKey = R.string.align_recover_up_short) + } + } + + // === Phase 4b (기존 4): LR_BALANCE === if (phase == Phase4.LR_BALANCE) { if (curSet.size > bestCenterSet.size) bestCenterSet = curSet val setLabel = bestCenterSet.sortedDescending().joinToString("+") { "ch$it" } @@ -508,6 +598,17 @@ class RollingAligner( val ch3 = dets.size > AlignmentConstants.CH3 && dets[AlignmentConstants.CH3] != null val chN = AlignmentConstants.CENTER_CH.count { it < dets.size && dets[it] != null } val detected = BooleanArray(6) { i -> i < dets.size && dets[i] != null } - return AlignSummary(ch3 = ch3, u4 = u(4), u5 = u(5), chN = chN, detected = detected) + // 2026-07-02: per-channel chord (angle-corrected mm) — CENTER_OPTIMIZE CV 계산용 + val chords = Array(6) { ch -> + val d = if (ch < dets.size) dets[ch] else null + if (d == null) null + else { + val urineLen = kotlin.math.max(0, d.post - d.ant - 1).toDouble() + val angleRad = Math.toRadians(PiezoHW.degreeAll[ch]) + urineLen * kotlin.math.cos(angleRad) * PiezoHW.distancePerSample + } + } + return AlignSummary(ch3 = ch3, u4 = u(4), u5 = u(5), chN = chN, + detected = detected, chords = chords) } } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index a59eea5..fbab1af 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -577,15 +577,17 @@ fun PlacementGuideView(appState: AppState) { // 2026-07-01: FINAL_CONFIRM phase 추가 (feature/tab-navigation 동기화). val isV2Active = com.example.medilightv2android.managers.GreenZoneConstants.alignmentAlgo == com.example.medilightv2android.managers.AlignmentAlgo.V2 + // 2026-07-02: CENTER_OPTIMIZE 추가 (5-stage → 6-stage) val (currentStepIdx, totalSteps) = if (isV2Active) { val idx = when (v2Aligner.phase) { com.example.medilightv2android.managers.AlignGuide4Stage.Phase4.INITIAL_ACCUM -> 0 com.example.medilightv2android.managers.AlignGuide4Stage.Phase4.VERTICAL_CLIMB -> 1 com.example.medilightv2android.managers.AlignGuide4Stage.Phase4.CH3_STABILIZE -> 2 - com.example.medilightv2android.managers.AlignGuide4Stage.Phase4.LR_BALANCE -> 3 - com.example.medilightv2android.managers.AlignGuide4Stage.Phase4.FINAL_CONFIRM -> 4 + com.example.medilightv2android.managers.AlignGuide4Stage.Phase4.CENTER_OPTIMIZE -> 3 + com.example.medilightv2android.managers.AlignGuide4Stage.Phase4.LR_BALANCE -> 4 + com.example.medilightv2android.managers.AlignGuide4Stage.Phase4.FINAL_CONFIRM -> 5 } - idx to 5 + idx to 6 } else { val idx = when (placementPhase) { PlacementPhase.VERTICAL -> 0