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" 로 쓴다. */
|
/** 검출된 채널 수(CH0~CH3 기준). 화면이 "4채널 중 n" 로 쓴다. */
|
||||||
val detectedCenter: Int,
|
val detectedCenter: Int,
|
||||||
val failure: String?,
|
val failure: String?,
|
||||||
|
/**
|
||||||
|
* 이 값을 낸 알고리즘 경로. **숫자와 함께 반드시 표시한다.**
|
||||||
|
*
|
||||||
|
* 두 경로가 같은 입력에 다른 값을 낸다(실측 align_cm1: 기존 125.52 mL vs
|
||||||
|
* 레퍼런스 122.25 mL). 어느 쪽에서 나온 값인지 모르면 "정렬 위치의 BV 가
|
||||||
|
* 타당한가"를 판단할 수 없다 — 검증 자체가 성립하지 않는다.
|
||||||
|
*/
|
||||||
|
val algoLabel: String,
|
||||||
/** 파형 마커용 (전벽, 후벽) 인덱스. 미검출 채널은 null. */
|
/** 파형 마커용 (전벽, 후벽) 인덱스. 미검출 채널은 null. */
|
||||||
val markers: List<Pair<Int, Int>?>,
|
val markers: List<Pair<Int, Int>?>,
|
||||||
val raw: BVResult?,
|
val raw: BVResult?,
|
||||||
@@ -69,8 +77,9 @@ object ClinicalBv {
|
|||||||
params: MethodDParams = MethodDParams.DEFAULT,
|
params: MethodDParams = MethodDParams.DEFAULT,
|
||||||
supine: Boolean = false,
|
supine: Boolean = false,
|
||||||
): Outcome {
|
): Outcome {
|
||||||
|
val algo = AlgoMode.label
|
||||||
if (signals.size < PiezoHW.centerCh.size) {
|
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.
|
// AnchorGuide.measure 의 cap_frac 경로와 **같은 호출**이다. CCC on.
|
||||||
@@ -115,6 +124,7 @@ object ClinicalBv {
|
|||||||
walls = walls,
|
walls = walls,
|
||||||
detectedCenter = centerN,
|
detectedCenter = centerN,
|
||||||
failure = failureOf(bv, centerN, detected.size),
|
failure = failureOf(bv, centerN, detected.size),
|
||||||
|
algoLabel = algo,
|
||||||
markers = markers,
|
markers = markers,
|
||||||
raw = bv,
|
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 {
|
outcome?.failure?.let {
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(it, fontSize = 12.sp, color = MlCritical)
|
Text(it, fontSize = 12.sp, color = MlCritical)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.medithings.vesiscan.ble.BleManager
|
import com.medithings.vesiscan.ble.BleManager
|
||||||
import com.medithings.vesiscan.ble.PiezoChannelData
|
import com.medithings.vesiscan.ble.PiezoChannelData
|
||||||
|
import com.medithings.vesiscan.managers.AlgoMode
|
||||||
import com.medithings.vesiscan.managers.ClinicalBv
|
import com.medithings.vesiscan.managers.ClinicalBv
|
||||||
import com.medithings.vesiscan.models.HospitalFixedParams
|
import com.medithings.vesiscan.models.HospitalFixedParams
|
||||||
import com.medithings.vesiscan.models.ProbeCycle
|
import com.medithings.vesiscan.models.ProbeCycle
|
||||||
@@ -78,6 +79,14 @@ fun BvMeasureSection(
|
|||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var history by remember { mutableStateOf<List<Double>>(emptyList()) }
|
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) {
|
LaunchedEffect(mode) {
|
||||||
val m = mode ?: return@LaunchedEffect
|
val m = mode ?: return@LaunchedEffect
|
||||||
error = null
|
error = null
|
||||||
@@ -159,6 +168,26 @@ fun BvMeasureSection(
|
|||||||
Text("${BV_FREQ.label}MHz · cycle ${BV_CYCLE.cycles} · ${AVG_CYCLES}회 평균",
|
Text("${BV_FREQ.label}MHz · cycle ${BV_CYCLE.cycles} · ${AVG_CYCLES}회 평균",
|
||||||
fontSize = 11.sp, color = MlSecondaryText)
|
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))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
Row(Modifier.fillMaxWidth()) {
|
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