fix(walking): sit-to-stand burst window leak — pitch transition 시 buffer 비움

증상: 앉아있다 일어서기만 해도 WALKING false-trigger (두 번 연속 발생).

원인 (로그 분석):
  앉아있음(pitch=20-30°) → 일어남 burst (gyro 90-220 dps) → 서있음(pitch=82°)

  타임라인:
    T=0  pitch=37°  wmean=49  (burst 시작, pitch < 45 → pitchOk=false)
    T=1  pitch=14°  wmean=110 (더 큰 burst, pitchOk=false)
    T=2  pitch=32°  wmean=174 (burst 최고, pitchOk=false)
    T=3  pitch=82°  wmean=190 ← pitchOk=true 된 순간 window 에 burst 가득
                              → start cond 즉시 만족, 카운트 시작
    T=4  pitch=50°  wmean=82  (burst 빠지는 중)
    T=5  pitch=46°  wmean=12  (거의 빠짐, 그래도 > Start Wmean(4))
    T=6  pitch=57°  wmean=24  → "WALKING start (held 3091ms)" ❌

  핵심: pitch 가 45° 통과한 순간 window 안엔 이미 burst data. burst 가 다
  빠지기 전에 (~1.5초) + 3초 hold 누적 = false-trigger.

수정: WalkingDetector 에 lastPitchOk 추적 + false→true 전이 시
      buffer.clear() + startCondSinceMs=null.

  if (pitchOk && !lastPitchOk && !walking) {
      buffer.clear()           // burst data 폐기
      startCondSinceMs = null  // hold 카운트 리셋
  }

  결과: pitch 가 45° 넘는 순간부터 fresh sample 만 누적. 사용자가 일어서서
        가만히 있으면 wmean=1-2 (정상 정지값) → cond 안 met → trigger 안 됨.
        실제 보행 시작하면 새 wmean 이 30-50+ 까지 올라가 → 3초 hold → trigger.

  walking 중에는 pitchOk false→true 가 발생해도 (잠깐 앉았다 일어남 등) buffer
  안 비움 (walking 상태 유지 — keep cond 가 처리).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 12:18:38 +09:00
parent 18a4cee00d
commit 80f3982352
@@ -44,6 +44,8 @@ class WalkingDetector {
private var startCondSinceMs: Long? = null private var startCondSinceMs: Long? = null
/** WALKING 에서 유지 조건 마지막 만족 시각. */ /** WALKING 에서 유지 조건 마지막 만족 시각. */
private var keepCondLastOkMs: Long? = null private var keepCondLastOkMs: Long? = null
/** 직전 cycle 의 pitchOk — false→true 전이 시 buffer reset (sit-to-stand burst 차단). */
private var lastPitchOk: Boolean = false
val isWalking: Boolean get() = walking val isWalking: Boolean get() = walking
@@ -52,6 +54,7 @@ class WalkingDetector {
walking = false walking = false
startCondSinceMs = null startCondSinceMs = null
keepCondLastOkMs = null keepCondLastOkMs = null
lastPitchOk = false
} }
/** /**
@@ -59,6 +62,19 @@ class WalkingDetector {
* pitchDeg = 같은 cycle 의 pitch (ImuPostureClassifier 가 계산한 값). * pitchDeg = 같은 cycle 의 pitch (ImuPostureClassifier 가 계산한 값).
*/ */
fun update(samples: List<ImuSample>, pitchDeg: Float, nowMs: Long): Snapshot { fun update(samples: List<ImuSample>, pitchDeg: Float, nowMs: Long): Snapshot {
val minPitch = GreenZoneConstants.walkingMinPitchDeg
val pitchOk = abs(pitchDeg) > minPitch
// Pitch false→true 전이 (sit-to-stand 등) — buffer 비움.
// 이유: 일어남 burst 가 pitch<45° 일 때 발생 → window 에 누적 → pitch 가 통과한
// 순간 wmean 이 이미 큰 값 → 즉시 start cond 만족 → 가짜 walking trigger.
// pitch 가 true 가 된 직후엔 새 데이터만으로 measure 하도록.
if (pitchOk && !lastPitchOk && !walking) {
buffer.clear()
startCondSinceMs = null
}
lastPitchOk = pitchOk
// 1) sample buffer 에 추가 (timestamp 단순 분배 — cycle 내 균등 간격 가정) // 1) sample buffer 에 추가 (timestamp 단순 분배 — cycle 내 균등 간격 가정)
val windowMs = GreenZoneConstants.walkingWindowMs val windowMs = GreenZoneConstants.walkingWindowMs
if (samples.isNotEmpty()) { if (samples.isNotEmpty()) {
@@ -89,10 +105,7 @@ class WalkingDetector {
} }
val wstd = sqrt(sqSum / n) val wstd = sqrt(sqSum / n)
// 4) state machine // 4) state machine (pitchOk 은 위에서 이미 계산됨)
val minPitch = GreenZoneConstants.walkingMinPitchDeg
val pitchOk = abs(pitchDeg) > minPitch
if (!walking) { if (!walking) {
// IDLE → WALKING 검사 (4-2) // IDLE → WALKING 검사 (4-2)
val cond = pitchOk && val cond = pitchOk &&