Files
VesiscanClinicalAndroid/app/src/main/java/com/example/medilightv2android/managers/ImuPostureClassifier.kt
T
dw.jang af469558e6 feat(posture): Phase 2 — 기상 알람 (LYING/NON_LYING + Settings 토글 + 빨간 팝업)
요구사항: 설정 탭에서 알람 on/off → on 상태에서 일어남 감지 시 빨간 팝업.

변경:

1) ImuPostureClassifier 2-state 단순화
   - 기존: SUPINE/SITTING/STANDING (3 state)
   - 신규: LYING / NON_LYING (2 state) + UNKNOWN
   - sitting/standing 구분 불필요 (낙상 예방엔 "누웠나/일어났나" 만 중요)
   - 분류: |pitch| > threshold → LYING, ≤ → NON_LYING

2) GreenZoneConstants.pitchLyingThreshold (default 25°)
   - @Volatile var — runtime 변경 가능
   - dev 모드 설정에서 slider 로 조정 (10~70°, 11 steps)

3) WakeupAlarmPrefs — SharedPreferences 저장
   - isEnabled (default false)
   - lyingDurationSec (default 180 = 3분)

4) WakeupDetector — state machine
   - LYING 진입 시 timestamp 기록
   - LYING → NON_LYING 전이 시 elapsed 측정
   - elapsed ≥ lyingDurationSec 이면 onWakeup 콜백
   - 1회 발동 후 LYING 복귀까지 재무장 안 됨 (스팸 방지)
   - UNKNOWN 은 무시 (motion artifact 흔들림 영향 X)

5) PiezoMonitoringView
   - posture 아이콘 매핑 LYING (Hotel, 파랑) / NON_LYING (AccessibilityNew, 초록) / UNKNOWN
   - imuCollector.onComplete 콜백에서 wakeupDetector.update(posture) 호출
   - showWakeupAlarm Boolean state + 빨간 Dialog (Color 0xFFD32F2F)
     · Warning 아이콘 + "환자가 일어났습니다" 메세지
     · 누운 시간 표시
     · 확인 버튼 (사용자 dismiss 필수, 자동 닫힘 X)
     · dismissOnBackPress/ClickOutside = false (실수 dismiss 방지)
   - Demo 모드: posture LYING 고정 (BLE 없음)

6) PiezoPersonalizationView — WakeupAlarmCard 신규
   - 빨간 톤 카드 + Switch
   - "기상 알람 — 환자가 일어나면 빨간 팝업으로 알림 (낙상 예방)"
   - 토글 즉시 WakeupAlarmPrefs.setEnabled

7) Dev 설정 (PiezoMonitoringView 의 dev panel)
   - "Pitch Threshold" 슬라이더 추가 (10°~70°)
   - GreenZoneConstants.pitchLyingThreshold 직접 조정
   - 실시간 반영 — 다음 cycle 부터 적용

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 17:17:58 +09:00

76 lines
2.8 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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 }
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
/** 자세 + 진단 정보 (UI/log용). */
data class Result(
val posture: Posture,
val pitchDeg: Float,
val accMag: Float,
val gyroMagDps: Float,
val reason: String,
)
fun classify(samples: List<ImuSample>): 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)
if (accMag < ACC_MAG_MIN || accMag > ACC_MAG_MAX) {
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))
}
val pitch = (atan(ax / sqrt(ay * ay + az * az)) * 180.0 / Math.PI).toFloat()
val absPitch = abs(pitch)
val thr = GreenZoneConstants.pitchLyingThreshold
val posture = if (absPitch > thr) Posture.LYING else Posture.NON_LYING
return Result(posture, pitch, accMag, gyroMag, "ok")
}
}