CLINICAL: 흐름 재설계 — 한 측정 = 한 라벨 = 한 폴더
이전: 한 세션이 9 conditions × 6 steps를 모두 묶고 마지막에 zip
새로: 한 측정마다 라벨 입력 → 측정 → 자동 finalize → 다음 측정 라벨
사용자 요청: "session 들어가면 일단 BLE 연결을 하고, 연결 이후에 내가 어떤 자세로
어느 위치 측정할건지를 직접 적고 테스트를 완료한 뒤에 로그가 저장되도록"
변경:
- ClinicalSession 모델: 단일 측정 단위로 재정의 (subjectId/device/posture/step
/examiner/notes/startedAt/endedAt). folderName에 라벨 전체 + timestamp 포함:
"S001_A_SUPINE_ALIGN_0CM_2026-05-28_14-30-15"
- ClinicalSessionStore: 단순화. zip 제거. startMeasurement/endMeasurement.
inClinicalFlow boolean 추가 (DeviceScan/Back 라우팅용).
lastSubjectId/Examiner/Notes 기억 (다음 측정 시 자동 채움).
lastFinalizedFolder (UI에 "Saved: ..." 표시용).
- 3 로거: stepLogDir() → currentLogDir() 단순화. 한 폴더에 모든 파일.
- ClinicalHomeView 전면 재작성:
* BLE 연결 상태 카드 (미연결 시 Connect 버튼 → DeviceScan)
* 직전 측정 결과 표시 (lastFinalizedFolder)
* 라벨 입력 폼 (Subject + Device/Posture/Step segmented control + Examiner + Notes)
* 라벨 preview 실시간
* Start Measurement 버튼 (BLE 연결 + Subject ID 있을 때만 활성)
- AppState.deviceConnected: 임상 흐름이면 ClinicalHome 복귀 (Personalization 우회)
- MainActivity Back handler:
* DEVICE_SCAN Back → inClinicalFlow면 ClinicalHome
* PLACEMENT_GUIDE/PIEZO_MONITORING Back → 활성 세션이면 endMeasurement +
ClinicalHome 복귀 (폴더 finalize)
* CLINICAL_HOME Back → inClinicalFlow 해제 + HOME
데이터 구조 (한 측정당):
Downloads/VesiScan_Sessions/{subjectId}_{device}_{posture}_{step}_{timestamp}/
├─ meta.json (라벨 + 앱/펌웨어/디바이스 버전 + 시작/종료 시각)
├─ adc.csv
├─ placements.jsonl (alignment) or events.jsonl (BV)
└─ ble.log
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,9 +40,10 @@ object AdcCsvLogger {
|
||||
* 일반 모드: 기존 세션 파일 사용.
|
||||
*/
|
||||
private fun getFile(): File {
|
||||
val clinicalStepDir = ClinicalSessionStore.stepLogDir()
|
||||
if (clinicalStepDir != null) {
|
||||
return File(clinicalStepDir, "adc.csv")
|
||||
val clinicalDir = ClinicalSessionStore.currentLogDir()
|
||||
if (clinicalDir != null) {
|
||||
clinicalDir.mkdirs()
|
||||
return File(clinicalDir, "adc.csv")
|
||||
}
|
||||
if (sessionFile == null) newSession()
|
||||
return sessionFile!!
|
||||
|
||||
@@ -85,21 +85,22 @@ class BleDebugLogger private constructor(private val context: Context) {
|
||||
// 임상 모드: 세션 step 폴더의 ble.log로 라우팅 (step 전환 시 새 파일)
|
||||
// 일반 모드: Downloads/VesiScan_BLE_*.log (BLE 연결 1회당 1파일)
|
||||
private var liveLogFile: File? = null
|
||||
private var liveLogStepLabel: String = "" // 임상 모드 step 변경 감지
|
||||
private var liveLogLabel: String = "" // 임상 모드 측정 단위 변경 감지
|
||||
private fun ensureLiveLogFile(): File {
|
||||
val clinicalStepDir = ClinicalSessionStore.stepLogDir()
|
||||
if (clinicalStepDir != null) {
|
||||
val clinicalDir = ClinicalSessionStore.currentLogDir()
|
||||
if (clinicalDir != null) {
|
||||
clinicalDir.mkdirs()
|
||||
val currentLabel = ClinicalSessionStore.currentLabel()
|
||||
// step이 바뀌었으면 새 파일로 전환
|
||||
if (liveLogFile == null || liveLogStepLabel != currentLabel) {
|
||||
liveLogFile = File(clinicalStepDir, "ble.log")
|
||||
liveLogStepLabel = currentLabel
|
||||
// 측정 단위가 바뀌었으면 새 파일로 전환
|
||||
if (liveLogFile == null || liveLogLabel != currentLabel) {
|
||||
liveLogFile = File(clinicalDir, "ble.log")
|
||||
liveLogLabel = currentLabel
|
||||
}
|
||||
} else if (liveLogFile == null || liveLogStepLabel.isNotEmpty()) {
|
||||
} else if (liveLogFile == null || liveLogLabel.isNotEmpty()) {
|
||||
// 일반 모드 진입 또는 임상 모드 종료
|
||||
val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS)
|
||||
liveLogFile = File(downloadsDir, "VesiScan_BLE_${dateFmt.format(Date())}.log")
|
||||
liveLogStepLabel = ""
|
||||
liveLogLabel = ""
|
||||
}
|
||||
return liveLogFile!!
|
||||
}
|
||||
|
||||
@@ -3,89 +3,83 @@ package com.example.medilightv2android.services
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import com.example.medilightv2android.models.ClinicalSession
|
||||
import com.example.medilightv2android.models.ClinicalStep
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
/**
|
||||
* 임상 세션 전역 store. Logger들이 세션 존재 여부와 폴더 경로를 조회.
|
||||
* 임상 측정 단위(한 측정 = 한 라벨 = 한 폴더) 전역 store.
|
||||
*
|
||||
* - currentSession != null 이면 임상 모드 진입 상태
|
||||
* - 모든 로거(AdcCsvLogger / MeasurementLogService / BleDebugLogger)는
|
||||
* sessionLogDir()이 반환하는 폴더로 저장 라우팅
|
||||
* - endSession()에서 자동 zip + meta.json 마무리
|
||||
* 사용자가 BLE 연결 후 라벨을 입력하고 startMeasurement()를 호출하면
|
||||
* Downloads/VesiScan_Sessions/{folderName}/ 폴더가 생성되고 meta.json이 작성됨.
|
||||
* 이후 모든 로거(AdcCsvLogger / MeasurementLogService / BleDebugLogger)가
|
||||
* 해당 폴더로 자동 라우팅. endMeasurement()에서 meta.json이 finalize됨.
|
||||
*
|
||||
* zip은 사용하지 않음 — 폴더 자체가 분석 단위.
|
||||
*/
|
||||
object ClinicalSessionStore {
|
||||
|
||||
/** ClinicalHome 화면에 진입한 상태인지. BLE 연결을 위한 DeviceScan 우회/Back 라우팅에 사용. */
|
||||
@Volatile
|
||||
var inClinicalFlow: Boolean = false
|
||||
|
||||
@Volatile
|
||||
var currentSession: ClinicalSession? = null
|
||||
private set
|
||||
|
||||
fun startSession(session: ClinicalSession) {
|
||||
/** 마지막으로 종료된 측정 (UI에서 "Saved: ..." 표시용) */
|
||||
@Volatile
|
||||
var lastFinalizedFolder: File? = null
|
||||
private set
|
||||
|
||||
/** 다음 측정 시 기본값으로 자동 채울 사용자 입력 (subjectId/examiner/notes) */
|
||||
var lastSubjectId: String = ""
|
||||
var lastExaminer: String = ""
|
||||
var lastNotes: String = ""
|
||||
|
||||
fun startMeasurement(context: Context, session: ClinicalSession) {
|
||||
currentSession = session
|
||||
// 세션 루트 폴더 생성
|
||||
sessionRootDir()?.mkdirs()
|
||||
lastSubjectId = session.subjectId
|
||||
lastExaminer = session.examiner
|
||||
lastNotes = session.notes
|
||||
currentLogDir()?.mkdirs()
|
||||
writeMeta(context)
|
||||
}
|
||||
|
||||
fun updateCurrent(
|
||||
device: com.example.medilightv2android.models.ClinicalDevice? = null,
|
||||
posture: com.example.medilightv2android.models.ClinicalPosture? = null,
|
||||
step: ClinicalStep? = null
|
||||
) {
|
||||
val s = currentSession ?: return
|
||||
device?.let { s.currentDevice = it }
|
||||
posture?.let { s.currentPosture = it }
|
||||
step?.let { s.currentStep = it }
|
||||
}
|
||||
|
||||
fun markCurrentCompleted() {
|
||||
currentSession?.markCurrentCompleted()
|
||||
}
|
||||
|
||||
/** 세션 루트: Downloads/VesiScan_Sessions/{folderName}/ */
|
||||
fun sessionRootDir(): File? {
|
||||
/** 현재 측정의 폴더 (= 모든 로거의 저장 디렉토리). */
|
||||
fun currentLogDir(): File? {
|
||||
val s = currentSession ?: return null
|
||||
val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
return File(File(downloads, "VesiScan_Sessions"), s.folderName)
|
||||
}
|
||||
|
||||
/** 현재 step의 로그 폴더: {sessionRoot}/{device}_{posture}/{step.folder}/ */
|
||||
fun stepLogDir(): File? {
|
||||
val s = currentSession ?: return null
|
||||
val root = sessionRootDir() ?: return null
|
||||
val d = s.currentDevice?.name ?: "UNK"
|
||||
val p = s.currentPosture?.name ?: "UNK"
|
||||
val st = s.currentStep?.folder ?: "UNK"
|
||||
return File(File(root, "${d}_${p}"), st).apply { mkdirs() }
|
||||
}
|
||||
/** clinical_label 컬럼/필드용. 비활성 시 "" */
|
||||
fun currentLabel(): String = currentSession?.label ?: ""
|
||||
|
||||
/** "S001_A_SUPINE_ALIGN_0CM" — CSV row 라벨 / JSONL 필드용 */
|
||||
fun currentLabel(): String {
|
||||
val s = currentSession ?: return ""
|
||||
val d = s.currentDevice?.name ?: "?"
|
||||
val p = s.currentPosture?.name ?: "?"
|
||||
val st = s.currentStep?.name ?: "?"
|
||||
return "${s.sessionId}_${d}_${p}_${st}"
|
||||
}
|
||||
|
||||
/** meta.json 작성/갱신 */
|
||||
fun writeMeta(context: Context) {
|
||||
val s = currentSession ?: return
|
||||
val root = sessionRootDir() ?: return
|
||||
root.mkdirs()
|
||||
val dir = currentLogDir() ?: return
|
||||
dir.mkdirs()
|
||||
val meta = JSONObject().apply {
|
||||
put("session_id", s.sessionId)
|
||||
put("subject_id", s.subjectId)
|
||||
put("device", s.device.name)
|
||||
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)
|
||||
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("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("app_version_name", try {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown"
|
||||
} catch (_: Exception) { "unknown" })
|
||||
@@ -97,48 +91,23 @@ object ClinicalSessionStore {
|
||||
put("device_manufacturer", android.os.Build.MANUFACTURER)
|
||||
put("android_release", android.os.Build.VERSION.RELEASE)
|
||||
put("android_sdk", android.os.Build.VERSION.SDK_INT)
|
||||
put("completed_steps", org.json.JSONArray().apply {
|
||||
s.completedKeys.forEach { put(it) }
|
||||
})
|
||||
put("completed_count", s.completedCount)
|
||||
put("total_steps", s.totalSteps)
|
||||
}
|
||||
File(root, "meta.json").writeText(meta.toString(2))
|
||||
File(dir, "meta.json").writeText(meta.toString(2))
|
||||
}
|
||||
|
||||
/** 세션 종료: endedAt 기록 → meta.json 갱신 → zip 생성 */
|
||||
fun endSession(context: Context): File? {
|
||||
/** 측정 종료: endedAt 기록 → meta.json 갱신 → currentSession=null */
|
||||
fun endMeasurement(context: Context): File? {
|
||||
val s = currentSession ?: return null
|
||||
s.endedAt = System.currentTimeMillis()
|
||||
writeMeta(context)
|
||||
val root = sessionRootDir() ?: return null
|
||||
if (!root.exists()) return null
|
||||
val zipFile = File(root.parentFile, "${s.folderName}.zip")
|
||||
try {
|
||||
ZipOutputStream(FileOutputStream(zipFile)).use { zos ->
|
||||
zipDirectory(root, root.name, zos)
|
||||
}
|
||||
} catch (_: Exception) { return null }
|
||||
val dir = currentLogDir()
|
||||
lastFinalizedFolder = dir
|
||||
currentSession = null
|
||||
return zipFile
|
||||
return dir
|
||||
}
|
||||
|
||||
/** 세션 폐기 (zip 없이 currentSession만 비움) */
|
||||
fun discardSession() {
|
||||
/** 측정 폐기 (메타/파일은 그대로 두되 store만 비움) */
|
||||
fun discardMeasurement() {
|
||||
currentSession = null
|
||||
}
|
||||
|
||||
private fun zipDirectory(dir: File, prefix: String, zos: ZipOutputStream) {
|
||||
val files = dir.listFiles() ?: return
|
||||
for (f in files) {
|
||||
val entryName = "$prefix/${f.name}"
|
||||
if (f.isDirectory) {
|
||||
zipDirectory(f, entryName, zos)
|
||||
} else {
|
||||
zos.putNextEntry(ZipEntry(entryName))
|
||||
f.inputStream().use { it.copyTo(zos) }
|
||||
zos.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,9 +169,10 @@ class MeasurementLogService private constructor(private val context: Context) {
|
||||
private fun appendLog(entry: JSONObject, clinicalFileName: String = "events.jsonl") {
|
||||
try {
|
||||
val line = entry.toString() + "\n"
|
||||
val clinicalStepDir = ClinicalSessionStore.stepLogDir()
|
||||
val file = if (clinicalStepDir != null) {
|
||||
File(clinicalStepDir, clinicalFileName)
|
||||
val clinicalDir = ClinicalSessionStore.currentLogDir()
|
||||
val file = if (clinicalDir != null) {
|
||||
clinicalDir.mkdirs()
|
||||
File(clinicalDir, clinicalFileName)
|
||||
} else {
|
||||
File(logDir, dateFormatter.format(Date()) + ".jsonl")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user