Clinical: measurement.json 통합 저장 (사이클 누적 + End 시점 finalize)

ClinicalSessionStore에 MeasurementCycle 누적 버퍼 추가. End Session 시
6채널 piezo + 15 IMU 샘플을 단일 measurement.json으로 묶어 저장하여
분석 PC에서 한 줄(json.load) 로드 가능. 기존 CSV 로거는 유지.

ClinicalLiveView는 pendingPiezo 패턴으로 piezo(reb×6+raa)와
imu(rim)를 한 cycle로 묶어 addCycle() 호출.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 12:18:43 +09:00
parent 38fa93c8b2
commit 6f9ce00c65
2 changed files with 125 additions and 12 deletions
@@ -2,13 +2,24 @@ package com.example.medilightv2android.services
import android.content.Context import android.content.Context
import android.os.Environment import android.os.Environment
import com.example.medilightv2android.ble.ImuSample
import com.example.medilightv2android.ble.PiezoChannelData
import com.example.medilightv2android.models.ClinicalSession import com.example.medilightv2android.models.ClinicalSession
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
/** 한 측정 사이클 = mtb 응답 1회 = 6채널 piezo + 15 IMU 샘플. */
data class MeasurementCycle(
val cycle: Int,
val timestampMs: Long,
val piezoChannels: Map<Int, List<Int>>, // channel idx → samples
val imuSamples: List<ImuSample>
)
/** /**
* 임상 측정 단위(한 측정 = 한 라벨 = 한 폴더) 전역 store. * 임상 측정 단위(한 측정 = 한 라벨 = 한 폴더) 전역 store.
* *
@@ -41,6 +52,9 @@ object ClinicalSessionStore {
var lastTrueVolumeMl: String = "" // 문자열로 보관 (입력 폼과 동일 형식) var lastTrueVolumeMl: String = "" // 문자열로 보관 (입력 폼과 동일 형식)
var lastAbdomenThicknessMm: String = "" var lastAbdomenThicknessMm: String = ""
/** End 시점에 measurement.json 통합 생성용 — 측정 중 사이클 누적 */
private val cyclesBuffer = mutableListOf<MeasurementCycle>()
fun startMeasurement(context: Context, session: ClinicalSession) { fun startMeasurement(context: Context, session: ClinicalSession) {
currentSession = session currentSession = session
lastExaminer = session.examiner lastExaminer = session.examiner
@@ -48,10 +62,25 @@ object ClinicalSessionStore {
lastSubjectId = session.subjectId lastSubjectId = session.subjectId
lastTrueVolumeMl = session.trueVolumeMl?.toString() ?: "" lastTrueVolumeMl = session.trueVolumeMl?.toString() ?: ""
lastAbdomenThicknessMm = session.abdomenThicknessMm?.toString() ?: "" lastAbdomenThicknessMm = session.abdomenThicknessMm?.toString() ?: ""
cyclesBuffer.clear()
currentLogDir()?.mkdirs() currentLogDir()?.mkdirs()
writeMeta(context) writeMeta(context)
} }
/** 한 사이클(piezo + IMU) 누적 — End 시 measurement.json에 합쳐짐 */
fun addCycle(piezo: List<PiezoChannelData>, imu: List<ImuSample>) {
if (currentSession == null) return
cyclesBuffer.add(MeasurementCycle(
cycle = cyclesBuffer.size,
timestampMs = System.currentTimeMillis(),
piezoChannels = piezo.sortedBy { it.channel }
.associate { it.channel to it.buffer.map { s -> s.toInt() } },
imuSamples = imu.toList(),
))
}
val cycleCount: Int get() = cyclesBuffer.size
/** 현재 측정의 폴더 (= 모든 로거의 저장 디렉토리). */ /** 현재 측정의 폴더 (= 모든 로거의 저장 디렉토리). */
fun currentLogDir(): File? { fun currentLogDir(): File? {
val s = currentSession ?: return null val s = currentSession ?: return null
@@ -110,17 +139,95 @@ object ClinicalSessionStore {
File(dir, "meta.json").writeText(meta.toString(2)) File(dir, "meta.json").writeText(meta.toString(2))
} }
/** 측정 종료: endedAt 기록 → meta.json 갱신 → currentSession=null */ /** 측정 종료: endedAt → meta.json 갱신 → measurement.json 통합 생성 → currentSession=null */
fun endMeasurement(context: Context): File? { fun endMeasurement(context: Context): File? {
val s = currentSession ?: return null val s = currentSession ?: return null
s.endedAt = System.currentTimeMillis() s.endedAt = System.currentTimeMillis()
writeMeta(context) writeMeta(context)
writeMeasurementJson(context)
val dir = currentLogDir() val dir = currentLogDir()
lastFinalizedFolder = dir lastFinalizedFolder = dir
currentSession = null currentSession = null
cyclesBuffer.clear()
return dir return dir
} }
/**
* End Session 시점에 모든 사이클을 단일 measurement.json으로 통합.
* 분석 PC에서 한 줄 로드: data = json.load(open('measurement.json'))
*/
private fun writeMeasurementJson(context: Context) {
val s = currentSession ?: return
val dir = currentLogDir() ?: return
dir.mkdirs()
try {
val root = JSONObject()
// meta: writeMeta()와 동일 내용 포함 (단독 파일로 자족 가능)
val meta = JSONObject().apply {
put("subject_id", s.subjectId)
put("device_name", s.deviceName)
put("posture", s.posture.name)
put("step", s.step.name)
put("is_alignment", s.step.isAlignment)
put("examiner", s.examiner)
put("notes", s.notes)
put("label", s.label)
s.trueVolumeMl?.let { put("true_volume_ml", it) }
s.abdomenThicknessMm?.let { put("abdomen_thickness_mm", it) }
put("started_at", s.startedAt)
put("started_at_iso",
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(s.startedAt)))
s.endedAt?.let {
put("ended_at", it)
put("ended_at_iso",
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(it)))
put("duration_ms", it - s.startedAt)
}
put("captured_cycles", cyclesBuffer.size)
put("app_version_name", try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown"
} catch (_: Exception) { "unknown" })
val ble = com.example.medilightv2android.ble.BleManager.current
ble?.firmwareVersion?.value?.takeIf { it.isNotEmpty() }?.let { put("firmware_version", it) }
ble?.hardwareVersion?.value?.takeIf { it.isNotEmpty() }?.let { put("hardware_version", it) }
ble?.serialNumber?.value?.takeIf { it.isNotEmpty() }?.let { put("serial_number", it) }
}
root.put("meta", meta)
// cycles: 모든 사이클을 piezo + imu 묶음으로
val cyclesArr = JSONArray()
for (c in cyclesBuffer) {
val piezo = JSONObject()
c.piezoChannels.forEach { (ch, samples) ->
piezo.put("CH$ch", JSONArray(samples))
}
val imuArr = JSONArray()
for (sample in c.imuSamples) {
imuArr.put(JSONObject().apply {
put("ax", sample.ax.toDouble())
put("ay", sample.ay.toDouble())
put("az", sample.az.toDouble())
put("gx", sample.gx.toDouble())
put("gy", sample.gy.toDouble())
put("gz", sample.gz.toDouble())
})
}
cyclesArr.put(JSONObject().apply {
put("cycle", c.cycle)
put("timestamp_ms", c.timestampMs)
put("piezo", piezo)
put("imu", imuArr)
})
}
root.put("cycles", cyclesArr)
File(dir, "measurement.json").writeText(root.toString(2))
} catch (_: Exception) {
// 저장 실패해도 측정 자체는 막지 않음
}
}
/** 측정 폐기 (메타/파일은 그대로 두되 store만 비움) */ /** 측정 폐기 (메타/파일은 그대로 두되 store만 비움) */
fun discardMeasurement() { fun discardMeasurement() {
currentSession = null currentSession = null
@@ -74,24 +74,29 @@ fun ClinicalLiveView(appState: AppState) {
var lastImu by remember { mutableStateOf<List<ImuSample>>(emptyList()) } var lastImu by remember { mutableStateOf<List<ImuSample>>(emptyList()) }
var lastCycleMs by remember { mutableLongStateOf(0L) } var lastCycleMs by remember { mutableLongStateOf(0L) }
// BLE 콜백 등록 — 측정 사이클 결과 받음 // BLE 콜백 — mtb 응답은 reb×6 + raa(piezo 완료) → rim(IMU 완료) 순서.
// 한 사이클 = piezo 완료 + imu 완료. piezo가 먼저 오면 buffer에 보관, imu 도착 시 합쳐 push.
var pendingPiezo by remember { mutableStateOf<List<PiezoChannelData>?>(null) }
DisposableEffect(Unit) { DisposableEffect(Unit) {
bleManager.piezoCollector.onMultiChannelComplete = { channels -> bleManager.piezoCollector.onMultiChannelComplete = { channels ->
lastChannels = channels lastChannels = channels
cycleCount++ pendingPiezo = channels
lastCycleMs = System.currentTimeMillis()
// 자동 저장 모드면 매 사이클 저장
if (autoCapture && ClinicalSessionStore.currentSession != null) {
val rawADC = channels.sortedBy { it.channel }.map { it.buffer }
AdcCsvLogger.log(rawADC, null)
capturedCount++
}
} }
bleManager.imuCollector.onComplete = { imuSamples -> bleManager.imuCollector.onComplete = { imuSamples ->
lastImu = imuSamples lastImu = imuSamples
if (autoCapture && ClinicalSessionStore.currentSession != null) { cycleCount++
lastCycleMs = System.currentTimeMillis()
// 자동 저장: piezo + imu 묶어서 한 사이클로 누적 + 기존 CSV 저장 (재현용)
val piezo = pendingPiezo
if (autoCapture && piezo != null && ClinicalSessionStore.currentSession != null) {
ClinicalSessionStore.addCycle(piezo, imuSamples)
val rawADC = piezo.sortedBy { it.channel }.map { it.buffer }
AdcCsvLogger.log(rawADC, null)
ImuCsvLogger.log(imuSamples) ImuCsvLogger.log(imuSamples)
capturedCount++
} }
pendingPiezo = null
} }
onDispose { onDispose {
bleManager.piezoCollector.onMultiChannelComplete = null bleManager.piezoCollector.onMultiChannelComplete = null
@@ -195,10 +200,11 @@ fun ClinicalLiveView(appState: AppState) {
Text(if (isMeasuring) "Pause" else "Resume", color = Color.White, Text(if (isMeasuring) "Pause" else "Resume", color = Color.White,
fontWeight = FontWeight.SemiBold) fontWeight = FontWeight.SemiBold)
} }
// Manual Capture (자동 OFF일 때만 의미 있음) // Manual Capture (Auto OFF일 때 의미 있음)
Button( Button(
onClick = { onClick = {
if (lastChannels.isNotEmpty()) { if (lastChannels.isNotEmpty()) {
ClinicalSessionStore.addCycle(lastChannels, lastImu)
val rawADC = lastChannels.sortedBy { it.channel }.map { it.buffer } val rawADC = lastChannels.sortedBy { it.channel }.map { it.buffer }
AdcCsvLogger.log(rawADC, null) AdcCsvLogger.log(rawADC, null)
if (lastImu.isNotEmpty()) ImuCsvLogger.log(lastImu) if (lastImu.isNotEmpty()) ImuCsvLogger.log(lastImu)