From e9f560137192eafcbcd9d2674a9e673c78c3b431 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Wed, 9 Sep 2026 09:36:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(clinical):=20=EC=9E=84=EC=83=81=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20BV=20=C2=B7=20=EC=B1=84=EB=84=90?= =?UTF-8?q?=EB=B3=84=20=EC=A0=84=ED=9B=84=EB=B2=BD=20=C2=B7=20Spot/Continu?= =?UTF-8?q?ous=20=EC=B8=A1=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 알고리즘은 이미 이식돼 있었다(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 --- .../com/medithings/vesiscan/ble/BleManager.kt | 40 ++- .../vesiscan/dev/BatteryDrainTester.kt | 30 +++ .../vesiscan/managers/ClinicalBv.kt | 158 +++++++++++ .../vesiscan/ui/components/BvPanel.kt | 138 ++++++++++ .../ui/views/clinical/AnchorAlignView.kt | 24 ++ .../ui/views/clinical/BvMeasureSection.kt | 251 ++++++++++++++++++ .../ui/views/clinical/HospitalModeView.kt | 30 +++ .../vesiscan/managers/ClinicalBvTest.kt | 124 +++++++++ 8 files changed, 791 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/medithings/vesiscan/managers/ClinicalBv.kt create mode 100644 app/src/main/java/com/medithings/vesiscan/ui/components/BvPanel.kt create mode 100644 app/src/main/java/com/medithings/vesiscan/ui/views/clinical/BvMeasureSection.kt create mode 100644 app/src/test/java/com/medithings/vesiscan/managers/ClinicalBvTest.kt diff --git a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt index 677e4f8..582b571 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -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 diff --git a/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTester.kt b/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTester.kt index a234228..7925880 100644 --- a/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTester.kt +++ b/app/src/main/java/com/medithings/vesiscan/dev/BatteryDrainTester.kt @@ -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 = diff --git a/app/src/main/java/com/medithings/vesiscan/managers/ClinicalBv.kt b/app/src/main/java/com/medithings/vesiscan/managers/ClinicalBv.kt new file mode 100644 index 0000000..fc621ad --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/managers/ClinicalBv.kt @@ -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, + /** 검출된 채널 수(CH0~CH3 기준). 화면이 "4채널 중 n" 로 쓴다. */ + val detectedCenter: Int, + val failure: String?, + /** 파형 마커용 (전벽, 후벽) 인덱스. 미검출 채널은 null. */ + val markers: List?>, + val raw: BVResult?, + ) { + val ok: Boolean get() = volumeMl != null + } + + /** + * @param signals 채널 순으로 정렬된 6채널 신호. 여러 cycle 을 평균한 것을 넣는다 + * ([meanScan]) — 한 cycle 만으로 재면 노이즈가 그대로 벽으로 잡힌다. + */ + fun compute( + signals: List, + 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() + 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 { + 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 = + channels.map { buf -> DoubleArray(buf.size) { buf[it].toDouble() } } +} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/components/BvPanel.kt b/app/src/main/java/com/medithings/vesiscan/ui/components/BvPanel.kt new file mode 100644 index 0000000..543c249 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/ui/components/BvPanel.kt @@ -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)) +} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt index 77c7cfa..70518f8 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt @@ -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(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>( @@ -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, diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/BvMeasureSection.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/BvMeasureSection.kt new file mode 100644 index 0000000..791120e --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/BvMeasureSection.kt @@ -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(null) } // null = 정지 + var outcome by remember { mutableStateOf(null) } + var live by remember { mutableStateOf>(emptyList()) } + var progress by remember { mutableIntStateOf(0) } + var error by remember { mutableStateOf(null) } + var history by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(mode) { + val m = mode ?: return@LaunchedEffect + error = null + val inbox = kotlinx.coroutines.channels.Channel>( + 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>(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 } diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt index a000bdc..4529288 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt @@ -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(null) } + // 측정 중 마지막 회차의 벽·용적. 프로토콜 루프는 저장이 목적이라 BV 를 쓰지 + // 않지만, 값이 말이 되는지는 그 자리에서 보여야 한다 — 20회를 다 돌린 뒤에 + // 알면 그 단계는 다시 못 잰다. + var runBv by remember { mutableStateOf(null) } + val detachWatcher = remember { com.medithings.vesiscan.managers.DetachWatcher() } var detachSnapshot by remember { mutableStateOf(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, diff --git a/app/src/test/java/com/medithings/vesiscan/managers/ClinicalBvTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/ClinicalBvTest.kt new file mode 100644 index 0000000..d6f53d7 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/ClinicalBvTest.kt @@ -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 { + val f = File("src/test/resources/align_cm0.csv") + assertTrue("실측 자원이 없다: ${f.absolutePath}", f.exists()) + // 첫 cycle 의 6채널만 꺼낸다 — parity 만 보므로 한 scan 이면 충분하다. + val byCh = linkedMapOf() + 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) + } +}