From bd7fb05c6ec3affc39456b474c5bb6ab8043e02c Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 8 Sep 2026 12:11:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(clinical):=20=EC=A0=95=EB=A0=AC=EC=97=90?= =?UTF-8?q?=20=EC=A2=8C=EC=9A=B0(LR)=20=EB=8B=A8=EA=B3=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 임상 정렬(AnchorAlignView → AnchorGuide)은 상하만 맞춘다. 지표(nch·ch3·cap_frac)가 전부 CH0~CH3 기준이라, 좌우가 틀어져 있어도 상하 최적은 그대로 정해진다. 그 상태로 임상을 진행하면 어긋난 단면에서 자세×용량 전체가 쌓인다. 레퍼런스에는 좌우 규칙이 이미 있다 — SPEC §8 "좌우(LR) 정렬", `alignment_runners.py:alignment_advice`. Kotlin 포팅본도 `AlignmentAdvice.advise` 로 이미 있었고 LAT_TOL(8)까지 일치한다. 없던 것은 **임상 화면에 붙는 배선**뿐이었다. LateralGuide 를 새로 둔다. 좌우 규칙이 아니라 그 앞단(프레임 누적 → 평균 → 검출 → 요약)만 맡는다 — 파이썬에서 `RollingAligner` 가 하는 일이다. 같은 이름의 Kotlin 클래스(AlignmentAdvisorV2.RollingAligner)를 쓰지 않은 이유는 그쪽이 6단계 상태기라 상하 단계를 통째로 끌고 오기 때문이다. 임상은 상하를 AnchorGuide 가 이미 정했다. Phase4.LR_BALANCE 도 쓰지 않았다. 거기엔 레퍼런스에 없는 것이 붙어 있다 — 방향 반전에 deadband(3)와 연속 2회 조건. 파이썬 제품 경로가 부르는 것은 stateless 한 alignment_advice 이고, 진동은 accum_k(10) 프레임 평균으로만 잡는다. 검출은 CCC **on** 이다. 상하(nch·ch3)는 off 로 재지만(SPEC §8.2), 파이썬 detect() 가 detect_walls 를 기본값(apply_cross=True)으로 부르기 때문이다. off 로 맞추면 같은 신호에 다른 urine_len 이 나와 좌우 판정이 레퍼런스와 갈린다. 화면: 상하 확정 뒤 "2단계 · 좌우 정렬" 카드. 방향 화살표를 크게, 근거(ch4·ch5·|Δ|)를 그 아래. 균형 도달(STOP) 때만 "좌우 확인 완료"를 누를 수 있다 — 아무 때나 누르면 그 표시가 뜻을 잃는다. 다만 **진행을 막지는 않는다**: lateral 이 끝내 안 잡히는 환자가 있는데 그때 막으면 임상 자체가 멈춘다. 대신 미확인 상태를 문구로 남긴다. 요약 JSON 에 lateral 블록을 남긴다. `done=false` 는 "안 맞췄다"가 아니라 "확인 단계를 거치지 않았다"는 뜻이다 — 진행을 막지 않으므로 둘을 구분해야 재분석 때 가려낼 수 있다. 테스트: alignment_advice 결정표 5분기 전부 + 누적 규약(슬라이딩·reset·채널 부족). ch3 게이트가 좌우보다 먼저라는 것도 고정했다 — 검출이 없을 때 PROBE_LR 이 아니라 MOVE_UP 이 나와야 한다(작성 중 이 기대를 틀리게 잡았다가 테스트가 잡아냈다). Co-Authored-By: Claude Opus 5 --- .../vesiscan/managers/LateralGuide.kt | 133 +++++++++ .../ui/views/clinical/AnchorAlignView.kt | 263 +++++++++++++++++- .../managers/LateralAlignParityTest.kt | 133 +++++++++ 3 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/medithings/vesiscan/managers/LateralGuide.kt create mode 100644 app/src/test/java/com/medithings/vesiscan/managers/LateralAlignParityTest.kt diff --git a/app/src/main/java/com/medithings/vesiscan/managers/LateralGuide.kt b/app/src/main/java/com/medithings/vesiscan/managers/LateralGuide.kt new file mode 100644 index 0000000..4f6f4b4 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/managers/LateralGuide.kt @@ -0,0 +1,133 @@ +/* + * LateralGuide — 좌우(LR) 정렬. 상하(anchor) 확정 **뒤** 단계. + * + * Python 원본: `vesiscan_test/library/alignment_runners.py` + * `RollingAligner.push` → `detect` → `summarize` → `alignment_advice` + * 계약 문서 : `vesiscan_test/porting/SPEC.md` §8 "좌우(LR) 정렬" + * + * ## 왜 별도 파일인가 + * 좌우 규칙 자체는 [AlignmentAdvice.advise] 가 이미 갖고 있다(레퍼런스와 1:1). 이 파일은 + * 그 앞단 — **프레임 누적 → 평균 → 검출 → 요약** — 만 맡는다. 파이썬에서 그 앞단을 + * 담당하는 것이 `RollingAligner` 인데, 같은 이름의 Kotlin 클래스는 6단계 상태기라 + * (`AlignmentAdvisorV2.RollingAligner`) 일반 사용자 화면 전용이고 상하 단계를 통째로 + * 끌고 온다. 임상 정렬은 상하를 [AnchorGuide] 가 이미 정했으므로 그 단계들이 필요 없다. + * + * ## 왜 [AlignmentAdvisorV2] 의 Phase4.LR_BALANCE 를 쓰지 않나 + * 그쪽에는 레퍼런스에 없는 것이 붙어 있다 — 방향 반전에 deadband(3)와 연속 2회 조건. + * 파이썬 제품 경로(`RollingAligner.push`)가 부르는 것은 **stateless** 한 + * `alignment_advice` 다. 진동은 파이썬도 `accum_k` 프레임 평균으로만 잡는다. + * SPEC 준수가 이 저장소의 규칙이라 여기서는 레퍼런스 경로를 그대로 쓴다. + * + * ## 검출 파라미터 — CCC **on** + * 상하 단계(nch·ch3)는 CCC off 로 재지만(SPEC §8.2), 좌우는 다르다. 파이썬 `detect()` 가 + * `detect_walls` 를 기본값(`apply_cross=True`)으로 부르기 때문이다. 여기서 off 로 맞추면 + * 같은 신호에 다른 `urine_len` 이 나와 좌우 판정이 레퍼런스와 갈린다. + */ +package com.medithings.vesiscan.managers + +import com.medithings.vesiscan.walldetect.MethodDResult +import com.medithings.vesiscan.walldetect.MethodDRunner +import com.medithings.vesiscan.walldetect.algo.methodd.MethodDParams +import kotlin.math.abs + +/** + * 좌우 정렬 상태기. 프레임을 [push] 로 하나씩 넣는다. + * + * @param accumK 판정 1회에 쓰는 프레임 수. 파이썬 `RollingAligner(accum_k=10)` 기본값. + * @param latTol 좌우 균형 허용 |u4−u5|. 파이썬 `LAT_TOL`. + */ +class LateralGuide( + private val accumK: Int = ACCUM_K_DEFAULT, + private val latTol: Int = AlignmentConstants.LAT_TOL, + private val params: MethodDParams = MethodDParams.DEFAULT, +) { + + /** + * 한 번의 판정 결과. + * + * [u4]·[u5] 는 CH4·CH5 의 `urineLen` 이다(ch4=left, ch5=right). null = 그 채널 미검출. + * [imbalance] 는 둘 다 검출됐을 때만 값이 있다 — 한쪽만 잡힌 상태에서 0 이나 임의값을 + * 넣으면 화면이 "균형에 가깝다"로 읽히는데 실제로는 판단 근거가 없는 상태다. + */ + data class Commit( + val action: AlignAction, + val msg: String, + val ch3: Boolean, + val u4: Int?, + val u5: Int?, + val imbalance: Int?, + val walls: List, + ) + + private val buf = ArrayDeque>() + + /** 마지막 판정. 아직 한 번도 못 채웠으면 null. */ + var committed: Commit? = null + private set + + /** 지금까지 모인 프레임 수. 화면 진행 표시용. */ + val accumulated: Int get() = buf.size + + /** 필요한 프레임 수. */ + val required: Int get() = accumK + + /** 위치를 옮겼을 때 부른다 — 옛 위치 프레임이 평균에 섞이면 판정이 흐려진다. */ + fun reset() { + buf.clear() + committed = null + } + + /** + * 6채널 프레임 1개 입력. + * + * @return 이번 프레임으로 판정이 갱신됐으면 그 결과, 아직 누적 중이면 null. + */ + fun push(frame6: List): Commit? { + if (frame6.size < 6) return null + buf.addLast(frame6) + // 슬라이딩 — 파이썬 deque(maxlen=accum_k) 와 같다. 가득 찬 뒤에는 매 프레임 판정한다. + while (buf.size > accumK) buf.removeFirst() + if (buf.size < accumK) return null + + val avg = average(buf) + val walls = MethodDRunner.detectMultichannel( + avg, params, applyTgc = true, applyCross = true, + ) + val ch3 = walls.size > CH3_INDEX && walls[CH3_INDEX] != null + val u4 = walls.getOrNull(CH4_INDEX)?.urineLen + val u5 = walls.getOrNull(CH5_INDEX)?.urineLen + val (action, msg) = AlignmentAdvice.advise(ch3, u4, u5, latTol) + + return Commit( + action = action, + msg = msg, + ch3 = ch3, + u4 = u4, + u5 = u5, + imbalance = if (u4 != null && u5 != null) abs(u4 - u5) else null, + walls = walls, + ).also { committed = it } + } + + /** 채널별 표본 평균. 길이가 다른 프레임이 섞이면 가장 짧은 것에 맞춘다. */ + private fun average(frames: Collection>): List { + val n = frames.size + return (0 until 6).map { ch -> + val len = frames.minOf { it[ch].size } + DoubleArray(len) { k -> + var s = 0.0 + for (f in frames) s += f[ch][k] + s / n + } + } + } + + companion object { + /** 파이썬 `RollingAligner(accum_k=10)`. */ + const val ACCUM_K_DEFAULT = 10 + + private const val CH3_INDEX = 3 + private const val CH4_INDEX = 4 + private const val CH5_INDEX = 5 + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt index e5295fd..3ff83ad 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt @@ -43,11 +43,14 @@ import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid import com.medithings.vesiscan.AppScreen import com.medithings.vesiscan.AppState import com.medithings.vesiscan.ble.BleManager +import com.medithings.vesiscan.managers.AlignAction +import com.medithings.vesiscan.managers.AlignmentConstants import com.medithings.vesiscan.managers.AnchorAction import com.medithings.vesiscan.managers.AnchorConfig import com.medithings.vesiscan.managers.AnchorGuide import com.medithings.vesiscan.managers.AnchorPosRecord import com.medithings.vesiscan.managers.AnchorStep +import com.medithings.vesiscan.managers.LateralGuide import com.medithings.vesiscan.models.HospitalFixedParams import com.medithings.vesiscan.services.HospitalRunStore import org.json.JSONArray @@ -99,6 +102,21 @@ fun AnchorAlignView(appState: AppState) { var liveChannels by remember { mutableStateOf>(emptyList()) } + // ── 좌우(LR) 정렬 ──────────────────────────────────────────────────────── + // 상하(anchor)가 확정된 **뒤** 도는 단계다. AnchorGuide 의 지표(nch·ch3·cap_frac)는 + // 전부 CH0~CH3 기준이라 좌우가 틀어져 있어도 상하는 정해진다 — 그 자리에서 CH4·CH5 + // 로 좌우를 맞춘다. 규칙은 [LateralGuide] 가 소유한다(SPEC §8 "좌우(LR) 정렬"). + // + // 파이썬 `AlignGuide.step` 도 수직 → 좌우 순서로 한 번만 가고 되돌아가지 않는다. + // 그래서 좌우를 맞춘 뒤 상하를 재확인하지 않는다. + val lateral = remember { LateralGuide() } + var lrRunning by remember { mutableStateOf(false) } + var lrCommit by remember { mutableStateOf(null) } + var lrAccum by remember { mutableIntStateOf(0) } + var lrError by remember { mutableStateOf(null) } + /** 간호사가 "좌우 확인 완료"를 누른 적이 있는가. 저장·다음 단계 안내에 쓴다. */ + var lrConfirmed by remember { mutableStateOf(false) } + // 한 정렬 세션의 시작 시각 — 저장 폴더가 위치마다 갈리지 않게 고정한다. val runStartedAt = remember { Date() } // 저장 폴더 이름. 환자명이 비면 시각으로 대체한다(데이터를 잃지 않기 위해). @@ -214,6 +232,72 @@ fun AnchorAlignView(appState: AppState) { } } + // 좌우 정렬 루프 — 연속 스트리밍. 위치 측정(20 cycle 배치)과 달리 프레임을 계속 + // 흘려 넣고 [LateralGuide] 가 accumK 개마다 판정한다(파이썬 RollingAligner 와 같다). + LaunchedEffect(lrRunning) { + if (!lrRunning) return@LaunchedEffect + lrError = null + val inbox = kotlinx.coroutines.channels.Channel< + List>( + kotlinx.coroutines.channels.Channel.CONFLATED) + // 좌우도 정렬 조건(2.3MHz·c3)으로 잰다 — 상하와 다른 조건으로 재면 검출 문턱이 + // 달라져 u4·u5 가 위치 측정 때와 다른 스케일이 된다. + val guard = ProbeConfigGuard(bleManager) + guard.capture() + val prev = bleManager.piezoCollector.onMultiChannelComplete + bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) } + try { + bleManager.piezoConfigEcho.value = null + bleManager.sendPiezoConfig( + freq = ALIGN_FREQ.freqOption, + cycles = ALIGN_CYCLE.cycles, + avg = HospitalFixedParams.AVG, + delayUs = HospitalFixedParams.DELAY_US, + samples = HospitalFixedParams.SAMPLES, + ) + val echo = withTimeoutOrNull(CONFIG_TIMEOUT_MS) { + while (bleManager.piezoConfigEcho.value == null) delay(20) + bleManager.piezoConfigEcho.value + } + if (echo?.matches( + ALIGN_FREQ.freqOption, ALIGN_CYCLE.cycles, + HospitalFixedParams.AVG, HospitalFixedParams.DELAY_US, + HospitalFixedParams.SAMPLES, + ) != true + ) { + lrError = "프로브 설정 실패 — ${echo?.error ?: "응답 없음 또는 설정 불일치"}" + return@LaunchedEffect + } + delay(SETTLE_MS) + + while (lrRunning) { + while (inbox.tryReceive().isSuccess) { /* 늦게 온 직전 결과 비우기 */ } + bleManager.sendMtb() + val got = withTimeoutOrNull(MEASURE_TIMEOUT_MS) { inbox.receive() } + if (got == null) { + // 한 프레임 놓친 것으로 멈추지 않는다 — 좌우는 사람이 프로브를 움직이는 + // 중이라 간헐적 미수신이 정상이다. 계속 돌리고 다음 프레임을 기다린다. + continue + } + val sorted = got.sortedBy { it.channel } + liveChannels = sorted + val frame = sorted.map { ch -> + DoubleArray(ch.buffer.size) { k -> ch.buffer[k].toDouble() } + } + lateral.push(frame)?.let { lrCommit = it } + lrAccum = lateral.accumulated + delay(CYCLE_GAP_MS) + } + } finally { + bleManager.piezoCollector.onMultiChannelComplete = prev + kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { + guard.restore() + } + inbox.close() + lrRunning = false + } + } + Column( Modifier.fillMaxSize().background(MlBackground).verticalScroll(rememberScrollState()) ) { @@ -381,21 +465,74 @@ fun AnchorAlignView(appState: AppState) { ) { Text("여기까지로 결정하기", color = MlSecondaryText) } } } else { + // ── 2단계: 좌우 정렬 ─────────────────────────────────────── + // 상하가 정해진 자리에서 프로브를 좌우로만 움직여 CH4·CH5 를 맞춘다. + LateralSection( + running = lrRunning, + commit = lrCommit, + accumulated = lrAccum, + required = lateral.required, + confirmed = lrConfirmed, + error = lrError, + enabled = isConnected, + onToggle = { + if (lrRunning) { + lrRunning = false + } else { + // 다시 시작할 때는 옛 프레임을 버린다 — 이전 위치의 신호가 + // 평균에 섞이면 첫 판정이 엉뚱한 방향을 가리킨다. + lateral.reset(); lrCommit = null; lrAccum = 0; lrConfirmed = false + lrRunning = true + } + }, + onConfirm = { + lrRunning = false + lrConfirmed = true + // 좌우 결과를 요약에 남긴다. 나중에 "이 세션은 좌우까지 맞춘 + // 것인가"를 파일만 보고 답할 수 있어야 한다 — 재분석 때 + // 좌우 미확인 세션을 가려낼 유일한 근거다. + last?.let { s -> + runCatching { + val dir = HospitalRunStore.alignDir(saveName, runStartedAt) + HospitalRunStore.writeAlignSummary( + dir, + buildAlignSummary( + saveName, appState.hospitalPatient, deviceName, + bleManager.firmwareVersion.value, guide.records, s, + lrCommit, + ), + ) + } + } + }, + ) + Spacer(Modifier.height(16.dp)) + Button( onClick = { appState.anchorCm = last?.anchorCm appState.currentScreen = AppScreen.HOSPITAL_MODE }, - enabled = last?.action == AnchorAction.STOP, + enabled = last?.action == AnchorAction.STOP && !lrRunning, modifier = Modifier.fillMaxWidth().height(52.dp), shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.buttonColors(containerColor = MlSuccess), ) { Text("이 위치로 임상 측정 진행", fontSize = 16.sp, fontWeight = FontWeight.Bold) } + // 막지는 않는다. lateral 이 끝내 안 잡히는 환자도 있는데 그때 진행을 + // 못 하게 하면 임상 자체가 멈춘다 — 상태만 분명히 보여 준다. + if (!lrConfirmed) { + Spacer(Modifier.height(6.dp)) + Text("좌우 정렬을 아직 확인하지 않았습니다.", + fontSize = 12.sp, color = MlSecondaryText) + } Spacer(Modifier.height(8.dp)) TextButton( onClick = { guide.reset(); cm = 0; last = null; records = emptyList(); error = null + lateral.reset(); lrCommit = null; lrAccum = 0 + lrConfirmed = false; lrError = null; lrRunning = false }, + enabled = !lrRunning, modifier = Modifier.fillMaxWidth(), ) { Text("처음부터 다시 정렬", color = MlSecondaryText) } } @@ -413,6 +550,115 @@ fun AnchorAlignView(appState: AppState) { } } +/** + * 좌우 정렬 카드 — 화살표 하나가 핵심이다. + * + * 간호사는 프로브를 잡은 채 이 화면을 곁눈으로 본다. 그래서 방향을 제일 크게 두고, + * 근거(ch4·ch5·|Δ|)는 그 아래 작은 글씨로 둔다 — 왜 그 방향인지 물어보게 되므로 + * 숨기지는 않는다. + */ +@Composable +private fun LateralSection( + running: Boolean, + commit: LateralGuide.Commit?, + accumulated: Int, + required: Int, + confirmed: Boolean, + error: String?, + enabled: Boolean, + onToggle: () -> Unit, + onConfirm: () -> Unit, +) { + val balanced = commit?.action == AlignAction.STOP + val tone = when { + confirmed || balanced -> MlSuccess + commit == null -> MlSecondaryText + else -> MlPrimary + } + Column( + Modifier.fillMaxWidth() + .background(Color.White, RoundedCornerShape(14.dp)) + .border(1.5.dp, tone.copy(alpha = 0.5f), RoundedCornerShape(14.dp)) + .padding(14.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("2단계 · 좌우 정렬", fontSize = 14.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.weight(1f)) + if (confirmed) Text("확인됨", fontSize = 12.sp, color = MlSuccess, + fontWeight = FontWeight.SemiBold) + } + Spacer(Modifier.height(4.dp)) + Text( + "위치는 그대로 두고 프로브를 좌우로만 옮기며 맞춥니다. " + + "|ch4−ch5| ≤ ${AlignmentConstants.LAT_TOL} 이면 완료입니다.", + fontSize = 12.sp, color = MlSecondaryText, + ) + Spacer(Modifier.height(12.dp)) + + // 방향 — 크게. + val arrow = when (commit?.action) { + AlignAction.MOVE_LEFT -> "←" + AlignAction.MOVE_RIGHT -> "→" + AlignAction.MOVE_UP -> "↑" + AlignAction.PROBE_LR -> "↔" + AlignAction.STOP -> "■" + else -> "–" + } + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(arrow, fontSize = 44.sp, fontWeight = FontWeight.Bold, color = tone) + Spacer(Modifier.height(2.dp)) + Text( + when { + error != null -> "—" + commit != null -> commit.msg + running -> "측정 중… ($accumulated/$required)" + else -> "시작을 누르면 좌우 안내가 나옵니다." + }, + fontSize = 13.sp, color = tone, + ) + } + } + Spacer(Modifier.height(10.dp)) + + // 근거 — 왜 그 방향인지. + commit?.let { c -> + Text( + "ch4(좌) ${c.u4 ?: "미검출"} · ch5(우) ${c.u5 ?: "미검출"}" + + (c.imbalance?.let { " |Δ|=$it" } ?: "") + + " · ch3 ${if (c.ch3) "O" else "X"}", + fontSize = 12.sp, color = MlSecondaryText, + ) + Spacer(Modifier.height(10.dp)) + } + error?.let { + Text(it, fontSize = 12.sp, color = MlCritical) + Spacer(Modifier.height(10.dp)) + } + + Row(Modifier.fillMaxWidth()) { + Button( + onClick = onToggle, + enabled = enabled, + modifier = Modifier.weight(1f).height(46.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (running) MlCritical else MlPrimary), + ) { Text(if (running) "정지" else "좌우 정렬 시작", fontWeight = FontWeight.Bold) } + Spacer(Modifier.width(8.dp)) + Button( + // 균형에 도달했을 때만 누를 수 있다. 아무 때나 확인 처리하면 이 표시가 + // "좌우를 맞췄다"는 뜻을 잃는다. + onClick = onConfirm, + enabled = balanced, + modifier = Modifier.weight(1f).height(46.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors(containerColor = MlSuccess), + ) { Text("좌우 확인 완료", fontWeight = FontWeight.Bold) } + } + } +} + @Composable private fun StatusChip(connected: Boolean, name: String?) { val bg = if (connected) MlSuccess.copy(alpha = 0.15f) else MlCritical.copy(alpha = 0.12f) @@ -465,6 +711,8 @@ private fun buildAlignSummary( firmware: String?, records: List, step: AnchorStep, + /** 좌우 정렬 결과. null = 아직 돌리지 않았음(= 상하만 맞춘 세션). */ + lateral: LateralGuide.Commit? = null, ): JSONObject = JSONObject().apply { put("patient", patient) put("save_name", saveName) @@ -490,6 +738,19 @@ private fun buildAlignSummary( // 앱 검출이 Python 레퍼런스와 불일치인 상태로 낸 값이라는 사실을 데이터와 함께 남긴다. // 2026-09-03 대조 완료: 검출·BV·선택 규칙이 Python 레퍼런스와 일치. put("selection_verified_against_reference", true) + // 좌우 정렬 (SPEC §8 "좌우(LR) 정렬" · LateralGuide). + // done=false 는 "안 맞췄다"가 아니라 "확인 단계를 거치지 않았다"는 뜻이다. + // lateral 이 끝내 안 잡히는 환자도 있어 진행을 막지 않기 때문에 둘을 구분해야 한다. + put("lateral", JSONObject().apply { + put("done", lateral != null) + put("lat_tol", AlignmentConstants.LAT_TOL) + put("accum_k", LateralGuide.ACCUM_K_DEFAULT) + put("action", lateral?.action?.name ?: JSONObject.NULL) + put("u4", lateral?.u4 ?: JSONObject.NULL) + put("u5", lateral?.u5 ?: JSONObject.NULL) + put("imbalance", lateral?.imbalance ?: JSONObject.NULL) + put("ch3", lateral?.ch3 ?: JSONObject.NULL) + }) put("positions", JSONArray().apply { records.forEach { r -> put(JSONObject().apply { diff --git a/app/src/test/java/com/medithings/vesiscan/managers/LateralAlignParityTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/LateralAlignParityTest.kt new file mode 100644 index 0000000..a272632 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/LateralAlignParityTest.kt @@ -0,0 +1,133 @@ +package com.medithings.vesiscan.managers + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * 좌우(LR) 정렬 — Python 레퍼런스 대조. + * + * 원본: `vesiscan_test/library/alignment_runners.py:alignment_advice` + * `vesiscan_test/library/alignment_runners.py:RollingAligner` + * 계약: `vesiscan_test/porting/SPEC.md` §8 "좌우(LR) 정렬" + * + * ``` + * ch3 미검출 → MOVE_UP + * |u4−u5| <= LAT_TOL(8) → STOP + * u4 < u5 → MOVE_RIGHT (ulen 큰 쪽으로) + * 둘 다 미검출 → PROBE_LR + * 한쪽만 미검출 → 그 반대쪽으로 + * ``` + * + * 이 표를 고정해 두는 이유는, 좌우 판정이 **틀려도 화면은 멀쩡해 보이기** 때문이다. + * 화살표가 반대로 나가도 간호사는 그쪽으로 움직이고, 데이터는 어긋난 위치에서 + * 20 cycle × 조합 전체가 쌓인 뒤에야 이상하다는 걸 알게 된다. + */ +class LateralAlignParityTest { + + // ── alignment_advice 결정표 ────────────────────────────────────────────── + + @Test fun `ch3 미검출이면 좌우를 보기 전에 머리방향`() { + // 상하가 안 맞은 상태에서 좌우를 맞추면 엉뚱한 단면을 맞추게 된다. + // u4·u5 가 멀쩡히 잡혀 있어도 ch3 가 우선이다. + val (action, _) = AlignmentAdvice.advise(ch3 = false, u4 = 30, u5 = 90) + assertEquals(AlignAction.MOVE_UP, action) + } + + @Test fun `허용오차 이내면 정지`() { + val tol = AlignmentConstants.LAT_TOL + assertEquals(8, tol) // config_al.py:LAT_TOL — 바뀌면 레퍼런스와 갈린다 + assertEquals(AlignAction.STOP, AlignmentAdvice.advise(true, 50, 50).first) + // 경계값 포함(<=). Python 도 `d <= lat_tol` 이다. + assertEquals(AlignAction.STOP, AlignmentAdvice.advise(true, 50, 50 + tol).first) + assertEquals(AlignAction.STOP, AlignmentAdvice.advise(true, 50 + tol, 50).first) + } + + @Test fun `경계를 한 칸 넘으면 이동`() { + val over = AlignmentConstants.LAT_TOL + 1 + assertEquals(AlignAction.MOVE_RIGHT, AlignmentAdvice.advise(true, 50, 50 + over).first) + assertEquals(AlignAction.MOVE_LEFT, AlignmentAdvice.advise(true, 50 + over, 50).first) + } + + @Test fun `ulen 이 큰 쪽으로 간다`() { + // ch4=left, ch5=right. u4 < u5 → 오른쪽(=ulen 큰 쪽)으로. + // 방향이 뒤집히면 간호사가 정확히 반대로 움직인다 — 제일 위험한 오류다. + assertEquals(AlignAction.MOVE_RIGHT, AlignmentAdvice.advise(true, 10, 90).first) + assertEquals(AlignAction.MOVE_LEFT, AlignmentAdvice.advise(true, 90, 10).first) + } + + @Test fun `둘 다 미검출이면 좌우 탐색`() { + assertEquals(AlignAction.PROBE_LR, AlignmentAdvice.advise(true, null, null).first) + } + + @Test fun `한쪽만 미검출이면 그 반대쪽으로`() { + // 잡히지 않은 쪽이 방광에서 멀다는 뜻이므로 그 반대(잡힌 쪽)로 간다. + // PROBE_LR 로 뭉뚱그리면 방향 정보를 버리게 된다. + assertEquals(AlignAction.MOVE_RIGHT, AlignmentAdvice.advise(true, null, 40).first) + assertEquals(AlignAction.MOVE_LEFT, AlignmentAdvice.advise(true, 40, null).first) + } + + // ── RollingAligner 누적 규약 ───────────────────────────────────────────── + + /** 6채널 평탄 신호 — 검출이 안 되므로 u4·u5 는 null 이 된다(PROBE_LR). */ + private fun flatFrame(value: Double = 100.0, n: Int = 120): List = + List(6) { DoubleArray(n) { value } } + + @Test fun `accumK 를 채우기 전에는 판정하지 않는다`() { + // 파이썬 RollingAligner 는 buf 가 accum_k 에 못 미치면 state='accum' 을 낸다. + // 한두 프레임으로 방향을 내면 손 떨림에 화살표가 요동친다. + val g = LateralGuide() + repeat(LateralGuide.ACCUM_K_DEFAULT - 1) { + assertNull(g.push(flatFrame())) + } + assertEquals(LateralGuide.ACCUM_K_DEFAULT - 1, g.accumulated) + assertNull(g.committed) + + assertNotNull(g.push(flatFrame())) + assertNotNull(g.committed) + } + + @Test fun `가득 찬 뒤에는 매 프레임 판정한다`() { + // deque(maxlen=accum_k) — 슬라이딩이다. 10개마다 한 번이 아니다. + val g = LateralGuide() + repeat(LateralGuide.ACCUM_K_DEFAULT) { g.push(flatFrame()) } + repeat(3) { assertNotNull(g.push(flatFrame())) } + assertEquals(LateralGuide.ACCUM_K_DEFAULT, g.accumulated) // 창 크기 유지 + } + + @Test fun `reset 은 옛 위치 프레임을 버린다`() { + // 프로브를 옮긴 뒤 이전 위치 신호가 평균에 남으면 첫 판정이 엉뚱해진다. + val g = LateralGuide() + repeat(LateralGuide.ACCUM_K_DEFAULT) { g.push(flatFrame()) } + g.reset() + assertEquals(0, g.accumulated) + assertNull(g.committed) + assertNull(g.push(flatFrame())) // 다시 처음부터 채워야 한다 + } + + @Test fun `채널이 모자란 프레임은 버린다`() { + // 6채널이 다 안 온 프레임을 평균에 넣으면 채널 인덱스가 밀린다. + val g = LateralGuide() + assertNull(g.push(List(4) { DoubleArray(120) { 100.0 } })) + assertEquals(0, g.accumulated) + } + + @Test fun `검출이 하나도 없으면 ch3 게이트가 먼저 걸린다`() { + // 평탄 신호 → 어느 채널도 안 잡힌다. 이때 나오는 것은 PROBE_LR 이 아니라 + // **MOVE_UP** 이다. `advise` 의 첫 줄이 ch3 이기 때문이다. + // + // 이 순서가 중요하다. 상하가 안 맞아 ch3 가 없는 상태에서 좌우 탐색을 시키면, + // 간호사는 애초에 신호가 없는 높이에서 프로브를 좌우로만 훑게 된다. + // PROBE_LR 은 "ch3 는 잡혔는데 lateral 만 없다"는 좁은 경우에만 나와야 한다. + val g = LateralGuide() + var c: LateralGuide.Commit? = null + repeat(LateralGuide.ACCUM_K_DEFAULT) { c = g.push(flatFrame()) ?: c } + assertNotNull(c) + assertEquals(false, c!!.ch3) + assertNull(c!!.u4) + assertNull(c!!.u5) + assertNull(c!!.imbalance) // 한쪽이라도 없으면 |Δ| 는 없다 — 0 이 아니다 + assertEquals(AlignAction.MOVE_UP, c!!.action) + } +}