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)")
}
}
}
}
@@ -130,10 +130,12 @@ fun PiezoMonitoringView(appState: AppState) {
var lastAllWalls by remember { mutableStateOf<List<Pair<Int, Int>?>>(List(6) { null }) }
val analyzer = remember { PiezoEchoAnalyzer.shared }
// Posture (flowchart-based, IMU polling — Phase 1) + Wakeup detector (Phase 2)
// Posture (flowchart-based, IMU polling — Phase 1) + Wakeup/Walking detector (Phase 2)
var currentPosture by remember { mutableStateOf(com.example.medilightv2android.managers.Posture.UNKNOWN) }
var showWakeupAlarm by remember { mutableStateOf(false) }
var showWakeupAlarm by remember { mutableStateOf(false) } // 주황 — LYING → NON_LYING/WALKING
var wakeupAlarmElapsedSec by remember { mutableStateOf(0L) }
var showWalkingAlarm by remember { mutableStateOf(false) } // 빨강 — NON_LYING → WALKING
val walkingDetector = remember { com.example.medilightv2android.managers.WalkingDetector() }
val wakeupDetector = remember {
com.example.medilightv2android.managers.WakeupDetector(
lyingDurationSec = com.example.medilightv2android.services.WakeupAlarmPrefs.lyingDurationSec(context)
@@ -146,10 +148,12 @@ fun PiezoMonitoringView(appState: AppState) {
}
}
}
// Wakeup alarm 빨간 팝업 — 사용자 dismiss 필요 (자동 닫힘 X)
// 직전 walking 상태 추적 — false → true 전이 시 critical 알람
var prevIsWalking by remember { mutableStateOf(false) }
// Wakeup alarm (warning, 주황) — LYING → NON_LYING/WALKING 전이
if (showWakeupAlarm) {
androidx.compose.ui.window.Dialog(
onDismissRequest = { /* not dismissible by outside tap */ },
onDismissRequest = { },
properties = androidx.compose.ui.window.DialogProperties(
dismissOnBackPress = false, dismissOnClickOutside = false
)
@@ -158,7 +162,7 @@ fun PiezoMonitoringView(appState: AppState) {
modifier = Modifier
.fillMaxWidth(0.92f)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xFFD32F2F))
.background(Color(0xFFFF9800)) // Material Orange 500
.padding(horizontal = 28.dp, vertical = 36.dp),
contentAlignment = Alignment.Center
) {
@@ -181,6 +185,54 @@ fun PiezoMonitoringView(appState: AppState) {
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { showWakeupAlarm = false },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White, contentColor = Color(0xFFFF9800)
),
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(14.dp)
) {
Text("확인", fontSize = 20.sp, fontWeight = FontWeight.Bold)
}
}
}
}
}
// Walking alarm (critical, 빨강) — NON_LYING/LYING → WALKING 전이 (보행 시작)
if (showWalkingAlarm) {
androidx.compose.ui.window.Dialog(
onDismissRequest = { },
properties = androidx.compose.ui.window.DialogProperties(
dismissOnBackPress = false, dismissOnClickOutside = false
)
) {
Box(
modifier = Modifier
.fillMaxWidth(0.94f)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xFFD32F2F))
.padding(horizontal = 28.dp, vertical = 36.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.DirectionsWalk, contentDescription = null,
tint = Color.White, modifier = Modifier.size(64.dp))
Spacer(modifier = Modifier.height(12.dp))
Text("환자가 걷고 있습니다", fontSize = 30.sp,
fontWeight = FontWeight.ExtraBold, color = Color.White,
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(6.dp))
Text("Patient is walking — fall risk",
fontSize = 16.sp, fontWeight = FontWeight.Medium,
color = Color.White.copy(alpha = 0.92f),
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(8.dp))
Text("즉시 환자 상태 확인 필요",
fontSize = 14.sp, color = Color.White.copy(alpha = 0.9f),
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { showWalkingAlarm = false },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White, contentColor = Color(0xFFD32F2F)
),
@@ -668,16 +720,39 @@ fun PiezoMonitoringView(appState: AppState) {
var unknownStreak = 0
bleManager.imuCollector.onComplete = { samples ->
val r = com.example.medilightv2android.managers.ImuPostureClassifier.classify(samples)
if (r.posture != com.example.medilightv2android.managers.Posture.UNKNOWN) {
currentPosture = r.posture
wakeupDetector.update(r.posture)
val nowMs = System.currentTimeMillis()
// Walking detector — pitch 가 valid 일 때만 의미. acc gate fail 시 그대로 update (wmean/wstd 계산 + 시간 grace 처리).
val walkSnap = walkingDetector.update(samples, r.pitchDeg, nowMs)
val isWalkingNow = walkSnap.isWalking
// Composite posture — WALKING 이 NON_LYING 보다 우선
val composite = when {
r.posture == com.example.medilightv2android.managers.Posture.UNKNOWN -> com.example.medilightv2android.managers.Posture.UNKNOWN
isWalkingNow -> com.example.medilightv2android.managers.Posture.WALKING
else -> r.posture // LYING or NON_LYING
}
if (composite != com.example.medilightv2android.managers.Posture.UNKNOWN) {
currentPosture = composite
wakeupDetector.update(composite)
unknownStreak = 0
} else {
unknownStreak++
// 3회 연속 UNKNOWN 이면 화면에 표시 (silent stuck 방지)
if (unknownStreak >= 3) currentPosture = com.example.medilightv2android.managers.Posture.UNKNOWN
}
Log.d("ImuDbg", "POSTURE ${r.posture} pitch=${"%.1f".format(r.pitchDeg)}° accMag=${"%.2f".format(r.accMag)}g gyro=${"%.1f".format(r.gyroMagDps)}dps (${r.reason}) samples=${samples.size} unknownStreak=$unknownStreak")
// Walking 시작 전이 (NON_WALKING → WALKING) — critical 알람 (alarm enabled 시)
if (isWalkingNow && !prevIsWalking) {
if (com.example.medilightv2android.services.WakeupAlarmPrefs.isEnabled(context)) {
showWalkingAlarm = true
}
}
prevIsWalking = isWalkingNow
Log.d("ImuDbg", "POSTURE ${r.posture}→$composite pitch=${"%.1f".format(r.pitchDeg)}° " +
"accMag=${"%.2f".format(r.accMag)}g gyro=${"%.1f".format(r.gyroMagDps)}dps | " +
"WALK wmean=${"%.1f".format(walkSnap.wmean)} wstd=${"%.1f".format(walkSnap.wstd)} " +
"n=${walkSnap.samplesInWindow} (${walkSnap.reason})")
}
bleManager.imuCollector.onLog = { msg ->
Log.d("ImuDbg", "imuCollector log: $msg")
@@ -1024,16 +1099,19 @@ fun PiezoMonitoringView(appState: AppState) {
val postureIcon = when (currentPosture) {
com.example.medilightv2android.managers.Posture.LYING -> Icons.Default.Hotel
com.example.medilightv2android.managers.Posture.NON_LYING -> Icons.Default.AccessibilityNew
com.example.medilightv2android.managers.Posture.WALKING -> Icons.Default.DirectionsWalk
com.example.medilightv2android.managers.Posture.UNKNOWN -> Icons.Default.HelpOutline
}
val postureTint = when (currentPosture) {
com.example.medilightv2android.managers.Posture.LYING -> Color(0xFF2196F3)
com.example.medilightv2android.managers.Posture.NON_LYING -> Color(0xFF4CAF50)
com.example.medilightv2android.managers.Posture.WALKING -> Color(0xFFD32F2F)
com.example.medilightv2android.managers.Posture.UNKNOWN -> Color.Gray.copy(alpha = 0.5f)
}
val postureLabel = when (currentPosture) {
com.example.medilightv2android.managers.Posture.LYING -> "Lying"
com.example.medilightv2android.managers.Posture.NON_LYING -> "Upright"
com.example.medilightv2android.managers.Posture.WALKING -> "Walking"
com.example.medilightv2android.managers.Posture.UNKNOWN -> "—"
}
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -1876,6 +1954,51 @@ fun PiezoMonitoringView(appState: AppState) {
steps = 0,
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
)
HorizontalDivider(modifier = Modifier.padding(vertical = dividerPadding))
// Walking detector 임계값 (4개)
Text("Walking Thresholds", fontSize = labelSize, fontWeight = FontWeight.SemiBold, color = MlPrimary)
var walkPitchVal by remember { mutableStateOf(GreenZoneConstants.walkingMinPitchDeg) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(" Min Pitch", fontSize = 12.sp)
Spacer(Modifier.weight(1f))
Text("${walkPitchVal.toInt()}°", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
Slider(value = walkPitchVal, onValueChange = {
walkPitchVal = it; GreenZoneConstants.walkingMinPitchDeg = it
}, valueRange = 20f..70f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary))
var walkStartWmean by remember { mutableStateOf(GreenZoneConstants.walkingStartWmean) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(" Start Wmean", fontSize = 12.sp)
Spacer(Modifier.weight(1f))
Text("%.1f".format(walkStartWmean), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
Slider(value = walkStartWmean, onValueChange = {
walkStartWmean = it; GreenZoneConstants.walkingStartWmean = it
}, valueRange = 1f..20f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary))
var walkStartWstd by remember { mutableStateOf(GreenZoneConstants.walkingStartWstd) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(" Start Wstd", fontSize = 12.sp)
Spacer(Modifier.weight(1f))
Text("%.1f".format(walkStartWstd), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
Slider(value = walkStartWstd, onValueChange = {
walkStartWstd = it; GreenZoneConstants.walkingStartWstd = it
}, valueRange = 0.5f..10f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary))
var walkKeepWmean by remember { mutableStateOf(GreenZoneConstants.walkingKeepWmean) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(" Keep Wmean", fontSize = 12.sp)
Spacer(Modifier.weight(1f))
Text("%.1f".format(walkKeepWmean), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
Slider(value = walkKeepWmean, onValueChange = {
walkKeepWmean = it; GreenZoneConstants.walkingKeepWmean = it
}, valueRange = 0.5f..10f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary))
}
HorizontalDivider(modifier = Modifier.padding(vertical = dividerPadding))