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:
@@ -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,19 +331,35 @@ 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),
|
||||
)
|
||||
// 안내대로 다음 측정 위치를 미리 맞춰 둔다. 사용자는 프로브만 옮기면 된다.
|
||||
// ── 다음 위치 ──────────────────────────────────────────────────
|
||||
// **수집 단계에서는 판정과 무관하게 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 -> cm += 1
|
||||
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
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) {
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user