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 4c16d72..4aa52de 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt @@ -165,21 +165,22 @@ class AlignGuide4Stage( private val latTol: Int = AlignmentConstants.LAT_TOL, private val initialTarget: Int = 10, private val stabilizeTarget: Int = 10, - /** LR_BALANCE 반전 deadband (samples). imbal 가 직전보다 이 값 초과 증가해야 - * '진짜 악화' 로 카운트. 노이즈/호흡 흡수. LAT_TOL(8) 의 약 1/3. */ + /** VERTICAL_CLIMB 에서 CH3_STABILIZE 로 넘어가기 위한 ch3 연속 commit 수. */ + private val verticalHitRequired: Int = 3, + /** LR_BALANCE 반전 deadband (samples). */ private val lrReverseDeadband: Int = 3, - /** LR_BALANCE 반전 요구 연속 commit 수. 2회 연속 악화여야 방향 반전. - * sliding window 잔여 효과 (사용자가 방향 바꿔도 평균은 직전 데이터 섞임) 흡수. */ + /** LR_BALANCE 반전 요구 연속 commit 수. */ private val lrReverseConsecutive: Int = 2, /** * LR_BALANCE 의 `lost` 감지 hysteresis — N 연속 commit 에서 같은 채널이 * 빠져있어야 진짜 "회복 필요" 로 인정. 1 cycle noise 깜빡임에 화살표 - * 흔들리는 현상 차단 (2026-06-30 실측 분석 결과 — LR_BALANCE 170 cycle - * 중 CH1=15%, CH3=12% 가 노이즈로 깜빡임 → 화살표 매번 발동). + * 흔들리는 현상 차단 (2026-06-30 실측 분석 결과). */ private val lostStreakRequired: Int = 3, + /** FINAL_CONFIRM 누적 프레임 수 — flowchart 의 \"마지막 측정 5회 반복\". */ + val finalConfirmTarget: Int = 5, ) { - enum class Phase4 { INITIAL_ACCUM, VERTICAL_CLIMB, CH3_STABILIZE, LR_BALANCE } + enum class Phase4 { INITIAL_ACCUM, VERTICAL_CLIMB, CH3_STABILIZE, LR_BALANCE, FINAL_CONFIRM } data class Out( val action: AlignAction?, @@ -197,20 +198,24 @@ class AlignGuide4Stage( var bestCenterSet: Set = emptySet() private set private var initialCount = 0 + private var verticalHitStreak = 0 private var stabilizeCount = 0 private var lateralDir: AlignAction? = null private var lateralImbal: Int? = null - private var lateralWorseStreak = 0 // LR_BALANCE 연속 악화 카운터 (deadband 초과 증가 시 +1) + private var lateralWorseStreak = 0 + private var finalConfirmCount = 0 // FINAL_CONFIRM 누적 카운터 private var lostStreak = 0 // LR_BALANCE 채널 lost hysteresis fun reset() { phase = Phase4.INITIAL_ACCUM bestCenterSet = emptySet() initialCount = 0 + verticalHitStreak = 0 stabilizeCount = 0 lateralDir = null lateralImbal = null lateralWorseStreak = 0 + finalConfirmCount = 0 lostStreak = 0 } @@ -221,7 +226,7 @@ class AlignGuide4Stage( if (phase == Phase4.INITIAL_ACCUM) { initialCount++ if (initialCount < initialTarget) { - return Out(null, "잠시 그대로 두세요 (${initialCount}/${initialTarget})", + return Out(null, "측정 준비 중... (${initialCount}/${initialTarget})", phase, initialCount, msgKey = R.string.align_hold_still, msgArgs = listOf(initialCount, initialTarget)) @@ -232,10 +237,21 @@ class AlignGuide4Stage( // === Phase 2: VERTICAL_CLIMB === if (phase == Phase4.VERTICAL_CLIMB) { if (!s.ch3) { - return Out(AlignAction.MOVE_UP, "↑ 위로 조금씩 올리세요", + verticalHitStreak = 0 // 연속 카운터 리셋 — false detection 거부 + return Out(AlignAction.MOVE_UP, "↑ 위로 조금씩 올리세요 (ch3 미검출)", phase, 0, msgKey = R.string.align_move_up) } + verticalHitStreak++ + if (verticalHitStreak < verticalHitRequired) { + // ch3 일시 검출 — verticalHitRequired 회 연속 확인 전까지 사용자 정지. + // (현재 위치가 진짜 ch3 인지 평균이 만든 false echo 인지 확인 중) + return Out(AlignAction.STOP, + "✓ ch3 일시 검출 — 확인 중 (${verticalHitStreak}/${verticalHitRequired})", + phase, verticalHitStreak, + msgKey = R.string.align_hold_position, + msgArgs = listOf(verticalHitStreak, verticalHitRequired)) + } phase = Phase4.CH3_STABILIZE stabilizeCount = 0 bestCenterSet = curSet @@ -249,13 +265,13 @@ class AlignGuide4Stage( // ch3 잃었으면 → VERTICAL_CLIMB 회귀 phase = Phase4.VERTICAL_CLIMB stabilizeCount = 0 - return Out(AlignAction.MOVE_UP, "↑ 살짝 놓침 — 다시 위로", + return Out(AlignAction.MOVE_UP, "↑ ch3 다시 잃음 — 위로", phase, 0, msgKey = R.string.align_recover_up) } if (stabilizeCount < stabilizeTarget) { return Out(AlignAction.STOP, - "✓ 좋아요! 그 위치 유지 (${stabilizeCount}/${stabilizeTarget})", + "✓ ch3 검출! 위치 유지 (${stabilizeCount}/${stabilizeTarget})", phase, stabilizeCount, msgKey = R.string.align_hold_position, msgArgs = listOf(stabilizeCount, stabilizeTarget)) @@ -264,71 +280,130 @@ class AlignGuide4Stage( } // === Phase 4: LR_BALANCE === - if (curSet.size > bestCenterSet.size) bestCenterSet = curSet - val setLabel = bestCenterSet.sortedDescending().joinToString("+") { "ch$it" } // dev/debug only - val lost = bestCenterSet - curSet - if (lost.isNotEmpty()) { - // 2026-06-30 hysteresis: N 연속 commit 에서 같은 채널이 lost 여야 진짜 회복 모드. - // 1 cycle 노이즈 깜빡임 (실측 CH1=15%, CH3=12% flap) 에 화살표 흔들리는 현상 차단. - lostStreak++ - if (lostStreak >= lostStreakRequired) { - val recoveryDir = if (3 in lost) AlignAction.MOVE_UP else AlignAction.MOVE_DOWN - val arrow = if (recoveryDir == AlignAction.MOVE_UP) "↑" else "↓" - val dirWord = if (recoveryDir == AlignAction.MOVE_UP) "위" else "아래" - val recoveryKey = if (recoveryDir == AlignAction.MOVE_UP) - R.string.align_recover_up_short else R.string.align_recover_down_short - return Out(recoveryDir, "$arrow 위치 살짝 벗어남 — ${dirWord}로 약간", phase, 0, - msgKey = recoveryKey) - } - // streak 미달 → noise 로 보고 STOP 유지. - return Out(AlignAction.STOP, - "✓ 정렬 완료! 그대로 측정 가능 (noise check $lostStreak/$lostStreakRequired)", - phase, 0, msgKey = R.string.align_complete) - } else { - lostStreak = 0 // full set 복귀 — streak 리셋 - } - - // full set 유지 중 — 좌우 균형 (deadband + consecutive 악화 로 oscillation 차단) - val u4 = s.u4 - val u5 = s.u5 - if (u4 != null && u5 != null) { - val imbal = kotlin.math.abs(u4 - u5) - if (imbal <= latTol) { + if (phase == Phase4.LR_BALANCE) { + if (curSet.size > bestCenterSet.size) bestCenterSet = curSet + val setLabel = bestCenterSet.sortedDescending().joinToString("+") { "ch$it" } + val lost = bestCenterSet - curSet + if (lost.isNotEmpty()) { + // 2026-06-30 hysteresis: N 연속 commit 에서 같은 채널이 lost 여야 진짜 회복 모드. + // 1 cycle 노이즈 깜빡임 (실측 CH1=15%, CH3=12% flap) 에 화살표 흔들리는 현상 차단. + lostStreak++ + if (lostStreak >= lostStreakRequired) { + val recoveryDir = if (3 in lost) AlignAction.MOVE_UP else AlignAction.MOVE_DOWN + val lostLabel = lost.sortedDescending().joinToString(",") { "ch$it" } + val arrow = if (recoveryDir == AlignAction.MOVE_UP) "↑" else "↓" + val recoveryKey = if (recoveryDir == AlignAction.MOVE_UP) + R.string.align_recover_up_short else R.string.align_recover_down_short + return Out(recoveryDir, "$arrow $lostLabel 잃음 — 위치 회복", phase, 0, + msgKey = recoveryKey) + } + // streak 미달 → noise 로 보고 STOP 유지. return Out(AlignAction.STOP, - "✓ 정렬 완료! 그대로 측정 가능", phase, 0, + "✓ 그대로 측정 가능 (noise check $lostStreak/$lostStreakRequired)", phase, 0, msgKey = R.string.align_complete) + } else { + lostStreak = 0 // full set 복귀 — streak 리셋 } - if (lateralDir == null) { - // 첫 진입 — u4/u5 비교로 방향 추정 - lateralDir = if (u4 < u5) AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT - lateralWorseStreak = 0 - } else if (lateralImbal != null) { - // 악화 판정: imbal 가 직전보다 deadband 초과로 증가해야 streak++ - val significantlyWorse = imbal > lateralImbal!! + lrReverseDeadband - if (significantlyWorse) { - lateralWorseStreak++ - if (lateralWorseStreak >= lrReverseConsecutive) { - // 연속 N회 진짜 악화 → 방향 반전, streak reset - lateralDir = if (lateralDir == AlignAction.MOVE_LEFT) - AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT + + // full set 유지 중 — 좌우 균형 (deadband + consecutive 악화 로 oscillation 차단) + val u4 = s.u4 + val u5 = s.u5 + if (u4 != null && u5 != null) { + val imbal = kotlin.math.abs(u4 - u5) + if (imbal <= latTol) { + // 균형 도달 → FINAL_CONFIRM 으로 전이. RollingAligner 가 buf clear → 5 fresh 프레임 누적. + phase = Phase4.FINAL_CONFIRM + finalConfirmCount = 0 + return Out(AlignAction.STOP, + "✓ 균형 도달 — 최종 확인 중 (0/${finalConfirmTarget})", phase, 0, + msgKey = R.string.align_hold_position, + msgArgs = listOf(0, finalConfirmTarget)) + } + if (lateralDir == null) { + lateralDir = if (u4 < u5) AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT + lateralWorseStreak = 0 + } else if (lateralImbal != null) { + val significantlyWorse = imbal > lateralImbal!! + lrReverseDeadband + if (significantlyWorse) { + lateralWorseStreak++ + if (lateralWorseStreak >= lrReverseConsecutive) { + lateralDir = if (lateralDir == AlignAction.MOVE_LEFT) + AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT + lateralWorseStreak = 0 + } + } else { lateralWorseStreak = 0 } - } else { - // 개선 또는 deadband 안의 변동 → 같은 방향 유지, streak reset - lateralWorseStreak = 0 } + lateralImbal = imbal + val arrow = if (lateralDir == AlignAction.MOVE_LEFT) "←" else "→" + val sideKey = if (lateralDir == AlignAction.MOVE_LEFT) + R.string.align_left else R.string.align_right + return Out(lateralDir!!, "$arrow $setLabel 유지 + 좌우 균형 (|Δ|=$imbal)", + phase, 0, + msgKey = sideKey) } - lateralImbal = imbal - val arrow = if (lateralDir == AlignAction.MOVE_LEFT) "←" else "→" - val sideWord = if (lateralDir == AlignAction.MOVE_LEFT) "왼쪽" else "오른쪽" - val sideKey = if (lateralDir == AlignAction.MOVE_LEFT) - R.string.align_left else R.string.align_right - return Out(lateralDir!!, "$arrow ${sideWord}으로 살짝", phase, 0, - msgKey = sideKey) + return Out(AlignAction.PROBE_LR, "↔ $setLabel 유지 + 좌우 천천히 (lateral 미검출)", + phase, 0, + msgKey = R.string.align_probe_lr) } - // u4/u5 미검출 → probe - return Out(AlignAction.PROBE_LR, "↔ 좌우로 천천히 움직여 보세요", phase, 0, - msgKey = R.string.align_probe_lr) + + // === Phase 5: FINAL_CONFIRM === + // RollingAligner 가 진입 시 buf 를 clear → 5 fresh 프레임 누적 후 imbal 재평가. + if (phase == Phase4.FINAL_CONFIRM) { + // center set 유지 확인 — 잃었으면 LR_BALANCE 회귀. + val lost = bestCenterSet - curSet + if (lost.isNotEmpty()) { + val recoveryDir = if (3 in lost) AlignAction.MOVE_UP else AlignAction.MOVE_DOWN + val arrow = if (recoveryDir == AlignAction.MOVE_UP) "↑" else "↓" + phase = Phase4.LR_BALANCE + finalConfirmCount = 0 + val recoveryKey = if (recoveryDir == AlignAction.MOVE_UP) + R.string.align_recover_up_short else R.string.align_recover_down_short + return Out(recoveryDir, "$arrow 채널 잃음 — 재정렬", phase, 0, + msgKey = recoveryKey) + } + + finalConfirmCount++ + + // 누적 중 — 5 프레임 모일 때까지 정지 안내 + if (finalConfirmCount < finalConfirmTarget) { + return Out(AlignAction.STOP, + "✓ 최종 확인 중 (${finalConfirmCount}/${finalConfirmTarget})", + phase, finalConfirmCount, + msgKey = R.string.align_hold_position, + msgArgs = listOf(finalConfirmCount, finalConfirmTarget)) + } + + // 5 프레임 도달 — imbal 재평가 + val u4 = s.u4; val u5 = s.u5 + if (u4 == null || u5 == null) { + phase = Phase4.LR_BALANCE + finalConfirmCount = 0 + return Out(AlignAction.PROBE_LR, "lateral 미검출 — 좌우 재확인", phase, 0, + msgKey = R.string.align_probe_lr) + } + val imbal = kotlin.math.abs(u4 - u5) + val setLabel = bestCenterSet.sortedDescending().joinToString("+") { "ch$it" } + return if (imbal <= latTol) { + // 최종 확인 통과 → 진짜 STOP + Out(AlignAction.STOP, + "■ 정렬 완료 ($setLabel, 확인 OK, |Δ|=$imbal)", phase, finalConfirmTarget, + msgKey = R.string.align_complete) + } else { + // 재평가 실패 → LR_BALANCE 회귀하여 추가 조정 + phase = Phase4.LR_BALANCE + finalConfirmCount = 0 + val arrow = if (u4 < u5) "→" else "←" + val sideKey = if (u4 < u5) R.string.align_right else R.string.align_left + Out(if (u4 < u5) AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT, + "$arrow 재조정 필요 — 좌우 균형 (|Δ|=$imbal)", phase, 0, + msgKey = sideKey) + } + } + + // 도달 불가 (defensive) + return Out(AlignAction.STOP, "unknown phase: $phase", phase, 0) } } @@ -343,36 +418,55 @@ class AlignGuide4Stage( */ class RollingAligner( private val latTol: Int = AlignmentConstants.LAT_TOL, - private val accumK: Int = 10, + /** INITIAL_ACCUM / VERTICAL_CLIMB / CH3_STABILIZE 단계 window 크기. flowchart 의 \"10회 측정 평균\". */ + private val accumKVertical: Int = 10, + /** LR_BALANCE 단계 window 크기. flowchart 의 \"5회 측정 평균\" (빠른 반응). */ + private val accumKLateral: Int = 5, + /** FINAL_CONFIRM 단계 window 크기. flowchart 의 \"마지막 5회 반복 측정\". */ + private val accumKConfirm: Int = 5, private val methodDParams: MethodDParams = MethodDParams.DEFAULT, ) { private val buf = ArrayDeque>() private val guide4 = AlignGuide4Stage(latTol = latTol, - initialTarget = accumK, stabilizeTarget = accumK) - /** 마지막 commit 결과. 외부 query 용. */ + initialTarget = accumKVertical, stabilizeTarget = accumKVertical, + finalConfirmTarget = accumKConfirm) + private var lastSeenPhase: AlignGuide4Stage.Phase4? = null + /** 마지막 commit 결과. */ var lastCommit: AdvisorState? = null private set - /** 현재 4-stage phase — UI step indicator 용. */ val phase: AlignGuide4Stage.Phase4 get() = guide4.phase - /** 지금까지 검출된 최대 center channel set — Phase 4 의 lock 대상. */ val bestCenterSet: Set get() = guide4.bestCenterSet - fun reset() { - buf.clear() - guide4.reset() - lastAvg = null - } - /** UI 그래프에서 N-cycle 평균 신호를 보여주기 위해 마지막 avg 노출. */ var lastAvg: List? = null private set + fun reset() { + buf.clear() + guide4.reset() + lastSeenPhase = null + lastAvg = null + } + /** 단일 채널 길이 100 가정. raw6.size == 6 이어야 함. */ fun push(raw6: List): AdvisorState { - // ring buffer — accumK 사이즈 sliding window. buf 가 덜 찼어도 항상 step 호출 - // (점진 평균 + 4-stage 가 INITIAL_ACCUM 으로 처리). + // Phase 전이 감지 — FINAL_CONFIRM 진입 시 buf clear 로 fresh 5 프레임 누적 보장. + // (flowchart \"마지막 측정 및 확인 — 5회 반복 측정\") + val curPhase = guide4.phase + if (lastSeenPhase != null && lastSeenPhase != AlignGuide4Stage.Phase4.FINAL_CONFIRM + && curPhase == AlignGuide4Stage.Phase4.FINAL_CONFIRM) { + buf.clear() + } + + // Phase 별 동적 window 크기. + val windowSize = when (curPhase) { + AlignGuide4Stage.Phase4.LR_BALANCE -> accumKLateral + AlignGuide4Stage.Phase4.FINAL_CONFIRM -> accumKConfirm + else -> accumKVertical + } + buf.addLast(raw6) - while (buf.size > accumK) buf.removeFirst() + while (buf.size > windowSize) buf.removeFirst() val nCh = raw6.size val avg = List(nCh) { ch -> @@ -387,13 +481,23 @@ class RollingAligner( val dets = MethodDRunner.detectMultichannel(avg, methodDParams) val summary = summarize(dets) val out = guide4.step(summary) - val state = if (out.phase == AlignGuide4Stage.Phase4.INITIAL_ACCUM) "accum" else "commit" + lastSeenPhase = guide4.phase + + // accum state — INITIAL_ACCUM 의 첫 N 프레임 + FINAL_CONFIRM 누적 중. + val isAccumulating = out.phase == AlignGuide4Stage.Phase4.INITIAL_ACCUM || + (out.phase == AlignGuide4Stage.Phase4.FINAL_CONFIRM && + out.progress in 1 until accumKConfirm) + val state = if (isAccumulating) "accum" else "commit" + val effectiveTarget = when (out.phase) { + AlignGuide4Stage.Phase4.FINAL_CONFIRM -> accumKConfirm + else -> accumKVertical + } val st = AdvisorState( state = state, action = out.action, msg = out.msg, msgKey = out.msgKey, msgArgs = out.msgArgs, dets = dets, summary = summary, - accumProgress = if (state == "accum") out.progress else accumK, - accumTarget = accumK, + accumProgress = if (state == "accum") out.progress else effectiveTarget, + accumTarget = effectiveTarget, ) lastCommit = st return st 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 488cab5..a59eea5 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 @@ -573,7 +573,8 @@ fun PlacementGuideView(appState: AppState) { horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(vertical = 4.dp) ) { - // V2 활성 시 4-stage step indicator, 아니면 V1 의 3-stage. + // V2 활성 시 5-stage step indicator, 아니면 V1 의 3-stage. + // 2026-07-01: FINAL_CONFIRM phase 추가 (feature/tab-navigation 동기화). val isV2Active = com.example.medilightv2android.managers.GreenZoneConstants.alignmentAlgo == com.example.medilightv2android.managers.AlignmentAlgo.V2 val (currentStepIdx, totalSteps) = if (isV2Active) { @@ -582,8 +583,9 @@ fun PlacementGuideView(appState: AppState) { 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 } - idx to 4 + idx to 5 } else { val idx = when (placementPhase) { PlacementPhase.VERTICAL -> 0