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.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<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() }
// 저장 폴더 이름. 환자명이 비면 시각으로 대체한다(데이터를 잃지 않기 위해).
@@ -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(
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<AnchorPosRecord>,
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 {