fix(alignment): clinical session — V2 phase label + Step 0 baseline + Back/Skip 분기

#1 placements.jsonl phase 라벨 통일
- 기존: placementPhase.name (V1 phase) 만 저장 → V2 활성이어도 V2 phase 안 보임
- 변경: V2 활성이면 v2Aligner.phase.name 사용 (INITIAL_ACCUM 등 4-stage)
- measurement.json 의 align_phase 와도 동일 라벨로 통일

#2 Step 0 baseline 데이터 캡쳐
- Clinical alignment session 진입 시 isContinuousMode 자동 ON
  (waitingForStart 는 true 유지 — 사용자 Start 누를 때까지)
- baseline cycles 의 phase = "STEP_0_BASELINE" 로 라벨링
- V2 algorithm push 는 !waitingForStart 일 때만 호출 — silent phase 진행 차단
- Start 누를 때 v2Aligner.reset() 추가 (fresh 시작 보장)

#3 Back / Skip 버튼 분기 — clinical alignment session 인 경우 CLINICAL_HOME 으로
- 기존: Back → PIEZO_PERSONALIZATION, Skip → PIEZO_MONITORING (잘못된 화면 이동)
- 변경: clinical alignment session 진입 상태면 discardAndDeleteFolder + CLINICAL_HOME
  (Back/Skip = 저장 의도 X — Complete Alignment 버튼만 저장)
- 일반 placement guide 동작은 그대로 유지

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 18:10:40 +09:00
parent be76f85e34
commit 026076aa11
@@ -132,7 +132,10 @@ fun PlacementGuideView(appState: AppState) {
if (isClinicalAlign) com.example.medilightv2android.managers.AlignmentAlgo.V2 if (isClinicalAlign) com.example.medilightv2android.managers.AlignmentAlgo.V2
else com.example.medilightv2android.managers.AlignmentAlgo.V1 else com.example.medilightv2android.managers.AlignmentAlgo.V1
v2Aligner.reset() v2Aligner.reset()
isContinuousMode = false // Clinical alignment session 진입 시 — 자동으로 scan 시작 (Step 0 baseline 캡쳐).
// waitingForStart 는 true 유지 — 사용자는 여전히 Start 눌러 알고리즘 시작 결정.
// baseline cycles 는 measurement.json 에 phase="STEP_0_BASELINE" 로 누적됨.
isContinuousMode = isClinicalAlign
waitingForStart = true waitingForStart = true
placementPhase = PlacementPhase.VERTICAL placementPhase = PlacementPhase.VERTICAL
isLocked = false isLocked = false
@@ -245,7 +248,19 @@ fun PlacementGuideView(appState: AppState) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = { appState.backFromPlacementGuide() }) { IconButton(onClick = {
isContinuousMode = false
// Clinical alignment session 진입한 경우 — abort 후 Clinical Home 으로.
// (PiezoMonitoring / Personalization 으로 떨어지지 않게)
val curSess = com.example.medilightv2android.services.ClinicalSessionStore.currentSession
if (curSess != null && curSess.mode == com.example.medilightv2android.models.SessionMode.ALIGNMENT) {
com.example.medilightv2android.services.ClinicalSessionStore.discardAndDeleteFolder()
com.example.medilightv2android.services.ClinicalSessionStore.inClinicalFlow = true
appState.currentScreen = AppScreen.CLINICAL_HOME
} else {
appState.backFromPlacementGuide()
}
}) {
Icon( Icon(
Icons.AutoMirrored.Filled.ArrowBack, Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back", contentDescription = "Back",
@@ -827,6 +842,13 @@ fun PlacementGuideView(appState: AppState) {
} else if (com.example.medilightv2android.managers.GreenZoneConstants.alignmentAlgo == } else if (com.example.medilightv2android.managers.GreenZoneConstants.alignmentAlgo ==
com.example.medilightv2android.managers.AlignmentAlgo.V2) { com.example.medilightv2android.managers.AlignmentAlgo.V2) {
// ═══ V2 alignment (alignment.py 포팅, method_d 기반) ═══ // ═══ V2 alignment (alignment.py 포팅, method_d 기반) ═══
// Clinical alignment session + waitingForStart 일 땐 알고리즘 skip —
// baseline cycles 만 누적 (Start 누르기 전 자동 phase 진행 차단).
if (waitingForStart) {
directionHint = "Place sensor above pubic bone, then press Start"
directionIcon = ""
placementScore = 0
} else {
val raw6 = (0..5).map { ch -> val raw6 = (0..5).map { ch ->
val chData = channels.find { it.channel == ch } val chData = channels.find { it.channel == ch }
if (chData != null && chData.buffer.isNotEmpty()) if (chData != null && chData.buffer.isNotEmpty())
@@ -862,6 +884,7 @@ fun PlacementGuideView(appState: AppState) {
if (lastLedMode != 5) { bleManager.sendLedMode(5); lastLedMode = 5 } if (lastLedMode != 5) { bleManager.sendLedMode(5); lastLedMode = 5 }
} }
} }
} // end of !waitingForStart V2 algorithm
} else { } else {
// Sphere-fit 계산 (SWEEP 모드에서 사용, 다른 모드에서는 무시됨) // Sphere-fit 계산 (SWEEP 모드에서 사용, 다른 모드에서는 무시됨)
val fitResult = com.example.medilightv2android.managers.BladderSphereSeek val fitResult = com.example.medilightv2android.managers.BladderSphereSeek
@@ -1004,30 +1027,40 @@ fun PlacementGuideView(appState: AppState) {
Log.d("PlacementGuide", "Scan #$scanCount: ${detected.count { it }}/6, score=$placementScore%") Log.d("PlacementGuide", "Scan #$scanCount: ${detected.count { it }}/6, score=$placementScore%")
// Phase 라벨 — V2 활성이면 V2 4-stage phase, 아니면 V1 placementPhase.
// Step 0 (Start 전 baseline) 은 alignment session 에서만 명시.
val isV2 = com.example.medilightv2android.managers.GreenZoneConstants.alignmentAlgo ==
com.example.medilightv2android.managers.AlignmentAlgo.V2
val clinSess = com.example.medilightv2android.services.ClinicalSessionStore.currentSession
val isClinAlign = clinSess != null &&
clinSess.mode == com.example.medilightv2android.models.SessionMode.ALIGNMENT
val phaseLabel = when {
isClinAlign && waitingForStart -> "STEP_0_BASELINE"
isV2 -> v2Aligner.phase.name
else -> placementPhase.name
}
logService.logPlacement( logService.logPlacement(
channelDetected = detected, channelDetected = detected,
direction = directionHint, direction = directionHint,
urineLens = urineLens.toList(), urineLens = urineLens.toList(),
cv = lastCv, cv = lastCv,
lrDev = Double.NaN, // computePlacementGuide 결과의 lrDev는 guideResult에 있음 lrDev = Double.NaN, // computePlacementGuide 결과의 lrDev는 guideResult에 있음
phase = placementPhase.name, phase = phaseLabel,
score = placementScore score = placementScore
) )
// 임상 모드에서는 raw ADC도 adc.csv에 자동 저장. // 임상 모드에서는 raw ADC도 adc.csv에 자동 저장.
val clinSess = com.example.medilightv2android.services.ClinicalSessionStore.currentSession
if (clinSess != 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 누적. // Alignment session 진행 중이면 measurement.json 용 frame 누적.
if (clinSess.mode == com.example.medilightv2android.models.SessionMode.ALIGNMENT) { if (clinSess.mode == com.example.medilightv2android.models.SessionMode.ALIGNMENT) {
val isV2 = com.example.medilightv2android.managers.GreenZoneConstants.alignmentAlgo ==
com.example.medilightv2android.managers.AlignmentAlgo.V2
com.example.medilightv2android.services.ClinicalSessionStore.addAlignmentFrame( com.example.medilightv2android.services.ClinicalSessionStore.addAlignmentFrame(
piezo = channels, piezo = channels,
imu = lastImuSamples, imu = lastImuSamples,
// V2 활성 시 4-stage phase 이름 (INITIAL_ACCUM 등), 그 외 V1 placementPhase // Step 0 baseline / V2 phase / V1 phase 통일된 라벨 사용
alignPhase = if (isV2) v2Aligner.phase.name else placementPhase.name, alignPhase = phaseLabel,
alignScore = placementScore, alignScore = placementScore,
alignDetected = (0 until 6).map { channelDetected[it] }, alignDetected = (0 until 6).map { channelDetected[it] },
alignUrineLens = (0 until 6).map { channelUrineLen[it] }, alignUrineLens = (0 until 6).map { channelUrineLen[it] },
@@ -1087,6 +1120,9 @@ fun PlacementGuideView(appState: AppState) {
onClick = { onClick = {
waitingForStart = false waitingForStart = false
isContinuousMode = true isContinuousMode = true
// V2 alignment 리셋 — baseline 동안 silent push 가 없었더라도 안전망.
// 사용자 Start 시점부터 fresh accumulation.
v2Aligner.reset()
// 전체 상태 리셋 // 전체 상태 리셋
debounceCount = 0 debounceCount = 0
lastHintChangeMs = 0L lastHintChangeMs = 0L
@@ -1162,7 +1198,19 @@ fun PlacementGuideView(appState: AppState) {
// Skip button (태블릿: 15 → 20sp) // Skip button (태블릿: 15 → 20sp)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = { isContinuousMode = false; appState.placementComplete() }) { TextButton(onClick = {
isContinuousMode = false
// Clinical alignment session 진입 → abort + Clinical Home 으로
// (Skip 은 저장 의도 X)
val curSess = com.example.medilightv2android.services.ClinicalSessionStore.currentSession
if (curSess != null && curSess.mode == com.example.medilightv2android.models.SessionMode.ALIGNMENT) {
com.example.medilightv2android.services.ClinicalSessionStore.discardAndDeleteFolder()
com.example.medilightv2android.services.ClinicalSessionStore.inClinicalFlow = true
appState.currentScreen = AppScreen.CLINICAL_HOME
} else {
appState.placementComplete()
}
}) {
Text( Text(
text = "Skip", text = "Skip",
fontSize = 20.sp, fontSize = 20.sp,