feat(posture): Phase 1 — flowchart 기반 자세 분류 + 도넛 우상단 아이콘 (demo-final)

allignment flowchart "Posture classification" 알고리즘 적용. Phase 1
범위: 자세 분류 + 화면 표시만 (알람/감지 없음).

1) ImuPostureClassifier.kt 신규
   flowchart 단계 그대로:
   - Acc magnitude 게이트: 0.8 ≤ |a| ≤ 1.2 (motion/bad reject)
   - Gyro magnitude 게이트: |g| ≤ 9.16 dps (회전 reject)
   - Pitch = atan(ax / sqrt(ay² + az²)) × 180/π
   - 분류:
     · |pitch| < 25°  → SUPINE
     · 25-70°         → SITTING
     · > 70°          → STANDING
   - 게이트 실패 시 UNKNOWN + 사유 반환
   - 입력: ImuSample 리스트 (한 응답 15 sample 평균)
   ※ flowchart 의 "|pitch| > 25; supine" 는 typo 로 판단 → < 25 적용

2) PiezoMonitoringView IMU polling 추가
   - 5초 주기로 sendImuQuery() → rim: 응답 → classifier
   - UNKNOWN 결과 시 직전 자세 유지 (artifact 흔들림 방지)
   - demo mode: SUPINE 으로 fix (BLE 없음)

3) 도넛 차트 우상단 자세 아이콘 표시 (Battery 옆)
   - SUPINE   → Icons.Hotel (파랑)
   - SITTING  → Icons.AirlineSeatReclineNormal (주황)
   - STANDING → Icons.AccessibilityNew (초록)
   - UNKNOWN  → Icons.HelpOutline (회색)
   - 아이콘 + 텍스트 라벨 (Lying/Sitting/Standing/—)

Phase 2 (다음): Settings 토글 + wake-up state machine + 빨간 팝업.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 12:24:22 +09:00
parent 1fc1a20bb9
commit cf476971a5
2 changed files with 148 additions and 0 deletions
@@ -0,0 +1,90 @@
/*
* Posture classifier — flowchart `Posture classification [Flow chart]` 기반 (2026-06-22).
*
* 단계:
* 1. Acc magnitude 게이트: 0.8 ≤ |a| ≤ 1.2 → motion/bad sample 거부
* 2. Pitch 계산: atan(ax / sqrt(ay² + az²)) × 180/π
* 3. Gyro magnitude 게이트: |g| ≤ 9.16 dps → 회전 중 sample 거부
* 4. 자세 분류 (|pitch| 기준):
* < 25° → SUPINE (누움)
* 25-70° → SITTING
* > 70° → STANDING
*
* 입력: ImuSample 15개 (한 응답 = 300ms 분량). 각 sample 의 평균에서 분류.
* 출력: Posture (UNKNOWN, SUPINE, SITTING, STANDING).
*
* flowchart 의 `|pitch| > 25; supine` 는 typo 로 판단 — `< 25` 로 해석.
*/
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, SUPINE, SITTING, STANDING }
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
// 자세 분류 임계값 (deg)
private const val PITCH_SUPINE_MAX = 25f
private const val PITCH_STANDING_MIN = 70f
/** 자세 + 진단 정보 (UI/log용). */
data class Result(
val posture: Posture,
val pitchDeg: Float,
val accMag: Float,
val gyroMagDps: Float,
val reason: String, // why UNKNOWN, or "ok"
)
fun classify(samples: List<ImuSample>): Result {
if (samples.isEmpty()) {
return Result(Posture.UNKNOWN, 0f, 0f, 0f, "no samples")
}
// sample 평균
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)
// 1. Acc magnitude 게이트
if (accMag < ACC_MAG_MIN || accMag > ACC_MAG_MAX) {
return Result(Posture.UNKNOWN, 0f, accMag, gyroMag,
"acc mag out of range (%.2f)".format(accMag))
}
// 3. Gyro magnitude 게이트 (motion 거부)
if (gyroMag > GYRO_MAG_MAX_DPS) {
return Result(Posture.UNKNOWN, 0f, accMag, gyroMag,
"moving (gyro %.1f dps)".format(gyroMag))
}
// 2. Pitch 계산
val pitch = (atan(ax / sqrt(ay * ay + az * az)) * 180.0 / Math.PI).toFloat()
val absPitch = abs(pitch)
// 4. 분류
val posture = when {
absPitch < PITCH_SUPINE_MAX -> Posture.SUPINE
absPitch <= PITCH_STANDING_MIN -> Posture.SITTING
else -> Posture.STANDING
}
return Result(posture, pitch, accMag, gyroMag, "ok")
}
}