fix(align): 진행 기록을 바로잡는다 — 부착 위치는 마지막 잰 자리, 수집 중 후보 없음은 탐색 미완료, 확인 뒤 best 가 옮기면 확정 해제

2026-10-08 P7(Supine) 두 세션과 2026-10-12 보고(Supine·Sitting 모두 CH3 미검출 상태에서
"이대로 진행")를 대조하다 찾은 세 가지다. 셋 다 [이대로 임상 측정 진행]이 남기는 기록
(proceed_anchor_cm · anchor_basis)과 그 뒤 임상 600 의 anchor_cm 에 바로 들어간다.

① 부착 위치가 프로브가 있던 자리가 아니었다. 수집 중에는 한 자리를 재면 화면 위치(cm)가
이미 다음 빈 자리로 넘어가고, 다 잰 뒤에는 best 자리로 "가서 재라"로 바뀐다 — 그 화면 위치를
그대로 기록했다. P7 1404: 0cm 를 재고 바로 진행했는데 요약·재부착 확인 파일·600 의 anchor_cm
이 모두 1cm 로 남았다. 간호사는 0cm 에서 쟀다고 했고 그 말이 맞았다. 이제 확정 전에는 마지막
측정 레코드의 cm 를, 확정 뒤에는 best 자리를 기록한다(recordedAnchorCm).

② 수집 중 후보가 없으면 "재부착 권고 무시"로 적었다. 재부착 권고는 0~4cm 를 다 잰 뒤의
판정인데 지시 카드는 sweeping 을 보고 버튼 쪽만 안 봤다. P7 1404 는 0cm 하나 재고 진행한
세션인데 reattach_override 로 남았다 — 실제는 "수동 지정 (탐색 미완료)"다. sweeping 을
컴포저블 최상위로 올려 카드·버튼·[처음부터 다시 정렬] 이 같은 값을 보게 했다(proceedBasis).

③ 확인 측정 뒤 best 가 다른 자리로 옮겨가도 confirmDone 이 풀리지 않았다
(`if (!isConfirm) confirmDone = false`). P7 1358 은 확인 측정마다 CH3 가 안 잡혀 best 가
1 → 2 → 4 → 3 으로 옮겨갔다 — 중간에 한 번 확정이 떴으면 3cm 를 한 번도 안 쟀는데 초록
"3cm 그대로 — 좌우 맞추기"가 보이고 근거는 알고리즘 확정으로 남는다. 후보가 사라진 경우
(target null)도 같이 푼다(afterMeasure).

분석 시: 이 커밋 전 빌드로 받은 세션은 anchor_cm 을 "그 직전에 잰 자리"로, 수집 중 진행한
reattach_override 는 manual 로 읽어야 한다. P7 1404 와 2026-10-12 세 자세 세션이 해당한다.

RecordedAnchorCmTest 5 · ProceedBasisTest 4 · AfterMeasureTest 5 가 잠근다.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 14:16:02 +09:00
parent 92852a1168
commit 30aaf310e3
4 changed files with 231 additions and 22 deletions
@@ -268,8 +268,18 @@ fun AnchorAlignView(appState: AppState) {
val finalBest = AnchorSelection.selectBest(selectable).bestCm
/** 단일 pass 가 냈을 답. **비교·감사용**이고 판정에는 쓰지 않는다. */
val singlePassBest = last?.bestCm
/** 잰 위치가 하나도 후보가 못 되면 재부착이다. */
/**
* 잰 위치 중 후보가 하나도 없다. **재부착 권고는 다 잰 뒤의 판정**이라, 쓰는 쪽은 모두
* `sweeping` 과 같이 본다 — 지시 카드([instructionOf])·진행 근거([proceedBasis])·
* [처음부터 다시 정렬] 버튼. 수집 중에 후보가 없는 것은 "탐색 미완료"다.
*/
val needReattach = records.isNotEmpty() && finalBest == null
// 수집 단계인가 — 판정이 났어도 0~SEARCH_MAX_CM 에 빈 자리가 있으면 계속 잰다.
// (2026-10-01 까지는 "최대 cm 까지 올렸나"였다. 칩으로 중간 위치를 다시 재도
// 빈 자리 기준이라야 흐름이 안 꼬인다.) 지시 카드와 진행 버튼이 **같은 값**을 봐야
// 한다 — 2026-10-08 P7 은 카드가 "1cm 에 붙이고 측정"인데 버튼은 재부착 권고 무시로 적었다.
val sweeping = !sweepStopped &&
(0..AnchorConfig.SEARCH_MAX_CM).any { c -> records.none { it.cm == c } }
val anchorTarget = finalBest?.let { it + AnchorConfig.FINAL_OFFSET_CM }
// ── 연결이 끊기면 기기에서 온 값을 비운다 ───────────────────────────────
@@ -491,11 +501,11 @@ fun AnchorAlignView(appState: AppState) {
// 있으므로 확정은 다시 판단한다.
val target = AnchorSelection.selectBest(
guide.records.filter { it.capFrac.isFinite() }).bestCm
if (target != null) {
// 방금 잰 자리가 곧 판정된 자리면 그 측정이 확인 측정이다.
if (cm == target && isConfirm) confirmDone = true
else { cm = target; if (!isConfirm) confirmDone = false }
}
// 방금 잰 자리가 곧 판정된 자리면 그 측정이 확인 측정이다. best 가 옮겨갔으면
// 확정도 같이 풀린다([afterMeasure]) — 옮겨간 자리는 아직 아무도 안 쟀다.
val next = afterMeasure(cm, target, isConfirm)
cm = next.cm
confirmDone = next.confirmDone
}
}
} finally {
@@ -791,11 +801,6 @@ fun AnchorAlignView(appState: AppState) {
val singlePassDisagrees = finalBest != null && singlePassBest != null &&
finalBest != singlePassBest
// 수집 단계인가 — 판정이 났어도 0~SEARCH_MAX_CM 에 빈 자리가 있으면 계속 잰다.
// (2026-10-01 까지는 "최대 cm 까지 올렸나"였다. 칩으로 중간 위치를 다시 재도
// 빈 자리 기준이라야 흐름이 안 꼬인다.)
val sweeping = !sweepStopped &&
(0..AnchorConfig.SEARCH_MAX_CM).any { c -> records.none { it.cm == c } }
val instr = instructionOf(
last, cm, AnchorConfig.FINAL_OFFSET_CM,
sweeping = sweeping, decidedAtCm = decidedAtCm,
@@ -1030,15 +1035,14 @@ fun AnchorAlignView(appState: AppState) {
// 대신 **근거를 같이 들고 간다**([AnchorBasis]). 사람이 고른 위치와 알고리즘이
// 확정한 위치가 기록에서 구분되지 않으면, 정확도 검증의 모집단을 나중에
// 가려낼 수 없어 데이터 전체가 주장을 받치지 못한다.
// 근거. 확인 측정까지 마쳤으면 앱 규칙이 정한 값이다.
val basis = when {
confirmDone -> AnchorBasis.ALGORITHM
needReattach -> AnchorBasis.REATTACH_OVERRIDE
else -> AnchorBasis.MANUAL
}
// 확정됐으면 그 값(best+offset), 아니면 지금 화면의 위치. 둘 다 "프로브가
// 지금 있는 자리"라는 뜻이라 간호사가 보는 숫자와 기록이 어긋나지 않는다.
val proceedCm = if (confirmDone) anchorTarget ?: cm else cm
// 근거. 확인 측정까지 마쳤으면 앱 규칙이 정한 값이다. 수집 중이면 재부착 권고가
// 아니라 탐색 미완료다([proceedBasis]).
val basis = proceedBasis(confirmDone, needReattach, sweeping)
// 프로브가 **지금 있는 자리**를 기록한다([recordedAnchorCm]). 확정됐으면 best 자리
// (확인 측정을 거기서 했다), 아니면 마지막으로 잰 자리다. 화면 위치 `cm` 은 수집 중엔
// 한 자리를 재고 나면 이미 다음 빈 자리로 넘어가 있어 프로브가 있는 곳이 아니다 —
// 2026-10-08 P7 은 0cm 를 재고 바로 진행했는데 1cm 로 기록됐다.
val proceedCm = recordedAnchorCm(confirmDone, anchorTarget, last?.record?.cm, cm)
Button(
onClick = {
appState.anchorCm = proceedCm
@@ -1090,7 +1094,7 @@ fun AnchorAlignView(appState: AppState) {
Text(note, fontSize = 12.sp, color = MlSecondaryText)
}
if (confirmDone || needReattach) {
if (confirmDone || (needReattach && !sweeping)) {
Spacer(Modifier.height(8.dp))
TextButton(
onClick = {
@@ -1596,6 +1600,70 @@ internal fun instructionOf(
else -> AlignInstruction("치골 위 ${nextCm}cm 에 붙이고 측정", null)
}
/**
* 수집이 끝난 뒤 한 위치를 잰 결과 → 다음에 잴 자리와 확정 여부.
*
* 확정은 **이번 측정이 확인 측정이었고, 그 결과까지 넣어 다시 고른 best 가 그 자리 그대로일
* 때**뿐이다. 확인 측정에서 CH3 가 안 잡혀 best 가 다른 자리로 옮겨가면 그 자리는 아직 아무도
* 재지 않은 자리다 — 옛 확정이 남아 있으면 안 된다. 후보가 아예 사라져도(null) 마찬가지다.
*
* 2026-10-08 P7(Supine) 1358 세션: 확인 측정마다 best 가 1 → 2 → 4 → 3 으로 옮겨갔다. 종전
* 코드는 `if (!isConfirm) confirmDone = false` 라 **확인 측정** 뒤 best 가 옮겨가면 확정을
* 되돌리지 않았다. 중간에 한 번이라도 확정이 떴으면, 3cm 는 한 번도 안 쟀는데 초록
* "치골 위 3cm 그대로 — 좌우 맞추기" 와 [처음부터 다시 정렬] 이 보이고 진행 근거는 알고리즘
* 확정으로 기록된다.
*
* @param cm 방금 잰 자리.
* @param target 이번 측정까지 넣어 전 위치로 다시 고른 best. null = 후보 없음(재부착).
* @param isConfirm 이번 측정이 확인 측정이었는가(재기 전 best 가 [cm] 이었다).
*/
internal data class AfterMeasure(val cm: Int, val confirmDone: Boolean)
internal fun afterMeasure(cm: Int, target: Int?, isConfirm: Boolean): AfterMeasure = AfterMeasure(
cm = target ?: cm,
confirmDone = target != null && cm == target && isConfirm,
)
/**
* [이대로 임상 측정 진행] 이 기록하는 부착 위치 = **프로브가 지금 있는 자리.**
*
* - 확정됐으면 best 자리(`anchorTarget`) — 확인 측정을 거기서 했으니 프로브도 거기 있다.
* - 아니면 **마지막으로 잰 자리**다. 간호사는 방금 잰 자리의 파형·채널을 보고 "이대로"를
* 누른다(2026-10-08 P7 · 2026-10-12 Supine/Sitting 모두 그랬다).
* - 한 번도 안 쟀으면 화면 위치다 — 그 외에 아는 것이 없다.
*
* 화면 위치(`screenCm`)를 그대로 쓰면 안 된다. 수집 중에는 한 자리를 재고 나면 화면이 이미
* **다음 빈 자리**로 넘어가 있고, 다 잰 뒤에는 best 자리로 "가서 재라"고 바뀌어 있다 — 둘 다
* 프로브가 아직 가지 않은 자리다. 2026-10-08 P7 1404: 0cm 를 재고 바로 진행했는데 화면은
* 1cm 였고, 요약·재부착 확인 파일·임상(600)의 anchor_cm 이 모두 1cm 로 남았다. 간호사는
* 0cm 에서 쟀다고 했고 그 말이 맞았다.
*
* 칩으로 자리를 고른 뒤 재지 않고 진행하면 마지막 잰 자리가 기록된다 — 재지 않은 자리를
* 기록하는 쪽보다 낫다. 기록된 자리는 "그 자리에서 받은 신호가 있다"는 뜻이어야 한다.
*/
internal fun recordedAnchorCm(
confirmDone: Boolean,
anchorTarget: Int?,
lastMeasuredCm: Int?,
screenCm: Int,
): Int =
if (confirmDone) anchorTarget ?: lastMeasuredCm ?: screenCm
else lastMeasuredCm ?: screenCm
/**
* 진행 버튼에 붙는 근거 — 기록(`anchor_basis`)과 병원 화면 라벨이 이 값을 쓴다.
*
* 재부착 권고는 **0~4cm 를 다 잰 뒤의 판정**이다. 수집 중에 후보가 없는 것은 아직 판정이
* 아니라 "탐색 미완료"다. 2026-10-08 P7(Supine) 1404 세션: 0cm 하나 재고(CH3 X) 진행한 것이
* `reattach_override` 로 기록됐다 — 지시 카드([instructionOf])는 `sweeping` 을 보는데 버튼
* 쪽만 안 봤다. 이 값으로 정확도 검증 모집단을 가르므로 틀리면 분석이 오염된다.
*/
internal fun proceedBasis(confirmDone: Boolean, needReattach: Boolean, sweeping: Boolean): AnchorBasis = when {
confirmDone -> AnchorBasis.ALGORITHM
needReattach && !sweeping -> AnchorBasis.REATTACH_OVERRIDE
else -> AnchorBasis.MANUAL
}
/**
* 진행 버튼 아래 한 줄 - **무엇을 모르고 넘어가는지** 말한다.
*
@@ -1638,7 +1706,10 @@ private fun buildAlignSummary(
* 진행 버튼을 누르는 순간 덮어쓰므로, 남은 null 은 "정렬만 하고 나갔다"는 뜻이다.
*/
basis: AnchorBasis? = null,
/** 실제로 임상에 넘긴 cm. [AnchorStep.anchorCm] 이 null 인 채 진행할 수 있다. */
/**
* 실제로 임상에 넘긴 cm = 프로브가 있던 자리([recordedAnchorCm]). [AnchorStep.anchorCm] 이
* null 인 채 진행할 수 있다.
*/
proceedCm: Int? = null,
/**
* 알고리즘이 판정을 내린 위치. **이 cm 까지가 판정의 후보다.**
@@ -0,0 +1,50 @@
package com.medithings.vesiscan.ui.views.clinical
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 수집이 끝난 뒤 한 위치를 잰 결과로 다음 자리와 확정 여부를 정하는 규칙([afterMeasure]).
*
* 2026-10-08 P7(Supine) 1358 세션: 0~4cm 를 다 잰 뒤 확인 측정마다 CH3 가 안 잡혀 best 가
* 1 → 2 → 4 → 3 으로 옮겨갔다. 종전 코드는 **확인 측정** 뒤 best 가 옮겨가면 `confirmDone`
* 을 되돌리지 않았다(`if (!isConfirm) confirmDone = false`). 중간에 한 번이라도 확정이 떴으면
* 3cm 는 한 번도 안 쟀는데 초록 "3cm 그대로 — 좌우 맞추기" 와 [처음부터 다시 정렬] 이 보인다.
*/
class AfterMeasureTest {
@Test fun `확인 측정에서 best 가 그대로면 확정이다`() {
val r = afterMeasure(cm = 3, target = 3, isConfirm = true)
assertEquals(3, r.cm)
assertTrue(r.confirmDone)
}
@Test fun `확인 측정에서 best 가 옮겨가면 확정이 풀리고 새 자리로 간다`() {
// P7 1358: 1cm 확인 측정 → best 2cm. 2cm 는 아직 아무도 확인하지 않았다.
val r = afterMeasure(cm = 1, target = 2, isConfirm = true)
assertEquals(2, r.cm)
assertFalse(r.confirmDone)
}
@Test fun `탐색을 끝낸 측정이 best 자리여도 확인 측정은 아니다`() {
// 4cm 까지 재서 수집이 끝났는데 best 가 마침 4cm. 그 측정은 탐색이라 한 번 더 재야 한다.
val r = afterMeasure(cm = 4, target = 4, isConfirm = false)
assertEquals(4, r.cm)
assertFalse(r.confirmDone)
}
@Test fun `칩으로 다른 자리를 다시 재면 확정이 풀리고 best 자리로 돌아간다`() {
val r = afterMeasure(cm = 1, target = 3, isConfirm = false)
assertEquals(3, r.cm)
assertFalse(r.confirmDone)
}
@Test fun `후보가 하나도 없어지면 자리는 그대로, 확정도 풀린다`() {
// 재측정으로 유일한 후보가 사라진 경우. 종전에는 아무것도 안 바꿔 옛 확정이 남았다.
val r = afterMeasure(cm = 2, target = null, isConfirm = true)
assertEquals(2, r.cm)
assertFalse(r.confirmDone)
}
}
@@ -0,0 +1,43 @@
package com.medithings.vesiscan.ui.views.clinical
import com.medithings.vesiscan.managers.AnchorBasis
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* 진행 버튼의 근거(`anchor_basis`) — 기록과 병원 화면 라벨이 이 값을 쓴다.
*
* 2026-10-08 P7(Supine) 1404 세션: 0cm 하나만 재고(CH3 X, 후보 없음) [이대로 임상 측정 진행]
* 을 눌렀는데 `anchor_basis = reattach_override` 로 남았다. 재부착 권고는 0~4cm 를 **다 잰
* 뒤**의 판정인데, 지시 카드([instructionOf])는 `sweeping` 을 보고 버튼 쪽만 안 봤다. 그
* 세션의 실제 상태는 "탐색 미완료 · 사람이 고른 자리"다. 이 값으로 정확도 검증 모집단을
* 가르므로 틀리면 두 집단이 섞인다.
*/
class ProceedBasisTest {
@Test fun `확인 측정까지 마쳤으면 알고리즘 확정이다`() {
assertEquals(AnchorBasis.ALGORITHM,
proceedBasis(confirmDone = true, needReattach = false, sweeping = false))
// 확정이 다른 어떤 상태보다 앞선다 — 확정이 났다는 것은 다 재고 후보도 있었다는 뜻이다.
assertEquals(AnchorBasis.ALGORITHM,
proceedBasis(confirmDone = true, needReattach = true, sweeping = true))
}
@Test fun `다 쟀는데 후보가 없으면 재부착 권고 무시다`() {
assertEquals(AnchorBasis.REATTACH_OVERRIDE,
proceedBasis(confirmDone = false, needReattach = true, sweeping = false))
}
@Test fun `수집 중에 후보가 없는 것은 재부착 권고가 아니라 탐색 미완료다`() {
// 2026-10-08 P7 1404: 0cm 한 번 재고 진행. 1~4cm 는 아직 안 쟀다.
assertEquals(AnchorBasis.MANUAL,
proceedBasis(confirmDone = false, needReattach = true, sweeping = true))
}
@Test fun `후보가 있어도 확인 측정을 안 했으면 수동이다`() {
assertEquals(AnchorBasis.MANUAL,
proceedBasis(confirmDone = false, needReattach = false, sweeping = false))
assertEquals(AnchorBasis.MANUAL,
proceedBasis(confirmDone = false, needReattach = false, sweeping = true))
}
}
@@ -0,0 +1,45 @@
package com.medithings.vesiscan.ui.views.clinical
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* [이대로 임상 측정 진행] 이 기록하는 부착 위치([recordedAnchorCm]).
*
* 2026-10-08 P7(Supine) 1404: 0cm 한 번 재고 바로 진행. 화면은 이미 "치골 위 1cm 에 붙이고
* 측정"으로 넘어가 있었고, 종전 코드는 그 화면 위치를 기록해 anchor_cm=1 이 됐다. 재부착 확인
* 파일도 `align_1cm_reattach.csv`, 임상(600) params 의 anchor_cm 도 1. 간호사는 0cm 에서 쟀다고
* 했고 그 말이 맞았다 — 프로브는 0cm 에 그대로 있었다.
*/
class RecordedAnchorCmTest {
@Test fun `확정 전에는 마지막으로 잰 자리다 - 화면이 다음 자리로 넘어가 있어도`() {
// P7 1404: 0cm 재고 → 화면 1cm → 진행.
assertEquals(0, recordedAnchorCm(
confirmDone = false, anchorTarget = null, lastMeasuredCm = 0, screenCm = 1))
}
@Test fun `다 재고 best 자리로 가라는 상태에서 확인 없이 진행하면 마지막 잰 자리다`() {
// P7 1358 끝: 4cm 를 마지막으로 재고 best 는 3cm. 화면은 "3cm 에 다시 붙이고 측정".
// 프로브는 아직 4cm 에 있다.
assertEquals(4, recordedAnchorCm(
confirmDone = false, anchorTarget = 3, lastMeasuredCm = 4, screenCm = 3))
}
@Test fun `확정이면 best 자리다`() {
assertEquals(3, recordedAnchorCm(
confirmDone = true, anchorTarget = 3, lastMeasuredCm = 3, screenCm = 3))
}
@Test fun `한 번도 안 쟀으면 화면 위치다`() {
assertEquals(0, recordedAnchorCm(
confirmDone = false, anchorTarget = null, lastMeasuredCm = null, screenCm = 0))
assertEquals(2, recordedAnchorCm(
confirmDone = false, anchorTarget = null, lastMeasuredCm = null, screenCm = 2))
}
@Test fun `확정인데 best 가 없는 어긋난 상태면 마지막 잰 자리로 떨어진다`() {
assertEquals(2, recordedAnchorCm(
confirmDone = true, anchorTarget = null, lastMeasuredCm = 2, screenCm = 4))
}
}