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)
|
||||
|
||||
+109
-57
@@ -1,6 +1,7 @@
|
||||
package com.medithings.vesiscan.ui.views.clinical
|
||||
|
||||
import com.medithings.vesiscan.managers.AnchorAction
|
||||
import com.medithings.vesiscan.managers.AnchorConfig
|
||||
import com.medithings.vesiscan.managers.AnchorPosRecord
|
||||
import com.medithings.vesiscan.managers.AnchorStep
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -10,14 +11,21 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 정렬 화면 지시문 — **위치 이름이 화면에 나오면 안 된다.**
|
||||
* 정렬 화면 지시문.
|
||||
*
|
||||
* 시험하는 사람이 "최적 위치"와 "부착 위치"를 계속 헷갈렸다(2026-09-09 현장). 둘 다
|
||||
* "위치"라는 이름의 숫자이고, 화면이 단계마다 다른 쪽을 크게 보여 줬기 때문이다.
|
||||
* 프로브를 잡은 사람에게 필요한 것은 지금 할 동작 하나다.
|
||||
* ## 두 가지를 고정한다
|
||||
* **① 위치 이름이 화면에 나오면 안 된다.** 시험하는 사람이 "최적 위치"와 "부착 위치"를
|
||||
* 계속 헷갈렸다(2026-09-09 현장) — 둘 다 "위치"라는 이름의 숫자이고 화면이 단계마다 다른
|
||||
* 쪽을 크게 보여 줬기 때문이다. 여기서는 **헷갈림 자체가 고치려던 결함**이라 문구가
|
||||
* 요구사항이다.
|
||||
*
|
||||
* 그래서 이 파일은 문구를 고정한다. 화면 문구 시험은 보통 과하지만, 여기서는 **헷갈림
|
||||
* 자체가 고치려던 결함**이라 문구가 요구사항이다.
|
||||
* **② 단계 순서.** 2026-09-10 부터 흐름이 바뀌었다:
|
||||
*
|
||||
* 0~4cm 전부 측정 → 전 위치로 판정 → 그 자리로 이동해 확인 측정 → +1cm → 좌우
|
||||
*
|
||||
* 종전에는 `AnchorGuide` 의 STOP 이 확정 신호였다. 이제 판정을 전 위치로 하므로 guide 의
|
||||
* STOP 과 앱의 부착 위치가 다를 수 있어, 화면이 `confirmTarget`/`confirmDone` 으로
|
||||
* 직접 단계를 몰고 간다. 그 계약을 여기서 고정한다.
|
||||
*/
|
||||
class AlignInstructionTest {
|
||||
|
||||
@@ -26,28 +34,23 @@ class AlignInstructionTest {
|
||||
capFrac = 0.64, nchTrace = 2.0, nchSd = 0.0,
|
||||
)
|
||||
|
||||
private fun step(
|
||||
action: AnchorAction,
|
||||
atCm: Int,
|
||||
bestCm: Int? = null,
|
||||
searching: Boolean = true,
|
||||
stopReason: String? = null,
|
||||
) = AnchorStep(
|
||||
action = action, message = "", record = rec(atCm), bestCm = bestCm,
|
||||
private fun step(atCm: Int, searching: Boolean = true, bestCm: Int? = null) = AnchorStep(
|
||||
action = if (searching) AnchorAction.MOVE_UP else AnchorAction.MOVE_DOWN,
|
||||
message = "", record = rec(atCm), bestCm = bestCm,
|
||||
anchorCm = bestCm?.let { it + 1 }, maxNch = 2.0, searching = searching,
|
||||
stopReason = stopReason,
|
||||
stopReason = if (searching) null else "ch3 소실 0%<80%",
|
||||
)
|
||||
|
||||
/** 화면에 절대 나오면 안 되는 말. */
|
||||
private val banned = listOf("최적", "부착 위치", "anchor")
|
||||
|
||||
private fun assertNoPositionWords(i: AlignInstruction) {
|
||||
val all = listOf(i.headline, i.sub ?: "").joinToString(" ")
|
||||
banned.forEach { w ->
|
||||
listOf("최적", "부착 위치", "anchor").forEach { w ->
|
||||
assertFalse("\"$w\" 가 지시문에 있으면 안 된다 — $all", all.contains(w))
|
||||
}
|
||||
}
|
||||
|
||||
// ── 첫 측정 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun `첫 측정 전에는 붙일 자리를 말한다`() {
|
||||
val i = instructionOf(null, nextCm = 0, offsetCm = 1)
|
||||
assertEquals("●", i.arrow)
|
||||
@@ -56,83 +59,132 @@ class AlignInstructionTest {
|
||||
assertNoPositionWords(i)
|
||||
}
|
||||
|
||||
@Test fun `탐색 중에는 올리고 측정하라고만 한다`() {
|
||||
// 0cm 을 재고 다음은 1cm. 절대 숫자가 아니라 **차이**를 말해야 한다.
|
||||
val i = instructionOf(step(AnchorAction.MOVE_UP, atCm = 0), nextCm = 1, offsetCm = 1)
|
||||
// ── 수집 단계 ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun `수집 중에는 계속 올리라고만 한다`() {
|
||||
val i = instructionOf(step(0), nextCm = 1, offsetCm = 1, sweeping = true)
|
||||
assertEquals("↑", i.arrow)
|
||||
assertEquals("1cm 올리고 측정", i.headline)
|
||||
assertTrue("어디까지 재는지 말해야 멈추지 않는다",
|
||||
i.sub!!.contains("${AnchorConfig.SEARCH_MAX_CM}cm"))
|
||||
assertFalse(i.done)
|
||||
assertNoPositionWords(i)
|
||||
}
|
||||
|
||||
@Test fun `지나쳤으면 내리고 측정하라고 한다`() {
|
||||
// 3cm 에서 종료가 걸리고 best=1 이면 2cm 내려야 한다.
|
||||
val i = instructionOf(
|
||||
step(AnchorAction.MOVE_DOWN, atCm = 3, bestCm = 1, searching = false),
|
||||
nextCm = 1, offsetCm = 1,
|
||||
)
|
||||
@Test fun `수집 중 옛 방식이면 멈췄을 자리를 알려 준다`() {
|
||||
// 판정이 전 위치로 바뀌었다는 사실을 현장에서도 알 수 있어야, "왜 계속 재냐"가
|
||||
// 안 나온다.
|
||||
val i = instructionOf(step(2, searching = false, bestCm = 0),
|
||||
nextCm = 3, offsetCm = 1, sweeping = true, decidedAtCm = 1)
|
||||
assertEquals("1cm 올리고 측정", i.headline)
|
||||
assertTrue(i.sub!!.contains("1cm"))
|
||||
assertTrue(i.sub!!.contains("옛 방식"))
|
||||
assertNoPositionWords(i)
|
||||
}
|
||||
|
||||
@Test fun `수집은 판정보다 우선한다`() {
|
||||
// guide 가 MOVE_DOWN 을 내도 수집 중이면 계속 올린다. 이게 뒤바뀌면 4cm 까지
|
||||
// 못 가고 데이터가 비어 버린다.
|
||||
val i = instructionOf(step(2, searching = false, bestCm = 0),
|
||||
nextCm = 3, offsetCm = 1, sweeping = true, confirmTarget = 0)
|
||||
assertEquals("↑", i.arrow)
|
||||
assertEquals("1cm 올리고 측정", i.headline)
|
||||
}
|
||||
|
||||
// ── 확인 측정 단계 ──────────────────────────────────────────────────────
|
||||
|
||||
@Test fun `판정된 자리로 내려가 확인 측정하라고 한다`() {
|
||||
// 4cm 까지 다 재고, 전 위치 판정이 1cm 이면 3cm 내려야 한다.
|
||||
val i = instructionOf(step(4, searching = false, bestCm = 0),
|
||||
nextCm = 1, offsetCm = 1, confirmTarget = 1)
|
||||
assertEquals("↓", i.arrow)
|
||||
assertEquals("2cm 내리고 측정", i.headline)
|
||||
assertEquals("3cm 내리고 측정", i.headline)
|
||||
assertTrue("왜 그 자리인지 말해야 한다", i.sub!!.contains("전부"))
|
||||
assertNoPositionWords(i)
|
||||
}
|
||||
|
||||
@Test fun `판정된 자리가 위면 올리라고 한다`() {
|
||||
val i = instructionOf(step(0, searching = false, bestCm = 0),
|
||||
nextCm = 3, offsetCm = 1, confirmTarget = 3)
|
||||
assertEquals("↑", i.arrow)
|
||||
assertEquals("3cm 올리고 측정", i.headline)
|
||||
assertNoPositionWords(i)
|
||||
}
|
||||
|
||||
@Test fun `같은 자리면 한 번 더 재라고 하고 이유를 댄다`() {
|
||||
// 이 화면에서 제일 많이 나오는 질문이 "왜 방금 잰 자리를 또 재냐"다.
|
||||
val i = instructionOf(
|
||||
step(AnchorAction.MOVE_DOWN, atCm = 2, bestCm = 2, searching = false),
|
||||
nextCm = 2, offsetCm = 1,
|
||||
)
|
||||
val i = instructionOf(step(2, searching = false, bestCm = 2),
|
||||
nextCm = 2, offsetCm = 1, confirmTarget = 2)
|
||||
assertEquals("●", i.arrow)
|
||||
assertTrue(i.headline.contains("한 번 더"))
|
||||
assertNotNull("이유가 없으면 또 물어본다", i.sub)
|
||||
assertNoPositionWords(i)
|
||||
}
|
||||
|
||||
@Test fun `확정되면 올리고 재지 말라고 한다`() {
|
||||
// ── 확정 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun `확인 측정을 마치면 올리고 재지 말라고 한다`() {
|
||||
// 여기가 제일 중요하다. 올린 자리를 측정하면 설계상 "미달"이 떠서 되돌아간다.
|
||||
val i = instructionOf(
|
||||
step(AnchorAction.STOP, atCm = 2, bestCm = 2, searching = false),
|
||||
nextCm = 2, offsetCm = 1,
|
||||
)
|
||||
val i = instructionOf(step(2, searching = false, bestCm = 2),
|
||||
nextCm = 2, offsetCm = 1, confirmTarget = 2, confirmDone = true)
|
||||
assertEquals("↑", i.arrow)
|
||||
assertEquals("1cm 올리세요", i.headline)
|
||||
assertTrue("측정하지 말라는 말이 빠지면 안 된다",
|
||||
i.sub!!.contains("측정하지 않습니다"))
|
||||
assertTrue("측정하지 말라는 말이 빠지면 안 된다", i.sub!!.contains("측정하지 않습니다"))
|
||||
assertTrue("다음 단계를 말해야 멈추지 않는다", i.sub!!.contains("좌우"))
|
||||
assertTrue("확정은 초록이다", i.done)
|
||||
assertNoPositionWords(i)
|
||||
}
|
||||
|
||||
@Test fun `확정은 수집이나 재부착보다 우선한다`() {
|
||||
// 상태가 겹쳐도 확정이 먼저다 — 이미 끝난 단계를 되돌리면 안 된다.
|
||||
val i = instructionOf(step(2, searching = false, bestCm = 2),
|
||||
nextCm = 2, offsetCm = 1, sweeping = true, confirmTarget = 2,
|
||||
confirmDone = true, needReattach = true)
|
||||
assertTrue(i.done)
|
||||
assertEquals("1cm 올리세요", i.headline)
|
||||
}
|
||||
|
||||
@Test fun `오프셋이 바뀌면 문구도 따라간다`() {
|
||||
// FINAL_OFFSET_CM 을 2 로 바꾸면 화면도 2cm 이라고 말해야 한다. 박아 두면 거짓이 된다.
|
||||
val i = instructionOf(
|
||||
step(AnchorAction.STOP, atCm = 1, bestCm = 1, searching = false),
|
||||
nextCm = 1, offsetCm = 2,
|
||||
)
|
||||
val i = instructionOf(step(1, searching = false, bestCm = 1),
|
||||
nextCm = 1, offsetCm = 2, confirmTarget = 1, confirmDone = true)
|
||||
assertEquals("2cm 올리세요", i.headline)
|
||||
}
|
||||
|
||||
@Test fun `재부착은 아래로 내리라고 하고 사유를 붙인다`() {
|
||||
val i = instructionOf(
|
||||
step(AnchorAction.REATTACH, atCm = 3, searching = false,
|
||||
stopReason = "탐색 상한 4cm"),
|
||||
nextCm = 3, offsetCm = 1,
|
||||
)
|
||||
// ── 재부착 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun `후보가 없으면 아래로 내려 다시 붙이라고 한다`() {
|
||||
val i = instructionOf(step(4, searching = false, bestCm = null),
|
||||
nextCm = 4, offsetCm = 1, needReattach = true)
|
||||
assertEquals("↓", i.arrow)
|
||||
assertTrue(i.headline.contains("아래"))
|
||||
assertTrue("사유가 없으면 같은 실수를 반복한다", i.sub!!.contains("탐색 상한"))
|
||||
assertTrue("무엇을 눌러야 하는지 말해야 한다", i.sub!!.contains("처음부터"))
|
||||
assertFalse(i.done)
|
||||
assertNoPositionWords(i)
|
||||
}
|
||||
|
||||
@Test fun `모든 동작에 지시문이 있다`() {
|
||||
// action 이 늘어났는데 여기를 안 고치면 조용히 "올리고 측정"으로 떨어진다.
|
||||
assertEquals(4, AnchorAction.entries.size)
|
||||
AnchorAction.entries.forEach { a ->
|
||||
val i = instructionOf(step(a, atCm = 1, bestCm = 1, searching = false),
|
||||
nextCm = 2, offsetCm = 1)
|
||||
assertTrue("$a 에 문구가 없다", i.headline.isNotBlank())
|
||||
// ── 전 조합 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun `어떤 조합에서도 문구가 비지 않는다`() {
|
||||
val targets = listOf<Int?>(null, 0, 2, 4)
|
||||
for (sw in listOf(true, false)) {
|
||||
for (cd in listOf(true, false)) {
|
||||
for (nr in listOf(true, false)) {
|
||||
for (t in targets) {
|
||||
val i = instructionOf(
|
||||
step(2, searching = false, bestCm = 0),
|
||||
nextCm = 3, offsetCm = 1,
|
||||
sweeping = sw, decidedAtCm = 1,
|
||||
confirmTarget = t, confirmDone = cd, needReattach = nr,
|
||||
)
|
||||
assertTrue(
|
||||
"sweeping=$sw confirmDone=$cd reattach=$nr target=$t 에 문구가 없다",
|
||||
i.headline.isNotBlank(),
|
||||
)
|
||||
assertNoPositionWords(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+56
-56
@@ -1,6 +1,6 @@
|
||||
# 병원 임상 측정 — 알고리즘 상세
|
||||
|
||||
> 대상: `demo-final` 브랜치, 2026-09-10 (`af16541`) 기준.
|
||||
> 대상: `demo-final` 브랜치, 2026-09-10 (`2e5a1f1` 이후) 기준.
|
||||
> 이 문서는 **코드를 읽고 쓴 것**이고, 각 항목에 파일·라인을 달았습니다. 값이 의심되면
|
||||
> 해당 파일을 보는 것이 이 문서보다 우선입니다.
|
||||
|
||||
@@ -345,6 +345,9 @@ eligible = ch3Mean && ch3Rate >= 0.80
|
||||
|
||||
`AnchorSelection.selectBest()` (`AnchorGuide.kt:174`)
|
||||
|
||||
> **후보는 잰 위치 전부입니다** (2026-09-10 변경). 종전에는 "종료 조건이 걸릴 때까지"의
|
||||
> 위치만 후보였습니다. 자세히는 [3.6](#36-실제-진행-순서).
|
||||
|
||||
```
|
||||
① 후보 자격 ch3 == "O" AND ch3_rate ≥ 0.8
|
||||
→ 통과가 0개면 → REATTACH (재부착)
|
||||
@@ -369,81 +372,78 @@ val anchorCm: Int? get() = bestCm?.let { it + offsetCm } // offsetCm = 1
|
||||
**오프셋 위치는 측정하지 않습니다.** 설계상 그 자리는 지표가 나쁜 쪽이라, 재서 문턱으로
|
||||
쓰면 "미달"이 떠서 도달 전에 멈춥니다.
|
||||
|
||||
### 3.6 실제 진행 순서 (2026-09-10 변경)
|
||||
|
||||
병원 임상은 위치를 고르는 것만이 목적이 아니라 **위치별 원신호를 모으는 것**도 목적입니다.
|
||||
그래서 종료 조건이 걸려도 **4cm 까지 전부 잽니다.**
|
||||
### 3.6 실제 진행 순서
|
||||
|
||||
```
|
||||
0cm 측정 → ↑ 1cm 올리고 측정
|
||||
1cm 측정 → 종료 조건 걸림 (판정 완료, best 확정)
|
||||
↑ 1cm 올리고 측정
|
||||
"부착 위치는 이미 정해졌습니다(1cm 에서 판정).
|
||||
4cm 까지는 데이터 수집용으로 잽니다 — 여기서 멈춰도 됩니다."
|
||||
2·3·4cm 측정 (수집)
|
||||
→ ↓ N cm 내리고 측정 (부착 위치로)
|
||||
→ ● 여기서 한 번 더 측정 ← 확정 판정의 입력
|
||||
→ ↑ 1cm 올리세요 (초록 · 여기서는 측정하지 않음)
|
||||
→ 좌우 정렬
|
||||
1cm 측정 → ↑ 1cm 올리고 측정 (판정이 나빠져도 멈추지 않습니다)
|
||||
2·3·4cm 측정
|
||||
↓
|
||||
잰 위치 전부로 판정 → best = N
|
||||
↓
|
||||
↓/↑ N cm 로 이동 → ● 여기서 한 번 더 측정 (확인 측정 = 확정 신호)
|
||||
↓
|
||||
↑ 1cm 올리세요 (초록 · 여기서는 측정하지 않음)
|
||||
↓
|
||||
좌우 정렬
|
||||
```
|
||||
|
||||
#### ⚠ 판정은 수집 때문에 바뀌지 않습니다
|
||||
[여기까지만 재고 판정하기] 로 수집을 중단할 수 있습니다 — 그 시점까지의 위치로 판정합니다.
|
||||
|
||||
`selectBest` 는 **넘긴 레코드 전부**를 후보로 봅니다. 후보가 늘면 답이 뒤집힐 수 있습니다:
|
||||
#### 판정 범위가 바뀌었습니다 (2026-09-10)
|
||||
|
||||
```
|
||||
0cm eligible nch=2 · 1cm ch3 소실 · 2cm eligible nch=4
|
||||
조기 종료 후보 {0,1} → best 0cm
|
||||
전부 넣으면 → best 2cm ← 다른 답
|
||||
```
|
||||
| | 종전 | 현재 |
|
||||
|---|---|---|
|
||||
| 측정 | 종료 조건 걸리면 중단 | **0~4cm 무조건 전부** |
|
||||
| 후보 | 종료까지의 위치 | **잰 위치 전부** |
|
||||
| 선택 규칙 | 3단 규칙 | **그대로** |
|
||||
| 부착 | best + 1cm 단순 덧셈 | **그대로** |
|
||||
| 종료 조건 | 판정을 끝냄 | **진단용** (`decided_at_cm`) |
|
||||
|
||||
안전한 이유는 `AnchorGuide` 가 `searching` 이 꺾이는 **그 순간 한 번만** `bestCm` 을
|
||||
계산하고, 이후 `step()` 은 `recs` 에만 쌓기 때문입니다. `AnchorSweepTest` 가 이 성질을
|
||||
고정합니다.
|
||||
**왜 바꿨나.** 단일 pass 는 지표가 cm 에 대해 **단봉(unimodal)** 이라고 가정해, 나빠지는
|
||||
순간 봉우리를 지난 것으로 보고 멈춥니다. 그런데 장내 가스·접촉 불량·호흡으로 **한 위치만**
|
||||
일시적으로 나빠져도 멈추므로, 더 위가 진짜 좋았는데 놓칠 수 있습니다(알고리즘 팀 질의).
|
||||
|
||||
#### 단일 pass 가 가정하는 것 — 그리고 그 가정을 측정하는 방법
|
||||
실측에 징후가 있었습니다. 2026-09-09 `kai` 는 **0cm `ch3=X` → 1cm `ch3=O 91%`** 로 지표가
|
||||
**올라갔습니다.** 시작 쪽은 종료 조건이 "앞에 eligible 이 있어야" 걸리게 막혀 있지만,
|
||||
**중간에서는 한 칸만 나빠도 끝났습니다.** 같은 날 `181128` 이 정확히 그 경우로, 1cm 에서
|
||||
`nch 2→0`·`ch3 100%→0%` 가 되어 `best=0cm` 으로 끝났고 2cm 이후는 알 수 없었습니다.
|
||||
|
||||
단일 pass 는 **지표가 cm 에 대해 단봉(unimodal)** 이라고 가정합니다. 올라가다 나빠지면
|
||||
봉우리를 지난 것으로 보고 멈춥니다. Python 도 같습니다 — *"수직 → 좌우 순서로 한 번만
|
||||
가고 되돌아가지 않는다."*
|
||||
**레퍼런스는 어떻게 되나.** 선택 규칙 `selectBest` 는 Python `select_supine_anchor` 와
|
||||
1:1 이고 200 케이스로 대조돼 있습니다(`AnchorSelectionParityTest`) — **손대지 않았습니다.**
|
||||
바뀐 것은 후보 집합뿐이고, **Python 의 회고 분석 경로가 이미 전 위치로 판정**하므로 같은
|
||||
방식으로 재현·대조할 수 있습니다. 다만 최종 보고서의 "단일 pass" 기술은 갱신이 필요합니다.
|
||||
|
||||
물리적 근거는 있습니다. CH3 은 제일 기울어진 채널로 **방광 아래쪽**을 보므로, 위로
|
||||
올라가면 점점 방광을 벗어납니다.
|
||||
|
||||
**깨질 수 있는 경우**는 *한 위치만* 일시적으로 나빠지는 것입니다 — 장내 가스가 CH3 을
|
||||
가림, 그 위치에서 접촉이 떴음, 호흡으로 방광이 움직임. 그러면 더 위가 진짜 좋았는데
|
||||
조기 종료합니다.
|
||||
|
||||
실측에 징후가 있습니다. 2026-09-09 `kai` 세션은 **0cm `ch3=X` → 1cm `ch3=O 91%`** 로
|
||||
지표가 **올라갔습니다.** 시작 쪽은 종료 조건이 "앞에 eligible 이 있어야" 걸리게 막혀
|
||||
있지만, **중간에서는 한 칸만 나빠도 끝납니다.**
|
||||
|
||||
전수 측정이 이 질문에 답을 냅니다. 세션마다 두 값을 같이 기록합니다:
|
||||
**감사 가능하게 둘 다 기록합니다:**
|
||||
|
||||
```json
|
||||
"best_cm": 0, // 앱이 쓰는 값 (단일 pass · 레퍼런스)
|
||||
"best_cm_full_sweep": 3, // 잰 위치 전부로 고르면
|
||||
"sweep_disagrees": true // 갈렸다
|
||||
"selection_scope": "full_sweep",
|
||||
"best_cm": 3, // 앱이 쓴 값 = 전 위치 판정
|
||||
"anchor_cm": 4, // best + 1
|
||||
"best_cm_single_pass": 0, // 옛 단일 pass 가 냈을 답
|
||||
"single_pass_disagrees": true,
|
||||
"decided_at_cm": 1, // 종료 조건이 걸린 위치 (진단용)
|
||||
"swept_to_cm": 4
|
||||
```
|
||||
|
||||
갈리면 화면에도 참고 문구가 뜹니다. **판정은 바꾸지 않습니다** — 레퍼런스 대조가 단일
|
||||
pass 기준이라 규칙을 바꾸면 검증을 다시 해야 합니다. 몇십 세션의 `sweep_disagrees` 빈도를
|
||||
보고 규칙을 바꿀지는 알고리즘 팀이 정합니다.
|
||||
갈리면 화면에도 참고 문구가 뜹니다:
|
||||
|
||||
> 참고 — 중간에 지표가 나빠진 적이 있습니다. 거기서 멈추는 옛 방식이면 **0cm** 이 뽑혔을
|
||||
> 자리입니다. 지금은 잰 위치 전부로 골라 **3cm** 입니다. 기록에 둘 다 남습니다.
|
||||
|
||||
`single_pass_disagrees` 빈도가 곧 **"단봉 가정이 얼마나 깨지는가"** 의 지표입니다.
|
||||
|
||||
#### 확인 측정은 판정 데이터를 덮지 않습니다
|
||||
|
||||
부착 위치로 내려가 한 번 더 재는 측정은 `align_2cm_confirm.csv` 로 따로 저장합니다.
|
||||
같은 이름에 쓰면 **판정에 쓰인 데이터가 확인 측정으로 덮여**, 파일로 재판정하면 앱과
|
||||
답이 갈릴 수 있습니다. 전수 측정으로 바뀐 뒤 이 재측정이 **항상** 일어나므로 중요합니다.
|
||||
판정된 자리로 내려가 다시 재는 측정은 `align_2cm_confirm.csv` 로 따로 저장합니다. 같은
|
||||
이름에 쓰면 **판정에 쓰인 데이터가 확인 측정으로 덮여**, 파일로 재판정하면 앱과 답이
|
||||
갈릴 수 있습니다. 전수 측정으로 바뀐 뒤 이 재측정이 **항상** 일어나므로 중요합니다.
|
||||
|
||||
**기록에 경계를 남깁니다** — 안 남기면 재분석하는 쪽이 전 위치를 후보로 넣어 다시 판정하고,
|
||||
다른 best 가 나와 앱이 틀린 것으로 읽힙니다:
|
||||
#### 기록에 후보 경계를 남깁니다
|
||||
|
||||
```json
|
||||
"decided_at_cm": 1,
|
||||
"swept_to_cm": 4,
|
||||
"positions": [{ "align_cm": 2, "in_decision": false, ... }]
|
||||
```
|
||||
`decided_at_cm` 은 이제 **판정 경계가 아니라 진단값**입니다 — 종료 조건이 어디서 걸렸는지
|
||||
(= 지표가 어디서 무너졌는지)를 말합니다. `positions[].in_decision` 도 같은 기준이라,
|
||||
**현재 판정은 `in_decision` 과 무관하게 전 위치를 씁니다.**
|
||||
|
||||
### 3.7 좌우 정렬 — 부착 높이에서
|
||||
|
||||
|
||||
Reference in New Issue
Block a user