연결 → 연결 해제 → 메인 → [기기 연결] → [저장된 기기] 를 누르면 스피너가 영원히 돌고,
다른 기기 행까지 전부 비활성이 됐다(enabled = connectingDeviceAddress == null).
## 원인
화면이 스피너를 끄는 신호는 **둘뿐**이다 — `isServiceReady` 또는 `connectionError`.
그런데 10초 타임아웃이 보던 것은 `isConnected` 였다:
if (!isConnected.value) { connectionError.value = "Connection timed out..." }
링크는 붙었는데(STATE_CONNECTED) 서비스 탐색·CCCD 구독이 끝나지 않으면 **타임아웃이
면제된다.** 에러도 성공도 안 오니 화면이 영원히 잠긴다.
그 상태가 실제로 생긴다. 2026-09-10 12:29 로그에서 연결 직후 **15초 동안** 모든 명령이
`CMDQ drop ... timeout` 이었다(RX 0). 그때는 풀렸지만 안 풀리면 그대로다 — 연결 해제 뒤
재연결에서 특히 잘 난다.
## 고친 것 넷
**① 타임아웃이 보는 신호** `!isConnected` → `!isServiceReady`. 화면이 기다리는 것과 같아야
한다. 같이 10초 → **20초**로 올렸다 — 위 실측이 15초라 10초로 자르면 정상 연결을 끊는다.
**② 타임아웃 후 정리** 종전에는 에러만 띄우고 GATT 를 살려 뒀다. 다시 누르면 같은 반쪽
연결을 물고 간다. disconnect + close + 상태 리셋까지 한다.
**③ 화면 쪽 보험** DeviceScanView 에 25초 상한. BleManager 가 신호를 못 주는 경로가 하나라도
남으면 이 화면은 영원히 잠기므로, 원인을 고쳤어도 상한은 둔다. BleManager 의 20초보다
**길게** 잡았다 — 짧으면 정상 연결 중인 시도를 다시 눌러 GATT 가 두 번 열린다.
**④ 재연결 경로에도 같은 구멍** 재연결의 connectGatt 에는 상한이 **아예 없었다.** 그리고
watchdog 은 `isServiceReady` 뒤에만 시작하고, 8초 스캔 타임아웃은 `isConnected` 를 보고
"붙었다"며 재시도를 멈춘다. 결과는 **아무도 보지 않는 반쪽 연결** — 앱은 연결됐다고
표시하는데 명령이 전부 timeout 난다. 같은 20초 상한을 걸고 실패하면 재연결을 잇는다.
문구는 en/ko 둘 다 추가했다.
테스트 143개 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
판정을 전 위치로 바꾼 뒤에도 [여기까지만 재고...] 버튼이 guide.bestCm(단일 pass 답)으로
이동했다. 앱이 쓰는 자리와 다른 곳으로 보내면 그 자리에서 확인 측정을 해도 confirmDone
이 안 되고, 간호사는 왜 안 넘어가는지 알 수 없다.
지금까지 잰 위치 전부로 판정한 자리로 간다. forceFinish 는 남겼다 — 판정에는 안 쓰지만
종료가 안 걸린 세션의 best_cm_single_pass 가 비지 않게 해야 감사가 된다.
라벨도 하나로 합쳤다. guide.settled 로 문구를 갈랐는데 그 구분이 이제 판정과 무관하다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
알고리즘 설계자가 물었다 — "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>
알고리즘 설계자가 물었다 — "0cm·1cm 만 쟀는데 0cm 가 최적으로 나왔다. 만약 3cm 가
최적이면?"
## 이건 버그가 아니라 설계 가정이다
단일 pass 는 **지표가 cm 에 대해 단봉**이라고 가정한다. 올라가다 나빠지면 봉우리를 지난
것으로 보고 멈춘다. Python 도 같다. 물리적 근거는 있다 — CH3 은 제일 기울어진 채널로
방광 아래쪽을 보므로 위로 가면 점점 벗어난다.
깨질 수 있는 경우는 **한 위치만** 일시적으로 나빠지는 것이다: 장내 가스가 CH3 을 가림,
접촉이 뜸, 호흡으로 방광이 움직임. 그러면 더 위가 진짜 좋았는데 조기 종료한다.
실측에 징후가 있다. 2026-09-09 `kai` 는 **0cm ch3=X → 1cm ch3=O 91%** 로 지표가
올라갔다. 시작 쪽은 종료 조건이 "앞에 eligible 이 있어야" 걸리게 막혀 있지만 **중간에서는
한 칸만 나빠도 끝난다.** 같은 날 `181128` 이 정확히 그 경우다 — 1cm 에서 nch 2→0,
ch3 100%→0% 로 두 조건이 동시에 걸려 best=0cm, 2cm 이후는 모른다.
## 판정은 바꾸지 않고 **측정 가능하게** 만든다
규칙을 바꾸면 레퍼런스 대조(단일 pass 기준)를 다시 해야 한다. 그건 알고리즘 팀이 숫자를
보고 정할 일이다. 그래서 전 위치로 고르면 어디가 뽑히는지 **같이 계산해 기록한다**:
"best_cm": 0, 앱이 쓰는 값 (단일 pass · 레퍼런스)
"best_cm_full_sweep": 3, 잰 위치 전부로 고르면
"sweep_disagrees": true
갈리면 화면에도 참고 문구가 뜬다 — 그 자리에서 "더 위가 좋아 보인다"를 알면 다시 정렬을
택할 수 있다. 어제 전수 측정으로 바꾼 것이 이 답을 내는 전제였다(조기 종료하면 비교할
위치가 없다).
## 확인 측정이 판정 데이터를 덮고 있었다
부착 위치로 내려가 한 번 더 재면 `align_2cm.csv` 를 **덮어썼다.** 화면 판정은 먼저 잰 것
기준인데 파일은 나중 것이라, 파일로 재판정하면 앱과 답이 갈릴 수 있다. 조기 종료 시절에는
드물었지만 전수 측정으로 바꾼 뒤에는 **항상** 일어난다.
`align_2cm_confirm.csv` 로 분리했다. 판정 근거가 파일에 그대로 남는다.
테스트 139개 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
병원 임상은 위치를 고르는 것만이 목적이 아니라 **위치별 원신호를 모으는 것**도 목적이다.
그런데 조기 종료하면 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>
## ① 복부 두께 40mm 이하 → 2.3MHz c3 (freq 5)
40mm 이하 freq 5 + c3 ← 1.8MHz 에서 바뀜
40mm 초과 freq 0 + c3, freq 5 + c3 (변경 없음)
얇으면 2.3MHz 로도 뒤벽까지 닿고 분해능이 더 좋다. 두꺼우면 2.3MHz 가 못 닿을 수 있는데
어느 쪽이 맞는지 받아 보고야 알므로 둘 다 받는다.
덤이 하나 있다. 정렬이 2.3MHz·c3 고정이라 **40mm 이하 환자는 정렬 BV 와 프로토콜 BV 가
같은 조건**이 된다(표본 수만 다르다 — 정렬 20 cycle 평균, 프로토콜 1 cycle). 40mm 초과는
1.8MHz 회차가 섞이므로 2.3MHz 회차만 비교해야 한다.
진행 판정 테스트의 기대값을 새 조합으로 옮겼다. 하나 추가했다 — 40mm 이하 기준에서
1.8MHz 만 받으면 완료가 아니라는 것. 두께를 잘못 고르고 측정하면 칩이 완료로 안 바뀌어
그 자리에서 드러난다.
## ② 동작 버튼을 파형보다 위로
종전 순서: 지시 카드 → BV → 접촉 → 업로드 → 파형 → **[측정]** → [진행]
한 위치를 잴 때마다 스크롤을 내려야 했다. 프로브를 한 손에 잡은 채 쓰는 화면이라 그 한
번이 매번 든다. 지시 바로 아래 누를 것을 둔다:
지시 카드 → 근거 한 줄 → 진행 표시 → [측정]/[좌우] → [진행] → BV → 접촉 → 파형
아래쪽은 **근거**다 — "왜 이 값인가"를 볼 때만 내려다본다. 연결 끊김 경고도 동작 버튼과
같이 올렸다(그 경고가 바로 [처음부터 다시 정렬] 을 가리킨다).
## ③ "다시 정렬"이 지시문으로 읽혔다
정렬을 마치고 넘어오면 부착 위치 카드에 [다시 정렬] 이 떴다. 상태는 정상이다 —
anchorCm 은 연결 해제·환자명 변경 때만 비워지고 진행 버튼 경로에서는 그대로 넘어온다.
문구가 "다시 해야 한다"로 읽힌 것이다.
✓ 부착 위치 3cm 확정 [위치 다시 찾기]
체크 표시로 끝난 상태임을 먼저 말하고, 버튼은 **선택**임이 드러나는 말로 바꿨다.
테스트 135개 통과(신규 1).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
정렬 화면이 `ClinicalBv.compute(meanScan)` 을 **인자 없이** 불렀다. supine 기본값은
false 다. 병원 임상 측정 화면은 `posture == SUPINE` 을 넘긴다. 같은 프로브·같은 자리·같은
알고리즘인데 **두 화면이 다른 식으로 계산**하고 있었다.
## 얼마나 갈리나
실측 기반 nch=2 입력에서:
supine=false 199.45 mL ← 정렬 화면이 내던 값
supine=true 377.86 mL ← 임상 측정 화면이 내던 값
**1.9배.** 범위는 좁다 — supine 은 PiezoBVEstimator 한 곳(1445)에서만 쓰이고, 레퍼런스
경로이고 검출 채널이 **정확히 2개**일 때 빔 기하 cap 상한을 건너뛰는 데만 쓴다. nch 3
이상이면 위쪽 top/bottom clamp 가 같은 일을 하므로 값이 같다.
그런데 정렬에서 nch=2 는 드물지 않다 — 2026-09-09 실측 4위치가 nch 2·2·1·1 이었으니
0cm·1cm 두 자리가 그 경우였다.
(앞서 이 인자가 F83 도 본다고 적었는데 그건 틀렸다. F83 은 mirrorBottomCap 이 켜고,
supine 을 읽지 않는다.)
## 자세를 AppState 로 올렸다
정렬 화면에는 자세를 고르는 칸이 없다. 병원 모드의 로컬 상태였으니 정렬이 알 길이
없었던 것이 근본 원인이다. `AppState.clinicalPosture` 로 올려 두 화면이 같은 값을 본다.
## 남는 차이는 캡션에 적는다
알고리즘·인자를 맞춰도 **조건과 표본 수는 다르다.** 안 적으면 세 숫자를 같은 것으로
놓고 비교한다:
정렬 2.3MHz c3 · 20회 평균 (판정과 같은 mean-scan 이어야 한다)
수동 확인 2.3MHz c3 · 1 cycle
프로토콜 조합별 (1.8/2.3) · 1 cycle ← 주파수가 조합마다 바뀐다
정렬과 수동 확인은 같은 조건이라 비교 가능하다. 프로토콜은 조합이 2.3MHz c3 인 회차만
비교 가능하다 — 복부 두께 40mm 이하면 1.8MHz 만 돌므로 정렬 값과 직접 비교할 수 없다.
테스트 134개 통과(신규 5). ClinicalBvSupineTest 가 "이 인자가 결과를 바꾼다"를 실측으로
고정한다 — 바꾸지 않는다면 호출부가 빠뜨려도 아무도 모르고, 그러면 언제든 다시 어긋난다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"여기서 측정"은 **"여기"가 어디인지 알 수 없다**는 피드백이 왔다(2026-09-10). 앞 커밋에서
절대 cm 을 화면에서 걷어내며 버튼까지 같이 지웠는데, 카드가 동작만 말하고 버튼도
동작만 말하니 "지금 프로브가 몇 cm 에 있는지"를 확인할 데가 없어졌다.
카드와 버튼이 역할을 나눈다:
카드(동작) 버튼(자리)
치골 바로 위에 붙이고 측정 [0cm 측정하기]
1cm 올리고 측정 [1cm 측정하기]
2cm 내리고 측정 [1cm 측정하기]
여기서 한 번 더 측정 [1cm 측정하기]
1cm 올리세요 (재지 말고 좌우로) — 버튼 없음
카드는 **어떻게 움직일지**, 버튼은 **움직인 뒤 어디를 재는지**다. 둘 다 숫자를 쓰면
서로를 확인해 준다 — 카드대로 움직였으면 버튼의 숫자가 지금 자리와 맞는다. 어긋나면
사람이 그 자리에서 알아챈다.
원래 헷갈림의 원인은 버튼의 숫자가 아니었다. "최적 위치 2cm"과 "부착 위치 3cm"이 둘 다
"위치"라는 이름으로 한 화면에 있던 것이다. 그건 앞 커밋에서 해결됐으니 버튼의 자리
표시는 되살리는 것이 맞다.
테스트 129개 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
연결 해제를 눌러도 앞 프로브의 값이 화면에 그대로 남아 있었다.
방광 용적 187 mL ← 끊긴 프로브의 값인데 "방광 용적"으로 보인다
채널 파형 6개 ← 옛 신호
채널 접촉 CH2 떠 있음 ← 옛 접촉
수동 확인의 "마지막 측정" · 연속 대표값
끊기 버튼이 비우던 것은 anchorCm·anchorBasis 둘뿐이었다.
## 왜 위험한가
**프리셋이 다른 프로브로 갈아 끼우면 같은 신호가 전혀 다른 용적이 된다.**
PiezoHW.activePreset 이 dps·delay 를 정하고, 실측에서 같은 데이터가 125mL 와 490mL 로
갈렸다. R100/R200/R300 을 번갈아 쓰는 것이 이 화면의 정상 사용이라 — 끊기 버튼 주석이
그렇게 적고 있다 — 남은 값을 새 프로브의 값으로 읽는 일이 실제로 일어난다.
## 버튼이 아니라 isConnected 를 본다
프로브가 **스스로** 끊기는 경우가 있다(VBTFW0206 freeze, 2026-09-08 로그에서 advertising
까지 멈췄다). 버튼에만 넣으면 그때는 여전히 옛 값이 남는다. LaunchedEffect(isConnected)
한 곳에서 끊기는 모든 경우를 맡는다.
## 비우는 것과 남기는 것
비움 runBv · liveChannels · detachSnapshot/Watcher
수동 확인의 outcome · live · window · tick · mode
남김 환자명 · 자세 · 충만도 · 반복 · 복부 두께 (조작자가 고른 설정)
lastRunDir · pendingUploads (못 올린 파일을 다시 올릴 유일한 길)
직전 실행 요약 · configFailed ("직전 실행"이라 라벨이 정확하다)
수동 확인 저장 결과 문구 (남겼는지 의심하게 된다)
수동 확인은 모드도 내린다. 끊긴 채로 연속 측정이 돌면 3초마다 시간 초과만 쌓인다.
## 정렬 화면은 판정 기록을 지키되 경고한다
BV·파형·접촉은 같이 비우지만 **guide·records·last 는 비우지 않는다.** 한 위치가 20
cycle 인데 연결이 잠깐 끊겼다고 날리면 환자를 다시 눕혀야 한다. 재연결 후 이어서 재는
것이 정상 경로다.
다만 프로브를 **바꿔서** 연결하면 그 세션은 섞인 데이터가 된다 — align_result.json 의
hw_preset 은 한 번만 기록되므로 절반이 잘못 라벨링된다. 그 경우를 앱이 알 수 없으므로
경고 한 줄로 사람에게 묻는다: "같은 프로브면 이어서 재도 됩니다. 바꿨다면 [처음부터
다시 정렬]."
테스트 129개 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
정렬 업로드만 수동이었다. 그 버튼의 원래 용도가 **"파형이 이상할 때 개발자에게 보내기"**
(메모를 받는 다이얼로그)라, 정상적으로 정렬을 끝내면 601 이 아예 안 올라갔다.
2026-09-09 실기기에서 4위치 80 record 가 폰에만 남아 있었다. 600·001 은 자동인데 601 만
빠져 있어, 같은 세션의 "어디에 붙였나"가 서버에 없는 상태가 된다 — 그 근거가 없으면
600 의 BV 를 위치와 묶을 수 없다.
## 버튼마다 넣지 않고 onDispose 한 곳에서
출구가 넷이다: 뒤로 화살표 · 시스템 뒤로 · [이대로 임상 측정 진행] · 기기 연결 화면으로
이동. 하나만 빠뜨려도 그 경로로 나간 세션은 조용히 사라진다. DisposableEffect 하나로
전부 덮는다.
## 화면 수명과 무관한 스코프가 필요했다
`rememberCoroutineScope` 로 띄우면 화면을 떠나는 순간 취소된다. 그런데 올릴 자연스러운
시점이 **바로 떠나는 순간**이라, 그 스코프로는 영원히 못 올린다.
`HospitalLabdbUploader.uploadAlignAsync` 를 오브젝트 스코프(SupervisorJob + IO)에서 돌린다.
결과는 기존 uploadingName·lastMessage 로 나가므로 다음 화면(병원 모드 카드)이 그대로 읽는다.
## 같은 폴더를 다시 올려도 안전하다
testId 가 `<저장이름>_align` 으로 고정이고 labdb 가 testId+rowIndex 로 멱등이라, 바뀐 것만
반영되고 나머지는 중복으로 센다. 그래도 dirty 깃발을 둔다 — 화면을 오가며 여러 번
들어올 수 있고, 변한 게 없는데 매번 0.3MB 를 보내면 labdb 분당 제한(10건)을 정렬 하나가
먹는다.
## 수동 버튼은 남긴다
용도가 다르다. 이쪽은 **메모를 붙여 지금 보내는** 길이다 — 파형이 이상할 때 개발자가
바로 보고 답해야 하므로 화면을 떠날 때까지 기다릴 수 없다. 주석으로 그 구분을 박았다.
테스트 129개 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
시험하는 분이 "최적 위치"와 "부착 위치"를 계속 헷갈렸다(2026-09-09 현장). 당연하다 —
둘 다 "위치"라는 이름의 숫자인데 화면이 단계마다 다른 쪽을 크게 보여 줬다.
측정할 위치 2 cm ← 탐색 중
부착 위치 확정 3 cm ← 확정 뒤
여기로 옮기고 (최적 2cm + 1cm) 고정하세요
숫자 셋(2·3·+1)이 한 화면에 있었다. 프로브를 잡은 사람에게 필요한 건 **지금 할 동작
하나**다. 절대 cm 은 기록의 몫이고 사람이 셀 일이 아니다.
치골 바로 위에 붙이고 측정
↑ 1cm 올리고 측정
↓ 2cm 내리고 측정
● 여기서 한 번 더 측정 (옮겨 온 자리를 확인하는 마지막 측정입니다)
↑ 1cm 올리세요 (여기서는 측정하지 않습니다. 올린 뒤 좌우로.) ← 초록
↓ 더 아래에 다시 붙이세요 (재부착)
화살표 + 한 줄 지시 + 이유. 전부 **상대 이동**이라 사람이 cm 을 누적해 셀 필요가 없다.
측정 버튼도 "2cm 측정"→"여기서 측정" 으로 바꿨다 — 카드가 "1cm 올리고 측정"이라고 말하는데
버튼이 다른 숫자를 달고 있으면 둘을 맞춰 보려다 또 헷갈린다.
"여기서 한 번 더 측정"에 이유를 붙인 것은 이 화면에서 제일 많이 나오는 질문이 "왜 방금
잰 자리를 또 재냐"이기 때문이다. 확정 판정의 입력이 그 측정이다.
판정 근거(cm · 채널 n/4 · CH3 · cap)는 카드 아래 작은 글씨로 남긴다. 왜 그 지시가
나왔는지 물어보게 되므로 숨기지 않는다. 기록에 남는 cm 도 진행 버튼 아래 한 줄로 남긴다.
좌우 카드 안내도 고쳤다: "부착 위치 3cm 로 옮기세요" → "앞에서 1cm 올린 자리 그대로
둡니다. 아직 안 올렸으면 지금 올리세요."
테스트 129개 통과(신규 8). AlignInstructionTest 가 지시문에 "최적"·"부착 위치" 가
들어가지 않는지까지 본다 — 여기서는 헷갈림 자체가 고치려던 결함이라 문구가 요구사항이다.
FINAL_OFFSET_CM 을 바꾸면 문구도 따라가는지도 고정했다(박아 두면 거짓이 된다).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## 왜: 실기기에서 데이터가 조용히 안 올라갔다
2026-09-09 A15 에서 임상 세션 3개(70 cycle)와 정렬 1세트(80 record)를 받았는데 labdb 에
하나도 없었다. 폴더를 열어 보니 **성공 마커도 실패 마커도 없었다** — 업로드를 시도한
기록 자체가 없다.
ClinicalSessionStore:273 if (creds.isRegistered) uploadAsync(...)
HospitalLabdbUploader:80 if (!isRegistered) return false to "미등록"
둘 다 isRegistered 뒤에 있어 미등록이면 아무 일도 일어나지 않는다. 그런데 등록 버튼은
임상 측정 홈(ClinicalHomeView)에만 있고, **병원 임상 모드는 홈에서 직접 들어가** 그곳을
거치지 않는다. 게다가 병원 모드의 업로드 카드가 `isRegistered && lastRunDir != null` 로
감싸져 있어, 미등록이면 화면에 **아무것도 없었다.** 조작자는 "이 화면에는 업로드 기능이
없다"로 읽는다.
## LabdbStatusCard — 측정 **전에** 보인다
환자명 위, 화면 제일 앞에 둔다. 20 cycle × 조합을 다 받은 뒤에 알면 그 데이터는 폰에만
남고 방광을 비운 뒤라 다시 잴 수 없다.
미등록 경고색 + [labdb 등록] 버튼
승인 대기 pending 경고색 — **"등록했다"와 "올라간다"는 다르다**
활성 active 초록 — 조합마다 자동 업로드
승인 대기를 초록으로 두지 않은 것이 핵심이다. 버튼을 누른 것만으로 끝났다고 읽으면 같은
일이 반복된다. 진입 때 checkStatus 를 한 번 불러, 승인이 떨어졌는지·기기가 삭제됐는지
(404 면 자격이 비워진다) 그 자리에서 보인다.
업로드 카드는 isRegistered 를 떼고 lastRunDir 만 본다. 미등록일 때도 **몇 건이 못
올라갔는지**는 보여야 한다 — 승인 뒤 [지금 업로드] 로 한꺼번에 보내야 하므로. 다만
버튼은 막는다(미등록으로 누르면 실패 마커만 쌓여 나중에 성공 여부가 헷갈린다).
## 최상위 subject
서버(server.js:650)는 **최상위 `data.subject` 만** 읽어 `sessions.subject` 컬럼에 넣는다.
앱은 `params.subject` 에만 넣고 있었다 — 서버 담당자가 지적한 "기존 72세션의 subject 가
전부 비어 있다"의 원인과 같은 버그다. 그 컬럼이 export 파일명 prefix 와 세션 목록 표시에
쓰이므로, 비면 분석하는 쪽이 testId 를 눈으로 파싱해야 한다.
params 안에도 그대로 둔다. 이미 올라간 세션들이 그 자리에 있어, 빼면 과거·신규를 같은
규칙으로 읽을 수 없다. 600/601 이 이미 같은 처리다.
테스트 121개 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
구분선 아래 1회/연속 측정은 화면에만 떴다가 사라졌다. 검증 항목 ③(위치 민감도 —
부착 위치에서 상·하·좌·우 1~2cm 옮겨 비교)이 바로 그 측정으로 하는 일인데, 남는 게
없으면 간호사가 종이에 받아 적어야 한다.
labdb 에는 올리지 않는다(사용자 결정). 저장 위치:
Downloads/VesiScan_Hospital/<날짜_환자>/manual/
manual_2026-09-09.csv 하루치 이어 붙임 — 행 하나 = 한 번의 확인
manual_2026-09-09_143052.json 누를 때마다 하나 — 6채널 원신호 + 채널별 벽
**하위 폴더인 것이 중요하다.** HospitalLabdbUploader.pending 과
HospitalRunStore.scanProgress 는 둘 다 폴더 최상위만 훑으므로, 여기 둔 CSV 가 600 으로
올라가지도 않고 진행 판정에 끼지도 않는다. 600 은 정확도 검증의 모집단이라, 조작자가
아무 자리에서 아무 때나 누른 값이 섞이면 "정해진 조건에서 받은 데이터"라는 전제가 깨진다.
## 자동 저장하지 않는다
연속 모드는 초당 두 번 값을 낸다. 자동으로 남기면 의미 없는 행이 수백 개 쌓여 정작
비교하려는 위치별 값이 묻힌다. 조작자가 "이 자리 값은 남길 것"이라고 판단한 순간만
[이 확인 기록] 으로 남긴다.
## 위치를 자유 문구로 받지 않는다
ManualCheckStore.Offset 9개(기준 · 위1·2 · 아래1·2 · 좌1·2 · 우1·2)로 고정하고, 메모는
그 옆에 따로 받는다. 자유 입력이면 같은 자리가 "좌1"·"왼쪽 1cm"·"L1" 로 적혀 나중에
묶을 수 없다 — 항목 ③ 은 위치끼리 값을 비교하는 일이라 표기가 흔들리면 데이터가 아니라
메모가 된다. 3열 격자로 두어 방향이 눈에 보이게 했다(드롭다운은 잘못 고른 것도 못 본다).
## 대표값은 화면과 같은 식이어야 한다
파일에 산술평균이 들어가면 보고서를 쓰는 사람이 화면을 믿어야 할지 파일을 믿어야 할지
알 수 없다. 두 trimmedMean 을 테스트에서 직접 맞댔다.
## 저장 실패를 조용히 넘기지 않는다
save 가 null 이면 빨간 글씨로 띄운다. 조용히 넘기면 조작자는 기록됐다고 믿고 다음
위치로 옮겨, 그 자리를 다시 잴 기회를 잃는다.
BvMeasureSection 이 supine:Boolean 대신 posture·fill·patient·anchorCm·anchorBasis 를
받는다. CSV 한 행만 보고 "어떤 상태에서 잰 값인가"에 답할 수 있어야 한다.
테스트 121개 통과(신규 7). 메모의 쉼표·따옴표·줄바꿈이 열을 밀지 않는지, 오프셋 기록
문자열이 고정인지를 잡았다 — 쉼표 하나가 그 행의 뒷열을 전부 한 칸씩 밀어 CSV 가
조용히 어긋나는 것이 제일 찾기 어렵다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## 화면을 두 덩어리로 나눴다
환자명 → 부착 위치 정렬 → 자세 → 방광 채움 → 반복 횟수 → 복부 두께 →
[측정 시작] → BV 카드
──────────────────────── 구분선 ────────────────────────
수동 확인 — 저장되지 않습니다
프로브 파라미터 주입 → [1회 측정]/[연속 측정] → BV → 마지막 측정 → 6채널
종전에는 BV 측정 구간이 [측정 시작] **위에** 있어 프로토콜 흐름 한가운데 끼어 있었다.
수동 측정 값은 저장도 업로드도 안 되는데, 한 흐름으로 붙어 있으면 조작자가 그것을
프로토콜의 한 단계로 착각한다. 구분선과 "저장되지 않습니다" 한 줄을 넣었다.
프로토콜 BV 카드는 실행이 끝난 뒤에도 남긴다(caption 이 "직전 회차"→"마지막 회차").
방광을 비우기 전에 방금 받은 값이 말이 되는지 한 번 더 볼 수 있어야 한다.
## 6조합 순회 → 복부 두께 2택
40mm 이하 1.8MHz c3 → n회
40mm 초과 1.8MHz c3 · 2.3MHz c3 → 2n회
cycle 은 3 고정. 한 단계 20회 × 6조합 = 120회가 환자를 너무 오래 눕힌다.
조작자에게 freq·cycle 을 고르게 하지 않는 것이 핵심이다. 눈으로 보고 답할 수 있는
것은 복부 두께이고, 조합은 거기서 따라 나온다(AbdomenThickness).
## 진행 판정: 개수 비교 → 집합 비교 (여기가 제일 위험했다)
`EXPECTED_COMBINATIONS = 6` 으로 **개수**를 세고 있었다. 그대로 두면 요구 조합이 1~2개인
지금 어떤 단계도 영원히 PARTIAL 로 남는다. 그런데 단순히 6을 2로 바꾸면 더 나쁘다 —
2.3MHz 를 두 번 채운 것과 1.8·2.3 을 각각 채운 것이 같은 숫자가 되어, 두꺼운 환자의
단계가 완료로 잡히고 **그 자리에서 1.8MHz 를 영원히 놓친다.**
`scanProgress(patient, day, expected)` 로 기대 집합을 받아 `containsAll` 로 본다.
expected 가 비면 완료라고 말하지 않는다 — `containsAll(emptySet)` 은 항상 true 라
가드가 없으면 아무것도 안 잰 단계까지 완료가 된다. 옛 6조합 데이터는 두 집합 모두의
상위집합이므로 계속 완료로 읽힌다(과거 데이터가 뒤집히면 간호사가 다 다시 잰다).
## 완료 칩을 초록 → 회색
초록은 "좋은 상태"로 읽혀 조작자가 거기서 멈춘다. 실제 의미는 "이미 받았으니 다음으로
가라"다. 아직 안 받은 칩이 눈에 들어와야 한다 — 남은 일이 어디인지가 그 줄의 존재
이유다. 누를 수는 있게 둔다(파형이 이상해 다시 재는 경우).
## 레퍼런스 알고리즘 스위치 제거
병원 임상의 값은 최종 보고본 하나여야 한다. 스위치가 있으면 화면을 본 사람과 기록을
읽는 사람이 서로 다른 경로의 숫자를 같은 값이라고 믿을 수 있고, 그 착오는 기록의
알고리즘 라벨을 일일이 확인해야만 드러난다. 두 경로 비교는 테스트가 맡는다
(ClinicalBvTest · AlgoPathReportTest).
## 기록
매니페스트에 `abdomen_thickness` / `abdomen_thickness_label`. 조합만 남기면 "왜 1개만
돌았나"를 나중에 답할 수 없다 — 시간이 없어 끊은 것과 얇아서 하나면 됐던 것이 구분되지
않는다. LABDB_DATATYPES.md 에 "한 조건에 세션 1개인 것이 정상"을 박았다.
테스트 114개 통과(진행 판정 12개 전면 재작성). HospitalProgressTest 가 과거 격자를
손으로 적지 않고 HOSPITAL_COMBINATIONS 를 참조한다 — 둘이 갈리면 "과거 데이터는 계속
완료로 읽힌다"는 보장이 거짓이 된다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
탐색 중에는 흰 카드 안의 숫자만 바뀐다. 확정도 같은 흰 카드에 글자색만 달랐으니
"또 한 번 바뀐 숫자"로 읽힌다. 그런데 여기가 이 화면의 유일한 전환점이다 — 이 뒤로는
프로브를 옮기고 **더 이상 재지 않는다.** 면 색이 바뀌어야 프로브를 잡은 채 곁눈으로
봐도 걸린다.
확정(action == STOP && anchorCm != null)일 때:
배경 MlSuccess 단색 · 글자 흰색
라벨 "부착 위치" → "부착 위치 확정"
숫자 40sp → 44sp
한 줄 "여기로 옮기고 (최적 2cm + 1cm) 고정하세요. 이 자리는 측정하지 않습니다."
마지막 줄에 최적 cm 을 괄호로 같이 적었다. 간호사가 제일 많이 틀릴 지점이 **최적
위치(best)와 부착 위치(best+1)를 헷갈려 방금 측정했던 자리에 그대로 붙이는 것**이다.
큰 숫자만 있으면 "2cm 에서 완료라고 했는데 왜 3cm 이지"가 되므로 둘의 관계를 그 자리에
보여 준다.
아래 안내 박스는 확정일 때 첫 줄(근거 nch·cap)만 남긴다. 둘째 줄이 "여기서 1cm 더 올려
3cm 에 부착하세요"라 초록 카드와 같은 말이었다 — 같은 지시를 두 번 쓰면 둘 다 안 읽힌다.
REATTACH 는 초록으로 칠하지 않는다. done 이긴 하지만 부착 위치가 확정된 상태가 아니다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실제 인체에서 정렬 로직이 끝까지 안 맞을 수 있다. CH3 가 어느 위치에서도 안 잡히거나
(REATTACH), 상한까지 올라가도 nch 가 붕괴하지 않아 종료 조건이 안 걸린다. 그런데 진행
버튼이 두 군데서 막혀 있었다.
탐색 중(!done) 버튼이 **없다** — 측정 버튼만 있다
REATTACH 판정 버튼이 **비활성** (enabled = action == STOP)
후자가 특히 나쁘다. REATTACH 도 done 이라 2단계 화면으로 넘어가는데, 거기서 진행 버튼이
회색이고 "처음부터 다시 정렬" 밖에 없다. 환자는 누워 있고 방광은 계속 차는데 앱이
다음 화면을 안 내주는 막다른 길이다.
진행 버튼을 단계 밖으로 빼서 **항상 활성**으로 두었다. 막는 조건은 측정·좌우 스캔 중
뿐이다 — 그때 나가면 프로브 설정 복원이 끊겨 그 위치 데이터가 반쪽이 된다. 넘기는
위치는 `last?.anchorCm ?: cm`, 둘 다 "프로브가 지금 있는 자리"라 화면 숫자와 기록이
어긋나지 않는다.
**막지 않는 대신 근거를 같이 들고 간다.** AnchorBasis 를 새로 만들었다:
algorithm 알고리즘이 best 확정 (STOP)
reattach_override 재부착 권고를 무시하고 진행
manual 탐색 도중 사람이 결정
이게 이 커밋에서 제일 중요한 부분이다. 위치(cm)만 들고 가면 사람이 고른 자리와
알고리즘이 고른 자리가 기록에서 한 덩어리가 된다. 그러면 "기존 초음파 측정기 대비
정확도"를 낼 때 사람이 고른 자리의 오차가 알고리즘의 오차로 계산되고, 나중에 둘을
가를 단서가 없어 **데이터 전체가 주장을 받치지 못한다.**
그래서 근거를 세 곳에 남긴다: appState(화면), 600 매니페스트, 601 align_result.json
(`anchor_basis` · `proceed_anchor_cm`). LABDB_DATATYPES.md 에 "정확도 분석에는
algorithm 만 추려야 한다"를 표로 박았다 — 서버 쪽이 세션 목록에서 필터를 걸 수 있다.
화면도 근거에 따라 갈린다. 진행 버튼은 algorithm 일 때만 초록이고 나머지는 주황,
AnchorCard 도 같은 규칙이다. 같은 색으로 두면 간호사가 "정렬 끝났다"로 읽는다.
버튼 아래 한 줄로 무엇을 건너뛰는지 말한다(proceedNote) — 다 맞췄으면 아무 말도 안
한다. 늘 뜨는 경고는 곧 무시당한다.
lateral 요약에 `confirmed` 를 추가했다. 좌우를 맞추는 중에 넘어가면 commit 은 있어도
확인은 없는데, 기존 `done` 하나로는 그 둘이 구분되지 않아 확인한 세션으로 잘못 읽힌다.
테스트 110개 통과(신규 11). AnchorAction 이 늘어나면 조용히 MANUAL 로 떨어지므로
entries.size 를 고정해 그때 이 결정을 다시 보게 했다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
앞 커밋에서 "메인 화면과 같은 방식"이라고 썼는데 절반만 맞았다. 메인의 [단일 측정]은
1 cycle 이 아니라 **5회를 모아 절사평균**한다 (PiezoMonitoringView: targetCount=5,
maxAttempts=8, 3개 이상이면 값). 임상 화면은 1회만 잰다 — 의도한 차이지만, 주석과
화면 문구가 "같다"고 말하고 있었다.
연속 / 자동 cycle 마다 BV → 최근 10개 절사평균, 5개부터 표시 같음
1회 / 단일 메인 5회 절사평균 vs 여기 1 cycle 그대로 다름
1회로 두는 이유는 검증 항목 ③(위치 민감도)이다. 상·하·좌·우 1~2cm 씩 옮기며 값을
기록하는 작업인데 메인 방식은 한 번에 10초 가까이 걸려 그 시간이 그대로 쌓인다.
**틀린 설명이 코드보다 위험하다.** 숫자가 다른 건 이유를 알면 해석할 수 있지만, "같은
방식"이라고 읽은 사람은 임상 1회 값과 메인 단일 값을 나란히 놓고 기기 차이로 결론낸다.
그래서 주석에 비교표와 "직접 비교하면 안 된다"를 넣고, 기존 초음파 측정기 대조(항목 ①)
에는 **연속 측정**을 쓰라고 못박았다.
버튼도 [단일 측정]→[1회 측정], [자동 측정]→[연속 측정]. 메인과 같은 이름이면 같은
동작으로 읽힌다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
임상 화면의 Spot/Continuous 를 메인 화면 [단일 측정]·[자동 측정]과 같은 역할로 쓰기로
했는데, 내가 만든 것은 방식이 달랐다.
메인 단일: 1 cycle → BV 그대로
자동: cycle 마다 BV → 최근 10개 절사평균 (5개 이상 쌓인 뒤 표시)
기존 구현 5 cycle 신호를 mean-scan → BV 1개
**평균을 내는 지점이 달랐다.** 메인은 BV 를 낸 뒤 부피끼리 평균하고, 내 것은 BV 내기
전 신호끼리 평균했다. 그러면 같은 프로브·같은 자리에서도 두 화면 숫자가 갈린다 —
검증 첫 항목이 "기존 초음파 측정기와 비교"라, 그 차이가 기기 탓인지 계산 탓인지
구분이 안 되면 검증 자체가 무너진다.
메인 규약을 그대로 옮겼다: 1 cycle = BV 1개, 자동은 최근 10개 절사평균(최대·최소 하나씩
버림), 5개 이상 쌓여야 대표값 표시, 완료 대기 후 남은 간격만 쉬기. trimmedMean 식이
메인과 같은지 테스트로 고정했다.
신호 평균(mean-scan)이 값은 더 안정적이지만 메인과 다른 수가 되고, 정렬의 20-cycle
mean-scan 과도 또 다른 세 번째 방식이 된다. 여기서는 **비교 가능성이 안정성보다
우선**이라 이쪽을 택했다.
편차·CV·범위는 남겼다. 메인에는 없지만 검증 항목 ③(위치 민감도)이 위치끼리 BV 를
비교하는 일이라, 흔들림을 모르면 "이 위치가 더 낫다"를 말할 수 없다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
labdb 관리자가 코드를 배정했다(2026-09-09). 임시로 쓰던 사용자 정의 구간에서 옮긴다.
901 → 600 병원 임상 측정
902 → 601 부착 위치 정렬
서버 조사에서 나온 지적 셋을 함께 처리했다.
**① 적재량 오기 정정.** 문서·주석·테스트에 "279건(2026-09-05)"이라고 적혀 있었는데
틀렸다. 279 는 일반 앱의 **미업로드 세션 수**였고 병원 적재량과 무관하다 — 그걸
옮겨 적으면서 섞였다. 실제는 **72세션 · 1,440레코드**(2026-09-04)이고 서버 조회로
확인했다. 기존 적재분은 서버에서 이미 600 으로 마이그레이션됐다.
**② subject 가 서버에서 비어 있었다.** 서버는 최상위 `data.subject` 만 읽어
`sessions.subject` 컬럼에 넣는데 앱은 `params` 안에만 넣고 있었다. 그 컬럼이 export
파일명 prefix 와 목록 표시에 쓰이므로, 72세션 전부 환자 구분이 안 되는 상태였다.
두 페이로드 모두 최상위에 추가한다(params 안에도 그대로 둔다 — 분석 쪽이 이미 쓴다).
환자명이 비면 필드 자체를 넣지 않는다: 빈 문자열이 들어가면 목록에서 빈칸과
구분이 안 된다.
**③ 601 은 레코드 시각이 업로드 시각으로 채워진다.** 원본 정렬 파일에 시각 열이 없어
`datetime` 을 못 넣는데 서버의 `records.timestamp` 는 NOT NULL 이다. 동작에는 문제가
없지만 전 레코드가 거의 같은 시각이 되므로, **조회·시각화는 rowIndex 로 정렬해야 한다**
— KDoc 과 스펙 문서에 명시했다.
PC 변환기(align2labdb.py · hospital2labdb.py)도 같이 고쳤다. 기존 902 정렬 1세션은
같은 testId 로 재업로드해 601 로 갱신했다(600 72 · 601 1 로 확인).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
병원 임상 화면은 최종 보고본(레퍼런스)이 기본이어야 하는데, AlgoMode.reference 는
프로세스 전역이라 그냥 켜면 **일반 측정 화면의 BV 까지** 바뀐다. 그쪽은 현행 임상에서
쓰이고 있어 임의로 건드리면 안 된다.
AlgoMode.withReference(ref) { } 범위 실행을 두고 ClinicalBv 가 그 안에서만 돈다.
계산이 끝나면 전역값은 원래대로다 — 임상 화면을 다녀왔다는 이유로 다른 화면의 값이
달라지지 않는다. 되돌리는지도 테스트로 고정했다. 임상 측정은 코루틴에서 돌아 두 계산이
겹칠 수 있으므로 동기화한다: 겹치면 한쪽의 복원이 다른 쪽의 설정을 지워 엉뚱한 경로로
계산된 값이 나오는데, 그 한 건이 검증 기록에 섞이면 나중에 찾아낼 방법이 없다.
ClinicalBv.compute(reference = true) 가 기본. BV 측정 화면의 스위치는 이제 전역을
쓰지 않고 그 화면의 계산에만 적용된다(두 경로 비교용).
**앞 커밋(21b0fca)의 숫자를 정정한다.** "align_cm1 기존 125.52 vs 레퍼런스 122.25 mL"
는 잘못된 측정이었다 — PiezoHW.activePreset 을 안 잡고 돌려 dps·delay 가 다른 프리셋
값으로 계산됐다(실측 자원은 VBT26050202=V1). 프리셋을 고정하면 이 데이터에서는 두
경로가 **같은 값**을 낸다(cm0 410.65 / cm1 490.63, 양쪽 동일).
두 경로가 같다는 뜻은 아니다. AlgoModeSwitchTest 실측으로 **cycle 44개 중 38개**의
검출·BV 가 갈린다. 여러 cycle 을 평균한 mean-scan 에서 차이가 묻힌 것이다. 즉 입력에
따라 같기도 다르기도 하므로, 값에 경로 표시를 붙이는 이유는 그대로 유효하다.
같은 함정을 테스트에도 반영했다 — ClinicalBvTest·AlgoPathReportTest 가 프리셋을 V1 로
고정한다. 다른 테스트가 남긴 전역 프리셋에 끌려가면 값이 통째로 흔들린다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
검증 목적이 "최종 보고된 알고리즘"인데 AlgoMode.reference 기본값이 false 라, 지금까지
임상 화면이 낸 BV 는 **기존 경로 값**이었다. 두 경로는 같은 신호에 다른 값을 낸다 —
실측 대조(AlgoPathReportTest):
align_cm0 기존 280.46 mL 레퍼런스 280.46 mL (동일)
align_cm1 기존 125.52 mL 레퍼런스 122.25 mL (3.27 mL · 2.6%)
그리고 최근 이식한 세 기능(post_tie_lock · _sibeam_cap_bounds · F83)은 reference 일
때만 동작한다. 스위치가 꺼진 채로는 "최종 알고리즘을 탑재했다"가 성립하지 않는다.
· ClinicalBv.Outcome 에 algoLabel 추가. BvPanel 이 **모든 값 옆에** 경로를 표시한다.
검증 기록에 남은 숫자를 나중에 해석할 수 있어야 한다 — 어느 경로인지 모르는 BV 는
"정렬 위치가 타당한가"의 근거가 될 수 없다.
· BV 측정 화면에 경로 스위치. 이 화면에서는 레퍼런스를 기본으로 켠다. AlgoMode 가
전역이라 일반 측정 화면에도 영향을 주므로 켠 사실을 드러내고 되돌릴 수 있게 뒀다.
· AlgoPathReportTest — 두 경로의 값을 실측 데이터로 출력한다. 단정문이 없는 리포트용
시험이라 보고서에 숫자를 그대로 옮길 수 있다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
알고리즘은 이미 이식돼 있었다(MethodDRunner + estimateBv, AnchorGuide 가 cap_frac 에
쓰고 있다). 없던 것은 그 결과를 화면에 꺼내는 부분이다.
**ClinicalBv** — 검출→BV 조합을 한 군데로 묶는다. 조합을 화면마다 다시 쓰면 반드시
갈린다: CCC on/off, missingOutside 전달 여부, ant vs antRefined 중 무엇을 넘기는가.
셋 다 결과를 바꾼다. AnchorGuide 의 cap_frac 경로를 그대로 쓰고, 테스트가 두 경로의
volumeMl 이 1e-9 안에서 같은지 실측 trace 로 고정한다 — 갈리면 정렬이 고른 위치의
근거와 화면의 용적이 다른 계산이 되어 이 기능의 목적(위치 검증)이 무너진다.
**실패를 값으로 돌려준다.** 이 화면들의 관심사는 "왜 용적이 안 나오나"다. null 만
주면 화면은 "—" 밖에 못 쓴다. 검출 0개(부착 문제) / 2개 미만(위치 문제) / 검출은
됐는데 기하가 안 풀림을 각각 다른 문구로 낸다 — 대응이 다르기 때문이다.
**BvPanel** — 용적 + 채널별 전벽·후벽 표. 샘플 인덱스(파형에서 보이는 것)·mm·직경
(BV 가 실제로 쓴 값)을 나란히 둔다. "파형엔 벽이 보이는데 용적이 이상하다"에서 어느
단계가 틀렸는지 갈리려면 셋이 같이 있어야 한다. 직경이 "제외"면 검출은 됐지만 BV 에서
빠진 채널이다. 실패해도 검출된 만큼은 그대로 보여준다 — 어느 채널이 빠졌는지가 답이다.
**BvMeasureSection** — Spot / Continuous. Spot 은 "지금 얼마인가", Continuous 는
**흔들리는지**를 본다. 한 번 재서 나온 200mL 가 진짜인지는 한 번으로 알 수 없고,
정렬 위치가 최적인지 판단하려면 재현성이 필요하다. 그래서 연속 모드는 최근 20회의
평균·표준편차·CV·범위를 같이 낸다. 5 cycle 을 모아 mean-scan 후 1회 계산한다 —
1 cycle 로 재면 노이즈가 그대로 벽으로 잡힌다.
측정 조건은 정렬과 같게 고정(2.3MHz·cycle 3). 검출 문턱이 절대값 기준이라 조건이
바뀌면 nch 가 달라지고, 정렬이 고른 위치의 근거와 다른 조건의 BV 가 된다.
붙인 곳:
· 부착 위치 정렬 — 위치마다 판정과 **같은 mean-scan** 으로 BV. 따로 재면 그 위치의
지표와 용적이 다른 데이터가 되어 대조가 성립하지 않는다.
· 병원 임상 모드 — 대기 중 Spot/Continuous, 측정 중 직전 회차 BV.
· 두 화면의 파형에 전벽(초록)·후벽(주황) 세로선. 마커가 검출 인덱스와 같은지도 테스트.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
params 를 화이트리스트로 채우고 있었는데, 그 목록에 없던 두 필드가 조용히 빠져 있었다:
· patient_unnamed — 환자명 없이 잰 데이터인가
· selection_verified_against_reference — 앱 판정이 레퍼런스와 대조 완료인가
요약에 필드를 추가할 때마다 여기도 고쳐야 하는데 안 고쳐도 아무 신호가 없다. 이 세션은
"파형이 이상하니 봐 달라"는 진단 요청이라 **덜 보내는 쪽이 위험**하다 — 개발자가 되물어야
하고 그 왕복이 임상에서는 하루다.
align_result.json 을 통째로 옮기고, 이미 다른 이름으로 넣은 것(patient→subject,
save_name)만 건너뛴다. 요약의 모든 키가 params 에 도달하는지 테스트로 고정했다.
문서도 함께 고쳤다. positions[] 표에서 ch3_hit·ch3_tot·eligible 이 빠져 있었고
(실제로는 올라가고 있었다), params 표에 위 두 필드를 추가했다. 표에 없는 키가 보여도
정상이라는 설명도 넣었다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
파형이 이상해 보일 때 그 자리에서 개발자에게 보내는 통로가 없었다. 임상이 끝난 뒤
파일을 꺼내 보내면 회신이 하루 뒤인데, 그때는 이미 그 환자로 다시 잴 수 없다.
정렬 전용 dataType 902 를 새로 쓴다. 임상 측정(901)과 구조가 다르다 — 저쪽은 한
조건에서 20 반복이고 정렬은 여러 위치 × 20 cycle 이다. 같은 코드로 올리면 params
만으로는 구분이 안 되고 콘솔이 두 종류를 한 차트로 그린다. labdb 는 dataType 마다
records[] 스키마가 완전히 달라도 되고(labdb.md §2-3), 900~999 가 사용자 정의 구간이다.
record = 한 위치의 한 cycle
rowIndex : 위치를 가로질러 0..N 연속 (labdb 규약)
align_cm : 그 cycle 을 잰 위치
cycle_idx : 그 위치 안에서의 순번
channels : 6 × {ch, peak, peakIdx, data}
**판정 근거(align_result.json)를 params 에 통째로 넣는다.** 개발자가 답해야 할 질문이
"왜 이 위치를 골랐나"라서 파형만 오면 되묻게 된다 — 지표(nch·ch3·cap_frac)와 선택
결과, 좌우 결과가 같이 가야 한 번에 답이 나온다.
**자동이 아니다.** 간호사가 버튼을 눌러야 올라간다. 목적이 "이상하니 봐 달라"는
요청이라 정상인 것까지 올리면 정작 봐야 할 것이 묻힌다. 무엇이 이상해 보였는지
한 줄을 받아 memo 로 보낸다 — 그게 없으면 "이게 왜 올라왔지"부터 시작한다.
params.upload_reason 에 사람이 올린 것임을 남겨 자동 업로드분과 섞이지 않게 했다.
마커는 남기지 않는다. 같은 정렬을 다시 보낼 수 있어야 한다 — 처음에 못 적은 증상을
memo 에 적어 다시 보내는 흐름이 실제로 생긴다. labdb 는 같은 testId 면 갱신한다.
판정이 끝나기 전에도 보낼 수 있다. 첫 위치만 재고 이상하다 싶은 때가 오히려 물어볼
상황이다 — 요약이 없으면 파형만 보낸다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
병원 임상 모드에는 labdb 업로드 경로가 **아예 없었다**. HospitalRunStore 는 Downloads 에
파일만 쓰고 업로더를 한 번도 부르지 않는다. 업로드는 ClinicalSessionStore.finalize 한
곳에만 붙어 있고 그건 병원 모드가 쓰지 않는 경로다. 2026-09-05 에 279건을 올릴 때
파이썬 변환기를 따로 만들어야 했던 이유가 이것이다.
포맷도 다르다. 기존 업로더는 measurement.json(cycles 배열)을 읽는데, 병원 모드는 CSV
한 행이 1 반복 × 1 채널이고 조건별로 파일이 갈린다. 그래서 그때의 변환 규칙을 앱으로
옮긴다 — 모양이 달라지면 labdb 에 같은 프로토콜 데이터가 두 형태로 쌓이고 분석하는
쪽이 두 벌을 만들게 된다.
CSV 파일 1개 = 세션 1개 (자세·충만도·주파수·cycle 고정 20 반복)
CSV 6행(CH0~5) = record 1개 → channels[0..5].data = s0..s99
rowIndex = repeat_idx
**조합이 끝날 때마다** 올린다. 한 바퀴를 다 돌 때까지 기다리면 중간에 앱이 죽거나
자리를 옮겼을 때 그때까지 잰 것이 통째로 남는다. 단위도 자연스럽다 — labdb 는 요청
1건 = 세션 1개 = record N개이고, 20 반복이 곧 한 요청이다. 측정 1회마다 올리면 한
바퀴가 120건이 되어 분당 10건 제한에 걸린다.
**오프라인이 정상 경로다.** 병원 무선망이 불안정한 곳이 많아 "나중에 올리기"가 예외가
아니다. 성공/실패를 CSV 옆 마커 파일로 남겨 앱이 죽어도 디스크만 보면 무엇이 남았는지
알 수 있게 했다 — 상태를 메모리에 두면 이 기능의 요점이 사라진다. 화면에 "미업로드
N건"과 [지금 업로드] 를 두고, 밀린 것을 올릴 때는 요청 사이를 7.5초 띄운다(분당 ~8건).
한 건이 실패해도 멈추지 않는다. 망이 잠깐 끊긴 것과 그 파일이 잘못된 것을 구분할 수
없는데, 뒤의 멀쩡한 것까지 막으면 손해가 크다. 실패 마커가 남아 다음에 다시 시도된다.
dataType 은 901(사용자 정의 구간)이다. 병원 프로토콜용 코드를 관리자에게 배정받으면
HospitalLabdbPayload.DATA_TYPE 만 바꾸면 된다 — 279건도 같은 값으로 올라가 있다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
헤더가 "detachment_detection.py 1:1 포팅"에 "둘 다 threshold 미만이면 미부착"이라고
돼 있었는데 둘 다 사실이 아니다. 임계는 100→30, 규칙은 AND→OR 로 바뀌었고 근거는
커밋 메시지에만 있었다 — 파일만 보면 왜 다른지 알 수 없고, 다음 사람이 "원본과 맞추자"
며 되돌리기 딱 좋은 상태였다.
두 변경 다 실기기 증상을 보고 내린 결정이다:
· 100→30 (258bad9) — 원본의 100 은 팬텀에서 미부착 vs incorrect 두 조건으로 뽑은
값이다. 실기기에는 "붙었는데 신호가 약한" 세 번째 조건이 있고, 100 을 쓰면 그것까지
미부착으로 잡아 측정 중인 사용자에게 경고가 계속 뜬다.
· AND→OR (b2c1ba2) — 분리된 상태인데 공기 중 EMI 로 std 가 30 을 넘어 판정이 지연.
parity 규칙이 여기 걸리지 않는다는 것도 적었다. SPEC 의 대조 계약은 §5·§8 등재 항목에
적용되는데 detachment 는 SPEC 에 없고, Python 쪽에서도 이 모듈을 부르는 코드가 하나도
없다(파이프라인 밖 독립 유틸).
남은 숙제도 같이 적었다 — 두 변경이 서로를 가린다. OR 의 근거("std 가 30 이상인 경우가
흔하다")는 임계가 30 이라서 생긴 문제라, 100 이었다면 AND 로도 즉시 판정됐을 것이다.
세 조합을 실기기에서 비교한 적이 없으므로, 채널 접촉 칸의 mean·std 실측값을 모아
정하기로 한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
임상 화면에는 미부착 판정이 **아예 없었다**. DetachmentDetection 호출부가 일반 측정
화면과 일반 정렬 화면 두 곳뿐이라, 병원 임상 모드와 부착 위치 정렬은 파형만 보고
사람이 읽어야 했다. 파형으로 "이 채널이 떴다"를 읽으려면 눈이 익어야 하고, 20회를
다 돌린 뒤에 알면 그 단계는 다시 못 잰다 — 방광을 비웠거나 자세가 바뀐다.
DetachWatcher 를 둔다. 판정식은 DetachmentDetection(레퍼런스 포팅본) 그대로 쓰고 두
가지만 더한다.
**채널별 판정** — 레퍼런스는 CH0~CH3 feature 를 평균 낸 뒤 임계와 비교한다. 주석에
이유가 있다("부분 탈착 노이즈에 robust"). 그런데 간호사에게 필요한 것은 정반대다.
"붙어 있나"가 아니라 **어느 쪽이 떴나**를 알아야 어디를 다시 누를지 안다. 평균만 보면
CH0 이 완전히 죽어도 나머지가 멀쩡하면 아무 표시가 없다.
그래서 알고리즘 판정은 레퍼런스 그대로 두고(aggregateDetached), 채널별은 **표시 전용**
으로 따로 낸다. 둘을 섞지 않는다 — 채널별 flag 로 측정을 막거나 LED 를 바꾸면 그때부터
레퍼런스에서 벗어난 알고리즘이 된다. 화면에도 둘을 같이 띄워 어느 쪽이 기록에 들어가는
값인지 밝힌다.
**연속 확인** — 한 프레임 결과를 그대로 띄우면 손동작마다 깜빡이고 곧 무시당한다.
연속 3프레임이 같아야 뒤집는다. 복귀에도 같은 수를 요구한다 — 한 방향만 걸면 접촉이
불안정한 구간에서 "떴다"가 붙는 즉시 사라졌다 다시 뜬다. 다만 **첫 프레임은 즉시**
반영한다: 프로브를 붙이기 전에 화면을 켜 두는 일이 흔한데, 그때 3프레임 동안 "정상"
으로 보이면 그 사이에 측정을 시작한다.
CH4·CH5 는 판정하지 않는다. 레퍼런스가 N_CENTER_CH=4 로 못 박고 4채널 미만이면 예외를
던진다. 임계값도 CH0~CH3 실측 분포에서 나온 값이라 lateral 에 적용할 근거가 없다.
값은 보여주되 "—" 로 두어 판정을 안 한 것과 정상을 구분한다 — 빈칸이면 정상으로 읽힌다.
붙인 곳: 병원 임상 모드(측정 중), 부착 위치 정렬(위치 측정 + 좌우 정렬 양쪽).
프로브를 다시 붙였을 수 있는 시점(실행 시작·위치 재측정)에는 확정 상태를 리셋한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
좌우 단계를 붙이면서 순서를 화면이 말해 주지 않았다.
1단계가 끝난 시점에 프로브는 **최적 위치**에 있고, 실제로 부착할 자리는 그보다 1cm
위다(오프셋 자리는 측정하지 않는다 — 설계상 지표가 나빠 재확인하면 반드시 미달이 뜬다).
그런데 좌우 카드가 그 사이에 "옮기세요" 없이 바로 나왔다.
좌우 균형은 그 높이의 단면에서 정해지는 값이라 높이를 바꾸면 u4·u5 가 달라진다. 옮기기
전에 맞추면 헛일이 되는데, 화면이 순서를 말하지 않으면 간호사마다 갈린다 — 맞춘 뒤
올리는 사람과 올린 뒤 맞추는 사람이 생기고, 둘의 데이터가 다른 조건이 된다.
카드 상단에 부착 위치를 명시하고, 시작 버튼 문구에도 cm 을 박았다("3cm 에서 좌우 정렬
시작"). 누르는 순간에도 어느 높이인지 눈에 들어와야 위 안내를 지나친 경우를 잡는다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
임상 정렬(AnchorAlignView → AnchorGuide)은 상하만 맞춘다. 지표(nch·ch3·cap_frac)가
전부 CH0~CH3 기준이라, 좌우가 틀어져 있어도 상하 최적은 그대로 정해진다. 그 상태로
임상을 진행하면 어긋난 단면에서 자세×용량 전체가 쌓인다.
레퍼런스에는 좌우 규칙이 이미 있다 — SPEC §8 "좌우(LR) 정렬",
`alignment_runners.py:alignment_advice`. Kotlin 포팅본도 `AlignmentAdvice.advise` 로
이미 있었고 LAT_TOL(8)까지 일치한다. 없던 것은 **임상 화면에 붙는 배선**뿐이었다.
LateralGuide 를 새로 둔다. 좌우 규칙이 아니라 그 앞단(프레임 누적 → 평균 → 검출 →
요약)만 맡는다 — 파이썬에서 `RollingAligner` 가 하는 일이다. 같은 이름의 Kotlin
클래스(AlignmentAdvisorV2.RollingAligner)를 쓰지 않은 이유는 그쪽이 6단계 상태기라
상하 단계를 통째로 끌고 오기 때문이다. 임상은 상하를 AnchorGuide 가 이미 정했다.
Phase4.LR_BALANCE 도 쓰지 않았다. 거기엔 레퍼런스에 없는 것이 붙어 있다 — 방향 반전에
deadband(3)와 연속 2회 조건. 파이썬 제품 경로가 부르는 것은 stateless 한
alignment_advice 이고, 진동은 accum_k(10) 프레임 평균으로만 잡는다.
검출은 CCC **on** 이다. 상하(nch·ch3)는 off 로 재지만(SPEC §8.2), 파이썬 detect() 가
detect_walls 를 기본값(apply_cross=True)으로 부르기 때문이다. off 로 맞추면 같은
신호에 다른 urine_len 이 나와 좌우 판정이 레퍼런스와 갈린다.
화면: 상하 확정 뒤 "2단계 · 좌우 정렬" 카드. 방향 화살표를 크게, 근거(ch4·ch5·|Δ|)를
그 아래. 균형 도달(STOP) 때만 "좌우 확인 완료"를 누를 수 있다 — 아무 때나 누르면
그 표시가 뜻을 잃는다. 다만 **진행을 막지는 않는다**: lateral 이 끝내 안 잡히는 환자가
있는데 그때 막으면 임상 자체가 멈춘다. 대신 미확인 상태를 문구로 남긴다.
요약 JSON 에 lateral 블록을 남긴다. `done=false` 는 "안 맞췄다"가 아니라 "확인 단계를
거치지 않았다"는 뜻이다 — 진행을 막지 않으므로 둘을 구분해야 재분석 때 가려낼 수 있다.
테스트: alignment_advice 결정표 5분기 전부 + 누적 규약(슬라이딩·reset·채널 부족).
ch3 게이트가 좌우보다 먼저라는 것도 고정했다 — 검출이 없을 때 PROBE_LR 이 아니라
MOVE_UP 이 나와야 한다(작성 중 이 기대를 틀리게 잡았다가 테스트가 잡아냈다).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
50시간짜리 소모율 측정을 걸려고 보니 기록이 분석에 못 쓸 상태였다.
1) 테스트가 10초마다 굴리는 mbb? 의 응답(rbb)에 **전압·온도가 로그에 안 남았다.**
파싱은 해서 화면에는 띄우면서 파일에는 "full measurement header (battery+IMU+temp)"
라는 고정 문자열만 썼다. 즉 이 테스트의 유일한 10초 주기 표본이 파일에 없었다.
(30초 주기 rsn 만 전압을 남기고 있었다.)
2) 진행 중 기록이 없었다. 시작·정지 두 줄뿐이라, 50시간을 걸어 놓고 중간에 프로세스가
죽으면 정지 줄조차 안 남는다 — 어디까지가 유효한 구간인지 판별할 방법이 없다.
3) BLE 로그는 초당 수 줄이 섞여 들어간다(RSSI 가 68%를 차지). 50시간이면 수십만 줄에서
전압만 골라내야 한다.
그래서 소모율 분석에 필요한 것만 별도 CSV 로 남긴다:
Download/VesiScan_BattDrain_<시작시각>.csv
time,elapsed_s,source,batt_mv,temp_c,connected,imu_*,full_*
10초 주기면 50시간에 18,000행이라 그대로 스프레드시트에 올라간다. 줄마다 flush 하므로
앱이 죽어도 그 시점까지는 남는다.
**rbb 가 안 와도 1분마다 한 행(source=tick)을 남긴다.** 프로브가 죽거나 BLE 가 끊기면
rbb 가 멈추는데 그때 CSV 도 같이 멈추면 "언제 끊겼는지"를 파일만 보고는 알 수 없다.
같은 주기로 BLE 로그에도 요약 한 줄을 남겨 두 파일을 맞춰 볼 수 있게 했다.
다이얼로그에 기록 파일명을 띄운다 — 장시간 돌린 뒤 Downloads 에서 어느 파일을 꺼내야
하는지 화면에서 바로 알아야 하고, 생성 실패도 여기서 보인다.
실기기 검증(23021RAA2Y · VBT26080001 · 141초): CSV 생성 · rbb 행 14건(전압 3849~3854mV,
온도 24.3~24.4C) · 60초 tick 1건 · stop 행 · BLE 로그의 rbb 줄에 값 표시 확인.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
조합 선택 — 요청받은 주파수·cycle 변경이 실질적으로 불가능했다
수동 주입 패널이 `mcs?` 를 한 번 쏘기만 해서, 측정을 시작하면 첫 조합이 곧바로 덮어썼다.
루프가 6조합을 무조건 다 돌기 때문이다. 이제 돌릴 조합을 고를 수 있다.
· 기본은 6개 전부. 프로토콜이 요구하는 것이 전부이고, 빼는 것은 "한 조합만 다시
잰다" 같은 예외를 위한 것이다. 마지막 하나는 못 끄게 했다 — 0개로 시작을 누르면
아무 일도 안 일어난다.
· 선택 순서가 아니라 프로토콜 순서를 지킨다. 매번 같은 순서로 돌아야 나중에 파일을
비교할 때 조건이 섞이지 않는다.
· 나눠 재도 조건은 완료로 잡힌다(앞 커밋의 합산 판정).
함께 고친 것 — 부분 선택으로 틀리게 된 표시 둘
· 진행 패널의 "1/6" 이 HOSPITAL_COMBINATIONS.size 로 박혀 있었다. 2조합만 골라도
"1/6" 이었다. 선택 수 기준으로 바꿨다(총 측정 횟수 계산도).
· 경고 문구의 "(2.3MHz · cycle 7)" 도 박혀 있었다. 프로브에 남는 값은 **선택한 것 중
프로토콜 순서상 마지막** 조합이라 부분 선택이면 달라진다. 실제 값을 계산해 보여준다.
파형 — 측정·정렬 중 6채널 원신호
측정하는 사람이 신호가 제대로 잡히는지 그 자리에서 보게 한다. 20회를 다 돌린 뒤에야
파일을 열어 보면, 잘못 붙은 것을 알았을 때는 그 단계를 다시 잴 수 없다(방광을 비웠거나
자세가 바뀌었다). 정렬은 끝난 뒤에도 남긴다 — 위치를 옮겨 가며 반복하는 작업이라
판정 결과와 마지막 파형을 나란히 보고 다음을 정한다.
그리는 값은 저장·판정에 쓰는 것과 같은 데이터다. 화면용으로 따로 재지 않는다.
프로브 설정 — 확인과 복원
· 패널에 "현재 설정" 과 읽기 버튼(`mcf?`). 임상 루프가 파라미터를 덮어쓰고 되돌리지
않으므로, 일반 측정으로 돌아가기 전에 무엇이 들어 있는지 볼 수 있어야 한다.
· 측정·정렬 종료 시 ProbeConfigGuard 로 측정 전 값 복원. finally 안이고
NonCancellable 로 감쌌다 — 중지로 코루틴이 취소되면 응답 대기가 끊겨 복원 명령이
안 나간다. 오히려 중단됐을 때가 더 필요한 자리다.
· 복원 결과를 화면에 한 줄로 남긴다. 실패했으면 다음 일반 측정이 임상 조건으로
돌게 되므로 눈에 띄어야 한다.
실기기(Redmi 23021RAA2Y) 확인 — 읽기 정상 동작. 테스트 44개 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
화면에서 6조합 중 일부만 골라 돌릴 수 있게 되면서(다음 커밋) 판정이 깨진다. 종전에는
실행 하나만 보고 "6조합이 다 있나"를 따졌는데, 나눠 재면 어느 실행도 6개를 못 채워
**영원히 PARTIAL** 로 남는다. 그러면 "덜 됐다"는 표시가 의미를 잃는다.
같은 자세·충만도의 여러 실행을 조합 단위로 합산한다. 조합 하나가 여러 실행에 나오면
한 번이라도 계획을 채운 실행이 있으면 그 조합은 끝난 것으로 본다 — 20회 중 3회에서
끊긴 뒤 다시 20회를 채웠다면 완료다.
판정 전부를 progressFrom(manifests) 순수 함수로 떼어 시험으로 고정했다. 이 판정이
틀리면 간호사가 다 됐다고 믿고 방광을 비우는데 데이터는 못 쓰고, 그 단계는 그날 다시
못 잰다. 실기기 없이도 고정해 둬야 한다.
나눠 재기 · 재측정 · 계획 미달 · planned 없는 옛 매니페스트 · 깨진 JSON ·
조건 간 혼선 8건.
org.json 은 안드로이드 단위테스트에서 모든 메서드가 스텁이라(isReturnDefaultValues
로도 안 된다 — 반환값이 아니라 동작이 없다) 실제 구현을 테스트 의존성으로 넣었다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
지금까지 프로브에 무엇이 저장돼 있는지 **알 방법이 없었다.** `mcs?`(쓰기)의 echo
`rcs:` 로만 알 수 있었는데, 그건 이미 값을 바꾼 뒤다.
· sendPiezoConfigQuery() — `mcf?` [tag][space][crc] 7B. 응답 `rcf:` 는 배치가
`rcs:` 와 같아 파서를 그대로 옮겼다(실패 시 freq=0xFFFF 도 동일).
· 응답은 piezoConfigRead 로 받는다. piezoConfigEcho 에 섞으면 안 된다 — 그쪽은
"내가 방금 쓴 게 먹혔나"를 확인하는 자리라 쓰기 직전에 null 로 비우고 기다린다.
조회 응답이 같은 곳에 들어오면 쓰기 검증이 엉뚱한 값을 보고 통과한다.
ProbeConfigGuard — 임상 측정 전후로 파라미터를 보존한다.
정렬과 병원 임상 모드는 자기 조건을 프로브 FDS 에 써 넣고 되돌리지 않는다. 일반 측정
화면은 mcs 를 아예 보내지 않고 프로브에 있는 값을 그대로 쓰므로, 임상을 한 번 돌리면
일반 측정의 취득 조건이 조용히 바뀐 채 남는다 — 주파수가 바뀌면 파형이 달라져 BV 에도
영향이 간다.
기본값을 박아 두지 않았다. "일반 측정용 기본값"이 앱 어디에도 없고, 제품이 어떤
조건으로 검증됐는지는 펌웨어·알고리즘 쪽 값이라 여기서 정할 수 없다. 정해서 박으면
그 값이 틀렸을 때 모든 임상 종료 시점에 틀린 값을 심는다. 대신 시작 전 값을 읽어
두었다가 그대로 되돌린다 — 원래 무엇이었든 전후가 같아진다.
· 못 읽었으면 되돌리지 않는다. 짐작한 값을 쓰면 원래와 다른 것을 심어 놓고
"복원했다"고 믿게 된다 — 안 되돌리는 것보다 나쁘다.
· 쓰기 echo 까지 확인한다. 설정이 거부돼도 rcs: 는 오므로.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ClinicalLiveView` 안에 private 으로 있던 `SixChannelGrid`/`ChannelChart` 를
`ui/components/ChannelWaveform.kt` 로 옮겼다. 임상 화면 여러 곳에서 같은 파형을
보여줘야 하는데, 복붙하면 두 벌이 따로 흘러가 한쪽만 고치는 일이 생긴다.
· cellHeight · compact 로 크기만 다르게 쓴다 (실시간 화면 320dp, 곁들이는 곳 140dp)
· walls 는 선택 — 검출을 돌리지 않는 화면은 비워 두면 된다
· y 축은 0~4095 고정. 자동 스케일이면 채널마다 축이 달라져 서로 비교할 수 없다
· 벽 마커를 파형보다 먼저 그린다 — 나중에 그리면 파형을 가린다
옮기면서 안 쓰게 된 import 9개를 정리했다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
레퍼런스에서 남겨 뒀던 세 가지를 옮겼다. 셋 다 레퍼런스 경로 전용이고 기존(현행
임상) 경로는 그대로다.
· post_tie_lock 후벽 상위 두 후보의 score 가 구조적으로 붙는 구간(전 데이터
5773 결정 중 격차<10% 가 17.9%)에서 직전 trace 선택을 유지한다.
방향을 강제하지 않는다 — 깊은-peak 고정·인접 골 tie-break 은
전부 계통 편향을 낳았고, tie 밴드에서는 정의상 두 후보의 증거가
같기 때문이다. 상태기를 넘길 때만 동작하며, 레퍼런스도 streaming
strict pass 에서만 건다. streaming.py 이식 전까지 호출부는 없다.
· _sibeam_cap_bounds 검출 채널이 정확히 2개일 때 미검출 이웃 빔까지의 SI 거리를
cap 상한으로. 2개뿐이면 끝 채널이 서로의 이웃이라 기존 top/bottom
clamp 가 성립하지 않아 cap 이 무제한 외삽된다. supine 은 건너뛴다.
· F83 적도-밖 bottom cap 연속 보간(실리콘·supine). 전제인
mirror_bottom_cap 도 함께 옮겼다.
좌표계가 두 개라는 것을 확인했다
레퍼런스 cross_channel._z 는 sample_to_ap_depth 를 인자 없이 불러 교차채널 좌표가
언제나 모듈 기본값(1.936/6.85)으로 떨어진다. BV 는 프리셋 값을 명시로 넘긴다.
abs 는 두 값이 같아 표가 안 나지만 실리콘(1.897/7.651)에서는 갈린다 — 실측
f06/r1 단일 cycle 에서 CH0 전벽이 8 vs 14 로 갈려 439.88 vs 416.95 mL 이 됐다.
is_deep 판정이 34.209 > 34.042 처럼 아슬아슬한 자리라 작은 좌표 차가 선택을 뒤집는다.
의도인지 누락인지는 알고리즘팀 확인이 필요하다. 확인 전까지 레퍼런스와 같은 값을
내는 쪽을 택했고, 기존 경로는 프리셋 값을 그대로 쓴다.
missing_outside 의 null 과 빈 집합을 구분했다
null = 계산 안 함 → cap clamp 를 언제나 상한으로. 빈 집합 = 계산했고 '방광 밖'
채널이 없다 → 하한 쪽으로. ifEmpty { null } 로 뭉개면 "미검출이지만 span 은 있다"는
가장 흔한 경우에 si_floor 가 통째로 죽는다.
검증
· 레퍼런스 대조 6종 전부 불일치 0 — tie 1568행(lock 24건) · 실데이터 BV 784행
(nch=2 6건) · sibeam 120행(상한 102건) · 합성 BV 640행(mirror 226건) ·
F83 blend 440행 · F83 apply 12행. 실데이터가 미러·nch=2 에 거의 안 닿아
그 경로는 합성 입력으로 레퍼런스 함수를 직접 호출한 기대값으로 덮었다.
· 기존 경로 무변경 — 44 cycle × CCC on/off 덤프가 HEAD 와 88행 전부 일치.
· 전체 36개 통과 · demoDebug APK 빌드 성공.
LegacyEquivalenceDumpTest 가 PiezoHW.activePreset 을 명시로 고정하게 했다. 전역
가변 상태라 다른 테스트가 먼저 돌면 덤프가 통째로 달라진다 — 실제로 한 번 착시를
만들었다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
신 저장소에 맞춘 레퍼런스(piezo-phantom-test/vesiscan_test) 단계들을 옮기되,
demo-final 의 기본 동작은 건드리지 않았다. 2026-09-03 임상이 현재 HEAD 위에서
돌았으므로 그 값을 움직이는 변경을 기본 경로에 넣을 수 없다.
`AlgoMode.reference` (계측 화면 dev 패널 → "알고리즘", 기본 꺼짐) 를 켜면:
· CCC 3단계 → 7단계
envelope(①.9) · ant_tiebreak(①) · Λ-dip(①.7) · edge_post(①.8) ·
lumen_return(①.95) · NTV(② Rule A 만) · fp_gate(③). inward_post 는 빠진다.
· 파이프라인 4개 추가
scale_recovery(2) · missing_outside 산출(4) · channel_confidence→untrusted(5b) ·
CONF_DEMOTE(6). missing_outside 는 CCC **앞**에서 계산한다 — CCC 가 복구한
채널은 방광 밖이 아니다.
· BV
si_floor/missing_outside 로 극 cap clamp 의 방향(상한/하한)을 관측 증거로 가름,
bottom cap 제한 신설, 중앙 채널 보간 은행가 반올림,
adaptive_large_bladder_relax 제거(b_si 를 0.85·a_ap 로 바닥 처리해 cap 축 비를
정확히 0.85 로 만들어 바로 다음 cap 축 폴백을 경계에서 막고 있었다).
검출 파라미터(d_max 15 · inward_walk_win 4) · ANT_FLOOR_REF · cap 축 폴백 ·
ELLIPSE_CAP_R_MAX · TOP_CAP_EDGE_K · cap_d_adaptive · 프리셋 유도 dps/delay 는
ebddc52/20a48ad 에서 이미 기본 경로에 들어가 있어 양쪽 공통이다. 되돌리지 않았다.
검증
· 기존 경로 무변경 — LegacyEquivalenceDumpTest 가 44 cycle × CCC on/off 를
검출→BV 로 돌려 덤프하고, HEAD 코드 덤프와 대조. 88 행 전부 일치.
(이 테스트는 일부러 AlgoMode 를 참조하지 않는다 — HEAD 에 없는 타입이라
참조하면 대조 자체가 불가능해진다.)
· 토글 배선 — AlgoModeSwitchTest: 켜면 44 중 40 cycle 이 갈리고
missing_outside 가 산출되며, 끄면 기존 값이 정확히 재현된다.
· 전체 30개 통과.
함께 고친 것
저장소가 갈리며 없어진 픽스처 경로(medilightv2android/data123)를
vesiscan-design-archive/data123 로 옮겼다. 5개 테스트가 FileNotFoundException 으로
조용히 실패하고 있었다.
그래서 ebddc52 이후 한 번도 안 돌던 CenterAlignerValidationTest 가 다시 돌았고
hit 7→8 · 10→11 로 어긋났다. 현행 레퍼런스 AnchorGuide(ver='v1') 를 같은 세션에
직접 돌려 보니 8 · 11 이었다 — 코틀린이 맞고 기대값이 낡은 것이다(구
v3_python_reference.py 기준, 현재 저장소에 없음). 기대값을 갱신했다.
bv_cv 는 현행 레퍼런스가 더 이상 내지 않아 대조할 수단이 없다. "Python 과
bit-exact" 라던 주석은 근거가 없어졌으므로 걷어내고 회귀 고정으로 성격을 바꿨다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
화면 안 뒤로가기 화살표가 CLINICAL_HOME 으로 보내고 있었다. 이 화면의 진입점은
**시작 화면(HOME)** 의 "병원 임상 측정" 버튼이라, 누른 적도 없는 다른 모드로
빠져나갔다. 둘은 목적도 저장 경로도 다르다(임상 R&D = VesiScan_Sessions,
병원 임상 = VesiScan_Hospital).
하드웨어 뒤로가기는 else 로 흘러 이미 HOME 이었지만, 헷갈리기 쉬운 자리라
BackHandler 에도 명시했다. 측정 중 나가면 루프가 취소되고 finally 가 그때까지의
결과를 매니페스트에 남긴다 — 진행 표시에서 '일부'로 보인다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
주파수(1.8/2.3) · cycle(3/5/7) 을 골라 "기기에 주입"으로 프로브에 써 넣는다.
병원 임상 모드 화면 아래, mcs 영구저장 경고가 있는 자리에 둔다.
## 왜 필요한가
프로토콜은 6조합을 자동으로 돌지만 **그 밖의 측정은 프로브에 마지막으로 남은
설정으로 돈다.** 프로토콜을 돌리면 2.3MHz·cycle 7 이, 정렬을 하면 2.3MHz·cycle 3 이
남는다. 원하는 조건으로 되돌리거나 특정 조건만 시험하려면 손으로 넣을 길이
있어야 한다는 현장 요구.
## echo 를 보고 나서 성공이라고 말한다
설정이 거부돼도 프로브는 옛 설정으로 측정을 계속한다. 그래서 `rcs:` 응답 값이
요청과 같은지까지 확인하고, 결과를 네 갈래로 구분해 보여 준다:
적용됨 / 응답 없음 / 거부됨(펌웨어 사유) / 값 불일치(프로브에 남은 실제 값 표시)
프로토콜 루프가 조합마다 하는 검사와 같은 기준이다.
avg 10 · delay 10µs · samples 100 은 고정이며 화면에 명시한다. 측정 중에는
비활성 — 루프가 조합을 바꿔 가며 쓰는 중에 끼어들면 라벨과 데이터가 어긋난다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
정렬 화면에는 환자명 입력란이 없었다(병원 임상 모드에만 있었다). 정렬부터 시작하면
이름이 빈 채로 재게 되는데, 저장이 `patient.isNotBlank()` 로 막혀 있어 20 cycle ×
위치 수를 통째로 잃었다. 임상에서 제일 아까운 것이 이미 받은 데이터다.
두 방향으로 막는다:
- 정렬 화면에 **환자명 입력란** 추가. AppState 공유라 병원 모드와 값이 오간다.
입력 여부에 따라 저장 위치를 supportingText 로 보여 준다.
- 그래도 비어 있으면 `unnamed_HHmmss` 폴더에 **무조건 저장**한다. 세션 시작 시각
기준이라 한 정렬의 위치별 파일이 한 폴더에 모인다.
align_result.json 에 `save_name` 과 `patient_unnamed` 를 남긴다 — 이름 없이 잰
데이터인지 파일만 봐도 알 수 있어야 나중에 짝을 맞출 수 있다.
## 실리콘 첫 대조 (VBT2607R300 · FW VBTFW0203)
앱이 저장한 정렬 raw 를 Python `AnchorGuide(ver='r3')` 로 돌려 전 항목 일치 확인:
n_trace 11 · nch 0 · ch3 X(0%) · cap_frac 1.0 · MOVE_UP, 3위치 모두.
프리셋 자동 판별(R300)과 20 cycle → 11 trace 규약도 확인했다.
(공기 중 측정이라 nch=0 이 정상이다 — 파형이 s0 최대에서 단조 감소해 ~700
잡음바닥으로 수렴하고 세 위치가 사실상 동일하다. 없는 벽을 만들지 않는다.)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
레퍼런스 `runners.estimate_bv` 는 `cap_d_adaptive="auto"` 로 hw 버전을 보고
실리콘(r*)이면 `CAP_D_ADAPTIVE_SILICON = ("aspect", 80.0, 2.0)` 을 켠다. 코틀린엔
이 개념이 아예 없었다.
규칙: cap 지름 D = 2h 가 80mm 를 넘으면 `h · (80/D)^2` 로 누른다. 큰 방광에서 끝단
외삽이 과대해지는 것을 막는 장치다. Python 과 같은 자리(`_bv_core` 의 fallback cap
분기, R_eff 가 없는 경우)에만 적용한다 — shrink 경로는 이미 R_eff 로 높이가 구속된다.
## 왜 지금인가
어제까지의 대조는 **v1(abs) 데이터로만** 했다. 실리콘은 그 데이터셋에 없어서
"완벽 일치"가 실리콘까지 담보되지 않았는데, 실리콘 기기로 임상을 진행하려는
시점이라 이 격차를 먼저 메운다. abs(v*)에는 종전과 동일하게 적용되지 않는다.
CapDAdaptiveTest 신규 — Python `_apply_cap_d_adaptive` 를 그대로 돌려 뽑은 8점
(경계 D=D0 포함)과 대조하고, 꺼졌을 때 항등인 것까지 확인한다.
남은 실리콘 격차: `NCH2_SIBEAM_CLAMP_NCH` / `_sibeam_cap_bounds`(유효 채널 2개일 때의
si_beam cap 상한)와 F83 cap 보간(실리콘·supine 전용)은 아직 미포팅이다. 둘 다
좁은 조건에서만 발동하지만 실리콘 데이터가 생기면 대조해야 한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실리콘 R100/R200/R300 을 번갈아 쓰려면 한 기기를 끊고 다음을 잡아야 하는데
끊을 길이 없었다. 연결돼 있으면 버튼이 "연결 해제"로 바뀐다(병원 임상 모드 ·
정렬 화면 양쪽). disconnect() 가 자동 재연결도 취소하므로 방금 끊은 기기로
되돌아가지 않는다.
기기를 바꾸면 **정렬 결과를 지운다.** R100/R200/R300 은 빔 각도가 서로 달라
(0/-7/-14/-21 vs 5/-4/-12/-21 vs 0/-9/-18/-27) 같은 자리에서도 nch·cap_frac 이
달라진다 — 최적 부착 위치 자체가 다르므로 앞 기기의 cm 을 들고 가면 안 된다.
## dps 동기화 버그
`WdConfig.DPS_DEFAULT` 는 검출 함수들의 기본 인자인데, 이전 커밋에서 그 값을
`PiezoHW.distancePerSample` **getter 안에서만** 갱신하게 만들었다. 프리셋이 바뀐 뒤
아무도 그 프로퍼티를 읽지 않으면 옛 값이 남는다 — 실리콘으로 갈아 끼웠는데 검출은
abs 의 1.936 으로 도는 상황이 정확히 그것이다. activePreset setter 에서 동기화한다.
PresetDpsTest 신규: 프리셋 → dps·delay 표(v*=1.936/6.85, r*=1.897/7.651)와
WdConfig 동기화, 그리고 기기 이름 → 프리셋 매핑 6종을 검증한다.
testOptions.unitTests.isReturnDefaultValues=true — android.util.Log 스텁 때문에
순수 계산 시험이 죽던 것을 막는다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실측 세션(HUMAN-kai VBT26050202, v1) 2위치로 stage 대조한 결과 다섯 곳이
레퍼런스와 어긋나 있었다. 전부 고쳐 **nch · ch3 · ch3_rate · cap_frac 이 전 항목
일치**한다.
| # | 어긋난 곳 | 값 | 증상 |
|---|---|---|---|
| 1 | `d_max` | 10 → **15** | 후벽 탐색창 부족 |
| 2 | `inward_walk_win` | 3 → **4** | span(low_start/low_end) 이탈 |
| 3 | prominence 기준점 | 인접 골 → **span 바닥** | 전벽 오선택 (py 4 vs kt 13) |
| 4 | cap 축 붕괴 폴백 | **누락** → 구현 | BV −14.9 mL |
| 5 | dps · delay | 전역 상수 → **프리셋 유도** | 좌표 전체 이탈 |
1·2·5 는 레퍼런스가 갱신된 것을 포팅이 따라가지 못한 경우다. 3 은 SPEC 이
"특히 틀리기 쉬운 곳"으로 짚어 둔 항목(`ANT_FLOOR_REF`)이고, 4 는
`runners.estimate_bv` 의 `CAP_AXIS_RATIO_MAX` 블록이 통째로 빠져 있었다.
곁들여 바로잡은 것:
- `ELLIPSE_CAP_R_MAX` 0.92 적용 (종전 사실상 1.0)
- `TOP_CAP_EDGE_K` 0.6 (최상단 채널이 최광폭일 때 top cap 제한) — 누락돼 있었다
- top cap clamp 게이트를 `n < nTotalCh` → **center 검출 채널 ≥ 2** 로 (레퍼런스 변경 반영)
- `estimateBv` 의 조기 return 들이 cap 축 폴백을 건너뛰던 것 수정. 특히
`validChannels.size < 4` 때문에 채널 하나만 빠져도 폴백이 사라졌다.
## ⚠ dps 기본값이 바뀐다
종전 전역 기본 1.968 은 "300ml 팬텀이 1.936 에서 130~140ml 로 나온다"는 관찰로
되돌렸던 값인데, 프리셋이 정한 값을 전역 상수가 덮는 구조라 레퍼런스 대조가
불가능했다. 이제 v0/v1/v2 = 1.936 · 6.85, r1/r2/r3 = 1.897 · 7.651 로 프리셋에서
유도한다. 팬텀 시연에서 스케일이 달라 보이면 dev 패널에서 dps 를 직접 지정하면
된다(`PiezoHW.distancePerSample`, 해제는 `clearDpsOverride()`).
## 검증
- 전처리(light/heavy) max|Δ| = 0
- CCC-off 검출 12/12 채널 일치 (ant·post·refined·span)
- CCC-on 검출 12/12 채널 일치
- 정렬 지표(nch·ch3·ch3_rate·cap_frac) 2위치 전 항목 일치 — AnchorMeasureParityTest
의 @Ignore 를 떼어 상시 게이트로 전환
- BV 제품 경로 고정 walls 155.4257 mL 일치 — BvProductPathParityTest 신규
(cap 축 폴백이 빠지면 140.5 로 떨어져 여기서 잡힌다)
- 선택 규칙 200/200 은 그대로 유지
정렬 화면의 "검증 전" 경고를 걷어내고 매니페스트
`selection_verified_against_reference` 를 true 로 바꿨다. 원시 데이터 저장은
유지한다 — 재판정 여지를 남기는 것은 검증과 별개다.
남은 것: 이 다섯 가지는 신 저장소(vesiscan_pre_product)의 같은 코드에도 그대로
있다. 임상 끝나고 이관해야 한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
간호사가 자세·방광 단계를 하나씩 올려 가며 재는데, 중간에 자리를 비우거나
순서가 꼬이면 "0% 했던가?"를 기억으로 판단하게 된다는 현장 의견. 그러면 한
단계를 통째로 빠뜨리거나(방광을 다시 채워야 한다) 같은 조건을 두 번 재게 된다.
화면이 대신 기억하게 한다.
- 방광 단계 칩: 지금 고른 자세 안에서의 진행. 완료 ✓(초록) / 일부 ·(주황 테두리)
- 자세 칩: 그 자세의 6단계가 전부 끝나야 완료
- 실행이 끝날 때마다 다시 읽어 방금 잰 단계가 즉시 채워진다
판정 근거는 **파일 존재가 아니라 실행 매니페스트**다. CSV 는 첫 회차만 저장돼도
만들어지므로, 파일 유무로 보면 20회 중 2회에서 끊긴 것도 "완료"로 보인다. 그게
제일 위험하다 — 다 됐다고 믿고 방광을 비우는데 데이터는 못 쓰고 그 단계는 그날
다시 못 잰다. 매니페스트의 조합별 planned/saved 로 "6조합이 전부 계획한 횟수를
채웠는가"를 본다. 중단된 실행도 매니페스트가 남으므로 PARTIAL 로 잡힌다.
완료/일부를 색뿐 아니라 기호(✓ ·)로도 구분한다 — 병실 조명·화면 반사에서
색만으로는 잘 안 보인다.
검증: 실기기(VBT26080001 연결)에서 오늘 09:40 실측 데이터로 확인.
(Supine, 0%) → 0% 칩 ✓ · Supine 칩 · (6단계 중 1단계라 일부). 선택을 20% 로
옮기면 0% 가 초록 채움으로 남는 것까지 확인. 측정 경로는 건드리지 않았다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"기기를 먼저 연결해 주세요" 라고 안내만 하고 정작 가는 길이 없었다. 연결이 이 모드의
첫 단계인데, 조작자는 홈까지 되돌아가 일반 흐름을 타고 다시 들어와야 했다.
## 복귀처를 불리언이 아니라 값으로
기기 연결 화면은 여러 곳에서 불려 온다. 원래는 `ClinicalSessionStore.inClinicalFlow`
불리언 하나로 "임상 흐름이면 CLINICAL_HOME, 아니면 HOME" 만 갈랐다. 여기에 병원 모드용
불리언을 하나 더 만들면 그 순간부터 두 깃발이 어긋나기 시작한다.
그래서 `AppState.connectReturnScreen` 에 **어디로 돌아갈지를 값으로** 담는다. null 이면
기존 규칙 그대로라 **기존 호출부는 하나도 건드리지 않았다.** 한 번 쓰면 비운다
(consumeConnectReturn) — 안 비우면 다음에 다른 경로로 들어온 연결 화면까지 엉뚱한
곳으로 보낸다.
## 나가는 길이 셋이라 셋 다 손봤다
· 연결 성공 AppState.deviceConnected()
· 시스템 뒤로가기 MainActivity BackHandler
· 화면 안 화살표 AppState.backToSensorSelect()
세 번째에서 기존 문제도 드러났다 — 화면 안 화살표는 `inClinicalFlow` 를 보지 않고 늘
HOME 으로 간다. 시스템 뒤로가기와 목적지가 다르다. 기존부터 그랬고 지금 고치면 임상
흐름의 동작이 바뀌므로 그대로 두고 주석으로 남겼다. 복귀처를 지정한 화면만 두 경로가
같아진다.
## 검증 (실기기 23021RAA2Y)
홈 3연타 → 개발자 모드 → [병원 임상 측정] → [기기 연결] → 검색 화면 →
시스템 뒤로가기 / 화면 안 화살표 **둘 다** 병원 임상 화면으로 복귀. 크래시 0건.
연결 성공 경로는 프로브가 없어 아직 확인 못 했다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## 앞선 커밋이 세 군데 틀렸다
2026-09-02 펌웨어팀 스펙을 받아 바로잡는다.
1. **인자가 2개가 아니라 5개다.**
mcs? [tag 4B][freq 2B][cycles 2B][avg 2B][delay_us 2B][samples 2B][crc 2B] = 16B
avg·delay_us·samples 를 안 보내면 길이 부족으로 거부된다(freq=0xFFFF 응답).
2. **주파수 값이 1/2 가 아니라 0/5 다.**
0=1.8 · 1=1.9 · 2=2.0 · 3=2.1 · 4=2.2 · 5=2.3 MHz.
앞선 커밋의 가정(1.8→1, 2.3→2)은 둘 다 틀렸다 — 실제로는 2.0 과 2.3 을 재게 된다.
가정을 파일명에 남겨 둔 안전장치가 없었다면 못 알아챌 뻔했다.
3. **응답을 확인하지 않고 있었다.** 이게 가장 위험하다 — 아래 참고.
## 설정 실패는 조용하다 — 그래서 반드시 확인한다
실패해도 `rcs:` 는 온다. 구분은 freq 값이다:
· 0xFFFF — 파라미터 범위 초과 또는 데이터 길이 부족
· 0xFFFD — 검증은 통과했으나 NVS 저장 실패
거부돼도 프로브는 **옛 설정으로 측정을 계속한다.** 확인하지 않으면 파일에는 요청한
값이 적힌 채 다른 조건의 데이터가 쌓인다 — 잘못된 데이터가 정상처럼 보이는, 임상에서
가장 나쁜 결과다.
그래서 조합마다 echo 를 받아 **요청한 다섯 값과 전부 일치할 때만** 측정한다.
불일치·무응답이면 그 조합을 통째로 건너뛰고 run json 에 `skipped_reason` 을 남기며
화면에 빨갛게 띄운다. 비는 편이 틀린 것보다 낫다.
## 고정 파라미터
프로토콜이 바꾸는 것은 주파수·cycle 뿐이다. 나머지 셋은 모든 조합에서 같아야 비교가
성립하므로 `HospitalFixedParams` 한 곳에 둔다 — avg 10 · delay_us 10 · samples 100
(펌웨어팀 예시값, samples 는 앱 채널 버퍼 100 과도 일치).
1.8MHz·c3 6D 63 73 3F 00 00 00 03 00 0A 00 0A 00 64 3C 4A
2.3MHz·c7 6D 63 73 3F 00 05 00 07 00 0A 00 0A 00 64 36 FC
## ⚠ 설정이 프로브에 영구 저장된다 (NVS)
전원을 껐다 켜도 유지된다. 즉 이 모드로 측정하고 나면 **일반 측정 화면도 마지막
조합(2.3MHz·cycle 7)으로 동작한다.** run json 에 남기고 화면에도 명시했다.
임상 후 원래 값으로 되돌릴지는 별도 결정이 필요하다 — 공장 기본값을 모른다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## 확인된 사실 (2026-09-02 펌웨어팀)
송신 주파수·cycle 설정 명령은 **`mcs?`** 다. `mpa?` 는 piezo power ON 이다.
이 앱은 여태 `mpa?` 에 [freq, cycles] 를 실어 보내며 그것이 주파수 설정이라고
가정하고 있었다(PlacementGuideView 의 `sendPiezoPowerOn()`, MeasurementService 의
`sendBurst(freqOption = 2, ...)`). `mcs` 는 코드에도 docs/BLE_PROTOCOL_REFERENCE.md
에도 **한 글자도 없다**.
즉 **지금까지 앱은 주파수·cycle 을 한 번도 바꾼 적이 없다.** 프로브 기본값으로만
측정해 온 셈이고, 그 사실을 아무도 몰랐다. 병원 임상 모드를 만들며 명령을 되짚다
드러났다.
## 고침
`BleManager.sendPiezoConfig(freqOption, cycles)` 를 추가하고 병원 임상 모드가 조합
진입마다 이것을 부른다. `sendPiezoPowerOn`(mpa)은 기존 호출부가 있어 그대로 둔다 —
power ON 은 그 나름의 역할이 있을 수 있고, 확인 전에 건드리면 정렬 화면이 깨진다.
응답 태그는 `sendRaw` 가 m→r 로 바꿔 `rcs` 를 기다린다. 파서의 when 에 rcs 분기가
없어도 태그 처리가 when 밖에 있어 큐는 정상 해제된다(BleManager L1783).
## ⚠ 아직 확인 안 된 것 — 인자 구성
명령 **이름만** 확인됐다. 인자의 개수·순서·인코딩은 듣지 못해, 이 프로토콜의 다른
수치 명령과 같은 규약을 따른다고 **가정**했다:
mcs? = 6D 63 73 3F | freq(BE 2B) | cycles(BE 2B) | CRC16-CCITT(LE 2B) 총 10 B
(1,3) 6D 63 73 3F 00 01 00 03 42 64
(2,5) 6D 63 73 3F 00 02 00 05 D4 5D
값↔MHz 대응도 여전히 모른다. 파일에 실제 전송 정수를 남기는 안전장치는 그대로다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## 조작자가 하는 일은 하나다
간호사가 방광을 정해진 정도까지 채워 두면, 조작자는 **그 채움 정도만** 고르고
[측정 시작]을 누른다. 이후 주파수 2종 × cycle 3종 = 6조합을 앱이 자동으로 순회하며
각 조합마다 n회(기본 20) 측정하고 조합별 파일로 저장한다.
조합을 손으로 바꾸게 두면 반드시 빠뜨리거나 잘못 기록한다. 이미 채워 둔 방광은 다시
만들 수 없으니, 그 자리에서 놓친 조합은 그날 데이터에서 영영 빈다. 사람이 개입하는
지점을 하나로 줄인 것이 이 모드의 핵심이다.
진입: 홈에서 캐릭터 3연타 → 개발자 모드 → [병원 임상 측정]
## 프로토콜
자세 Supine / Sitting / Standing (기존 ClinicalPosture 재사용)
주파수 1.8 · 2.3 MHz → mpa? 첫 인자
cycle 3 · 5 · 7 → mpa? 둘째 인자
방광 채움 0/20/40/60/80/100 % → 사람이 아는 값 = 정답 라벨
반복 화면 입력 (기본 20)
## 저장
Downloads/VesiScan_Hospital/{날짜_환자}/
2026-09-03_홍길동_Supine_040pct_1.8MHz-fopt1_c3.csv
run_HHmmss.json ← 계획 대비 실제 저장 수
파일명에 조건을 전부 적는다. 폴더로만 구분하면 파일 하나를 옮기는 순간 조건을 잃는데,
임상 데이터는 나중에 다른 사람이 모아서 분석한다. CSV 열 구성은 기존 AdcCsvLogger 와
맞춰(scan_id·timestamp·channel·s0~s99) 분석 스크립트를 새로 만들지 않아도 되게 했다.
기존 임상 R&D(`VesiScan_Sessions/`)와 폴더를 나눴다 — 목적도 구조도 달라서 섞이면
나중에 파일을 하나씩 열어 봐야 한다.
## ⚠ 주파수 코드값이 확인 전이다
`mpa?` 첫 인자는 정수인데 그 정수와 MHz 의 대응이 코드에도
docs/BLE_PROTOCOL_REFERENCE.md 에도 없다. 기존 코드는 늘 `freqOption = 2` 만 썼다
(PlacementGuideView · MeasurementService). 그래서 1.8→1, 2.3→2 는 **가정**이다.
확인 전에 모은 데이터가 버려지지 않도록, 실제로 보낸 정수를 파일명(`fopt1`)과 CSV 열
(`freq_option`), run json 에 함께 남긴다. 대응이 반대로 밝혀져도 라벨만 바꾸면 된다.
run json 에 `freq_option_mapping_confirmed: false` 를 박아 두어 나중에 이 데이터가
어떤 상태에서 모였는지 알 수 있게 했다. 화면에도 같은 경고를 띄운다.
## 데이터 정합성
결과를 평범한 var 로 주고받으면 BLE 스레드↔코루틴 간 가시성이 보장되지 않아 Channel
을 쓴다. 그리고 **회차마다 보내기 전에 채널을 비운다** — 직전 회차가 시간초과된 뒤
뒤늦게 도착한 결과가 남아 있으면 이번 회차 데이터로 잘못 기록된다.
한 회차가 3초 안에 안 오면 실패로 세고 다음으로 넘어간다. 한 번 막혔다고 전체가
멈추면 채워 둔 방광을 버리게 된다. 실패 수는 화면과 run json 에 남는다.
## 검증
빌드 통과. **실기기 확인은 아직 못 했다** — 폰이 절전 상태로 들어가 화면을 못 띄웠고,
프로브 연결 상태의 측정 루프는 전혀 돌려보지 못했다. 내일 임상 전에 반드시 한 번
돌려봐야 한다(6조합 × 20회 = 120측정 · 약 2~3분 예상).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
이 브랜치는 동결 시연판이다. 이제 앱이 셋으로 늘어 한 폰에 같이 깔리므로, 이름과
applicationId 를 1:1 로 못 박아 무엇이 무엇인지 헷갈리지 않게 한다.
vesiscan-demo com.medithings.vesiscan.demo ← 이 브랜치
vesiscan-user com.medithings.vesiscan ← vesiscan_pre_product
vesiscan-caregiver com.medithings.vesiscan.caregiver ← vesiscan_pre_product
## 이름
"VesiScan-Basic Demo"(demo) · "VesiScan Dev"(dev) 를 각각 vesiscan-demo ·
vesiscan-demo-dev 로. 예전 이름은 **무슨 앱인지가 아니라 어떤 빌드인지**만 알려 줘서,
폰에 여러 개가 깔리면 구별할 수 없었다.
## applicationId — 이게 진짜 문제였다
base 가 `com.medithings.vesiscan` 이고 demo 플레이버가 `.demo` 를 붙이는 구조라,
**dev 플레이버는 접미가 없어 `com.medithings.vesiscan`** 이었다. 그 id 는 새 저장소의
환자 앱과 정확히 같다 — 이 브랜치에서 devDebug 를 한 번 빌드해 깔면 환자 앱이
말없이 덮어써진다. 이름을 아무리 잘 붙여도 막을 수 없는 종류의 사고다.
base 를 `com.medithings.vesiscan.demo` 로 옮기고(=demo 빌드의 id 는 그대로),
demo 는 접미 없음, dev 는 `.dev` 를 붙인다. 이제 어떤 플레이버를 빌드해도 다른 앱을
건드리지 않는다.
검증: aapt2 dump badging — package `com.medithings.vesiscan.demo` ·
label `vesiscan-demo` · versionName `2.0.0-demo`. assembleDemoDebug PASS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
같은 빌드가 태블릿 34시간 / Xiaomi 40분 이었던 문제 대응.
앱 구조(Foreground Service + connectedDevice + WakeLock)는 이미 맞았고
제조사 배터리 관리자가 프로세스를 죽인 것이라, 구조 변경이 아니라
감지와 안내로 접근한다.
- LongRunGuard: 측정 시작/생존/정상종료를 기록해 비정상 종료를 감지.
다음 실행 때 "언제부터 언제까지 몇 분" 을 알려준다. 제조사 무관하게 동작.
- BLE 로그 헤더에 기기/제한 상태 진단줄 (실패 후 원인 판별용).
- 설정 패널에 "장시간 측정 준비" — 배터리 최적화·미사용앱 제한 상태 확인 및
해제 진입. 자동시작은 상태를 읽을 수 없어 안내만 제공.
측정 시작을 모달로 막지 않는다. 초안에서는 Auto Scan 에 사전점검을 걸었는데
OEM_AUTOSTART 를 제조사 이름만으로 판단해서(상태 조회 API 가 없음) 설정을
완벽히 해둔 기기에도 영원히 뜨는 문제가 있었다. 고칠 수도 없는 항목으로
측정을 가로막으면 경고가 무시된다. 추측으로 경고하지 않고, 실제로 중단됐을
때만 알린다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>