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>
This commit is contained in:
2026-06-23 17:17:58 +09:00
parent 6a06452d7e
commit af469558e6
6 changed files with 266 additions and 40 deletions
@@ -28,6 +28,9 @@ object GreenZoneConstants {
@Volatile var placementGuideMode: PlacementGuideMode = PlacementGuideMode.SIMPLE
@Volatile var alignmentAlgo: AlignmentAlgo = AlignmentAlgo.V1 // dev 토글 — default V1 (legacy)
/** Posture pitch 임계값 (deg). |pitch| > 이 값 → LYING, ≤ 이 값 → NON_LYING. dev 모드에서 조정 가능. */
@Volatile var pitchLyingThreshold: Float = 25f
// ═══════════════════════════════════════════════════════════
// 신호 범위
// ═══════════════════════════════════════════════════════════
@@ -1,19 +1,17 @@
/*
* Posture classifier — flowchart `Posture classification [Flow chart]` 기반 (2026-06-22).
* 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. 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` 로 해석.
* 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
@@ -22,7 +20,7 @@ import kotlin.math.abs
import kotlin.math.atan
import kotlin.math.sqrt
enum class Posture { UNKNOWN, SUPINE, SITTING, STANDING }
enum class Posture { UNKNOWN, LYING, NON_LYING }
object ImuPostureClassifier {
@@ -31,17 +29,13 @@ object ImuPostureClassifier {
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"
val reason: String,
)
fun classify(samples: List<ImuSample>): Result {
@@ -49,7 +43,6 @@ object ImuPostureClassifier {
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) {
@@ -63,28 +56,20 @@ object ImuPostureClassifier {
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)
val thr = GreenZoneConstants.pitchLyingThreshold
// 4. 분류
val posture = when {
absPitch < PITCH_SUPINE_MAX -> Posture.SUPINE
absPitch <= PITCH_STANDING_MIN -> Posture.SITTING
else -> Posture.STANDING
}
val posture = if (absPitch > thr) Posture.LYING else Posture.NON_LYING
return Result(posture, pitch, accMag, gyroMag, "ok")
}
}
@@ -0,0 +1,67 @@
/*
* WakeupDetector — LYING → NON_LYING 전이 감지 (낙상 예방용 기상 알람).
*
* 로직:
* - 사용자가 LYING 상태로 들어가면 startTimestamp 기록
* - LYING 으로 lyingDurationSec(default 180) 이상 유지 후 NON_LYING 으로 전환 → 기상 인정
* - 기상 인정 시 onWakeup(elapsedSec) 콜백 호출 (한 번)
* - 알람 발동 후 다시 LYING 으로 돌아가야 재무장 (재발동 가능)
*
* UNKNOWN 상태는 무시 (직전 상태 유지). 자세 분류 게이트 실패 시 잠깐 ?_ 떠도 detector 영향 X.
*
* 사용:
* val detector = WakeupDetector(lyingDurationSec = 180)
* detector.onWakeup = { elapsedSec -> showAlarm() }
* // 매 posture 갱신 시:
* detector.update(currentPosture, System.currentTimeMillis())
*/
package com.example.medilightv2android.managers
class WakeupDetector(
/** 알람 발동을 위해 직전 LYING 상태로 유지되어야 하는 최소 시간 (초). */
var lyingDurationSec: Int = 180,
) {
/** 일어남 감지 시 호출 — argument 는 직전 LYING 유지 시간 (초). */
var onWakeup: ((elapsedSec: Long) -> Unit)? = null
private var lastPosture: Posture = Posture.UNKNOWN
private var lyingStartMs: Long = 0L // 현재 LYING streak 시작 시각 (0 = LYING 아님)
private var armed: Boolean = true // false 면 알람 1회 발동 후 LYING 복귀 대기
/** 외부 reset (Settings 토글 변경 등). */
fun reset() {
lastPosture = Posture.UNKNOWN
lyingStartMs = 0L
armed = true
}
/** 현재 자세 + 현재 시각으로 갱신. UNKNOWN 은 직전 상태 유지. */
fun update(posture: Posture, nowMs: Long = System.currentTimeMillis()) {
if (posture == Posture.UNKNOWN) return
val prev = lastPosture
lastPosture = posture
when {
// LYING 진입 — start timestamp 기록 + 재무장
posture == Posture.LYING && prev != Posture.LYING -> {
lyingStartMs = nowMs
armed = true
}
// LYING 유지 — 그대로
posture == Posture.LYING -> { /* keep streak */ }
// LYING → NON_LYING 전이 — 알람 판정
posture == Posture.NON_LYING && prev == Posture.LYING -> {
val elapsedMs = nowMs - lyingStartMs
val elapsedSec = elapsedMs / 1000
if (armed && lyingStartMs > 0 && elapsedSec >= lyingDurationSec) {
armed = false
onWakeup?.invoke(elapsedSec)
}
lyingStartMs = 0L
}
// NON_LYING 유지 — 그대로
else -> { /* nothing */ }
}
}
}