From f04bcf63f081bd87fe3cef82b8db2c2381aa1e50 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Thu, 10 Sep 2026 09:24:42 +0900 Subject: [PATCH] =?UTF-8?q?fix(clinical):=20=EC=A0=95=EB=A0=AC=20BV=20?= =?UTF-8?q?=EC=97=90=20supine=20=EC=9D=84=20=EB=84=98=EA=B8=B4=EB=8B=A4=20?= =?UTF-8?q?=E2=80=94=20=EB=91=90=20=ED=99=94=EB=A9=B4=EC=9D=98=20BV=20?= =?UTF-8?q?=EA=B0=80=20=EA=B0=88=EB=A6=AC=EA=B3=A0=20=EC=9E=88=EC=97=88?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정렬 화면이 `ClinicalBv.compute(meanScan)` 을 **인자 없이** 불렀다. supine 기본값은 false 다. 병원 임상 측정 화면은 `posture == SUPINE` 을 넘긴다. 같은 프로브·같은 자리·같은 알고리즘인데 **두 화면이 다른 식으로 계산**하고 있었다. ## 얼마나 갈리나 실측 기반 nch=2 입력에서: supine=false 199.45 mL ← 정렬 화면이 내던 값 supine=true 377.86 mL ← 임상 측정 화면이 내던 값 **1.9배.** 범위는 좁다 — supine 은 PiezoBVEstimator 한 곳(1445)에서만 쓰이고, 레퍼런스 경로이고 검출 채널이 **정확히 2개**일 때 빔 기하 cap 상한을 건너뛰는 데만 쓴다. nch 3 이상이면 위쪽 top/bottom clamp 가 같은 일을 하므로 값이 같다. 그런데 정렬에서 nch=2 는 드물지 않다 — 2026-09-09 실측 4위치가 nch 2·2·1·1 이었으니 0cm·1cm 두 자리가 그 경우였다. (앞서 이 인자가 F83 도 본다고 적었는데 그건 틀렸다. F83 은 mirrorBottomCap 이 켜고, supine 을 읽지 않는다.) ## 자세를 AppState 로 올렸다 정렬 화면에는 자세를 고르는 칸이 없다. 병원 모드의 로컬 상태였으니 정렬이 알 길이 없었던 것이 근본 원인이다. `AppState.clinicalPosture` 로 올려 두 화면이 같은 값을 본다. ## 남는 차이는 캡션에 적는다 알고리즘·인자를 맞춰도 **조건과 표본 수는 다르다.** 안 적으면 세 숫자를 같은 것으로 놓고 비교한다: 정렬 2.3MHz c3 · 20회 평균 (판정과 같은 mean-scan 이어야 한다) 수동 확인 2.3MHz c3 · 1 cycle 프로토콜 조합별 (1.8/2.3) · 1 cycle ← 주파수가 조합마다 바뀐다 정렬과 수동 확인은 같은 조건이라 비교 가능하다. 프로토콜은 조합이 2.3MHz c3 인 회차만 비교 가능하다 — 복부 두께 40mm 이하면 1.8MHz 만 돌므로 정렬 값과 직접 비교할 수 없다. 테스트 134개 통과(신규 5). ClinicalBvSupineTest 가 "이 인자가 결과를 바꾼다"를 실측으로 고정한다 — 바꾸지 않는다면 호출부가 빠뜨려도 아무도 모르고, 그러면 언제든 다시 어긋난다. Co-Authored-By: Claude Opus 5 --- .../java/com/medithings/vesiscan/AppState.kt | 12 ++ .../ui/views/clinical/AnchorAlignView.kt | 17 ++- .../ui/views/clinical/BvMeasureSection.kt | 8 +- .../ui/views/clinical/HospitalModeView.kt | 10 +- .../vesiscan/managers/ClinicalBvSupineTest.kt | 142 ++++++++++++++++++ 5 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/com/medithings/vesiscan/managers/ClinicalBvSupineTest.kt diff --git a/app/src/main/java/com/medithings/vesiscan/AppState.kt b/app/src/main/java/com/medithings/vesiscan/AppState.kt index c8d116c..d98524a 100644 --- a/app/src/main/java/com/medithings/vesiscan/AppState.kt +++ b/app/src/main/java/com/medithings/vesiscan/AppState.kt @@ -56,6 +56,18 @@ class AppState(private val context: Context) { */ var anchorBasis: com.medithings.vesiscan.managers.AnchorBasis? by mutableStateOf(null) + /** + * 병원 임상 자세. **정렬 화면과 병원 모드가 같은 값을 봐야 한다.** + * + * BV 계산이 이 값을 본다(`supine` → nch=2 빔 clamp 건너뛰기, F83). 화면마다 따로 + * 들고 있으면 같은 프로브·같은 자리에서 **정렬 BV 와 임상 BV 가 다른 식으로** + * 계산된다 — 실제로 정렬이 `supine` 을 안 넘겨 그렇게 돌고 있었다(2026-09-10). + * + * 정렬 화면에는 자세를 고르는 칸이 없다. 병원 모드에서 고른 값을 그대로 쓴다. + */ + var clinicalPosture: com.medithings.vesiscan.models.ClinicalPosture by + mutableStateOf(com.medithings.vesiscan.models.ClinicalPosture.SUPINE) + /** * 병원 임상 환자명. 화면 상태가 아니라 여기 둔다. * diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt index fd5ae50..9b5b9ab 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt @@ -69,6 +69,7 @@ import com.medithings.vesiscan.managers.AnchorStep import com.medithings.vesiscan.managers.ClinicalBv import com.medithings.vesiscan.managers.DetachWatcher import com.medithings.vesiscan.managers.LateralGuide +import com.medithings.vesiscan.models.ClinicalPosture import com.medithings.vesiscan.models.HospitalFixedParams import com.medithings.vesiscan.services.HospitalRunStore import com.medithings.vesiscan.services.labdb.HospitalLabdbUploader @@ -305,7 +306,18 @@ fun AnchorAlignView(appState: AppState) { // 판정과 **같은 mean-scan** 으로 BV 를 낸다. 따로 재면 그 위치의 지표와 // 용적이 다른 데이터에서 나온 값이 되어 대조가 성립하지 않는다. - bv = ClinicalBv.compute(ClinicalBv.meanScan(cycles)) + // + // supine 을 꼭 넘긴다. 이걸 빼면 nch=2 일 때 빔 기하 cap 상한이 걸려 + // (PiezoBVEstimator: NCH2_SIBEAM_SKIP_SUPINE) 임상 측정 화면과 **다른 식으로** + // 계산된다. 누운 자세에서는 적도가 fan 아래로 내려가 "빔이 못 잡았다"가 + // "방광이 없다"를 뜻하지 않으므로 그 상한을 건너뛰어야 한다. + // + // 정렬에서 nch=2 는 드문 경우가 아니다 — 2026-09-09 실측은 전 위치가 + // nch 1~2 였다. 2026-09-10 까지 이 인자가 빠져 있었다. + bv = ClinicalBv.compute( + ClinicalBv.meanScan(cycles), + supine = appState.clinicalPosture == ClinicalPosture.SUPINE, + ) val step = guide.step(cycles, cm) last = step @@ -596,7 +608,8 @@ fun AnchorAlignView(appState: AppState) { Text("이 위치의 방광 용적", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText) Spacer(Modifier.height(6.dp)) - BvPanel(bv, caption = "${cm}cm · 20회 평균") + BvPanel(bv, caption = "${cm}cm · ${ALIGN_FREQ.label}MHz " + + "c${ALIGN_CYCLE.cycles} · ${AnchorConfig.ALIGN_CYCLES}회 평균") Spacer(Modifier.height(16.dp)) } 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 0541dae..5076ae5 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 @@ -85,6 +85,9 @@ private const val WINDOW_MIN = 5 /** 연속 모드 목표 주기. 메인 화면 `piezoSettings.autoScanIntervalMs` 기본값과 같다. */ private const val INTERVAL_MS = 500L +/** 캡션에 붙이는 조건. 정렬과 같은 조건이라는 것이 비교의 전제다. */ +private val CONDITION: String get() = "${BV_FREQ.label}MHz c${BV_CYCLE.cycles} · 1 cycle" + private const val MEASURE_TIMEOUT_MS = 3_000L private const val CONFIG_TIMEOUT_MS = 3_000L private const val SETTLE_MS = 600L @@ -306,9 +309,10 @@ fun BvMeasureSection( BvPanel( outcome = outcome, caption = when (mode) { - Mode.CONTINUOUS -> "직전 회차 (${tick}번째)" + Mode.CONTINUOUS -> "직전 회차 (${tick}번째) · $CONDITION" Mode.SPOT -> "측정 중" - null -> if (outcome == null) "측정을 시작하면 값이 나옵니다." else "마지막 측정" + null -> if (outcome == null) "측정을 시작하면 값이 나옵니다." + else "마지막 측정 · $CONDITION" }, ) diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt index 9807da8..79d1eea 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt @@ -85,7 +85,8 @@ fun HospitalModeView(appState: AppState) { // 환자명은 AppState 에 둔다 — 정렬 화면을 다녀와도 살아있어야 한다. var patient by appState::hospitalPatient - var posture by remember { mutableStateOf(ClinicalPosture.SUPINE) } + // 정렬 화면도 이 값을 봐야 BV 가 같은 식으로 계산된다([AppState.clinicalPosture]). + var posture by appState::clinicalPosture var fill by remember { mutableStateOf(BladderFill.P0) } var repeatsText by remember { mutableStateOf(HOSPITAL_DEFAULT_REPEATS.toString()) } /** @@ -553,9 +554,14 @@ fun HospitalModeView(appState: AppState) { Spacer(Modifier.height(12.dp)) Label("방광 용적") Spacer(Modifier.height(6.dp)) + // 캡션에 **조건을 적는다.** 프로토콜은 조합마다 주파수가 바뀌는데 + // 정렬·수동 확인은 2.3MHz c3 고정이라, 조건을 안 적으면 세 숫자를 + // 같은 것으로 놓고 비교한다. + val shown = thickness.combos.getOrElse(comboIdx) { thickness.combos.first() } com.medithings.vesiscan.ui.components.BvPanel( runBv, - caption = if (running) "직전 회차" else "마지막 회차", + caption = (if (running) "직전 회차" else "마지막 회차") + + " · ${shown.first.label}MHz c${shown.second.cycles} · 1 cycle", compact = true, ) } diff --git a/app/src/test/java/com/medithings/vesiscan/managers/ClinicalBvSupineTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/ClinicalBvSupineTest.kt new file mode 100644 index 0000000..e328ed4 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/ClinicalBvSupineTest.kt @@ -0,0 +1,142 @@ +package com.medithings.vesiscan.managers + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File + +/** + * `supine` 은 **화면마다 달라지면 안 되는 인자**다. + * + * ## 무슨 일이 있었나 + * 2026-09-10 까지 정렬 화면이 `ClinicalBv.compute(meanScan)` 을 인자 없이 불렀다 — + * `supine` 기본값 false 다. 병원 임상 측정 화면은 `posture == SUPINE` 을 넘긴다. + * 즉 **같은 프로브·같은 자리·같은 알고리즘인데 두 화면의 BV 가 다른 식으로 계산**됐다. + * + * `supine` 은 **한 곳에서만** 쓰인다(PiezoBVEstimator:1445): 레퍼런스 경로이고 + * 검출 채널이 **정확히 2개**일 때 빔 기하 cap 상한을 건너뛴다 + * (`NCH2_SIBEAM_SKIP_SUPINE`). 누운 자세에서는 적도가 배열 fan 아래로 내려가 + * "빔이 못 잡았다"가 "방광이 없다"를 뜻하지 않기 때문이다. + * + * 즉 범위는 좁지만 **걸리면 두 배 가까이 갈린다**(아래 실측 199 mL vs 378 mL). + * 그리고 정렬에서 nch=2 는 드문 경우가 아니다 — 2026-09-09 실측 4위치가 nch 2·2·1·1 + * 이었으니 0cm·1cm 두 자리가 그 경우였다. + * + * ## 이 파일이 지키는 것 + * 값 자체가 아니라 **"이 인자가 결과를 바꾼다"는 사실**이다. 바꾸지 않는다면 호출부가 + * 빠뜨려도 아무도 모르고, 그러면 언제든 다시 어긋난다. + */ +class ClinicalBvSupineTest { + + // 실측 자원은 VBT26050202(=V1) 로 받은 것이다. 프리셋이 dps·delay 를 정하므로 + // 다른 테스트가 남긴 전역 프리셋에 끌려가면 값이 통째로 흔들린다. + private val saved = PiezoHW.activePreset + + @Before fun pin() { PiezoHW.activePreset = PiezoHW.DevicePreset.V1 } + + @After fun restore() { PiezoHW.activePreset = saved } + + /** 한 파일의 전 cycle 을 mean-scan 으로 — 정렬 화면이 BV 에 넘기는 것과 같은 모양. */ + private fun meanScanOf(name: String): List { + val f = File("src/test/resources/$name") + assertTrue("실측 자원이 없다: ${f.absolutePath}", f.exists()) + val cycles = 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 + cycles.getOrPut(ci) { linkedMapOf() }[ch] = + DoubleArray(p.size - 2) { p[it + 2].toDoubleOrNull() ?: 0.0 } + } + val full = cycles.values.filter { it.size == 6 }.map { m -> (0..5).map { m.getValue(it) } } + assertTrue("cycle 이 하나도 안 읽혔다", full.isNotEmpty()) + return ClinicalBv.meanScan(full) + } + + /** + * CH0~CH3 중 **정확히 2개**만 검출되게 만든 실측 기반 입력. + * + * 받아 둔 자원은 nch 3·4 라서 이 경로를 안 탄다. 합성 신호로 만들면 검출이 + * 현실과 달라지므로, 실측 mean-scan 에서 한 채널만 평탄화해 nch 를 2 로 내린다. + */ + private fun nch2Scan(): List { + val base = meanScanOf("align_cm0.csv") + // CH0 평탄화 → CH1·CH2 만 남아 nch=2. + val mod = base.mapIndexed { ch, a -> + if (ch == 0) DoubleArray(a.size) { 1000.0 } else a + } + assertEquals("이 입력이 nch=2 여야 이 파일의 전제가 성립한다", + 2, ClinicalBv.compute(mod).detectedCenter) + return mod + } + + @Test fun `nch가 2면 supine 이 부피를 크게 바꾼다`() { + // 여기가 이 결함의 실체다. 실측 기반 nch=2 입력에서 **1.9배** 갈린다. + // 정렬이 supine 을 안 넘기고 임상 측정은 넘겼으니, 같은 자리에서 199 mL 와 + // 378 mL 가 각각 화면에 떴다. + val scan = nch2Scan() + val off = ClinicalBv.compute(scan, supine = false).volumeMl + val on = ClinicalBv.compute(scan, supine = true).volumeMl + assertNotNull(off); assertNotNull(on) + assertEquals(199.45, off!!, 0.01) + assertEquals(377.86, on!!, 0.01) + // 비율까지 박아 둔다. 값이 바뀌어도 "두 배 가까이 갈린다"는 사실이 남아야, + // 호출부가 인자를 빠뜨렸을 때의 무게를 읽는 사람이 안다. + assertTrue("nch=2 에서 supine 차이가 사라지면 이 파일의 전제를 다시 봐야 한다", + on / off > 1.5) + } + + @Test fun `nch가 2가 아니면 supine 은 값을 바꾸지 않는다`() { + // 상한이 `n == NCH2_SIBEAM_CLAMP_NCH` 에서만 걸린다. 3개 이상이면 위쪽 + // top/bottom 채널 clamp 가 같은 일을 하므로 중복이다. + listOf("align_cm0.csv", "align_cm1.csv").forEach { name -> + val scan = meanScanOf(name) + val nch = ClinicalBv.compute(scan).detectedCenter + assertTrue("$name 은 nch=3 이상이어야 이 검사가 의미 있다", nch >= 3) + assertEquals( + "nch=$nch ($name) 에서는 supine 이 값을 바꾸면 안 된다", + ClinicalBv.compute(scan, supine = false).volumeMl, + ClinicalBv.compute(scan, supine = true).volumeMl, + ) + } + } + + @Test fun `supine 이 바뀌어도 검출과 마커는 그대로다`() { + // supine 은 **부피 계산**만 건드린다. 벽 검출까지 달라지면 화면의 마커와 숫자가 + // 따로 움직여 사람이 원인을 짚을 수 없다. + val scan = nch2Scan() + val off = ClinicalBv.compute(scan, supine = false) + val on = ClinicalBv.compute(scan, supine = true) + assertTrue("값이 갈리는 입력에서 봐야 의미가 있다", off.volumeMl != on.volumeMl) + assertEquals(off.detectedCenter, on.detectedCenter) + assertEquals(off.markers, on.markers) + assertEquals(off.algoLabel, on.algoLabel) + assertEquals(off.preset, on.preset) + } + + @Test fun `기본값은 false 다 — 호출부가 반드시 넘겨야 한다`() { + // 기본값이 false 라는 것이 이 결함의 원인이었다. 바꾸지 않는다: 일반 측정 화면 + // (현행 임상에서 쓰는 경로)이 같은 함수를 쓰고, 그쪽 기본 동작을 건드리면 안 된다. + // 대신 **임상 화면 호출부는 전부 넘겨야 한다**는 것을 여기 적어 둔다. + val scan = nch2Scan() + assertEquals( + ClinicalBv.compute(scan, supine = false).volumeMl, + ClinicalBv.compute(scan).volumeMl, + ) + } + + @Test fun `레퍼런스 경로에서만 갈린다`() { + // nch=2 cap clamp 는 `AlgoMode.reference` 안에서만 걸린다. 기존 경로에는 그 + // 제약이 없으므로 supine 이 그 경로의 값을 바꾸지 않는다 — 병원 임상이 레퍼런스 + // 고정인 이유 중 하나다. + val scan = nch2Scan() + val legacyOff = ClinicalBv.compute(scan, supine = false, reference = false) + val legacyOn = ClinicalBv.compute(scan, supine = true, reference = false) + assertNotNull(legacyOff.algoLabel) + assertEquals(legacyOff.volumeMl, legacyOn.volumeMl) + } +}