feat(alignment): demo AlignGuide 5-phase 로 확장 — FINAL_CONFIRM 추가

feature/tab-navigation 의 AlignmentAdvisorV2 를 demo 에 포팅 (packages 만 rename).

기존 4-phase (INITIAL_ACCUM → VERTICAL_CLIMB → CH3_STABILIZE → LR_BALANCE)
→ 5-phase (+ FINAL_CONFIRM).

FINAL_CONFIRM phase:
- LR_BALANCE 에서 |u4-u5| ≤ 8 도달 시 진입
- buf.clear() 후 5 fresh cycle 누적
- imbal 여전히 ≤ 8 유지 → 최종 STOP
- 실패 시 LR_BALANCE 로 복귀
- 목적: sliding window 잔여 효과 제거 후 재검증

추가 항목:
- Phase 별 dynamic window (accumKVertical=10, accumKLateral=5, accumKConfirm=5)
- lostStreakRequired=3 hysteresis (LR_BALANCE 채널 깜빡임 방지)
- msgKey / msgArgs (i18n 지원)

Sensor alignment default:
- 일반 사용자 (V1): 기존 3-step 유지 (변경 없음)
- Clinical alignment session (V2): 5-phase 사용

PlacementGuideView step indicator: 4 → 5.

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