Commit Graph

451 Commits

Author SHA1 Message Date
dw.jang fef3eb28dd test: Python 대조용 덤프 시험 3건의 입·출력을 환경 변수로 받고, 없으면 건너뛴다
Cm3PerTraceDump·GoldenDump·PrecisionDump 는 실측 세션 JSON 을 파이프라인에 흘려
중간값을 떨구는 개발 도구인데, 입력이 이 PC 의 절대 경로, 출력이 옛 Claude 세션의
임시 폴더로 박혀 있었다. 그 폴더가 사라진 뒤로 전체 단위시험이 늘 3건 실패였고,
다른 PC·CI 에서는 입력부터 없다.

DumpEnv: VESISCAN_DATA123_DIR(입력 폴더) · VESISCAN_DUMP_DIR(출력 폴더). 둘 다 없으면
Assume 로 건너뛴다 — 보고서에 skipped 로 남아 "통과"로 위장하지 않는다.

확인: 기본 실행 168건 중 skipped 3 · failures 0. 환경 변수를 주고 돌리면 3건 모두
실행되어 cm3/golden/precision_kotlin.json 을 쓴다.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 09:38:47 +09:00
dw.jang 4fc40219f2 fix(labdb): 600·601 의 sensor.imu 를 labdb 표준 모양(샘플 하나)으로 — CSV 내보내기에 IMU 가 비어 있었다
labdb 에서 CSV 로 받으면 ax..gz 열이 빈칸이고 raw JSON 에는 IMU 가 있다(2026-09-29
보고). 사이트의 파싱 문제가 아니다 — labdb 표준(labdb.md "000" 레코드)은
`sensor.imu` 가 샘플 하나 `{ax,ay,az,gx,gy,gz}` 이고 CSV 내보내기는 그 여섯 값을
편다. 병원 임상(600)·정렬(601)은 2026-09-21 부터 거기에 회차 샘플 전부(배열)를
넣고 있었다. 사내 임상(001)은 처음부터 표준대로 보내고 있었다.

  · LabdbSensor.of(samples): imu = 마지막 샘플 하나(표준·CSV 에 나옴),
    imu_samples = 전부(FIFO 순), imu_sample_count. 없으면 빈 객체(0 으로 안 채움).
    001 의 LabdbUploader·tools/labdb_upload.py 와 같은 모양.
  · HospitalLabdbPayload·AlignLabdbPayload 가 이것을 쓴다.
  · tools/align_analyze.py 는 imu_samples 를 우선 읽고, 09-21~28 분(imu 가 배열)도
    그대로 받는다.
  · docs/LABDB_DATATYPES.md §IMU 를 새 모양으로. 09-21~28 업로드분은 CSV 에서
    IMU 가 비는 이유와, 내보내기에서 배열이면 마지막 원소를 쓰는 선택 사항을 적었다.

시험: LabdbSensorTest 3건 신규, Align/Hospital 페이로드 시험을 새 모양으로
(services.labdb 38건 통과). 전체 168건 중 실패 3건은 옛 세션 임시 경로에 쓰는
덤프 시험(Cm3PerTraceDump·GoldenDump·PrecisionDump)으로 이 변경과 무관하다.
병원 폰(SM-A155N)에 설치 완료.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 09:26:41 +09:00
dw.jang 93b5726f21 fix(hospital): 프로브가 안 붙어 있어도 [정렬 시작] 이 열리게 한다 — 비활성인데 파란 글자였다
[정렬 시작] 은 `isConnected` 가 조건이었는데 글자색을 항상 MlPrimary 로 박아 두어
비활성 상태가 눈에 보이지 않았다. 프로브가 안 붙은 채 누르면 아무 일도 없다 —
"버튼이 안 눌린다"(2026-09-28 실기 보고). 앱을 다시 깔거나 프로브가 잠시 끊긴
뒤에 자주 만나는 상태다.

연결 여부로 막지 않는다. 정렬 화면에 [기기 연결] 이 있고, 측정 버튼(`n cm
측정하기`)은 거기서 연결을 본다 — 들어가서 붙이면 된다. 남는 조건은 측정 중
(`!running`)뿐이고, 그때는 색을 지정하지 않아 TextButton 의 비활성 색이 보인다.

A34 실기: 미연결 상태에서 [정렬 시작] → 정렬 화면("기기 연결 필요" · [기기 연결] ·
측정 버튼 비활성) 확인.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 17:57:42 +09:00
dw.jang ae277fe26b ui(hospital): 자세 선택을 정렬 카드 위로 올린다 — 정렬은 자세에 매인 일이다
정렬은 자세를 본다: BV 의 supine 분기와 Sitting 의 35°~55° 요구 범위가
AppState.clinicalPosture 를 읽는다. 그런데 화면에서는 정렬 카드가 자세 칩보다
위에 있어, 자세를 안 고른 채(기본 Supine) [정렬 시작] 에 들어가면 정렬 화면의
기울기 카드에 띠가 없었다 — 붙이고 돌아와서 Sitting 을 고르면 그제야 요구 범위가
뜬다.

순서를 환자명 → labdb → 자세 → 정렬 → 방광 채움 → … 으로 바꾼다. A34 실기 확인.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 17:45:00 +09:00
dw.jang c8cf982b71 fix(ImuSidecar): 사이드카가 겹쳐 걸려도 원래 콜백이 되돌아가게 콜백을 사슬로 다룬다
A34 실기 로그(2026-09-28): 병원 화면의 기울기 폴링 사이드카가 취소되기 전에 정렬
화면의 사이드카가 install 됐다 — Compose 가 취소된 효과의 finally 를 새 효과 뒤에
돌린 것. 옛 restore 는 "자기 콜백일 때만 되돌림"이라 SKIPPED 로 끝났고, 그 뒤로
imuCollector.onComplete 가 닫힌 채널의 죽은 람다를 가리킨 채 원래 콜백이 영영
돌아오지 않았다. 일반 화면들은 들어올 때 자기 콜백을 다시 걸어 당장 깨지는 것은
없었지만, 사슬이 틀어진 채 쌓이는 구조였다.

  · 콜백을 Hook(owner) 클래스로 걸어 다른 사이드카가 주인을 알아본다.
  · restore: 맨 위면 내려오고, 아니면 위쪽 사이드카의 previous 에서 자기만 뺀다.
    사이드카 아닌 것이 덮어썼으면 건드리지 않는다.
  · await 는 restore 뒤에 불려도 null (닫힌 채널을 던지지 않는다).
  · 생성자를 ImuPacketCollector 로 받아 JVM 단위시험 6건으로 잠근다. BleManager
    생성자는 부생성자로 남겨 호출부 4곳은 그대로다.

실기 확인: 병원 → 정렬 → 병원 전환에서 install/spliced out 만 찍히고 SKIPPED·
closed=true 는 0건.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 16:17:15 +09:00
dw.jang c667597e2b feat(hospital): 부착 위치 정렬 화면에도 프로브 기울기 카드를 둔다
Sitting 은 35°~55° 에서 재는데, 프로브를 붙이고 각도를 잡는 일은 정렬 단계에서
일어난다. 카드가 병원 임상 측정 화면에만 있으면 정렬을 끝내고 돌아와서야 각도가
틀린 것을 알게 된다 — 그 자리에서 보여야 한다.

정렬 화면(AnchorAlignView):
  · 연결 상태 줄 아래에 ImuTiltCard. 자세는 이 화면에서 고르지 않으므로
    AppState.clinicalPosture 기준임을 한 줄로 적는다.
  · 쉬는 동안 `mim` 폴링, 위치 측정(20 cycle) 중에는 회차 IMU, 좌우 정렬(연속
    스트리밍) 중에는 프레임 IMU 로 갱신. 좌우 루프에도 ImuSidecar 를 걸되 저장은 안
    한다 — 좌우는 원래 프레임을 저장하지 않는 단계다.
  · 폴링은 measuring·lrRunning 어느 쪽이 돌든 끈다. `mim` 이 끼면 `imuCollector.reset()`
    이 `mtb` 의 IMU 를 지운다.

공통:
  · 폴링 루프를 LiveTilt.kt `pollLiveTilt()` 로 뽑아 두 화면이 같은 것을 쓴다.
    HospitalModeView 의 자체 루프·IDLE_TILT_POLL_MS 는 이것으로 대체.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 15:18:32 +09:00
dw.jang e6ef235784 docs(hospital): Sitting 35°~55° 가 앱의 기울기 정의와 같은 값임을 요청자가 확인 (2026-09-28)
PostureTilt KDoc 에 "분석팀 정의가 다르면 여기서 맞춘다" 는 열린 질문이 있었다.
요청자가 같은 정의(atan(√(ax²+ay²)/|az|) · 0° 평평 · 90° 세로)로 낸 값이라고
확인했으므로 닫는다.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 11:45:11 +09:00
dw.jang 812c0a2ae7 feat(hospital): 프로브 기울기 상시 표시 · Sitting 35°~55° 측정 각도 안내
## 요청 (2026-09-28 · 임상 데이터 분석)
Sitting 정확도 개선을 위해 특정 각도 범위의 데이터가 필요하다. 사내 임상 분석 결과로
Sitting 은 35°~55° 에서 재야 한다. 임상 앱에 ① IMU 각도 상시 표시(+ IMU 저장) ②
Sitting 단계에서 그 범위가 측정 가능 각도라는 안내.

## 각도의 정의 — 분석팀 숫자와 같은 식이어야 한다
`ImuPostureClassifier.classify().pitchDeg` 를 그대로 쓴다: IMU 15샘플 평균으로
atan(√(ax²+ay²)/|az|). 0° = 평평(누워서 배 위) · 90° = 세로. 앱이 자세 판정에 쓰는
것과 같은 정의라 저장된 `*_imu.csv` 원시값에서 다시 계산해도 같은 숫자가 나온다.
(`PostureTilt` KDoc 에 식을 적어 두었다 — 분석팀이 쓴 정의가 이것과 다르면 여기서
맞춘다.)

## 상시 표시는 어디서 값을 받나
간호사가 프로브를 대고 각도를 맞추는 것은 **측정 전**이다. 측정 중에만 오는 mtb 의
IMU 로는 늦다. 그래서 연결돼 있고 루프가 안 돌 때 `mim`(IMU 만, 무음)을 800ms 마다
보내 기울기를 띄우고, 루프가 돌면 회차마다 오는 mtb 의 IMU 로 갱신한다.

폴링과 루프가 같은 `imuCollector.onComplete` 를 쓰므로 둘 다 ImuSidecar 를 통해
걸고 푼다. `running` 이 켜지면 폴링 효과가 취소되는데, 취소된 코루틴의 finally(restore)
가 루프의 install 뒤에 실행되는 순서가 가능하다 — ImuSidecar.restore 가 "자기
콜백일 때만 되돌림"(00fef4a9)이라 이 순서에서 루프의 훅이 살아남는다. 그 방어가
바로 여기서 쓰인다.

## 안내지 차단이 아니다
범위 밖이어도 측정은 막지 않는다. 요청이 "안내(표시)"이고, 막으면 범위를 벗어난
대조 데이터를 일부러 받을 길이 없어진다. 대신 사후에 걸러낼 수 있게 남긴다:
  · 회차별 IMU — 이미 `*_imu.csv` (2026-09-21)
  · 실행 요약 JSON — tilt_deg_mean/min/max/n · tilt_deg_required (신규)

## 화면
`ImuTiltCard` — 연결 상태 줄 바로 아래, 항상. 큰 숫자 + 출처("실시간"/"측정 회차마다
갱신"/"연결 안 됨"). 자세에 요구 범위가 있으면 0~90° 축 위에 띠와 현재 위치 눈금,
"범위 안" 또는 "N° 더 세워야/눕혀야 함". 자세 칩 아래에도 한 줄 힌트.

## 시험
PostureTiltTest 2건 — 경계(35·55) 포함, Sitting 외 자세는 범위 없음. 범위 숫자 자체는
분석팀 결정이라 시험이 옳고 그름을 말하지는 않는다.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 10:58:39 +09:00
dw.jang 00fef4a99d fix(hospital): ImuSidecar.restore 가 남의 콜백을 되돌리지 않게 한다 · 단계별 로그
## 계기 — 그리고 정정
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>
2026-09-28 10:40:47 +09:00
dw.jang 08f5a182df feat(hospital): 병원 임상·정렬에 IMU 를 남긴다 — 600/601 sensor.imu
병원 경로는 `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>
2026-09-21 17:08:26 +09:00
dw.jang 3708ce5f95 fix(labdb): 정렬 확인 측정이 업로드에서 빠져 있던 것 · phase 로 구분
전수 탐색(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>
2026-09-21 11:35:33 +09:00
dw.jang 6c5e38080c fix(ble): 서비스 탐색을 500ms 늦춘다 — 반쪽 연결의 진짜 원인 · 스캔 빈도 제한도 앱에서 막는다
## ① 반쪽 연결 — 원인은 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 11:49:32 +09:00
dw.jang 50a2d4bb6a fix(ble): 반쪽 연결에서 자동 재연결하지 않는다 — 무한 반복 대신 조작자에게 알린다
사용자 지시(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>
2026-09-15 11:14:06 +09:00
dw.jang 8b42996797 fix(ble): 중복 MTU 콜백에 서비스 탐색을 두 번 걸지 않는다 (효과는 미확인)
병원 임상 당일 빌드의 추적성을 위해 커밋한다 — 설치된 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>
2026-09-15 11:06:43 +09:00
dw.jang 6be4b5a200 fix(labdb): 정렬(601)에 재시도 경로가 없어 조용히 사라지고 있었다
정렬 업로드는 정렬 화면을 떠나는 순간 한 번만 쏘고, 마커를 남기지 않고, "미업로드 전부
올리기"는 `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-11 12:03:43 +09:00
dw.jang a7d7cf469f fix(labdb): 미업로드를 전 폴더에서 올린다 · 업로드가 화면과 함께 죽지 않게
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 10:41:00 +09:00
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 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 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