PostureTilt KDoc 에 "분석팀 정의가 다르면 여기서 맞춘다" 는 열린 질문이 있었다.
요청자가 같은 정의(atan(√(ax²+ay²)/|az|) · 0° 평평 · 90° 세로)로 낸 값이라고
확인했으므로 닫는다.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
## 요청 (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>
## ① 복부 두께 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>
## 화면을 두 덩어리로 나눴다
환자명 → 부착 위치 정렬 → 자세 → 방광 채움 → 반복 횟수 → 복부 두께 →
[측정 시작] → 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-02 펌웨어팀 스펙을 받아 바로잡는다.
1. **인자가 2개가 아니라 5개다.**
mcs? [tag 4B][freq 2B][cycles 2B][avg 2B][delay_us 2B][samples 2B][crc 2B] = 16B
avg·delay_us·samples 를 안 보내면 길이 부족으로 거부된다(freq=0xFFFF 응답).
2. **주파수 값이 1/2 가 아니라 0/5 다.**
0=1.8 · 1=1.9 · 2=2.0 · 3=2.1 · 4=2.2 · 5=2.3 MHz.
앞선 커밋의 가정(1.8→1, 2.3→2)은 둘 다 틀렸다 — 실제로는 2.0 과 2.3 을 재게 된다.
가정을 파일명에 남겨 둔 안전장치가 없었다면 못 알아챌 뻔했다.
3. **응답을 확인하지 않고 있었다.** 이게 가장 위험하다 — 아래 참고.
## 설정 실패는 조용하다 — 그래서 반드시 확인한다
실패해도 `rcs:` 는 온다. 구분은 freq 값이다:
· 0xFFFF — 파라미터 범위 초과 또는 데이터 길이 부족
· 0xFFFD — 검증은 통과했으나 NVS 저장 실패
거부돼도 프로브는 **옛 설정으로 측정을 계속한다.** 확인하지 않으면 파일에는 요청한
값이 적힌 채 다른 조건의 데이터가 쌓인다 — 잘못된 데이터가 정상처럼 보이는, 임상에서
가장 나쁜 결과다.
그래서 조합마다 echo 를 받아 **요청한 다섯 값과 전부 일치할 때만** 측정한다.
불일치·무응답이면 그 조합을 통째로 건너뛰고 run json 에 `skipped_reason` 을 남기며
화면에 빨갛게 띄운다. 비는 편이 틀린 것보다 낫다.
## 고정 파라미터
프로토콜이 바꾸는 것은 주파수·cycle 뿐이다. 나머지 셋은 모든 조합에서 같아야 비교가
성립하므로 `HospitalFixedParams` 한 곳에 둔다 — avg 10 · delay_us 10 · samples 100
(펌웨어팀 예시값, samples 는 앱 채널 버퍼 100 과도 일치).
1.8MHz·c3 6D 63 73 3F 00 00 00 03 00 0A 00 0A 00 64 3C 4A
2.3MHz·c7 6D 63 73 3F 00 05 00 07 00 0A 00 0A 00 64 36 FC
## ⚠ 설정이 프로브에 영구 저장된다 (NVS)
전원을 껐다 켜도 유지된다. 즉 이 모드로 측정하고 나면 **일반 측정 화면도 마지막
조합(2.3MHz·cycle 7)으로 동작한다.** run json 에 남기고 화면에도 명시했다.
임상 후 원래 값으로 되돌릴지는 별도 결정이 필요하다 — 공장 기본값을 모른다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## 조작자가 하는 일은 하나다
간호사가 방광을 정해진 정도까지 채워 두면, 조작자는 **그 채움 정도만** 고르고
[측정 시작]을 누른다. 이후 주파수 2종 × cycle 3종 = 6조합을 앱이 자동으로 순회하며
각 조합마다 n회(기본 20) 측정하고 조합별 파일로 저장한다.
조합을 손으로 바꾸게 두면 반드시 빠뜨리거나 잘못 기록한다. 이미 채워 둔 방광은 다시
만들 수 없으니, 그 자리에서 놓친 조합은 그날 데이터에서 영영 빈다. 사람이 개입하는
지점을 하나로 줄인 것이 이 모드의 핵심이다.
진입: 홈에서 캐릭터 3연타 → 개발자 모드 → [병원 임상 측정]
## 프로토콜
자세 Supine / Sitting / Standing (기존 ClinicalPosture 재사용)
주파수 1.8 · 2.3 MHz → mpa? 첫 인자
cycle 3 · 5 · 7 → mpa? 둘째 인자
방광 채움 0/20/40/60/80/100 % → 사람이 아는 값 = 정답 라벨
반복 화면 입력 (기본 20)
## 저장
Downloads/VesiScan_Hospital/{날짜_환자}/
2026-09-03_홍길동_Supine_040pct_1.8MHz-fopt1_c3.csv
run_HHmmss.json ← 계획 대비 실제 저장 수
파일명에 조건을 전부 적는다. 폴더로만 구분하면 파일 하나를 옮기는 순간 조건을 잃는데,
임상 데이터는 나중에 다른 사람이 모아서 분석한다. CSV 열 구성은 기존 AdcCsvLogger 와
맞춰(scan_id·timestamp·channel·s0~s99) 분석 스크립트를 새로 만들지 않아도 되게 했다.
기존 임상 R&D(`VesiScan_Sessions/`)와 폴더를 나눴다 — 목적도 구조도 달라서 섞이면
나중에 파일을 하나씩 열어 봐야 한다.
## ⚠ 주파수 코드값이 확인 전이다
`mpa?` 첫 인자는 정수인데 그 정수와 MHz 의 대응이 코드에도
docs/BLE_PROTOCOL_REFERENCE.md 에도 없다. 기존 코드는 늘 `freqOption = 2` 만 썼다
(PlacementGuideView · MeasurementService). 그래서 1.8→1, 2.3→2 는 **가정**이다.
확인 전에 모은 데이터가 버려지지 않도록, 실제로 보낸 정수를 파일명(`fopt1`)과 CSV 열
(`freq_option`), run json 에 함께 남긴다. 대응이 반대로 밝혀져도 라벨만 바꾸면 된다.
run json 에 `freq_option_mapping_confirmed: false` 를 박아 두어 나중에 이 데이터가
어떤 상태에서 모였는지 알 수 있게 했다. 화면에도 같은 경고를 띄운다.
## 데이터 정합성
결과를 평범한 var 로 주고받으면 BLE 스레드↔코루틴 간 가시성이 보장되지 않아 Channel
을 쓴다. 그리고 **회차마다 보내기 전에 채널을 비운다** — 직전 회차가 시간초과된 뒤
뒤늦게 도착한 결과가 남아 있으면 이번 회차 데이터로 잘못 기록된다.
한 회차가 3초 안에 안 오면 실패로 세고 다음으로 넘어간다. 한 번 막혔다고 전체가
멈추면 채워 둔 방광을 버리게 된다. 실패 수는 화면과 run json 에 남는다.
## 검증
빌드 통과. **실기기 확인은 아직 못 했다** — 폰이 절전 상태로 들어가 화면을 못 띄웠고,
프로브 연결 상태의 측정 루프는 전혀 돌려보지 못했다. 내일 임상 전에 반드시 한 번
돌려봐야 한다(6조합 × 20회 = 120측정 · 약 2~3분 예상).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>