diff --git a/app/src/main/java/com/example/medilightv2android/services/PostureEventLogger.kt b/app/src/main/java/com/example/medilightv2android/services/PostureEventLogger.kt new file mode 100644 index 0000000..e3153ac --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/services/PostureEventLogger.kt @@ -0,0 +1,109 @@ +/* + * PostureEventLogger — 자세/walking 알고리즘 검증용 CSV logger. + * + * 매 cycle 마다 분류 결과 + walking 상태 + 신호값 기록 + + * 사용자가 dev settings 의 annotation 버튼 누를 때 ground-truth marker 기록. + * + * 파일: Downloads/VesiScan_Posture/yyyy-MM-dd_HHmmss.csv + * 컬럼: + * timestamp_ms, type, posture, isWalking, pitch, wmean, wstd, accMag, gyroMag, + * alarm_event, annotation + * + * Row type: + * CYCLE — 매 cycle 의 자세/walking detector 결과 + * ALARM — 알람 발동 (wakeup / walking) + * ANNOTATION — 사용자가 "지금 OOO" 표시 버튼 누름 (ground truth) + * + * 사용: + * 1. logSession(context) 한 번 호출 (PiezoMonitoring 진입 시) — 파일 생성 + * 2. logCycle(...) 매 cycle 마다 호출 + * 3. logAlarm(...) 알람 발동 시 + * 4. annotate("WALK_START") 사용자가 버튼 누를 때 + * 5. closeSession() 종료 시 (선택) + * + * 분석: CSV → Excel / python pandas → ANNOTATION 과 CYCLE 의 walking 전이 대조. + */ +package com.example.medilightv2android.services + +import android.content.Context +import android.os.Environment +import java.io.File +import java.io.FileWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object PostureEventLogger { + + private val sessionFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US) + @Volatile private var sessionFile: File? = null + + val currentFile: File? get() = sessionFile + + private fun getDir(): File { + val dir = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + "VesiScan_Posture" + ) + if (!dir.exists()) dir.mkdirs() + return dir + } + + /** 새 세션 파일 생성 + 헤더 작성. */ + fun startSession(@Suppress("UNUSED_PARAMETER") context: Context): File { + val f = File(getDir(), "${sessionFmt.format(Date())}.csv") + try { + FileWriter(f, false).buffered().use { w -> + w.write("timestamp_ms,type,posture,isWalking,pitch,wmean,wstd,accMag,gyroMag,alarm_event,annotation") + w.newLine() + } + } catch (_: Exception) {} + sessionFile = f + return f + } + + /** Cycle 의 자세 분류 결과 기록. */ + fun logCycle( + nowMs: Long, + posture: String, + isWalking: Boolean, + pitch: Float, + wmean: Float, + wstd: Float, + accMag: Float, + gyroMag: Float, + ) { + appendRow( + nowMs, "CYCLE", posture, isWalking.toString(), + pitch, wmean, wstd, accMag, gyroMag, "", "" + ) + } + + /** 알람 발동 (wakeup / walking) 기록. */ + fun logAlarm(nowMs: Long, alarmEvent: String) { + appendRow(nowMs, "ALARM", "", "", 0f, 0f, 0f, 0f, 0f, alarmEvent, "") + } + + /** 사용자 ground-truth annotation 기록 (예: "WALK_START"). */ + fun annotate(label: String, nowMs: Long = System.currentTimeMillis()) { + appendRow(nowMs, "ANNOTATION", "", "", 0f, 0f, 0f, 0f, 0f, "", label) + } + + fun closeSession() { + sessionFile = null + } + + private fun appendRow( + ts: Long, type: String, posture: String, isWalking: String, + pitch: Float, wmean: Float, wstd: Float, accMag: Float, gyroMag: Float, + alarm: String, annotation: String + ) { + val f = sessionFile ?: return + try { + FileWriter(f, true).buffered().use { w -> + w.write("$ts,$type,$posture,$isWalking,${"%.2f".format(pitch)},${"%.2f".format(wmean)},${"%.2f".format(wstd)},${"%.3f".format(accMag)},${"%.2f".format(gyroMag)},$alarm,$annotation") + w.newLine() + } + } catch (_: Exception) {} + } +} 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 6c8a5cc..a98aad3 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 @@ -145,6 +145,10 @@ fun PiezoMonitoringView(appState: AppState) { wakeupAlarmElapsedSec = elapsed showWakeupAlarm = true } + com.example.medilightv2android.services.PostureEventLogger.logAlarm( + System.currentTimeMillis(), + "wakeup_alarm_lying_${elapsed}s" + ) } } } @@ -707,6 +711,11 @@ fun PiezoMonitoringView(appState: AppState) { } } + // Posture event 로깅 — 검증용 CSV (앱 진입 시 새 파일) + LaunchedEffect(Unit) { + com.example.medilightv2android.services.PostureEventLogger.startSession(context) + } + // IMU polling + 자세 분류 (Phase 1) — 10초 주기로 mtb 명령 → rim: 응답 받아 분류. // msp 가 펌웨어에서 rsp: 응답 안 보내는 듯해 mtb (alignment) 로 우회. // mtb 는 piezo measurement 도 트리거하므로 piezoCollector.skipNextComplete 로 @@ -746,9 +755,19 @@ fun PiezoMonitoringView(appState: AppState) { if (com.example.medilightv2android.services.WakeupAlarmPrefs.isEnabled(context)) { showWalkingAlarm = true } + com.example.medilightv2android.services.PostureEventLogger.logAlarm(nowMs, "walking_start") + } + if (!isWalkingNow && prevIsWalking) { + com.example.medilightv2android.services.PostureEventLogger.logAlarm(nowMs, "walking_end") } prevIsWalking = isWalkingNow + // CSV logging — 검증용 + com.example.medilightv2android.services.PostureEventLogger.logCycle( + nowMs, composite.name, isWalkingNow, + r.pitchDeg, walkSnap.wmean, walkSnap.wstd, r.accMag, r.gyroMagDps, + ) + 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)} " + @@ -2055,6 +2074,43 @@ fun PiezoMonitoringView(appState: AppState) { Slider(value = accMagMax, onValueChange = { accMagMax = it; GreenZoneConstants.postureAccMagMax = it }, valueRange = 1.0f..3.0f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)) + + HorizontalDivider(modifier = Modifier.padding(vertical = dividerPadding)) + + // Verification annotation buttons — ground truth marker. + // 누르는 순간 timestamp 가 CSV 에 ANNOTATION row 로 기록 → 알고리즘 detection 과 대조. + // 파일: Downloads/VesiScan_Posture/yyyy-MM-dd_HHmmss.csv + Text("Verification (Ground Truth)", fontSize = labelSize, + fontWeight = FontWeight.SemiBold, color = MlPrimary) + Text(" 현재 동작 시작 직전에 누르세요 → CSV 에 기록", + fontSize = 11.sp, color = MlSecondaryText) + + val annotateLabels = listOf( + "LIE" to "누움", + "STAND" to "일어남", + "WALK↑" to "걷기시작", + "WALK↓" to "걷기멈춤", + "SIT" to "앉음", + ) + Row(modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp)) { + for ((tag, label) in annotateLabels) { + Button( + onClick = { + com.example.medilightv2android.services.PostureEventLogger.annotate(tag) + android.widget.Toast.makeText(context, + "Marked: $label", android.widget.Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.weight(1f).height(40.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(2.dp), + colors = ButtonDefaults.buttonColors(containerColor = MlPrimary.copy(alpha = 0.7f)) + ) { + Text(tag, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.White) + } + } + } + Text(" 파일: Downloads/VesiScan_Posture/*.csv", + fontSize = 10.sp, color = MlSecondaryText) } HorizontalDivider(modifier = Modifier.padding(vertical = dividerPadding))