fix(clinical): 정렬 BV 에 supine 을 넘긴다 — 두 화면의 BV 가 갈리고 있었다
정렬 화면이 `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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
/**
|
||||
* 병원 임상 환자명. 화면 상태가 아니라 여기 둔다.
|
||||
*
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<DoubleArray> {
|
||||
val f = File("src/test/resources/$name")
|
||||
assertTrue("실측 자원이 없다: ${f.absolutePath}", f.exists())
|
||||
val cycles = 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
|
||||
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<DoubleArray> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user