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.os.Environment
import com.example.medilightv2android.ble.ImuSample
import com.example.medilightv2android.ble.PiezoChannelData
import com.example.medilightv2android.models.ClinicalSession
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
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.
*
@@ -41,6 +52,9 @@ object ClinicalSessionStore {
var lastTrueVolumeMl: String = "" // 문자열로 보관 (입력 폼과 동일 형식)
var lastAbdomenThicknessMm: String = ""
/** End 시점에 measurement.json 통합 생성용 — 측정 중 사이클 누적 */
private val cyclesBuffer = mutableListOf<MeasurementCycle>()
fun startMeasurement(context: Context, session: ClinicalSession) {
currentSession = session
lastExaminer = session.examiner
@@ -48,10 +62,25 @@ object ClinicalSessionStore {
lastSubjectId = session.subjectId
lastTrueVolumeMl = session.trueVolumeMl?.toString() ?: ""
lastAbdomenThicknessMm = session.abdomenThicknessMm?.toString() ?: ""
cyclesBuffer.clear()
currentLogDir()?.mkdirs()
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? {
val s = currentSession ?: return null
@@ -110,17 +139,95 @@ object ClinicalSessionStore {
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? {
val s = currentSession ?: return null
s.endedAt = System.currentTimeMillis()
writeMeta(context)
writeMeasurementJson(context)
val dir = currentLogDir()
lastFinalizedFolder = dir
currentSession = null
cyclesBuffer.clear()
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만 비움) */
fun discardMeasurement() {
currentSession = null