/* * Posture classifier — flowchart `Posture classification` 기반 (2026-06-22). * * 단순화: 3 state (SUPINE/SITTING/STANDING) → **2 state (LYING / NON_LYING)**. * - 낙상 예방 목적상 sitting/standing 구분 불필요. "누웠나, 일어났나" 만 중요. * * 단계: * 1. Acc magnitude 게이트: 0.8 ≤ |a| ≤ 1.2 → motion/bad sample 거부 * 2. Gyro magnitude 게이트: |g| ≤ 9.16 dps → 회전 중 sample 거부 * 3. Pitch 계산: atan(ax / sqrt(ay² + az²)) × 180/π * 4. 분류 (|pitch| 기준, threshold = GreenZoneConstants.pitchLyingThreshold): * > threshold → LYING (누움) * ≤ threshold → NON_LYING (앉음/섬) * threshold default = 25°, dev 모드 설정에서 변경 가능. */ package com.example.medilightv2android.managers import com.example.medilightv2android.ble.ImuSample import kotlin.math.abs import kotlin.math.atan import kotlin.math.sqrt enum class Posture { UNKNOWN, LYING, NON_LYING, WALKING } object ImuPostureClassifier { // 게이트 임계값 private const val ACC_MAG_MIN = 0.8f private const val ACC_MAG_MAX = 1.2f // 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( val posture: Posture, val pitchDeg: Float, val accMag: Float, val gyroMagDps: Float, val reason: String, ) fun classify(samples: List): Result { if (samples.isEmpty()) { return Result(Posture.UNKNOWN, 0f, 0f, 0f, "no samples") } var sumAx = 0f; var sumAy = 0f; var sumAz = 0f var sumGx = 0f; var sumGy = 0f; var sumGz = 0f for (s in samples) { sumAx += s.ax; sumAy += s.ay; sumAz += s.az sumGx += s.gx; sumGy += s.gy; sumGz += s.gz } val n = samples.size val ax = sumAx / n; val ay = sumAy / n; val az = sumAz / n val gx = sumGx / n; val gy = sumGy / n; val gz = sumGz / n val accMag = sqrt(ax * ax + ay * ay + az * az) val gyroMag = sqrt(gx * gx + gy * gy + gz * gz) // Pitch 는 acc gate 통과 여부와 무관하게 항상 계산 (walking detector 가 사용). val pitchPre = (atan(ax / sqrt(ay * ay + az * az)) * 180.0 / Math.PI).toFloat() if (accMag < ACC_MAG_MIN || accMag > ACC_MAG_MAX) { return Result(Posture.UNKNOWN, pitchPre, accMag, gyroMag, "acc mag out of range (%.2f)".format(accMag)) } // Gyro gate 제거 — bias 큰 디바이스에서 영구 fail 문제 (위 주석 참조) val absPitch = abs(pitchPre) val thr = GreenZoneConstants.pitchLyingThreshold // Pitch 의미: AX 가 중력과 일치할수록 큰 값 (≈90°), AZ 가 중력과 일치하면 작은 값 (≈0°). // 사용자 직관: 센서 평평하게 둠 (AZ up) = LYING, 세움 (AX up) = NON_LYING (upright). // → 작은 pitch → LYING, 큰 pitch → NON_LYING val posture = if (absPitch <= thr) Posture.LYING else Posture.NON_LYING return Result(posture, pitchPre, accMag, gyroMag, "ok") } }