feat(clinical): 정렬에 좌우(LR) 단계 추가

임상 정렬(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 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 12:11:23 +09:00
parent cc950184cd
commit bd7fb05c6e
3 changed files with 528 additions and 1 deletions
@@ -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<MethodDResult?>,
)
private val buf = ArrayDeque<List<DoubleArray>>()
/** 마지막 판정. 아직 한 번도 못 채웠으면 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<DoubleArray>): 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<DoubleArray>>): List<DoubleArray> {
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
}
}
@@ -43,11 +43,14 @@ import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid
import com.medithings.vesiscan.AppScreen import com.medithings.vesiscan.AppScreen
import com.medithings.vesiscan.AppState import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.ble.BleManager 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.AnchorAction
import com.medithings.vesiscan.managers.AnchorConfig import com.medithings.vesiscan.managers.AnchorConfig
import com.medithings.vesiscan.managers.AnchorGuide import com.medithings.vesiscan.managers.AnchorGuide
import com.medithings.vesiscan.managers.AnchorPosRecord import com.medithings.vesiscan.managers.AnchorPosRecord
import com.medithings.vesiscan.managers.AnchorStep import com.medithings.vesiscan.managers.AnchorStep
import com.medithings.vesiscan.managers.LateralGuide
import com.medithings.vesiscan.models.HospitalFixedParams import com.medithings.vesiscan.models.HospitalFixedParams
import com.medithings.vesiscan.services.HospitalRunStore import com.medithings.vesiscan.services.HospitalRunStore
import org.json.JSONArray import org.json.JSONArray
@@ -99,6 +102,21 @@ fun AnchorAlignView(appState: AppState) {
var liveChannels by remember { var liveChannels by remember {
mutableStateOf<List<com.medithings.vesiscan.ble.PiezoChannelData>>(emptyList()) mutableStateOf<List<com.medithings.vesiscan.ble.PiezoChannelData>>(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<LateralGuide.Commit?>(null) }
var lrAccum by remember { mutableIntStateOf(0) }
var lrError by remember { mutableStateOf<String?>(null) }
/** 간호사가 "좌우 확인 완료"를 누른 적이 있는가. 저장·다음 단계 안내에 쓴다. */
var lrConfirmed by remember { mutableStateOf(false) }
// 한 정렬 세션의 시작 시각 — 저장 폴더가 위치마다 갈리지 않게 고정한다. // 한 정렬 세션의 시작 시각 — 저장 폴더가 위치마다 갈리지 않게 고정한다.
val runStartedAt = remember { Date() } 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<com.medithings.vesiscan.ble.PiezoChannelData>>(
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( Column(
Modifier.fillMaxSize().background(MlBackground).verticalScroll(rememberScrollState()) Modifier.fillMaxSize().background(MlBackground).verticalScroll(rememberScrollState())
) { ) {
@@ -381,21 +465,74 @@ fun AnchorAlignView(appState: AppState) {
) { Text("여기까지로 결정하기", color = MlSecondaryText) } ) { Text("여기까지로 결정하기", color = MlSecondaryText) }
} }
} else { } 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( Button(
onClick = { onClick = {
appState.anchorCm = last?.anchorCm appState.anchorCm = last?.anchorCm
appState.currentScreen = AppScreen.HOSPITAL_MODE appState.currentScreen = AppScreen.HOSPITAL_MODE
}, },
enabled = last?.action == AnchorAction.STOP, enabled = last?.action == AnchorAction.STOP && !lrRunning,
modifier = Modifier.fillMaxWidth().height(52.dp), modifier = Modifier.fillMaxWidth().height(52.dp),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = MlSuccess), colors = ButtonDefaults.buttonColors(containerColor = MlSuccess),
) { Text("이 위치로 임상 측정 진행", fontSize = 16.sp, fontWeight = FontWeight.Bold) } ) { 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)) Spacer(Modifier.height(8.dp))
TextButton( TextButton(
onClick = { onClick = {
guide.reset(); cm = 0; last = null; records = emptyList(); error = null 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(), modifier = Modifier.fillMaxWidth(),
) { Text("처음부터 다시 정렬", color = MlSecondaryText) } ) { 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 @Composable
private fun StatusChip(connected: Boolean, name: String?) { private fun StatusChip(connected: Boolean, name: String?) {
val bg = if (connected) MlSuccess.copy(alpha = 0.15f) else MlCritical.copy(alpha = 0.12f) val bg = if (connected) MlSuccess.copy(alpha = 0.15f) else MlCritical.copy(alpha = 0.12f)
@@ -465,6 +711,8 @@ private fun buildAlignSummary(
firmware: String?, firmware: String?,
records: List<AnchorPosRecord>, records: List<AnchorPosRecord>,
step: AnchorStep, step: AnchorStep,
/** 좌우 정렬 결과. null = 아직 돌리지 않았음(= 상하만 맞춘 세션). */
lateral: LateralGuide.Commit? = null,
): JSONObject = JSONObject().apply { ): JSONObject = JSONObject().apply {
put("patient", patient) put("patient", patient)
put("save_name", saveName) put("save_name", saveName)
@@ -490,6 +738,19 @@ private fun buildAlignSummary(
// 앱 검출이 Python 레퍼런스와 불일치인 상태로 낸 값이라는 사실을 데이터와 함께 남긴다. // 앱 검출이 Python 레퍼런스와 불일치인 상태로 낸 값이라는 사실을 데이터와 함께 남긴다.
// 2026-09-03 대조 완료: 검출·BV·선택 규칙이 Python 레퍼런스와 일치. // 2026-09-03 대조 완료: 검출·BV·선택 규칙이 Python 레퍼런스와 일치.
put("selection_verified_against_reference", true) 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 { put("positions", JSONArray().apply {
records.forEach { r -> records.forEach { r ->
put(JSONObject().apply { put(JSONObject().apply {
@@ -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<DoubleArray> =
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)
}
}