Clinical #1: Subject ID 자유입력 → Phantom/Human/ETC 카테고리 (회의 결정)
회의 결정: 자유 텍스트 ID 입력 대신 측정 대상을 PHANTOM/HUMAN/ETC 세
카테고리로 명확히 구분. JSON/labdb 저장 방식도 함께 변경.
데이터 모델 (ClinicalSession.kt):
- SubjectType enum 추가: PHANTOM / HUMAN / ETC
- val subjectId: String 제거 → val subjectType: SubjectType
- 폴더명: "{subjectId}_{...}" → "{SUBJECT_TYPE}_{...}"
예: PHANTOM_VBT26050202_SUPINE_ALIGN_0CM_2026-06-08_15-30-00
JSON 변경 (meta.json + measurement.json):
- "subject_id": "S001" → "subject_type": "PHANTOM"
labdb payload (LabdbUploader):
- params.subject_id → params.subject_type (서버는 free JSON이라 코드 변경 불필요)
UI (ClinicalHomeView):
- Subject ID OutlinedTextField 제거
- LabeledSegmented "Subject Type" 3개 버튼 (Phantom/Human/ETC)
- 마지막 선택 기억 (ClinicalSessionStore.lastSubjectType)
ClinicalLive 상단 sub line: "S=S001 · TV=300mL · Ab=22mm"
→ "Phantom · TV=300mL · Ab=22mm"
호환성: 기존 측정 폴더는 그대로. 새 폴더부터 새 형식 적용.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,11 @@ enum class ClinicalPosture(val label: String) {
|
||||
SUPINE("Supine"), SITTING("Sitting"), STANDING("Standing")
|
||||
}
|
||||
|
||||
/** 측정 대상 분류 — 회의 결정 (subject ID 자유 입력 대체). */
|
||||
enum class SubjectType(val label: String) {
|
||||
PHANTOM("Phantom"), HUMAN("Human"), ETC("ETC")
|
||||
}
|
||||
|
||||
/**
|
||||
* 측정 단계.
|
||||
* - ALIGN_0CM ~ ALIGN_4CM: 정렬모드 알고리즘 개발용 (기기 아래 끝 기준 치골 위 0~4cm)
|
||||
@@ -44,8 +49,8 @@ data class ClinicalSession(
|
||||
val step: ClinicalStep,
|
||||
val examiner: String = "",
|
||||
val notes: String = "",
|
||||
// 임상 시험 정보 (vesiscan_test Section 2-1 프로토콜과 매칭)
|
||||
val subjectId: String = "", // 피험자 ID (예: "S001")
|
||||
// 측정 대상 분류 (회의 결정: Phantom/Human/ETC 버튼 — subject ID 자유 입력 대체)
|
||||
val subjectType: SubjectType = SubjectType.PHANTOM,
|
||||
val trueVolumeMl: Double? = null, // 외부 초음파 측정기 참값 (mL)
|
||||
val abdomenThicknessMm: Double? = null, // 복부 두께 측정기 참값 (mm)
|
||||
val startedAt: Long = System.currentTimeMillis(),
|
||||
@@ -53,13 +58,9 @@ data class ClinicalSession(
|
||||
) {
|
||||
private val folderFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||
|
||||
/** "S001_VBT26050202_SUPINE_ALIGN_0CM_2026-05-28_14-30-15"
|
||||
* subjectId 비어있으면 기존 형식 유지 (deviceName_posture_step_timestamp). */
|
||||
/** "PHANTOM_VBT26050202_SUPINE_ALIGN_0CM_2026-05-28_14-30-15" */
|
||||
val folderName: String
|
||||
get() = buildString {
|
||||
if (subjectId.isNotBlank()) { append(subjectId); append('_') }
|
||||
append("${deviceName}_${posture.name}_${step.name}_${folderFmt.format(Date(startedAt))}")
|
||||
}
|
||||
get() = "${subjectType.name}_${deviceName}_${posture.name}_${step.name}_${folderFmt.format(Date(startedAt))}"
|
||||
|
||||
/** clinical_label 컬럼/필드 값 (라벨링 자동) */
|
||||
val label: String
|
||||
|
||||
@@ -51,7 +51,8 @@ object ClinicalSessionStore {
|
||||
/** 다음 측정 시 기본값으로 자동 채울 사용자 입력 — 같은 피험자/검사자 여러 측정 시 편의 */
|
||||
var lastExaminer: String = ""
|
||||
var lastNotes: String = ""
|
||||
var lastSubjectId: String = ""
|
||||
var lastSubjectType: com.example.medilightv2android.models.SubjectType =
|
||||
com.example.medilightv2android.models.SubjectType.PHANTOM
|
||||
var lastTrueVolumeMl: String = "" // 문자열로 보관 (입력 폼과 동일 형식)
|
||||
var lastAbdomenThicknessMm: String = ""
|
||||
|
||||
@@ -62,7 +63,7 @@ object ClinicalSessionStore {
|
||||
currentSession = session
|
||||
lastExaminer = session.examiner
|
||||
lastNotes = session.notes
|
||||
lastSubjectId = session.subjectId
|
||||
lastSubjectType = session.subjectType
|
||||
lastTrueVolumeMl = session.trueVolumeMl?.toString() ?: ""
|
||||
lastAbdomenThicknessMm = session.abdomenThicknessMm?.toString() ?: ""
|
||||
cyclesBuffer.clear()
|
||||
@@ -107,8 +108,9 @@ object ClinicalSessionStore {
|
||||
put("examiner", s.examiner)
|
||||
put("notes", s.notes)
|
||||
put("label", s.label)
|
||||
// 측정 대상 분류 (회의 결정: Phantom/Human/ETC)
|
||||
put("subject_type", s.subjectType.name)
|
||||
// 임상 시험 참값 (외부 측정기, vesiscan_test 프로토콜)
|
||||
if (s.subjectId.isNotBlank()) put("subject_id", s.subjectId)
|
||||
s.trueVolumeMl?.let { put("true_volume_ml", it) }
|
||||
s.abdomenThicknessMm?.let { put("abdomen_thickness_mm", it) }
|
||||
put("started_at", s.startedAt)
|
||||
@@ -179,7 +181,7 @@ object ClinicalSessionStore {
|
||||
// meta: writeMeta()와 동일 내용 포함 (단독 파일로 자족 가능)
|
||||
val meta = JSONObject().apply {
|
||||
put("data_type", DATA_TYPE)
|
||||
put("subject_id", s.subjectId)
|
||||
put("subject_type", s.subjectType.name)
|
||||
put("device_name", s.deviceName)
|
||||
put("posture", s.posture.name)
|
||||
put("step", s.step.name)
|
||||
|
||||
@@ -89,7 +89,7 @@ object LabdbUploader {
|
||||
put("step", meta.optString("step"))
|
||||
put("is_alignment", meta.optBoolean("is_alignment"))
|
||||
put("examiner", meta.optString("examiner"))
|
||||
meta.optString("subject_id").takeIf { it.isNotBlank() }?.let { put("subject_id", it) }
|
||||
meta.optString("subject_type").takeIf { it.isNotBlank() }?.let { put("subject_type", it) }
|
||||
if (meta.has("true_volume_ml")) put("true_volume_ml", meta.optDouble("true_volume_ml"))
|
||||
if (meta.has("abdomen_thickness_mm")) put("abdomen_thickness_mm", meta.optDouble("abdomen_thickness_mm"))
|
||||
meta.optString("firmware_version").takeIf { it.isNotBlank() }?.let { put("firmware_version", it) }
|
||||
|
||||
+11
-12
@@ -63,8 +63,8 @@ fun ClinicalHomeView(appState: AppState) {
|
||||
var step by remember { mutableStateOf(ClinicalStep.ALIGN_0CM) }
|
||||
var examiner by remember { mutableStateOf(ClinicalSessionStore.lastExaminer) }
|
||||
var notes by remember { mutableStateOf(ClinicalSessionStore.lastNotes) }
|
||||
// 임상 시험 정보 (vesiscan_test Section 2-1 프로토콜)
|
||||
var subjectId by remember { mutableStateOf(ClinicalSessionStore.lastSubjectId) }
|
||||
// 측정 대상 분류 (회의 결정: Phantom/Human/ETC 버튼)
|
||||
var subjectType by remember { mutableStateOf(ClinicalSessionStore.lastSubjectType) }
|
||||
var trueVolText by remember { mutableStateOf(ClinicalSessionStore.lastTrueVolumeMl) }
|
||||
var abdomenText by remember { mutableStateOf(ClinicalSessionStore.lastAbdomenThicknessMm) }
|
||||
|
||||
@@ -348,15 +348,14 @@ fun ClinicalHomeView(appState: AppState) {
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 임상 시험 참값 — vesiscan_test Section 2-1 프로토콜과 매칭
|
||||
OutlinedTextField(
|
||||
value = subjectId,
|
||||
onValueChange = { subjectId = it },
|
||||
label = { Text("Subject ID (예: S001)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
// 측정 대상 분류 — Phantom/Human/ETC 버튼 (회의 결정)
|
||||
LabeledSegmented(
|
||||
label = "Subject Type",
|
||||
options = com.example.medilightv2android.models.SubjectType.values().toList(),
|
||||
selected = subjectType,
|
||||
onSelect = { subjectType = it }
|
||||
) { it.label }
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = trueVolText,
|
||||
@@ -419,7 +418,7 @@ fun ClinicalHomeView(appState: AppState) {
|
||||
step = step,
|
||||
examiner = examiner.trim(),
|
||||
notes = notes.trim(),
|
||||
subjectId = subjectId.trim(),
|
||||
subjectType = subjectType,
|
||||
trueVolumeMl = trueVolText.toDoubleOrNull(),
|
||||
abdomenThicknessMm = abdomenText.toDoubleOrNull(),
|
||||
)
|
||||
|
||||
+1
-1
@@ -161,7 +161,7 @@ fun ClinicalLiveView(appState: AppState) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Live · ${session.label}",
|
||||
fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||
val subj = if (session.subjectId.isNotBlank()) "S=${session.subjectId} · " else ""
|
||||
val subj = "${session.subjectType.label} · "
|
||||
val tv = session.trueVolumeMl?.let { "TV=${it.toInt()}mL · " } ?: ""
|
||||
val ab = session.abdomenThicknessMm?.let { "Ab=${"%.0f".format(it)}mm" } ?: ""
|
||||
Text("$subj$tv$ab",
|
||||
|
||||
Reference in New Issue
Block a user