Commit Graph

460 Commits

Author SHA1 Message Date
dw.jang 14e4b33072 fix(ble): 폴링 건너뛰기를 파일 로그로 · 광고 없음 문구를 실제 원인에 맞게
2026-09-11 실기기 시험(IM-H091)에서 둘이 드러났다.

## ① 건너뛰기가 파일 로그에 안 남았다

`logd` 로 남겨서 logcat 에만 갔다. 현장에서는 `Download/VesiScan_BLE_*.log` 파일만 받아
보므로, 그러면 **"왜 배터리가 안 갱신되나"를 파일로 답할 수 없다.** `debugLogger.info` 로
올렸다 — `BATT_POLL_SKIP`. 최대 30초에 한 줄이라 부담이 없다.

## ② 문구가 틀린 원인을 가리켰다

시험 로그가 이랬다:

    09:11:11.980  DISCONNECTED
    09:11:12.863  ADVERT_WAIT        0.88초 뒤 연결 누름 — 광고를 못 봐서 대기
    09:11:18.867  ADVERT_TIMEOUT     6초 기다려도 없음
    09:11:26.988  ADVERT_TIMEOUT     또 없음
    09:11:28.967  ADVERT_FOUND       16초 만에 광고 재개 → 연결 성공

**프로브가 빠른 연결/해제 반복 뒤 약 16초간 광고를 멈췄다.** 가드는 정확히 작동했다 —
연결을 시도하지 않아 반쪽 연결도, 무한 스피너도, 본드 비대칭도 없었다(HALF_CONNECTED ·
CONNECT_TIMEOUT · stale GATT 전부 안 찍힘).

그런데 문구가 "전원이 켜져 있는지 확인" 이었다. 기기는 켜져 있었다 — 광고를 아직 안
시작한 것이다. 사용자를 엉뚱한 곳으로 보낸다. 실제 원인과 할 일을 적었다:

    기기 신호가 잡히지 않습니다. 방금 연결을 끊었다면 기기가 다시 신호를 보낼 때까지
    10~20초 걸릴 수 있습니다 — 잠시 뒤 다시 눌러 주세요. 계속 안 되면 전원을 확인하세요.

## 펌웨어 쪽에 남길 것

광고 재개가 16초 걸린 것은 **펌웨어 동작**이다. 빠른 연결/해제를 7회 반복한 뒤였다. 앱은
기다리고 다시 시도하면 되지만, 그 지연 자체는 펌웨어팀이 볼 문제다.

demo-final 143 · user 79 · caregiver 33 테스트 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:17:20 +09:00
dw.jang ef221a81c6 fix(ble): 배터리 폴링을 **조용할 때만** — 측정 스트림에 끼어들지 않게
`msn?` 을 30초마다 보내고 있었다. 원래 목적은 **링크를 살려 두는 것**이었는데, 자동
측정(500ms 주기 `mtb`)이나 배터리 소모 시험(1Hz `mbb`)처럼 계속 통신하는 동안에는 링크가
그 트래픽으로 이미 살아 있다 — 보낼 이유가 없는데 측정 스트림 사이에 끼어든다.

## 기존 가드로는 부족했다

`isMtbBusy` 는 **한 스트림이 흐르는 동안**만 막는다(`startMultiChannel` ~ `isComplete`).
자동 측정은 cycle 사이에 완료 구간이 생기므로 그 틈에 `msn?` 이 나가고, 그 응답(`rsn`)이
다음 `mtb` 스트림과 겹친다. 끼어들면 펌웨어 GATT 큐가 꼬여 응답이 실종되고 freeze 로
이어진다 — 2026-07-08 주석이 그 증상을 적고 있다.

## TX 시각을 보고 판단한다

전송 관문(demo-final `sendRawWrite` · user `sendRaw`)에서 `lastTxAtMs` 를 남기고, 폴링은
직전 TX 로부터 5초가 지났을 때만 보낸다. 5초는 자동 측정 주기(500ms)와 배터리 시험
주기(1초)보다 충분히 길어 **측정 중에는 한 번도 나가지 않는다.** 쉬고 있으면 직전 TX 가
30초 전(지난 폴링)이라 정상적으로 나간다.

첫 응답 재시도(3초 간격 2회)에도 같은 기준을 걸었다 — 연결 직후 바로 측정을 시작하는
흐름이 있어(배터리 시험) 그 재시도가 스트림에 끼어들 수 있다.

## 부작용을 막았다 — rbb 에서 배터리를 읽는다

폴링을 멈추면 화면의 배터리 표시가 멈춘다. 그런데 **`mbb` 응답 헤더(`rbb`)에는 배터리가
이미 실려 온다** — 앱이 그걸 안 읽고 있었다. 읽게 했더니 배터리 소모 시험(1Hz `mbb`,
50시간) 내내 표시가 살아 있고 **추가 통신은 0**이다. 원래는 배터리가 그 시험의 측정값인데
화면이 멈추는 모양새였다.

변환식과 단조 감소 가드를 `applyBatteryMv()` 한 곳으로 모았다. 따로 구현하면 같은 전압이
경로에 따라 다른 %로 보인다. 단조 가드를 그대로 둔 이유는 부하가 걸리면 전압이 일시적으로
떨어졌다 회복하는데, 그때마다 %가 오르내리면 사용자가 배터리가 늘었다고 읽기 때문이다.

## 남는 한 가지

`mtb`(자동 측정) 응답에는 배터리가 없어 **자동 측정 중에는 표시가 멈춘다.** 피할 수 없고,
원래 의도(측정 중에는 끼어들지 않는다)의 대가다. 끝나면 다음 tick 에 갱신된다.

보호자 앱에는 BLE 가 없어(아이콘 import 뿐) 대상이 아니다.

demo-final 143 · user 79 · caregiver 33 테스트 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:45:17 +09:00
dw.jang 44460d5149 fix(ble): 광고를 확인하고 연결한다 — 반쪽 연결을 원인에서 막는다
앞 커밋은 반쪽 연결을 **복구**했다. 이건 **생기지 않게** 한다.

## 전제가 틀렸다

`connectByAddress` 는 바로 connectGatt 했다. 주석이 "스캔 결과에 안 잡혀도 OS 본드가 있으면
바로 됨"이라고 적고 있었는데, **BLE 는 상대가 광고(connectable)하지 않으면 연결이 성립하지
않는다.** 광고 전/직후에 [저장된 기기] 를 누르면 링크만 붙고 서비스가 안 올라온다 —
현장 재연 ③의 "광고를 하기 전 또는 직후, 휴대폰이 인지하지 못한 상황" 이 정확히 그것이다.

## 방금 봤으면 기다리지 않는다

주소별로 광고를 마지막에 본 시각을 들고 있다가(`lastSeenAtMs`), 5초 안이면 바로 연결한다.
기기 목록 화면은 이미 스캔 중이라 **대부분 이 경로**다 — 평소 연결이 느려지지 않는다.
매번 스캔을 돌리면 안드로이드 스캔 횟수 제한(30초에 5회)에 걸린다.

못 봤으면 그 주소만 필터로 걸어 최대 6초 기다린다. 광고가 오면 그 ScanResult 의 device 로
연결하고, 안 오면 "기기를 찾을 수 없습니다. 전원이 켜져 있는지 확인한 뒤 다시 눌러
주세요." 로 끝낸다 — **연결을 시도하지 않으므로 반쪽 연결이 애초에 안 생긴다.**

기록은 **이름 검사보다 먼저** 한다. 광고에 이름이 안 실린 패킷도 "지금 광고 중"이라는
증거다 — 놓치면 멀쩡히 광고하는 기기를 6초 기다린다.

## 확인이 불가능할 때는 막지 않는다

스캐너가 없거나 스캔이 실패하면(권한·횟수 제한) 확인을 포기하고 그냥 시도한다. 여기서
멈추면 연결할 길이 아예 없어진다. 그 경우는 앞 커밋의 반쪽 연결 가드(20초)가 받는다.

## unbondSmart 타임아웃 5초 → 14초

그 함수는 `connectByAddress` 로 재연결한 뒤 msr? 를 보낸다. 광고 대기가 최대 6초라 5초면
스캔 단계에서 잘려 **항상 "기기 오프라인"** 으로 떨어진다.

테스트 143개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 15:34:14 +09:00
dw.jang f676bd7c8a fix(ble): 반쪽 연결 — "연결됨"이라 말하고 아무 데이터도 안 오던 것
현장 재연(2026-09-10) 10단계를 받아 원인 사슬을 끝까지 따라갔다. 결론은 **반쪽 연결**
(링크는 붙었는데 서비스 탐색·CCCD 미완)이고, 마지막 단계가 제일 나쁘다 — **기기를 15초
길게 눌러 물리적으로 초기화해야만** 복구됐다.

    ③ 광고 직전/직후에 [저장된 기기] → 링크만 붙고 txCharacteristic = null
    ④ isServiceReady 가 안 와서 무한 스피너
    ⑤⑥ 화면은 isConnected 만 봐서 "연결됨"
    ⑦ txCharacteristic null → sendRawWrite 가 조용히 return → 배터리·IMU 전무
    ⑧-2 [페어링 삭제] → msr? 가 **안 나갔는데** removeBond() 는 실행
         폰: 본드 삭제 ✓   프로브: 본드 그대로 ✗   ← 비대칭
    ⑨ 재연결 시 프로브가 옛 LTK 를 요구 → "PIN/passkey 가 올바르지 않다"
    ⑩ 기기 15초 길게 눌러 초기화해야 복구

## 고친 것 넷

**① 보낼 수 없으면 본드를 지우지 않는다** (⑧-2 → ⑨⑩ 차단)

`canSendCommands`(= isServiceReady && tx != null && gatt != null)를 만들어
`disconnectAndUnbond()` 맨 앞에서 본다. false 면 **아무것도 지우지 않고** 끊고, 기기
초기화를 안내한다. 한쪽만 지운 상태보다 양쪽 다 남은 상태가 훨씬 낫다 — 후자는 그냥 다시
연결하면 된다.

설정탭·임상의 [페어링 삭제] 가 이 함수를 **직접** 부르고 있었다(unbondSmart 만
isServiceReady 를 확인했다). 그래서 방어를 함수 안에 뒀다.

**② "연결됨"의 뜻을 바꿨다** (⑤⑥)

`AppState.isDeviceConnected` 가 `isConnected` → **`isServiceReady`** 를 본다.
"붙었다"가 아니라 **"쓸 수 있다"** 가 사용자에게 의미 있는 상태다.

**③ 반쪽 연결 자가 복구** (④⑤)

링크가 붙은 시점부터 20초 상한을 건다(`armHalfConnectedGuard`). 못 넘기면 끊고, 사용자가
끊은 게 아니면 재연결을 잇는다. connect() 의 타임아웃과 중복이 아니다 — 그쪽은 사용자가
시작한 경로만 덮고, 이쪽은 자동 재연결·autoConnect 로 들어온 연결까지 덮는다. 실측 최악이
15초라 20초로 잡았다.

**④ 쓰기 실패가 보이게** `sendRawWrite` 가 Boolean 을 돌려주고, 실패하면
`TX_FAIL ...` 를 로그에 남긴다. 종전에는 logd 만 찍고 조용했다.

문구는 en/ko 둘 다. `HALF_CONNECTED` 는 "다시 연결합니다",
`UNBOND_UNREACHABLE` 은 15초 길게 누르기까지 구체적으로 안내한다.

테스트 143개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 14:45:22 +09:00
dw.jang b0351342de fix(ble): 옛 연결의 콜백이 새 연결 상태를 덮어쓰고 있었다
연결 해제 후 **빠르게** [연결] 을 누르면 연결이 안 됐다. 병원 임상·정렬·설정탭의
[연결 해제] 는 전부 같은 `disconnect()` 이고, 문제는 그 뒤에 있었다.

## 원인 — gattCallback 이 주인을 확인하지 않았다

`gattCallback` 은 객체 하나를 모든 연결이 공유한다. `gatt.disconnect()` 는 비동기라 옛
연결의 콜백이 **새 연결이 들어선 뒤에** 도착한다:

    ① [연결 해제]  gatt.disconnect()                    ← 콜백은 몇 초 뒤
    ② 바로 [연결]  옛 gatt close + bluetoothGatt = 새 GATT
    ③ 옛 GATT 의 STATE_DISCONNECTED 도착 →
                   bluetoothGatt = null                  ← 새 참조를 지운다
                   isConnected.value = false

③ 이후 새 GATT 가 STATE_CONNECTED 를 받아도 그 분기는 `bluetoothGatt` 를 대입하지 않아
**isConnected=true 인데 bluetoothGatt 가 null** 이 된다. sendRaw 가 그 필드를 쓰므로
명령이 하나도 안 나간다. 반대로 isConnected 가 false 로 덮이면 화면만 "연결 안 됨".

늦게 온 **알림(onCharacteristicChanged)** 이 섞이면 더 나쁘다 — 다른 기기의 파형이
화면에 뜬다.

## 고친 것 셋

**① 주인 확인** `isStaleGatt()` 를 만들어 콜백 일곱 개 전부에 걸었다
(onConnectionStateChange · onMtuChanged · onServicesDiscovered · onDescriptorWrite ·
onCharacteristicChanged ×2 · onReadRemoteRssi). 주인이 아니면 **닫고 버린다** — 안 닫으면
그 핸들이 누수다. onConnectionStateChange 는 `handler.post` 안에서 **한 번 더** 본다:
post 사이에 새 연결이 들어설 수 있다.

`bluetoothGatt == null` 이면 통과시킨다. 연결을 막 만들어 대입 전인 구간이 있고, 그때
막으면 STATE_CONNECTED 를 놓쳐 영원히 연결되지 않는다.

**② STATE_CONNECTED 에서 `bluetoothGatt = gatt`** 뒷북이 지워도 여기서 다시 잡힌다.

**③ 재연결 쿨다운 600ms** close() 는 핸들만 돌려주고 컨트롤러의 링크 정리는 조금 뒤에
끝난다. 그 틈에 다시 열면 0x3E(status 62)나 반쪽 연결이 된다. 마지막 disconnect 로부터
충분히 지났으면 기다리지 않는다 — 평소 연결이 느려지면 안 된다.

예약은 하나만 둔다(두 번 열리면 하나가 고아가 된다). [연결 해제] 는 예약을 **취소**한다 —
안 그러면 끊은 뒤 600ms 만에 스스로 다시 연결된다.

테스트 143개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 14:29:42 +09:00
dw.jang 338ae7a19d fix(ble): 연결 타임아웃이 isConnected 를 봐서 무한 로딩이 났다
연결 → 연결 해제 → 메인 → [기기 연결] → [저장된 기기] 를 누르면 스피너가 영원히 돌고,
다른 기기 행까지 전부 비활성이 됐다(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>
2026-09-10 13:48:30 +09:00
dw.jang 1ad1da229b fix(clinical): 수집 중단 버튼이 옛 판정 자리로 보내고 있었다
판정을 전 위치로 바꾼 뒤에도 [여기까지만 재고...] 버튼이 guide.bestCm(단일 pass 답)으로
이동했다. 앱이 쓰는 자리와 다른 곳으로 보내면 그 자리에서 확인 측정을 해도 confirmDone
이 안 되고, 간호사는 왜 안 넘어가는지 알 수 없다.

지금까지 잰 위치 전부로 판정한 자리로 간다. forceFinish 는 남겼다 — 판정에는 안 쓰지만
종료가 안 걸린 세션의 best_cm_single_pass 가 비지 않게 해야 감사가 된다.

라벨도 하나로 합쳤다. guide.settled 로 문구를 갈랐는데 그 구분이 이제 판정과 무관하다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 12:24:43 +09:00
dw.jang c01da3512e 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>
2026-09-10 12:21:59 +09:00
dw.jang b71a04374f feat(clinical): 전수 판정을 같이 계산해 기록 · 확인 측정이 판정 데이터를 덮지 않게
알고리즘 설계자가 물었다 — "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>
2026-09-10 12:05:16 +09:00
dw.jang 2ec874d7f8 docs: 병원 임상 알고리즘 상세 — BV · 그래프 필터 · 정렬 판정 · 벽 검출
docs/CLINICAL_ALGORITHM.md 신규. 네 항목을 코드에서 읽어 정리했고 각 주장에 파일·라인을
달았다.

## ① BV 계산
샘플→mm 변환(프리셋별 dps·delay) → 각도 보정 단면 지름 → 타원 단면적(lrRatio) →
y-z 타원 피팅 → 절두원뿔 적분 → 위/아래 모자. 공식과 상수를 그대로 적었다.

## ② 그래프 필터 — 제일 많이 오해할 지점
**화면 파형은 원신호다.** SG도 TGC도 안 들어간다(y축 0~4095 고정). 세로선만 SG·TGC·CCC 를
거친 결과다. 그래서 선이 봉우리에서 살짝 비켜 있는 것이 정상인데, 모르면 검출이 틀린
것으로 읽는다. SG(7,3) heavy/light 를 왜 둘 쓰는지, TGC 의 ratio 식도 적었다.

## ③ 정렬 판정
지표 넷(nch·ch3·ch3_rate·cap_frac)이 왜 CCC on/off 로 갈리는지, 종료 조건 셋의 우선순위,
3단 선택 규칙, 부착=best+1 을 왜 단순 덧셈으로 두는지(스냅은 회고 분석 전용),
2026-09-10 전수 측정으로 바꿨어도 판정이 안 바뀌는 이유.

## ④ 벽 검출
"벽보다 오줌을 먼저 찾는다"는 발상부터 5단계. Otsu 구간, 후보 점수식, 어깨 페널티가
데드밴드인 이유, 게이트 넷, 레퍼런스 경로의 CCC 단계가 기존과 다른 점.

## 문서에 꼭 넣은 세 가지 함정
  · 화면 세로선은 정수 ant/post, BV는 소수 antRefined/postRefined — 손으로 검산하면
    미세하게 안 맞는다
  · 프리셋이 틀리면 BV가 통째로 틀린다(실측 125 ↔ 490 mL)
  · 좌우 미검출이면 lrRatio=1.0, 즉 방광을 원으로 놓고 계산한다

마지막에 상수 표 한 장과 "기억할 것 다섯", 레퍼런스 대조 테스트 목록을 붙였다.
인용한 테스트 7개와 값(155.4257 mL, anchor_cases 200건)은 파일로 확인했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 11:14:24 +09:00
dw.jang af16541221 feat(clinical): 정렬에서 0~4cm 를 전부 재고, 판정은 레퍼런스 그대로 둔다
병원 임상은 위치를 고르는 것만이 목적이 아니라 **위치별 원신호를 모으는 것**도 목적이다.
그런데 조기 종료하면 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>
2026-09-10 10:53:35 +09:00
dw.jang 8167b5536d fix(clinical): 40mm 이하는 2.3MHz · 동작 버튼을 파형 위로 · 부착 위치 문구
## ① 복부 두께 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>
2026-09-10 09:58:36 +09:00
dw.jang f04bcf63f0 fix(clinical): 정렬 BV 에 supine 을 넘긴다 — 두 화면의 BV 가 갈리고 있었다
정렬 화면이 `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 09:24:42 +09:00
dw.jang 60d36a8206 fix(clinical): 측정 버튼에 재는 자리를 다시 적는다
"여기서 측정"은 **"여기"가 어디인지 알 수 없다**는 피드백이 왔다(2026-09-10). 앞 커밋에서
절대 cm 을 화면에서 걷어내며 버튼까지 같이 지웠는데, 카드가 동작만 말하고 버튼도
동작만 말하니 "지금 프로브가 몇 cm 에 있는지"를 확인할 데가 없어졌다.

카드와 버튼이 역할을 나눈다:

    카드(동작)                      버튼(자리)
    치골 바로 위에 붙이고 측정        [0cm 측정하기]
    1cm 올리고 측정                  [1cm 측정하기]
    2cm 내리고 측정                  [1cm 측정하기]
    여기서 한 번 더 측정              [1cm 측정하기]
    1cm 올리세요 (재지 말고 좌우로)    — 버튼 없음

카드는 **어떻게 움직일지**, 버튼은 **움직인 뒤 어디를 재는지**다. 둘 다 숫자를 쓰면
서로를 확인해 준다 — 카드대로 움직였으면 버튼의 숫자가 지금 자리와 맞는다. 어긋나면
사람이 그 자리에서 알아챈다.

원래 헷갈림의 원인은 버튼의 숫자가 아니었다. "최적 위치 2cm"과 "부착 위치 3cm"이 둘 다
"위치"라는 이름으로 한 화면에 있던 것이다. 그건 앞 커밋에서 해결됐으니 버튼의 자리
표시는 되살리는 것이 맞다.

테스트 129개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:13:54 +09:00
dw.jang a0b07def9a fix(clinical): 연결이 끊기면 기기에서 온 값을 비운다
연결 해제를 눌러도 앞 프로브의 값이 화면에 그대로 남아 있었다.

    방광 용적 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>
2026-09-10 09:09:59 +09:00
dw.jang 8b3b5c0f0a feat(labdb): 정렬(601)도 자동 업로드 — 화면을 떠나는 순간
정렬 업로드만 수동이었다. 그 버튼의 원래 용도가 **"파형이 이상할 때 개발자에게 보내기"**
(메모를 받는 다이얼로그)라, 정상적으로 정렬을 끝내면 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 18:14:02 +09:00
dw.jang 01c06c7848 fix(clinical): 정렬 화면에서 위치 이름을 없애고 지시문 하나만
시험하는 분이 "최적 위치"와 "부착 위치"를 계속 헷갈렸다(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 18:05:46 +09:00
dw.jang 1c979fc26b fix(labdb): 병원 임상 화면에 등록 카드 · 임상 세션에 최상위 subject
## 왜: 실기기에서 데이터가 조용히 안 올라갔다

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>
2026-09-09 17:30:24 +09:00
dw.jang 7926b61667 feat(clinical): 수동 확인을 폰에 기록 — CSV(분석) + JSON(보관)
구분선 아래 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>
2026-09-09 15:59:22 +09:00
dw.jang d401740210 feat(clinical): 병원 임상 화면 재구성 · 조합은 복부 두께로 · 레퍼런스 고정
## 화면을 두 덩어리로 나눴다

    환자명 → 부착 위치 정렬 → 자세 → 방광 채움 → 반복 횟수 → 복부 두께 →
    [측정 시작] → 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>
2026-09-09 15:33:07 +09:00
dw.jang f5afce6d17 feat(clinical): 부착 위치가 확정되면 카드를 통째로 초록으로
탐색 중에는 흰 카드 안의 숫자만 바뀐다. 확정도 같은 흰 카드에 글자색만 달랐으니
"또 한 번 바뀐 숫자"로 읽힌다. 그런데 여기가 이 화면의 유일한 전환점이다 — 이 뒤로는
프로브를 옮기고 **더 이상 재지 않는다.** 면 색이 바뀌어야 프로브를 잡은 채 곁눈으로
봐도 걸린다.

확정(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>
2026-09-09 15:17:19 +09:00
dw.jang d66b69506f feat(clinical): 정렬이 안 끝나도 임상 측정으로 넘어갈 수 있게
실제 인체에서 정렬 로직이 끝까지 안 맞을 수 있다. 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>
2026-09-09 13:55:52 +09:00
dw.jang 2853127ff5 docs(clinical): 1회 측정은 메인의 [단일 측정]과 다르다고 분명히
앞 커밋에서 "메인 화면과 같은 방식"이라고 썼는데 절반만 맞았다. 메인의 [단일 측정]은
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>
2026-09-09 12:26:09 +09:00
dw.jang 055f24349c fix(clinical): BV 측정을 메인 화면의 단일·자동 측정과 같은 방식으로
임상 화면의 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>
2026-09-09 12:16:46 +09:00
dw.jang 2770d0296a feat(labdb): dataType 600·601 배정 반영 · subject 를 최상위로
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>
2026-09-09 10:41:04 +09:00
dw.jang e17391ca9f feat(clinical): 병원 임상만 최종 알고리즘으로 · 전역은 건드리지 않는다
병원 임상 화면은 최종 보고본(레퍼런스)이 기본이어야 하는데, 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>
2026-09-09 10:14:52 +09:00
dw.jang 21b0fcaed8 feat(clinical): BV 에 알고리즘 경로를 붙이고 레퍼런스를 기본으로
검증 목적이 "최종 보고된 알고리즘"인데 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>
2026-09-09 09:44:39 +09:00
dw.jang e9f5601371 feat(clinical): 임상 화면에 BV · 채널별 전후벽 · Spot/Continuous 측정
알고리즘은 이미 이식돼 있었다(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>
2026-09-09 09:36:22 +09:00
dw.jang afeb3acc57 fix(labdb): 902 가 정렬 요약을 통째로 보내게 한다
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>
2026-09-08 14:12:17 +09:00
dw.jang 287296480c docs(labdb): dataType 901·902 등록 요청서
labdb 서버에 새 dataType 두 개를 등록해야 한다. labdb.md §2-3 이 요구하는 항목
(코드 · 데이터 의미 · records[] 스키마 · 시각화 요구사항)을 그대로 채웠다.

지금은 사용자 정의 구간(900~999)에 임시로 올리고 있다. 901 로는 2026-09-05 수동
업로드분 279건이 이미 쌓여 있어, 코드를 바꾸면 마이그레이션이 따라야 한다는 점도
적었다.

스키마는 각 payload 파일 KDoc 에도 있지만, 서버 쪽에 들고 갈 때 코드를 열게 할 수는
없어 문서로 뽑았다. 두 곳이 갈라지지 않도록 정의 파일 경로를 문서 머리에 적어 뒀다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:04:11 +09:00
dw.jang 3d8168a0f5 feat(clinical): 정렬 파형을 간호사 판단으로 labdb 에 보낸다
파형이 이상해 보일 때 그 자리에서 개발자에게 보내는 통로가 없었다. 임상이 끝난 뒤
파일을 꺼내 보내면 회신이 하루 뒤인데, 그때는 이미 그 환자로 다시 잴 수 없다.

정렬 전용 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>
2026-09-08 13:57:11 +09:00
dw.jang 8bf7f54b72 feat(clinical): 병원 임상 측정을 labdb 로 자동 업로드
병원 임상 모드에는 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>
2026-09-08 13:50:27 +09:00
dw.jang 38c08f2ddf docs(detach): 원본과 값이 다른 이유를 파일에 남긴다
헤더가 "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>
2026-09-08 13:44:14 +09:00
dw.jang 965714306d feat(clinical): 채널별 접촉 상태를 글자로 표시
임상 화면에는 미부착 판정이 **아예 없었다**. 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>
2026-09-08 13:38:05 +09:00
dw.jang b1975408e3 fix(clinical): 좌우 정렬을 부착 위치에서 하도록 안내
좌우 단계를 붙이면서 순서를 화면이 말해 주지 않았다.

1단계가 끝난 시점에 프로브는 **최적 위치**에 있고, 실제로 부착할 자리는 그보다 1cm
위다(오프셋 자리는 측정하지 않는다 — 설계상 지표가 나빠 재확인하면 반드시 미달이 뜬다).
그런데 좌우 카드가 그 사이에 "옮기세요" 없이 바로 나왔다.

좌우 균형은 그 높이의 단면에서 정해지는 값이라 높이를 바꾸면 u4·u5 가 달라진다. 옮기기
전에 맞추면 헛일이 되는데, 화면이 순서를 말하지 않으면 간호사마다 갈린다 — 맞춘 뒤
올리는 사람과 올린 뒤 맞추는 사람이 생기고, 둘의 데이터가 다른 조건이 된다.

카드 상단에 부착 위치를 명시하고, 시작 버튼 문구에도 cm 을 박았다("3cm 에서 좌우 정렬
시작"). 누르는 순간에도 어느 높이인지 눈에 들어와야 위 안내를 지나친 경우를 잡는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:15:36 +09:00
dw.jang bd7fb05c6e feat(clinical): 정렬에 좌우(LR) 단계 추가
임상 정렬(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>
2026-09-08 12:11:23 +09:00
dw.jang cc950184cd feat(dev): 배터리 부하 테스트 실측을 CSV 로 남긴다
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>
2026-09-08 09:30:54 +09:00
dw.jang 2e9b9b204c feat(clinical): 측정 조합 선택 · 파형 표시 · 프로브 설정 확인·복원
조합 선택 — 요청받은 주파수·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>
2026-09-07 15:51:05 +09:00
dw.jang c7cddbf9c9 fix(clinical): 조건 완료 판정을 실행 단위 → 조합 단위 합산으로
화면에서 6조합 중 일부만 골라 돌릴 수 있게 되면서(다음 커밋) 판정이 깨진다. 종전에는
실행 하나만 보고 "6조합이 다 있나"를 따졌는데, 나눠 재면 어느 실행도 6개를 못 채워
**영원히 PARTIAL** 로 남는다. 그러면 "덜 됐다"는 표시가 의미를 잃는다.

같은 자세·충만도의 여러 실행을 조합 단위로 합산한다. 조합 하나가 여러 실행에 나오면
한 번이라도 계획을 채운 실행이 있으면 그 조합은 끝난 것으로 본다 — 20회 중 3회에서
끊긴 뒤 다시 20회를 채웠다면 완료다.

판정 전부를 progressFrom(manifests) 순수 함수로 떼어 시험으로 고정했다. 이 판정이
틀리면 간호사가 다 됐다고 믿고 방광을 비우는데 데이터는 못 쓰고, 그 단계는 그날 다시
못 잰다. 실기기 없이도 고정해 둬야 한다.

  나눠 재기 · 재측정 · 계획 미달 · planned 없는 옛 매니페스트 · 깨진 JSON ·
  조건 간 혼선 8건.

org.json 은 안드로이드 단위테스트에서 모든 메서드가 스텁이라(isReturnDefaultValues
로도 안 된다 — 반환값이 아니라 동작이 없다) 실제 구현을 테스트 의존성으로 넣었다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:50:29 +09:00
dw.jang 33e5946895 feat(ble): 프로브 측정 파라미터 읽기(mcf?) · 임상 전후 보존 도구
지금까지 프로브에 무엇이 저장돼 있는지 **알 방법이 없었다.** `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>
2026-09-07 15:49:53 +09:00
dw.jang 49975b7792 refactor(ui): 6채널 파형을 공용 컴포넌트로
`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>
2026-09-07 15:48:09 +09:00
dw.jang d97b02bbee docs(algo): 오타·갱신된 수치 정정
'abs(vr\*)' → 'abs(v\*)', 토글 대조 수치 40 → 39 (좌표계 분기가 들어가며
한 cycle 이 기존 경로와 같아졌다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:09:47 +09:00
dw.jang 441f249410 feat(algo): 미이식 3건 마저 이식 — post_tie_lock · sibeam · F83
레퍼런스에서 남겨 뒀던 세 가지를 옮겼다. 셋 다 레퍼런스 경로 전용이고 기존(현행
임상) 경로는 그대로다.

  · 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>
2026-09-03 18:09:26 +09:00
dw.jang ccb44f0ea6 feat(algo): 레퍼런스 경로를 dev 스위치로 · 기본은 현행 유지
신 저장소에 맞춘 레퍼런스(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>
2026-09-03 17:31:58 +09:00
dw.jang c7f9cc976e fix(clinical): 병원 임상 모드 뒤로가기가 임상 R&D 모드로 튀던 것
화면 안 뒤로가기 화살표가 CLINICAL_HOME 으로 보내고 있었다. 이 화면의 진입점은
**시작 화면(HOME)** 의 "병원 임상 측정" 버튼이라, 누른 적도 없는 다른 모드로
빠져나갔다. 둘은 목적도 저장 경로도 다르다(임상 R&D = VesiScan_Sessions,
병원 임상 = VesiScan_Hospital).

하드웨어 뒤로가기는 else 로 흘러 이미 HOME 이었지만, 헷갈리기 쉬운 자리라
BackHandler 에도 명시했다. 측정 중 나가면 루프가 취소되고 finally 가 그때까지의
결과를 매니페스트에 남긴다 — 진행 표시에서 '일부'로 보인다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:41:43 +09:00
dw.jang a58c927066 feat(clinical): 프로브 파라미터 직접 설정 (mcs 수동 주입)
주파수(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>
2026-09-03 12:23:46 +09:00
dw.jang 7d44b22e96 fix(clinical): 이름 없이 정렬해도 데이터를 잃지 않게
정렬 화면에는 환자명 입력란이 없었다(병원 임상 모드에만 있었다). 정렬부터 시작하면
이름이 빈 채로 재게 되는데, 저장이 `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>
2026-09-03 12:03:32 +09:00
dw.jang 20a48ade96 fix(algo): 실리콘 전용 cap 억제(cap_d_adaptive) 구현
레퍼런스 `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>
2026-09-03 11:51:26 +09:00
dw.jang eb2a0b19e7 feat(clinical): 연결 해제 버튼 · 프리셋 전환 시 dps 즉시 동기화
실리콘 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>
2026-09-03 11:38:24 +09:00
dw.jang ebddc52efb fix(algo): 검출·BV 를 piezo-phantom-test 레퍼런스와 일치시킴
실측 세션(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>
2026-09-03 11:23:41 +09:00