CLINICAL: Dev 모드 한정 임상 R&D 측정 세션 메뉴 추가

회사 내 알고리즘 개발용 임상 시험(3 devices × 3 postures × 6 steps = 54 measurements
per subject)을 위한 데이터 라벨링/라우팅/zip 자동화. Dev 모드 켠 경우에만 노출.

신규 파일:
- models/ClinicalSession.kt — ClinicalDevice/Posture/Step 모델 + 완료 추적
- services/ClinicalSessionStore.kt — 전역 세션 store + 폴더 라우팅 + meta.json + zip
- ui/views/clinical/ClinicalHomeView.kt — 세션 메타 입력 다이얼로그 + 9-conditions
  그리드 + 6 steps + Mark Complete + End Session(zip) 화면

로거 라우팅 (세션 존재 시 자동 분기):
- AdcCsvLogger: sessionRoot/{device}_{posture}/{step}/adc.csv +
  CSV에 clinical_label 컬럼 추가 (S001_A_SUPINE_ALIGN_0CM 형태)
- MeasurementLogService: 내부 filesDir → Downloads/VesiScan_Events/ 영구 이동.
  세션 시 step 폴더의 events.jsonl/placements.jsonl로 분기.
  BuildConfig 대신 PackageInfo로 versionName/versionCode 정확 주입.
  보관 90일 → 365일 (R&D 장기 보관)
- BleDebugLogger: step 변경 감지해서 ble.log 파일 자동 전환

UI/라우팅:
- AppScreen.CLINICAL_HOME + MainActivity Crossfade/BackHandler 분기
- HomeView에 dev 모드 한정 보라색 "Clinical Mode (Dev)" 버튼
- 측정 화면(PlacementGuide/PiezoMonitoring) Back 시 임상 세션 활성 → ClinicalHome 복귀

Placement 구조화:
- PlacementGuideView가 logService.logPlacement() 호출 시 urineLens/cv/phase/score
  추가 인자 전달 → placements.jsonl에 구조화 기록

데이터 구조 (한 세션당):
  Downloads/VesiScan_Sessions/{S001_2026-05-28_14:30}/
    ├─ meta.json
    ├─ A_SUPINE/align_0cm/{adc.csv, placements.jsonl, ble.log}
    ├─ A_SUPINE/align_1cm/...
    ├─ ...
    ├─ A_SUPINE/bv/{adc.csv, events.jsonl, ble.log}
    ├─ A_SITTING/... (총 9 conditions × 6 steps)
    └─ session.zip (End Session 시 자동 생성)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 12:06:39 +09:00
parent 63bb8586ec
commit 6e2dba749b
10 changed files with 655 additions and 27 deletions
@@ -35,7 +35,15 @@ object AdcCsvLogger {
sessionFile = File(getDir(), "${sessionFmt.format(Date())}.csv")
}
/**
* 임상 모드: ClinicalSessionStore에 활성 세션이 있으면 그 step 폴더의 adc.csv 사용.
* 일반 모드: 기존 세션 파일 사용.
*/
private fun getFile(): File {
val clinicalStepDir = ClinicalSessionStore.stepLogDir()
if (clinicalStepDir != null) {
return File(clinicalStepDir, "adc.csv")
}
if (sessionFile == null) newSession()
return sessionFile!!
}
@@ -60,10 +68,12 @@ object AdcCsvLogger {
val dpsStr = "%.3f".format(com.example.medilightv2android.managers.PiezoHW.distancePerSample)
val sgStr = if (analyzer.useSgFilter) "ON" else "OFF"
val clinicalLabel = ClinicalSessionStore.currentLabel() // 일반 모드면 ""
java.io.FileWriter(file, !isNew).buffered().use { writer ->
if (isNew) {
val header = buildString {
append("scan_id,timestamp,volume_ml,lr_ratio,threshold,dps,sg_filter,channel")
append("scan_id,timestamp,clinical_label,volume_ml,lr_ratio,threshold,dps,sg_filter,channel")
for (i in 0 until 100) append(",s$i")
}
writer.write(header)
@@ -72,7 +82,7 @@ object AdcCsvLogger {
for ((chIdx, buffer) in channels.withIndex()) {
val row = buildString {
append("$scanCounter,$timestamp,$volStr,$lrStr,$thrStr,$dpsStr,$sgStr,CH$chIdx")
append("$scanCounter,$timestamp,$clinicalLabel,$volStr,$lrStr,$thrStr,$dpsStr,$sgStr,CH$chIdx")
for (sample in buffer) {
append(",${sample.toInt()}")
}
@@ -82,11 +82,24 @@ class BleDebugLogger private constructor(private val context: Context) {
private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
// 실시간 파일 기록 (화면 잠금/앱 종료에도 보존)
// 임상 모드: 세션 step 폴더의 ble.log로 라우팅 (step 전환 시 새 파일)
// 일반 모드: Downloads/VesiScan_BLE_*.log (BLE 연결 1회당 1파일)
private var liveLogFile: File? = null
private var liveLogStepLabel: String = "" // 임상 모드 step 변경 감지
private fun ensureLiveLogFile(): File {
if (liveLogFile == null) {
val clinicalStepDir = ClinicalSessionStore.stepLogDir()
if (clinicalStepDir != null) {
val currentLabel = ClinicalSessionStore.currentLabel()
// step이 바뀌었으면 새 파일로 전환
if (liveLogFile == null || liveLogStepLabel != currentLabel) {
liveLogFile = File(clinicalStepDir, "ble.log")
liveLogStepLabel = currentLabel
}
} else if (liveLogFile == null || liveLogStepLabel.isNotEmpty()) {
// 일반 모드 진입 또는 임상 모드 종료
val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS)
liveLogFile = File(downloadsDir, "VesiScan_BLE_${dateFmt.format(Date())}.log")
liveLogStepLabel = ""
}
return liveLogFile!!
}
@@ -0,0 +1,144 @@
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들이 세션 존재 여부와 폴더 경로를 조회.
*
* - currentSession != null 이면 임상 모드 진입 상태
* - 모든 로거(AdcCsvLogger / MeasurementLogService / BleDebugLogger)는
* sessionLogDir()이 반환하는 폴더로 저장 라우팅
* - endSession()에서 자동 zip + meta.json 마무리
*/
object ClinicalSessionStore {
@Volatile
var currentSession: ClinicalSession? = null
private set
fun startSession(session: ClinicalSession) {
currentSession = session
// 세션 루트 폴더 생성
sessionRootDir()?.mkdirs()
}
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? {
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() }
}
/** "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 meta = JSONObject().apply {
put("session_id", s.sessionId)
put("examiner", s.examiner)
put("notes", s.notes)
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("app_version_name", try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown"
} catch (_: Exception) { "unknown" })
put("app_version_code", try {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0).versionCode
} catch (_: Exception) { -1 })
put("device_model", android.os.Build.MODEL)
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))
}
/** 세션 종료: endedAt 기록 → meta.json 갱신 → zip 생성 */
fun endSession(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 }
currentSession = null
return zipFile
}
/** 세션 폐기 (zip 없이 currentSession만 비움) */
fun discardSession() {
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()
}
}
}
}
@@ -17,14 +17,26 @@ import java.util.*
* File location: vesiscan_logs/YYYY-MM-DD.jsonl
* Each line = one measurement event (JSON)
*/
class MeasurementLogService private constructor(context: Context) {
class MeasurementLogService private constructor(private val context: Context) {
private val logDir: File
private val dateFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.US)
private val timestampFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.US)
private val appVersionName: String = try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown"
} catch (_: Exception) { "unknown" }
private val appVersionCode: Int = try {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0).versionCode
} catch (_: Exception) { -1 }
init {
logDir = File(context.filesDir, "vesiscan_logs")
// Downloads/VesiScan_Events/ 로 이동 — 앱 삭제 시 데이터 손실 방지.
// 내부 저장소(filesDir)는 앱 uninstall 시 같이 사라지지만, 외부 Downloads는 유지됨.
val downloads = android.os.Environment.getExternalStoragePublicDirectory(
android.os.Environment.DIRECTORY_DOWNLOADS
)
logDir = File(downloads, "VesiScan_Events")
if (!logDir.exists()) logDir.mkdirs()
}
@@ -53,7 +65,10 @@ class MeasurementLogService private constructor(context: Context) {
put("event", "measurement")
put("timestamp", timestampFormatter.format(Date()))
put("device", Build.MODEL)
put("app_version", getAppVersion())
put("app_version", appVersionName)
put("app_version_code", appVersionCode)
val clinical = ClinicalSessionStore.currentLabel()
if (clinical.isNotEmpty()) put("clinical_label", clinical)
}
if (volumeMl != null) {
@@ -86,10 +101,18 @@ class MeasurementLogService private constructor(context: Context) {
/**
* Log Placement Guide result.
*
* 임상 모드에서는 구조화된 placements.jsonl로 별도 라우팅.
* 일반 모드에서는 기존 events JSONL 묶음에 그대로.
*/
fun logPlacement(
channelDetected: List<Boolean>,
direction: String
direction: String,
urineLens: List<Int> = emptyList(),
cv: Double = Double.NaN,
lrDev: Double = Double.NaN,
phase: String = "",
score: Int = -1
) {
val entry = JSONObject().apply {
put("event", "placement")
@@ -99,8 +122,18 @@ class MeasurementLogService private constructor(context: Context) {
put("detected", arr)
put("detected_count", channelDetected.count { it })
put("direction", direction)
if (urineLens.isNotEmpty()) {
put("urine_lens", JSONArray().apply { urineLens.forEach { put(it) } })
put("urine_sum", urineLens.sum())
}
if (!cv.isNaN()) put("cv", cv)
if (!lrDev.isNaN()) put("lr_dev", lrDev)
if (phase.isNotEmpty()) put("phase", phase)
if (score >= 0) put("score", score)
val clinical = ClinicalSessionStore.currentLabel()
if (clinical.isNotEmpty()) put("clinical_label", clinical)
}
appendLog(entry)
appendLog(entry, "placements.jsonl")
}
/**
@@ -129,11 +162,19 @@ class MeasurementLogService private constructor(context: Context) {
// -- File Operations --
private fun appendLog(entry: JSONObject) {
/**
* 임상 모드면 세션 step 폴더의 지정 파일에 추가.
* 일반 모드면 Downloads/VesiScan_Events/YYYY-MM-DD.jsonl 에 추가.
*/
private fun appendLog(entry: JSONObject, clinicalFileName: String = "events.jsonl") {
try {
val line = entry.toString() + "\n"
val fileName = dateFormatter.format(Date()) + ".jsonl"
val file = File(logDir, fileName)
val clinicalStepDir = ClinicalSessionStore.stepLogDir()
val file = if (clinicalStepDir != null) {
File(clinicalStepDir, clinicalFileName)
} else {
File(logDir, dateFormatter.format(Date()) + ".jsonl")
}
file.appendText(line)
} catch (e: Exception) {
// Silently fail — logging should never crash the app
@@ -173,8 +214,8 @@ class MeasurementLogService private constructor(context: Context) {
val totalLogSize: Long
get() = listLogFiles().sumOf { it.length() }
/** Purge logs older than [keepDays] days (default 90) */
fun purgeOldLogs(keepDays: Int = 90) {
/** Purge logs older than [keepDays] days (default 365, R&D 데이터 장기 보관) */
fun purgeOldLogs(keepDays: Int = 365) {
val calendar = Calendar.getInstance()
calendar.add(Calendar.DAY_OF_YEAR, -keepDays)
val cutoffStr = dateFormatter.format(calendar.time)
@@ -186,14 +227,6 @@ class MeasurementLogService private constructor(context: Context) {
}
}
private fun getAppVersion(): String {
return try {
val context = logDir.parentFile // filesDir parent hack — not ideal
"1.0" // Fallback; ideally inject from BuildConfig
} catch (e: Exception) {
"unknown"
}
}
}
/** Data class for channel log entries */