From b2546d14ca6dc0488d12371b7f8f8f535e9200f0 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 30 Jun 2026 13:47:54 +0900 Subject: [PATCH] =?UTF-8?q?fix(alignment):=20LR=5FBALANCE=20lost=20?= =?UTF-8?q?=EC=B1=84=EB=84=90=20hysteresis=20=E2=80=94=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EC=95=B1=20(50b8620)=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-06-30 실측 분석 (244 cycle alignment session) 결과: LR_BALANCE phase 170 cycle 중 |u4-u5| 항상 LAT_TOL(8) 이하 (좌우 균형 늘 OK). 하지만 CH1=15%, CH3=12% 가 cycle 마다 노이즈로 깜빡임 → 매번 lost.isNotEmpty() true → "↑위로" / "↓아래로" 화살표 → 다음 cycle 복구 → STOP 반복. 사용자 체감: "어디로 옮길지 모르겠음". - lostStreakRequired=3 추가 - 같은 채널이 3 연속 commit 에서 lost 여야 회복 모드 발동 - streak 미달 시 STOP 유지 (noise 로 판단) - full set 복귀 시 lostStreak 0 리셋 예상 효과: LR_BALANCE 화살표 flap rate 55% → ~5%. Co-Authored-By: Claude Opus 4.7 --- .../managers/AlignmentAdvisorV2.kt | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) 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 4987570..4c16d72 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt @@ -171,6 +171,13 @@ class AlignGuide4Stage( /** LR_BALANCE 반전 요구 연속 commit 수. 2회 연속 악화여야 방향 반전. * sliding window 잔여 효과 (사용자가 방향 바꿔도 평균은 직전 데이터 섞임) 흡수. */ 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% 가 노이즈로 깜빡임 → 화살표 매번 발동). + */ + private val lostStreakRequired: Int = 3, ) { enum class Phase4 { INITIAL_ACCUM, VERTICAL_CLIMB, CH3_STABILIZE, LR_BALANCE } @@ -194,6 +201,7 @@ class AlignGuide4Stage( private var lateralDir: AlignAction? = null private var lateralImbal: Int? = null private var lateralWorseStreak = 0 // LR_BALANCE 연속 악화 카운터 (deadband 초과 증가 시 +1) + private var lostStreak = 0 // LR_BALANCE 채널 lost hysteresis fun reset() { phase = Phase4.INITIAL_ACCUM @@ -203,6 +211,7 @@ class AlignGuide4Stage( lateralDir = null lateralImbal = null lateralWorseStreak = 0 + lostStreak = 0 } fun step(s: AlignSummary): Out { @@ -259,14 +268,24 @@ class AlignGuide4Stage( val setLabel = bestCenterSet.sortedDescending().joinToString("+") { "ch$it" } // dev/debug only val lost = bestCenterSet - curSet if (lost.isNotEmpty()) { - // 잃은 채널 있음 → 회복 우선. ch3 잃었으면 더 위로, 아니면 아래로. - 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) + // 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 차단)