feat(clinical): 병원 임상만 최종 알고리즘으로 · 전역은 건드리지 않는다

병원 임상 화면은 최종 보고본(레퍼런스)이 기본이어야 하는데, AlgoMode.reference 는
프로세스 전역이라 그냥 켜면 **일반 측정 화면의 BV 까지** 바뀐다. 그쪽은 현행 임상에서
쓰이고 있어 임의로 건드리면 안 된다.

AlgoMode.withReference(ref) { } 범위 실행을 두고 ClinicalBv 가 그 안에서만 돈다.
계산이 끝나면 전역값은 원래대로다 — 임상 화면을 다녀왔다는 이유로 다른 화면의 값이
달라지지 않는다. 되돌리는지도 테스트로 고정했다. 임상 측정은 코루틴에서 돌아 두 계산이
겹칠 수 있으므로 동기화한다: 겹치면 한쪽의 복원이 다른 쪽의 설정을 지워 엉뚱한 경로로
계산된 값이 나오는데, 그 한 건이 검증 기록에 섞이면 나중에 찾아낼 방법이 없다.

ClinicalBv.compute(reference = true) 가 기본. BV 측정 화면의 스위치는 이제 전역을
쓰지 않고 그 화면의 계산에만 적용된다(두 경로 비교용).

**앞 커밋(21b0fca)의 숫자를 정정한다.** "align_cm1 기존 125.52 vs 레퍼런스 122.25 mL"
는 잘못된 측정이었다 — PiezoHW.activePreset 을 안 잡고 돌려 dps·delay 가 다른 프리셋
값으로 계산됐다(실측 자원은 VBT26050202=V1). 프리셋을 고정하면 이 데이터에서는 두
경로가 **같은 값**을 낸다(cm0 410.65 / cm1 490.63, 양쪽 동일).

두 경로가 같다는 뜻은 아니다. AlgoModeSwitchTest 실측으로 **cycle 44개 중 38개**의
검출·BV 가 갈린다. 여러 cycle 을 평균한 mean-scan 에서 차이가 묻힌 것이다. 즉 입력에
따라 같기도 다르기도 하므로, 값에 경로 표시를 붙이는 이유는 그대로 유효하다.

같은 함정을 테스트에도 반영했다 — ClinicalBvTest·AlgoPathReportTest 가 프리셋을 V1 로
고정한다. 다른 테스트가 남긴 전역 프리셋에 끌려가면 값이 통째로 흔들린다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 10:14:52 +09:00
parent 21b0fcaed8
commit e17391ca9f
5 changed files with 177 additions and 62 deletions
@@ -47,5 +47,32 @@ object AlgoMode {
var reference: Boolean = false
/** 화면에 표시할 이름. */
val label: String get() = if (reference) "레퍼런스 (Python 1:1)" else "기존 (현행 임상)"
val label: String get() = labelOf(reference)
/** 전역 상태가 아니라 **그 계산에 실제로 쓰인** 값의 이름. */
fun labelOf(ref: Boolean): String =
if (ref) "레퍼런스 (Python 1:1)" else "기존 (현행 임상)"
private val lock = Any()
/**
* [ref] 경로로 [block] 을 실행하고 **원래 값으로 되돌린다.**
*
* ## 왜 전역 토글을 그냥 켜지 않나
* [reference] 는 프로세스 전역이라, 병원 임상 화면이 켜면 **일반 측정 화면의 BV 까지**
* 바뀐다. 그쪽은 현행 임상에서 쓰이고 있어 임의로 바꾸면 안 된다.
*
* 그래서 임상 경로([ClinicalBv])만 이 범위 안에서 돌린다. 계산이 끝나면 전역값은
* 원래대로다 — 임상 화면을 다녀왔다는 이유로 다른 화면의 값이 달라지지 않는다.
*
* ## 동기화하는 이유
* 임상 화면의 측정은 코루틴에서 돈다. 두 계산이 겹치면 한쪽의 복원이 다른 쪽의
* 설정을 지워 **엉뚱한 경로로 계산된 값**이 나온다. 겹칠 일이 드물어도, 그때 나온
* 한 건이 검증 기록에 섞이면 나중에 찾아낼 방법이 없다.
*/
fun <T> withReference(ref: Boolean, block: () -> T): T = synchronized(lock) {
val prev = reference
reference = ref
try { block() } finally { reference = prev }
}
}
@@ -56,9 +56,13 @@ object ClinicalBv {
/**
* 이 값을 낸 알고리즘 경로. **숫자와 함께 반드시 표시한다.**
*
* 두 경로가 같은 입력에 다른 값을 낸다(실측 align_cm1: 기존 125.52 mL vs
* 레퍼런스 122.25 mL). 어느 쪽에서 나온 값인지 모르면 "정렬 위치의 BV 가
* 타당한가"를 판단할 수 없다 — 검증 자체가 성립하지 않는다.
* 두 경로는 같은 신호에 다른 값을 낼 수 있다 — `AlgoModeSwitchTest` 실측에서
* **cycle 44개 중 38개**의 검출·BV 가 갈렸다. 다만 여러 cycle 을 평균한
* mean-scan 에서는 차이가 묻히기도 한다(align_cm0·cm1 은 두 경로가 동일).
*
* 즉 **입력에 따라 같기도 다르기도 하다.** 그래서 값만 남기면 나중에 해석할 수
* 없다 — 어느 경로에서 나온 BV 인지 모르면 "정렬 위치가 타당한가"의 근거가
* 되지 못한다.
*/
val algoLabel: String,
/** 파형 마커용 (전벽, 후벽) 인덱스. 미검출 채널은 null. */
@@ -76,8 +80,26 @@ object ClinicalBv {
signals: List<DoubleArray>,
params: MethodDParams = MethodDParams.DEFAULT,
supine: Boolean = false,
/**
* 병원 임상은 **최종 보고본(레퍼런스)** 이 기본이다.
*
* 전역 [AlgoMode.reference] 를 켜는 대신 [AlgoMode.withReference] 범위 안에서만
* 돌린다 — 전역을 켜면 일반 측정 화면의 BV 까지 바뀌는데, 그쪽은 현행 임상에서
* 쓰이고 있어 임의로 건드리면 안 된다.
*
* false 는 비교용이다(두 경로가 얼마나 다른지 그 자리에서 보기 위함).
*/
reference: Boolean = true,
): Outcome = AlgoMode.withReference(reference) {
computeInScope(signals, params, supine, AlgoMode.labelOf(reference))
}
private fun computeInScope(
signals: List<DoubleArray>,
params: MethodDParams,
supine: Boolean,
algo: String,
): Outcome {
val algo = AlgoMode.label
if (signals.size < PiezoHW.centerCh.size) {
return Outcome(null, emptyList(), 0, "채널 부족 (${signals.size}/6)", algo, emptyList(), null)
}
@@ -16,7 +16,6 @@ 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
@@ -79,13 +78,10 @@ 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 }
}
// 병원 임상은 **최종 보고본이 기본**이다. 전역 AlgoMode 를 켜지 않는다 —
// ClinicalBv 가 계산 동안만 그 경로로 돌리고 되돌리므로, 일반 측정 화면의 BV 는
// 영향을 받지 않는다. 끄는 것은 두 경로를 그 자리에서 비교하기 위한 용도다.
var useReference by remember { mutableStateOf(true) }
LaunchedEffect(mode) {
val m = mode ?: return@LaunchedEffect
@@ -142,7 +138,8 @@ fun BvMeasureSection(
}
if (!aborted && cycles.size == AVG_CYCLES) {
error = null
val r = ClinicalBv.compute(ClinicalBv.meanScan(cycles), supine = supine)
val r = ClinicalBv.compute(
ClinicalBv.meanScan(cycles), supine = supine, reference = useReference)
outcome = r
r.volumeMl?.let { v ->
history = (history + v).takeLast(HISTORY_MAX)
@@ -173,7 +170,7 @@ fun BvMeasureSection(
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = useReference,
onCheckedChange = { useReference = it; AlgoMode.reference = it },
onCheckedChange = { useReference = it },
enabled = !running,
)
Spacer(Modifier.width(8.dp))
@@ -183,7 +180,8 @@ fun BvMeasureSection(
fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
)
Text(
"끄면 기존 경로로 계산합니다. 두 경로는 같은 신호에 다른 값을 낼 수 있습니다.",
"이 화면에만 적용됩니다. 끄면 기존 경로로 계산합니다 — " +
"두 경로는 같은 신호에 다른 값을 낼 수 있습니다.",
fontSize = 10.sp, color = MlSecondaryText,
)
}