feat(clinical): 정렬에서 0~4cm 를 전부 재고, 판정은 레퍼런스 그대로 둔다

병원 임상은 위치를 고르는 것만이 목적이 아니라 **위치별 원신호를 모으는 것**도 목적이다.
그런데 조기 종료하면 2~3 위치만 남는다 — 2026-09-09 실측이 0·1cm 두 자리로 끝났고,
재분석할 거리가 없었다.

이제 종료 조건이 걸려도 SEARCH_MAX_CM(4cm)까지 계속 올리며 잰다. 수집이 끝나면 부착
위치로 내려가 확인 측정 → 확정. 중간에 [여기까지만 재고 부착 위치로] 로 끊을 수 있다.

## 판정은 바뀌지 않는다 — 이게 이 커밋의 핵심

selectBest 는 **넘긴 레코드 전부**를 후보로 본다. 후보가 늘면 답이 뒤집힐 수 있다:

    0cm eligible nch=2 · 1cm ch3 소실 · 2cm eligible nch=4
      조기 종료 후보 {0,1} → best 0cm
      전부 넣으면         → best 2cm     ← 다른 답

앱이 후자를 내면 레퍼런스(Python AnchorGuide)와 갈리고 검증의 근거가 무너진다.

안전한 이유는 AnchorGuide 가 `searching` 이 꺾이는 **그 순간 한 번만** bestCm 을 계산하고
그 뒤 step() 은 recs 에만 쌓기 때문이다. 화면이 그 성질에 의존하므로 AnchorSweepTest 가
고정한다 — 누가 step() 에서 매번 다시 고르게 바꾸면 그 파일이 먼저 깨진다.

## 경계를 기록에 남긴다

안 남기면 재분석하는 쪽이 전 위치를 후보로 넣어 다시 판정하고, 다른 best 가 나와 **앱이
틀린 것으로 읽는다.**

    align_result.json   decided_at_cm · swept_to_cm · search_max_cm
    positions[]         in_decision: true/false
    화면                수집 전용 줄은 흐리게 + "수집" 꼬리표 + 경계 안내 한 줄

## 화면 문구

수집 단계에서는 판정이 MOVE_DOWN 이라고 해도 "1cm 올리고 측정"이라고 말한다. 대신 이미
답이 나왔다는 사실을 같이 적는다 — 안 그러면 "왜 계속 재라고 하지?"가 되고 중단 버튼을
못 찾는다:

    ↑ 1cm 올리고 측정
      부착 위치는 이미 정해졌습니다(1cm 에서 판정).
      4cm 까지는 데이터 수집용으로 잽니다 — 여기서 멈춰도 됩니다.

## 테스트 자원의 한계를 적어 뒀다

실측 둘로는 "수집 때문에 답이 뒤집히는" 장면을 AnchorGuide 수준에서 만들 수 없다. 수집
위치는 항상 cm 이 크고 동률은 낮은 cm 이 이기므로, 뒤집으려면 수집 위치의 nch 가 더
높거나 cap 이 더 낮아야 하는데 자원이 하나뿐이라 같은 값이다. 그래서 **위험은 선택 함수
수준에서 합성 레코드로**, **안정성은 실측으로** 본다. traceWin=20 으로 만든 이유도 적었다
(기본 10 이면 align_cm1 의 CH3 검출률이 0.77 로 임계 0.80 에 미달해 이 시나리오가 성립하지
않는다).

테스트 139개 통과(신규 4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 10:53:35 +09:00
parent 8167b5536d
commit af16541221
2 changed files with 275 additions and 16 deletions
@@ -111,6 +111,15 @@ fun AnchorAlignView(appState: AppState) {
var records by remember { mutableStateOf<List<AnchorPosRecord>>(emptyList()) }
// 위치를 다 썼는데 종료 조건이 안 걸리면 호출부가 강제 확정한다(AnchorGuide.finish).
var forceFinish by remember { mutableStateOf(false) }
/**
* 알고리즘이 **판정을 내린** 위치(cm). null = 아직 탐색 중.
*
* 이 위치까지가 판정의 근거다. 그 뒤 위치는 데이터 수집용으로만 잰다 — 기록에
* 그 경계를 남겨야 나중에 Python 으로 다시 판정할 때 같은 답이 나온다.
*/
var decidedAtCm by remember { mutableStateOf<Int?>(null) }
/** 수집 단계를 사람이 중단했는가. 중단하면 바로 부착 위치 안내로 넘어간다. */
var sweepStopped by remember { mutableStateOf(false) }
/**
* 방금 받은 6채널 원본. 아래쪽 파형이 이걸 그린다.
*
@@ -322,18 +331,34 @@ fun AnchorAlignView(appState: AppState) {
val step = guide.step(cycles, cm)
last = step
records = guide.records
// 판정이 내려진 순간을 한 번만 붙잡는다. 그 뒤 위치는 수집용이다.
if (decidedAtCm == null && !step.searching) decidedAtCm = cm
// 결과 요약도 매 위치마다 갱신한다. 중간에 앱이 죽어도 여기까지는 남는다.
HospitalRunStore.writeAlignSummary(
dir,
buildAlignSummary(saveName, appState.hospitalPatient, deviceName,
bleManager.firmwareVersion.value, guide.records, step),
bleManager.firmwareVersion.value, guide.records, step,
decidedAtCm = decidedAtCm),
)
// 안내대로 다음 측정 위치를 미리 맞춰 둔다. 사용자는 프로브만 옮기면 된다.
when (step.action) {
AnchorAction.MOVE_UP -> cm += 1
AnchorAction.MOVE_DOWN -> step.bestCm?.let { cm = it }
else -> Unit
// ── 다음 위치 ──────────────────────────────────────────────────
// **수집 단계에서는 판정과 무관하게 1cm 씩 올린다.** 병원 임상은 위치를
// 고르는 것만이 목적이 아니라 위치별 원신호를 모으는 것도 목적이다
// (2026-09-10 결정). 조기 종료하면 2~3 위치만 남아 재분석할 거리가 없다.
//
// **판정은 이것 때문에 바뀌지 않는다.** AnchorGuide 는 `searching` 이 false
// 로 넘어가는 순간 bestCm 을 한 번만 계산하고, 그 뒤 step() 은 recs 에만
// 쌓는다 — 후보 집합이 늘어도 best 를 다시 고르지 않는다. 그 성질이 깨지면
// 전부 측정한 세션의 답이 레퍼런스와 달라지므로 `AnchorSweepTest` 가 고정한다.
val swept = guide.records.maxOfOrNull { it.cm } ?: cm
if (!sweepStopped && swept < AnchorConfig.SEARCH_MAX_CM) {
cm = swept + 1
} else {
when (step.action) {
AnchorAction.MOVE_UP -> step.bestCm?.let { cm = it } ?: run { cm += 1 }
AnchorAction.MOVE_DOWN -> step.bestCm?.let { cm = it }
else -> Unit
}
}
} finally {
bleManager.piezoCollector.onMultiChannelComplete = prev
@@ -550,7 +575,13 @@ fun AnchorAlignView(appState: AppState) {
// 붙여야 하는지 매번 헷갈렸다(2026-09-09 현장 피드백). 실제로 필요한 것은
// **지금 할 동작 하나**다 — 1cm 올려서 재라, 2cm 내려서 재라, 1cm 올리고
// 좌우로 넘어가라. 절대 위치는 기록의 몫이고 사람이 셀 일이 아니다.
val instr = instructionOf(last, cm, AnchorConfig.FINAL_OFFSET_CM)
// 수집 단계인가 — 판정이 났어도 SEARCH_MAX_CM 까지는 계속 올린다.
val sweptTo = records.maxOfOrNull { it.cm } ?: -1
val sweeping = !sweepStopped && sweptTo < AnchorConfig.SEARCH_MAX_CM
val instr = instructionOf(
last, cm, AnchorConfig.FINAL_OFFSET_CM,
sweeping = sweeping, decidedAtCm = decidedAtCm,
)
Box(
Modifier.fillMaxWidth()
.background(
@@ -638,14 +669,26 @@ fun AnchorAlignView(appState: AppState) {
// 준다 — 카드대로 움직였으면 버튼의 숫자가 지금 자리와 맞는다.
) { Text("${cm}cm 측정하기", fontSize = 16.sp, fontWeight = FontWeight.Bold) }
// 탐색 상한(4cm)까지 갔는데도 종료가 안 걸릴 수 있다 — 그때 쓰는 문이다.
if (guide.records.isNotEmpty() && !guide.settled) {
// 수집을 중단하는 문. 두 경우를 한 버튼으로 덮는다:
// · 아직 판정이 안 났다 → forceFinish 로 강제 확정
// · 판정은 났고 수집 중 → 수집만 멈추고 부착 위치 안내로
if (records.isNotEmpty() && sweeping) {
Spacer(Modifier.height(8.dp))
TextButton(
onClick = { forceFinish = true },
onClick = {
sweepStopped = true
if (!guide.settled) forceFinish = true
else last?.bestCm?.let { cm = it }
},
enabled = !measuring,
modifier = Modifier.fillMaxWidth(),
) { Text("여기까지로 결정하기", color = MlSecondaryText) }
) {
Text(
if (guide.settled) "여기까지만 재고 부착 위치로"
else "여기까지로 결정하기",
color = MlSecondaryText,
)
}
}
} else {
// ── 2단계: 좌우 정렬 ───────────────────────────────────────
@@ -684,6 +727,7 @@ fun AnchorAlignView(appState: AppState) {
saveName, appState.hospitalPatient, deviceName,
bleManager.firmwareVersion.value, guide.records, s,
lrCommit, lateralConfirmed = true,
decidedAtCm = decidedAtCm,
),
)
}
@@ -720,7 +764,7 @@ fun AnchorAlignView(appState: AppState) {
buildAlignSummary(
saveName, appState.hospitalPatient, deviceName,
bleManager.firmwareVersion.value, guide.records, s,
lrCommit, lrConfirmed, basis, proceedCm,
lrCommit, lrConfirmed, basis, proceedCm, decidedAtCm,
),
)
}
@@ -830,7 +874,22 @@ fun AnchorAlignView(appState: AppState) {
Text("측정한 위치", fontSize = 13.sp, fontWeight = FontWeight.Bold,
color = MlSecondaryText)
Spacer(Modifier.height(6.dp))
records.forEach { r -> RecordRow(r, isBest = r.cm == last?.bestCm) }
records.forEach { r ->
RecordRow(
r, isBest = r.cm == last?.bestCm,
// 판정 뒤에 잰 위치는 **후보가 아니다.** 표시하지 않으면 나중에
// Python 으로 전 위치를 다시 판정했을 때 답이 달라 보여,
// 앱이 틀린 것으로 읽힌다.
dataOnly = decidedAtCm?.let { r.cm > it } == true,
)
}
Text(
decidedAtCm?.let {
"판정 근거는 ${it}cm 까지입니다. 그 뒤는 데이터 수집용으로만 쟀습니다(수집)."
} ?: "아직 판정 전입니다 — 잰 위치 전부가 후보입니다.",
fontSize = 11.sp, color = MlSecondaryText,
modifier = Modifier.padding(top = 4.dp),
)
}
Spacer(Modifier.height(24.dp))
}
@@ -990,11 +1049,22 @@ private fun StatusChip(connected: Boolean, name: String?) {
/** 한 위치의 지표. 판정 근거를 그대로 보여준다 — 나중에 왜 그 위치였는지 물어보게 된다. */
@Composable
private fun RecordRow(r: AnchorPosRecord, isBest: Boolean) {
private fun RecordRow(
r: AnchorPosRecord,
isBest: Boolean,
/** 판정 뒤에 수집만 한 위치인가. 후보가 아니었다는 사실을 줄에 남긴다. */
dataOnly: Boolean = false,
) {
Row(
Modifier.fillMaxWidth()
.background(
if (isBest) MlSuccess.copy(alpha = 0.12f) else Color.White,
when {
isBest -> MlSuccess.copy(alpha = 0.12f)
// 수집 전용 줄은 흐리게. 여기서도 best 를 고른 것으로 읽히면
// 나중에 재분석한 사람과 답이 갈리는 이유를 못 찾는다.
dataOnly -> MlSecondaryText.copy(alpha = 0.06f)
else -> Color.White
},
RoundedCornerShape(8.dp),
)
.padding(horizontal = 12.dp, vertical = 8.dp),
@@ -1007,7 +1077,8 @@ private fun RecordRow(r: AnchorPosRecord, isBest: Boolean) {
"채널 ${r.nch}/4 · CH3 ${if (r.ch3Mean) "O" else "X"} " +
"${Math.round(r.ch3Rate * 100)}% · cap " +
(if (r.capFrac.isFinite()) "%.3f".format(r.capFrac) else "-") +
if (r.eligible) "" else " (CH3 미달)",
(if (r.eligible) "" else " (CH3 미달)") +
(if (dataOnly) " · 수집" else ""),
fontSize = 13.sp, color = MlSecondaryText,
)
}
@@ -1043,6 +1114,10 @@ internal fun instructionOf(
step: AnchorStep?,
nextCm: Int,
offsetCm: Int,
/** 수집 단계인가 — 판정이 났어도 SEARCH_MAX_CM 까지 올린다. */
sweeping: Boolean = false,
/** 판정이 내려진 위치. 수집 단계에서 "이미 답은 나왔다"를 알려 주는 데 쓴다. */
decidedAtCm: Int? = null,
): AlignInstruction = when {
step == null -> AlignInstruction(
"●", "치골 바로 위에 붙이고 측정",
@@ -1053,6 +1128,20 @@ internal fun instructionOf(
"치골 쪽으로 내려 다시 부착한 뒤 처음부터 측정합니다. " +
(step.stopReason ?: "CH3 가 잡히는 위치가 없었습니다."))
// 수집 단계에서는 판정이 뭐라 하든 계속 올린다. 단 **이미 답이 나왔다는 사실은
// 말해 준다** — 안 그러면 "왜 계속 재라고 하지?"가 되고, 중단 버튼을 못 찾는다.
sweeping && step.searching -> AlignInstruction(
"↑", "1cm 올리고 측정",
"${AnchorConfig.SEARCH_MAX_CM}cm 까지 전부 잽니다.")
sweeping -> AlignInstruction(
"↑", "1cm 올리고 측정",
decidedAtCm?.let {
"부착 위치는 이미 정해졌습니다(${it}cm 에서 판정). " +
"${AnchorConfig.SEARCH_MAX_CM}cm 까지는 데이터 수집용으로 잽니다 — " +
"여기서 멈춰도 됩니다."
} ?: "${AnchorConfig.SEARCH_MAX_CM}cm 까지 전부 잽니다.")
step.action == AnchorAction.STOP -> AlignInstruction(
"↑", "${offsetCm}cm 올리세요",
"여기서는 측정하지 않습니다. 올린 뒤 좌우 맞추기로 넘어갑니다.",
@@ -1117,6 +1206,14 @@ private fun buildAlignSummary(
basis: AnchorBasis? = null,
/** 실제로 임상에 넘긴 cm. [AnchorStep.anchorCm] 이 null 인 채 진행할 수 있다. */
proceedCm: Int? = null,
/**
* 알고리즘이 판정을 내린 위치. **이 cm 까지가 판정의 후보다.**
*
* 병원 임상은 조기 종료 뒤에도 SEARCH_MAX_CM 까지 전부 잰다(데이터 수집). 그 경계를
* 안 남기면 재분석하는 쪽이 전 위치를 후보로 넣어 다시 판정하고, 다른 best 가 나와
* 앱이 틀린 것으로 읽는다.
*/
decidedAtCm: Int? = null,
): JSONObject = JSONObject().apply {
put("patient", patient)
put("save_name", saveName)
@@ -1137,6 +1234,10 @@ private fun buildAlignSummary(
put("best_cm", step.bestCm ?: JSONObject.NULL)
put("anchor_cm", step.anchorCm ?: JSONObject.NULL)
put("action", step.action.name)
// 판정 경계. positions[].in_decision 과 같은 사실을 세션 수준에서도 적는다.
put("decided_at_cm", decidedAtCm ?: JSONObject.NULL)
put("swept_to_cm", records.maxOfOrNull { it.cm } ?: JSONObject.NULL)
put("search_max_cm", AnchorConfig.SEARCH_MAX_CM)
put("stop_reason", step.stopReason ?: JSONObject.NULL)
put("max_nch", step.maxNch)
// 앱 검출이 Python 레퍼런스와 불일치인 상태로 낸 값이라는 사실을 데이터와 함께 남긴다.
@@ -1174,6 +1275,8 @@ private fun buildAlignSummary(
put("ch3_rate", r.ch3Rate)
put("cap_frac", if (r.capFrac.isFinite()) r.capFrac else JSONObject.NULL)
put("eligible", r.eligible)
// 이 위치가 **판정의 후보였는가.** false = 판정 뒤에 수집만 한 위치.
put("in_decision", decidedAtCm?.let { r.cm <= it } ?: true)
})
}
})