fix(posture): gyro gate 제거 + UNKNOWN streak 화면 표시

증상: 디바이스를 lying 상태로 둬도 "Upright" 표시가 안 바뀜.

진단 (raw 로그 분석):
  - 디바이스 정지 상태에서도 gyro y 축 ≈ -25 dps 일관 표시
  - 이는 IMU 칩의 **calibration bias** (정지 시 zero offset 안 됨)
  - flowchart 의 gyro gate (9.16 dps) 가 항상 fail → classifier 영구 UNKNOWN
  - currentPosture 가 UNKNOWN 시 업데이트 안 되는 로직 때문에 첫 valid
    값 (NON_LYING, 사용자가 손에 들고 진입한 순간) 으로 stuck
  - 사용자가 눕혀도 분류 자체가 안 되니 "Upright" 그대로

수정:
  1) ImuPostureClassifier: gyro gate 제거
     - accel magnitude gate (0.8-1.2g) 가 큰 모션은 이미 거름
     - gyro magnitude 는 Result/log 에 계속 노출 (디버그용)
     - flowchart 그대로 따르려면 gyro 도 활용해야 하지만, bias 큰
       디바이스에선 무용지물이라 실용성 우선

  2) PiezoMonitoringView: UNKNOWN streak 카운터
     - 3회 연속 UNKNOWN 이면 currentPosture = UNKNOWN 으로 강제 표시
     - silent stuck 상태 (잘못된 자세 계속 보여줌) 방지
     - 잠시 1-2 cycle 동안 motion artifact 로 UNKNOWN 떠도 직전
       자세 유지 (이전 동작 보존)

  3) Log: unknownStreak 도 출력 → 디버그 시 stuck 케이스 가시화

테스트:
  - lying → upright 전환 시 즉시 "Upright" 표시되어야 함
  - 정지 시 "Lying" 또는 "Upright" 안정적 유지
  - 큰 모션 (들어올림 등) 중에는 3 cycle 후 "—" 표시

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 17:25:58 +09:00
parent af469558e6
commit 9a5fa16f94
2 changed files with 13 additions and 7 deletions
@@ -24,10 +24,13 @@ enum class Posture { UNKNOWN, LYING, NON_LYING }
object ImuPostureClassifier {
// 게이트 임계값 — flowchart 그대로
// 게이트 임계값
private const val ACC_MAG_MIN = 0.8f
private const val ACC_MAG_MAX = 1.2f
private const val GYRO_MAG_MAX_DPS = 9.16f
// Gyro gate 비활성화 — 일부 디바이스의 gyro calibration bias (-25 dps 등) 가
// flowchart 임계(9.16) 보다 커서 정지 상태에서도 fail → 분류 영구 불가.
// accel magnitude gate (0.8-1.2g) 가 이미 큰 모션 거름. gyro 게이트 제거.
// 단 dev 모드 디버그 위해 magnitude 는 계속 계산해 로그/Result 에 노출.
/** 자세 + 진단 정보 (UI/log용). */
data class Result(
@@ -60,10 +63,7 @@ object ImuPostureClassifier {
return Result(Posture.UNKNOWN, 0f, accMag, gyroMag,
"acc mag out of range (%.2f)".format(accMag))
}
if (gyroMag > GYRO_MAG_MAX_DPS) {
return Result(Posture.UNKNOWN, 0f, accMag, gyroMag,
"moving (gyro %.1f dps)".format(gyroMag))
}
// Gyro gate 제거 — bias 큰 디바이스에서 영구 fail 문제 (위 주석 참조)
val pitch = (atan(ax / sqrt(ay * ay + az * az)) * 180.0 / Math.PI).toFloat()
val absPitch = abs(pitch)
@@ -665,13 +665,19 @@ fun PiezoMonitoringView(appState: AppState) {
currentPosture = com.example.medilightv2android.managers.Posture.LYING
return@LaunchedEffect
}
var unknownStreak = 0
bleManager.imuCollector.onComplete = { samples ->
val r = com.example.medilightv2android.managers.ImuPostureClassifier.classify(samples)
if (r.posture != com.example.medilightv2android.managers.Posture.UNKNOWN) {
currentPosture = r.posture
wakeupDetector.update(r.posture)
unknownStreak = 0
} else {
unknownStreak++
// 3회 연속 UNKNOWN 이면 화면에 표시 (silent stuck 방지)
if (unknownStreak >= 3) currentPosture = com.example.medilightv2android.managers.Posture.UNKNOWN
}
Log.d("ImuDbg", "POSTURE ${r.posture} pitch=${"%.1f".format(r.pitchDeg)}° accMag=${"%.2f".format(r.accMag)}g gyro=${"%.1f".format(r.gyroMagDps)}dps (${r.reason}) samples=${samples.size}")
Log.d("ImuDbg", "POSTURE ${r.posture} pitch=${"%.1f".format(r.pitchDeg)}° accMag=${"%.2f".format(r.accMag)}g gyro=${"%.1f".format(r.gyroMagDps)}dps (${r.reason}) samples=${samples.size} unknownStreak=$unknownStreak")
}
bleManager.imuCollector.onLog = { msg ->
Log.d("ImuDbg", "imuCollector log: $msg")