Files
VesiscanClinicalAndroid/VesiScan_Android_Pipeline_Summary.md
T
dw.jang eb25c01c97 docs(tools): align_analyze.py 이관 + CH3 flicker fix Python replay 검증 결과
- tools/align_analyze.py 신규: AlignGuide4Stage 상태머신 Python 재현
  - `--both` 모드로 NEW (majority + relaxed + soft-hint) vs LEGACY (연속 3 hit)
    로직 병행 replay 지원
  - Kotlin AlignmentConstants (V_WIN/V_MAJ/L_WIN/L_MAJ/STUCK/SOFT) 값 그대로 매핑
- tools/README.md: align_analyze.py 사용법 + 파라미터 매핑 표 + data123/
  4 세션 검증 결과 표 (Phantom / Human 0·1·3 CM) 정리
- tools/labdb_upload.py 신규 추가 (기존 문서만 있고 실체 미커밋 상태였음)
- VesiScan_Android_Pipeline_Summary.md v7 patch (2026-07-13):
  align_analyze.py 도구 언급 + CH3 flicker fix 실측 검증 결과 요약

검증 결과 요약 (data123/ human 4 세션):
- Human 0CM (CH3 31.8%): LEGACY 즉시 회귀 → NEW flick 1/4 흡수 유지
- Human 1CM (CH3 77.3%): LEGACY idx15 oscillation → NEW idx13 조기 진입
- Human 3CM (CH3 97.2%): 양쪽 정상 (회귀 없음)
- Phantom: 양쪽 정상 (relaxed mode 오진입 없음)
2026-07-13 15:21:07 +09:00

57 KiB
Raw Blame History

VesiScan Android App — 완전 가이드

작성일: 2026-04-23

작성자: dwjang

마지막 업데이트: 2026-07-13 (v7 patch) — 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 §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/align_analyze.py 신규 (2026-07-13) — AlignGuide4Stage 상태머신을 Python 으로 재현하여 alignment 세션 JSON 을 오프라인 replay. --both 모드로 NEW (majority + relaxed + soft-hint) vs LEGACY (연속 3 hit) 로직 비교 지원. data123/ 인체 임상 4 세션 (Phantom / Human 0·1·3 CM) replay 결과 CH3 flicker fix 회귀·조기 진입 개선 확인, relaxed mode false-positive 없음. 파라미터 매핑 · 결과 표: 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 시점) 호출:

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 canSendMaa()

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.21.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 폐기 방지)
// ❌ 안 됨 (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(
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) 분석 결과

// 파라미터 없는 명령: '?' 뒤에 공백 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 + CRC(LE) buildCommandBE
msn [0] msn? + 0x00,0x00 + CRC(LE) buildCommandBE

21. ADC 데이터 CSV 저장

파일 위치

Downloads/VesiScan_ADC/YYYY-MM-DD.csv

하루 1개 CSV 파일에 매 측정마다 append. 파일 탐색기에서 바로 접근 가능.

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에서 읽기

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 전체 포팅 여부 결정
인체 임상 검증 🔜 예정 상수 재조정