Files
VesiscanClinicalAndroid/VesiScan_Android_Pipeline_Summary.md
T
dw.jang 60b630d3de docs: v7 (2026-07-10) 반영 — msp 제거, 6-stage alignment, mtb queue fix, labdb 자동 재시도
이번 세션 대량 변경 사항을 5개 문서에 반영. 각 문서마다 stale 이던
섹션을 갱신하거나 신규 섹션 추가.

USER_GUIDE.md
  - Sensor Alignment 를 V1 (3-stage) / V2 (6-stage) 로 재구성
  - V2 6-stage 표 + relaxed mode / soft hint 설명
  - GREEN 진입 5초 hold + 10-strike 리셋 완화 명시
  - "최적의 위치입니다!" 문구 반영

docs/BLE_PROTOCOL_REFERENCE.md
  - msp 명령 취소선 처리 + mim 신규 명령 문서화
  - Watchdog timeout 25초 연장 명시 (2.5)
  - §2.6 신규: firmware VBTFW0121 mls mode 0 freeze 취약점 +
    앱 측 3-layer 회피 (isMtbBusy / mtb 3초 timeout / 자동 재연결)

VesiScan_Android_Pipeline_Summary.md
  - v7 (2026-07-10) 섹션 신규 추가 — BLE / Alignment / UI / labdb /
    tools 5개 카테고리로 변경 사항 정리
  - 권장 펌웨어 표기 VBTFW0116 → VBTFW0120+ 로 갱신

labdb.md
  - §12b 신규: 앱 측 Auto Retry Policy — endMeasurement 자동 업로드
    조건 완화, LabdbAutoRetry object, UI 배너, 재시도 안전성

tools/README.md
  - labdb_upload.py 섹션 신규 — 사용법 / 폴더 구조 / 재실행 안전성 /
    buildPayload 로직 / 활용 예 정리

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-10 11:25:26 +09:00

1504 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VesiScan Android App — 완전 가이드
## 작성일: 2026-04-23
## 작성자: dwjang
## 마지막 업데이트: 2026-07-10 (v7) — 1.2.0-demo (versionCode 26)
## 권장 펌웨어: **VBTFW0120+** (mim FIFO 지원 필수. VBTFW0121 은 `mls mode 0` handler 취약)
### v7 주요 변경 요약 (2026-07-06 ~ 2026-07-10)
**BLE / Firmware 대응**
- **`msp` 완전 제거 → `mim` (FIFO 15 sample) 로 통일** (커밋 34ae4d7/deae8a7/9907931).
신 firmware 는 mim 만 사용. `rsp:` 파서는 legacy 응답 대비 유지.
- **mtb queue overrun freeze 3-layer fix** (커밋 9907931). 실측 로그에서 mtb 응답
stream 중 `msn`/`mim` 이 끼어들어 firmware GATT queue 꼬임 → `mls mode 0`
에서 완전 freeze 확인. 상세: [BLE_PROTOCOL_REFERENCE.md](docs/BLE_PROTOCOL_REFERENCE.md) §2.6.
- `isMtbBusy` 프로퍼티 + battery/mim polling 시 skip
- mtb 3초 timeout + `consecutiveMtbTimeouts` state
- 3회 연속 실패 시 자동 `forceDisconnectAndReconnect()`
- **Watchdog timeout 15초 → 25초 연장** (커밋 0383a21). 재연결 후 첫 RX 가 14초
지연 도착하는 케이스 허용.
- **좀비 세션 방지**: `onConnectionStateChanged` 콜백을 CCCD write 완료 시점으로
이동 (BleManager). `fwFallbackTimer` / `cccdRetryTimer` / `mtbTimeoutRunnable`
disconnect 시 취소.
- **Auto scan 명령 `maa` → `mtb` 로 전환** (커밋 29b6ca5). maa 는 IMU 응답
없음 (posture 갱신 X). mtb 는 reb×6 + raa + rim 이라 auto scan 중에도 posture
실시간 업데이트.
- **lastBleRxAt 갱신 확대** (커밋 3a41864). `processReceivedData` 진입점에서
모든 유효 응답에 대해 갱신 → "기기 응답 없음" false-positive 배너 해소.
**Alignment (V2 6-stage)**
- **CH3 flicker 무한 대기 방지** (커밋 e808b5b). VERTICAL_CLIMB 진입 조건을
"3연속 hit" → "최근 6프레임 중 3회 hit" sliding window majority 로 완화.
20 프레임 대기 시 상단 3채널 relaxed mode 진입 (CH3 없이도 정렬 완료).
12 프레임 반복 시 문구 자동 완화 ("↑ 위로" → "CH3 확인 중").
- **6단계 완료 후 "측정 시작" 버튼 활성화 fix** (커밋 49aab7b). 기존
`phase == LR_BALANCE` 조건이 실제 완료 phase (`FINAL_CONFIRM`) 를 못 잡아
버튼 disable 되던 버그. `state=="commit" && phase==FINAL_CONFIRM` 로 수정.
- **GREEN 진입 후 5초 hold** (커밋 159116d). V2 에도 V1 처럼 hold 로직 추가.
순간 flick 으로 GREEN 즉시 풀리는 UX 문제 해소.
- **GREEN zone 조기 리셋 완화** (커밋 7484077). V1 의 3-strike → **10-strike**
(약 5초+). 미세 자세 흔들림으로 "0/3 정렬시작" 리셋되던 버그 완화.
- **문구 개선**: "제 위치입니다!" → **"최적의 위치입니다!"** / "In position!"
→ "Optimal position!" (커밋 7484077).
**UI / UX**
- **Posture 라벨**: "서있음" → **"일어남"** (`piezo_posture_upright`, 한글).
영문 "Upright" 유지.
- **도넛차트 posture chip stuck 버그** (커밋 149e511/3abc024): `imuCollector.onComplete`
콜백이 DisposableEffect race 로 즉시 null 되던 문제. 세 화면
(PiezoMonitoring / PlacementGuide / ClinicalLive) 의 DisposableEffect 에서
콜백 null 정리 제거. stale 콜백은 다음 화면 진입 시 자기 콜백으로 덮어씀.
**Clinical 세션 / labdb**
- **labdb 자동 업로드 강화** (커밋 db6e7e1/93b8650/8c17407):
- `endMeasurement` 자동 업로드 조건 완화 (`isRegistered` 만 체크, `lastStatus == "active"` 조건 제거)
- 신규 `LabdbAutoRetry` object — ClinicalHome 진입 시 미업로드 폴더 자동 재시도
- 상단 배너 UI (진행 중 / 완료 결과)
- **KnownDeviceStore clinical flow 제한 제거** (커밋 b822b49). 일반 모드에서도
연결한 기기 자동 저장 + DeviceScan 상단에 표시.
**Python tools**
- **`tools/labdb_upload.py` 신규** — LabdbUploader.kt 의 `buildPayload` 로직을
Python 으로 이식. 외부 저장 세션 폴더 일괄 업로드용.
자세한 사용법: [tools/README.md](tools/README.md).
**참고**
- 이번 세션에서 발견되었으나 아직 미조치: PiezoMonitoringView (2900L) /
PlacementGuideView (2200L) 대형 파일 분해, Design Token 중앙화, ViewModel
도입 등 아키텍처 개선 사항은 별도 로드맵으로 진행 예정.
### v6 주요 변경 요약 (2026-05-12 ~ 2026-05-26)
### v6 주요 변경 요약 (2026-05-12 ~ 2026-05-26)
- **BLE throttle 상태 기반 게이트화** — 시간 단독(800ms)에서 `isMultiChannel && !isComplete` + 시간(600ms) 복합 게이트로 전환. 잔여 reb 폐기로 인한 CH0/CH1만 도착 + BV_FAIL/BV_SKIP 증상 해소
- **CONNECTION_PRIORITY_HIGH 요청 추가** — CCCD write 완료 시점에 `gatt.requestConnectionPriority(HIGH)` 호출. peripheral이 수락 시 interval ~15ms로 단축
- **MTU 247 협상 확정** — VBT26050202 + VBTFW0116 조합에서 247 정상 협상 (logcat 검증)
- **측정 사이클 시간 1.2s → 0.33s** (4배 향상). Placement loop 1000ms → 600ms, Auto loop 1500ms → 600ms
- **PinView DEMO 우회**, **DeviceScanView Dev 측정모드 진입 버튼**, **AppState placementFromMonitoring 라우팅 분기** 추가
- **PiezoPersonalizationView 평행 2-카드 리디자인** (Maximum Bladder Capacity + Catheter Threshold, StepperButton, roundToInt fix)
- **PlacementGuideView Sensor Alignment 리디자인** (3-stop gradient, 38sp 큰 타이틀, "pubic bone" 빨강 강조, Canvas 화살표/아치 제거, chestY 0.09)
- **PiezoMonitoringView 누적 업데이트** (Voiding/ 텍스트, 48sp Recorded Dialog, useScrollLayout, Volume 72sp ExtraBold, Bladdy 0.483, Home 아이콘, 설정 패널 폰/태블릿 자동 분기)
- **HomeView Bladdy 3× 확대** (220 → 660dp + BoxWithConstraints 90% 캡)
---
# 1. 이 앱이 뭔가요?
배에 초음파 센서를 갖다 대면 **방광에 소변이 얼마나 차 있는지** 알려주는 앱입니다.
병원에 안 가고도 집에서 방광 상태를 확인할 수 있습니다.
**iOS 버전과 동일한 기능**을 Kotlin + Jetpack Compose로 구현한 Android 버전입니다.
---
# 2. iOS 버전과 달라진 점
| 항목 | iOS | Android | 비고 |
|------|-----|---------|------|
| **전처리** | TVD + SG 필터 | **SG 필터만** | TVD 제거 (6ch 알고리즘) |
| **LOW_ECHO_AMP** | 1600 | **1150** | ADC range 변경 |
| **DISTANCE_PER_SAMPLE** | 1.974 | **1.771** | 6ch 기기 보정값 |
| **기기 프리셋** | FLAT/CASE1/CASE2 | **V0/V1/V2** | Snell's law 굴절 각도 적용 |
| **lr_ratio** | 고정 1.0 | **CH4/CH5에서 동적 계산** | 타원 단면 추정 |
| **BV 추정** | 5ch (CH0~CH4) | **6ch (center 4ch + lateral lr_ratio)** | estimate_bladder_volume_6ch |
| **배터리 폴링** | 5초 | **5초** | 동일 (keep-alive) |
| **BLE reconnect** | 수동 | **자동 (3s→5s→10s, 5회)** | 백오프 재시도 |
| **Spot 자동측정** | 0.8초 롱프레스 | **롱프레스 (detectTapGestures)** | 30초 간격 |
---
# 3. 앱 사용 순서 (처음부터 끝까지)
```
① 앱 실행
↓
② 온보딩 (처음 한 번만)
↓
③ 개인정보 동의
↓
④ 사용자 등록 (이름, 나이, 성별, 키/몸무게)
↓
⑤ PIN 설정 (4자리, 셔플 키패드)
↓
⑥ 홈 화면
↓
⑦ "Start" 버튼 → 센서 종류 선택 (Piezo / NIRS)
↓
⑧ BLE 기기 스캔 → 연결 (2025* / VBT* 필터)
↓
⑨ 센서 설정 (자세, 위치, 최대 용량)
↓
⑩ ★ 센서 위치 안내 (Placement Guide)
↓
⑪ ★ 방광 측정 화면 (Piezo Monitoring)
↓
⑫ 결과 확인 + 기록
```
---
# 4. 기기 자동 감지 (프리셋)
BLE 기기 이름으로 센서 각도 preset을 자동 설정합니다.
## 기기 이름 규칙
```
VBT26040x0y
│ │
│ └─ y: 기기 번호 (1,2,3...)
└─── x: 각도 타입
0 = V0 (0,0,0,0,0,0) — BLE 테스트용
2 = V1 (max 20° housing, Snell's law)
3 = V2 (max 30° housing, Snell's law)
2025MEDIP = 기존 5ch 기기 (legacy)
```
## Preset별 각도 (Snell's law 굴절 후)
| Preset | CH0 | CH1 | CH2 | CH3 | CH4(L) | CH5(R) | LR(CH4) | LR(CH5) |
|--------|-----|-----|-----|-----|--------|--------|---------|---------|
| V0 | 0° | 0° | 0° | 0° | 0° | 0° | 0° | 0° |
| V1 | 6.89° | 0° | -6.89° | -13.66° | -6.87° | -6.87° | -3.42° | 3.42° |
| V2 | 0° | -6.89° | -13.66° | -20.20° | -6.87° | -6.87° | -3.42° | 3.42° |
| legacy | 0° | -2.2° | -4.4° | -6.6° | -8.8° | — | 0° | 0° |
**Housing 각도 → 실제 빔 각도 변환 (Snell's law):**
- V1 housing: [10, 0, -10, -20, -10, -10] → refracted: [6.89, 0, -6.89, -13.66, -6.87, -6.87]
- V2 housing: [0, -10, -20, -30, -10, -10] → refracted: [0, -6.89, -13.66, -20.20, -6.87, -6.87]
## 센서 좌표 (mm)
| Preset | CH0 z | CH1 z | CH2 z | CH3 z | CH4 z | CH5 z | CH4 x | CH5 x |
|--------|-------|-------|-------|-------|-------|-------|-------|-------|
| V0 | 21.0 | 14.0 | 7.0 | 0.0 | 10.5 | 10.5 | -10.0 | 10.0 |
| V1 | 20.1 | 12.8 | 7.0 | 0.0 | 9.9 | 9.9 | -10.0 | 10.0 |
| V2 | 19.3 | 13.0 | 6.7 | 0.0 | 9.85 | 9.85 | -10.0 | 10.0 |
---
# 5. 측정 파이프라인 상세
## 5.1 BLE 통신
### 사용하는 명령어
| 명령 | 응답 | 용도 |
|------|------|------|
| `mpa?` | `rpa:` | 센서 전원 켜기 |
| `maa?` | `reb:` × 6 + `raa:` | 6채널 전체 측정 |
| `msn?` | `rsn:` | 배터리 확인 (**5초마다**, keep-alive) |
### 패킷 구조 (208 바이트)
```
reb:(4바이트) + repeat_count(2바이트) + ADC데이터(200바이트) + CRC(2바이트)
└ "reb:" 문자 └ 항상 100 └ 100개 × 2바이트(16-bit) └ 체크섬
```
### Connection Parameter 협상 (v6 신규)
**MTU 247**: 연결 직후 `gatt.requestMtu(247)` 호출. 208B `reb:` 패킷을 단일 노티로 전송하여
단편화 drop 위험 제거. 실측 VBT26050202 + VBTFW0116에서 247 정상 협상 확인.
**CONNECTION_PRIORITY_HIGH**: CCCD write 성공 직후(서비스 ready 시점) 호출:
```kotlin
gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
debugLogger.info("CONN_PRIORITY HIGH requested (ok=$priorityOk)")
```
peripheral이 connection parameter update를 수락하면 interval ~15ms로 협상되어
Central Link Layer ACK 속도가 향상됨 → 펌웨어 SoftDevice TX queue 포화 빈도 감소.
수락 여부는 안드로이드 API로 직접 확인 불가(HCI 레벨 차단) — 응답 시간 측정으로 간접 확인.
### maa Throttle (v6 상태 기반 게이트로 강화)
이전: 시간 단독 `now - lastMaaSentMs < 800` 차단 → 펌웨어 응답이 1.2s 걸릴 때
`startMultiChannel(6)`이 응답 도중 호출되어 잔여 reb 패킷이 폐기되는 race 발생.
신규: [BleManager.kt:464-481](app/src/main/java/com/example/medilightv2android/ble/BleManager.kt#L464-L481) `canSendMaa()`
```kotlin
private fun canSendMaa(caller: String): Boolean {
val now = System.currentTimeMillis()
val collectorBusy = piezoCollector.isMultiChannel && !piezoCollector.isComplete
val sinceLast = now - lastMaaSentMs
if (collectorBusy) {
if (sinceLast < 3000) {
Log.w("BleManager", "maa BUSY [$caller] — prev response in progress")
return false // (A) 상태 차단
}
Log.w("BleManager", "maa FORCE [$caller] — prev incomplete after ${sinceLast}ms")
}
if (sinceLast < 600) {
Log.w("BleManager", "maa THROTTLED [$caller] — ${sinceLast}ms since last")
return false // (B) 시간 차단
}
return true
}
```
| 게이트 | 차단 조건 | 풀림 조건 |
|---|---|---|
| (A) State | `isMultiChannel && !isComplete` (raa 미도착) | raa 도착 OR 3초 경과 (FORCE) |
| (B) Time | 마지막 송신 후 600ms 미만 | 600ms 경과 |
두 게이트 모두 통과해야 maa 송신. State gate가 안전망, time gate가 펌웨어 burst 방어.
### 측정 사이클 실측 (VBTFW0116 + MTU 247 + CONN_PRIORITY HIGH)
logcat 측정 (2026-05-26):
| 사이클 | TX maa | RX raa | 소요 |
|---|---|---|---|
| Placement #1 | 57.333 | 57.652 | **319ms** |
| Monitor #1 | 00.952 | 01.233 | **281ms** |
| Monitor #2 | 02.851 | 03.191 | **340ms** |
| Monitor #3 | 04.414 | 04.719 | **305ms** |
| Monitor #4 | 05.982 | 06.292 | **310ms** |
| Monitor #5 | 07.549 | 07.972 | **423ms** |
**평균 ~330ms**. 이전 ~1.2~1.4s 대비 4배 향상. 모든 사이클에서 6/6 채널 도착, 유실 0건.
### Big Endian 자동 감지
```
reb: 패킷 수신 → 첫 번째 채널(CH0)의 첫 ADC 샘플 확인
LE로 해석한 값 > 4095 AND BE로 해석한 값 ≤ 4095 → Big Endian
그 외 → Little Endian
첫 채널에서 감지 → 나머지 CH1~CH5에 동일 적용
```
### BLE 자동 재연결
```
예기치 않은 끊김 감지
↓
자동 재연결 시도 (최대 5회)
├── 1차: 3초 후
├── 2차: 5초 후
├── 3차: 10초 후
├── 4차: 10초 후
└── 5차: 10초 후
성공 → 배너 자동 숨김 + 배터리 폴링 재시작
실패 → 빨간 배너 "Could not reconnect" + 수동 Reconnect 버튼
사용자가 수동 disconnect → 자동 재연결 안 함
측정 중 끊김 → isMeasuring 리셋 + 자동측정 중지
```
---
## 5.2 전처리 — 노이즈 제거
### iOS와 다른 점
```
iOS: TVD (Chambolle) → SG 필터
Android: SG 필터만 사용 (TVD 제거)
```
### Savitzky-Golay Filter (5-tap, poly=2)
```
고정 계수: [-3, 12, 17, 12, -3] / 35
내부 (i=2~n-3):
out[i] = (-3×sig[i-2] + 12×sig[i-1] + 17×sig[i] + 12×sig[i+1] - 3×sig[i+2]) / 35
Edge (i=0,1 / n-2,n-1):
5개 점에 2차 다항식 피팅 → 가장자리 값 보간
효과: 미세한 울퉁불퉁 제거, 벽(높은 곳)은 유지
```
---
## 5.3 UrinAI — 소변 존재 판정 (문지기)
### 역할
**"여기에 소변이 있나요?"** → Yes / No
No이면 벽 찾기를 아예 안 함 → 엉뚱한 결과 방지.
### 상수 (GreenZoneConstants.kt)
| 상수 | 값 | 의미 |
|------|-----|------|
| `liquidThrLoose` | 1400 | 이보다 낮으면 "액체" (이진화) |
| `liquidThrStrict` | 1400 | 순수 소변 확인용 |
| `minLiquidRun` | 5 | 연속 소변 최소 5칸 |
| `minChord` | 15 | 앞벽~뒷벽 최소 15칸 |
| `contrastMin` | 0.15 | 벽과 소변의 차이 15% 이상 |
| `ringSkip` | 3 | 처음 3칸은 무시 (센서 자체 반사) |
| `useSamples` | 90 | 앞 90개 샘플만 사용 |
### 알고리즘
```
1. 이진화: ADC < 1400 → 액체(true) / ≥ 1400 → 조직(false)
2. fw 찾기: 3번째 칸부터 시작해서
"연속 3개가 전부 액체"인 첫 위치 = 소변 시작점
3. bw 찾기: fw 이후에서 "연속 3개가 전부 조직"인 그룹 중
합계가 가장 큰 그룹의 최대값 위치 = 뒷벽
(못 찾으면 2개로 완화 → 소방광 대응)
4. 순수 소변 확인: fw~bw 사이에서
ADC < 1400이 연속 몇 칸인지 세기
5. 대비 계산: (벽 최대 - 소변 최소) / 벽 최대
6. 판정: 순수소변 ≥ 5칸 AND 깊이 ≥ 15칸 AND 대비 ≥ 0.15
→ 세 개 다 통과해야 "소변 있음!"
```
---
## 5.4 Low-Echo Detection — 벽 찾기 (Method B)
### 역할
UrinAI가 "소변 있다!" 하면, **정확히 어디서 어디까지**인지 찾습니다.
### 상수 (PiezoEchoAnalyzer.kt)
| 상수 | 값 | 의미 |
|------|-----|------|
| `lowEchoAmp` | **1150** | denoised ≤ 이 값 = low-echo |
| `lowMinLen` | 3 | low-echo span 최소 길이 |
| `mergeGapMax` | 3 | 인접 span 합치기 최대 gap |
| `peakSearchWin` | 20 | 벽 peak 탐색 범위 |
| `postMaxIdx` | 80 | 후벽 최대 인덱스 |
| `minPeakMargin` | 30 | peak 최소 높이 = low_mean + 30 |
| `minUrineLen` | 3 | 최소 소변 구간 길이 |
| `maxPeakCandidates` | 3 | 방향별 최대 후보 수 |
| `valleyStopRise` | **50** | valley 스캔 시 상승 early stop 임계 (기존 10) |
| `edgeDistDecay` | **0.12** | edge 거리 penalty 계수 (신규) |
### 알고리즘 (Prominence + Edge Distance, 2026-04-24 업데이트)
```
1. 디노이징된 신호에서 1150 이하인 연속 구간 찾기
→ "여기가 소변이겠구나" (low-echo span)
2. 인접한 구간 합치기 (gap ≤ 3칸이면 합침)
단, gap 안에 1155 이상인 peak이 있으면 합치지 않음
3. 양쪽에서 벽 peak 후보 찾기 (±20칸 범위)
- edge 포함 boundary: left [leftLo:edge+1], right [edge-1:rightHi+1]
- 후보 중 score가 가장 높은 것을 선택
score = prominence / (1 + 0.12 × distance_from_edge)
prominence = peak 높이 - valley(소변 방향)
distance = |peak위치 - low-echo edge|
→ low-echo에 가까운 peak이 더 높은 점수 (먼 peak은 패널티)
valley 스캔 시 최소값에서 50 이상 올라가면 early stop
(기존 10에서 50으로 상향 → 더 깊은 valley까지 탐색)
4. 후벽이 80칸 넘으면 → 구간 뒤쪽 절반에서 다시 찾기
5. 결과: ant(전벽 위치), post(후벽 위치), urine_len(소변 길이)
```
### 파형 차트에서 보이는 것
```
색상 의미
─────────────────────
연한 파랑 Raw ADC (원본 신호)
진한 파랑 Denoised (노이즈 제거 후)
주황 점선 Threshold (1150)
노란 영역 Low-echo span (1150 이하 구간)
시안 영역 Urine region (ant ~ post)
초록 선+점 Anterior wall (전벽)
빨강 선+점 Posterior wall (후벽)
```
---
## 5.5 LR Ratio 계산 — x-z 타원 피팅 (2026-05-07 업데이트, appshare #21)
### 알고리즘 (x-z 평면 6점 타원 피팅)
```
1. CH4/CH5 벽 좌표 (x-z 평면):
- ant/post 벽의 x좌표: sx + d × cos(α) × sin(β)
- ant/post 벽의 z좌표: d × cos(α) × cos(β)
→ 최대 4개 lateral 점
2. Center 벽 좌표를 lateral SI 높이로 보간:
- CH1, CH2의 z_ant, z_post를 선형 보간 (x=0)
→ 2개 center 점
3. 6개 경계점으로 x-z 축 정렬 타원 피팅:
- 정규화 후 α·x̂² + β·ẑ² + γ·x̂ + δ·ẑ = 1
- least squares → b_lr (LR 반축), a_ap (AP 반축)
- lr_ratio = b_lr / a_ap (하한 1.0)
4. Shrinkage:
- confidence = ((1 - avg_chord_ratio) / 0.10).clamp(0, 1)
- lr_final = lr_prior(1.2) + (lr_raw - 1.2) × confidence
```
---
## 5.6 BV Estimation — 용적 계산
### iOS와 다른 점
```
iOS: estimateBladderVolume(5채널, lr_ratio=1.0)
Android: estimateBladderVolume6ch(6채널)
→ center 4채널 분리 + lateral lr_ratio 계산
→ estimateBladderVolume(4채널, lr_ratio=동적)
```
### Frustum(절두체) 적분
```
┌──╮ ← Top Cap
╱ ╲
│ CH3 │ ← 층 3 (절두체)
│ │
│ CH2 │ ← 층 2 (절두체)
│ │
│ CH1 │ ← 층 1 (절두체)
╲ ╱
└──╯ ← Bottom Cap
각 층의 부피 = (높이/3) × (위 면적 + 아래 면적 + √(위×아래))
총 부피 = 바닥 + 층1 + 층2 + 층3 + 뚜껑
```
### 파이프라인 (2026-05-07 업데이트, appshare #21/#22)
| Step | 뭘 하나? | 수식 |
|------|---------|------|
| 0 | Center wall 보간/정리 | gap=1 보간, gap≥2 top 그룹 제거 |
| 1 | Edge 채널 필터 | CH3이 CH1/CH2 대비 0.9배 미만이면 short |
| 2 | 칸 번호 → 실제 거리(mm) | d = 6.85 + 칸번호 × DPS |
| 2-1 | Post median outlier 제거 | median ±25% 밖 채널 제거 (≥3ch) |
| 3 | 방광 직경 구하기 (각도 보정) | D = (뒷벽거리 - 앞벽거리) × cos(각도) |
| 4 | 단면적 구하기 | S = (π/4) × D² × lr_ratio |
| 5 | 각 층의 높이 위치 + 벽 좌표 | y, z 벽 좌표 (타원 피팅용) |
| 6 | 아래→위 순서로 정렬 | - |
| 7 | y-z 타원 피팅 → cap 높이 | 반복 outlier 제거 (잔차>0.5 시 worst 제거, 최소 5점) |
| 7-1 | b_si 상한 | a_ap × 1.3 (해부학적 SI/AP 제한) |
| 7-2 | Top cap 상한 | 미검출 상위 채널 빔 y좌표 기준 제한 |
| 8 | 층별 부피 합산 | 절두체 공식 |
| 9 | 바닥 뚜껑 (spherical cap) | V = S×h/2 + π×h³/6 |
| 10 | 윗 뚜껑 (cone) | V = S×h/3 |
### 물리 상수
| 상수 | 값 | 의미 |
|------|-----|------|
| `distancePerSample` | **1.771** mm | 샘플 당 거리 (iOS: 1.974) |
| `delayOffsetMm` | 6.85 mm | ADC 전 고정 전파 지연 |
| `areaK` | π/4 | 원형 단면적 계수 |
---
# 6. Placement Guide — 센서 위치 안내
## 센서 배치도 (실제 기기 스펙)
```
[CH3] 30° ← 가장 아래 (치골쪽, y=0mm)
│
[CH4] [CH2] 20° [CH5] ← 중앙 + 좌우 날개
왼쪽 │ 오른쪽
[CH1] 10° ← 위쪽
│
[CH0] 0° ← 가장 위 (배꼽쪽, y=21mm)
```
## 방향 안내 로직 — 3모드 (2026-05-11 업데이트)
개발자 모드 Settings에서 전환 가능: **Gradient** (기본) / Boundary / **SWEEP**
### 조작 흐름 (Start 버튼 필수)
```
화면 진입 → "Place VesiScan above the pubic bone, then press Start"
↓ Start Alignment 버튼 탭 (96dp 대형)
전체 상태 리셋 → 1초 간격 연속 스캔 시작
↓
VERTICAL → LATERAL → GREEN (순차, 건너뛰기 없음)
↓ GREEN 7초 유지 + 3회 연속 실패 안 하면
Start Scanning 버튼 활성화 (초록색)
```
### Gradient 모드 (기본) — 신호 길이 비교
```
시작: 치골 위
1단계 VERTICAL:
0~1채널: ch0/ch1 감지→↑, ch2/ch3 감지→↓
2+채널: weighted center gradient
weightedCenter = Σ(ch_pos × len) / Σ(len)
gradient = weightedCenter - 1.5
gradient > 0 → "Slide down ↓" (ch3쪽 길다 = 방광 아래)
gradient < 0 → "Slide up ↑" (ch0쪽 길다 = 방광 위)
|gradient| < 0.3 + centerDetected >= 4 → "Checking..." → LATERAL
2단계 LATERAL:
|len4 - len5| ≤ 10 sample → "Final check..." → GREEN
lrSymmetry ≥ 0.5 → "Final check..." → GREEN
아니면 "Slide left ←" / "Slide right →"
3단계 GREEN:
CV ≤ threshold (V0=0.07, V1/V2=0.10, 5회마다 +0.03 완화)
|lrDev| ≤ 0.20
둘 다 통과 → "In position!" (7초 hold)
3회 연속 실패 → "Position lost. Restart above pubic bone." + 스캔 중지
```
### SWEEP 모드 (CKLaw) — 위에서 아래로 쓸어내리기
```
시작: 배꼽 근처 → 아래로 내림
1단계 VERTICAL:
① 0채널: "Place near navel, slide down ↓"
② 1~3채널: "Slide down ↓" (채널이 하나씩 켜짐)
③ 4채널: sagittalArgMaxCh로 정렬 판정
argmax=0 → 정중앙! → LATERAL
argmax=1~3 → "Slide down" (아직 더 내려야)
④ 4채널→줄어듦: "Past optimum — slide back up ↑"
+ 좌우 차이 ≤10 → LATERAL 건너뛰고 바로 GREEN
2단계 LATERAL:
lateralOffsetMm (mm 단위 정밀 안내)
|offset| ≤ 8mm → GREEN
3단계 GREEN:
CV + LR dev + R-peak gate (bestR - currentR ≤ 1.5mm)
```
### Boundary 모드 — 올라갔다 내려오기
```
시작: 치골 위 → 위로 올림
채널 수 최대→감소 감지 → "Too high, lower" → 복귀 시 LATERAL
```
### GREEN 판정 상수
| 상수 | 값 | 의미 |
|------|-----|------|
| DEFAULT_CV_THR | V0=0.07, V1/V2=0.10, 기타=0.08 | 기본 CV 임계 |
| CV 완화 | +0.03 per 5 scans | 5회마다 임계값 증가 |
| DEFAULT_LR_DEV_THR | 0.20 | LR 비대칭 임계 |
| 절대 LR 차이 | ≤10 sample | LATERAL 즉시 통과 |
| R-peak tolerance | 1.5mm | SWEEP 모드 GREEN gate |
| GREEN hold | 7초 | GREEN 진입 후 최소 유지 시간 |
| GREEN exit | 3회 연속 실패 | hold 이후 이탈 조건 |
| 디바운스 | 1.5초 (같은 phase) / 3초 (phase 전환) | |
| Phase 보호 | 3스캔 | phase 전환 후 힌트 강제 유지 |
### 힌트 텍스트 (모든 방향 힌트에 화살표 포함)
| 상황 | 텍스트 |
|------|--------|
| 상하 이동 | "Slide up ↑" / "Slide down ↓" / "Slide up slightly ↑" / "Slide down slightly ↓" |
| 좌우 이동 | "Slide left ←" / "Slide right →" |
| 확인 중 | "Checking..." |
| 최종 확인 | "Final check..." |
| 최적 위치 | "In position!" |
| 위치 이탈 | "Position lost. Restart above pubic bone." |
| 센서 미부착 | "Sensor detached. Restart above pubic bone." |
## 연속 스캔 동작 (2026-05-26 업데이트)
```
"Start Alignment" 버튼 탭 (waitingForStart → false)
↓
전체 상태 리셋 (phase, score, debounce, greenFail 등)
↓
즉시 첫 스캔 (maa 6채널)
↓ 결과: CV, 방향, Score 표시 (28sp 대형 힌트)
600ms 대기 (이전 1000ms → 600ms 단축, BleManager throttle과 동기화)
↓
자동 재스캔 (scanCount 증가 → CV threshold 완화)
↓
... 반복 (GREEN 도달 or 이탈 시 자동 중지)
is_pass = true → Lock → 7초 hold → "Start Scanning" 버튼 활성화
```
### v6 화면 디자인 변경
- 큰 "Sensor Alignment" 38sp ExtraBold 검정 (Maximum Bladder Capacity 카드와 톤 통일)
- Hint 텍스트 "pubic bone" 인라인 빨강 ExtraBold (AnnotatedString + SpanStyle)
- 3-stop vertical gradient 배경 (MlTeal 14% / 흰 / MlPrimary 10%) — GREEN 진입 시 단색 톤
- Canvas 방향 화살표 4종 제거 (hint 텍스트만으로 안내)
- 치골 아치 라인 + halo + endpoint 모두 제거
- 토르소 chestY 0.06 → 0.09 (실루엣 위로 16% shift)
- Body 상단 Spacer 8dp
---
# 7. "Spot" 버튼 측정 흐름
```
Spot 버튼 탭
│
▼
앱 → 센서: "maa" 명령 (6채널 전부 측정해!)
│
▼ (~0.7초 후)
센서 → 앱: 6개 채널의 초음파 에코 데이터
│
▼
[전처리] SG 필터만 (TVD 없음)
│
▼
[벽 찾기] 각 채널에서 방광 앞벽/뒷벽 위치 탐색
│ - 신호가 1150 이하로 떨어지는 구간 = 소변
│ - 그 양옆의 prominence 가장 큰 peak = 벽
│
▼
[lr_ratio] CH4/CH5 lateral에서 타원 비율 계산
│ - 2개 lateral → 타원 방정식 풀기
│ - 1개 → 중심 가정, 0개 → 1.0 (원형)
│
▼
[용적 계산] Frustum(절두체) 적분
│ - CH0~CH3 (center 4채널)만 사용
│ - 단면적 = π/4 × D² × lr_ratio
│ - 층별 절두체 공식 + Cap 보정
│
▼
화면에 "305 ml" 표시! (도넛차트 + 레벨 업데이트)
```
## Auto Scan 모드 (2026-05-26 업데이트)
"Auto Scan" 버튼으로 자동 반복 측정:
- **최소 600ms 간격** 자동 측정 (BLE maa state-based throttle 보장)
- LaunchedEffect loop delay: 1500ms → **600ms** (FW VBTFW0116 + MTU 247 + CONN_PRIORITY HIGH 적용 후 응답 ~330ms이므로 일관 단축)
- 첫 5회: "—" 표시 (데이터 수집 중)
- 5회 이상: **sliding window trimmed mean** (10개 윈도우, 최대/최소 각 1개 제외 → 8개 평균)
- "Stop Scan" 버튼으로 종료
- **Voiding/Catheterization 버튼 클릭 시 Auto 자동 stop + 측정 상태 정규화** (displayMaxVolumeMl/window/timer 모두 0) — v6 신규
- Auto fail: 2ch+ missing 5회 연속 → "Position lost. Restart from alignment" 다이얼로그
## Single Scan 모드 (2026-05-11 업데이트)
"Single Scan" 버튼 1탭 → **5회 측정 → trimmed mean**:
- 최대 8회까지 재시도 (유효값 5개 수집 목표, 최소 3개)
- 측정 간격: maa 600ms throttle이 간격 보장 (v6에서 800 → 600 단축)
- 중간 값 화면 미표시 (isSpotInProgress 플래그)
- 1초 쿨다운 후 재사용 가능
## 측정 실패 시 자동 재시도
```
15초 타임아웃 → retry 1/2 (collector reset + 재전송)
↓
15초 타임아웃 → retry 2/2
↓
실패 → isMeasuring=false, 에러 로그
```
---
# 8. "Catheterize" 버튼 — 배뇨 기록
4가지 입력 방식 (iOS DiaryRecordSheet 스타일):
```
┌──────────────────────────────┐
│ 배뇨 기록 │
│ 추정 용량: 305 ml │
│ │
│ ✏️ 수기 입력 (주황) → │
│ 🎤 음성 입력 (파랑) → │
│ 📷 카메라 입력 (보라) → │
│ ⚡ 빠른 저장 305 ml → │ ← Quick Save (NEW!)
│ │
│ [취소] │
└──────────────────────────────┘
```
- **수기**: 숫자 키패드로 ml 직접 입력
- **음성**: 음성인식 → 숫자 추출
- **카메라**: YOLO 소변컵 감지 + OCR 측정
- **빠른 저장**: 현재 추정 용량으로 즉시 저장 ⚡
---
# 9. 파일 구조
```
medilightv2android/
├── ble/
│ ├── BleManager.kt ← BLE 통신 (자동 reconnect 포함)
│ ├── PiezoPacketCollector.kt ← 멀티패킷 수집 + Endian 감지
│ └── CRC16.kt ← 체크섬 계산
│
├── managers/
│ ├── PiezoEchoAnalyzer.kt ← Low-echo detection (벽 찾기)
│ ├── PiezoBVEstimator.kt ← 6ch BV 추정 + lr_ratio 계산
│ ├── UrinAI.kt ← 소변 존재 판정 (문지기)
│ ├── GreenZoneConstants.kt ← 공유 상수 (한 곳에서 관리)
│ └── PiezoEchoAnalyzer.kt ← NIRS 산소 포화도 계산
│
├── models/
│ ├── BladderLevel.kt ← 방광 레벨 (0~10)
│ ├── UrgencyLevel.kt ← 긴급도 (Safe/Warning/Urgent)
│ └── PiezoSettings.kt ← 센서 설정 (자세/위치/최대용량)
│
├── services/
│ ├── ReminderService.kt ← 배뇨 알림 (5종 랜덤, 유동 스케줄)
│ ├── MeasurementLogService.kt ← 측정 로그 자동 저장 (JSONL)
│ └── NotificationService.kt ← 긴급도 알림
│
├── ui/views/
│ ├── monitoring/
│ │ ├── PiezoMonitoringView.kt ← ★ 메인 측정 화면
│ │ ├── PlacementGuideView.kt ← ★ 센서 위치 안내
│ │ ├── VivamyoMonitoringView.kt ← NIRS 모니터링
│ │ ├── VoidingDiaryView.kt ← 배뇨일지
│ │ └── UrineCameraScreen.kt ← 소변컵 카메라 측정
│ ├── reminder/
│ │ └── ReminderSettingsView.kt ← 알림 설정 (iOS 동기화)
│ └── (onboarding, registration, pin, ...)
│
└── AppState.kt ← 전역 상태 관리 + 화면 라우팅
(v6: placementFromMonitoring 플래그 +
enterPlacementFromMonitoring() / backFromPlacementGuide())
```
## 포팅 원본 대조
| Kotlin 파일 | 원본 | 원본 언어 |
|-------------|------|----------|
| PiezoEchoAnalyzer | low_echo_detection_method_b.py | Python (원은지) |
| PiezoBVEstimator | bv_estimation.py (6ch) | Python (원은지) |
| UrinAI | UrinAI.kt (xBench) | Kotlin (권오준) |
| GreenZoneConstants | GreenZoneConstants.kt (xBench) | Kotlin (권오준) |
| config (PiezoHW) | config_6ch.py | Python (원은지) |
---
# 10. 상수 조정 가이드
### 증상별 조정
| 증상 | 원인 | 조정 |
|------|------|------|
| "소변 있는데 No Signal" | strict 임계값 너무 낮음 | `liquidThrStrict` ↑ |
| "소변 없는데 감지됨" | loose 임계값 너무 높음 | `liquidThrLoose` ↓ |
| "작은 방광 못 잡음" | 최소 기준 너무 높음 | `minChord` ↓, `minLiquidRun` ↓ |
| "벽을 못 찾음" | low-echo 임계값 안 맞음 | `lowEchoAmp` 조정 |
| "용적이 너무 큼/작음" | DPS 캘리브레이션 | `distancePerSample` 조정 |
---
# 11. 측정 로그 자동 저장
### 저장 위치
```
Android: app내부저장소/vesiscan_logs/YYYY-MM-DD.jsonl
```
### 로그 이벤트
| 이벤트 | 내용 |
|--------|------|
| `measurement` | 용적, 채널별 ant/post/score, BV 상세, lr_ratio |
| `placement` | 6채널 감지 상태, 방향 안내 |
| `ble_connected` | 기기 이름, 연결 시각, preset |
| `ble_disconnected` | 기기 이름, 해제 시각 |
### 보관 정책
- 일별 파일 자동 생성
- 90일 이후 자동 삭제
---
# 12. Python ↔ Android 1:1 검증 결과
## 검증 대상
| Python 파일 | Android 파일 | 검증 결과 |
|------------|-------------|----------|
| config_6ch.py | PiezoBVEstimator.kt (PiezoHW) | ✅ 완벽 일치 |
| denoising.py | PiezoEchoAnalyzer.kt (denoise, sgSmooth) | ✅ 완벽 일치 |
| low_echo_detection_method_b.py | PiezoEchoAnalyzer.kt (detectLowEcho) | ✅ 완벽 일치 |
| bv_estimation.py (6ch) | PiezoBVEstimator.kt (estimateBladderVolume6ch) | ✅ 완벽 일치 |
| span_utils.py | PiezoEchoAnalyzer.kt (findPeaks1D, mergeClose) | ✅ 완벽 일치 |
## 상수 비교 (전수 검증)
### 하드웨어 프리셋 (config_6ch.py ↔ PiezoHW)
| 항목 | Python V0 | Android V0 | Python V1 | Android V1 | Python V2 | Android V2 |
|------|-----------|------------|-----------|------------|-----------|------------|
| CH0 z | 21.0 | 21.0 ✅ | 20.1 | 20.1 ✅ | 19.3 | 19.3 ✅ |
| CH1 z | 14.0 | 14.0 ✅ | 12.8 | 12.8 ✅ | 13.0 | 13.0 ✅ |
| CH2 z | 7.0 | 7.0 ✅ | 7.0 | 7.0 ✅ | 6.7 | 6.7 ✅ |
| CH3 z | 0.0 | 0.0 ✅ | 0.0 | 0.0 ✅ | 0.0 | 0.0 ✅ |
| CH4/5 z | 10.5 | 10.5 ✅ | 9.9 | 9.9 ✅ | 9.85 | 9.85 ✅ |
| CH0 deg | 0.0 | 0.0 ✅ | 6.89 | 6.89 ✅ | 0.0 | 0.0 ✅ |
| CH1 deg | 0.0 | 0.0 ✅ | 0.0 | 0.0 ✅ | -6.89 | -6.89 ✅ |
| CH2 deg | 0.0 | 0.0 ✅ | -6.89 | -6.89 ✅ | -13.66 | -13.66 ✅ |
| CH3 deg | 0.0 | 0.0 ✅ | -13.66 | -13.66 ✅ | -20.20 | -20.20 ✅ |
| CH4/5 deg | 0.0 | 0.0 ✅ | -6.87 | -6.87 ✅ | -6.87 | -6.87 ✅ |
| CH4/5 LR | 0.0 | 0.0 ✅ | ∓3.42 | ∓3.42 ✅ | ∓3.42 | ∓3.42 ✅ |
### 음향 상수
| 상수 | Python (6ch) | Android | 일치 |
|------|-------------|---------|------|
| DISTANCE_PER_SAMPLE | 1.771 mm | 1.771 mm | ✅ |
| DELAY_OFFSET_MM | 6.85 mm | 6.85 mm | ✅ |
| AREA_K | π/4 | π/4 | ✅ |
### Low-echo Detection 상수
| 상수 | Python | Android | 일치 |
|------|--------|---------|------|
| LOW_ECHO_AMP | 1150 | 1150 | ✅ |
| LOW_MIN_LEN | 3 | 3 | ✅ |
| MERGE_GAP_MAX | 3 | 3 | ✅ |
| PEAK_SEARCH_WIN | 20 | 20 | ✅ |
| POST_MAX_IDX | 80 | 80 | ✅ |
| MIN_PEAK_MARGIN | 30 | 30 | ✅ |
| MIN_URINE_LEN | 3 | 3 | ✅ |
### LR Ratio 상수
| 상수 | Python | Android | 일치 |
|------|--------|---------|------|
| DEFAULT_LR_RATIO | 1.0 | 1.0 | ✅ |
| LR_RATIO_NO_DETECTION | 1.0 | 1.0 | ✅ |
| LR_RATIO_INVALID | 1.2 | 1.2 | ✅ |
| LR_PRIOR | 1.2 | 1.2 | ✅ |
## 알고리즘 비교 (로직 수준)
### 전처리 (denoising.py ↔ PiezoEchoAnalyzer.denoise)
```
Python: denoise() → sg_smooth() (SG 필터만, TVD 없음)
Android: denoise() → sgSmooth() (SG 필터만, TVD 없음)
SG 계수: [-3, 12, 17, 12, -3] / 35 (양쪽 동일)
Edge 처리: 양 끝 2샘플에 2차 다항식 피팅 (양쪽 동일)
```
**Adaptive TGC**: Python 공유 코드에 미포함. 원은지님이 언급했으나 `denoising.py`에 TGC 코드 없음.
### Low-echo Detection (Prominence 기반)
```
1. low_mask = sg ≤ 1150 (양쪽 동일 ✅)
2. span 추출 (≥3칸) (양쪽 동일 ✅)
3. 인접 span 합치기 (gap≤3) (양쪽 동일 ✅)
- gap 내 peak > 1155면 합치지 않음
4. 양쪽 벽 prominence 탐색 (양쪽 동일 ✅)
- ant: 오른쪽 valley 기준
- post: 왼쪽 valley 기준
- 최대 3개 후보 중 prominence 최대
5. post > 80 → 뒤쪽 절반에서 재탐색 (양쪽 동일 ✅)
6. urineLen = post - ant - 1 (양쪽 동일 ✅)
```
### LR Ratio 계산 (compute_lr_ratio)
```
1. Center AP 직경: CH1/CH2의 (post-ant)×cos(θ) 평균 (양쪽 동일 ✅)
2. Lateral 직경:
P = cos(α)·cos(β), Q = cos(α)·sin(β)
D_lateral = (dFar-dNear)·P
y_mid = sensor_x + d_mid·Q (양쪽 동일 ✅)
3-A. 2개 lateral → 타원 방정식:
u = yL·yR·(yR-yL) / denom
d = -(u·(rL²-1) + yL²) / (2·yL)
lr_raw = 2√u / D_center (양쪽 동일 ✅)
3-B. 1개 lateral → 중심 가정:
b = |y_mid| / √(1-r²)
lr_raw = b / (D_center/2) (양쪽 동일 ✅)
4. Shrinkage:
confidence = ((1-avg_ratio)/0.10).clamp(0,1)
lr = 1.2 + (lr_raw-1.2)·confidence (양쪽 동일 ✅)
```
### BV 추정 (Frustum + Cap)
```
1. Edge 필터: CH3 < CH1/CH2 × 0.9 → short (양쪽 동일 ✅)
2. sample → mm: d = 6.85 + idx × 1.771 (양쪽 동일 ✅)
3. 직경: D = (dPost-dAnt) × cos(θ) (양쪽 동일 ✅)
4. 단면적: S = (π/4) × D² × lr_ratio (양쪽 동일 ✅)
5. y좌표: y = sensorZ + dMid × sin(θ) (양쪽 동일 ✅)
6. y 오름차순 정렬 (양쪽 동일 ✅)
7. 포물선 피팅: Phase 0(edge-peak) → Phase 1(1.5σ) → Phase 2(b_eff)
(양쪽 동일 ✅)
8. Frustum: V = (h/3)·(S₁+S₂+√(S₁·S₂)) (양쪽 동일 ✅)
9. Bottom cap: V = S·h/2 + π·h³/6 (항상 구) (양쪽 동일 ✅)
10. Top cap: cone(정상) or sphere(edge short) (양쪽 동일 ✅)
```
## 미구현 항목
| 항목 | Python | Android | 비고 |
|------|--------|---------|------|
| Adaptive TGC | 공유 코드에 없음 | 없음 | 원은지님 확인 필요 |
| Phantom preset (lrRatio) | 있음 | 없음 | 디버깅용, 앱 불필요 |
| Hybrid mode (coord) | 있음 (disabled) | 없음 | Python에서도 미사용 |
---
# 13. BLE 자동 재연결 시스템
## 흐름도
```
정상 통신 중 (5초마다 msn/rsn 주고받음)
↓
기기와 멀어짐 (RSSI -80 → -88 → 응답 없음)
↓
[경로 A] Android GATT 에러 콜백 (status≠0)
→ GATT_ERROR 로그
→ wasConnected=true → scheduleAutoReconnect()
[경로 B] GATT 콜백 안 옴 (좀비 연결)
→ Watchdog Thread (별도 스레드, 5초마다 체크)
→ 15초 무응답 → WATCHDOG_TIMEOUT
→ 강제 disconnect → scheduleAutoReconnect()
↓
scheduleAutoReconnect()
├── 1차: 3초 후 → BLE 스캔 8초 (기기 이름/MAC 매칭)
├── 2차: 5초 후 → BLE 스캔 8초
├── 3차: 10초 후 → BLE 스캔 8초
├── 4차: 10초 후 → BLE 스캔 8초
└── 5차: 10초 후 → BLE 스캔 8초
↓ 발견 시
connectGatt(autoConnect=false) → 빠른 연결 (1~2초)
↓ 5회 모두 실패 시
빨간 배너 "Could not reconnect" + 수동 Reconnect 버튼
```
## Watchdog 상세
- **실행 위치**: 별도 daemon Thread (BLE handler 블로킹 무관)
- **체크 주기**: 5초
- **타임아웃**: 15초 (마지막 RX 기준)
- **GATT close**: watchdog Thread에서 직접 호출 (스레드 안전)
- **UI 갱신**: handler.post로 메인 스레드에서 처리
## GATT 에러 경로 (핵심 버그 수정)
Android BLE는 disconnect 시 `STATE_DISCONNECTED` 대신 `GATT 에러(status≠0)`로
콜백을 보내는 경우가 많음. 기존에는 에러 경로에서 재연결을 시작하지 않아서
첫 끊김 시 재연결이 안 되는 버그가 있었음.
수정: `wasConnected=true`이면 에러 경로에서도 `scheduleAutoReconnect()` 호출.
## BLE 디버그 로거
모든 TX/RX 패킷을 파싱된 형태로 기록:
```
TX: "maa 6ch measure [8B]"
RX: "rsn battery=3850mV (100%) [8B]"
RX: "reb 100samples [208B]"
RX: "raa all-ch complete [8B]"
```
내보내기: Downloads/VesiScan_BLE_날짜시간.log
---
# 14. BLE 명령어 포맷 (펌웨어 특이사항)
이 기기(VBTFW0111 ~ **VBTFW0116**)는 파라미터 없는 명령어에도 **`cmd? `(물음표+공백)** 포맷이 필요합니다.
### VBTFW0116 신규 사항 (펌웨어 측 패치)
```
원인:
- Central(앱)의 Link Layer ACK / connection event 처리 속도보다 Peripheral이
notify enqueue를 더 빨리 시도해서 SoftDevice TX queue 포화
- 기존 FW: pending slot 1개만 보관 (이미 pending 있으면 다음 패킷 drop)
- PC 환경에선 Central 처리 빨라 문제 없음 → 실제 앱 환경에선 OS/connection 영향으로 포화 잦음
개선:
- pending slot 1 → 8개 확장 → TX queue 포화 시 보관/재전송 → 중간 채널 데이터 유실 감소
- 결과: ADC drop 현상 재현되지 않음 (장시간 반복 테스트 진행 중)
안드로이드 측 짝꿍 변경:
- MTU 247 협상 (208B reb 단일 노티)
- CONNECTION_PRIORITY_HIGH 요청 (interval 단축 시도)
- maa throttle을 state-based gate로 강화 (잔여 reb 폐기 방지)
```
```kotlin
// ❌ 안 됨 (rxs: cmd_not_supported 응답)
CRC16.buildCommand("mid") // → "mid?" + CRC
// ✅ 됨
CRC16.buildCommandASCII("mid", " ") // → "mid? " + CRC
```
### 명령어별 포맷
| 명령 | 함수 | 포맷 | 응답 |
|------|------|------|------|
| `maa?` | `sendChannelsOnly()` | buildCommand("maa", [0]) | reb:×6 → raa: |
| `mbb? ` | `sendFullMeasurement()` | buildCommandASCII("mbb", " ") | rbb: → reb:×6 → raa: |
| `msp? ` | `sendImuQuery()` | buildCommandASCII("msp", " ") | rsp: (18B, accel+gyro) |
| `mid? ` | `sendDeviceInfoQuery()` | buildCommandASCII("mid", " ") | rid: (HW/SN/FW) |
| `mls?` + state | `sendLedMode(state)` | buildCommand("mls", [state]) | rls: |
| `msn?` + 0 | `sendBatteryQuery()` | buildCommand("msn", [0]) | rsn: (mV) |
| `mpa?` + freq/cyc | `sendPiezoPowerOn()` | buildCommand("mpa", [...]) | rpa: |
### 기기 정보 (연결 시 자동 조회)
```
연결 직후 mid? 전송 → rid: 응답:
DEVICE_INFO VBTHW0100 VBT26040001 VBTFW0111
├ HW Version ├ Serial Number └ FW Version
```
---
# 15. 자동 측정 루프 (IMU + 6ch)
### 동작 방식
```
자동측정 시작 (Spot 롱프레스)
↓
1초차: msp? (IMU) → rsp: accel(x,y,z) + gyro(x,y,z)
2초차: msp? (IMU)
...
9초차: msp? (IMU)
10초차: maa? (6ch 측정) → reb:×6 → raa: → BV 파이프라인
11초차: msp? (IMU)
...
20초차: maa? (6ch 측정)
... 반복 (Stop 누를 때까지)
```
### 병렬 통신
배터리 쿼리(msn, 5초)와 IMU/측정이 동시에 진행됩니다:
```
13:52:09.549 TX msp IMU query [7B]
13:52:09.607 TX msn battery query [8B] ← 거의 동시
13:52:09.707 RX rsp IMU data [18B]
13:52:09.707 RX rsn battery=4128mV [8B] ← 둘 다 응답 옴
```
---
# 16. LED 모드 (Placement Guide 연동)
### LED 상태 코드
| state | 이름 | 용도 |
|-------|------|------|
| 0 | OFF | 기본 |
| 4 | DETACH_WARNING | 미부착 시 |
| 5 | ALIGN_SEARCHING | 정렬 모드 탐지 중 |
| 6 | ALIGN_COMPLETE | 정렬 모드 탐지 완료 |
| 7 | ERROR | 오류 발생 |
### Placement Guide 연동 (3단계 판정)
```
매 3초 스캔 →
│
├─ [1단계] 미부착 감지 (DetachmentDetection)
│ mean(|y|) < 100 AND std(y) < 100 → 미부착
│ → LED 4 (DETACH_WARNING)
│ → "Sensor detached — check contact"
│
├─ [2단계] Placement 판정 (CV threshold)
│ CV > 0.15 또는 |lr_dev| > 0.15 → 부적절
│ → LED 5 (ALIGN_SEARCHING)
│ → 방향 가이드 (MOVE UP/DOWN/LEFT/RIGHT)
│
└─ [3단계] 최적 위치
CV ≤ 0.15 AND |lr_dev| ≤ 0.15 → PASS
→ LED 6 (ALIGN_COMPLETE)
→ "Best Placement!"
Stop Scanning → LED 0 (OFF)
```
---
# 17. 미부착 감지 (Detachment Detection)
### 원본: detachment_detection.py (원은지님, 2026-04-24)
### 알고리즘
```
입력: 6채널 ADC raw 데이터 (100 samples × 6ch)
1. Center 채널 추출 (CH0~CH3, 4채널)
2. Baseline 제거:
baseline = median(signal[80:100]) ← 끝부분 20개 샘플의 중앙값
y = signal[20:80] - baseline ← 중간 60개 샘플에서 baseline 차감
3. 특징 계산 (채널별):
mean_abs = mean(|y|) ← 절대값 평균 (신호 크기)
std = std(y) ← 표준편차 (신호 변동성)
4. 4채널 평균:
mean_abs = avg(mean_abs_ch0, ..., mean_abs_ch3)
std_val = avg(std_ch0, ..., std_ch3)
5. 판정 ("and" rule):
미부착 = mean_abs < 100 AND std_val < 100
```
### 왜 이 방식인가?
```
부착된 경우: 미부착된 경우:
초음파가 조직을 통과 → 반사 공기 중 → 신호 거의 없음
signal[20:80] 구간에 변동 있음 signal[20:80] 구간이 평탄
mean_abs >> 100, std >> 100 mean_abs < 100, std < 100
실측 데이터 (원은지님):
미부착 max: mean=33.7, std=43.4 ← threshold 100보다 훨씬 낮음
부적절 min: mean=254.8, std=234.7 ← threshold 100보다 훨씬 높음
→ ~2.5배 안전 마진
```
### 상수
| 상수 | 값 | 의미 |
|------|-----|------|
| `WINDOW_START` | 20 | 분석 구간 시작 (ringdown 이후) |
| `WINDOW_END` | 80 | 분석 구간 끝 |
| `BASELINE_WINDOW_START` | 80 | baseline 구간 시작 |
| `BASELINE_WINDOW_END` | 100 | baseline 구간 끝 |
| `THR_MEAN` | 100 | mean(|y|) 임계값 |
| `THR_STD` | 100 | std(y) 임계값 |
---
# 18. Placement 전체 파이프라인 (2단계 순차 탐색 + CV)
### 대표님 요구사항 반영 (2026-04-27)
### 전체 흐름도
```
Start Scanning (3초 간격 자동 반복)
↓
[매 스캔] 6채널 ADC 수신 → Detachment 체크
↓
┌───────────────────────────────────────┐
│ 0. Detachment Detection │
│ mean(|y|) < 100 AND std < 100? │
│ → YES: LED 4 (미부착) → 다음 스캔 │
└───────────────────────────────────────┘
↓ NO (부착됨)
╔═══════════════════════════════════════╗
║ Step 1/3: 상하 탐색 (VERTICAL) ║
║ ║
║ 안내: "센서를 아래에서 위로 천천히 ║
║ 이동하세요" ║
║ ║
║ CH3,2,1,0 low echo 검출 ║
║ 조건: 3개 이상 + 크기 유사 ║
║ 또는 3≥2≥1≥0 패턴 ║
║ ║
║ 미달 → "위로 이동" / "아래로 이동" ║
║ PASS → Step 2로 자동 전환 ║
╚═══════════════════════════════════════╝
↓ PASS
╔═══════════════════════════════════════╗
║ Step 2/3: 좌우 탐색 (LATERAL) ║
║ ║
║ 안내: "센서를 좌우로 천천히 ║
║ 이동하세요" ║
║ ║
║ CH4, CH5 대칭성 확인 ║
║ |len4-len5| / max < 0.3 → 대칭 ║
║ ║
║ 미달 → "왼쪽으로" / "오른쪽으로" ║
║ 상하 벗어남 → Step 1로 복귀 ║
║ PASS → Step 3로 자동 전환 ║
╚═══════════════════════════════════════╝
↓ PASS
╔═══════════════════════════════════════╗
║ Step 3/3: 최종 Green (CV 확인) ║
║ ║
║ CV = std(depths) / mean(depths) ║
║ CV ≤ threshold → Green! ║
║ ║
║ Threshold Relaxation (5회 단위): ║
║ 1~5회: 0.15 ║
║ 6~10회: 0.20 ║
║ 11~15회: 0.25 ║
║ 16+회: 0.30 (최대) ║
║ ║
║ CV 미달 → Step 1부터 다시 ║
║ PASS → LED 6 (ALIGN_COMPLETE) + Lock ║
╚═══════════════════════════════════════╝
↓ Green!
"Start Scanning" 버튼 활성화 (초록, 96dp)
```
### 태블릿 대응
```
fontScale = (screenWidth / 360f).coerceIn(1.0, 1.8)
→ 폰(360dp)=1.0배, 태블릿(600dp+)=~1.6배
버튼/힌트 텍스트, InfoCard, 배터리/카테터 표시 등 전체 적용
```
### 패킷 드랍 대응
```
maa? → reb 4개만 수신 (CH4,CH5 드랍)
→ INCOMPLETE_SCAN 4/6 → 자동 retry (최대 2회)
→ retry 후 6채널 수신 → 정상 진행
```
### BLE 디버그 로그 출력 예시
```
13:45:39 WARN DETACHED mean=12.3 std=8.7
13:45:42 INFO PLACEMENT_SEARCHING cv=0.234 dir=MOVE UP moderate score=35
13:45:45 INFO PLACEMENT_SEARCHING cv=0.180 dir=MOVE DOWN slight score=52
13:45:48 INFO PLACEMENT_PASS cv=0.112 lr_dev=0.089 score=78
```
---
# 19. 런타임 캘리브레이션 (Settings 패널)
측정 화면 Settings(톱니바퀴)에서 실시간으로 조절 가능:
### Low Echo Threshold (주황색 슬라이더, 800~2000)
```
기본값: 1150
용도: low-echo 판정 임계값. 올리면 벽 경계를 더 일찍 잡음.
문제: CH4/CH5 lateral에서 threshold가 너무 낮으면 urine을 과도하게 잡음
→ len이 비정상적으로 길어짐 → lr_ratio 왜곡 → BV 과소평가
해결: threshold를 1200~1300으로 올려서 테스트
```
### Distance/Sample (초록색 숫자 직접 입력, 0.5~5.0mm)
```
기본값: 1.771 mm/sample
용도: ADC 샘플 1개당 물리적 거리. BV 계산에 직접 영향.
d_mm = delay_offset + sample_index × distance_per_sample
입력: 소수점 3자리까지 정밀 입력 가능 (슬라이더 대신 텍스트 필드)
조절: 기기별 캘리브레이션 시 팬텀 실측값과 맞추기 위해 사용
```
### SG Filter (토글 스위치, ON/OFF)
```
기본값: ON
용도: Savitzky-Golay 5-tap 스무딩 필터 on/off
ON: SG 적용 → 부드러운 파형, 안정적인 벽 탐지
OFF: Raw ADC 그대로 → 원본 신호로 벽 탐지 (디버깅용)
효과: 그래프에서 진한 파랑(Denoised)이 연한 파랑(Raw)과 동일해지면 OFF
```
### lr_ratio 하한 제한
```
lr_ratio = computeLrRatio(center, lateral)
result = max(lr_ratio, 1.0) ← 원형보다 좁은 단면 방지
문제: 500ml 팬텀에서 CH4/CH5 짧음 → lr=0.32 → 100ml 과소평가
수정: 하한 1.0 제한 → 정상 용적 출력
```
---
# 20. BLE 명령어 Endian 통일 (테스터 exe 분석)
### 테스터 코드 (vsbtester/ble.js buildPacket) 분석 결과
```javascript
// 파라미터 없는 명령: '?' 뒤에 공백 0x20 추가
if (payloadParams.length === 0) {
bytes.push(0x20);
}
// 파라미터 있는 명령: 16비트 Big Endian
for (const p of payloadParams) {
bytes.push((n >> 8) & 0xff); // High byte first
bytes.push(n & 0xff); // Low byte
}
// CRC: Little Endian
bytes.push(crc & 0xff);
bytes.push((crc >> 8) & 0xff);
```
### Android 통일 결과
| 명령 | 파라미터 | 포맷 | 함수 |
|------|---------|------|------|
| maa | 없음 | `maa? ` + CRC(LE) | buildCommandASCII(" ") |
| mbb | 없음 | `mbb? ` + CRC(LE) | buildCommandASCII(" ") |
| msp | 없음 | `msp? ` + CRC(LE) | buildCommandASCII(" ") |
| mid | 없음 | `mid? ` + CRC(LE) | buildCommandASCII(" ") |
| mpa | [freq,cyc] | `mpa?` + params(BE) + CRC(LE) | buildCommandBE |
| mec | [6개] | `mec?` + params(BE) + CRC(LE) | buildCommandBE |
| mls | [state] | `mls?` + [0x00,state](BE) + CRC(LE) | buildCommandBE |
| msn | [0] | `msn?` + [0x00,0x00](BE) + CRC(LE) | buildCommandBE |
---
# 21. ADC 데이터 CSV 저장
### 파일 위치
```
Downloads/VesiScan_ADC/YYYY-MM-DD.csv
```
하루 1개 CSV 파일에 매 측정마다 append. 파일 탐색기에서 바로 접근 가능.
### CSV 포맷
```csv
scan_id,timestamp,volume_ml,lr_ratio,threshold,dps,sg_filter,channel,s0,s1,...,s99
1,14:30:12,305.2,1.20,1150,1.771,ON,CH0,1823,1801,...,1245
1,14:30:12,305.2,1.20,1150,1.771,ON,CH1,1756,1734,...,1198
1,14:30:12,305.2,1.20,1150,1.771,ON,CH2,1802,1788,...,1234
1,14:30:12,305.2,1.20,1150,1.771,ON,CH3,1834,1812,...,1267
1,14:30:12,305.2,1.20,1150,1.771,ON,CH4,1645,1623,...,1123
1,14:30:12,305.2,1.20,1150,1.771,ON,CH5,1721,1698,...,1178
2,14:30:22,419.0,1.20,1221,1.968,ON,CH0,1830,...
3,14:30:32,,1.00,1150,1.771,OFF,CH0,1234,...
```
- `scan_id`: 측정 회차 (같은 ID = 같은 측정의 6채널). BLE 로그에도 동일 ID 기록
- `volume_ml`: BV 추정 결과 (빈칸이면 측정 실패)
- `lr_ratio`: lr_ratio 계산값
- `threshold`: Low Echo Threshold (측정 시점의 설정값)
- `dps`: Distance Per Sample mm (측정 시점의 설정값)
- `sg_filter`: SG 필터 적용 여부 (ON/OFF)
- `channel`: CH0~CH5
- `s0~s99`: Raw ADC 100개 샘플 (SG 적용 전 원본, 항상 raw)
### Python에서 읽기
```python
import pandas as pd
df = pd.read_csv("2026-04-27.csv")
# 1회차 측정 전체
scan1 = df[df.scan_id == 1]
# CH0 ADC 100개
ch0_adc = scan1[scan1.channel == "CH0"].iloc[0, 8:].values
# 전체 측정 용적 추이
volumes = df[df.channel == "CH0"][["scan_id", "timestamp", "volume_ml", "threshold", "dps"]].drop_duplicates()
# threshold 1221로 측정한 것만
df_1221 = df[df.threshold == 1221]
# SG OFF로 측정한 것만
df_nosg = df[df.sg_filter == "OFF"]
```
### BLE 로그와 대조
```
BLE 로그: INFO MEASUREMENT scan=3 vol=305.2ml lr=1.20 valid=4/6
CSV: 3,14:30:12,305.2,1.20,1150,1.771,ON,CH0,...
→ scan=3으로 동일 측정의 ADC + 설정값 + 로그를 대조 가능
```
### 저장 시점
- BV 추정 성공 시: volume_ml + lr_ratio + 설정값 + 6채널 ADC
- BV 추정 실패 시 (center 부족): volume_ml 빈칸 + 설정값 + 6채널 ADC
- 0채널 감지 시: volume_ml 빈칸 + 설정값 + 6채널 ADC (raw 보존)
### Endian 자동 감지
ADC 파싱 시 Big/Little Endian을 자동 감지:
- VBT* 기기: 기본 Big Endian (forceBigEndian=true)
- 2025MEDIP: 기본 Little Endian
- CH0 첫 샘플로 확인, 애매하면 기기 기본값 사용
- CSV에는 항상 정상 파싱된 0~4095 범위의 raw ADC 저장
---
# 22. 향후 과제
| 항목 | 상태 | 비고 |
|------|------|------|
| BLE 6채널 수신 | ✅ 완료 | |
| Foreground Service (화면 잠금 BLE 유지) | ✅ 완료 | PARTIAL_WAKE_LOCK 4시간 |
| Low-echo Detection Method B | ✅ 완료 | VALLEY_STOP=50, EDGE_DECAY=0.12 |
| V4.1 CharlesKWON Method C | ✅ 완료 | 45 파일 walldetect 패키지 |
| Method A (Plateau-based) | ✅ 완료 | contrastRatio × cos(θ) |
| Frustum BV 추정 (6ch) | ✅ 완료 | y-z 타원 피팅 cap + outlier 제거 (#21/#22) |
| lr_ratio x-z 타원 피팅 | ✅ 완료 | 6점 타원 피팅 (#21) |
| Center wall repair | ✅ 완료 | gap=1 보간, gap≥2 top 제거 (#21) |
| BV 타원 피팅 outlier 제거 | ✅ 완료 | 반복 worst 제거, 잔차≤0.5, b_si≤a_ap×1.3 (#22) |
| Back Reflection 처리 | ✅ 완료 | consensus, amp_ratio=1.5, min_votes=3 |
| Placement Guide 3모드 | ✅ 완료 | Gradient / Boundary / SWEEP |
| SWEEP (BladderSphereSeek) | ✅ 완료 | Kasa circle fit + RTracker + argmax |
| Gradient placement | ✅ 완료 | weighted center + R-peak gate GREEN |
| 개발자 모드 | ✅ 완료 | 캐릭터 3탭 토글, dev-only settings/graphs/logs |
| Auto 측정 (sliding window) | ✅ 완료 | 10-sample trimmed mean, 대기 중 loading |
| Spot 측정 (재시도) | ✅ 완료 | 3회 median, 최대 6회 재시도, 깜빡임 없음 |
| 미부착 감지 | ✅ 완료 | mean/std < 30 → LED 4 |
| BLE 자동 reconnect + watchdog | ✅ 완료 | 15초 timeout, IMU heartbeat |
| BLE 디버그 로거 | ✅ 완료 | 실시간 파일 append |
| ADC CSV 저장 | ✅ 완료 | 세션별 파일 |
| Canvas 하복부 실루엣 | ✅ 완료 | PNG 제거, CKLaw Bezier torso |
| i18n (한국어/영어) | ✅ 완료 | strings.xml |
| UI 텍스트 통일 | ✅ 완료 | revised 문서 반영 (Slide up ↑, In position! 등) |
| 태블릿 대응 | ✅ 완료 | fontScale, 비율 기반 레이아웃, 힌트/버튼 확대 |
| 페어링 다이얼로그 | ✅ 완료 | bonding 대기 중 안내 표시 |
| 앱 아이콘 | ✅ 완료 | 방광이 캐릭터 PNG (5 density) |
| Placement 화살표 수정 | ✅ 완료 | 디바운스/보호 중에도 화살표 항상 표시 |
| GREEN 안정성 | ✅ 완료 | 7초 hold + 3회 연속 실패 exit + phase 보호 |
| MTU 247 + CONN_PRIORITY HIGH | ✅ 완료 (v6) | VBT26050202 + VBTFW0116 정상 협상 |
| maa throttle state-based gate | ✅ 완료 (v6) | isComplete + 600ms + 3s FORCE |
| 측정 사이클 1.2s → 0.33s | ✅ 완료 (v6) | 4배 향상, 6/6 유실 0 |
| Placement loop 600ms 단축 | ✅ 완료 (v6) | 1000 → 600ms |
| Auto loop 600ms 단축 | ✅ 완료 (v6) | 1500 → 600ms |
| placementFromMonitoring 라우팅 분기 | ✅ 완료 (v6) | enterPlacementFromMonitoring/backFromPlacementGuide |
| PiezoPersonalization 평행 2-카드 | ✅ 완료 (v6) | Maximum Bladder Capacity + Catheter Threshold |
| PlacementGuide Sensor Alignment 리디자인 | ✅ 완료 (v6) | 3-stop gradient + 38sp 큰 타이틀 + pubic bone 빨강 |
| PiezoMonitoring Home 아이콘 / Voiding/ / Recorded Dialog | ✅ 완료 (v6) | |
| HomeView Bladdy 3× (660dp 캡) | ✅ 완료 (v6) | BoxWithConstraints 90% cap |
| 설정 패널 폰/태블릿 자동 분기 | ✅ 완료 (v6) | fontScale (360dp→1.0, 600dp+→1.8) |
| PinView DEMO 자동통과 | ✅ DEMO 빌드 한정 | 배포 전 if(false) 블록 제거 |
| IMU 모션 감지 | 🔜 예정 | vesiscanbasicAndroid에서 포팅 예정 |
| Placement 팬텀 테스트 | 🔜 예정 | Gradient vs SWEEP 비교 |
| 5-Anchor calibration | 🔜 검토 | CKLaw 전체 포팅 여부 결정 |
| 인체 임상 검증 | 🔜 예정 | 상수 재조정 |