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:
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user