feat(clinical): 정렬 판정을 **잰 위치 전부**로 — 0~4cm 다 재고 판단
알고리즘 설계자가 물었다 — "0cm·1cm 만 쟀는데 0cm 가 최적으로 나왔다. 3cm 가 최적이면?"
앞 커밋은 전수 측정만 하고 판정은 단일 pass 로 뒀는데, 원래 의도는 **다 재고 판단**이었다.
## 무엇이 바뀌고 무엇이 그대로인가
측정 종료 조건에서 중단 → 0~4cm 무조건 전부
후보 종료까지의 위치 → 잰 위치 전부 ← 바뀜
선택 규칙 자격→nch→cap→낮은 cm → 그대로
부착 best + 1cm 단순 덧셈 → 그대로
종료 조건 판정을 끝냄 → 진단용 (decided_at_cm)
선택 규칙 `AnchorSelection.selectBest` 는 Python `select_supine_anchor` 와 1:1 이고 200
케이스로 대조돼 있다 — **손대지 않았다.** 바뀐 것은 후보 집합뿐이고, Python 의 회고 분석
경로가 이미 전 위치로 판정하므로 같은 방식으로 재현·대조할 수 있다. 다만 **최종 보고서의
"단일 pass" 기술은 갱신이 필요하다.**
## 왜
단일 pass 는 지표가 단봉이라고 가정해 나빠지는 순간 멈춘다. 장내 가스·접촉 불량·호흡으로
**한 위치만** 일시적으로 나빠져도 멈추므로 더 위를 놓친다. 실측에 징후가 있었다 —
2026-09-09 `kai` 는 0cm ch3=X → 1cm ch3=O 91% 로 지표가 **올라갔다.** 같은 날 `181128` 은
1cm 에서 nch 2→0, ch3 100%→0% 로 best=0cm 으로 끝나 2cm 이후를 몰랐다.
## 화면 단계를 화면이 직접 몰고 간다
종전에는 `AnchorGuide` 의 STOP(`last.done`)이 확정 신호였다. 이제 guide 의 단일 pass 답과
앱의 부착 위치가 다를 수 있어 그걸 쓸 수 없다. `confirmTarget`/`confirmDone` 으로 바꿨다:
수집 중 → ↑ 1cm 올리고 측정 (판정보다 우선)
수집 끝 → ↓/↑ N cm 로 이동해 확인 측정
확인 측정 완료 → ↑ 1cm 올리세요 (초록) → 좌우
후보 없음 → ↓ 더 아래에 다시 붙이세요
우선순위를 테스트로 고정했다 — 확정이 수집·재부착보다 먼저, 수집이 판정보다 먼저.
뒤바뀌면 4cm 까지 못 가거나 끝난 단계를 되돌린다.
## 감사 가능하게 둘 다 기록
"selection_scope": "full_sweep"
"best_cm": 3 앱이 쓴 값 = 전 위치 판정
"best_cm_single_pass": 0 옛 방식이 냈을 답
"single_pass_disagrees": true
"decided_at_cm": 1 종료 조건이 걸린 위치 (이제 진단값)
갈리면 화면에도 참고 문구가 뜬다. 이 빈도가 곧 "단봉 가정이 얼마나 깨지는가"의 지표다.
## 같이 정리
AnchorBasis 에 FULL_SWEEP 을 만들려다 **만들지 않았다** — 전 위치 판정이 이제 앱의 기본
규칙이므로 ALGORITHM 이 그 뜻이다. 항목을 늘리면 정확도 검증 모집단을 가르는 기준이
흐려진다.
테스트 143개 통과(AlignInstructionTest 13개 전면 재작성).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -119,8 +119,16 @@ fun AnchorAlignView(appState: AppState) {
|
||||
* 그 경계를 남겨야 나중에 Python 으로 다시 판정할 때 같은 답이 나온다.
|
||||
*/
|
||||
var decidedAtCm by remember { mutableStateOf<Int?>(null) }
|
||||
/** 수집 단계를 사람이 중단했는가. 중단하면 바로 부착 위치 안내로 넘어간다. */
|
||||
/** 수집 단계를 사람이 중단했는가. 중단하면 바로 그 시점까지로 판정한다. */
|
||||
var sweepStopped by remember { mutableStateOf(false) }
|
||||
/**
|
||||
* 부착 위치에서 **확인 측정까지 끝냈는가.** 이게 true 가 되면 좌우 단계로 넘어간다.
|
||||
*
|
||||
* 종전에는 `AnchorGuide` 의 STOP(`last.done`)이 이 역할을 했다. 이제 판정을 전 위치로
|
||||
* 하므로 guide 의 STOP 과 앱의 부착 위치가 다를 수 있어(guide 는 단일 pass 답을 들고
|
||||
* 있다) 화면이 직접 들고 있어야 한다.
|
||||
*/
|
||||
var confirmDone by remember { mutableStateOf(false) }
|
||||
/**
|
||||
* 방금 받은 6채널 원본. 아래쪽 파형이 이걸 그린다.
|
||||
*
|
||||
@@ -188,7 +196,27 @@ fun AnchorAlignView(appState: AppState) {
|
||||
}
|
||||
val saveName = appState.hospitalPatient.ifBlank { fallbackName }
|
||||
|
||||
val done = last?.done == true
|
||||
// ── 판정: **잰 위치 전부**로 고른다 ────────────────────────────────────
|
||||
// 2026-09-10 결정. 종전에는 AnchorGuide 의 단일 pass 답(종료 시점까지의 후보)을 썼다.
|
||||
// 단일 pass 는 지표가 cm 에 대해 단봉이라고 가정해 나빠지는 순간 멈추는데, 장내
|
||||
// 가스·접촉 불량·호흡으로 **한 위치만** 일시적으로 나빠져도 멈춘다 — 더 위가 진짜
|
||||
// 좋았는데 놓친다(알고리즘 팀 질의). 실측에도 징후가 있었다: 2026-09-09 `kai` 는
|
||||
// 0cm ch3=X → 1cm ch3=O 91% 로 지표가 **올라갔다.**
|
||||
//
|
||||
// **선택 규칙은 그대로다.** [AnchorSelection.selectBest] 는 Python
|
||||
// `select_supine_anchor` 와 1:1 이고 200 케이스로 대조돼 있다(AnchorSelectionParityTest).
|
||||
// 바뀌는 것은 **후보 집합**뿐이고, Python 의 회고 분석 경로가 이미 전 위치로 판정하므로
|
||||
// 같은 방식으로 재현·대조할 수 있다.
|
||||
//
|
||||
// 종료 조건(붕괴·ch3 소실·상한)은 **진단용**으로 남는다 — 지표가 어디서 무너졌는지는
|
||||
// 여전히 알아야 한다. `decided_at_cm` 으로 기록에 남는다.
|
||||
val selectable = records.filter { it.capFrac.isFinite() }
|
||||
val finalBest = AnchorSelection.selectBest(selectable).bestCm
|
||||
/** 단일 pass 가 냈을 답. **비교·감사용**이고 판정에는 쓰지 않는다. */
|
||||
val singlePassBest = last?.bestCm
|
||||
/** 잰 위치가 하나도 후보가 못 되면 재부착이다. */
|
||||
val needReattach = records.isNotEmpty() && finalBest == null
|
||||
val anchorTarget = finalBest?.let { it + AnchorConfig.FINAL_OFFSET_CM }
|
||||
|
||||
// ── 연결이 끊기면 기기에서 온 값을 비운다 ───────────────────────────────
|
||||
// 병원 모드와 같은 이유다 — 끊긴 프로브의 BV·파형·접촉이 새 프로브의 것으로 읽힌다.
|
||||
@@ -346,22 +374,19 @@ fun AnchorAlignView(appState: AppState) {
|
||||
decidedAtCm = decidedAtCm),
|
||||
)
|
||||
// ── 다음 위치 ──────────────────────────────────────────────────
|
||||
// **수집 단계에서는 판정과 무관하게 1cm 씩 올린다.** 병원 임상은 위치를
|
||||
// 고르는 것만이 목적이 아니라 위치별 원신호를 모으는 것도 목적이다
|
||||
// (2026-09-10 결정). 조기 종료하면 2~3 위치만 남아 재분석할 거리가 없다.
|
||||
//
|
||||
// **판정은 이것 때문에 바뀌지 않는다.** AnchorGuide 는 `searching` 이 false
|
||||
// 로 넘어가는 순간 bestCm 을 한 번만 계산하고, 그 뒤 step() 은 recs 에만
|
||||
// 쌓는다 — 후보 집합이 늘어도 best 를 다시 고르지 않는다. 그 성질이 깨지면
|
||||
// 전부 측정한 세션의 답이 레퍼런스와 달라지므로 `AnchorSweepTest` 가 고정한다.
|
||||
// **0~SEARCH_MAX_CM 를 무조건 다 잰다.** guide 의 안내(MOVE_UP/MOVE_DOWN)는
|
||||
// 여기서 보지 않는다 — 판정을 전 위치로 하므로 중간 안내가 의미가 없다.
|
||||
// 다 재고 나면 판정된 위치로 내려가 확인 측정 한 번, 그 뒤 +1cm 에서 좌우.
|
||||
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
|
||||
// 수집 끝 — 전 위치로 판정한 자리로 간다.
|
||||
val target = AnchorSelection.selectBest(
|
||||
guide.records.filter { it.capFrac.isFinite() }).bestCm
|
||||
if (target != null) {
|
||||
// 방금 잰 자리가 곧 판정된 자리면 그 측정이 확인 측정이다.
|
||||
if (cm == target) confirmDone = true else cm = target
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -579,24 +604,11 @@ fun AnchorAlignView(appState: AppState) {
|
||||
// 붙여야 하는지 매번 헷갈렸다(2026-09-09 현장 피드백). 실제로 필요한 것은
|
||||
// **지금 할 동작 하나**다 — 1cm 올려서 재라, 2cm 내려서 재라, 1cm 올리고
|
||||
// 좌우로 넘어가라. 절대 위치는 기록의 몫이고 사람이 셀 일이 아니다.
|
||||
// ── 전수 판정과 비교 ────────────────────────────────────────────
|
||||
// 단일 pass 는 **지표가 cm 에 대해 단봉**이라고 가정한다(Python 도 같다).
|
||||
// 올라가다 나빠지면 봉우리를 지난 것으로 보고 멈춘다.
|
||||
//
|
||||
// 그 가정이 깨지는 경우가 있다 — 장내 가스·접촉 불량·호흡으로 **한 위치만**
|
||||
// 일시적으로 나빠지면 조기 종료하고, 더 위가 진짜 좋았는데 놓친다.
|
||||
// 알고리즘 설계자가 제기한 우려이고(2026-09-10), 실측에도 징후가 있다:
|
||||
// 2026-09-09 `kai` 는 0cm ch3=X → 1cm ch3=O 91% 로 **올라갔다**.
|
||||
//
|
||||
// **판정은 바꾸지 않는다.** 레퍼런스 대조가 단일 pass 기준이라, 규칙을 바꾸면
|
||||
// 검증을 다시 해야 한다. 대신 전 위치로 고르면 어디가 뽑히는지 **같이 계산해
|
||||
// 보여 주고 기록한다** — 몇십 세션 쌓이면 "실제로 갈리는가"에 답이 나온다.
|
||||
// 규칙을 바꿀지는 그 숫자를 보고 알고리즘 팀이 정한다.
|
||||
val fullSweepBest = remember(records) {
|
||||
AnchorSelection.selectBest(records.filter { it.capFrac.isFinite() }).bestCm
|
||||
}
|
||||
val sweepDisagrees = last?.bestCm != null && fullSweepBest != null &&
|
||||
fullSweepBest != last?.bestCm
|
||||
// ── 단일 pass 와 비교 (감사용) ──────────────────────────────────
|
||||
// 앱이 쓰는 값은 전수 판정이다. 단일 pass 가 냈을 답을 같이 보여 주면
|
||||
// "단봉 가정이 얼마나 깨지는가"를 현장에서도 세어 볼 수 있다.
|
||||
val singlePassDisagrees = finalBest != null && singlePassBest != null &&
|
||||
finalBest != singlePassBest
|
||||
|
||||
// 수집 단계인가 — 판정이 났어도 SEARCH_MAX_CM 까지는 계속 올린다.
|
||||
val sweptTo = records.maxOfOrNull { it.cm } ?: -1
|
||||
@@ -604,6 +616,9 @@ fun AnchorAlignView(appState: AppState) {
|
||||
val instr = instructionOf(
|
||||
last, cm, AnchorConfig.FINAL_OFFSET_CM,
|
||||
sweeping = sweeping, decidedAtCm = decidedAtCm,
|
||||
confirmTarget = if (sweeping) null else finalBest,
|
||||
confirmDone = confirmDone,
|
||||
needReattach = needReattach && !sweeping,
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxWidth()
|
||||
@@ -657,12 +672,11 @@ fun AnchorAlignView(appState: AppState) {
|
||||
|
||||
// 전수 판정이 다르면 사람에게 알린다. 앱은 단일 pass 답을 쓰지만, 그
|
||||
// 자리에서 "더 위가 좋아 보인다"를 알면 다시 정렬을 택할 수 있다.
|
||||
if (sweepDisagrees) {
|
||||
if (singlePassDisagrees && !sweeping) {
|
||||
Text(
|
||||
"참고 — 잰 위치 전부로 고르면 ${fullSweepBest}cm 이 뽑힙니다 " +
|
||||
"(앱 판정은 ${last?.bestCm}cm). 앱은 검증된 단일 pass 규칙을 " +
|
||||
"그대로 씁니다. 이 차이가 계속 보이면 알고리즘 팀에 알려 주세요 — " +
|
||||
"기록에도 남습니다.",
|
||||
"참고 — 중간에 지표가 나빠진 적이 있습니다. 거기서 멈추는 옛 방식이면 " +
|
||||
"${singlePassBest}cm 이 뽑혔을 자리입니다. 지금은 잰 위치 전부로 " +
|
||||
"골라 ${finalBest}cm 입니다. 기록에 둘 다 남습니다.",
|
||||
fontSize = 12.sp, color = MlPrimary,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.background(MlPrimary.copy(alpha = 0.08f), RoundedCornerShape(8.dp))
|
||||
@@ -692,7 +706,7 @@ fun AnchorAlignView(appState: AppState) {
|
||||
// (2026-09-10 피드백). 지시(카드) 바로 아래 누를 것을 둔다.
|
||||
//
|
||||
// 아래쪽은 **근거**다 — BV·접촉·파형은 "왜 이 값인가"를 볼 때만 내려다본다.
|
||||
if (!done) {
|
||||
if (!confirmDone) {
|
||||
Button(
|
||||
onClick = { measuring = true },
|
||||
enabled = isConnected && !measuring,
|
||||
@@ -733,7 +747,7 @@ fun AnchorAlignView(appState: AppState) {
|
||||
// ── 2단계: 좌우 정렬 ───────────────────────────────────────
|
||||
// 상하가 정해진 자리에서 프로브를 좌우로만 움직여 CH4·CH5 를 맞춘다.
|
||||
LateralSection(
|
||||
anchorCm = last?.anchorCm,
|
||||
anchorCm = anchorTarget,
|
||||
running = lrRunning,
|
||||
commit = lrCommit,
|
||||
accumulated = lrAccum,
|
||||
@@ -785,10 +799,15 @@ fun AnchorAlignView(appState: AppState) {
|
||||
// 대신 **근거를 같이 들고 간다**([AnchorBasis]). 사람이 고른 위치와 알고리즘이
|
||||
// 확정한 위치가 기록에서 구분되지 않으면, 정확도 검증의 모집단을 나중에
|
||||
// 가려낼 수 없어 데이터 전체가 주장을 받치지 못한다.
|
||||
val basis = AnchorBasis.of(last?.action)
|
||||
// 근거. 확인 측정까지 마쳤으면 앱 규칙이 정한 값이다.
|
||||
val basis = when {
|
||||
confirmDone -> AnchorBasis.ALGORITHM
|
||||
needReattach -> AnchorBasis.REATTACH_OVERRIDE
|
||||
else -> AnchorBasis.MANUAL
|
||||
}
|
||||
// 확정됐으면 그 값(best+offset), 아니면 지금 화면의 위치. 둘 다 "프로브가
|
||||
// 지금 있는 자리"라는 뜻이라 간호사가 보는 숫자와 기록이 어긋나지 않는다.
|
||||
val proceedCm = last?.anchorCm ?: cm
|
||||
val proceedCm = if (confirmDone) anchorTarget ?: cm else cm
|
||||
Button(
|
||||
onClick = {
|
||||
appState.anchorCm = proceedCm
|
||||
@@ -832,7 +851,7 @@ fun AnchorAlignView(appState: AppState) {
|
||||
Text(note, fontSize = 12.sp, color = MlSecondaryText)
|
||||
}
|
||||
|
||||
if (done) {
|
||||
if (confirmDone || needReattach) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
TextButton(
|
||||
onClick = {
|
||||
@@ -840,6 +859,7 @@ fun AnchorAlignView(appState: AppState) {
|
||||
lateral.reset(); lrCommit = null; lrAccum = 0
|
||||
lrConfirmed = false; lrError = null; lrRunning = false
|
||||
disconnectedMidRun = false
|
||||
decidedAtCm = null; sweepStopped = false; confirmDone = false
|
||||
},
|
||||
enabled = !lrRunning,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -1153,52 +1173,57 @@ internal fun instructionOf(
|
||||
step: AnchorStep?,
|
||||
nextCm: Int,
|
||||
offsetCm: Int,
|
||||
/** 수집 단계인가 — 판정이 났어도 SEARCH_MAX_CM 까지 올린다. */
|
||||
/** 수집 단계인가 — 판정과 무관하게 SEARCH_MAX_CM 까지 올린다. */
|
||||
sweeping: Boolean = false,
|
||||
/** 판정이 내려진 위치. 수집 단계에서 "이미 답은 나왔다"를 알려 주는 데 쓴다. */
|
||||
/** 단일 pass 가 멈춘 위치. 수집 중 "옛 방식이면 여기서 끝났다"를 알려 주는 데 쓴다. */
|
||||
decidedAtCm: Int? = null,
|
||||
/** 수집이 끝나고 확인 측정할 위치(= 전 위치 판정 결과). null = 아직 수집 중이거나 후보 없음. */
|
||||
confirmTarget: Int? = null,
|
||||
/** 확인 측정을 마쳤는가. true 면 "+offset 올리고 좌우로". */
|
||||
confirmDone: Boolean = false,
|
||||
/** 후보가 하나도 없어 재부착이 필요한가. */
|
||||
needReattach: Boolean = false,
|
||||
): AlignInstruction = when {
|
||||
step == null -> AlignInstruction(
|
||||
"●", "치골 바로 위에 붙이고 측정",
|
||||
"젤을 바르고, 프로브를 치골 바로 위에 밀착시킵니다.")
|
||||
|
||||
step.action == AnchorAction.REATTACH -> AlignInstruction(
|
||||
"↓", "더 아래에 다시 붙이세요",
|
||||
"치골 쪽으로 내려 다시 부착한 뒤 처음부터 측정합니다. " +
|
||||
(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(
|
||||
// 확인 측정까지 끝났다 — 올리고 좌우로. 이 자리는 재지 않는다.
|
||||
confirmDone -> AlignInstruction(
|
||||
"↑", "${offsetCm}cm 올리세요",
|
||||
"여기서는 측정하지 않습니다. 올린 뒤 좌우 맞추기로 넘어갑니다.",
|
||||
done = true)
|
||||
|
||||
else -> {
|
||||
// 지금 있는 자리는 방금 잰 위치다. 다음 자리는 호출부가 이미 정해 두었다.
|
||||
val from = step.record?.cm ?: nextCm
|
||||
val delta = nextCm - from
|
||||
// 수집 중에는 판정이 뭐라 하든 계속 올린다. 판정은 다 재고 나서 한다.
|
||||
sweeping -> AlignInstruction(
|
||||
"↑", "1cm 올리고 측정",
|
||||
decidedAtCm?.let {
|
||||
"${AnchorConfig.SEARCH_MAX_CM}cm 까지 전부 잽니다. " +
|
||||
"(옛 방식이면 ${it}cm 에서 멈췄을 자리입니다 — 지금은 다 재고 판정합니다.)"
|
||||
} ?: "${AnchorConfig.SEARCH_MAX_CM}cm 까지 전부 잽니다.")
|
||||
|
||||
// 다 쟀는데 후보가 하나도 없다.
|
||||
needReattach -> AlignInstruction(
|
||||
"↓", "더 아래에 다시 붙이세요",
|
||||
"잰 위치 중 CH3 이 제대로 잡힌 곳이 없습니다. 치골 쪽으로 내려 다시 부착한 뒤 " +
|
||||
"[처음부터 다시 정렬] 을 눌러 주세요.")
|
||||
|
||||
// 판정된 자리로 이동 → 확인 측정.
|
||||
confirmTarget != null -> {
|
||||
val from = step.record?.cm ?: confirmTarget
|
||||
val delta = confirmTarget - from
|
||||
when {
|
||||
delta > 0 -> AlignInstruction("↑", "${delta}cm 올리고 측정", null)
|
||||
delta < 0 -> AlignInstruction("↓", "${-delta}cm 내리고 측정", null)
|
||||
// 이미 그 자리다 — 확인 측정 한 번이 남았다. 이 화면에서 제일 많이 나오는
|
||||
// 질문이 "왜 방금 잰 자리를 또 재냐"라, 이유를 같이 적는다.
|
||||
delta > 0 -> AlignInstruction("↑", "${delta}cm 올리고 측정",
|
||||
"잰 위치 전부로 고른 자리입니다. 여기서 확인 측정 한 번 합니다.")
|
||||
delta < 0 -> AlignInstruction("↓", "${-delta}cm 내리고 측정",
|
||||
"잰 위치 전부로 고른 자리입니다. 여기서 확인 측정 한 번 합니다.")
|
||||
else -> AlignInstruction("●", "여기서 한 번 더 측정",
|
||||
"옮겨 온 자리의 상태를 확인하는 마지막 측정입니다.")
|
||||
}
|
||||
}
|
||||
|
||||
// 여기까지 오면 아직 한 위치도 못 쟀거나 상태가 어긋난 것이다.
|
||||
else -> AlignInstruction("↑", "1cm 올리고 측정", null)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1254,12 +1279,18 @@ private fun buildAlignSummary(
|
||||
*/
|
||||
decidedAtCm: Int? = null,
|
||||
): JSONObject = JSONObject().apply {
|
||||
// 잰 위치 **전부**로 고르면 어디가 뽑히는가. 앱이 쓰는 값은 `best_cm`(단일 pass)이고
|
||||
// 이것은 **비교용**이다. 단일 pass 가 가정하는 단봉성이 실제로 성립하는지 세션마다
|
||||
// 세어 보려고 남긴다(2026-09-10 알고리즘 팀 질의).
|
||||
// 앱이 쓴 값 = **잰 위치 전부**로 고른 것. 선택 규칙은 Python `select_supine_anchor`
|
||||
// 그대로이고(AnchorSelectionParityTest 200케이스), 바뀐 것은 후보 집합뿐이다.
|
||||
val fullBest = AnchorSelection.selectBest(records.filter { it.capFrac.isFinite() }).bestCm
|
||||
put("best_cm_full_sweep", fullBest ?: JSONObject.NULL)
|
||||
put("sweep_disagrees", step.bestCm != null && fullBest != null && fullBest != step.bestCm)
|
||||
|
||||
// ── 판정 범위 ──────────────────────────────────────────────────────────
|
||||
// 2026-09-10 부터 앱은 **잰 위치 전부**로 판정한다. `best_cm`/`anchor_cm` 은 그 값이다.
|
||||
// 단일 pass 가 냈을 답을 같이 남긴다 — 변경을 감사할 수 있어야 하고, 그 빈도가
|
||||
// "단봉 가정이 얼마나 깨지는가"의 지표다.
|
||||
put("selection_scope", "full_sweep")
|
||||
put("best_cm_single_pass", step.bestCm ?: JSONObject.NULL)
|
||||
put("single_pass_disagrees",
|
||||
step.bestCm != null && fullBest != null && fullBest != step.bestCm)
|
||||
put("patient", patient)
|
||||
put("save_name", saveName)
|
||||
// 이름 없이 잰 데이터인지 파일만 봐도 알 수 있어야 나중에 짝을 맞출 수 있다.
|
||||
@@ -1276,8 +1307,8 @@ private fun buildAlignSummary(
|
||||
put("samples", HospitalFixedParams.SAMPLES)
|
||||
put("ch3_hit_min", AnchorConfig.CH3_HIT_MIN)
|
||||
put("final_offset_cm", AnchorConfig.FINAL_OFFSET_CM)
|
||||
put("best_cm", step.bestCm ?: JSONObject.NULL)
|
||||
put("anchor_cm", step.anchorCm ?: JSONObject.NULL)
|
||||
put("best_cm", fullBest ?: JSONObject.NULL)
|
||||
put("anchor_cm", fullBest?.let { it + AnchorConfig.FINAL_OFFSET_CM } ?: JSONObject.NULL)
|
||||
put("action", step.action.name)
|
||||
// 판정 경계. positions[].in_decision 과 같은 사실을 세션 수준에서도 적는다.
|
||||
put("decided_at_cm", decidedAtCm ?: JSONObject.NULL)
|
||||
|
||||
Reference in New Issue
Block a user