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(실측 자원 없음 — 비교 생략)") + } +}