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()) } var records by remember { mutableStateOf<List<AnchorPosRecord>>(emptyList()) }
// 위치를 다 썼는데 종료 조건이 안 걸리면 호출부가 강제 확정한다(AnchorGuide.finish). // 위치를 다 썼는데 종료 조건이 안 걸리면 호출부가 강제 확정한다(AnchorGuide.finish).
var forceFinish by remember { mutableStateOf(false) } var forceFinish by remember { mutableStateOf(false) }
/**
* 알고리즘이 **판정을 내린** 위치(cm). null = 아직 탐색 중.
*
* 이 위치까지가 판정의 근거다. 그 뒤 위치는 데이터 수집용으로만 잰다 — 기록에
* 그 경계를 남겨야 나중에 Python 으로 다시 판정할 때 같은 답이 나온다.
*/
var decidedAtCm by remember { mutableStateOf<Int?>(null) }
/** 수집 단계를 사람이 중단했는가. 중단하면 바로 부착 위치 안내로 넘어간다. */
var sweepStopped by remember { mutableStateOf(false) }
/** /**
* 방금 받은 6채널 원본. 아래쪽 파형이 이걸 그린다. * 방금 받은 6채널 원본. 아래쪽 파형이 이걸 그린다.
* *
@@ -322,19 +331,35 @@ fun AnchorAlignView(appState: AppState) {
val step = guide.step(cycles, cm) val step = guide.step(cycles, cm)
last = step last = step
records = guide.records records = guide.records
// 판정이 내려진 순간을 한 번만 붙잡는다. 그 뒤 위치는 수집용이다.
if (decidedAtCm == null && !step.searching) decidedAtCm = cm
// 결과 요약도 매 위치마다 갱신한다. 중간에 앱이 죽어도 여기까지는 남는다. // 결과 요약도 매 위치마다 갱신한다. 중간에 앱이 죽어도 여기까지는 남는다.
HospitalRunStore.writeAlignSummary( HospitalRunStore.writeAlignSummary(
dir, dir,
buildAlignSummary(saveName, appState.hospitalPatient, deviceName, buildAlignSummary(saveName, appState.hospitalPatient, deviceName,
bleManager.firmwareVersion.value, guide.records, step), bleManager.firmwareVersion.value, guide.records, step,
decidedAtCm = decidedAtCm),
) )
// 안내대로 다음 측정 위치를 미리 맞춰 둔다. 사용자는 프로브만 옮기면 된다. // ── 다음 위치 ──────────────────────────────────────────────────
// **수집 단계에서는 판정과 무관하게 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) { when (step.action) {
AnchorAction.MOVE_UP -> cm += 1 AnchorAction.MOVE_UP -> step.bestCm?.let { cm = it } ?: run { cm += 1 }
AnchorAction.MOVE_DOWN -> step.bestCm?.let { cm = it } AnchorAction.MOVE_DOWN -> step.bestCm?.let { cm = it }
else -> Unit else -> Unit
} }
}
} finally { } finally {
bleManager.piezoCollector.onMultiChannelComplete = prev bleManager.piezoCollector.onMultiChannelComplete = prev
kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) {
@@ -550,7 +575,13 @@ fun AnchorAlignView(appState: AppState) {
// 붙여야 하는지 매번 헷갈렸다(2026-09-09 현장 피드백). 실제로 필요한 것은 // 붙여야 하는지 매번 헷갈렸다(2026-09-09 현장 피드백). 실제로 필요한 것은
// **지금 할 동작 하나**다 — 1cm 올려서 재라, 2cm 내려서 재라, 1cm 올리고 // **지금 할 동작 하나**다 — 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( Box(
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.background( .background(
@@ -638,14 +669,26 @@ fun AnchorAlignView(appState: AppState) {
// 준다 — 카드대로 움직였으면 버튼의 숫자가 지금 자리와 맞는다. // 준다 — 카드대로 움직였으면 버튼의 숫자가 지금 자리와 맞는다.
) { Text("${cm}cm 측정하기", fontSize = 16.sp, fontWeight = FontWeight.Bold) } ) { 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)) Spacer(Modifier.height(8.dp))
TextButton( TextButton(
onClick = { forceFinish = true }, onClick = {
sweepStopped = true
if (!guide.settled) forceFinish = true
else last?.bestCm?.let { cm = it }
},
enabled = !measuring, enabled = !measuring,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("여기까지로 결정하기", color = MlSecondaryText) } ) {
Text(
if (guide.settled) "여기까지만 재고 부착 위치로"
else "여기까지로 결정하기",
color = MlSecondaryText,
)
}
} }
} else { } else {
// ── 2단계: 좌우 정렬 ─────────────────────────────────────── // ── 2단계: 좌우 정렬 ───────────────────────────────────────
@@ -684,6 +727,7 @@ fun AnchorAlignView(appState: AppState) {
saveName, appState.hospitalPatient, deviceName, saveName, appState.hospitalPatient, deviceName,
bleManager.firmwareVersion.value, guide.records, s, bleManager.firmwareVersion.value, guide.records, s,
lrCommit, lateralConfirmed = true, lrCommit, lateralConfirmed = true,
decidedAtCm = decidedAtCm,
), ),
) )
} }
@@ -720,7 +764,7 @@ fun AnchorAlignView(appState: AppState) {
buildAlignSummary( buildAlignSummary(
saveName, appState.hospitalPatient, deviceName, saveName, appState.hospitalPatient, deviceName,
bleManager.firmwareVersion.value, guide.records, s, 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, Text("측정한 위치", fontSize = 13.sp, fontWeight = FontWeight.Bold,
color = MlSecondaryText) color = MlSecondaryText)
Spacer(Modifier.height(6.dp)) 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)) Spacer(Modifier.height(24.dp))
} }
@@ -990,11 +1049,22 @@ private fun StatusChip(connected: Boolean, name: String?) {
/** 한 위치의 지표. 판정 근거를 그대로 보여준다 — 나중에 왜 그 위치였는지 물어보게 된다. */ /** 한 위치의 지표. 판정 근거를 그대로 보여준다 — 나중에 왜 그 위치였는지 물어보게 된다. */
@Composable @Composable
private fun RecordRow(r: AnchorPosRecord, isBest: Boolean) { private fun RecordRow(
r: AnchorPosRecord,
isBest: Boolean,
/** 판정 뒤에 수집만 한 위치인가. 후보가 아니었다는 사실을 줄에 남긴다. */
dataOnly: Boolean = false,
) {
Row( Row(
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.background( .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), RoundedCornerShape(8.dp),
) )
.padding(horizontal = 12.dp, vertical = 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"} " + "채널 ${r.nch}/4 · CH3 ${if (r.ch3Mean) "O" else "X"} " +
"${Math.round(r.ch3Rate * 100)}% · cap " + "${Math.round(r.ch3Rate * 100)}% · cap " +
(if (r.capFrac.isFinite()) "%.3f".format(r.capFrac) else "-") + (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, fontSize = 13.sp, color = MlSecondaryText,
) )
} }
@@ -1043,6 +1114,10 @@ internal fun instructionOf(
step: AnchorStep?, step: AnchorStep?,
nextCm: Int, nextCm: Int,
offsetCm: Int, offsetCm: Int,
/** 수집 단계인가 — 판정이 났어도 SEARCH_MAX_CM 까지 올린다. */
sweeping: Boolean = false,
/** 판정이 내려진 위치. 수집 단계에서 "이미 답은 나왔다"를 알려 주는 데 쓴다. */
decidedAtCm: Int? = null,
): AlignInstruction = when { ): AlignInstruction = when {
step == null -> AlignInstruction( step == null -> AlignInstruction(
"●", "치골 바로 위에 붙이고 측정", "●", "치골 바로 위에 붙이고 측정",
@@ -1053,6 +1128,20 @@ internal fun instructionOf(
"치골 쪽으로 내려 다시 부착한 뒤 처음부터 측정합니다. " + "치골 쪽으로 내려 다시 부착한 뒤 처음부터 측정합니다. " +
(step.stopReason ?: "CH3 가 잡히는 위치가 없었습니다.")) (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( step.action == AnchorAction.STOP -> AlignInstruction(
"↑", "${offsetCm}cm 올리세요", "↑", "${offsetCm}cm 올리세요",
"여기서는 측정하지 않습니다. 올린 뒤 좌우 맞추기로 넘어갑니다.", "여기서는 측정하지 않습니다. 올린 뒤 좌우 맞추기로 넘어갑니다.",
@@ -1117,6 +1206,14 @@ private fun buildAlignSummary(
basis: AnchorBasis? = null, basis: AnchorBasis? = null,
/** 실제로 임상에 넘긴 cm. [AnchorStep.anchorCm] 이 null 인 채 진행할 수 있다. */ /** 실제로 임상에 넘긴 cm. [AnchorStep.anchorCm] 이 null 인 채 진행할 수 있다. */
proceedCm: Int? = null, proceedCm: Int? = null,
/**
* 알고리즘이 판정을 내린 위치. **이 cm 까지가 판정의 후보다.**
*
* 병원 임상은 조기 종료 뒤에도 SEARCH_MAX_CM 까지 전부 잰다(데이터 수집). 그 경계를
* 안 남기면 재분석하는 쪽이 전 위치를 후보로 넣어 다시 판정하고, 다른 best 가 나와
* 앱이 틀린 것으로 읽는다.
*/
decidedAtCm: Int? = null,
): JSONObject = JSONObject().apply { ): JSONObject = JSONObject().apply {
put("patient", patient) put("patient", patient)
put("save_name", saveName) put("save_name", saveName)
@@ -1137,6 +1234,10 @@ private fun buildAlignSummary(
put("best_cm", step.bestCm ?: JSONObject.NULL) put("best_cm", step.bestCm ?: JSONObject.NULL)
put("anchor_cm", step.anchorCm ?: JSONObject.NULL) put("anchor_cm", step.anchorCm ?: JSONObject.NULL)
put("action", step.action.name) 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("stop_reason", step.stopReason ?: JSONObject.NULL)
put("max_nch", step.maxNch) put("max_nch", step.maxNch)
// 앱 검출이 Python 레퍼런스와 불일치인 상태로 낸 값이라는 사실을 데이터와 함께 남긴다. // 앱 검출이 Python 레퍼런스와 불일치인 상태로 낸 값이라는 사실을 데이터와 함께 남긴다.
@@ -1174,6 +1275,8 @@ private fun buildAlignSummary(
put("ch3_rate", r.ch3Rate) put("ch3_rate", r.ch3Rate)
put("cap_frac", if (r.capFrac.isFinite()) r.capFrac else JSONObject.NULL) put("cap_frac", if (r.capFrac.isFinite()) r.capFrac else JSONObject.NULL)
put("eligible", r.eligible) put("eligible", r.eligible)
// 이 위치가 **판정의 후보였는가.** false = 판정 뒤에 수집만 한 위치.
put("in_decision", decidedAtCm?.let { r.cm <= it } ?: true)
}) })
} }
}) })
@@ -0,0 +1,156 @@
package com.medithings.vesiscan.managers
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* 조기 종료 뒤에도 **끝까지 재는** 것이 판정을 바꾸지 않아야 한다.
*
* ## 왜 이 파일이 필요한가
* 병원 임상은 위치를 고르는 것만이 목적이 아니라 **위치별 원신호를 모으는 것**도 목적이다
* (2026-09-10 결정). 그래서 화면이 조기 종료 뒤에도 [AnchorConfig.SEARCH_MAX_CM] 까지
* 계속 [AnchorGuide.step] 을 부른다.
*
* 위험은 명확하다. [AnchorSelection.selectBest] 는 **넘긴 레코드 전부**를 후보로 본다 —
* 후보가 늘면 답이 달라질 수 있다(아래 첫 테스트가 0cm → 2cm 로 뒤집히는 것을 보여준다).
* 앱이 그 답을 내면 레퍼런스(Python `AnchorGuide`)와 갈리고, 검증의 근거가 무너진다.
*
* 안전한 이유는 [AnchorGuide] 가 `searching` 이 꺾이는 **그 순간 한 번만** `bestCm` 을
* 계산하고, 그 뒤 `step()` 은 `recs` 에만 쌓기 때문이다. **화면이 그 성질에 의존한다.**
* 누가 `step()` 에서 매번 다시 고르게 바꾸면 이 파일이 먼저 깨져야 한다.
*
* ## 자원의 한계를 적어 둔다
* 받아 둔 실측 둘로는 "수집 때문에 답이 뒤집히는" 장면을 [AnchorGuide] 수준에서 만들 수
* 없다. 수집 위치는 항상 cm 이 더 크고 동률은 **낮은 cm** 이 이기므로, 뒤집으려면 수집
* 위치가 nch 가 더 높거나 cap 이 더 낮아야 하는데 자원이 하나뿐이라 같은 값이 나온다.
* 그래서 **위험은 선택 함수 수준에서 합성 레코드로**, **안정성은 실측으로** 본다.
*/
class AnchorSweepTest {
private val saved = PiezoHW.activePreset
@Before fun pin() { PiezoHW.activePreset = PiezoHW.DevicePreset.V1 }
@After fun restore() { PiezoHW.activePreset = saved }
// ── ① 위험: 후보가 늘면 답이 바뀐다 ─────────────────────────────────────
private fun rec(cm: Int, nch: Int, ch3: Boolean, rate: Double, cap: Double) =
AnchorPosRecord(
cm = cm, nTrace = 11, nch = nch, ch3Mean = ch3, ch3Rate = rate, capFrac = cap,
)
@Test fun `후보가 늘면 best 가 뒤집힌다 — 이것이 막아야 할 것이다`() {
// 0cm eligible nch=2 · 1cm ch3 소실 · 2cm eligible nch=4
val p0 = rec(0, nch = 2, ch3 = true, rate = 1.0, cap = 0.60)
val p1 = rec(1, nch = 2, ch3 = false, rate = 0.0, cap = 0.70)
val p2 = rec(2, nch = 4, ch3 = true, rate = 1.0, cap = 0.50)
// 조기 종료 시점의 후보 집합 — 1cm 은 자격 미달이라 0cm 만 남는다.
assertEquals(0, AnchorSelection.selectBest(listOf(p0, p1)).bestCm)
// 수집한 위치까지 넣어 다시 고르면 규칙 A(nch 최대)로 2cm 이 이긴다.
assertEquals(2, AnchorSelection.selectBest(listOf(p0, p1, p2)).bestCm)
assertNotEquals(
"이 둘이 같아지면 이 파일의 전제가 사라진다 — 선택 규칙이 바뀐 것이다",
AnchorSelection.selectBest(listOf(p0, p1)).bestCm,
AnchorSelection.selectBest(listOf(p0, p1, p2)).bestCm,
)
}
// ── ② 안정성: 종료 뒤 step() 이 판정을 건드리지 않는다 ──────────────────
/** 실측 한 파일 → cycle 목록 (cycle, 6ch, samples). */
private fun cyclesOf(name: String): List<List<DoubleArray>> {
val f = File("src/test/resources/$name")
assertTrue("실측 자원이 없다: ${f.absolutePath}", f.exists())
val byCycle = linkedMapOf<Int, MutableMap<Int, DoubleArray>>()
f.forEachLine { line ->
val p = line.trim().split(',')
if (p.size < 3) return@forEachLine
val ci = p[0].toIntOrNull() ?: return@forEachLine
val ch = p[1].toIntOrNull() ?: return@forEachLine
byCycle.getOrPut(ci) { linkedMapOf() }[ch] =
DoubleArray(p.size - 2) { p[it + 2].toDoubleOrNull() ?: 0.0 }
}
val full = byCycle.values.filter { it.size == 6 }.map { m -> (0..5).map { m.getValue(it) } }
assertTrue("cycle 이 하나도 안 읽혔다", full.isNotEmpty())
return full
}
/** CH3 를 평탄화해 그 위치의 CH3 검출을 떨어뜨린다 — "ch3 소실" 종료를 만든다. */
private fun killCh3(cycles: List<List<DoubleArray>>) =
cycles.map { ch -> ch.mapIndexed { i, a -> if (i == 3) DoubleArray(a.size) { 1000.0 } else a } }
/**
* `traceWin = 20` 으로 만든다.
*
* 기본값(10)이면 `align_cm1` 의 CH3 검출률이 0.77 로 임계 0.80 에 **아슬아슬하게
* 미달**해 eligible 이 아니다. 그러면 "ch3 소실" 종료가 아예 발동하지 않아(앞에
* eligible 한 위치가 있어야 걸린다) 이 시나리오를 못 만든다. 20 이면 trace 가 하나,
* 즉 mean-scan 그대로라 검출률이 1.0 이 된다.
*/
private fun guide() = AnchorGuide(traceWin = 20)
@Test fun `종료 뒤에 더 재도 판정이 그대로다`() {
val ok = cyclesOf("align_cm1.csv") // eligible · nch 4
val dead = killCh3(ok) // ch3 미검출 → 자격 미달
val g = guide()
assertTrue("0cm 은 탐색이 계속돼야 한다", g.step(ok, 0).searching)
val terminated = g.step(dead, 1)
assertFalse("1cm 에서 ch3 소실로 종료돼야 한다", terminated.searching)
val decidedBest = g.bestCm
val decidedReason = g.stopReason
assertNotNull("종료 시점에 best 가 정해져야 한다", decidedBest)
assertEquals("후보가 0cm 하나뿐이다", 0, decidedBest)
assertEquals("ch3 소실 0%<80%", decidedReason)
// 여기서부터 수집 단계 — 화면이 SEARCH_MAX_CM 까지 계속 부른다.
g.step(ok, 2)
g.step(ok, 3)
g.step(ok, 4)
assertEquals("수집이 best 를 바꿨다 — 레퍼런스와 답이 갈린다", decidedBest, g.bestCm)
assertEquals("수집이 종료 사유를 덮었다", decidedReason, g.stopReason)
assertFalse("한 번 꺾인 searching 이 되살아나면 안 된다", g.searching)
assertEquals("기록은 전부 쌓여야 한다 — 데이터 수집이 목적이다", 5, g.records.size)
}
@Test fun `판정 경계로 후보와 수집을 가를 수 있다`() {
// 화면·요약이 `decided_at_cm` 으로 둘을 가른다. 그 경계는 종료가 걸린 위치다.
val ok = cyclesOf("align_cm1.csv")
val g = guide()
g.step(ok, 0)
val terminated = g.step(killCh3(ok), 1)
assertFalse(terminated.searching)
val decidedAt = terminated.record!!.cm
assertEquals(1, decidedAt)
g.step(ok, 2)
assertEquals(listOf(0, 1), g.records.filter { it.cm <= decidedAt }.map { it.cm })
assertEquals(listOf(2), g.records.filter { it.cm > decidedAt }.map { it.cm })
}
@Test fun `아무 조건도 안 걸리면 탐색 상한에서 끝난다`() {
// 화면이 4cm 까지 올리는 경우 — 상한 종료가 받아 준다. 이 경로가 막히면
// 수집 단계가 영원히 안 끝난다.
val ok = cyclesOf("align_cm1.csv")
val g = guide()
(0 until AnchorConfig.SEARCH_MAX_CM).forEach { cm ->
assertTrue("${cm}cm 에서 끝나면 안 된다", g.step(ok, cm).searching)
}
val last = g.step(ok, AnchorConfig.SEARCH_MAX_CM)
assertFalse(last.searching)
assertEquals("탐색 상한 ${AnchorConfig.SEARCH_MAX_CM}cm", g.stopReason)
assertEquals(AnchorConfig.SEARCH_MAX_CM + 1, g.records.size)
}
}