Files
VesiscanClinicalAndroid/VesiScan_Android_Pipeline_Summary.md
T
dw.jang a7133a13ae feat: UI 대량 개선 + Placement 안정성 + BV 수정
[Placement]
- Start 버튼 필수 (자동 스캔 안 함)
- 화면 진입 시 전체 상태 리셋 (Step 2 직행 방지)
- Step 1 완료 조건: ch0~ch3 4개 전부 감지 필수
- Step 2 전환 시 "Checking left/right balance..." 3스캔 보호
- Step 전환 힌트 강제 유지 (phaseHoldHint)
- GREEN hold 7초 + 3회 연속 실패 후 이탈
- GREEN 이탈 시 스캔 중지 + Start 재시작 대기
- Detach 시 스캔 중지 + Start 버튼 표시
- 힌트 점 순환 (Raise.. / Raise... 업데이트 표시)
- 텍스트: "Raise the sensor up" 등 자연어
- 초기/재시작 시 화살표 제거
- Step 2 스킵 제거 (항상 3단계 순서)
- GREEN에서 ch4/ch5 개별 탈락 시 방향 안내
- gradient dead zone에 4채널 필수

[도넛차트]
- Max Volume 5초 간격 갱신
- Current Measurement / Fill 카드 (Fill = 65% + 325/500 mL)
- Auto 윈도우 < 5일 때 Current Measurement "—"
- Spot 완료 시 displayMaxVolumeMl 즉시 갱신
- ml → mL 통일
- Sensor Detached 상단 문구 제거
- 카테터 기본값 15개

[기능]
- Auto 2ch 미수신 5회 연속 → Realign 팝업
- Spot 간격 800ms→100ms (throttle이 보장)
- Auto Stop 버튼 깜빡임 해결
- Plot fallback 제거 (allWalls null이면 피크 안 보임)
- Pairing 미완료 시 다음 화면 차단
- 설정 패널 Save/Cancel 버튼
- 뒤로가기 앱 종료 확인

[코드 품질]
- Thread → coroutine (measureScope + Dispatchers.Default)
- mutableStateOf 메인 스레드 보장
- measureDoneSignal var→val mutableStateOf
- @Volatile (BleManager, GreenZoneConstants, PiezoHW)
- maa throttle deadlock 방지 (Boolean 반환)
- channelLogs allWalls 기준 통일

README v4 업데이트

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:20:34 +09:00

1273 lines
43 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-05-09 (v4) — VBTAND0101
---
# 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) └ 체크섬
```
### 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-07 업데이트)
개발자 모드 Settings에서 전환 가능: **Gradient** (기본) / Boundary / **SWEEP**
### Gradient 모드 (기본) — 신호 길이 비교
```
시작: 치골 위 → 위로 올림
1단계 VERTICAL:
0~1채널: ch0/ch1 감지→↑, ch2/ch3 감지→↓
2+채널: weighted center gradient
weightedCenter = Σ(ch_pos × len) / Σ(len)
gradient = weightedCenter - 1.5
gradient > 0 → ↓ (ch3쪽 길다 = 방광 아래)
gradient < 0 → ↑ (ch0쪽 길다 = 방광 위)
|gradient| < 0.4 + lenVariation < 0.5 → 상하 OK
2단계 LATERAL:
|len4 - len5| ≤ 10 sample → 바로 GREEN
lrSymmetry ≥ 0.5 → GREEN
아니면 len 차이 방향으로 안내
3단계 GREEN:
CV ≤ 0.12 (10회마다 +0.03 완화)
|lrDev| ≤ 0.35
둘 다 통과 → Green!
CV만 간당간당 → "Almost there..." (GREEN에 머물며 재시도)
```
### 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 | 0.12 | 기본 CV 임계 (10회마다 +0.03 완화) |
| DEFAULT_LR_DEV_THR | 0.35 | LR 비대칭 임계 |
| 절대 LR 차이 | ≤10 sample | LATERAL 즉시 통과 |
| R-peak tolerance | 1.5mm | SWEEP 모드 GREEN gate |
| 디바운스 | 1.5초 | phase 전환은 즉시, 같은 phase 힌트만 디바운스 |
## 연속 스캔 동작
```
"Start Scanning" 버튼 탭
↓
즉시 첫 스캔 (maa 6채널)
↓ 결과: CV, 방향, Score 표시
3초 대기
↓
자동 재스캔 (repeatCount 증가 → threshold 완화)
↓
... 반복 (사용자가 Stop 누를 때까지)
is_pass = true → Lock → "Start Measurement" 활성화
```
---
# 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 모드 (2026-05-07 업데이트)
별도 Auto 버튼으로 자동 반복 측정:
- 1.5초 간격 자동 측정
- 첫 5회: "Measuring... (n/5)" 로딩 표시
- 5회 이상: **sliding window trimmed mean** (10개 윈도우) → 안정된 값 표시
- Stop 버튼으로 종료
## Spot 모드 (2026-05-07 업데이트)
Spot 버튼 1탭 → **3회 측정 → median** (중간값 깜빡임 없음):
- 최대 6회까지 재시도 (유효값 3개 수집 목표)
- 측정 간격 300ms (BLE 겹침 방지)
- 중간 값 화면 미표시 (isSpotInProgress 플래그)
## 측정 실패 시 자동 재시도
```
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 ← 전역 상태 관리 + 화면 라우팅
```
## 포팅 원본 대조
| 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)는 파라미터 없는 명령어에도 **`cmd? `(물음표+공백)** 포맷이 필요합니다.
```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 Measurement" 버튼 활성화
```
### 패킷 드랍 대응
```
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 |
| Placement 팬텀 테스트 | 🔜 예정 | Gradient vs SWEEP 비교 |
| 5-Anchor calibration | 🔜 검토 | CKLaw 전체 포팅 여부 결정 |
| 인체 임상 검증 | 🔜 예정 | 상수 재조정 |