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:
2026-09-10 09:24:42 +09:00
parent 60d36a8206
commit f04bcf63f0
5 changed files with 183 additions and 6 deletions
@@ -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,
)
}