diff --git a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt index 9f28335..db6ad9e 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt @@ -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 + // ═══════════════════════════════════════════════════════════ // 신호 범위 // ═══════════════════════════════════════════════════════════ diff --git a/app/src/main/java/com/example/medilightv2android/managers/ImuPostureClassifier.kt b/app/src/main/java/com/example/medilightv2android/managers/ImuPostureClassifier.kt index cbf40e4..fbfcbdd 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/ImuPostureClassifier.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/ImuPostureClassifier.kt @@ -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 { diff --git a/app/src/main/java/com/example/medilightv2android/managers/WalkingDetector.kt b/app/src/main/java/com/example/medilightv2android/managers/WalkingDetector.kt new file mode 100644 index 0000000..0fbe542 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/managers/WalkingDetector.kt @@ -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() + 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, 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)") + } + } + } +} diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index 4e5673b..a13c319 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -130,10 +130,12 @@ fun PiezoMonitoringView(appState: AppState) { var lastAllWalls by remember { mutableStateOf?>>(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))