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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user