feat(clinical): BV 에 알고리즘 경로를 붙이고 레퍼런스를 기본으로
검증 목적이 "최종 보고된 알고리즘"인데 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Pair<Int, Int>?>,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<String?>(null) }
|
||||
var history by remember { mutableStateOf<List<Double>>(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()) {
|
||||
|
||||
@@ -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<DoubleArray>? {
|
||||
val f = File(path)
|
||||
if (!f.exists()) return null
|
||||
// cycle,ch,samples… — 전 cycle 을 채널별 평균(mean-scan)해 정렬과 같은 입력으로.
|
||||
val byCycle = linkedMapOf<Int, MutableMap<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
|
||||
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<DoubleArray>) {
|
||||
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(실측 자원 없음 — 비교 생략)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user