From 21b0fcaed808da661899379f90ba17f81d3d8d33 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Wed, 9 Sep 2026 09:44:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(clinical):=20BV=20=EC=97=90=20=EC=95=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=EC=A6=98=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=B6=99=EC=9D=B4=EA=B3=A0=20=EB=A0=88=ED=8D=BC=EB=9F=B0?= =?UTF-8?q?=EC=8A=A4=EB=A5=BC=20=EA=B8=B0=EB=B3=B8=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검증 목적이 "최종 보고된 알고리즘"인데 AlgoMode.reference 기본값이 false 라, 지금까지 임상 화면이 낸 BV 는 **기존 경로 값**이었다. 두 경로는 같은 신호에 다른 값을 낸다 — 실측 대조(AlgoPathReportTest): align_cm0 기존 280.46 mL 레퍼런스 280.46 mL (동일) align_cm1 기존 125.52 mL 레퍼런스 122.25 mL (3.27 mL · 2.6%) 그리고 최근 이식한 세 기능(post_tie_lock · _sibeam_cap_bounds · F83)은 reference 일 때만 동작한다. 스위치가 꺼진 채로는 "최종 알고리즘을 탑재했다"가 성립하지 않는다. · ClinicalBv.Outcome 에 algoLabel 추가. BvPanel 이 **모든 값 옆에** 경로를 표시한다. 검증 기록에 남은 숫자를 나중에 해석할 수 있어야 한다 — 어느 경로인지 모르는 BV 는 "정렬 위치가 타당한가"의 근거가 될 수 없다. · BV 측정 화면에 경로 스위치. 이 화면에서는 레퍼런스를 기본으로 켠다. AlgoMode 가 전역이라 일반 측정 화면에도 영향을 주므로 켠 사실을 드러내고 되돌릴 수 있게 뒀다. · AlgoPathReportTest — 두 경로의 값을 실측 데이터로 출력한다. 단정문이 없는 리포트용 시험이라 보고서에 숫자를 그대로 옮길 수 있다. Co-Authored-By: Claude Opus 5 --- .../vesiscan/managers/ClinicalBv.kt | 12 +++- .../vesiscan/ui/components/BvPanel.kt | 7 +++ .../ui/views/clinical/BvMeasureSection.kt | 29 +++++++++ .../vesiscan/managers/AlgoPathReportTest.kt | 62 +++++++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/medithings/vesiscan/managers/AlgoPathReportTest.kt diff --git a/app/src/main/java/com/medithings/vesiscan/managers/ClinicalBv.kt b/app/src/main/java/com/medithings/vesiscan/managers/ClinicalBv.kt index fc621ad..06bd557 100644 --- a/app/src/main/java/com/medithings/vesiscan/managers/ClinicalBv.kt +++ b/app/src/main/java/com/medithings/vesiscan/managers/ClinicalBv.kt @@ -53,6 +53,14 @@ object ClinicalBv { /** 검출된 채널 수(CH0~CH3 기준). 화면이 "4채널 중 n" 로 쓴다. */ val detectedCenter: Int, val failure: String?, + /** + * 이 값을 낸 알고리즘 경로. **숫자와 함께 반드시 표시한다.** + * + * 두 경로가 같은 입력에 다른 값을 낸다(실측 align_cm1: 기존 125.52 mL vs + * 레퍼런스 122.25 mL). 어느 쪽에서 나온 값인지 모르면 "정렬 위치의 BV 가 + * 타당한가"를 판단할 수 없다 — 검증 자체가 성립하지 않는다. + */ + val algoLabel: String, /** 파형 마커용 (전벽, 후벽) 인덱스. 미검출 채널은 null. */ val markers: List?>, val raw: BVResult?, @@ -69,8 +77,9 @@ object ClinicalBv { params: MethodDParams = MethodDParams.DEFAULT, supine: Boolean = false, ): Outcome { + val algo = AlgoMode.label if (signals.size < PiezoHW.centerCh.size) { - return Outcome(null, emptyList(), 0, "채널 부족 (${signals.size}/6)", emptyList(), null) + return Outcome(null, emptyList(), 0, "채널 부족 (${signals.size}/6)", algo, emptyList(), null) } // AnchorGuide.measure 의 cap_frac 경로와 **같은 호출**이다. CCC on. @@ -115,6 +124,7 @@ object ClinicalBv { walls = walls, detectedCenter = centerN, failure = failureOf(bv, centerN, detected.size), + algoLabel = algo, markers = markers, raw = bv, ) 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 index 543c249..b84c7b5 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/components/BvPanel.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/components/BvPanel.kt @@ -87,6 +87,13 @@ fun BvPanel( // ── 실패 사유 ── // "측정 실패"로 뭉뚱그리면 다음 행동을 못 정한다. 검출이 안 된 것(부착 문제)과 // 검출은 됐는데 기하가 안 풀린 것(위치 문제)은 대응이 다르다. + // 어느 경로에서 나온 값인지 **항상** 붙인다. 두 경로가 다른 값을 내므로 + // 이게 없으면 검증 기록에 남은 숫자를 나중에 해석할 수 없다. + outcome?.let { + Spacer(Modifier.height(2.dp)) + Text("알고리즘: ${it.algoLabel}", fontSize = 11.sp, color = MlSecondaryText) + } + outcome?.failure?.let { Spacer(Modifier.height(4.dp)) Text(it, fontSize = 12.sp, color = MlCritical) 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 index 791120e..4cab2cb 100644 --- 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 @@ -16,6 +16,7 @@ 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.AlgoMode import com.medithings.vesiscan.managers.ClinicalBv import com.medithings.vesiscan.models.HospitalFixedParams import com.medithings.vesiscan.models.ProbeCycle @@ -78,6 +79,14 @@ fun BvMeasureSection( var error by remember { mutableStateOf(null) } var history by remember { mutableStateOf>(emptyList()) } + // 검증 목적이 **최종 보고된 알고리즘**이라 이 화면에서는 레퍼런스 경로를 기본으로 + // 켠다. AlgoMode 는 전역이라 일반 측정 화면에도 영향을 주므로, 켠 사실을 화면에 + // 드러내고 되돌릴 수 있게 둔다. + var useReference by remember { mutableStateOf(AlgoMode.reference) } + LaunchedEffect(Unit) { + if (!AlgoMode.reference) { AlgoMode.reference = true; useReference = true } + } + LaunchedEffect(mode) { val m = mode ?: return@LaunchedEffect error = null @@ -159,6 +168,26 @@ fun BvMeasureSection( Text("${BV_FREQ.label}MHz · cycle ${BV_CYCLE.cycles} · ${AVG_CYCLES}회 평균", fontSize = 11.sp, color = MlSecondaryText) } + // 경로 전환. 값이 달라지므로(실측 align_cm1: 125.52 vs 122.25 mL) 기록에는 + // 반드시 어느 쪽인지 같이 남겨야 한다 — BvPanel 이 매 값에 붙인다. + Row(verticalAlignment = Alignment.CenterVertically) { + Switch( + checked = useReference, + onCheckedChange = { useReference = it; AlgoMode.reference = it }, + enabled = !running, + ) + Spacer(Modifier.width(8.dp)) + Column { + Text( + if (useReference) "레퍼런스 알고리즘 (최종 보고본)" else "기존 알고리즘 (현행 임상)", + fontSize = 13.sp, fontWeight = FontWeight.SemiBold, + ) + Text( + "끄면 기존 경로로 계산합니다. 두 경로는 같은 신호에 다른 값을 낼 수 있습니다.", + fontSize = 10.sp, color = MlSecondaryText, + ) + } + } Spacer(Modifier.height(8.dp)) Row(Modifier.fillMaxWidth()) { diff --git a/app/src/test/java/com/medithings/vesiscan/managers/AlgoPathReportTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/AlgoPathReportTest.kt new file mode 100644 index 0000000..6ab5e93 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/AlgoPathReportTest.kt @@ -0,0 +1,62 @@ +package com.medithings.vesiscan.managers + +import org.junit.After +import org.junit.Test +import java.io.File + +/** + * 두 알고리즘 경로가 같은 입력에 어떤 값을 내는지 **숫자로 남긴다**. + * + * 검증 목적이 "정렬이 고른 위치의 BV 가 타당한가"인데, 그 BV 가 어느 경로에서 나온 + * 값인지 모르면 판단이 성립하지 않는다. [AlgoMode.reference] 는 기본이 **false**라 + * 앱은 기존 경로로 돈다 — 이 시험은 그 차이를 실측 데이터로 찍어 보고서에 쓴다. + * + * 통과·실패를 가리는 시험이 아니다(단정문이 없다). 값을 출력하는 것이 목적이라 + * 리포트에 그대로 옮길 수 있다. + */ +class AlgoPathReportTest { + + @After fun restore() { AlgoMode.reference = false } + + private fun scan(path: String): List? { + val f = File(path) + if (!f.exists()) return null + // cycle,ch,samples… — 전 cycle 을 채널별 평균(mean-scan)해 정렬과 같은 입력으로. + val byCycle = 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 + byCycle.getOrPut(ci) { linkedMapOf() }[ch] = + DoubleArray(p.size - 2) { p[it + 2].toDoubleOrNull() ?: 0.0 } + } + val cycles = byCycle.values.filter { it.size >= 6 }.map { m -> (0..5).map { m.getValue(it) } } + return if (cycles.isEmpty()) null else ClinicalBv.meanScan(cycles) + } + + private fun report(label: String, sig: List) { + val out = StringBuilder("\n=== $label ===\n") + for (ref in listOf(false, true)) { + AlgoMode.reference = ref + val r = ClinicalBv.compute(sig) + out.append(" %-22s BV=%s 검출=%d/4 %s\n".format( + if (ref) "레퍼런스(Python 1:1)" else "기존(현행 임상)", + r.volumeMl?.let { "%.2f mL".format(it) } ?: "계산불가", + r.detectedCenter, + r.walls.filterNotNull().joinToString(" ") { "CH${it.channel}(${it.ant}~${it.post})" }, + )) + } + println(out) + } + + @Test fun `두 경로의 BV 를 실측 데이터로 비교`() { + var any = false + for (name in listOf("align_cm0.csv", "align_cm1.csv")) { + val sig = scan("src/test/resources/$name") ?: continue + any = true + report(name, sig) + } + if (!any) println("\n(실측 자원 없음 — 비교 생략)") + } +}