feat: Clinical Mode 에 Sensor Alignment 세션 추가 (demo-final, V1 전용)

새 흐름:
  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 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 13:16:49 +09:00
parent 0d938af352
commit 429964571f
5 changed files with 170 additions and 45 deletions
@@ -164,6 +164,13 @@ class AppState(private val context: Context) {
currentScreen = AppScreen.PLACEMENT_GUIDE currentScreen = AppScreen.PLACEMENT_GUIDE
} }
/** Clinical Mode alignment session 진입 — PlacementGuide 로 이동.
* ClinicalSessionStore.startMeasurement(mode=ALIGNMENT) 가 먼저 호출돼 있어야 함. */
fun enterAlignmentSession() {
placementFromMonitoring = false
currentScreen = AppScreen.PLACEMENT_GUIDE
}
fun backFromPlacementGuide() { fun backFromPlacementGuide() {
// 회의 #8: AutoScan을 Personalization으로 통합했으므로 Placement Back은 // 회의 #8: AutoScan을 Personalization으로 통합했으므로 Placement Back은
// 바로 Personalization으로 (이전 AUTO_SCAN_SETUP 단계 폐기). // 바로 Personalization으로 (이전 AUTO_SCAN_SETUP 단계 폐기).
@@ -41,39 +41,45 @@ enum class ClinicalStep(val label: String, val description: String, val isAlignm
ALIGN_4CM("4 cm", "치골 위로 4 cm", true), 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( data class ClinicalSession(
val deviceName: String, // BLE 기기 이름 (예: "VBT26050202") val deviceName: String, // BLE 기기 이름 (예: "VBT26050202")
val posture: ClinicalPosture, val posture: ClinicalPosture,
val step: ClinicalStep, val step: ClinicalStep,
val examiner: String = "", val examiner: String = "",
val notes: String = "", val notes: String = "",
// 측정 대상 분류 (회의: Phantom/Human/ETC 버튼)
val subjectType: SubjectType = SubjectType.PHANTOM, val subjectType: SubjectType = SubjectType.PHANTOM,
// 카테고리 내 추가 식별자 (회의 추가 결정: optional)
// 예: "500mL", "S001", "small phantom v2" 등
// 비어있으면 folderName에 포함 안 됨
val subject: String = "", val subject: String = "",
val trueVolumeMl: Double? = null, // 외부 초음파 측정기 참값 (mL) val trueVolumeMl: Double? = null, // 외부 초음파 측정기 참값 (mL)
val abdomenThicknessMm: Double? = null, // 복부 두께 측정기 참값 (mm) val abdomenThicknessMm: Double? = null, // 복부 두께 측정기 참값 (mm)
val mode: SessionMode = SessionMode.MEASUREMENT, // 신규
val alignmentAlgo: String = "", // demo-final: V1 only
val startedAt: Long = System.currentTimeMillis(), val startedAt: Long = System.currentTimeMillis(),
var endedAt: Long? = null var endedAt: Long? = null
) { ) {
private val folderFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US) private val folderFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
/** "PHANTOM-500mL_VBT26050202_SUPINE_ALIGN_0CM_2026-05-28_14-30-15" /** mode == ALIGNMENT 시 "_ALIGNMODE_" 접미사 (Live 6ch 세션과 폴더 분리). */
* subject 비어있으면 "PHANTOM_VBT...". 폴더명 안전한 문자만 (특수문자 → '-'). */
val folderName: String val folderName: String
get() = buildString { get() = buildString {
append(subjectType.name) append(subjectType.name)
if (subject.isNotBlank()) { if (subject.isNotBlank()) {
append('-') append('-')
// 폴더명/파일명 안전 문자만 — testId 형식과도 일관 (^[A-Za-z0-9_-]$)
append(subject.replace(Regex("[^A-Za-z0-9_-]"), "-")) append(subject.replace(Regex("[^A-Za-z0-9_-]"), "-"))
} }
append('_') append('_')
append(deviceName); append('_') append(deviceName); append('_')
append(posture.name); append('_') append(posture.name); append('_')
append(step.name); append('_') append(step.name); append('_')
if (mode == SessionMode.ALIGNMENT) append("ALIGNMODE_")
append(folderFmt.format(Date(startedAt))) append(folderFmt.format(Date(startedAt)))
} }
@@ -12,12 +12,20 @@ import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
/** 한 측정 사이클 = mtb 응답 1회 = 6채널 piezo + 15 IMU 샘플. */ /** 한 측정 사이클 = mtb 응답 1회 = 6채널 piezo + 15 IMU 샘플.
* alignment 모드일 때 alignXxx 필드들이 채워짐 (V1 advisor 출력). */
data class MeasurementCycle( data class MeasurementCycle(
val cycle: Int, val cycle: Int,
val timestampMs: Long, val timestampMs: Long,
val piezoChannels: Map<Int, List<Int>>, // channel idx → samples val piezoChannels: Map<Int, List<Int>>, // channel idx → samples
val imuSamples: List<ImuSample> val imuSamples: List<ImuSample>,
// Alignment mode metadata (V1 placementPhase, detected channels, urineLens)
val alignPhase: String = "", // VERTICAL / LATERAL / GREEN / ""
val alignScore: Int = 0, // placementScore
val alignDetected: List<Boolean> = emptyList(), // 6개 채널 검출 여부
val alignUrineLens: List<Int> = 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<PiezoChannelData>,
imu: List<ImuSample>,
alignPhase: String,
alignScore: Int,
alignDetected: List<Boolean>,
alignUrineLens: List<Int>,
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 val cycleCount: Int get() = cyclesBuffer.size
/** 현재 측정의 폴더 (= 모든 로거의 저장 디렉토리). */ /** 현재 측정의 폴더 (= 모든 로거의 저장 디렉토리). */
@@ -196,8 +231,13 @@ object ClinicalSessionStore {
val root = JSONObject() val root = JSONObject()
// meta: writeMeta()와 동일 내용 포함 (단독 파일로 자족 가능) // 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 { 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) put("subject_type", s.subjectType.name)
if (s.subject.isNotBlank()) put("subject", s.subject) if (s.subject.isNotBlank()) put("subject", s.subject)
put("device_name", s.deviceName) put("device_name", s.deviceName)
@@ -252,6 +292,15 @@ object ClinicalSessionStore {
put("timestamp_ms", c.timestampMs) put("timestamp_ms", c.timestampMs)
put("piezo", piezo) put("piezo", piezo)
put("imu", imuArr) 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) root.put("cycles", cyclesArr)
@@ -8,6 +8,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
@@ -476,39 +478,68 @@ fun ClinicalHomeView(appState: AppState) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Start Measurement — Live 6ch 시각화 화면으로 진입 (vesiscan_test run_stream 패턴) // 두 가지 진입점 — Live 6ch (기존) + Sensor Alignment (신규)
Button( val labdbOk = LabdbCredentials.isRegistered && labdbStatus == "active"
onClick = { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClinicalSessionStore.startMeasurement( // Start Live Measurement (기존)
context, Button(
ClinicalSession( onClick = {
deviceName = deviceName, ClinicalSessionStore.startMeasurement(
posture = posture, context,
step = step, ClinicalSession(
examiner = examiner.trim(), deviceName = deviceName, posture = posture, step = step,
notes = notes.trim(), examiner = examiner.trim(), notes = notes.trim(),
subjectType = subjectType, subjectType = subjectType, subject = subject.trim(),
subject = subject.trim(), trueVolumeMl = trueVolText.toDoubleOrNull(),
trueVolumeMl = trueVolText.toDoubleOrNull(), abdomenThicknessMm = abdomenText.toDoubleOrNull(),
abdomenThicknessMm = abdomenText.toDoubleOrNull(), mode = com.example.medilightv2android.models.SessionMode.MEASUREMENT,
)
) )
) appState.currentScreen = AppScreen.CLINICAL_LIVE
appState.currentScreen = AppScreen.CLINICAL_LIVE },
}, enabled = isConnected,
enabled = isConnected, modifier = Modifier.weight(1f).height(56.dp),
modifier = Modifier.fillMaxWidth().height(56.dp), shape = RoundedCornerShape(14.dp),
shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary) ) {
) { Icon(Icons.Default.PlayArrow, null, tint = Color.White)
Icon(Icons.Default.PlayArrow, null, tint = Color.White) Spacer(modifier = Modifier.width(6.dp))
Spacer(modifier = Modifier.width(8.dp)) Text("Live 6ch", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp)
Text("Start Live Measurement", color = Color.White, }
fontWeight = FontWeight.Bold, fontSize = 16.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) { if (!isConnected) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text("→ BLE 연결 후 활성화", fontSize = 11.sp, color = Color(0xFFFF9800)) 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)) Spacer(modifier = Modifier.height(24.dp))
@@ -42,6 +42,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.medilightv2android.AppScreen
import com.example.medilightv2android.AppState import com.example.medilightv2android.AppState
import com.example.medilightv2android.ble.BleManager import com.example.medilightv2android.ble.BleManager
import com.example.medilightv2android.ble.PiezoChannelData import com.example.medilightv2android.ble.PiezoChannelData
@@ -844,11 +845,24 @@ fun PlacementGuideView(appState: AppState) {
score = placementScore score = placementScore
) )
// 임상 모드에서는 raw ADC도 adc.csv에 자동 저장 (정렬모드 알고리즘 분석용). // 임상 모드에서는 raw ADC도 adc.csv에 자동 저장.
// 일반 사용에선 1초마다 도는데 매번 저장하면 파일 폭주하므로 임상 모드 한정. val clinSess = com.example.medilightv2android.services.ClinicalSessionStore.currentSession
if (com.example.medilightv2android.services.ClinicalSessionStore.currentSession != null) { if (clinSess != null) {
val rawADC = channels.sortedBy { it.channel }.map { it.buffer } val rawADC = channels.sortedBy { it.channel }.map { it.buffer }
com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, null) 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 } // 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( if (!waitingForStart) Button(
onClick = { onClick = {
isContinuousMode = false 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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(96.dp), .height(96.dp),
shape = RoundedCornerShape(18.dp), shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors( 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) disabledContainerColor = Color.Gray.copy(alpha = 0.3f)
) )
) { ) {
Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = Color.White, modifier = Modifier.size(32.dp)) Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = Color.White, modifier = Modifier.size(32.dp))
Spacer(modifier = Modifier.width(10.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) // Skip button (태블릿: 15 → 20sp)