feat(clinical): 임상 화면에 BV · 채널별 전후벽 · Spot/Continuous 측정

알고리즘은 이미 이식돼 있었다(MethodDRunner + estimateBv, AnchorGuide 가 cap_frac 에
쓰고 있다). 없던 것은 그 결과를 화면에 꺼내는 부분이다.

**ClinicalBv** — 검출→BV 조합을 한 군데로 묶는다. 조합을 화면마다 다시 쓰면 반드시
갈린다: CCC on/off, missingOutside 전달 여부, ant vs antRefined 중 무엇을 넘기는가.
셋 다 결과를 바꾼다. AnchorGuide 의 cap_frac 경로를 그대로 쓰고, 테스트가 두 경로의
volumeMl 이 1e-9 안에서 같은지 실측 trace 로 고정한다 — 갈리면 정렬이 고른 위치의
근거와 화면의 용적이 다른 계산이 되어 이 기능의 목적(위치 검증)이 무너진다.

**실패를 값으로 돌려준다.** 이 화면들의 관심사는 "왜 용적이 안 나오나"다. null 만
주면 화면은 "—" 밖에 못 쓴다. 검출 0개(부착 문제) / 2개 미만(위치 문제) / 검출은
됐는데 기하가 안 풀림을 각각 다른 문구로 낸다 — 대응이 다르기 때문이다.

**BvPanel** — 용적 + 채널별 전벽·후벽 표. 샘플 인덱스(파형에서 보이는 것)·mm·직경
(BV 가 실제로 쓴 값)을 나란히 둔다. "파형엔 벽이 보이는데 용적이 이상하다"에서 어느
단계가 틀렸는지 갈리려면 셋이 같이 있어야 한다. 직경이 "제외"면 검출은 됐지만 BV 에서
빠진 채널이다. 실패해도 검출된 만큼은 그대로 보여준다 — 어느 채널이 빠졌는지가 답이다.

**BvMeasureSection** — Spot / Continuous. Spot 은 "지금 얼마인가", Continuous 는
**흔들리는지**를 본다. 한 번 재서 나온 200mL 가 진짜인지는 한 번으로 알 수 없고,
정렬 위치가 최적인지 판단하려면 재현성이 필요하다. 그래서 연속 모드는 최근 20회의
평균·표준편차·CV·범위를 같이 낸다. 5 cycle 을 모아 mean-scan 후 1회 계산한다 —
1 cycle 로 재면 노이즈가 그대로 벽으로 잡힌다.

측정 조건은 정렬과 같게 고정(2.3MHz·cycle 3). 검출 문턱이 절대값 기준이라 조건이
바뀌면 nch 가 달라지고, 정렬이 고른 위치의 근거와 다른 조건의 BV 가 된다.

붙인 곳:
· 부착 위치 정렬 — 위치마다 판정과 **같은 mean-scan** 으로 BV. 따로 재면 그 위치의
  지표와 용적이 다른 데이터가 되어 대조가 성립하지 않는다.
· 병원 임상 모드 — 대기 중 Spot/Continuous, 측정 중 직전 회차 BV.
· 두 화면의 파형에 전벽(초록)·후벽(주황) 세로선. 마커가 검출 인덱스와 같은지도 테스트.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 09:36:22 +09:00
parent afeb3acc57
commit e9f5601371
8 changed files with 791 additions and 4 deletions
@@ -102,6 +102,32 @@ class BleManager private constructor(private val context: Context) {
// 2026-08-04: 10 → 5 축소. 지속 실패 시 사용자에게 명확한 안내가 낫고 무한 시도로
// 상단 배너/로그 스팸 방지. 실패 후 사용자가 명시적으로 재시도 (앱 재시작 · 재연결).
private val MAX_RECONNECT_ATTEMPTS = 5
/**
* 무한 재연결 모드 — **장시간 무인 측정 전용**.
*
* 평소에는 5회(약 1분)에서 포기하는 것이 맞다. 사용자가 옆에 있으니 "재연결 실패,
* 기기를 확인하세요"가 무한 스캔보다 낫고, 무한 스캔은 폰 배터리를 계속 먹는다.
*
* 그런데 밤새 걸어 두는 부하 테스트에서는 그 정책이 그대로 실패가 된다 — 2026-09-08
* 실측에서 프로브 펌웨어가 3시간 18분 만에 얼어붙었고, 앱이 1분 뒤 포기한 뒤로
* **39분을 아무것도 안 하고 흘려보냈다.** 프로브가 리셋되어 살아나도 다시 붙지 않는다.
*
* 이 모드에서는 포기하지 않고 [FOREVER_SLOW_INTERVAL_MS] 간격으로 계속 시도한다.
* 복구되면 부하가 자동 재개되고, 언제 죽고 언제 살아났는지가 로그와 CSV 에 남는다.
*
* 끄는 것을 잊으면 일반 사용에서도 무한 스캔이 도므로, 켠 쪽이 반드시 되돌린다
* (BatteryDrainTester.start/stop).
*/
@Volatile var reconnectForever: Boolean = false
/**
* 무한 모드에서 초기 5회를 넘긴 뒤의 재시도 간격.
*
* 3초 간격을 밤새 유지하면 스캔이 1,200회/시간이라 폰 배터리와 로그를 모두 태운다.
* 프로브가 죽은 뒤 살아나는 것은 분 단위 사건이므로 30초면 충분하다.
*/
private val FOREVER_SLOW_INTERVAL_MS = 30_000L
private var reconnectTimer: Runnable? = null
@Volatile private var lastRxTimestamp: Long = 0
private var watchdogTimer: Runnable? = null
@@ -1242,14 +1268,18 @@ class BleManager private constructor(private val context: Context) {
// ── Auto Reconnect ──
private fun scheduleAutoReconnect() {
logd { "Auto-reconnect attempt ${reconnectAttempt + 1} in ${reconnectIntervalMs}ms" }
debugLogger.reconnecting(reconnectAttempt + 1, -1, reconnectIntervalMs)
// 무한 모드에서 초기 시도를 넘기면 간격을 늘린다 — 3초 간격을 밤새 유지하면
// 스캔이 시간당 1,200회라 폰 배터리와 로그를 같이 태운다.
val delay = if (reconnectForever && reconnectAttempt >= MAX_RECONNECT_ATTEMPTS)
FOREVER_SLOW_INTERVAL_MS else reconnectIntervalMs
logd { "Auto-reconnect attempt ${reconnectAttempt + 1} in ${delay}ms" }
debugLogger.reconnecting(reconnectAttempt + 1, -1, delay)
isReconnecting.value = true
cancelReconnectTimer()
val timer = Runnable { attemptAutoReconnect() }
reconnectTimer = timer
handler.postDelayed(timer, reconnectIntervalMs)
handler.postDelayed(timer, delay)
}
private var reconnectScanCallback: ScanCallback? = null
@@ -1264,7 +1294,9 @@ class BleManager private constructor(private val context: Context) {
return
}
// max 시도 가드 — 무한 루프 방지. 초과 시 사용자가 명시적으로 재시도하도록.
if (reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
// 단 [reconnectForever] 면 포기하지 않는다(장시간 무인 측정). 간격은
// scheduleAutoReconnect 가 늦춰 두었으므로 여기서는 통과만 시킨다.
if (!reconnectForever && reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
debugLogger.warn("RECONNECT_GIVE_UP after ${reconnectAttempt} attempts — manual retry required")
isReconnecting.value = false
reconnectAttempt = 0