feat(alignment): 데모 CENTER_OPTIMIZE phase 추가 — 사용자앱 (a2fc1d9) 동기화

CH0~CH3 chord CV 로 최적 vertical 위치 판정하는 신규 phase.
V1 GREEN 판정 CV=0.15 실증값을 5-phase 에 이식.

- Phase4 enum: CENTER_OPTIMIZE 추가 (CH3_STABILIZE ↔ LR_BALANCE 사이)
- AlignSummary: chords Array<Double?> (angle-corrected mm) 추가
- summarize(): per-channel chord 계산 (post-ant × cos(angle) × DPS)
- CENTER_OPTIMIZE step 로직:
  * CH3 잃음 → CH3_STABILIZE 복귀
  * CH0/1/2 미검출 → MOVE_UP
  * 4채널 검출 + CV ≤ 0.15 (3 연속) → LR_BALANCE
  * CV > 0.15: chord[3] > chord[0] → MOVE_DOWN, else MOVE_UP
- AlignmentConstants: CV_THRESHOLD_STRICT=0.15, LOOSE=0.20,
  CENTER_OPT_CONFIRM_STREAK=3
- PlacementGuideView: V2 step indicator 5-stage → 6-stage

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 09:59:01 +09:00
parent cd3efc5f24
commit 889c65b038
2 changed files with 110 additions and 7 deletions
@@ -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<Double?> = 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<Double?>(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)
}
}
@@ -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