## 계기 — 그리고 정정
2026-09-28 A34 에서 0cm 정렬 뒤 `align_0cm_imu.csv` 가 없다고 판단해 원인을 쫓았다.
BLE 로그·parseRim 로그로 IMU 가 20/20 도착한 것까지 확인한 뒤 "사이드카 콜백이
덮였다"는 가설로 진단 로그를 넣어 재설치했다. 그런데 파일은 **처음부터 있었다** —
0cm·1cm 모두 09:49/09:51 에 20 cycle × 15 sample 로 써졌다. `adb shell` 이 보는
`/sdcard`(FUSE · MediaProvider 뷰)가 앱이 방금 쓴 파일을 한동안 안 보여 준 것이고,
`find -type f` 는 한 파일만, `find -mmin` 은 아무것도 돌려주지 않았다. 진단은
불필요했다. 코드에 결함은 없었다.
## 그래도 남기는 것
- `restore()` 방어: 지금 걸린 콜백이 **자기 것일 때만** 되돌린다. LaunchedEffect 가
재시작하면 옛 인스턴스의 restore 가 새 인스턴스의 install 뒤에 실행될 수 있고
(취소된 코루틴의 finally 는 나중에 돈다 · 새 코루틴은 Main.immediate 로 즉시 시작),
그러면 새 훅이 옛 previous 로 덮여 그 자리 20회가 전부 IMU 없이 저장된다.
이번엔 그 순서가 아니었지만 순서 자체는 가능하다.
- 로그(태그 ImuSidecar · AnchorAlign): install 시 이전 콜백 종류 · 콜백 도착 ·
await 결과 · 회차별 imu 수. "IMU 가 왔나" 는 파형 해석에서 제일 먼저 묻는 질문인데
답이 BLE 로그를 뒤져야 나왔다. 이제 logcat 한 줄로 답이 나온다.
## 교훈 (도구)
앱이 공용 Downloads 에 방금 쓴 파일은 `adb shell ls/find` 에 늦게 나타난다. 파일
유무를 근거로 결론 내리기 전에 `run-as`(앱 내부 경로) 로 보거나, 몇 분 뒤 다시 보거나,
앱이 남긴 로그·업로드 표식으로 교차 확인할 것.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
병원 경로는 `mtb?` 를 보내면서 **piezo 콜백만** 걸고 있었다. 응답에 딸려 오는
`rim:`(IMU)은 받는 곳이 없어 그대로 버려졌고, 저장된 측정에 IMU 가 하나도 없었다.
labdb 600/601 의 `sensor` 가 "항상 빈 객체"였던 이유다.
## 왜 필요한가
자세는 조작자가 고르는 실험 조건이라 판정에는 안 쓴다. 필요한 것은 다른 것이다 —
같은 조건 20회 반복에서 **어떤 회차만 값이 튈 때**, 알고리즘 문제인지 환자가 그 순간
움직인 것인지 가를 근거가 없었다. 정렬도 사람이 프로브를 옮겨 가며 재는 과정이라,
특정 위치의 파형이 이상할 때 자리 탓인지 흔들림 탓인지 알 수 없었다.
## 수집 — ImuSidecar
세 루프가 같은 패턴이라 헬퍼로 뺐다. 저장이 있는 두 곳에만 붙인다:
· HospitalModeView (600 · 20회 반복)
· AnchorAlignView (601 · 0~4cm 탐색 + 확인)
좌우 정렬 루프는 화면 안내용 스트리밍이라 저장이 없어 건드리지 않았다.
응답 순서가 `reb×6 → raa → rim` 이라 IMU 가 나중에 온다. piezo 를 받은 뒤 0.7초
기다리고, 안 오면 비운다. **IMU 가 없다고 측정을 실패로 돌리지 않는다** — 펌웨어·설정에
따라 `rim:` 이 없을 수 있고, 그때 실패로 만들면 기존에 되던 일이 안 되게 된다.
## 저장 — 형제 CSV
파형 행(meta + s0..s99)을 넓히지 않았다. 그 헤더는 이미 올라간 데이터와 파서가 함께
쓰는 규약이고, IMU 는 채널당이 아니라 **회차당** 값이라 같은 행에 넣으면 6 채널 행에
같은 IMU 를 여섯 번 복사하게 된다.
600 <측정파일>_imu.csv scan_id 로 파형과 잇는다
601 align_{n}cm_imu.csv
align_{n}cm_confirm_imu.csv 파형과 같은 confirm 분리 규칙
601 에서 confirm 을 따로 두는 이유는 파형과 같다 — 확인 측정의 IMU 가 판정에 쓰인
탐색 측정 것을 덮으면 안 된다.
## 업로드 — sensor.imu (600·601 같은 모양)
"sensor": { "imu": [ {ax,ay,az,gx,gy,gz}, … ] }
ax/ay/az = g · gx/gy/gz = dps
⚠ **없으면 빈 객체다. 0 으로 채우면 안 된다.** 부재가 곧 "그때는 안 쟀다" 이고,
0 으로 채우면 "무중력·완전 정지"로 정반대로 읽힌다. 이전 업로드분 전부가 여기 해당한다.
## 문서
LABDB_DATATYPES.md 의 "sensor 는 항상 빈 객체" 기술을 고치고 IMU 절을 새로 썼다.
labdb 쪽에 필요한 일(저장·뷰어 표시·없음/0 구분·마이그레이션 불필요)과 파생값
(accel_mag·gyro_mag) 계산식을 함께 적었다. 프로토콜 이름과 기존 필드는 그대로라
추가 키뿐이며 기존 파서를 깨지 않는다.
테스트 3건 추가(IMU 유/무 · cycle 분리 · confirm 이 sweep 을 덮지 않음).
601 15건 · 600 11건 전부 통과.
⚠ 실기기 미검증 — 병원 설정(2.3MHz·c3)에서도 `rim:` 이 오는지는 프로브로 확인해야 한다.
파싱 자체는 dev 임상에서 쓰던 같은 수집기다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
전수 탐색(0~4cm)이 끝나면 고른 위치를 한 번 더 재고, 그 결과는
`align_{n}cm_confirm.csv` 로 따로 쓰인다(판정에 쓰인 데이터를 덮지 않기 위해).
그런데 페이로드의 파일 정규식이 `^align_(-?\d+)cm\.csv$` 로 끝나 **확인 측정
파일이 하나도 잡히지 않았다** — 개발자가 "왜 이 위치인가"를 보려면 고른 자리의
실제 파형이 필요한데 그것이 서버에 없었다.
정규식에 `(_confirm)?` 를 선택 그룹으로 넣고, 레코드에 `phase`("sweep" |
"confirm")를 추가했다. 같은 `align_cm` 으로 두 벌이 올라가므로 phase 가 없으면
받는 쪽이 같은 위치를 두 번 잰 것을 두 위치로 읽는다.
정렬 순서도 (cm 오름차순, 같은 cm 이면 sweep → confirm) 으로 바꿨다. 확인 측정이
실제로 나중에 일어나므로, 받는 쪽이 파일 순서를 시간 순서로 읽어도 어긋나지 않는다.
이 변경은 작업 트리에 이미 있던 것이고(2026-09-18 자 주석), 이번 세션에서 작성한
것이 아니다. `AlignLabdbPayloadTest` 12건을 재실행해 통과를 확인하고 커밋했다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## ① 반쪽 연결 — 원인은 MTU 협상 타이밍이었다
성공과 실패를 가르는 것은 두 번째 `onMtuChanged` 가 **언제** 오는지 하나였다.
2026-09-15 Xiaomi 23021RAA2Y · VBT2607R300 · 9건에 예외가 없었다:
두 번째 콜백 +1~2ms → WATCHDOG_STARTED 3/3
두 번째 콜백 +297~342ms → HALF_CONNECTED 6/6
+1ms 는 탐색이 시작도 안 했을 때라 살아남고, +300ms 는 탐색 한가운데라 깨진다.
프로브가 연결 뒤 ~300ms 에 자기 쪽에서 MTU 협상을 걸고(폰 GATT 서버 로그의
`gatts_process_mtu_req: MTU 247 request from remote`), 그 ATT 트랜잭션이 진행 중인
서비스 탐색을 깨뜨려 `onServicesDiscovered` 가 오지 않는 것으로 보인다.
**중복 호출을 막는 것으로는 고쳐지지 않았다.** 직전 커밋(8b42996)에서 막아 봤고
`DISCOVER_SKIPPED` 가 발동한 5회 중 3회가 여전히 반쪽이었다 — 깨뜨리는 것은 우리
호출이 아니라 프로브의 ATT 요청이고 앱이 막을 수 없다. 그래서 **피한다**:
탐색을 500ms 뒤에 시작해 협상 창을 지나 보낸다.
실측: 수정 전 6/6 실패 → 수정 후 **4/4 성공**(전부 MTU 2회였다).
대가는 연결 완료가 0.5초 늦는 것뿐이고, 실패하면 20초 `armHalfConnectedGuard` 가
그대로 잡는다. 지연 중 끊기면 `cancelPendingDiscover()` 로 취소한다(정리 경로 10곳).
⚠ 근본 원인은 프로브가 연결 직후 MTU 협상을 거는 것이다. 펌웨어에서 없애거나 연결
직후 즉시 끝내면 이 지연은 필요 없어진다 — 문의 예정.
## ② "광고가 없습니다" 의 정체는 안드로이드 스캔 제한이었다
연결/해제를 연타한 뒤 앱이 기기를 못 찾았다. 그런데 **프로브 LED 는 광고 중이었고
다른 폰에서는 잡혔다.** 시스템 로그에 근거가 그대로 있었다:
E/BtGatt.GattService: App 'com.medithings.vesiscan.demo' is scanning too frequently
안드로이드는 30초에 5회를 넘기면 스캔을 **조용히** 막는다. 빈 결과가 오므로 앱은
"광고가 없습니다" 로 보고하고, 사용자는 기기를 의심하게 된다 — 실제로 그랬다.
OS 가 막기 전에 앱에서 먼저 막는다. 30초 창에 4회(한도 5 에서 하나 남김)를 넘으면
스캔하지 않고 `SCAN_THROTTLED:<남은 초>` 로 알린다. 화면에 남은 초와 함께 "기기 문제가
아닙니다" 를 적었다.
스캔 시작 세 경로 전부 가드를 거친다 — `startScan`, `waitForAdvertThenConnect`,
자동 재연결 스캔(이쪽은 재시도 경로라 건너뛰고 다음 차례로 넘긴다).
광고 확인(`ADVERT_WAIT`)을 생략하는 선택지는 택하지 않았다. 광고하지 않거나 먼 기기에
붙으려다 오류가 났던 이력이 있어 그 확인을 넣은 것이다 — 스캔을 줄이려고 그걸 빼면
예전 문제가 돌아온다(사용자 지적).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 지시(2026-09-15, 병원 임상 당일). 실측에서 23초 주기로 영원히 반복했다.
## 무엇이 일어났나 (Xiaomi 23021RAA2Y · VBT2607R300 · RSSI −46)
11:08:41 연결 → MTU×2 → 20초 뒤 HALF_CONNECTED → 3초 뒤 재연결
11:09:04 연결 → MTU×2 → 20초 뒤 HALF_CONNECTED → 3초 뒤 재연결
11:09:27 연결 → MTU×2 → 20초 뒤 HALF_CONNECTED → 3초 뒤 재연결
11:09:50 연결 → MTU×2 → (반복)
신호는 강했고(−46) 프로브는 매번 연결을 받았다. 그런데 GATT 서비스를 끝까지 내주지
않아 `isServiceReady` 가 한 번도 서지 않았다. **프로브가 갇힌 상태**이고, 같은 프로브가
같은 날 13회 정상 동작했으므로 도중에 그 상태로 빠진 것이다.
다시 붙어도 같은 결과가 나온다. 그리고 링크는 매번 붙으므로 재시도 카운터가 1 로
리셋돼(`attempt=1/-1`) escalation 도 없었다 — 화면에는 "재연결 중" 만 계속 뜨고
조작자는 무엇을 해야 하는지 알 수 없다. 임상에서 이게 제일 나쁘다.
## 바꾼 것
반쪽 연결 가드에서 `scheduleAutoReconnect()` 를 뺐다. 멈추고 `connectionError` 를
`HALF_CONNECTED` 로 남긴다 — 표시 경로는 이미 있었다(DeviceScanView).
문구도 사실에 맞게 고쳤다. "다시 연결합니다" 라고 적혀 있었는데 이제 재연결하지
않으므로 거짓이 된다. 대신 **해야 할 일**을 적었다 — 이 상태를 푸는 것은 프로브 전원
재투입이고 앱이 할 수 있는 일이 아니다.
연결이 끝까지 되지 않았습니다 — 기기가 응답했지만 준비를 마치지 못했습니다.
프로브 전원을 껐다 켠 뒤 다시 연결해 주세요. 다시 시도해도 같으면 다른 프로브를 쓰세요.
## 건드리지 않은 것
진짜 링크 유실(범위 이탈·간섭·`status 8`)의 자동 재연결은 그대로다. 그쪽은 다시 붙으면
실제로 복구되고 `MAX_RECONNECT_ATTEMPTS`(5) 로 escalation 이 있다. 반쪽 연결만 예외로
뺐다 — 재시도가 의미 없는 유일한 경우다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
병원 임상 당일 빌드의 추적성을 위해 커밋한다 — 설치된 APK 가 기록된 커밋과
일치해야 한다.
## 무엇을 넣었나
`onMtuChanged` 에서 무조건 `discoverServices()` 를 부르던 것을, 연결당 한 번만
부르도록 가드를 뒀다(`discoverServicesOnce`). 중복 호출 자체는 없어진다 —
로그에 `DISCOVER_SKIPPED` 로 남는다.
## ⚠ 이것이 반쪽 연결을 고치지 못한다 — 실측으로 확인
2026-09-15 Xiaomi 23021RAA2Y · 프로브 VBT2607R300:
10:52:56 MTU×2, 가드 미발동 → HALF_CONNECTED
10:53:19 MTU×2, DISCOVER_SKIPPED → 정상
10:57:01 MTU×2, DISCOVER_SKIPPED → HALF_CONNECTED
10:57:25 MTU×2, DISCOVER_SKIPPED → ADVERT_TIMEOUT
10:57:46 MTU×2, DISCOVER_SKIPPED → HALF_CONNECTED
10:58:10 MTU×2, DISCOVER_SKIPPED → 정상
가드가 발동한 5회 중 3회가 여전히 반쪽 연결이다. **중복 MTU 는 증상이고 원인이
아니다.** 464건 전수의 상관관계(1회 97.0% vs 2회 22.6%)를 인과로 읽은 것이 잘못이었다.
그래서 이 커밋은 수정이 아니라 **중복 호출 제거 + 진단 로그**로만 취급해야 한다.
`DISCOVER_SKIPPED` 는 중복 MTU 가 언제 오는지를 기록에 남겨, 임상 후 분석의 근거가
된다. 펌웨어팀에 "MTU 를 먼저 걸지 말라"는 요청은 **보내지 않는다** — 근거가 무너졌다.
## 관찰된 패턴 (가설, 표본 4건)
성공한 회차는 두 번째 콜백이 1~2ms 뒤에 오고, 실패한 회차는 ~300ms 뒤에 온다.
300ms 뒤의 두 번째 MTU 협상이 진행 중인 서비스 탐색을 깨뜨리는 것으로 보이는데,
그렇다면 탐색을 다시 부르지 않아도 실패하므로 앱에서 막을 수 없다. 미검증이다.
동작이 나빠질 경로는 없다(중복 호출을 건너뛰는 것뿐). 임상 로그로 표본을 늘린다.
⚠ 반쪽 연결 자체는 기존 `HALF_CONNECTED` 가드가 20초 안에 잡아 재연결한다.
다만 그 20초 동안 측정 명령(`mcf`/`mcs`)이 큐에 들어가 타임아웃까지 대기한다 —
사용자에게는 "0cm · 프로브가 응답이 없습니다" 로 보인다. 이 경로는 아직 손대지 않았다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
정렬 업로드는 정렬 화면을 떠나는 순간 한 번만 쏘고, 마커를 남기지 않고, "미업로드 전부
올리기"는 `align/` 을 보지 않았다. 세 가지가 겹쳐 **실패하면 흔적도 없이 없어졌다.**
2026-09-10 병원에서 조합 CSV 39건이 망 때문에 실패한 건 화면에 남아 나중에 올릴 수 있었지만,
같은 시각의 정렬은 올라갔는지조차 알 수 없었다. PC 로 데이터를 꺼내 labdb 와 대조해서야
31건이 빠진 것을 찾았다.
## 바꾼 것
pendingAlign(runDir) 안 올라간 정렬 폴더를 찾는다
pendingAll() 조합 CSV + 정렬 폴더 (디렉터리면 정렬 한 건)
uploadAllPending() 둘을 한 버튼으로 올린다
uploadAlign() 성공/실패 마커를 남긴다
마커는 **자동 재시도가 무엇을 건너뛸지 고를 때만** 본다. `uploadAlign` 자체는 마커를 보지
않으므로 "memo 붙여 다시 보내기"는 그대로 된다 — 원래 의도를 깨지 않으면서 빠진 것을 알 수
있게 하는 것이 목적이다.
raw CSV 가 없는 빈 정렬 폴더는 대상에서 뺀다. 정렬 화면에 들어갔다 아무것도 재지 않고 나오면
빈 폴더만 남는데(실측 `2026-09-10_unnamed_123015`), 그걸 올리려 들면 영원히 실패한다.
확인 측정(`align_Ncm_confirm.csv`)도 raw 로 세지 않는다 — 페이로드에서 빠지는 데이터라
그것만 있는 폴더는 올릴 것이 없다.
화면은 이미 pendingAll()/uploadAllPending() 만 쓰므로 따로 손대지 않았다.
## 배포 전 주의
폰에 마커가 없으면 이 변경은 **이미 올라간 것까지 전부 다시 올리려 한다.** PC 가 올린 세션은
다른 deviceId 소유라 서버가 409 SESSION_ID_CONFLICT 로 거절하므로 영구 실패로 남는다.
그래서 labdb 에 있는 것(조합 111 · 정렬 34)에 대해 폰 쪽 마커를 먼저 심었다. 마커의
`verified` 필드에 근거를 적었다 — `labdb_listing` 은 목록에서 직접 확인, `upload_conflict`
는 409 로 존재만 확인(레코드 수는 대조 못 함).
테스트 8개 추가 (전체 151, 실패 0).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 병원에서 600 업로드 24건이 실패했고, 앱을 다시 켜자 **올릴 길이 없어져** PC 로
꺼내 올려야 했다(39건). 사유가 셋이었고 그중 하나는 우리 쪽 결함이다.
12건 DNS 해석 실패 망
6건 TLS 인증서 신뢰 실패 망 (LTE 에서는 정상 접속 — 병원 망의 검사 프록시로 보인다)
6건 화면 이탈로 업로드 취소 **앱 결함**
## ① 화면을 떠나면 업로드가 잘렸다
측정 루프의 `launch { upload(file) }` 가 `LaunchedEffect` 스코프였다. 조합이 끝나고 화면을
떠나면 전송이 취소된다 — `The coroutine scope left the composition`. 정렬(601)은 같은 이유로
이미 오브젝트 스코프로 옮겼는데(8b3b5c0) **프로토콜(600)만 남아 있었다.**
`uploadAsync()` 를 만들어 옮겼다.
## ② [지금 업로드] 의 사정권이 한 폴더뿐이었다
`lastRunDir` 은 **이번 실행**의 폴더이고 `remember` 다. 그래서:
· 앱을 다시 켜면 null → **카드 자체가 사라진다** ← 39건이 고립된 직접 원인
· 폴더 하나만 훑음 → 어제 것은 6개 폴더에 흩어져 있었다
`allRunDirs()` · `pendingAll()` · `uploadAllPending()` 을 만들고, 화면은 진입할 때 전 폴더를
훑는다. 버튼도 **[미업로드 전부 올리기]**, 문구도 "미업로드 N건 (지난 측정 포함)".
매니페스트는 폴더마다 다르므로 `manifestParamsFor(csv)` 가 그 CSV 와 같은 폴더의 run json
에서 찾는다. 못 찾으면 null — CSV 헤더만으로도 페이로드는 만들어진다.
## ③ 망 오류 문구를 사람 말로
Java 예외를 그대로 흘리고 있었다(`Unable to resolve host ...`,
`CertPathValidatorException: Trust anchor ...`). 조작자가 할 일이 원인마다 다른데 알 수
없었다. **둘은 망을 바꾸면 되는 것이었다** — LTE 에서는 정상 접속된다.
TLS 차단 "보안 연결이 차단되었습니다 — 이 네트워크가 통신을 검사하고 있을 수
있습니다. 다른 망(LTE·테더링)으로 바꾼 뒤 [미업로드 전부 올리기] 를…
데이터는 폰에 그대로 있습니다."
DNS 실패 "서버 주소를 찾을 수 없습니다 — 인터넷이 끊겼거나 이 망이 외부 접속을…"
시간 초과 "서버 응답이 없습니다 — 잠시 뒤 다시…"
**인증서 검증을 느슨하게 하는 선택은 하지 않았다.** 의료기기에서 그건 보안 후퇴다. 대신
무엇을 해야 하는지 말해 준다 — "측정은 병원에서, 업로드는 망이 되는 곳에서"가 성립하려면
②가 있어야 하고, 이제 있다.
테스트 143개 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
`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>
앞 커밋은 반쪽 연결을 **복구**했다. 이건 **생기지 않게** 한다.
## 전제가 틀렸다
`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) 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>
연결 해제 후 **빠르게** [연결] 을 누르면 연결이 안 됐다. 병원 임상·정렬·설정탭의
[연결 해제] 는 전부 같은 `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>
연결 → 연결 해제 → 메인 → [기기 연결] → [저장된 기기] 를 누르면 스피너가 영원히 돌고,
다른 기기 행까지 전부 비활성이 됐다(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>