From 429964571fdec3fcf19a404b87140d4e573bc357 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Wed, 17 Jun 2026 13:16:49 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Clinical=20Mode=20=EC=97=90=20Sensor=20?= =?UTF-8?q?Alignment=20=EC=84=B8=EC=85=98=20=EC=B6=94=EA=B0=80=20(demo-fin?= =?UTF-8?q?al,=20V1=20=EC=A0=84=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 새 흐름: ClinicalHome → BLE 연결 + labdb 활성 확인 → [Sensor Alignment] 버튼 (우측, 보라색) → PlacementGuide (alignment session 모드, V1 advisor) → 매 cycle phase/score/detected/urineLens/hint 누적 → [Complete Alignment] 버튼 → measurement.json (data_type='002') + labdb 자동 업로드 → ClinicalHome 복귀 Live 6ch 측정은 기존대로: ClinicalHome → [Live 6ch] → ClinicalLive → measurement.json (data_type='001') models/ClinicalSession.kt: + SessionMode { ALIGNMENT, MEASUREMENT } enum + ClinicalSession.mode (default = MEASUREMENT, 기존 동작 유지) + ClinicalSession.alignmentAlgo ("V1" demo-final 에서) + folderName 에 mode == ALIGNMENT 시 "_ALIGNMODE_" 접미사 services/ClinicalSessionStore.kt: + MeasurementCycle 에 alignment 필드 6개 (phase/score/detected/urineLens/hint/icon) + addAlignmentFrame(piezo, imu, ...V1 advisor state) 새 메서드 + writeMeasurementJson: mode==ALIGNMENT → data_type='002' (R&D alignment) 그 외 = '001' (measurement) 각 cycle JSON 에 align_phase / align_score / align_detected / align_urine_lens / align_hint / align_icon 첨부 AppState.kt: + enterAlignmentSession() ui/views/clinical/ClinicalHomeView.kt: + Live 6ch + Sensor Alignment 2-button Row 로 교체 + Sensor Alignment: BLE + labdb active 두 조건 모두 OK 시만 활성 + 비활성 시 안내 텍스트 ui/views/monitoring/PlacementGuideView.kt: + 매 cycle ClinicalSessionStore.addAlignmentFrame 호출 (clinical alignment 모드 시) — phase=placementPhase.name, score, detected, urineLens, hint, icon + Start Scanning 버튼이 clinical alignment 모드에선 "Complete Alignment" 로 전환 → endMeasurement() → CLINICAL_HOME 복귀 (자동 labdb upload) → enabled 조건도 항상 활성 (사용자 직접 종료) labdb 업로드: 기존 LabdbUploader 경로 그대로 — meta.data_type 으로 002/001 구분. 영향: - 기존 Live 6ch flow: 영향 0 (mode=MEASUREMENT default) - 기존 PlacementGuide UX: 영향 0 (clinical alignment 모드 아닐 때 분기 X) - 신규: Clinical 내 alignment-only 세션 채집/업로드 가능 feature/tab-navigation 에 V2 advisor 버전이 별도 적용됨 (commit 173ff4d 까지). demo-final 은 V1 advisor 만 가능 — V2/Method D/RollingAligner 코드 없음. Co-Authored-By: Claude Opus 4.7 --- .../example/medilightv2android/AppState.kt | 7 ++ .../models/ClinicalSession.kt | 20 +++-- .../services/ClinicalSessionStore.kt | 55 +++++++++++- .../ui/views/clinical/ClinicalHomeView.kt | 85 +++++++++++++------ .../ui/views/monitoring/PlacementGuideView.kt | 48 +++++++++-- 5 files changed, 170 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/example/medilightv2android/AppState.kt b/app/src/main/java/com/example/medilightv2android/AppState.kt index 625c06c..5007fae 100644 --- a/app/src/main/java/com/example/medilightv2android/AppState.kt +++ b/app/src/main/java/com/example/medilightv2android/AppState.kt @@ -164,6 +164,13 @@ class AppState(private val context: Context) { currentScreen = AppScreen.PLACEMENT_GUIDE } + /** Clinical Mode alignment session 진입 — PlacementGuide 로 이동. + * ClinicalSessionStore.startMeasurement(mode=ALIGNMENT) 가 먼저 호출돼 있어야 함. */ + fun enterAlignmentSession() { + placementFromMonitoring = false + currentScreen = AppScreen.PLACEMENT_GUIDE + } + fun backFromPlacementGuide() { // 회의 #8: AutoScan을 Personalization으로 통합했으므로 Placement Back은 // 바로 Personalization으로 (이전 AUTO_SCAN_SETUP 단계 폐기). diff --git a/app/src/main/java/com/example/medilightv2android/models/ClinicalSession.kt b/app/src/main/java/com/example/medilightv2android/models/ClinicalSession.kt index 40ad200..1433382 100644 --- a/app/src/main/java/com/example/medilightv2android/models/ClinicalSession.kt +++ b/app/src/main/java/com/example/medilightv2android/models/ClinicalSession.kt @@ -41,39 +41,45 @@ enum class ClinicalStep(val label: String, val description: String, val isAlignm ALIGN_4CM("4 cm", "치골 위로 4 cm", true), } +/** + * 한 ClinicalSession 의 데이터 수집 모드. + * - ALIGNMENT : PlacementGuide(Sensor Alignment) 에서 V1 advisor 프레임 + 라벨 누적 + * → labdb 업로드 (dataType="002") + * - MEASUREMENT: ClinicalLive 에서 6ch 실시간 측정 누적 (기존 동작) + * → labdb 업로드 (dataType="001") + */ +enum class SessionMode { ALIGNMENT, MEASUREMENT } + data class ClinicalSession( val deviceName: String, // BLE 기기 이름 (예: "VBT26050202") val posture: ClinicalPosture, val step: ClinicalStep, val examiner: String = "", val notes: String = "", - // 측정 대상 분류 (회의: Phantom/Human/ETC 버튼) val subjectType: SubjectType = SubjectType.PHANTOM, - // 카테고리 내 추가 식별자 (회의 추가 결정: optional) - // 예: "500mL", "S001", "small phantom v2" 등 - // 비어있으면 folderName에 포함 안 됨 val subject: String = "", val trueVolumeMl: Double? = null, // 외부 초음파 측정기 참값 (mL) val abdomenThicknessMm: Double? = null, // 복부 두께 측정기 참값 (mm) + val mode: SessionMode = SessionMode.MEASUREMENT, // 신규 + val alignmentAlgo: String = "", // demo-final: V1 only val startedAt: Long = System.currentTimeMillis(), var endedAt: Long? = null ) { private val folderFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US) - /** "PHANTOM-500mL_VBT26050202_SUPINE_ALIGN_0CM_2026-05-28_14-30-15" - * subject 비어있으면 "PHANTOM_VBT...". 폴더명 안전한 문자만 (특수문자 → '-'). */ + /** mode == ALIGNMENT 시 "_ALIGNMODE_" 접미사 (Live 6ch 세션과 폴더 분리). */ val folderName: String get() = buildString { append(subjectType.name) if (subject.isNotBlank()) { append('-') - // 폴더명/파일명 안전 문자만 — testId 형식과도 일관 (^[A-Za-z0-9_-]$) append(subject.replace(Regex("[^A-Za-z0-9_-]"), "-")) } append('_') append(deviceName); append('_') append(posture.name); append('_') append(step.name); append('_') + if (mode == SessionMode.ALIGNMENT) append("ALIGNMODE_") append(folderFmt.format(Date(startedAt))) } diff --git a/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt b/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt index 6523fa5..8f70de0 100644 --- a/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt +++ b/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt @@ -12,12 +12,20 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -/** 한 측정 사이클 = mtb 응답 1회 = 6채널 piezo + 15 IMU 샘플. */ +/** 한 측정 사이클 = mtb 응답 1회 = 6채널 piezo + 15 IMU 샘플. + * alignment 모드일 때 alignXxx 필드들이 채워짐 (V1 advisor 출력). */ data class MeasurementCycle( val cycle: Int, val timestampMs: Long, val piezoChannels: Map>, // channel idx → samples - val imuSamples: List + val imuSamples: List, + // Alignment mode metadata (V1 placementPhase, detected channels, urineLens) + val alignPhase: String = "", // VERTICAL / LATERAL / GREEN / "" + val alignScore: Int = 0, // placementScore + val alignDetected: List = emptyList(), // 6개 채널 검출 여부 + val alignUrineLens: List = emptyList(), // 6개 채널 urineLen + val alignHint: String = "", // directionHint + val alignIcon: String = "", // directionIcon ) /** @@ -92,6 +100,33 @@ object ClinicalSessionStore { )) } + /** Alignment 모드 frame 누적 — V1 advisor 출력 포함 (phase / score / detected / urineLens / hint). */ + fun addAlignmentFrame( + piezo: List, + imu: List, + alignPhase: String, + alignScore: Int, + alignDetected: List, + alignUrineLens: List, + alignHint: String, + alignIcon: String, + ) { + if (currentSession == null) return + cyclesBuffer.add(MeasurementCycle( + cycle = cyclesBuffer.size, + timestampMs = System.currentTimeMillis(), + piezoChannels = piezo.sortedBy { it.channel } + .associate { it.channel to it.buffer.map { s -> s.toInt() } }, + imuSamples = imu.toList(), + alignPhase = alignPhase, + alignScore = alignScore, + alignDetected = alignDetected, + alignUrineLens = alignUrineLens, + alignHint = alignHint, + alignIcon = alignIcon, + )) + } + val cycleCount: Int get() = cyclesBuffer.size /** 현재 측정의 폴더 (= 모든 로거의 저장 디렉토리). */ @@ -196,8 +231,13 @@ object ClinicalSessionStore { val root = JSONObject() // meta: writeMeta()와 동일 내용 포함 (단독 파일로 자족 가능) + // alignment 모드 → dataType=002, 그 외 = 001 (기존 measurement) + val effectiveDataType = if (s.mode == com.example.medilightv2android.models.SessionMode.ALIGNMENT) + "002" else DATA_TYPE val meta = JSONObject().apply { - put("data_type", DATA_TYPE) + put("data_type", effectiveDataType) + put("session_mode", s.mode.name) + if (s.alignmentAlgo.isNotBlank()) put("alignment_algo", s.alignmentAlgo) put("subject_type", s.subjectType.name) if (s.subject.isNotBlank()) put("subject", s.subject) put("device_name", s.deviceName) @@ -252,6 +292,15 @@ object ClinicalSessionStore { put("timestamp_ms", c.timestampMs) put("piezo", piezo) put("imu", imuArr) + // Alignment metadata (alignment mode 일 때만 채워짐) + if (c.alignPhase.isNotBlank()) { + put("align_phase", c.alignPhase) + put("align_score", c.alignScore) + put("align_hint", c.alignHint) + put("align_icon", c.alignIcon) + put("align_detected", JSONArray(c.alignDetected)) + put("align_urine_lens", JSONArray(c.alignUrineLens)) + } }) } root.put("cycles", cyclesArr) diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt index 57eb8a9..ab42788 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MyLocation +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.PlayArrow @@ -476,39 +478,68 @@ fun ClinicalHomeView(appState: AppState) { Spacer(modifier = Modifier.height(8.dp)) - // Start Measurement — Live 6ch 시각화 화면으로 진입 (vesiscan_test run_stream 패턴) - Button( - onClick = { - ClinicalSessionStore.startMeasurement( - context, - ClinicalSession( - deviceName = deviceName, - posture = posture, - step = step, - examiner = examiner.trim(), - notes = notes.trim(), - subjectType = subjectType, - subject = subject.trim(), - trueVolumeMl = trueVolText.toDoubleOrNull(), - abdomenThicknessMm = abdomenText.toDoubleOrNull(), + // 두 가지 진입점 — Live 6ch (기존) + Sensor Alignment (신규) + val labdbOk = LabdbCredentials.isRegistered && labdbStatus == "active" + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // Start Live Measurement (기존) + Button( + onClick = { + ClinicalSessionStore.startMeasurement( + context, + ClinicalSession( + deviceName = deviceName, posture = posture, step = step, + examiner = examiner.trim(), notes = notes.trim(), + subjectType = subjectType, subject = subject.trim(), + trueVolumeMl = trueVolText.toDoubleOrNull(), + abdomenThicknessMm = abdomenText.toDoubleOrNull(), + mode = com.example.medilightv2android.models.SessionMode.MEASUREMENT, + ) ) - ) - appState.currentScreen = AppScreen.CLINICAL_LIVE - }, - enabled = isConnected, - modifier = Modifier.fillMaxWidth().height(56.dp), - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.buttonColors(containerColor = MlPrimary) - ) { - Icon(Icons.Default.PlayArrow, null, tint = Color.White) - Spacer(modifier = Modifier.width(8.dp)) - Text("Start Live Measurement", color = Color.White, - fontWeight = FontWeight.Bold, fontSize = 16.sp) + appState.currentScreen = AppScreen.CLINICAL_LIVE + }, + enabled = isConnected, + modifier = Modifier.weight(1f).height(56.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = MlPrimary) + ) { + Icon(Icons.Default.PlayArrow, null, tint = Color.White) + Spacer(modifier = Modifier.width(6.dp)) + Text("Live 6ch", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp) + } + // Sensor Alignment — 신규. BLE + labdb 둘 다 OK 일 때만 활성. + Button( + onClick = { + ClinicalSessionStore.startMeasurement( + context, + ClinicalSession( + deviceName = deviceName, posture = posture, step = step, + examiner = examiner.trim(), notes = notes.trim(), + subjectType = subjectType, subject = subject.trim(), + trueVolumeMl = trueVolText.toDoubleOrNull(), + abdomenThicknessMm = abdomenText.toDoubleOrNull(), + mode = com.example.medilightv2android.models.SessionMode.ALIGNMENT, + alignmentAlgo = "V1", // demo-final: V1 전용 + ) + ) + appState.enterAlignmentSession() + }, + enabled = isConnected && labdbOk, + modifier = Modifier.weight(1f).height(56.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF7C3AED)) + ) { + Icon(Icons.Default.MyLocation, null, tint = Color.White) + Spacer(modifier = Modifier.width(6.dp)) + Text("Sensor Alignment", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp) + } } if (!isConnected) { Spacer(modifier = Modifier.height(4.dp)) Text("→ BLE 연결 후 활성화", fontSize = 11.sp, color = Color(0xFFFF9800)) + } else if (!labdbOk) { + Spacer(modifier = Modifier.height(4.dp)) + Text("→ Sensor Alignment 는 labdb 활성 필요", fontSize = 11.sp, color = Color(0xFFFF9800)) } Spacer(modifier = Modifier.height(24.dp)) diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index 94f34a1..9a6784f 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.example.medilightv2android.AppScreen import com.example.medilightv2android.AppState import com.example.medilightv2android.ble.BleManager import com.example.medilightv2android.ble.PiezoChannelData @@ -844,11 +845,24 @@ fun PlacementGuideView(appState: AppState) { score = placementScore ) - // 임상 모드에서는 raw ADC도 adc.csv에 자동 저장 (정렬모드 알고리즘 분석용). - // 일반 사용에선 1초마다 도는데 매번 저장하면 파일 폭주하므로 임상 모드 한정. - if (com.example.medilightv2android.services.ClinicalSessionStore.currentSession != null) { + // 임상 모드에서는 raw ADC도 adc.csv에 자동 저장. + val clinSess = com.example.medilightv2android.services.ClinicalSessionStore.currentSession + if (clinSess != null) { val rawADC = channels.sortedBy { it.channel }.map { it.buffer } com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, null) + // Alignment session 진행 중이면 measurement.json 용 frame 누적 (V1 advisor 출력). + if (clinSess.mode == com.example.medilightv2android.models.SessionMode.ALIGNMENT) { + com.example.medilightv2android.services.ClinicalSessionStore.addAlignmentFrame( + piezo = channels, + imu = lastImuSamples, + alignPhase = placementPhase.name, + alignScore = placementScore, + alignDetected = (0 until 6).map { channelDetected[it] }, + alignUrineLens = (0 until 6).map { channelUrineLen[it] }, + alignHint = directionHint, + alignIcon = directionIcon, + ) + } } } } // handler.post @@ -931,25 +945,43 @@ fun PlacementGuideView(appState: AppState) { } } - // Start Scanning button (스캔 진행 중에만) + // Start Scanning / Complete Alignment 버튼 — clinical alignment 모드면 후자 + val curClinSess = com.example.medilightv2android.services.ClinicalSessionStore.currentSession + val isClinicalAlign = curClinSess != null && + curClinSess.mode == com.example.medilightv2android.models.SessionMode.ALIGNMENT if (!waitingForStart) Button( onClick = { isContinuousMode = false - appState.placementComplete() + if (isClinicalAlign) { + // endMeasurement → measurement.json finalize + labdb 자동 업로드 + com.example.medilightv2android.services.ClinicalSessionStore.endMeasurement(context) + com.example.medilightv2android.services.ClinicalSessionStore.inClinicalFlow = true + appState.currentScreen = AppScreen.CLINICAL_HOME + } else { + appState.placementComplete() + } }, - enabled = isLocked, // GREEN(Optimal)일 때만 활성 + // Clinical alignment: 항상 활성 / 일반 모드: GREEN 일 때만 + enabled = isClinicalAlign || isLocked, modifier = Modifier .fillMaxWidth() .height(96.dp), shape = RoundedCornerShape(18.dp), colors = ButtonDefaults.buttonColors( - containerColor = if (isLocked) Color(0xFF4CAF50) else MlPrimary, + containerColor = when { + isClinicalAlign -> Color(0xFF7C3AED) + isLocked -> Color(0xFF4CAF50) + else -> MlPrimary + }, disabledContainerColor = Color.Gray.copy(alpha = 0.3f) ) ) { Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = Color.White, modifier = Modifier.size(32.dp)) Spacer(modifier = Modifier.width(10.dp)) - Text("Start Scanning", fontSize = 28.sp, fontWeight = FontWeight.Bold, color = Color.White) + Text( + if (isClinicalAlign) "Complete Alignment" else "Start Scanning", + fontSize = 28.sp, fontWeight = FontWeight.Bold, color = Color.White + ) } // Skip button (태블릿: 15 → 20sp)