feat(clinical): 부착 위치 정렬(AnchorGuide) — 임상 프로토콜 전 단계

piezo-phantom-test `vesiscan_test` 의 단일 pass 정렬을 이식한다. 규칙은
`alignment_selection.select_supine_anchor`(tie='cap'), 절차는
`alignment_runners.AnchorGuide`, 계약은 `porting/SPEC.md` §8.

- managers/AnchorGuide.kt  알고리즘 코어(측정·종료판정·선택 위임·안내)
- ui/.../AnchorAlignView.kt 위치별 측정 화면 + 원시 데이터 저장
- HospitalRunStore        align/ 폴더에 위치별 raw cycle + 결과 요약
- 병원 모드에 정렬 카드 · 매니페스트에 anchor_cm

기존 AlignmentAdvisorV3 는 같은 저장소의 **구 알고리즘**(2-pass CenterAligner ·
bvcv tie · 오프셋 없음)이고 호출처가 0이라 그대로 두었다. Python 쪽에서도 이미
주석 처리됐다. 제품 경로는 새 파일이다.

## 검증
- 선택 규칙: Python 무작위 200 케이스 전수 대조 **200/200 일치**
  (CH3 2단 게이트 · fallback · NaN=+inf · 동률 min cm 경계 포함)
- trace 분할: 22 cycle · win 10 → 11 trace, Python 과 일치
- 실기기: 20 cycle 수집 → 판정 → 안내 → 저장까지 완주 확인

## 알려진 불일치 — 화면에도 적었다
앱의 **벽 검출**(MethodDRunner)이 현재 Python 레퍼런스와 어긋난다. 실측
(HUMAN-kai VBT26050202, v1):
  cm=0 CH2 ant 13.913 vs 18.559 · span 24~61 vs 33~60
  cm=1 CH1 ant  4.486 vs 13.188 · CH3 는 Python 검출 / 앱 미검출
  cm=1 판정  nch=4 ch3=O  vs  nch=3 ch3=X
  cm=0 cap_frac 0.63899 vs 0.67479 · BV 410.6 vs 470.5 mL
cm=1 은 레퍼런스에서 유력 후보인데 앱에서는 후보 자격조차 없다. 정렬 선택이 이
값들 위에 서 있으므로 **앱의 추천 위치는 아직 레퍼런스의 답이 아니다.**

그래서 (1) 화면에 검증 전임을 명시하고, (2) 위치별 raw cycle 을 반드시 남긴다
(align_{n}cm.csv · Python 대조 스크립트가 바로 읽는 형식). 앱 판정이 틀려도
나중에 Python 으로 다시 고를 수 있어야 그 환자를 다시 부르지 않는다.
AnchorMeasureParityTest 는 이 불일치를 표로 기록한 채 @Ignore 로 격리했다 —
기대값을 코틀린 값으로 낮추면 대조 시험의 존재 이유가 사라진다.

BV **코어** 자체는 맞다. 벽을 고정해 넣으면 Python estimate_bv
(ellipse_cap_height=true · cap_fit='specific')와 마지막 자리까지 일치한다.

## 곁가지
- gradle.properties 에서 -Dfile.encoding=UTF-8 제거. 한글 사용자 폴더에서
  테스트 워커가 클래스패스를 못 찾아(GradleWorkerMain) **단위시험이 통째로
  실행되지 않고 있었다**. 신 저장소에서 같은 원인을 이미 확인했다.
- 환자명을 AppState 로 올렸다. 정렬 화면을 다녀오면 remember 가 초기화돼,
  이름을 다시 치는 순간 "환자가 바뀌었다"로 보여 방금 맞춘 정렬이 지워졌다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 10:57:17 +09:00
parent 5a7e2cb918
commit 2126aa9361
13 changed files with 1636 additions and 3 deletions
@@ -150,6 +150,55 @@ object HospitalRunStore {
}
}
// ── 정렬(anchor) ─────────────────────────────────────────────────────
//
// 위치마다 잰 raw cycle 을 그대로 남긴다.
//
// 왜 지표만이 아니라 raw 인가: 앱의 벽 검출이 Python 레퍼런스와 아직 어긋나 있어
// (2026-09-03 실측 · AnchorMeasureParityTest KDoc 참조) 앱이 고른 위치가 레퍼런스가
// 고를 위치와 다를 수 있다. raw 가 남아 있으면 나중에 Python 으로 다시 판정할 수 있고,
// 없으면 그 환자는 다시 부를 수밖에 없다.
/** 정렬 raw 는 임상 데이터와 같은 환자·날짜 폴더 밑 `align/` 에 둔다. */
fun alignDir(patient: String, startedAt: Date): File {
val dir = File(runDir(patient, startedAt), "align")
if (!dir.exists()) dir.mkdirs()
return dir
}
/**
* 한 위치의 cycle 전부를 CSV 로 저장한다.
*
* 형식은 Python 대조 스크립트가 바로 읽도록 `cycle,channel,s0..sN` 으로 맞췄다
* (`app/src/test/resources/align_cm*.csv` 와 동일).
*/
fun writeAlignCycles(
dir: File, cm: Int, cycles: List<List<DoubleArray>>,
): Boolean = try {
File(dir, "align_%dcm.csv".format(cm)).bufferedWriter().use { w ->
cycles.forEachIndexed { ci, chans ->
chans.forEachIndexed { ch, buf ->
w.write(buildString {
append(ci); append(','); append(ch)
for (v in buf) { append(','); append(v.toInt()) }
})
w.newLine()
}
}
}
true
} catch (e: Exception) {
android.util.Log.w("HospitalRunStore", "align cycles 저장 실패 cm=$cm: ${e.message}")
false
}
/** 정렬 결과 요약(위치별 지표 + 선택). 판정 근거를 남겨 두어야 나중에 대조할 수 있다. */
fun writeAlignSummary(dir: File, json: JSONObject) {
runCatching {
File(dir, "align_result.json").writeText(json.toString(2), Charsets.UTF_8)
}
}
// ── 진행 현황 ────────────────────────────────────────────────────────
//
// 간호사가 자세·방광 단계를 하나씩 올려 가며 재는데, 중간에 자리를 비우거나