fix(align V2): LR_BALANCE 좌우 화살표 oscillation 차단 (deadband + 연속 악화)

증상: ch3 확인 통과 후 LR_BALANCE 단계에서 좌/우 화살표가 빠른
      간격으로 번갈아 나타남.

원인:
  AlignGuide4Stage.LR_BALANCE 의 hill-climbing 반전 조건이
  imbal > lateralImbal 만 검사 → 1 sample 라도 늘면 즉시 반전.
  - 노이즈/호흡 으로 imbal 가 ±1-2 fluctuate → 잘못된 반전
  - sliding window 평균 (10-frame) 잔여 효과: 방향 바꿔도 직전
    데이터 섞여 imbal 변화 못 따라옴 → 가짜 "악화" 판정
  - 사용자 반응 지연 동안 imbal 더 누적 → 또 반전 → ←→←→ 진동

수정 (A + C 조합):
  1) A. 반전 deadband (lrReverseDeadband = 3 samples ≈ 6 mm)
     imbal 가 직전보다 "deadband 초과" 증가해야 악화로 카운트.
     LAT_TOL(8) 의 약 1/3 — 정렬 완료 조건 위협하지 않으면서 노이즈
     흡수.
  2) C. 연속 N회 악화 요구 (lrReverseConsecutive = 2)
     deadband 초과 증가가 2 commit 연속이어야 방향 반전.
     단발 노이즈/sliding window 잔여 효과 흡수.

  상태 추가: lateralWorseStreak Int (reset 포함).
  로직:
    - 개선 또는 deadband 안 변동 → 같은 방향 유지, streak = 0
    - deadband 초과 증가 → streak++
    - streak >= 2 → 방향 반전, streak = 0
  lateralImbal 은 매 commit 갱신 (기존 동작 유지).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 11:26:04 +09:00
parent fea5879cdc
commit 1fc1a20bb9
@@ -152,6 +152,12 @@ 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. */
private val lrReverseDeadband: Int = 3,
/** LR_BALANCE 반전 요구 연속 commit 수. 2회 연속 악화여야 방향 반전.
* sliding window 잔여 효과 (사용자가 방향 바꿔도 평균은 직전 데이터 섞임) 흡수. */
private val lrReverseConsecutive: Int = 2,
) {
enum class Phase4 { INITIAL_ACCUM, VERTICAL_CLIMB, CH3_STABILIZE, LR_BALANCE }
@@ -165,6 +171,7 @@ class AlignGuide4Stage(
private var stabilizeCount = 0
private var lateralDir: AlignAction? = null
private var lateralImbal: Int? = null
private var lateralWorseStreak = 0 // LR_BALANCE 연속 악화 카운터 (deadband 초과 증가 시 +1)
fun reset() {
phase = Phase4.INITIAL_ACCUM
@@ -173,6 +180,7 @@ class AlignGuide4Stage(
stabilizeCount = 0
lateralDir = null
lateralImbal = null
lateralWorseStreak = 0
}
fun step(s: AlignSummary): Out {
@@ -230,7 +238,7 @@ class AlignGuide4Stage(
return Out(recoveryDir, "$arrow $lostLabel 잃음 — 위치 회복", phase, 0)
}
// full set 유지 중 — 좌우 균형
// full set 유지 중 — 좌우 균형 (deadband + consecutive 악화 로 oscillation 차단)
val u4 = s.u4
val u5 = s.u5
if (u4 != null && u5 != null) {
@@ -240,10 +248,24 @@ class AlignGuide4Stage(
"■ 정렬 완료 ($setLabel 유지, |Δ|=$imbal)", phase, 0)
}
if (lateralDir == null) {
// 첫 진입 — u4/u5 비교로 방향 추정
lateralDir = if (u4 < u5) AlignAction.MOVE_RIGHT else AlignAction.MOVE_LEFT
} else if (lateralImbal != null && imbal > lateralImbal!!) {
lateralDir = if (lateralDir == AlignAction.MOVE_LEFT) 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
lateralWorseStreak = 0
}
} else {
// 개선 또는 deadband 안의 변동 → 같은 방향 유지, streak reset
lateralWorseStreak = 0
}
}
lateralImbal = imbal
val arrow = if (lateralDir == AlignAction.MOVE_LEFT) "←" else "→"