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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-18
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user