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
@@ -0,0 +1,82 @@
package com.example.medilightv2android.models
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* 임상 R&D 측정 세션 모델.
*
* 한 피험자의 한 측정 세션 = 3 devices × 3 postures × 6 steps = 54 measurements.
* Dev 모드에서만 접근 가능. 세션 폴더로 모든 로그가 자동 라우팅됨.
*/
enum class ClinicalDevice(val label: String) {
A("Device A"), B("Device B"), C("Device C")
}
enum class ClinicalPosture(val label: String) {
SUPINE("Supine"), SITTING("Sitting"), STANDING("Standing")
}
/**
* 한 condition 내 측정 단계.
* - ALIGN_0CM ~ ALIGN_4CM: 정렬모드 알고리즘 개발용 (치골 위 0~4cm)
* - BV: cradle 부착 후 본측정 (방광 추정 알고리즘 개발용)
*/
enum class ClinicalStep(val label: String, val folder: String, val isAlignment: Boolean) {
ALIGN_0CM("0 cm", "align_0cm", true),
ALIGN_1CM("1 cm", "align_1cm", true),
ALIGN_2CM("2 cm", "align_2cm", true),
ALIGN_3CM("3 cm", "align_3cm", true),
ALIGN_4CM("4 cm", "align_4cm", true),
BV("BV", "bv", false)
}
data class ClinicalSession(
val sessionId: String, // "S001" — 사용자 입력 라벨 (익명)
val examiner: String = "",
val notes: String = "",
val startedAt: Long = System.currentTimeMillis(),
var endedAt: Long? = null,
var currentDevice: ClinicalDevice? = null,
var currentPosture: ClinicalPosture? = null,
var currentStep: ClinicalStep? = null,
val completedKeys: MutableSet<String> = mutableSetOf()
) {
private val folderFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
/** Downloads/VesiScan_Sessions/{folderName}/ 의 폴더명 */
val folderName: String
get() = "${sessionId}_${folderFmt.format(Date(startedAt))}"
/** "A_SUPINE_ALIGN_0CM" — 라벨/완료 추적용 키 */
fun stepKey(d: ClinicalDevice, p: ClinicalPosture, s: ClinicalStep): String =
"${d.name}_${p.name}_${s.name}"
fun currentStepKey(): String? {
val d = currentDevice ?: return null
val p = currentPosture ?: return null
val s = currentStep ?: return null
return stepKey(d, p, s)
}
fun markCompleted(d: ClinicalDevice, p: ClinicalPosture, s: ClinicalStep) {
completedKeys.add(stepKey(d, p, s))
}
fun markCurrentCompleted() {
currentStepKey()?.let { completedKeys.add(it) }
}
fun isCompleted(d: ClinicalDevice, p: ClinicalPosture, s: ClinicalStep): Boolean =
stepKey(d, p, s) in completedKeys
fun conditionCompletedCount(d: ClinicalDevice, p: ClinicalPosture): Int =
ClinicalStep.values().count { isCompleted(d, p, it) }
val totalSteps: Int =
ClinicalDevice.values().size * ClinicalPosture.values().size * ClinicalStep.values().size
val completedCount: Int get() = completedKeys.size
}