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:
2026-05-26 12:26:49 +09:00
parent 6e2dba749b
commit ab25b3c65d
8 changed files with 304 additions and 380 deletions
@@ -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")
}