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:
@@ -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
|
||||||
|
|||||||
+17
-11
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user