feat(posture): Walking detector (flowchart Path B) + 2단계 알람

새 flowchart 의 walking 검출 추가:

1) Posture enum: UNKNOWN/LYING/NON_LYING + **WALKING** (4-state)

2) WalkingDetector.kt 신규 (flowchart Path B 구현)
   - Sliding window (default 1.5초) buffer 에 gyro magnitude 누적
   - 매 cycle 마다 wmean (이동평균) + wstd (이동표준편차) 계산
   - State machine:
     IDLE → WALKING:
       |pitch| > 45° AND wmean > 4 AND wstd > 2
       → 1초 이상 연속 만족 시 전이
     WALKING → IDLE:
       |pitch| > 45° AND wmean > 2
       → 미달 후 2초 grace 지나면 전이

3) GreenZoneConstants 에 walking 임계 7개 추가 (모두 dev 조정 가능):
   - walkingMinPitchDeg=45, walkingStartWmean=4, walkingStartWstd=2,
     walkingStartHoldMs=1000, walkingKeepWmean=2, walkingKeepHoldMs=2000,
     walkingWindowMs=1500

4) PiezoMonitoring:
   - walkingDetector 인스턴스
   - imuCollector.onComplete: classifier + detector 동시 호출,
     composite posture (WALKING > NON_LYING > LYING) 결정
   - NON_WALKING → WALKING 전이 시 critical 알람 trigger

5) UI:
   - 상단 status bar: Walking 아이콘 (DirectionsWalk, 빨강) 추가
   - 기존 wakeup 팝업 색: 빨강 → 주황 (warning level)
   - 신규 walking 팝업: 빨강 + DirectionsWalk + "환자가 걷고 있습니다 — fall risk"

6) Dev settings: walking 임계 슬라이더 4개 추가
   (Min Pitch / Start Wmean / Start Wstd / Keep Wmean)
   sliding window / hold 시간은 default 유지 (필요 시 추가)

2단계 알람 체계 (의료적):
  - 주황 (warning): LYING N초 → NON_LYING/WALKING 전이 (일어남 감지)
  - 빨강 (critical): NON_WALKING → WALKING 전이 (보행 시작 — fall risk 최고)

Tag phase2-pre-walking (28a8769) 이전 상태로 복원 가능.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 10:28:43 +09:00
parent 28a8769fa3
commit ecdec7c97c
4 changed files with 289 additions and 11 deletions
@@ -34,6 +34,22 @@ object GreenZoneConstants {
/** Posture polling 간격 (ms). PiezoMonitoring 에서 mtb 보내는 주기. dev 모드에서 조정 가능. */
@Volatile var posturePollIntervalMs: Long = 1000L
// ── Walking detector 임계값 (flowchart 4-2 / 5-2) ─────────────────
/** Walking 인정 최소 pitch (deg). |pitch| > 이 값 이라야 walking 후보. */
@Volatile var walkingMinPitchDeg: Float = 45f
/** Walking 시작 — gyro 이동평균 (wmean) 임계 (dps). */
@Volatile var walkingStartWmean: Float = 4f
/** Walking 시작 — gyro 이동표준편차 (wstd) 임계 (dps). */
@Volatile var walkingStartWstd: Float = 2f
/** Walking 시작 — 두 임계 모두 만족한 채 지속해야 하는 최소 시간 (ms). */
@Volatile var walkingStartHoldMs: Long = 1000L
/** Walking 유지 — gyro wmean 임계 (dps). 시작보다 낮음. */
@Volatile var walkingKeepWmean: Float = 2f
/** Walking 유지 — wmean 임계 미달 후 종료까지 grace 시간 (ms). 미만 지속이 이 값 넘으면 walking 종료. */
@Volatile var walkingKeepHoldMs: Long = 2000L
/** Walking detector sliding window (ms). 이 시간 분량 sample 평균/표준편차. */
@Volatile var walkingWindowMs: Long = 1500L
// ═══════════════════════════════════════════════════════════
// 신호 범위
// ═══════════════════════════════════════════════════════════
@@ -20,7 +20,7 @@ import kotlin.math.abs
import kotlin.math.atan
import kotlin.math.sqrt
enum class Posture { UNKNOWN, LYING, NON_LYING }
enum class Posture { UNKNOWN, LYING, NON_LYING, WALKING }
object ImuPostureClassifier {
@@ -0,0 +1,139 @@
/*
* WalkingDetector — flowchart Path B (4-2 / 5-2) 구현.
*
* 알고리즘:
* 1. Sliding window (walkingWindowMs, default 1.5초) 동안 IMU gyro sample 누적
* 2. window 의 gyro magnitude 평균 (wmean) 과 표준편차 (wstd) 계산
* 3. 상태 머신:
* IDLE (not walking):
* 조건: |pitch| > walkingMinPitchDeg
* AND wmean > walkingStartWmean (default 4)
* AND wstd > walkingStartWstd (default 2)
* → 조건 만족이 walkingStartHoldMs (default 1초) 이상 지속 → WALKING
* WALKING:
* 조건: |pitch| > walkingMinPitchDeg
* AND wmean > walkingKeepWmean (default 2)
* → 조건 미달이 walkingKeepHoldMs (default 2초) 이상 지속 → IDLE
*
* 입력: 매 mtb cycle (≈1초 주기) 마다 IMU sample 리스트 + 현재 pitch + 시각
* 출력: 현재 walking 상태 (Boolean) + 진단 정보
*/
package com.example.medilightv2android.managers
import com.example.medilightv2android.ble.ImuSample
import kotlin.math.abs
import kotlin.math.sqrt
class WalkingDetector {
/** 진단 결과. UI/log 용. */
data class Snapshot(
val isWalking: Boolean,
val wmean: Float,
val wstd: Float,
val pitchOk: Boolean,
val samplesInWindow: Int,
val reason: String,
)
private data class TsGyro(val tsMs: Long, val magDps: Float)
private val buffer = ArrayDeque<TsGyro>()
private var walking = false
/** IDLE 에서 시작 조건 처음 만족된 시각. null 이면 미만족 / 끊김. */
private var startCondSinceMs: Long? = null
/** WALKING 에서 유지 조건 마지막 만족 시각. */
private var keepCondLastOkMs: Long? = null
val isWalking: Boolean get() = walking
fun reset() {
buffer.clear()
walking = false
startCondSinceMs = null
keepCondLastOkMs = null
}
/**
* 매 cycle 마다 호출. samples = 이번 cycle 에 들어온 IMU sample 리스트.
* pitchDeg = 같은 cycle 의 pitch (ImuPostureClassifier 가 계산한 값).
*/
fun update(samples: List<ImuSample>, pitchDeg: Float, nowMs: Long): Snapshot {
// 1) sample buffer 에 추가 (timestamp 단순 분배 — cycle 내 균등 간격 가정)
val windowMs = GreenZoneConstants.walkingWindowMs
if (samples.isNotEmpty()) {
// cycle 내 각 sample 의 timestamp 를 nowMs 기준으로 역산 (≈ ICM ODR 50Hz → 20ms 간격)
val sampleGapMs = 20L
val startTs = nowMs - (samples.size - 1) * sampleGapMs
for ((i, s) in samples.withIndex()) {
val mag = sqrt(s.gx * s.gx + s.gy * s.gy + s.gz * s.gz)
buffer.addLast(TsGyro(startTs + i * sampleGapMs, mag))
}
}
// 2) window 밖 sample 제거
val cutoff = nowMs - windowMs
while (buffer.isNotEmpty() && buffer.first().tsMs < cutoff) buffer.removeFirst()
// 3) wmean, wstd 계산
val n = buffer.size
if (n < 2) {
return Snapshot(walking, 0f, 0f, false, n, "buffer too small")
}
var sum = 0f
for (b in buffer) sum += b.magDps
val wmean = sum / n
var sqSum = 0f
for (b in buffer) {
val d = b.magDps - wmean
sqSum += d * d
}
val wstd = sqrt(sqSum / n)
// 4) state machine
val minPitch = GreenZoneConstants.walkingMinPitchDeg
val pitchOk = abs(pitchDeg) > minPitch
if (!walking) {
// IDLE → WALKING 검사 (4-2)
val cond = pitchOk &&
wmean > GreenZoneConstants.walkingStartWmean &&
wstd > GreenZoneConstants.walkingStartWstd
if (cond) {
if (startCondSinceMs == null) startCondSinceMs = nowMs
val held = nowMs - startCondSinceMs!!
if (held >= GreenZoneConstants.walkingStartHoldMs) {
walking = true
keepCondLastOkMs = nowMs
return Snapshot(true, wmean, wstd, pitchOk, n,
"WALKING start (held ${held}ms)")
}
return Snapshot(false, wmean, wstd, pitchOk, n,
"walking start cond met for ${held}ms")
} else {
startCondSinceMs = null
return Snapshot(false, wmean, wstd, pitchOk, n,
"idle (pitchOk=$pitchOk wmean=${"%.1f".format(wmean)} wstd=${"%.1f".format(wstd)})")
}
} else {
// WALKING → IDLE 검사 (5-2)
val cond = pitchOk && wmean > GreenZoneConstants.walkingKeepWmean
if (cond) {
keepCondLastOkMs = nowMs
return Snapshot(true, wmean, wstd, pitchOk, n,
"WALKING keep (wmean=${"%.1f".format(wmean)})")
} else {
// 조건 미달 — grace 시간 체크
val sinceOk = nowMs - (keepCondLastOkMs ?: nowMs)
if (sinceOk >= GreenZoneConstants.walkingKeepHoldMs) {
walking = false
startCondSinceMs = null
keepCondLastOkMs = null
return Snapshot(false, wmean, wstd, pitchOk, n,
"WALKING end (grace ${sinceOk}ms exceeded)")
}
return Snapshot(true, wmean, wstd, pitchOk, n,
"WALKING grace (${sinceOk}ms / ${GreenZoneConstants.walkingKeepHoldMs}ms)")
}
}
}
}