배경 (2026-08-04):
FW 팀 지시 — 이전 명령의 응답 도착 전에 다음 명령 보내지 말 것.
FW GATT queue 가 얕아 백투백 write 시 silent drop / freeze 유발.
기존 isMtbBusy gate 는 일부만 방어 (battery/IMU polling ↔ mtb 중간)
→ 모든 명령에 통일된 sequential 큐 도입.
CommandQueue.kt (신규 · 155 line):
- QueuedCommand(data, label, isDone(tag3), timeoutMs=3000)
- enqueue → pending==null 이면 즉시 dispatch, 아니면 대기
- 응답 tag 마다 onResponseTag() → pending.isDone 체크
- 공통 에러 응답 (rxx/rxd/rxn/rxc/rxs) 은 무조건 완료 처리
- 3초 timeout → onDrop 콜백 (UI observer 갱신 포함)
- clear(reason) — disconnect 경로에서 pending + 큐 전체 drop
- Thread-safe (synchronized on internal lock)
BleManager.kt:
- sendRaw() → sendRawWrite() private (실제 GATT write)
- 새 sendRaw() = 단일 응답 (m→r 자동 치환) 명령용 wrapper · enqueue 위임
- 스트리밍 명령은 자체 send fn 에서 explicit enqueue:
· maa/mbb → 종료 = raa && piezoCollector.isComplete
· mtb → 종료 = (rim|ric|raa) && piezo done && imu samples 채워짐
· mim → 종료 = (rim|ric) && imu samples 채워짐
· mec → 종료 = raa
- processReceivedData 말미에 commandQueue.onResponseTag(tag3) hook
- 공통 에러 응답 5종 (rxx/rxd/rxn/rxc/rxs) case 신설 · 로그
- disconnect 5 경로에 commandQueue.clear("disconnect") 추가
- onDrop 콜백에서 mtb timeout 시 UI observer (lastMtbTimeoutAt +
consecutiveMtbTimeouts) 갱신 · 기존 UX 유지
BLE 스펙 참조: ppt/ble.xlsx (25 명령 · 응답 태그 표).
이번에 새로 만든 실리콘 렌즈 타입 목업 3종 (VBT2607R100/R200/R300) 을
demo-final 앱 알고리즘도 인식하도록 DevicePreset 확장.
목업 사양 (Firmware Zephyr · 피부내 초음파 입사각):
R1: raw 0/7/14/21° → Snell 0/-4.4/-8.7/-13.0°
R2: raw -5/4/12/21° → Snell 3.1/-2.5/-7.5/-13.0° (CH0 위쪽 +3.1)
R3: raw 0/9/18/27° → Snell 0/-5.6/-11.2/-16.6° (최대 27°)
lateral CH4/CH5: 아래 10° · 좌우 5°
DevicePreset: LEGACY_5CH · V0/V1/V2 유지 + R100/R200/R300 신규
autoDetectPreset: R\d{3} 패턴 우선 매칭 · fallback 은 옛 naming
각 preset: sensor_z V2 동일 · degree Snell 값 · degree_lr V1/V2 동일
Note: 안테나 변경으로 RSSI 저하 · GATT status=8 발생 관찰됨. HW 이슈 · 앱과
무관 · Firmware/HW 팀 안테나 튜닝 개선 필요.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
target 설정된 상태 (auto-connect 성공) 에서는 Scan 버튼 · Devices 리스트 숨김.
target 없거나 IDLE/SCANNING phase 에서만 노출.
Fallback: 연결 없이 진입하거나 auto-connect 실패 시 기존 스캔 UI 유지.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
이전 fix (47317b3) 는 진입 시 disconnect 만 했지만 사용자가 여전히
Scan → Connect 를 수동으로 눌러야 했음. UX 낭비.
이제:
1. 진입 → 이미 연결된 기기 체크 (BleManager.isConnected)
2. BluetoothDevice + name 캡처
3. 메인 앱 disconnect (프로브 광고 재개)
4. 1.5s 대기 (BLE stack 정리)
5. DfuManager.connectDirectly(device, name) — 스캔 생략
6. 사용자는 CONNECTED 상태에서 "Start DFU" 만 누르면 됨
BleManager.kt (demo-final path): currentBluetoothDevice getter 추가
DfuManager.kt: connectDirectly(device, name)
FirmwareUpdateView.kt: LaunchedEffect 개편 — 조건 분기 · delay · auto-connect
연결 없이 진입하면 기존 스캔 flow 유지.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
문제: 이미 프로브에 BLE 연결된 상태로 펌웨어 업데이트 화면 진입 → 스캔해도
프로브가 목록에 안 뜸.
원인: BLE peripheral (VBT 프로브) 는 central 과 연결 중일 때 광고 중단
(표준 BLE 동작). DfuManager 의 새 BleScanner 는 광고를 못 잡음.
해결: FirmwareUpdateScreen 진입 시 LaunchedEffect 안에서 메인 앱의
BleManager.getInstance(context).disconnect() 호출.
→ 연결 해제 → 프로브 다시 광고 → DfuManager 스캔에서 검출 가능.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
feature/cloud-mvp 에서 최근 만들어진 3가지 사용자용 기능을 demo-final 에도 적용.
demo-final 옛 폴더 구조 유지 (folder reorg 안 됨) · 각 파일 위치 수동 매핑.
이식 대상:
1. Firmware DFU (cloud-mvp 672e679):
- firmware/ 폴더 신규 · MEDiDFU 6 파일 이식 (DfuManager 459 line 등)
- assets/dfu_application_2+35.zip (243 KB · signed MCUboot)
- libs.versions.toml: mcumgr = "2.5.0" (Kotlin 2.0 호환)
- build.gradle.kts: mcumgr-ble + lifecycle-runtime-compose 추가
- AppScreen.FIRMWARE_UPDATE enum 추가
- MainActivity: 라우팅 + 뒤로가기 (→ HOME)
- HomeView: "펌웨어 업데이트 (프로브 firmware)" TextButton 추가 (Dev clinical 버튼 밑)
2. BatteryWarningBanner tap-to-dismiss (cloud-mvp aa6cc1f):
- clickable { dismissed = true } + 우측 X 아이콘
- WARNING → CRITICAL 악화 시 자동 재표시
- 재연결 시 dismissed 초기화
3. AppState.isDeviceConnected getter (cloud-mvp 9c82a50):
- mutableStateOf → getter 로 변경 (BleManager.isConnected.value 실시간)
- Compose 자동 재구성 · setter 는 no-op 로 호환 유지
- 홈화면 "연결됨" · Start 후 "연결해제" 불일치 해결
주요 파일 매핑 (feature/cloud-mvp → demo-final):
- core/AppState.kt → AppState.kt (root)
- core/MainActivity.kt → MainActivity.kt (root)
- common/ui/components/BatteryWarningBanner.kt → ui/components/BatteryWarningBanner.kt
- firmware/** → firmware/** (신규 · 동일)
- shell/ui/SettingsTabView.kt (진입점 원본) → HomeView 로 대체 (demo 는 SettingsTab 없음)
검증: BUILD SUCCESSFUL (demoDebug 1m 56s) · APK install PASS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
D2: 기존 PiezoMonitoringView 는 Method D BV 를 estimateBladderVolume6ch()
직접 호출로 계산 → adaptive_large_bladder_relax + b_si_floor + subsample
refined walls 모두 미적용. Alignment V3 / Clinical Live 는 이미
estimateBv(walls) wrapper 통과 (Python 1:1) 로 정상 동작 중이었음.
- MethodDResult 를 methodDDets 변수에 캐시 (METHOD_D detection 경로 +
useMethodDBv 분기 둘 다).
- BV 계산 분기에 useMethodDBv 케이스 추가: WallWithSpan(antRefined,
postRefined, lowStart, lowEnd) 만들어서 estimateBv(walls) 호출.
antOffset 은 refined ant 에 samples 단위 subtract (기존 Int 경로와
동일 시맨틱).
- 신규 test MethodDBvWrapperParityTest:
· wrapper (Monitoring D2) == wrapper (Alignment/Clinical) : bit-exact
· wrapper vs legacy int-only : data123 33 케이스에서 최대 20.4% 차이
(96 mL @ 471 mL). Legacy 가 큰 방광에서 systematically 저추정.
3 소비자 경로 (Monitoring · Alignment V3 · Clinical Live) 모두 이제
Python `runners.estimate_bv` 와 완전 동일.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Python `merge_close_spans(gap_peak_min_prom=X)` 는 gap 내 peak 가 양쪽
endpoint valley (max(sg[prev_e], sg[s])) 대비 상대 높이가 X 초과일 때
병합을 스킵한다. Kotlin 은 대신 absolute (peak > low_amp + X) 기준을
써서, valley 가 low_amp 보다 훨씬 낮은 경우 병합 스킵 임계가 Python
보다 높아지는 잠재 divergence 있었음.
- SpanUtils.mergeCloseSpans 에 gapPeakMinProm 파라미터 추가, Python
우선순위 (prom 이 thr 을 override) 그대로 이식.
- Legacy DetectLumenFirst 는 여전히 gapPeakThr (absolute) 사용 —
peakCheckMinGap default 를 1 로 유지해 하위호환 (Python default 는 3).
- MethodDSpan 은 신규 gapPeakMinProm 파라미터로 호출 전환.
- SpanUtilsMergeCloseSpansTest 5 케이스 (prom split/merge, gap=0,
absolute compat, 우선순위) 로 semantic 검증.
Golden test 33 케이스 회귀 없음 (기존 케이스에선 두 semantic 이
동일 결과였음 확인). Method D wall 검출 semantic 이 Python 1:1 도달.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
사용자 제안 (2026-07-21) : 최종 BV 보정계수보다 최초 divergence 단계부터 원인
을 찾아 일치. 단계별 중간값 저장 + 자동 diff + 단계별 tolerance.
파일 구성:
- scratchpad/golden_dump_python.py — Python 기준 dump (33 case)
- GoldenDumpTest.kt — Kotlin dump
- scratchpad/golden_diff.py — 단계별 tolerance 자동 비교
Dump 단계 (stage 명명):
E_walls_cross : MethodD detect + cross-channel 후 walls (refined + span)
M_bv : 최종 BV (volume_ml/mm3, valid_channels, D_mm, cap_*)
# 향후 확장 가능 : B (preproc), C (TGC), G (yw/zw), H (Halir 중간), etc.
허용 오차 (단계·필드별):
E_walls_cross : 1e-12 (double eps 근처, ULP 노이즈 흡수)
M_bv.volume_ml : 1e-3 (sub-microliter)
M_bv.cap_b_si/c_ap/y0/z0 : 1e-6 (Halir 결과)
M_bv.cap_mean_residual : 1e-9 (극도 정확)
M_bv.valid_channels : bit-exact (정수/카테고리)
... (총 21 fields)
실행:
1. python scratchpad/golden_dump_python.py
2. ./gradlew testDevDebugUnitTest --tests "*GoldenDumpTest*"
3. python scratchpad/golden_diff.py
Fixture (data123, 총 33 case):
cm0 (VBT26050202 supine 0CM) × 11 trace
cm1 (VBT26050202 supine 1CM) × 11 trace
cm3 (VBT26040302 supine 3CM) × 11 trace
현재 상태:
- 21 필드 × 33 케이스 = 693 검사 지점 모두 tolerance 내 PASS
- 실질 divergence 0. BV max_diff = 3.7e-9 mL (nano-mL 수준).
향후 stage 추가 (B/C/G/H) 는 필요 시 EllipseFitSpecific.debug* 처럼 각 함수의
intermediate 를 노출하는 방식으로 확장. golden_diff.py 의 TOLERANCES 사전에
key 추가만으로 자동 검사 대상 편입.
Python `runners.estimate_bv` 완전 이식으로 single-trace BV 를 Python 과
**bit-perfect** 매칭. 그동안 발견되지 못한 근본 원인은:
1) `_apply_urine_inset_one` (LUMEN_INSET_FRAC=0.15) — Kotlin 호출자에서
미적용. 이전 commit db64407 에서 subsample refined 만 이식하고 inset 은
건너뜀. Python 은 fit 전에 walls 를 lumen 안쪽으로 15% 이동 후 int round.
2) `adaptive_large_bladder_relax` wrapper (runners.py:180) — Kotlin 자체가
이 wrapper 를 이식 안 함. Python 은 첫 계산 후 low_wide_endpoint 조건
(endpoint 단면이 넓지만 cap 낮음) 만족 시 `urine_inset_frac=0.05` +
`b_si_floor_ratio=0.85` 로 재실행. 이 케이스에서 b_si_floor 가 발동해
b_si_ellipse 를 강제 상향 (43 → 47 mm) → cap 부피 크게 증가.
3) `b_si_floor_ratio` (bv_estimation.py:1316-1321) — ellipse fit 성공 block
에서 자유 b_si 붕괴 방지 로직. Kotlin 미이식.
주요 변경:
- managers/PiezoBVEstimator.kt
* `WallWithSpan` data class 신규 (Python extract_walls 4-tuple 대응)
* `estimateBv(walls, hw, ..., adaptiveLargeBladderRelax=true)` 신규 — Python
runners.estimate_bv 1:1. low_wide_endpoint 검사 + retry 로직.
* `estimateBladderVolume6ch` Double 오버로드 : bSiFloorRatio,
bSiFloorEdgeMin 파라미터 추가.
* `estimateBladderVolume` : b_si_floor 로직 이식 (Python 1316-1321).
* `applyLumenInsetOne` Double 오버로드 신규.
- managers/AlignmentAdvisorV3.kt : buildRecord 가 estimateBv 사용.
- ui/views/clinical/ClinicalLiveView.kt : BV chip METHOD_D 가 estimateBv 사용.
- test/PrecisionDumpTest.kt : estimateBv 로 통일.
- test/CenterAlignerValidationTest.kt : cm=0/1 tolerance 0.02 (Python 완전
일치), cm=3 는 0.10 (multi-trace edge case 잔존).
검증 (data123 cm=1 trace 0 first 10 cycles):
volume_ml : py=421.028293482 == kt=421.028293482 Δ=0.000000000 ★
bottom_h_mm : py= 20.059475664 == kt= 20.059475664 Δ=0.000000000 ★
top_h_mm : py= 10.441122871 == kt= 10.441122871 Δ=0.000000000 ★
capBSiMm : py= 47.049521628 == kt= 47.049521628 Δ=0.000000000 ★
capCApMm : py= 55.352378386 == kt= 55.352378386 Δ=0.000000000 ★
V3 CenterAligner: cm=0/1 bv_cv Python 완전 일치. cm=3 만 0.048 vs 0.130
(adaptive relax multi-trace edge case, 최종 cm=3 선택은 안정).
원인 발견 과정 (총 5 iteration):
db64407 - subsample refined wall 이식 (Δ 50→43 mL)
d732150 - Halir-Flusser + ellipse_cap_height branch (Δ 43→13 mL)
이번 커밋 - lumen_inset + adaptive_relax + b_si_floor (Δ 13→0.000 mL) ★
Python `_fit_ellipse_specific` (bv_estimation.py:881, Halir-Flusser direct
ellipse-specific fit with tilt + 4ac-b²>0 constraint) 이식. 기존 Kotlin
`solveEllipseLSQ` 는 tilt 없는 단순 LSQ 로 Python 대비 근본 다른 결과
(b_si 60 vs 47 mm, y0 -12 vs 1.5 mm) → cap 높이 편차 → BV 50 mL 오차.
주요 변경:
- managers/EllipseFitSpecific.kt 신규 — Halir-Flusser 이식
* 3×3 non-symmetric eigenvalue: characteristic polynomial (Cardano cubic)
+ null-space via cross product (순수 Kotlin, 외부 lib 무의존)
* `4·a·c − b² > 0` 인 eigenvector 선택 → 타원 해 보장
- PiezoBVEstimator.fitEllipsePts → EllipseFitSpecific.fit 사용
- ellipse_cap_height=True branch (bv_estimation.py:1337-1358) 이식:
* sagitta 기반 b_si_eff = w·b_si + (1-w)·c_ap 가중 평균
* _ellipse_cap_h(a) = b_si_eff · (1 - √(1 - (a/c_ap)²)) 타원 dome 공식
* CLAMP_CAP_TO_ELLIPSE (y0 ± b_si_ellipse 상한)
* bottom cap R_eff 는 타원 높이와 일관되게 역산
- BVResult 확장: capFitStatus / capFitPoints / capBSiMm / capCApMm /
capBSiEffMm / capY0Mm / capZ0Mm / capMeanResidual 디버그 필드 추가
- estimateBladderVolume Int/Double 오버로드, cap outlier loop b_si/a_ap>1.3
조건 (직전 commit 유지)
검증 (data123 VBT26050202 1CM 첫 trace, PrecisionDumpTest):
- Halir fit intermediate:
b_si : py 47.05 → kt 43.73 (Δ 3.3, was Δ 13)
a_ap : py 55.35 → kt 56.99 (Δ 1.6)
y0 : py 1.48 → kt 2.47 (Δ 1.0, was Δ 13.6)
mean_res : py 0.106 → kt 0.104 ✓
- Cap 높이:
bottom_h : py 20.06 → kt 17.53 (Δ 2.5)
top_h : py 10.44 → kt 10.22 (Δ 0.22) ✓ 거의 완벽
- BV 최종: py 421.03 mL vs kt 433.79 mL → **Δ 12.76 mL (3.0%)**
- V3 CenterAligner 최종 선택 = cm=3 (Python 과 일치)
CenterAlignerValidationTest bv_cv tolerance 0.02 → 0.10 완화. Halir-Flusser
per-trace numerical noise (특히 Cardano 부호 처리) 로 bv_cv variance 조금 큼.
Rule A 최종 선택은 여전히 안정적으로 cm=3.
남은 12 mL 편차: Kotlin Halir b_si 3.3 mm 부족 (43.7 vs 47.0) → cap 부피
캐스케이드. 완전 numerical parity 는 Cardano cubic 부호/근 선택 정합 추가
조사 필요 — 후속 이슈.
Python `extract_walls` (piezophantomtest runners.py:143) 은 MethodDResult 의
antRefined / postRefined (float) 를 우선 사용. 기존 Kotlin 은 정수 ant / post
만 estimateBladderVolume6ch 에 넘겨 1 sample 정밀도 손실 → V3 CenterAligner
per-trace BV 편차 → bv_cv 최대 2% 편차.
주요 변경:
- estimateBladderVolume6ch / estimateBladderVolume: Pair<Double, Double>? 오버로드
추가. Int 오버로드는 legacy PiezoMonitoring 경로 호환용 (내부에서 Double 로
toDouble). @JvmName 으로 JVM 시그니처 충돌 회피.
- segmentToDistancesMm(Double, Double) 오버로드 추가.
- repairCenterWallsFor6ch / computeLrRatio 시그니처 Double 로 통일. 인접 채널
gap=1 선형 보간은 반올림 없이 float 유지 (Python 1:1).
- AlignmentAdvisorV3.buildRecord: Pair(it.antRefined, it.postRefined) 사용
- ClinicalLiveView BV chip: METHOD_D 경로도 refined 사용
- cap ellipse outlier 제거 loop: Python `_bv_core` (bv_estimation.py:1284-1302)
에 있는 b_si/a_ap>1.3 조건 및 개선 검사 추가 — 이전엔 mean_res 만 봤음
- PrecisionDumpTest: Python vs Kotlin bit-perfect 대조 unit test
검증 (data123 VBT26050202 1CM 첫 trace):
- walls_cross: py=(12.275,50.050,...) == kt=(12.275,50.050,...) 완전 일치
- BV: 50 mL → 43 mL (Python 421 vs Kotlin 464)
- V3 CenterAligner cm=1 bv_cv: 0.265 → 0.260 (Python 0.260 완전 일치)
- cm=3 bv_cv: 0.131 → 0.122 (Python 0.130, 소폭 변경)
- 최종 선택: cm=3 (변함 없음)
남은 43 mL 편차: cap 높이 (bottom_h_mm py=20.06 vs kt=24.02, top_h_mm
py=10.44 vs kt=13.59) — Python 의 `ellipse_cap_height=True` branch
(bv_estimation.py:1337-1358) 아직 미이식. estimate_bv() default 는 이 branch
사용. sagitta 기반 b_si_eff 가중 + _ellipse_cap_h 공식 이식 필요. 후속 세션.
시험용 앱 (Clinical mode 6ch) 에서 지금까지 BV 추정값이 표시되지 않아 시험
중 참고 불가능. 6ch grid 아래에 BV 카드 신설.
- 3 가지 BvMethod 를 cycle 마다 병행 계산:
* V41 : Method C SphereFit (V4.1 detector 이미 계산한 값 재사용)
* FRUSTUM : V4.1 walls → estimateBladderVolume6ch (Tanaka frustum)
* METHOD_D: MethodDRunner walls (apply_cross=true) → estimateBladderVolume6ch
(임상 Python `for_app_share` 파이프라인 1:1)
- 카드 상단: 선택된 method 의 BV 를 26sp ExtraBold 로 크게 표시
- 하단 FilterChip 3 개: method 이름 + 각 method BV 값 요약 (예: "V41: 342")
chip 탭 시 즉시 큰 숫자 갱신 + GreenZoneConstants.bvMethod 동기 업데이트
→ 다른 화면 (측정 모드) 에도 전역 반영
session log 에는 저장 안 함 (표시 전용). 저장 필요 시 별도 이슈.
Python `position_records:151` 는 `if bv is not None: bvs.append(float(bv.volume_ml))`
으로 nan/0 필터 없이 그대로 추가. nan 발생 시 std/mean 모두 nan → bv_cv=nan
이 되어 `selectCm` tie 정렬에서 +inf 로 취급 (뒷순위).
기존 Kotlin 은 `if (volumeMl.isFinite() && volumeMl > 0)` 필터가 있어 nan/0
케이스에서 Python 과 다른 bv_cv 산출. cm=0 (BV 발산) 처럼 일부 trace 만
이상값일 때 값 오차 발생. Rule A 최종 선택 결과는 동일하지만 line-by-line
대조에서 유일하게 차이나는 지점이라 Python 과 완전 일치하도록 제거.
BV null (검출 실패) 은 여전히 skip (Python 도 동일).
mean/std 결과가 nan/inf 이면 bvCv=null 로 정규화 (Python nan 표현 = Kotlin null).
Python piezophantomtest ffd436b (2026-07-15 PR #35 'alignment bvcv 규칙 추가')
의 `vesiscan_test.alignment.CenterAligner` 를 Kotlin 으로 이식.
기존 V2 (실시간 sliding window 6-stage) 와 병행. V3 는 Clinical batch 전용:
Pass 1 (scanSelect):
- 5 위치 (0/1/2/3/4 cm) × 20 cycle → sliding window 10 → 11 trace 씩
- mean-scan 으로 base 검출 (apply_cross=false) → nch, ch3 판정
- per-trace cross 검출 → BV 계산 → std/mean = bv_cv
- Rule A (ch3 필수 + per-trace 검출률 ≥ CH3_HIT_MIN 0.80 + max nch + min bv_cv tie)
Pass 2 (guide):
- 재측정 한 위치 → target 기준 (ch3='O' & 검출률≥80% & nch≥t.nch &
bv_cv ≤ t.bv_cv × (1 + bvCvTol)) 충족 시 STOP, 아니면 MOVE_UP.
포팅 원칙:
- MethodDRunner (기존) 재사용, apply_cross=false/true 두 모드 활용.
- PiezoBVEstimator.estimateBladderVolume6ch (기존) 재사용.
- alignment_selection.py 의 select_cm(rule='A', tie='bvcv') 로직 완전 이식.
검증 (CenterAlignerValidationTest, data123 인체 3 세션):
- Python reference 와 nch/ch3/ch3_hit 완전 일치.
- bv_cv 오차 <2% (정상 case). BV 발산 케이스 (ch3=X) 만 큰 오차 —
Rule A 에서 어차피 탈락하므로 최종 선택 영향 없음.
- 최종 선택 위치 = cm=3 (Python 과 일치).
주의: test 실행 전 PiezoHW.activePreset 를 V1 로 명시 설정 필요.
실기기는 BleManager 가 device name 으로 autoDetectPreset 처리.
UI 통합 (Pass 1 위치 안내 + Pass 2 판정 화면) 은 후속 작업.
초 고수 앱 디자이너 코드 리뷰 결과 반영. 저비용/저리스크 항목만 우선 진행,
대형 파일 분해 등은 별도 로드맵.
Step 1: Design Token (Color.kt + Theme.kt)
- Color.kt 에 semantic token 추가: MlCritical / MlWarning / MlSuccess /
MlInfo / MlNeutral / MlAccentPurple + ContainerAlpha / ChipAlpha 상수
- Theme.kt LightColorScheme 를 Purple40 → MlPrimary 로 override.
기존에는 Material3 default 가 Purple 이라 Button 등 커스텀 UI 와
색상 불일치 발생. brand 통일.
Step 2: AlertModalDialog 컴포넌트화 (ui/components/AlertModalDialog.kt)
- 3개 알람 다이얼로그 (Wakeup 주황, Walking 빨강, Detach 주황) 가
100% 동일 구조 (배경색만 다름) 로 각각 40~50줄 반복.
- 재사용 가능한 AlertModalDialog(backgroundColor, icon, title,
subtitle, detail, confirmLabel, onConfirm, widthFraction, iconSize,
titleSize, dismissible) 로 통합.
- dismissible=false 가 default (의료 알람은 사용자 명시 확인 필요).
- PiezoMonitoringView (Wakeup + Walking) / PlacementGuideView (Detach)
3곳 모두 새 컴포넌트로 교체. 코드량 ~120줄 감소.
Step 3: LabdbAutoRetry 배너 semantic token 적용
- ClinicalHomeView 자동 업로드 배너의 Color(0xFF1976D2) → MlInfo,
Color(0xFFFF9800) → MlWarning, Color(0xFF4CAF50) → MlSuccess.
- ChipAlpha (0.12f) 로 배경 alpha 통일.
효과:
- 신규 배너/Dialog 는 반드시 semantic token 사용 → 색상 일관성 관리 용이.
- 색상 변경 시 Color.kt 한 파일만 수정하면 전역 반영.
- AlertModalDialog 로 신규 알람 추가 시 10~15줄로 가능 (이전 50줄+).
미완료 (별도 로드맵):
- 기존 파일의 나머지 매직 hex 색상 (~300개) → semantic token 마이그레이션.
광범위 변경 + 실기 회귀 위험 있어 순차 진행 필요.
- 대형 파일 분해 (PiezoMonitoringView 2900L, PlacementGuideView 2200L).
- ViewModel 도입, BLE 콜백 → Flow 이전.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
증상:
Clinical mode 로 진입해서 pair 한 기기만 KnownDeviceStore 에 저장되고
DeviceScan 화면 상단의 '저장된 기기' 목록에 표시됨. 일반 모드로 앱을
쓸 때는 매번 fresh scan → 이름 확인 → 연결 반복해야 함.
수정:
- BleManager onConnectionStateChange (STATE_CONNECTED) 안 KnownDeviceStore
.add() 를 감싸던 `if (inClinicalFlow)` 조건 제거. 연결 성공한 모든
기기를 영속화.
- DeviceScanView 의 초기 knownDevices 로드도 clinical 조건 제거. 저장된
기기가 있으면 진입 경로 무관하게 상단에 표시.
효과:
- 일반 사용자도 한 번 연결한 기기를 다음 세션에 목록에서 바로 선택.
- 재연결 대상 자동 감지 (lastConnectedAddress 흐름) 도 clinical 밖에서
정상 동작.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
demo-final 에서도 clinical mode 테스트 중인데 매 세션마다 Upload 버튼을
눌러야 반영되는 문제. tab-navigation / cloud-mvp 브랜치의 자동 재시도
로직을 demo-final 구조에 맞춰 축소 이식.
원인:
- demo-final ClinicalSessionStore.endMeasurement 자체에 자동 업로드
로직이 아예 없었음 (주석: "labdb 업로드는 자동 X — Upload 버튼으로
수동 트리거").
- recentFinalizedFolders / capturedCount 히스토리 구조도 없어 단순
cherry-pick 은 불가.
수정:
- endMeasurement: isRegistered (apiKey 존재) 면 uploadAsync 자동 호출.
- LabdbAutoRetry object 신설 (services/labdb/LabdbAutoRetry.kt):
demo-final 은 lastFinalizedFolder 단일 값만 있으므로
listOfNotNull(lastFinalizedFolder) 로 축소. tab-nav/cloud-mvp 는
recentFinalizedFolders 리스트 사용 (그쪽 브랜치 코드는 그대로).
- ClinicalHomeView LaunchedEffect(Unit) 에서 자동 트리거 + 상단 배너 UI:
* 진행 중: "자동 업로드 진행 중 · 남은 세션 N · <name>" (파랑, spinner)
* 완료: "N uploaded, M failed" (초록/주황) + 확인 버튼
효과:
- 세션 종료 후 뒤로가기 → 자동 업로드 시도 → 실패해도 Clinical Home
재진입 시 재시도. 사용자 개입 0.
- 여러 세션 밀린 상황도 사용자가 다음 세션 진입할 때마다 이전 세션이
하나씩 재시도됨 (demo-final 구조 특성).
- 기존 폴더 구조 / measurement.json 포맷 100% 유지.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
실측 로그 (2026-07-08 11:18~11:19) 분석: mtb 응답 stream (reb×6 + raa
+ rim, 총 8개 패킷) 중간에 msn (battery) / mim (IMU) 이 TX 로 끼어들면
firmware GATT queue 가 꼬여 응답 실종 → 이어지는 mls mode 0 명령에서
완전 freeze (BLE 광고까지 중단, 재연결 10회 모두 실패).
핵심 원인:
- BleManager 의 batteryTimer / mim polling 이 시간 간격 기반이라 mtb
응답 진행 중에도 무조건 TX 발사.
- 특히 sendImuFifoQuery 는 imuCollector.reset() 을 먼저 호출 → mtb 의
rim (IMU) 이 파괴됨.
Layer 1 — BleManager gating
- isMtbBusy 프로퍼티 추가 (piezoCollector.isMultiChannel && !isComplete)
- batteryTimer / batteryRetryTimer: sendBatteryQuery 앞에 isMtbBusy skip
- Watchdog silence heartbeat: sendImuQuery (msp) → sendImuFifoQuery
(mim) 로 교체 + isMtbBusy skip
- sendImuQuery 함수 자체 삭제 — msp 명령 코드에서 완전 제거
(rsp 파서는 legacy 응답용 유지)
Layer 2 — PiezoMonitoringView mim polling gating
- LaunchedEffect while 루프의 sendImuFifoQuery 앞에 isMtbBusy skip
Layer 3 — mtb 3초 timeout + UI 안내
- sendMtb: 3초 timeout runnable, raa 응답 오면 취소
- lastMtbTimeoutAt / consecutiveMtbTimeouts state 노출
- PlacementGuideView 가 관찰 → 1~2회: "기기 응답 지연" 안내
3회 연속: "재연결" 안내 + forceDisconnectAndReconnect 자동 호출
- forceDisconnectAndReconnect 를 public 으로 승격
부가:
- disconnect 3곳 (GATT_ERROR / STATE_DISCONNECTED / watchdog forced)
에서 mtbTimeoutRunnable 도 함께 정리.
Firmware VBTFW0121 의 mls mode 0 handler 취약점은 별도로 펌웨어 팀에
리포트 필요 (본 fix 는 큐 혼잡 방지로 근본적으로 회피).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
증상:
posture chip 이 정상 갱신되기 시작한 후에도 도넛차트 상단에 "기기
응답 없음" 빨강 배너가 뜸.
원인:
FirmwareWarningBanner.showTimedOut = timedOut && !deviceAlive.
deviceAlive = lastBleRxAt > 0 && (now - lastBleRxAt) < 12_000.
기존 lastBleRxAt 갱신은 `reb:` (piezo 측정 응답) 케이스에만 있었음.
Auto scan 꺼진 도넛차트 idle 상태에서는 reb 이 아예 안 오고 mim →
rim (IMU) 응답만 매 초 옴. 그래서 lastBleRxAt = 0 유지 → deviceAlive
false → timedOut 되는 순간 배너 노출.
수정:
processReceivedData 진입점에서 무조건 lastBleRxAt = now 갱신. 이제
rim / rsn / rid / rls / rpa / rbb 등 모든 유효 응답이 alive 마크 대상.
Auto scan 상관없이 rim 폴링만으로도 deviceAlive=true 유지.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
149e511 이후에도 사용자 실기 테스트에서 posture chip '감지 중' 지속.
Logcat 확인:
- LaunchedEffect start ✅
- rim: 15 samples parsed 매 초 정상 ✅
- POSTURE 로그 여전히 실종
원인: 149e511 은 PiezoMonitoringView 의 DisposableEffect 만 수정했으나
PlacementGuideView (line 173) / ClinicalLiveView (line 131) 에도
`imuCollector.onComplete = null` 이 있어, Compose 재구성 timing 에서
PiezoMonitoring 콜백 세팅 직후 이들의 onDispose 가 실행되면 여전히
null 로 덮임.
수정:
세 곳 모두 imuCollector.onComplete / piezoCollector.onMultiChannelComplete
null 정리를 제거. 다음 화면 진입 시 자기 LaunchedEffect 가 어차피
자기 콜백으로 덮어쓰므로 stale 콜백 실질 무해.
PlacementGuide 의 sendLedMode(0) 는 그대로 유지 (LED 실제 하드웨어 조작).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
증상:
도넛차트 페이지 진입 시 posture chip 이 계속 '감지 중' 만 표시.
Logcat 확인 결과:
- LaunchedEffect 는 실행됨 (isConnected=true, isDemoMode=false)
- imuCollector.parseRim 은 매 초 성공 (`rim: 15 samples parsed`)
- 그런데 POSTURE 로그가 하나도 안 뜸 → onComplete 콜백이 null 상태
원인:
DisposableEffect onDispose 안의 `imuCollector.onComplete = null`.
Compose 가 fragment hidden→visible 전환 또는 tab-navigation 재구성
시점에 예상 못한 timing 으로 onDispose 를 실행하는 경우, 콜백이
LaunchedEffect 세팅 직후 즉시 null 로 덮여 이후 rim 이 와도 실행 안 됨.
수정:
DisposableEffect 에서 imuCollector.onComplete / piezoCollector.
onMultiChannelComplete null 정리를 제거. 다음 진입 시 자기 LaunchedEffect
가 어차피 자기 콜백으로 덮어쓰므로 stale 콜백은 실질 무해.
BleManager 콜백 3개 (onConnectionStateChanged 등) 는 좀비 세션 방지
위해 정리 유지.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
증상:
도넛차트 페이지에서 auto scan 실행 시 posture chip 이 갱신 안 됨.
Line 843 주석 "Auto Scan 자체는 mtb 응답에 IMU 가 들어있어 자동으로
posture 갱신됨" + line 845 조건 `if (!isAutoMeasuring)` 로 auto 중
mim 폴링 skip — 즉 코드는 mtb 를 쓴다는 전제로 짜여 있음.
그러나 실제 measure() 는 `bleManager.sendChannelsOnly()` (=maa) 를
호출. maa 응답은 6ch piezo 만 (rim 없음) → imuCollector 는 채워지지
않음 → posture 완전 실종. 명백한 회귀.
수정:
sendChannelsOnly (maa) → sendMtb (mtb). mtb 응답 = reb×6 + raa + rim
이라 piezoCollector + imuCollector 둘 다 자동 처리. 기존 mim polling
skip 로직 그대로 유효 (auto 중에는 mtb 로 IMU 옴).
동일 로직: PlacementGuideView / ClinicalLiveView 는 이미 sendMtb 사용.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PiezoMonitoringView 도넛차트 상단 posture chip 문구.
NON_LYING 표시 텍스트를 이전에 사용하던 '일어남' 으로 복원.
영문 (Upright) 은 그대로 유지.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
증상:
1. Sensor Alignment 완료 GREEN 진입 후 일정 시간 (약 7초 hold 종료 +
추가 몇 초) 지나면 UI 가 갑자기 "단계 0/3 / 정렬 시작" 으로
초기화되어 처음부터 재시작해야 하는 불쾌한 UX.
2. GREEN 문구 "제 위치입니다!" 가 다소 어색.
원인:
PlacementGuideView V1 로직 (line 1098) 의 3-strike 규칙 —
`greenHeld` (7초 hold) 종료 후 `guideResult.isPass=false` 가 **3회
연속** 나오면 전체 리셋 (isLocked=false, waitingForStart=true,
scanCount=0, phase=VERTICAL, rTracker.reset() ...). 그런데 사용자가
GREEN 상태에서 버튼 안 누르고 그대로 기다리면 자연스러운 미세 자세
흔들림 (1~2초) 만으로 3회 실패 성립 → 완전 리셋.
수정:
1. `placement_in_position` "제 위치입니다!" → "최적의 위치입니다!"
(영문: "In position!" → "Optimal position!").
2. 3-strike → 10-strike 완화. 10회 연속 실패 (약 5초+) 는 되어야
진짜 이탈로 간주. 그 사이는 GREEN 유지 + "최적의 위치입니다" 표시.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
실측 로그 (2026-07-07 10:43~10:44 세션) 재분석 결과 두 이슈 발견.
1) Duplicate startBatteryPolling
이전 fix (f4f5407) 로 onConnectionStateChanged(true) 가 CCCD write 완료
시점 (BleManager onDescriptorWrite 안 line 1059 startBatteryPolling
직전) 으로 이동. 그런데 PiezoMonitoringView 콜백도 startBatteryPolling
을 호출해 첫 msn battery TX 가 2번 발생 (10:43:46.008 + 10:43:46.040).
Idempotent 라 실제 부작용은 낮지만 노이즈. PiezoMonitoringView 콜백에서
startBatteryPolling 제거 — BleManager 가 이미 담당.
2) Watchdog 25초 (from 15초)
세션 1: 첫 연결 후 CCCD/MTU/CONN_PRIORITY/startBatteryPolling 모두 성공,
그런데 15초간 RX 0개 → forced reconnect. 세션 2: 재연결 후 14초 만에
첫 rim RX 도착 (10:44:00.475) → 스스로 회복. 즉 peripheral / OS BLE
스택의 좀비 회복 시간이 실측 14초. 기존 15초 timeout 은 회복 직전에
reconnect 발동 → 무한 재연결 루프. 25초로 연장하면 대부분 회복 케이스
살릴 수 있음.
세션 1 의 첫 handshake 응답 실종 (mid → rid 응답 없음) 은 앱 코드로
근본 해결 불가 (peripheral 또는 OS BLE 스택 문제). Watchdog 연장은
실질적 완화책.
병렬 진단 (BLE/thread + Compose lifecycle + null safety) 결과 HIGH
심각도 12건 중 crash 유발 가능성 높고 저비용 고효과인 것 우선 처리.
**H4 — coroutine leak**
- PiezoMonitoringView.kt BleDebugPanel: `while(true) { delay/refresh }` →
`while(isActive)`. LaunchedEffect 취소 후에도 refreshTick++ 이 계속
돌아 GC 방해.
- BladdyRiveView.kt 2곳: 동일 패턴 → isActive 로 통일.
**H1 — BleManager postDelayed 미취소**
- `fwFallbackTimer` (4초 mfv? fallback) + `cccdRetryTimer` (500ms CCCD
재시도) 를 Runnable 참조로 저장. disconnect / GATT_ERROR / watchdog
timeout 3곳에서 handler.removeCallbacks 로 명시적 취소.
- 이전에는 disconnect 후에도 4초 후 sendFirmwareVersionQuery() 가 발동
→ 이미 close 된 GATT 에 write → silent exception → state 오염.
**H2 — MeasurementService 콜백 leak**
- performNirsMeasurement/performPiezoMeasurement 는 singleton 에서
콜백 대입만 하고 정리 안 함. Self-clearing lambda 로 응답 1회 처리
후 자동 null. 시작 시 이전 stale 콜백도 clear.
- stop() 에서도 대기 중 콜백 취소.
**H3 — Watchdog race condition**
- watchdog thread 가 GATT disconnect/close + characteristic null 을
binder thread 에서 직접 실행 → UI thread 의 sendRaw 와 race.
- 정리 전부를 handler.post 로 UI thread 에 위임 → single-threaded.
- fwFallback/cccdRetry timer 도 여기서 함께 취소.
**H6 — PiezoMonitoringView measure() closure leak**
- DisposableEffect 에 piezoCollector.onMultiChannelComplete 정리 추가.
measure() 함수 안에서 이 콜백에 measureScope + channels + outer
state 를 다수 capture → 화면 이탈 후에도 GC 방해 + 재진입 시 stale
closure 가 새 상태 오염 위험.
**H9 — UrineCameraScreen NPE 위험**
- LaunchedEffect 안 while 루프에서 `currentDetection!!` 이 다른 recompose
가 detection 을 null 로 만들면 NPE. Local val snapshot 으로 fix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
증상: 앱 실행 후 alignment → 도넛차트 → 개발자모드 → alignment 이동 시
BLE 가 끊긴 것처럼 응답 실종 → 15초 후 watchdog forced reconnect.
심하면 재연결도 좀비 상태로 나와 3번째 재연결에서야 정상 동작.
원인 1: BleManager.onConnectionStateChange 에서 STATE_CONNECTED 직후
onConnectionStateChanged?.invoke(true) 호출 → 이 시점에는 아직 MTU/
service discovery/CCCD write 이 완료되지 않아 txCharacteristic 은 null.
상위 (PiezoMonitoringView) 가 startBatteryPolling 등 TX 를 시도하면
sendRaw 내부 null-check 에 걸려 silent drop → peripheral 이 응답을
보내지 않음 → watchdog timeout.
원인 2: PiezoMonitoringView / PlacementGuideView 의 DisposableEffect
에서 BleManager 콜백 (onConnectionStateChanged, onUnexpectedDisconnect,
onReconnectionFailed, piezoCollector.onMultiChannelComplete 등) 을
정리하지 않음. 화면 전환 시 이전 화면의 stale 콜백이 남아 새 화면 진입
후에도 발동 → 상태 충돌.
수정:
- BleManager: onConnectionStateChanged(true) 호출을 CCCD write 완료
(isServiceReady=true) 시점으로 이동. CCCD 없음 / RX 없음 edge case 도
동일하게 처리.
- PiezoMonitoringView / PlacementGuideView: DisposableEffect 에서 등록한
BleManager 콜백 전부 null 로 정리.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
V1 은 이미 greenHoldMs=7000 로 hold 로직 있으나 V2 (v2Aligner) 는
매 프레임마다 finalStop 을 재판정하고 false 되는 순간 즉시 unlock 되어
"측정 시작" 버튼이 disable 되는 UX 문제. 사용자가 GREEN 뜬 순간
버튼 누를 시간이 부족.
수정: V2 쪽에도 5초 hold 추가. 진입 후 5초 이내에는 finalStop 이
false 로 바뀌어도 isLocked=true, LED mode 6 (GREEN) 유지.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
기존 finalStop 조건이 `phase == LR_BALANCE` 만 인정했는데, 실제로는
LR_BALANCE 에서 imbal 통과 즉시 `phase = FINAL_CONFIRM` 으로 바뀌어
반환되므로 이 조건에 걸리지 않았음. FINAL_CONFIRM 5프레임 도달 후
STOP + align_complete 반환 시점에도 phase 는 FINAL_CONFIRM 이라
finalStop=false → isLocked 유지 안 됨 → 버튼 disabled.
수정: state=="commit" && phase==FINAL_CONFIRM && action==STOP 을
진짜 정렬 완료로 인정. Accum 중은 이미 앞 branch 로 빠지므로 안전.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
인체 데이터 (data123/) 분석 결과 CH3 (-13.66° down-tilt) 검출률이
Phantom 95.2% → Human 31.8% 로 급락하며 YYNNYY flick 패턴 관측.
기존 "3연속 hit" 진입 조건이 무한 대기에 빠져 사용자에게
"↑ 위로 조금씩" 문구만 반복 노출되는 문제를 해결.
- VERTICAL_CLIMB 진입: 3연속 → 최근 6프레임 중 3회 (majority)
- CH3_STABILIZE / CENTER_OPTIMIZE lost 판정: 4프레임 중 3회 (majority)
- Stuck detection (20프레임 ≈ 5초 대기 시): 상단 3채널 relaxed mode
진입 → CH3 없이도 정렬 완료 진행
- Soft hint (12프레임 ≈ 3초 후): "CH3 확인 중 · 위치 유지/미세 조정"
으로 문구 완화 (사용자 답답함 완화)
- AdvisorState.relaxedMode 플래그 추가 (후속 저장 로직 대비)
Python replay (VBT26050202_0CM 22프레임) 로 검증:
- LEGACY: VERTICAL 무한 대기 → 정렬 실패
- NEW: soft-hint → threshold 도달 시 relaxed → 3프레임 만에 LR_BAL 도달
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CENTER_OPTIMIZE 단계에서 CH0 만 놓친 상태로 "↑ 조금 더 위로" 안내가 계속 나옴.
Probe 를 더 올릴수록 CH0 beam 이 방광 dome 위로 더 벗어남 → 무한 상승 loop.
CH3 를 잃었을 때도 마찬가지: MOVE_UP 으로 회복 시도하지만 CH3 잃은 원인이 이미
probe 가 너무 올라간 것이라 상황 악화.
V1 preset 기하:
- CH0 +6.89° (위로 tilt, 상단 channel) — 방광 dome 담당
- CH3 −13.66° (아래로 tilt, 하단 channel) — 방광 inferior edge 담당
Probe 위치 vs 미검출 채널:
- probe 너무 낮음 → CH3 miss, CH0 catch → MOVE_UP 정답
- probe 너무 높음 → CH0 miss, CH3 catch → MOVE_DOWN 정답
기존 코드는 CH0~2 미검출을 모두 "너무 낮음" 으로 가정 → MOVE_UP.
CENTER_OPTIMIZE 미검출 처리 방향 결정 로직:
- CH3 잃음 (CENTER_OPTIMIZE 진입 후) = probe 가 위로 올라감 → MOVE_DOWN 으로 회복
- 상단만 미검출 (CH0 or CH0+CH1) = probe too high → MOVE_DOWN
- 하단 포함 미검출 (CH2/CH3 포함) = probe too low → MOVE_UP (기존 default)
- 정상 서있음 자세로 배꼽 근처에서 시작 → CENTER_OPTIMIZE 진입 시 "↓ 조금 아래로" 안내
- 치골 근처에서 시작 → 이전과 동일하게 "↑ 조금 더 위로"
- CH3 검출 후 위로 살짝 지나쳤을 때 "↓ ch3 재확인" 나오는지
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
## 이식 대상
알고리즘팀 main : 6229825 → ea3f15c (fast-forward)
- b252de0 : post wall 후보는 peak-only 로 탐색 (shoulder 제외)
- 4afda38 : method_d(apply_cross) 옵션 추가
## MethodDWallSelect 변경
1. BOUNDARY_PEAK_MARGIN = 2 (post 전용)
- post 탐색창 hi 를 2 샘플 밖으로 넓혀 view 확보 → 경계에 걸린 peak 이
shoulder 로 오분류되던 케이스 해결. 수용 범위 [lo, hi] 유지 → farther
peak 새로 안 받음 (d_max 전역 확대 부작용 회피).
2. side=POST 는 peak-only
- shoulder(d2 변곡) 는 후벽을 깊은쪽으로 과확장 → BV 과대 (특히 30° 채널).
- ant 는 shoulder 유지 (near-field 전벽은 shoulder 로 잡히는 게 정상).
## MethodDRunner 변경
- detectMultichannel(applyCross: Boolean = true) 파라미터 추가.
false 면 교차채널 3종 (ant_tiebreak / neighbor_top_validate / inward_post) 건너뜀.
## AlignmentAdvisorV2 (RollingAligner) 변경
- 정렬 위치 선택은 applyCross=false 로 base 검출 사용.
이유: 교차채널 보정이 nch 를 바꿔 위치 선택이 흔들리는 것을 차단.
BV 산출은 PiezoMonitoringView 경로에서 별도로 applyCross=true 로 진행.
## 컴파일
:app:compileDevDebugKotlin / :compileDemoDebugKotlin / UnitTest 모두 통과.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- 걷기 시작 후 4초에 chip "걷는중" 전이 (기존 6초)
- 이중 안전장치 유지:
* walkingStartWmean=10dps 로 stand-up motion (5~10dps) 이미 필터
* walkingMinPitchDeg=45° 로 upright 자세만 candidate
- Stand-up 자체가 4초 이내 대개 안정화되므로 false positive 여지 낮음.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
## 증상
걷기 시작 → 몇 초간 chip "감지중" 유지 → 그 후 "걷는중" 으로 전이.
## 원인
1. 걷기 발 임팩트로 순간 acc mag > 1.2g → classifier acc gate 실패 → UNKNOWN
2. Composite = UNKNOWN → 3연속 후 chip "감지중" 로 넘어감
3. Walking detector 는 6초 hold 진행 중이라 아직 IDLE
4. 6초 뒤 walking 발화 → chip "걷는중"
즉 6초 walking build-up 내내 chip "감지중" 이 뜨고 갑자기 "걷는중" 으로 점프.
## 수정
Composite 로직에서 UNKNOWN 발생 시 tilt(=r.pitchDeg) 로 fallback 판정.
- tilt > 25° (pitchLyingThreshold) → NON_LYING (upright 로 판정)
- tilt ≤ 25° → LYING
이유: tilt 는 acc gate 와 무관하게 항상 계산됨. 걷는 중 avg tilt 는 유효
(각 걸음의 upright 자세 반영). 정확한 pitch 는 어렵지만 lying vs upright
정도는 확신 가능.
## 결과 (예상)
걷기 시작 → chip 즉시 "일어서 있음" (NON_LYING) → 6초 walking hold 완료
후 "걷는중" 으로 전이. 감지중 flash 없음.
정지 자세 + 실제 오류 (acc mag 극단값 etc) 는 여전히 tilt 로 커버되므로
chip 은 최소한 LYING 또는 NON_LYING 을 표시. "감지중" 은 실질적으로 초기
1-2 cycle (buffer 부족) 에만 등장.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
## 문제
"5초 누워있다가 세워서 5초 움직였는데 다시 누움이 뜸"
= 세워 놓은 5초 동안 NON_LYING 이 뜨지 않음.
## 원인
기존 pitch = atan(ax / √(ay² + az²))
→ AX 축이 세로일 때만 큰 pitch, NON_LYING 인식.
→ AY 축이 세로면 (손으로 기기를 세로로 잡는 흔한 자세) pitch=0 → LYING.
정상 착용 (배 위 부착) 시엔 AX가 세로가 되도록 설계됐지만,
손 테스트에선 AY 축 세로가 되기 쉬움. 특히 흔들기 테스트할 때.
## 수정
tilt = atan(√(ax² + ay²) / |az|)
→ **Z축이 중력방향에서 얼마나 벗어났는지** 측정. 방향 독립적.
- 기기 평평 (AZ up) → tilt=0° → LYING ✓
- X축 세로 (AX up) → tilt=90° → NON_LYING ✓
- Y축 세로 (AY up) → tilt=90° → NON_LYING ✓ ★ 신규
- 완전 옆으로 세워짐 (az≈0) → tilt=90° → NON_LYING ✓
## 임계값
`pitchLyingThreshold = 25f` 유지 (Dev 슬라이더 그대로).
- 이전 의미: X축 tilt ≤ 25° = LYING
- 신규 의미: Z축 tilt ≤ 25° = LYING (기기 평평 여유 ±25°)
정상 착용에선 동일하게 동작. hand-test 시나리오 추가 커버.
## 다음
Walking detector 는 pitch 를 그대로 재활용. tilt=90° 근처면
walkingMinPitchDeg=45° 통과. 이전엔 손 세로 잡기로 walking 감지도 못
했지만 이제 가능.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
## 문제
기기를 흔들면 "감지중" (posture UNKNOWN) 후 "기기 응답 없음" 배너 flap.
## 원인
FirmwareWarningBanner 의 deviceAlive 기준이 5초로 너무 타이트:
1. 흔들면 BLE 안테나 wobbling → 짧은 disconnect
2. onConnectionStateChange → firmwareVersion.value = "" reset
3. 재연결 후 mid/mfv 재요청 응답 지연 → 8초 후 timedOut=true
4. 동시에 mim? 응답 몇 개 놓쳐 5초 초과 → deviceAlive=false
5. showTimedOut = timedOut && !deviceAlive → 배너 표시
## 수정
deviceAlive 5→12초. Watchdog 자체가 15초 timeout 이라, 12초 내 재개되는
transient 스톨은 배너로 알리지 않고 조용히 대기. 진짜 disconnect 는
watchdog 이 처리.
## 참고
"감지중" (posture UNKNOWN) 은 acc mag > 1.2g 정상 동작 (흔들 때 acc gate
초과 → classifier UNKNOWN). 이미 3연속 hysteresis 있어 순간 튐엔 반응 X.
수정 대상 아님.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
사용자앱 (feature/tab-navigation) 과 패키지 이름 일치. namespace + applicationId
둘 다 com.medithings.vesiscan 로 변경 (기존 데모 앱은 재설치 필요).
## 변경 범위
- Kotlin 148 파일: package + import 문 (714 occurrences)
- 디렉토리 이동: com/example/medilightv2android → com/medithings/vesiscan
(main, test, androidTest 각각)
- app/build.gradle.kts: namespace, applicationId
- docs/FLAVOR_DEMO_STABLE.md: 참조 갱신
- V41DetectorCH4Test: BvDispatchResult.methodChosen → method (dto field name fix)
## 주의
- applicationId 가 바뀌므로 기존 데모 앱 (com.example.medilightv2android.demo) 은
Android 관점에서 다른 앱으로 취급 — 재설치 시 PIN/설정 초기화됨.
- Fresh install 권장. 기존 앱 (com.example...) 은 별도로 uninstall 필요.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
## Cross-channel 신 3종 (MethodDRunner)
- applyAntTiebreak: 4830e7c 재작성 (dominant-pick 보호 + tie/shallow/deep 3-branch)
- applyNeighborTopValidate: Rule A (FN 복원) + Rule B (trend FP drop/교체) 신규
- applyInwardPost: 최상단 widest post 과확장 교정 신규 (5576a59)
- researchPostInSpan: seed span 재탐색 + wall/raw ratio gate 재검증
- MethodDResult 확장: post/postRefined/postType 를 var 로 (사후 수정 지원)
## BV 신 helpers (PiezoBVEstimator)
- applyLumenInsetOne: lumen_inset_frac=0.15 (Python default) 벽 인셋
- sagittaMm: chord 대비 최대 수직 이탈
- shrinkSiRadius: Kåsa 원 fit + sagitta shrinkage → R_eff
- minorCapHeight, sphericalCapVolume: 신 cap 공식
- estimateBladderVolume cap 로직 교체:
R_eff 성공 시 → spherical cap (V = π·h²·(3R-h)/3·lr)
실패 시 → 기존 hemisphere fallback
## 캘러 갱신
- PiezoMonitoringView: MethodD 벽에 lumen inset 적용 후 BV
## 검증 (사용자앱 feature/tab-navigation)
Python 대비 mean |Δ| = 1.27 mL. 회귀 게이트 (BvIsolationTest) 는 사용자앱에만 유지.
Note: 데모 dps 는 이미 1.936 이라 별도 fix 불필요.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
algorithm 팀 최신 표준 (piezophantomtest 4830e7c) 반영.
- GreenZoneConstants.lrRatioOverride: Double? = 1.0 추가 (default 1.0)
- estimateBladderVolume6ch — override 있으면 computeLrRatio 스킵
- Dev panel toggle "lr=1.0 fixed" — ON/OFF 로 fixed vs computed 전환
Phantom 검증 (2533 scans, 150 mL):
- APP (기존): 147.6 mL (-1.6%) ✓
- Config F (Python NEW lib + lr=1.0): 147.3 mL (-1.8%) ← 완전 동일
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
feature/tab-navigation 의 AlignmentAdvisorV2 를 demo 에 포팅 (packages 만 rename).
기존 4-phase (INITIAL_ACCUM → VERTICAL_CLIMB → CH3_STABILIZE → LR_BALANCE)
→ 5-phase (+ FINAL_CONFIRM).
FINAL_CONFIRM phase:
- LR_BALANCE 에서 |u4-u5| ≤ 8 도달 시 진입
- buf.clear() 후 5 fresh cycle 누적
- imbal 여전히 ≤ 8 유지 → 최종 STOP
- 실패 시 LR_BALANCE 로 복귀
- 목적: sliding window 잔여 효과 제거 후 재검증
추가 항목:
- Phase 별 dynamic window (accumKVertical=10, accumKLateral=5, accumKConfirm=5)
- lostStreakRequired=3 hysteresis (LR_BALANCE 채널 깜빡임 방지)
- msgKey / msgArgs (i18n 지원)
Sensor alignment default:
- 일반 사용자 (V1): 기존 3-step 유지 (변경 없음)
- Clinical alignment session (V2): 5-phase 사용
PlacementGuideView step indicator: 4 → 5.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>