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
@@ -143,6 +143,8 @@ object BatteryDrainTester {
override fun run() {
val b = ble
if (b == null || !isRunning.value) return
// 1초마다 도는 이 자리가 연결 전이를 가장 촘촘히 잡는 지점이다.
noteConnection(b.isConnected.value)
when {
!b.isConnected.value -> imuSkipped.value++
// mtb/mbb 응답 stream 진행 중 — imuCollector.reset() 이 그 rim 을 파괴하므로 skip.
@@ -170,6 +172,27 @@ object BatteryDrainTester {
}
}
/**
* 직전에 관측한 연결 상태. null = 아직 한 번도 안 봄.
*
* `connected` 열만으로도 끊긴 구간은 보이지만, tick 이 1분 간격이라 **정확히 언제**
* 끊기고 언제 붙었는지가 ±60초로 흐려진다. 50시간에서 회복이 몇 번 있었는지 세려면
* 전이 시각이 필요하다.
*/
private var lastConnected: Boolean? = null
/** 연결 상태가 바뀐 순간에만 행 하나. [imuTick] 이 1초마다 불러 초 단위로 잡는다. */
private fun noteConnection(connected: Boolean) {
if (lastConnected == connected) return
val first = lastConnected == null
lastConnected = connected
if (first) return // 시작 시점은 tick 이 이미 남긴다
csvRow(if (connected) "reconnect" else "disconnect")
ble?.debugLogger?.info(
"BATT_DRAIN ${if (connected) "RECONNECTED" else "DISCONNECTED"} t=${elapsedMs() / 1000}s"
)
}
/**
* 1분 heartbeat — CSV 한 행 + BLE 로그 한 줄.
*
@@ -248,6 +271,10 @@ object BatteryDrainTester {
startedAtMs.value = System.currentTimeMillis()
isRunning.value = true
bleManager.onResponseFrame = frameTap
// 밤새 걸어 두는 측정이라 재연결을 포기하면 안 된다. 기본 정책(5회 ≈ 1분)은
// 사용자가 옆에 있을 때 맞는 것이고, 여기서는 그 뒤로 몇 시간을 그냥 흘려보낸다
// (2026-09-08: 프로브 freeze 뒤 39분 손실). 반드시 stop 에서 되돌린다.
bleManager.reconnectForever = true
openCsv(startedAtMs.value)
bleManager.debugLogger.info(
"BATT_DRAIN start · mim=${IMU_INTERVAL_MS}ms mbb=${FULL_INTERVAL_MS}ms " +
@@ -268,6 +295,8 @@ object BatteryDrainTester {
handler.removeCallbacks(sampleTick)
closeCsv()
ble?.let { b ->
// 켠 쪽이 되돌린다 — 안 끄면 일반 사용에서도 무한 스캔이 돈다.
b.reconnectForever = false
if (b.onResponseFrame === frameTap) b.onResponseFrame = null
b.debugLogger.info(
"BATT_DRAIN stop · elapsed=${elapsedMs() / 1000}s " +
@@ -288,6 +317,7 @@ object BatteryDrainTester {
lastBatteryMv.value = 0; firstBatteryMv.value = 0
lastTempC.value = Float.NaN
lastRxAtMs.value = 0L
lastConnected = null
}
fun elapsedMs(): Long =
@@ -0,0 +1,158 @@
/*
* ClinicalBv — 6채널 신호 → 벽 검출 → 방광용적(BV). 임상 화면들이 공유하는 단일 진입점.
*/
package com.medithings.vesiscan.managers
import com.medithings.vesiscan.walldetect.MethodDRunner
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDParams
/**
* 임상 화면에서 BV 를 뽑는 **하나뿐인 통로**.
*
* ## 왜 필요한가
* 검출과 BV 는 이미 다 있다([MethodDRunner] · [estimateBv]). 그런데 그것을 **올바른
* 순서와 옵션으로 엮는 부분**이 [AnchorGuide.measure] 안에 묻혀 있어, 화면에서 BV 를
* 보려면 그 조합을 다시 써야 했다. 조합을 두 번 쓰면 반드시 갈린다 — CCC on/off,
* `missingOutside` 전달, `antRefined` vs `ant` 중 어느 것을 쓰는지가 전부 결과를 바꾼다.
*
* 그래서 AnchorGuide 의 `cap_frac` 산출과 **같은 경로**를 여기 한 번만 적고 둘 다 쓴다.
* (SPEC §8.2: `cap_frac` 은 mean-scan 1회 검출 · **CCC on**.)
*
* ## 실패를 값으로 돌려준다
* BV 가 안 나오는 것이 이 화면들의 주된 관심사다 — "왜 용적이 안 뜨나"를 그 자리에서
* 답해야 한다. null 만 돌려주면 화면은 "—" 밖에 못 쓴다. [Outcome.failure] 가 사람이
* 읽을 이유를 담고, [Outcome.walls] 는 실패했더라도 **검출된 만큼**을 그대로 준다.
* 파형 위의 전벽·후벽 마커가 그 값을 쓴다.
*/
object ClinicalBv {
/**
* 한 채널의 검출 결과. 화면이 파형 위에 그리고, 표로도 보여준다.
*
* [ant]·[post] 는 **샘플 인덱스**(정수)다 — 파형 x축이 샘플이므로 그대로 그린다.
* [antMm]·[postMm] 는 같은 값을 거리로 옮긴 것이고, [diameterMm] 는 BV 가 실제로
* 쓰는 단면 직경이다(각도 보정 포함). 셋을 같이 두는 이유는 "파형에서는 벽이
* 보이는데 용적이 이상하다"는 상황에서 어느 단계가 틀렸는지 갈라야 하기 때문이다.
*/
data class ChannelWall(
val channel: Int,
val ant: Int,
val post: Int,
val antMm: Double,
val postMm: Double,
/** BV 파이프라인이 산출한 단면 직경. BV 실패 시 null. */
val diameterMm: Double?,
/** BV 계산에 실제로 쓰였는가. 검출됐어도 제외될 수 있다. */
val usedInBv: Boolean,
)
data class Outcome(
/** null = 계산 실패. 이유는 [failure]. */
val volumeMl: Double?,
val walls: List<ChannelWall?>,
/** 검출된 채널 수(CH0~CH3 기준). 화면이 "4채널 중 n" 로 쓴다. */
val detectedCenter: Int,
val failure: String?,
/** 파형 마커용 (전벽, 후벽) 인덱스. 미검출 채널은 null. */
val markers: List<Pair<Int, Int>?>,
val raw: BVResult?,
) {
val ok: Boolean get() = volumeMl != null
}
/**
* @param signals 채널 순으로 정렬된 6채널 신호. 여러 cycle 을 평균한 것을 넣는다
* ([meanScan]) — 한 cycle 만으로 재면 노이즈가 그대로 벽으로 잡힌다.
*/
fun compute(
signals: List<DoubleArray>,
params: MethodDParams = MethodDParams.DEFAULT,
supine: Boolean = false,
): Outcome {
if (signals.size < PiezoHW.centerCh.size) {
return Outcome(null, emptyList(), 0, "채널 부족 (${signals.size}/6)", emptyList(), null)
}
// AnchorGuide.measure 의 cap_frac 경로와 **같은 호출**이다. CCC on.
val outcome = MethodDRunner.detectMultichannelWithEvidence(
signals, params, applyTgc = true, applyCross = true,
)
val detected = outcome.walls
val markers = detected.map { r -> r?.let { it.ant to it.post } }
val centerN = PiezoHW.centerCh.count { it < detected.size && detected[it] != null }
val spans = detected.map { r ->
r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) }
}
val bv = estimateBv(spans, missingOutside = outcome.missingOutside, supine = supine)
// 직경은 BV 가 valid 로 잡은 채널만 낸다. 순서는 validChannels 와 1:1이다.
val dByCh = HashMap<Int, Double>()
bv?.let { r ->
r.validChannels.forEachIndexed { i, ch ->
if (i < r.dMm.size) dByCh[ch] = r.dMm[i]
}
}
val dps = PiezoHW.distancePerSample
val off = PiezoHW.delayOffsetMm
val walls = detected.mapIndexed { ch, r ->
r?.let {
ChannelWall(
channel = ch,
ant = it.ant,
post = it.post,
antMm = it.antRefined * dps + off,
postMm = it.postRefined * dps + off,
diameterMm = dByCh[ch],
usedInBv = bv?.validChannels?.contains(ch) == true,
)
}
}
return Outcome(
volumeMl = bv?.volumeMl,
walls = walls,
detectedCenter = centerN,
failure = failureOf(bv, centerN, detected.size),
markers = markers,
raw = bv,
)
}
/**
* BV 가 안 나온 이유를 한 줄로.
*
* "측정 실패"로 뭉뚱그리면 간호사도 개발자도 다음 행동을 못 정한다. 검출이 안 된
* 것(부착 문제)과 검출은 됐는데 기하가 안 풀린 것(위치·자세 문제)은 대응이 다르다.
*/
private fun failureOf(bv: BVResult?, centerN: Int, chCount: Int): String? = when {
bv != null -> null
centerN == 0 -> "벽이 하나도 검출되지 않았습니다 — 부착 상태를 확인하세요."
centerN < 2 -> "검출된 채널이 ${centerN}개뿐입니다 (최소 2개 필요) — 위치를 조정하세요."
else -> "채널 ${centerN}개가 검출됐지만 용적이 계산되지 않았습니다 — " +
"벽 위치가 기하적으로 맞지 않습니다."
}
/**
* 여러 cycle 의 채널별 평균. [AnchorGuide] 의 mean-scan 과 같은 규약이다.
*
* @param cycles (cycle, 채널, 샘플)
*/
fun meanScan(cycles: List<List<DoubleArray>>): List<DoubleArray> {
if (cycles.isEmpty()) return emptyList()
val nCh = cycles[0].size
return List(nCh) { ch ->
val len = cycles.minOf { it[ch].size }
DoubleArray(len) { i ->
var s = 0.0
for (c in cycles) s += c[ch][i]
s / cycles.size
}
}
}
/** UShort 버퍼(BLE 수신 형식)를 계산용 실수 배열로. */
fun toSignals(channels: List<List<UShort>>): List<DoubleArray> =
channels.map { buf -> DoubleArray(buf.size) { buf[it].toDouble() } }
}
@@ -0,0 +1,138 @@
/*
* BvPanel — 방광용적과 채널별 전벽·후벽. 임상 화면들이 공유한다.
*/
package com.medithings.vesiscan.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.managers.ClinicalBv
import com.medithings.vesiscan.ui.theme.MlCritical
import com.medithings.vesiscan.ui.theme.MlPrimary
import com.medithings.vesiscan.ui.theme.MlSecondaryText
import com.medithings.vesiscan.ui.theme.MlSuccess
/**
* 용적 + 채널별 벽.
*
* ## 실패했을 때가 더 중요하다
* 이 화면을 보는 이유는 "용적이 왜 안 나오나"이지 정상값 확인이 아니다. 그래서 실패
* 사유를 숫자 자리에 그대로 띄우고, 채널별 표는 **실패해도 검출된 만큼** 보여준다 —
* 어느 채널이 빠졌는지가 곧 답이다.
*
* ## 표에 세 가지를 같이 둔다
* 샘플 인덱스(파형에서 보이는 것) · mm(거리로 옮긴 것) · 직경(BV 가 실제로 쓴 것).
* "파형에는 벽이 보이는데 용적이 이상하다"는 상황에서 어느 단계가 틀렸는지 갈리려면
* 셋이 나란히 있어야 한다. 직경이 비어 있으면 그 채널은 검출은 됐지만 BV 계산에서
* 빠진 것이다.
*/
@Composable
fun BvPanel(
outcome: ClinicalBv.Outcome?,
modifier: Modifier = Modifier,
/** 위에 붙일 라벨. 화면마다 다르다(정렬 위치 / Spot / 연속). */
caption: String? = null,
compact: Boolean = false,
) {
val tone = when {
outcome == null -> MlSecondaryText
outcome.ok -> MlSuccess
else -> MlCritical
}
Column(
modifier
.fillMaxWidth()
.background(Color.White, RoundedCornerShape(12.dp))
.border(1.5.dp, tone.copy(alpha = 0.45f), RoundedCornerShape(12.dp))
.padding(12.dp)
) {
caption?.let {
Text(it, fontSize = 12.sp, color = MlSecondaryText)
Spacer(Modifier.height(4.dp))
}
// ── 용적 ──
Row(verticalAlignment = Alignment.Bottom) {
Text(
outcome?.volumeMl?.let { "%.1f".format(it) } ?: "—",
fontSize = if (compact) 30.sp else 38.sp,
fontWeight = FontWeight.Bold,
color = tone,
)
Spacer(Modifier.width(4.dp))
Text("mL", fontSize = 15.sp, color = MlSecondaryText,
modifier = Modifier.padding(bottom = 5.dp))
Spacer(Modifier.weight(1f))
outcome?.let {
Text("검출 ${it.detectedCenter}/4", fontSize = 13.sp, color = MlSecondaryText,
modifier = Modifier.padding(bottom = 6.dp))
}
}
// ── 실패 사유 ──
// "측정 실패"로 뭉뚱그리면 다음 행동을 못 정한다. 검출이 안 된 것(부착 문제)과
// 검출은 됐는데 기하가 안 풀린 것(위치 문제)은 대응이 다르다.
outcome?.failure?.let {
Spacer(Modifier.height(4.dp))
Text(it, fontSize = 12.sp, color = MlCritical)
}
if (outcome == null || outcome.walls.isEmpty()) return@Column
Spacer(Modifier.height(10.dp))
// ── 채널별 벽 ──
Row(Modifier.fillMaxWidth().height(20.dp), verticalAlignment = Alignment.CenterVertically) {
HeadCell("", 38.dp); HeadCell("전벽", 62.dp); HeadCell("후벽", 62.dp)
HeadCell("두께", 66.dp); HeadCell("직경", 62.dp)
}
outcome.walls.forEachIndexed { ch, w ->
Row(
Modifier.fillMaxWidth().height(21.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("CH$ch", fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
color = if (w == null) MlSecondaryText else MlPrimary,
modifier = Modifier.width(38.dp))
if (w == null) {
Text("미검출", fontSize = 12.sp, color = MlCritical)
} else {
Cell("${w.ant}", 62.dp)
Cell("${w.post}", 62.dp)
Cell("%.1f".format(w.postMm - w.antMm), 66.dp)
// 직경이 비었으면 검출은 됐지만 BV 에서 빠진 채널이다.
Cell(w.diameterMm?.let { "%.1f".format(it) } ?: "제외", 62.dp,
color = if (w.usedInBv) MlSecondaryText else MlCritical)
}
}
}
Spacer(Modifier.height(4.dp))
Text("전벽·후벽은 샘플 번호, 두께·직경은 mm. 직경이 \"제외\"면 검출은 됐지만 용적 계산에서 빠진 채널입니다.",
fontSize = 10.sp, color = MlSecondaryText)
}
}
@Composable
private fun HeadCell(text: String, w: androidx.compose.ui.unit.Dp) {
Text(text, fontSize = 10.sp, color = MlSecondaryText, modifier = Modifier.width(w))
}
@Composable
private fun Cell(text: String, w: androidx.compose.ui.unit.Dp, color: Color = MlSecondaryText) {
Text(text, fontSize = 12.sp, color = color, modifier = Modifier.width(w))
}
@@ -39,6 +39,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.ui.components.BvPanel
import com.medithings.vesiscan.ui.components.ChannelDetachPanel
import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid
import com.medithings.vesiscan.AppScreen
@@ -51,6 +52,7 @@ import com.medithings.vesiscan.managers.AnchorConfig
import com.medithings.vesiscan.managers.AnchorGuide
import com.medithings.vesiscan.managers.AnchorPosRecord
import com.medithings.vesiscan.managers.AnchorStep
import com.medithings.vesiscan.managers.ClinicalBv
import com.medithings.vesiscan.managers.DetachWatcher
import com.medithings.vesiscan.managers.LateralGuide
import com.medithings.vesiscan.models.HospitalFixedParams
@@ -118,6 +120,12 @@ fun AnchorAlignView(appState: AppState) {
/** 위치를 한 번이라도 저장했는가 — 올릴 것이 있는지 판단. */
var hasAlignData by remember { mutableStateOf(false) }
// ── 방광 용적 ────────────────────────────────────────────────────────────
// 정렬은 지표(nch·ch3·cap_frac)로 위치를 고르는데, 그 지표가 좋다고 BV 가
// 타당한지는 별개다. 위치마다 실제 용적을 같이 보여줘야 "이 자리가 맞나"를
// 사람이 판단할 수 있다 — 알고리즘이 고른 위치의 검증이 이 화면의 목적이다.
var bv by remember { mutableStateOf<ClinicalBv.Outcome?>(null) }
// ── 채널 접촉 ────────────────────────────────────────────────────────────
// 정렬은 "어디에 붙일지"를 정하는 단계라, 떠 있는 채널이 있으면 그 위치의 지표
// (nch·ch3·cap_frac) 자체가 의미를 잃는다. 위치를 고르기 **전에** 알아야 한다.
@@ -162,6 +170,7 @@ fun AnchorAlignView(appState: AppState) {
progress = 0
// 위치를 옮겨 다시 재는 것이므로 옛 확정 상태는 무효다.
detachWatcher.reset(); detachSnapshot = null
bv = null
val inbox = kotlinx.coroutines.channels.Channel<
List<com.medithings.vesiscan.ble.PiezoChannelData>>(
@@ -232,6 +241,10 @@ fun AnchorAlignView(appState: AppState) {
error = "정렬 원시 데이터 저장 실패 (${cm}cm) — 저장 공간을 확인하세요."
}
// 판정과 **같은 mean-scan** 으로 BV 를 낸다. 따로 재면 그 위치의 지표와
// 용적이 다른 데이터에서 나온 값이 되어 대조가 성립하지 않는다.
bv = ClinicalBv.compute(ClinicalBv.meanScan(cycles))
val step = guide.step(cycles, cm)
last = step
records = guide.records
@@ -508,6 +521,14 @@ fun AnchorAlignView(appState: AppState) {
// ── 채널 파형 ───────────────────────────────────────────────────
// 측정 중에도, 끝난 뒤에도 보여준다. 정렬은 위치를 옮겨 가며 반복하는
// 작업이라 "방금 이 위치에서 뭐가 잡혔는지"를 보고 다음을 정한다.
if (bv != null) {
Text("이 위치의 방광 용적", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = MlSecondaryText)
Spacer(Modifier.height(6.dp))
BvPanel(bv, caption = "${cm}cm · 20회 평균")
Spacer(Modifier.height(16.dp))
}
if (detachSnapshot != null) {
Text("채널 접촉", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = MlSecondaryText)
@@ -543,6 +564,9 @@ fun AnchorAlignView(appState: AppState) {
Spacer(Modifier.height(6.dp))
SixChannelWaveformGrid(
channels = liveChannels,
// 전벽·후벽 마커. BV 가 안 나올 때 어느 채널의 벽이 어디로 잡혔는지가
// 원인 분석의 출발점이다.
walls = bv?.markers ?: emptyList(),
modifier = Modifier.fillMaxWidth(),
cellHeight = 140.dp,
compact = true,
@@ -0,0 +1,251 @@
/*
* BvMeasureSection — Spot / Continuous 방광용적 측정. 병원 임상 화면에 붙는다.
*/
package com.medithings.vesiscan.ui.views.clinical
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.ble.PiezoChannelData
import com.medithings.vesiscan.managers.ClinicalBv
import com.medithings.vesiscan.models.HospitalFixedParams
import com.medithings.vesiscan.models.ProbeCycle
import com.medithings.vesiscan.models.ProbeFrequency
import com.medithings.vesiscan.ui.components.BvPanel
import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid
import com.medithings.vesiscan.ui.theme.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
/**
* 한 번 재는 것(Spot)과 계속 재는 것(Continuous).
*
* ## 왜 두 모드인가
* **Spot** 은 "지금 이 자세·이 충만도에서 얼마인가"를 한 번 확인하는 것이다. 정렬이
* 맞는지, 프로토콜을 돌리기 전에 값이 말이 되는지를 본다.
*
* **Continuous** 는 값이 **흔들리는지**를 본다. 한 번 재서 나온 200mL 가 진짜인지,
* 아니면 그 순간만 그런지는 한 번으로 알 수 없다. 정렬 위치가 최적인지 판단하려면
* 반복 재현성이 필요하고, 그건 연속으로 재야 보인다.
*
* ## 평균 몇 회로 재는가
* [AVG_CYCLES] 회를 모아 mean-scan 한 뒤 한 번 계산한다. 1 cycle 로 재면 노이즈가
* 그대로 벽으로 잡혀 값이 튄다 — 정렬(20 cycle)과 같은 이유이고, 실시간성을 위해
* 그보다 적게 잡았다.
*
* ## 측정 조건은 정렬과 같게 고정한다
* 2.3MHz · cycle 3. 검출 문턱이 절대값 기준이라 조건이 바뀌면 `nch` 가 달라지고,
* 그러면 정렬이 고른 위치의 근거와 여기서 나온 BV 가 다른 조건의 값이 된다.
*/
private val BV_FREQ = ProbeFrequency.F_2_3
private val BV_CYCLE = ProbeCycle.C3
/** 한 번의 BV 산출에 모으는 cycle 수. */
private const val AVG_CYCLES = 5
/** 연속 모드에서 한 회 산출을 마친 뒤 쉬는 시간. */
private const val CONTINUOUS_GAP_MS = 500L
private const val MEASURE_TIMEOUT_MS = 3_000L
private const val CONFIG_TIMEOUT_MS = 3_000L
private const val SETTLE_MS = 600L
private const val CYCLE_GAP_MS = 300L
/** 연속 모드가 화면에 들고 있는 최근 값 개수. */
private const val HISTORY_MAX = 20
@Composable
fun BvMeasureSection(
bleManager: BleManager,
isConnected: Boolean,
modifier: Modifier = Modifier,
/** 자세가 누움이면 BV 파이프라인이 nch=2 clamp 와 F83 에서 그것을 본다. */
supine: Boolean = false,
) {
var mode by remember { mutableStateOf<Mode?>(null) } // null = 정지
var outcome by remember { mutableStateOf<ClinicalBv.Outcome?>(null) }
var live by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) }
var progress by remember { mutableIntStateOf(0) }
var error by remember { mutableStateOf<String?>(null) }
var history by remember { mutableStateOf<List<Double>>(emptyList()) }
LaunchedEffect(mode) {
val m = mode ?: return@LaunchedEffect
error = null
val inbox = kotlinx.coroutines.channels.Channel<List<PiezoChannelData>>(
kotlinx.coroutines.channels.Channel.CONFLATED)
// 이 구간이 프로브 설정을 바꾸므로 끝나면 되돌린다 — 정렬·병원 모드와 같은 이유.
val guard = ProbeConfigGuard(bleManager)
guard.capture()
val prev = bleManager.piezoCollector.onMultiChannelComplete
bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) }
try {
bleManager.piezoConfigEcho.value = null
bleManager.sendPiezoConfig(
freq = BV_FREQ.freqOption, cycles = BV_CYCLE.cycles,
avg = HospitalFixedParams.AVG, delayUs = HospitalFixedParams.DELAY_US,
samples = HospitalFixedParams.SAMPLES,
)
val echo = withTimeoutOrNull(CONFIG_TIMEOUT_MS) {
while (bleManager.piezoConfigEcho.value == null) delay(20)
bleManager.piezoConfigEcho.value
}
if (echo?.matches(
BV_FREQ.freqOption, BV_CYCLE.cycles, HospitalFixedParams.AVG,
HospitalFixedParams.DELAY_US, HospitalFixedParams.SAMPLES,
) != true
) {
error = "프로브 설정 실패 — ${echo?.error ?: "응답 없음"}"
mode = null
return@LaunchedEffect
}
delay(SETTLE_MS)
do {
val cycles = ArrayList<List<DoubleArray>>(AVG_CYCLES)
progress = 0
var aborted = false
for (i in 1..AVG_CYCLES) {
if (mode == null) { aborted = true; break }
while (inbox.tryReceive().isSuccess) { /* 늦게 온 것 비우기 */ }
bleManager.sendMtb()
val got = withTimeoutOrNull(MEASURE_TIMEOUT_MS) { inbox.receive() }
if (got == null) {
// 한 회 놓쳤다고 멈추지 않는다 — 연속 모드는 계속 돌아야 한다.
error = "응답 시간 초과 — 다시 시도합니다."
aborted = true
break
}
val sorted = got.sortedBy { it.channel }
live = sorted
cycles.add(ClinicalBv.toSignals(sorted.map { it.buffer }))
progress = i
delay(CYCLE_GAP_MS)
}
if (!aborted && cycles.size == AVG_CYCLES) {
error = null
val r = ClinicalBv.compute(ClinicalBv.meanScan(cycles), supine = supine)
outcome = r
r.volumeMl?.let { v ->
history = (history + v).takeLast(HISTORY_MAX)
}
}
if (m == Mode.SPOT) break
delay(CONTINUOUS_GAP_MS)
} while (mode == Mode.CONTINUOUS)
} finally {
bleManager.piezoCollector.onMultiChannelComplete = prev
kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { guard.restore() }
inbox.close()
if (mode == Mode.SPOT) mode = null
}
}
val running = mode != null
Column(modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("방광 용적 (BV)", fontSize = 14.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.weight(1f))
Text("${BV_FREQ.label}MHz · cycle ${BV_CYCLE.cycles} · ${AVG_CYCLES}회 평균",
fontSize = 11.sp, color = MlSecondaryText)
}
Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth()) {
Button(
onClick = { if (mode == Mode.SPOT) mode = null else mode = Mode.SPOT },
enabled = isConnected && mode != Mode.CONTINUOUS,
modifier = Modifier.weight(1f).height(46.dp),
shape = RoundedCornerShape(12.dp),
) { Text(if (mode == Mode.SPOT) "측정 중…" else "1회 측정", fontWeight = FontWeight.Bold) }
Spacer(Modifier.width(8.dp))
Button(
onClick = { mode = if (mode == Mode.CONTINUOUS) null else Mode.CONTINUOUS },
enabled = isConnected && mode != Mode.SPOT,
modifier = Modifier.weight(1f).height(46.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (mode == Mode.CONTINUOUS) MlCritical else MlSuccess),
) {
Text(if (mode == Mode.CONTINUOUS) "연속 중지" else "연속 측정",
color = Color.White, fontWeight = FontWeight.Bold)
}
}
if (running) {
Spacer(Modifier.height(6.dp))
LinearProgressIndicator(
progress = { progress / AVG_CYCLES.toFloat() },
modifier = Modifier.fillMaxWidth(),
)
}
error?.let {
Spacer(Modifier.height(6.dp))
Text(it, fontSize = 12.sp, color = MlCritical)
}
Spacer(Modifier.height(10.dp))
BvPanel(
outcome = outcome,
caption = when (mode) {
Mode.CONTINUOUS -> "연속 측정 중"
Mode.SPOT -> "측정 중"
null -> if (outcome == null) "측정을 시작하면 값이 나옵니다." else "마지막 측정"
},
)
// ── 연속 측정 재현성 ──
// 정렬 위치가 최적인지 판단하려면 값 하나가 아니라 **흔들림**을 봐야 한다.
// 평균만 보이면 "200mL 나왔다"로 끝나는데, 편차가 30mL 면 그 위치는 못 쓴다.
if (history.size >= 2) {
val mean = history.average()
val sd = kotlin.math.sqrt(history.sumOf { (it - mean) * (it - mean) } / history.size)
Spacer(Modifier.height(8.dp))
Box(
Modifier.fillMaxWidth()
.background(MlPrimary.copy(alpha = 0.07f), RoundedCornerShape(10.dp))
.padding(10.dp)
) {
Column {
Text(
"최근 ${history.size}회 — 평균 %.1f mL · 표준편차 %.1f mL (CV %.1f%%)"
.format(mean, sd, if (mean > 0) sd / mean * 100 else 0.0),
fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(2.dp))
Text(
"범위 %.1f ~ %.1f mL".format(history.min(), history.max()),
fontSize = 11.sp, color = MlSecondaryText,
)
}
}
}
// ── 파형 + 벽 마커 ──
// BV 가 안 나올 때 원인을 여기서 본다. 초록·주황 세로선이 전벽·후벽이다.
if (live.isNotEmpty()) {
Spacer(Modifier.height(12.dp))
Text("채널 파형 · 전벽(초록) 후벽(주황)", fontSize = 12.sp, color = MlSecondaryText)
Spacer(Modifier.height(6.dp))
SixChannelWaveformGrid(
channels = live,
walls = outcome?.markers ?: emptyList(),
modifier = Modifier.fillMaxWidth(),
cellHeight = 150.dp,
compact = true,
)
}
}
}
private enum class Mode { SPOT, CONTINUOUS }
@@ -37,6 +37,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.managers.ClinicalBv
import com.medithings.vesiscan.ui.components.ChannelDetachPanel
import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid
import com.medithings.vesiscan.AppScreen
@@ -127,6 +128,11 @@ fun HospitalModeView(appState: AppState) {
var uploadBusy by remember { mutableStateOf(false) }
var uploadMsg by remember { mutableStateOf<String?>(null) }
// 측정 중 마지막 회차의 벽·용적. 프로토콜 루프는 저장이 목적이라 BV 를 쓰지
// 않지만, 값이 말이 되는지는 그 자리에서 보여야 한다 — 20회를 다 돌린 뒤에
// 알면 그 단계는 다시 못 잰다.
var runBv by remember { mutableStateOf<ClinicalBv.Outcome?>(null) }
val detachWatcher = remember { com.medithings.vesiscan.managers.DetachWatcher() }
var detachSnapshot by remember {
mutableStateOf<com.medithings.vesiscan.managers.DetachWatcher.Snapshot?>(null)
@@ -244,6 +250,12 @@ fun HospitalModeView(appState: AppState) {
// 접촉 판정도 같은 데이터로 낸다 — 화면 파형과 판정이 어긋나면
// 간호사가 어느 쪽을 믿어야 할지 알 수 없다.
detachWatcher.push(raw)?.let { detachSnapshot = it }
// 한 회차 신호로 바로 낸다. 여기서 여러 회를 모아 평균하면
// 저장 루프의 주기가 흔들려 회차 간격이 달라진다.
runBv = ClinicalBv.compute(
ClinicalBv.toSignals(raw),
supine = posture == ClinicalPosture.SUPINE,
)
val saved = HospitalRunStore.appendMeasurement(
file = file,
scanId = AdcCsvLogger.nextScanId(),
@@ -487,6 +499,12 @@ fun HospitalModeView(appState: AppState) {
//
// 셀을 작게 잡는다(140dp). 이 화면의 주인공은 진행 상황과 중지 버튼이고
// 파형은 곁눈질로 보는 것이라, 크게 잡으면 중지 버튼이 화면 밖으로 밀린다.
Spacer(Modifier.height(12.dp))
Label("방광 용적")
Spacer(Modifier.height(6.dp))
com.medithings.vesiscan.ui.components.BvPanel(
runBv, caption = "직전 회차", compact = true)
Spacer(Modifier.height(12.dp))
Label("채널 접촉")
Spacer(Modifier.height(6.dp))
@@ -497,6 +515,7 @@ fun HospitalModeView(appState: AppState) {
Spacer(Modifier.height(6.dp))
SixChannelWaveformGrid(
channels = liveChannels,
walls = runBv?.markers ?: emptyList(),
modifier = Modifier.fillMaxWidth(),
cellHeight = 140.dp,
compact = true,
@@ -506,12 +525,23 @@ fun HospitalModeView(appState: AppState) {
}
Spacer(Modifier.height(16.dp))
} else {
// ── BV 측정 (Spot / Continuous) ─────────────────────────────
// 프로토콜을 돌리기 **전에** 값이 말이 되는지 확인하는 자리다.
// 정렬이 고른 위치가 실제로 최적인지는 그 위치의 BV 로만 판단할 수 있다.
BvMeasureSection(
bleManager = bleManager,
isConnected = isConnected,
supine = posture == ClinicalPosture.SUPINE,
)
Spacer(Modifier.height(16.dp))
Button(
onClick = {
comboIdx = 0; repeatIdx = 0
// 지난 실행의 확정 상태가 남으면 새 부착 위치의 첫 판정이
// 거기에 끌려간다 — 프로브를 다시 붙였을 수 있다.
detachWatcher.reset(); detachSnapshot = null
runBv = null
running = true
},
enabled = canStart,
@@ -0,0 +1,124 @@
package com.medithings.vesiscan.managers
import com.medithings.vesiscan.walldetect.MethodDRunner
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDParams
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
/**
* 임상 화면의 BV 진입점.
*
* ## 무엇을 지키는가
* [ClinicalBv] 는 새 알고리즘이 아니라 **기존 제품 경로를 한 군데로 묶은 것**이다.
* 그래서 검증할 것은 값 자체가 아니라 **AnchorGuide 가 `cap_frac` 을 낼 때 쓰는 것과
* 같은 경로인가**이다. 둘이 갈리면 정렬이 고른 위치의 근거와 화면에 뜨는 용적이
* 다른 계산에서 나온 값이 되어, 이 기능의 목적(정렬 위치 검증)이 무너진다.
*
* 갈릴 수 있는 지점이 셋이다 — CCC on/off, `missingOutside` 전달 여부,
* `ant` 와 `antRefined` 중 무엇을 BV 에 넘기는가. 셋 다 고정한다.
*/
class ClinicalBvTest {
/**
* 정렬 검증용 실측 trace (`cycle,ch,samples…`). 파일이 없으면 **실패시킨다** —
* 조용히 건너뛰면 이 클래스에서 제일 중요한 parity 검사가 통과한 것처럼 보인다.
*/
private fun realScan(): List<DoubleArray> {
val f = File("src/test/resources/align_cm0.csv")
assertTrue("실측 자원이 없다: ${f.absolutePath}", f.exists())
// 첫 cycle 의 6채널만 꺼낸다 — parity 만 보므로 한 scan 이면 충분하다.
val byCh = linkedMapOf<Int, DoubleArray>()
f.forEachLine { line ->
val p = line.trim().split(',')
if (p.size < 3) return@forEachLine
val ci = p[0].toIntOrNull() ?: return@forEachLine
val ch = p[1].toIntOrNull() ?: return@forEachLine
if (ci != 0 || ch in byCh) return@forEachLine
byCh[ch] = DoubleArray(p.size - 2) { p[it + 2].toDoubleOrNull() ?: 0.0 }
}
assertEquals("6채널이 다 읽혀야 한다", 6, byCh.size)
return (0..5).map { byCh.getValue(it) }
}
private fun flat(n: Int = 120) = List(6) { DoubleArray(n) { 1000.0 } }
@Test fun `AnchorGuide 의 cap_frac 과 같은 경로로 계산한다`() {
val scan = realScan()
// AnchorGuide.measure 가 하는 것과 **같은 호출**을 손으로 재현.
val outcome = MethodDRunner.detectMultichannelWithEvidence(
scan, MethodDParams.DEFAULT, applyTgc = true, applyCross = true)
val spans = outcome.walls.map { r ->
r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) }
}
val expected = estimateBv(spans, missingOutside = outcome.missingOutside)
val got = ClinicalBv.compute(scan)
if (expected == null) {
assertNull("레퍼런스 경로가 null 인데 ClinicalBv 가 값을 냈다", got.volumeMl)
} else {
assertNotNull("레퍼런스 경로는 값을 냈는데 ClinicalBv 가 null", got.volumeMl)
assertEquals(expected.volumeMl, got.volumeMl!!, 1e-9)
}
}
@Test fun `마커는 검출 인덱스를 그대로 준다`() {
// 파형 위 세로선이 실제 검출 위치와 어긋나면, 눈으로 보는 것과 계산이 달라져
// 원인 분석이 성립하지 않는다.
val scan = realScan()
val outcome = MethodDRunner.detectMultichannelWithEvidence(
scan, MethodDParams.DEFAULT, applyTgc = true, applyCross = true)
val got = ClinicalBv.compute(scan)
assertEquals(outcome.walls.size, got.markers.size)
outcome.walls.forEachIndexed { i, r ->
if (r == null) assertNull(got.markers[i])
else assertEquals(r.ant to r.post, got.markers[i])
}
}
@Test fun `검출이 없으면 이유를 돌려준다`() {
// "측정 실패"로 뭉뚱그리면 부착 문제인지 위치 문제인지 구분이 안 된다.
val r = ClinicalBv.compute(flat())
assertNull(r.volumeMl)
assertEquals(0, r.detectedCenter)
assertNotNull(r.failure)
assertTrue("부착을 가리키는 문구여야 한다", r.failure!!.contains("부착"))
}
@Test fun `채널이 모자라면 계산하지 않는다`() {
val r = ClinicalBv.compute(List(3) { DoubleArray(120) { 1000.0 } })
assertNull(r.volumeMl)
assertTrue(r.failure!!.contains("채널 부족"))
assertTrue(r.markers.isEmpty())
}
@Test fun `mean-scan 은 채널별 평균이다`() {
val a = List(6) { ch -> DoubleArray(10) { 100.0 * ch } }
val b = List(6) { ch -> DoubleArray(10) { 200.0 * ch } }
val m = ClinicalBv.meanScan(listOf(a, b))
assertEquals(6, m.size)
for (ch in 0..5) assertEquals(150.0 * ch, m[ch][0], 1e-9)
}
@Test fun `길이가 다른 cycle 이 섞이면 짧은 쪽에 맞춘다`() {
// BLE 가 끊겨 짧게 온 회차가 실제로 생긴다. 긴 쪽에 맞추면 없는 샘플을
// 0 으로 읽어 벽이 엉뚱한 데 잡힌다.
val a = List(6) { DoubleArray(100) { 1000.0 } }
val b = List(6) { DoubleArray(40) { 1000.0 } }
val m = ClinicalBv.meanScan(listOf(a, b))
assertEquals(40, m[0].size)
}
@Test fun `UShort 버퍼를 그대로 변환한다`() {
val sig = ClinicalBv.toSignals(listOf(listOf(1.toUShort(), 4095.toUShort())))
assertEquals(1, sig.size)
assertEquals(1.0, sig[0][0], 0.0)
assertEquals(4095.0, sig[0][1], 0.0)
}
}