piezo-phantom-test `vesiscan_test` 의 단일 pass 정렬을 이식한다. 규칙은
`alignment_selection.select_supine_anchor`(tie='cap'), 절차는
`alignment_runners.AnchorGuide`, 계약은 `porting/SPEC.md` §8.
- managers/AnchorGuide.kt 알고리즘 코어(측정·종료판정·선택 위임·안내)
- ui/.../AnchorAlignView.kt 위치별 측정 화면 + 원시 데이터 저장
- HospitalRunStore align/ 폴더에 위치별 raw cycle + 결과 요약
- 병원 모드에 정렬 카드 · 매니페스트에 anchor_cm
기존 AlignmentAdvisorV3 는 같은 저장소의 **구 알고리즘**(2-pass CenterAligner ·
bvcv tie · 오프셋 없음)이고 호출처가 0이라 그대로 두었다. Python 쪽에서도 이미
주석 처리됐다. 제품 경로는 새 파일이다.
## 검증
- 선택 규칙: Python 무작위 200 케이스 전수 대조 **200/200 일치**
(CH3 2단 게이트 · fallback · NaN=+inf · 동률 min cm 경계 포함)
- trace 분할: 22 cycle · win 10 → 11 trace, Python 과 일치
- 실기기: 20 cycle 수집 → 판정 → 안내 → 저장까지 완주 확인
## 알려진 불일치 — 화면에도 적었다
앱의 **벽 검출**(MethodDRunner)이 현재 Python 레퍼런스와 어긋난다. 실측
(HUMAN-kai VBT26050202, v1):
cm=0 CH2 ant 13.913 vs 18.559 · span 24~61 vs 33~60
cm=1 CH1 ant 4.486 vs 13.188 · CH3 는 Python 검출 / 앱 미검출
cm=1 판정 nch=4 ch3=O vs nch=3 ch3=X
cm=0 cap_frac 0.63899 vs 0.67479 · BV 410.6 vs 470.5 mL
cm=1 은 레퍼런스에서 유력 후보인데 앱에서는 후보 자격조차 없다. 정렬 선택이 이
값들 위에 서 있으므로 **앱의 추천 위치는 아직 레퍼런스의 답이 아니다.**
그래서 (1) 화면에 검증 전임을 명시하고, (2) 위치별 raw cycle 을 반드시 남긴다
(align_{n}cm.csv · Python 대조 스크립트가 바로 읽는 형식). 앱 판정이 틀려도
나중에 Python 으로 다시 고를 수 있어야 그 환자를 다시 부르지 않는다.
AnchorMeasureParityTest 는 이 불일치를 표로 기록한 채 @Ignore 로 격리했다 —
기대값을 코틀린 값으로 낮추면 대조 시험의 존재 이유가 사라진다.
BV **코어** 자체는 맞다. 벽을 고정해 넣으면 Python estimate_bv
(ellipse_cap_height=true · cap_fit='specific')와 마지막 자리까지 일치한다.
## 곁가지
- gradle.properties 에서 -Dfile.encoding=UTF-8 제거. 한글 사용자 폴더에서
테스트 워커가 클래스패스를 못 찾아(GradleWorkerMain) **단위시험이 통째로
실행되지 않고 있었다**. 신 저장소에서 같은 원인을 이미 확인했다.
- 환자명을 AppState 로 올렸다. 정렬 화면을 다녀오면 remember 가 초기화돼,
이름을 다시 치는 순간 "환자가 바뀌었다"로 보여 방금 맞춘 정렬이 지워졌다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
간호사가 자세·방광 단계를 하나씩 올려 가며 재는데, 중간에 자리를 비우거나
순서가 꼬이면 "0% 했던가?"를 기억으로 판단하게 된다는 현장 의견. 그러면 한
단계를 통째로 빠뜨리거나(방광을 다시 채워야 한다) 같은 조건을 두 번 재게 된다.
화면이 대신 기억하게 한다.
- 방광 단계 칩: 지금 고른 자세 안에서의 진행. 완료 ✓(초록) / 일부 ·(주황 테두리)
- 자세 칩: 그 자세의 6단계가 전부 끝나야 완료
- 실행이 끝날 때마다 다시 읽어 방금 잰 단계가 즉시 채워진다
판정 근거는 **파일 존재가 아니라 실행 매니페스트**다. CSV 는 첫 회차만 저장돼도
만들어지므로, 파일 유무로 보면 20회 중 2회에서 끊긴 것도 "완료"로 보인다. 그게
제일 위험하다 — 다 됐다고 믿고 방광을 비우는데 데이터는 못 쓰고 그 단계는 그날
다시 못 잰다. 매니페스트의 조합별 planned/saved 로 "6조합이 전부 계획한 횟수를
채웠는가"를 본다. 중단된 실행도 매니페스트가 남으므로 PARTIAL 로 잡힌다.
완료/일부를 색뿐 아니라 기호(✓ ·)로도 구분한다 — 병실 조명·화면 반사에서
색만으로는 잘 안 보인다.
검증: 실기기(VBT26080001 연결)에서 오늘 09:40 실측 데이터로 확인.
(Supine, 0%) → 0% 칩 ✓ · Supine 칩 · (6단계 중 1단계라 일부). 선택을 20% 로
옮기면 0% 가 초록 채움으로 남는 것까지 확인. 측정 경로는 건드리지 않았다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## 조작자가 하는 일은 하나다
간호사가 방광을 정해진 정도까지 채워 두면, 조작자는 **그 채움 정도만** 고르고
[측정 시작]을 누른다. 이후 주파수 2종 × cycle 3종 = 6조합을 앱이 자동으로 순회하며
각 조합마다 n회(기본 20) 측정하고 조합별 파일로 저장한다.
조합을 손으로 바꾸게 두면 반드시 빠뜨리거나 잘못 기록한다. 이미 채워 둔 방광은 다시
만들 수 없으니, 그 자리에서 놓친 조합은 그날 데이터에서 영영 빈다. 사람이 개입하는
지점을 하나로 줄인 것이 이 모드의 핵심이다.
진입: 홈에서 캐릭터 3연타 → 개발자 모드 → [병원 임상 측정]
## 프로토콜
자세 Supine / Sitting / Standing (기존 ClinicalPosture 재사용)
주파수 1.8 · 2.3 MHz → mpa? 첫 인자
cycle 3 · 5 · 7 → mpa? 둘째 인자
방광 채움 0/20/40/60/80/100 % → 사람이 아는 값 = 정답 라벨
반복 화면 입력 (기본 20)
## 저장
Downloads/VesiScan_Hospital/{날짜_환자}/
2026-09-03_홍길동_Supine_040pct_1.8MHz-fopt1_c3.csv
run_HHmmss.json ← 계획 대비 실제 저장 수
파일명에 조건을 전부 적는다. 폴더로만 구분하면 파일 하나를 옮기는 순간 조건을 잃는데,
임상 데이터는 나중에 다른 사람이 모아서 분석한다. CSV 열 구성은 기존 AdcCsvLogger 와
맞춰(scan_id·timestamp·channel·s0~s99) 분석 스크립트를 새로 만들지 않아도 되게 했다.
기존 임상 R&D(`VesiScan_Sessions/`)와 폴더를 나눴다 — 목적도 구조도 달라서 섞이면
나중에 파일을 하나씩 열어 봐야 한다.
## ⚠ 주파수 코드값이 확인 전이다
`mpa?` 첫 인자는 정수인데 그 정수와 MHz 의 대응이 코드에도
docs/BLE_PROTOCOL_REFERENCE.md 에도 없다. 기존 코드는 늘 `freqOption = 2` 만 썼다
(PlacementGuideView · MeasurementService). 그래서 1.8→1, 2.3→2 는 **가정**이다.
확인 전에 모은 데이터가 버려지지 않도록, 실제로 보낸 정수를 파일명(`fopt1`)과 CSV 열
(`freq_option`), run json 에 함께 남긴다. 대응이 반대로 밝혀져도 라벨만 바꾸면 된다.
run json 에 `freq_option_mapping_confirmed: false` 를 박아 두어 나중에 이 데이터가
어떤 상태에서 모였는지 알 수 있게 했다. 화면에도 같은 경고를 띄운다.
## 데이터 정합성
결과를 평범한 var 로 주고받으면 BLE 스레드↔코루틴 간 가시성이 보장되지 않아 Channel
을 쓴다. 그리고 **회차마다 보내기 전에 채널을 비운다** — 직전 회차가 시간초과된 뒤
뒤늦게 도착한 결과가 남아 있으면 이번 회차 데이터로 잘못 기록된다.
한 회차가 3초 안에 안 오면 실패로 세고 다음으로 넘어간다. 한 번 막혔다고 전체가
멈추면 채워 둔 방광을 버리게 된다. 실패 수는 화면과 run json 에 남는다.
## 검증
빌드 통과. **실기기 확인은 아직 못 했다** — 폰이 절전 상태로 들어가 화면을 못 띄웠고,
프로브 연결 상태의 측정 루프는 전혀 돌려보지 못했다. 내일 임상 전에 반드시 한 번
돌려봐야 한다(6조합 × 20회 = 120측정 · 약 2~3분 예상).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
같은 빌드가 태블릿 34시간 / Xiaomi 40분 이었던 문제 대응.
앱 구조(Foreground Service + connectedDevice + WakeLock)는 이미 맞았고
제조사 배터리 관리자가 프로세스를 죽인 것이라, 구조 변경이 아니라
감지와 안내로 접근한다.
- LongRunGuard: 측정 시작/생존/정상종료를 기록해 비정상 종료를 감지.
다음 실행 때 "언제부터 언제까지 몇 분" 을 알려준다. 제조사 무관하게 동작.
- BLE 로그 헤더에 기기/제한 상태 진단줄 (실패 후 원인 판별용).
- 설정 패널에 "장시간 측정 준비" — 배터리 최적화·미사용앱 제한 상태 확인 및
해제 진입. 자동시작은 상태를 읽을 수 없어 안내만 제공.
측정 시작을 모달로 막지 않는다. 초안에서는 Auto Scan 에 사전점검을 걸었는데
OEM_AUTOSTART 를 제조사 이름만으로 판단해서(상태 조회 API 가 없음) 설정을
완벽히 해둔 기기에도 영원히 뜨는 문제가 있었다. 고칠 수도 없는 항목으로
측정을 가로막으면 경고가 무시된다. 추측으로 경고하지 않고, 실제로 중단됐을
때만 알린다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Downloads 에 VesiScan_BLE_*.log 가 여러 개 생기고 그마저 내용이 비던 문제.
원인이 셋이었다.
1. 재연결마다 파일이 갈라짐
connected() 가 liveLogFile=null 로 핸들을 버려서 BLE 가 끊겼다 붙을 때마다
새 파일이 생겼다. 앱 시작~첫 연결 구간도 별도 파일로 빠졌다.
→ 프로세스 1회당 파일 1개. 재연결 시에는 구분선만 남긴다.
2. 파일 쓰기에 동기화가 없음
rx() 는 BLE 콜백 스레드, 나머지는 UI 스레드에서 호출되는데 매번 파일을
새로 열어 쓰며 잠금이 없었다. 동시 쓰기로 줄이 섞이거나 유실됐다.
→ synchronized + 핸들 유지, 줄마다 flush (강제 종료에도 그 시점까지 보존).
3. 저장 실패가 조용히 묻힘
Downloads 쓰기가 막히면 예외를 삼켜 아무 데도 안 남았다.
→ 내부 저장소로 폴백하고 실제 경로를 파일 헤더에 기록.
추가로 두 가지:
- Export 버튼이 메모리 링버퍼(2000줄)를 덤프해 "마지막 몇 분"만 나오던 것을
실시간 세션 파일을 그대로 내보내도록 변경.
- disconnected() 가 비정상 종료 경로에서만 호출돼, 사용자가 직접 끊으면
아무 기록 없이 로그가 끊겼다. 이유(user/unexpected/gatt_error)를 구분해
항상 남기도록 수정.
임상 모드 step 별 ble.log 는 부가 사본으로 그대로 유지한다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
배경 (LOG_STORAGE_ANALYSIS §D):
ClinicalSessionStore.cyclesBuffer 는 endMeasurement() 호출 전까지 모든 사이클을
메모리 보관. MeasurementCycle 1개 ≈ 16KB · autoCapture 600ms → 시간당 ~90MB 힙.
장시간 임상 세션 OOM 위험.
수정:
cyclesBuffer (MutableList<MeasurementCycle>) 완전 제거 · cycleCounter (Int) 로 대체.
addCycle / addAlignmentFrame 은 이제:
1. MeasurementCycle 생성
2. appendCycleToStream() → cycles.jsonl 에 즉시 append (fire-and-forget)
3. cycleCounter++
writeMeasurementJson (endMeasurement 시점):
- cycles.jsonl 를 useLines 스트리밍 read → cyclesArr 조립
- measurement.json 생성 성공 시 cycles.jsonl 삭제 (SSOT 유지)
- 파싱 실패 line 은 skip (corrupt 방지)
startMeasurement:
- 이전 세션 잔여 cycles.jsonl 삭제 (같은 folderName 재사용 방지)
discardAndDeleteFolder / endMeasurement:
- cycleCounter = 0 리셋
효과:
메모리 상시: cycleCounter (4B) 만 · 이전 대비 시간당 90MB 절감
End 시점 read-back 은 필요 (measurement.json aggregate) · 그때만 잠깐 spike
Cycle 저장 실패해도 측정 계속 (silent · fire-and-forget UC-05)
측정.json 스키마 변경 없음 · guardian app / 분석 툴 하위 호환.
검증: BUILD SUCCESSFUL 2s
배경 (LOG_STORAGE_ANALYSIS 2026-08-04):
§A · ble.log 의 scan= 값이 실제 adc.csv scan_id 대비 1씩 밀림 (off-by-one)
- 원인: measurementResult() 가 AdcCsvLogger.lastScanId 를 읽은 후에
AdcCsvLogger.log() 가 scanCounter 를 증가시켜 실제 CSV 는 N+1 로 기록.
§B · events.jsonl 에 scan_id 필드 부재 → 4개 로그 (adc.csv · imu.csv · ble.log ·
events.jsonl) 간 조인 키 없음. timestamp 밀리초만으로는 autoScanIntervalMs=600ms
에서 신뢰 불가.
§F · BV 실패 케이스가 events.jsonl 에 아무 흔적 없음.
수정:
AdcCsvLogger:
- nextScanId(): Int 신규 · thread-safe counter 증가 후 id 반환
- log(...): 새 scanId 파라미터 · 호출자가 미리 발급한 id 우선 사용
- counterLock 으로 동시성 방어
MeasurementLogService.logMeasurement(...):
- scanId: Int? 파라미터 추가 · events.jsonl 에 "scan_id" 필드 기록
BleDebugLogger.measurementResult(...):
- scanId: Int? 파라미터 · 호출자가 발급한 id 우선 (미제공 시 legacy fallback)
ImuCsvLogger.log(...):
- scanId: Int? 파라미터 · 호출자가 발급한 id 우선
PiezoMonitoringView (5 sites · onMultiChannelComplete):
- 사이클 진입 시 val scanId = AdcCsvLogger.nextScanId() 미리 발급
- useV41Bv · useMethodDBv · effectiveCenterWalls>=4 · totalWallCount>0 ·
no-valid-channels 5경로 모두 scanId 전달
- §F: BV_SKIP · BV_FAIL 케이스도 events.jsonl 에 기록 (null volume)
ClinicalLiveView (1 site):
- autoCapture 시 val sid = AdcCsvLogger.nextScanId() → AdcCsvLogger + ImuCsvLogger
에 동일 id 전달
효과:
ble.log · adc.csv · imu.csv · events.jsonl 이 scan_id 로 정합 조인 가능.
Guardian 앱이 이벤트 재구성 시 timestamp 대신 scan_id 단일 키 사용.
BV 실패도 audit trail 에 기록.
검증: BUILD SUCCESSFUL 8s
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>
병렬 진단 (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>
사용자앱 (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>