fix(clinical): BV 측정을 메인 화면의 단일·자동 측정과 같은 방식으로
임상 화면의 Spot/Continuous 를 메인 화면 [단일 측정]·[자동 측정]과 같은 역할로 쓰기로
했는데, 내가 만든 것은 방식이 달랐다.
메인 단일: 1 cycle → BV 그대로
자동: cycle 마다 BV → 최근 10개 절사평균 (5개 이상 쌓인 뒤 표시)
기존 구현 5 cycle 신호를 mean-scan → BV 1개
**평균을 내는 지점이 달랐다.** 메인은 BV 를 낸 뒤 부피끼리 평균하고, 내 것은 BV 내기
전 신호끼리 평균했다. 그러면 같은 프로브·같은 자리에서도 두 화면 숫자가 갈린다 —
검증 첫 항목이 "기존 초음파 측정기와 비교"라, 그 차이가 기기 탓인지 계산 탓인지
구분이 안 되면 검증 자체가 무너진다.
메인 규약을 그대로 옮겼다: 1 cycle = BV 1개, 자동은 최근 10개 절사평균(최대·최소 하나씩
버림), 5개 이상 쌓여야 대표값 표시, 완료 대기 후 남은 간격만 쉬기. trimmedMean 식이
메인과 같은지 테스트로 고정했다.
신호 평균(mean-scan)이 값은 더 안정적이지만 메인과 다른 수가 되고, 정렬의 20-cycle
mean-scan 과도 또 다른 세 번째 방식이 된다. 여기서는 **비교 가능성이 안정성보다
우선**이라 이쪽을 택했다.
편차·CV·범위는 남겼다. 메인에는 없지만 검증 항목 ③(위치 민감도)이 위치끼리 BV 를
비교하는 일이라, 흔들림을 모르면 "이 위치가 더 낫다"를 말할 수 없다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,20 +27,30 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* 한 번 재는 것(Spot)과 계속 재는 것(Continuous).
|
||||
* 한 번 재는 것(단일/Spot)과 계속 재는 것(자동/Continuous).
|
||||
*
|
||||
* ## 왜 두 모드인가
|
||||
* **Spot** 은 "지금 이 자세·이 충만도에서 얼마인가"를 한 번 확인하는 것이다. 정렬이
|
||||
* 맞는지, 프로토콜을 돌리기 전에 값이 말이 되는지를 본다.
|
||||
* ## 메인 화면의 [단일 측정]·[자동 측정]과 **같은 방식**이다
|
||||
* 두 화면의 숫자가 비교 가능해야 한다. 방식이 다르면 같은 프로브·같은 자리에서도 값이
|
||||
* 갈려서, "임상 화면은 180 인데 메인은 195"가 기기 차이인지 계산 차이인지 알 수 없다.
|
||||
* 검증의 첫 항목이 "기존 초음파 측정기와 비교"라 이 혼동은 치명적이다.
|
||||
*
|
||||
* **Continuous** 는 값이 **흔들리는지**를 본다. 한 번 재서 나온 200mL 가 진짜인지,
|
||||
* 아니면 그 순간만 그런지는 한 번으로 알 수 없다. 정렬 위치가 최적인지 판단하려면
|
||||
* 반복 재현성이 필요하고, 그건 연속으로 재야 보인다.
|
||||
* 그래서 메인 화면([com.medithings.vesiscan.ui.views.monitoring.PiezoMonitoringView])의
|
||||
* 규약을 그대로 옮긴다:
|
||||
*
|
||||
* ## 평균 몇 회로 재는가
|
||||
* [AVG_CYCLES] 회를 모아 mean-scan 한 뒤 한 번 계산한다. 1 cycle 로 재면 노이즈가
|
||||
* 그대로 벽으로 잡혀 값이 튄다 — 정렬(20 cycle)과 같은 이유이고, 실시간성을 위해
|
||||
* 그보다 적게 잡았다.
|
||||
* | | 메인 | 여기 |
|
||||
* |---|---|---|
|
||||
* | 단일 | 1 cycle → BV 그대로 | 같음 |
|
||||
* | 자동 | cycle 마다 BV → 최근 [WINDOW_SIZE]개 **절사평균** | 같음 |
|
||||
* | 표시 시작 | 창에 [WINDOW_MIN]개 이상 쌓인 뒤 | 같음 |
|
||||
* | 주기 | 완료 대기 + 남은 간격만 | 같음 |
|
||||
*
|
||||
* **평균을 BV 뒤에 낸다**는 점이 핵심이다. 신호를 먼저 평균하면(mean-scan) 값이 더
|
||||
* 안정적이지만 메인 화면과 다른 수가 되고, 정렬 단계의 20-cycle mean-scan 과도 또
|
||||
* 다른 세 번째 방식이 된다. 여기서는 **비교 가능성이 안정성보다 우선**이라 이쪽을 택했다.
|
||||
*
|
||||
* ## 절사평균인 이유
|
||||
* 최대·최소 하나씩 버리고 평균낸다. cycle 단위 BV 는 벽 오검출로 가끔 크게 튀는데,
|
||||
* 산술평균은 그 한 번에 통째로 끌려간다.
|
||||
*
|
||||
* ## 측정 조건은 정렬과 같게 고정한다
|
||||
* 2.3MHz · cycle 3. 검출 문턱이 절대값 기준이라 조건이 바뀌면 `nch` 가 달라지고,
|
||||
@@ -49,19 +59,18 @@ import kotlinx.coroutines.withTimeoutOrNull
|
||||
private val BV_FREQ = ProbeFrequency.F_2_3
|
||||
private val BV_CYCLE = ProbeCycle.C3
|
||||
|
||||
/** 한 번의 BV 산출에 모으는 cycle 수. */
|
||||
private const val AVG_CYCLES = 5
|
||||
/** 자동 모드 표시 창 크기. 메인 화면 `autoWindowSize` 와 같은 값. */
|
||||
private const val WINDOW_SIZE = 10
|
||||
|
||||
/** 연속 모드에서 한 회 산출을 마친 뒤 쉬는 시간. */
|
||||
private const val CONTINUOUS_GAP_MS = 500L
|
||||
/** 이만큼 쌓여야 절사평균을 띄운다. 메인 화면과 같다. */
|
||||
private const val WINDOW_MIN = 5
|
||||
|
||||
/** 자동 모드 목표 주기. 메인 화면 `piezoSettings.autoScanIntervalMs` 기본값과 같다. */
|
||||
private const val INTERVAL_MS = 500L
|
||||
|
||||
private const val MEASURE_TIMEOUT_MS = 3_000L
|
||||
private const val CONFIG_TIMEOUT_MS = 3_000L
|
||||
private const val SETTLE_MS = 600L
|
||||
private const val CYCLE_GAP_MS = 300L
|
||||
|
||||
/** 연속 모드가 화면에 들고 있는 최근 값 개수. */
|
||||
private const val HISTORY_MAX = 20
|
||||
|
||||
@Composable
|
||||
fun BvMeasureSection(
|
||||
@@ -74,9 +83,10 @@ fun BvMeasureSection(
|
||||
var mode by remember { mutableStateOf<Mode?>(null) } // null = 정지
|
||||
var outcome by remember { mutableStateOf<ClinicalBv.Outcome?>(null) }
|
||||
var live by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) }
|
||||
var progress by remember { mutableIntStateOf(0) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var history by remember { mutableStateOf<List<Double>>(emptyList()) }
|
||||
/** cycle 마다 나온 BV. 메인 화면 `autoVolumeWindow` 와 같은 역할. */
|
||||
var window by remember { mutableStateOf<List<Double>>(emptyList()) }
|
||||
var tick by remember { mutableIntStateOf(0) }
|
||||
|
||||
// 병원 임상은 **최종 보고본이 기본**이다. 전역 AlgoMode 를 켜지 않는다 —
|
||||
// ClinicalBv 가 계산 동안만 그 경로로 돌리고 되돌리므로, 일반 측정 화면의 BV 는
|
||||
@@ -86,6 +96,7 @@ fun BvMeasureSection(
|
||||
LaunchedEffect(mode) {
|
||||
val m = mode ?: return@LaunchedEffect
|
||||
error = null
|
||||
if (m == Mode.CONTINUOUS) { window = emptyList(); tick = 0 }
|
||||
val inbox = kotlinx.coroutines.channels.Channel<List<PiezoChannelData>>(
|
||||
kotlinx.coroutines.channels.Channel.CONFLATED)
|
||||
// 이 구간이 프로브 설정을 바꾸므로 끝나면 되돌린다 — 정렬·병원 모드와 같은 이유.
|
||||
@@ -116,37 +127,32 @@ fun BvMeasureSection(
|
||||
delay(SETTLE_MS)
|
||||
|
||||
do {
|
||||
val cycles = ArrayList<List<DoubleArray>>(AVG_CYCLES)
|
||||
progress = 0
|
||||
var aborted = false
|
||||
for (i in 1..AVG_CYCLES) {
|
||||
if (mode == null) { aborted = true; break }
|
||||
val startedAt = System.currentTimeMillis()
|
||||
while (inbox.tryReceive().isSuccess) { /* 늦게 온 것 비우기 */ }
|
||||
bleManager.sendMtb()
|
||||
val got = withTimeoutOrNull(MEASURE_TIMEOUT_MS) { inbox.receive() }
|
||||
if (got == null) {
|
||||
// 한 회 놓쳤다고 멈추지 않는다 — 연속 모드는 계속 돌아야 한다.
|
||||
// 한 회 놓쳤다고 멈추지 않는다 — 자동 모드는 계속 돌아야 한다.
|
||||
error = "응답 시간 초과 — 다시 시도합니다."
|
||||
aborted = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
error = null
|
||||
val sorted = got.sortedBy { it.channel }
|
||||
live = sorted
|
||||
cycles.add(ClinicalBv.toSignals(sorted.map { it.buffer }))
|
||||
progress = i
|
||||
delay(CYCLE_GAP_MS)
|
||||
}
|
||||
if (!aborted && cycles.size == AVG_CYCLES) {
|
||||
error = null
|
||||
// **1 cycle = BV 1개.** 메인 화면 measure() 와 같은 단위다.
|
||||
val r = ClinicalBv.compute(
|
||||
ClinicalBv.meanScan(cycles), supine = supine, reference = useReference)
|
||||
ClinicalBv.toSignals(sorted.map { it.buffer }),
|
||||
supine = supine, reference = useReference,
|
||||
)
|
||||
outcome = r
|
||||
r.volumeMl?.let { v ->
|
||||
history = (history + v).takeLast(HISTORY_MAX)
|
||||
tick++
|
||||
if (m == Mode.CONTINUOUS) {
|
||||
r.volumeMl?.let { v -> window = (window + v).takeLast(WINDOW_SIZE) }
|
||||
}
|
||||
}
|
||||
if (m == Mode.SPOT) break
|
||||
delay(CONTINUOUS_GAP_MS)
|
||||
// 완료까지 걸린 만큼을 빼고 남은 간격만 쉰다 — 메인 화면과 같은 규약.
|
||||
val remain = INTERVAL_MS - (System.currentTimeMillis() - startedAt)
|
||||
if (remain > 0) delay(remain)
|
||||
} while (mode == Mode.CONTINUOUS)
|
||||
} finally {
|
||||
bleManager.piezoCollector.onMultiChannelComplete = prev
|
||||
@@ -162,11 +168,16 @@ fun BvMeasureSection(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("방광 용적 (BV)", fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("${BV_FREQ.label}MHz · cycle ${BV_CYCLE.cycles} · ${AVG_CYCLES}회 평균",
|
||||
Text("${BV_FREQ.label}MHz · cycle ${BV_CYCLE.cycles}",
|
||||
fontSize = 11.sp, color = MlSecondaryText)
|
||||
}
|
||||
// 경로 전환. 값이 달라지므로(실측 align_cm1: 125.52 vs 122.25 mL) 기록에는
|
||||
// 반드시 어느 쪽인지 같이 남겨야 한다 — BvPanel 이 매 값에 붙인다.
|
||||
Text(
|
||||
"메인 화면의 [단일 측정] · [자동 측정]과 같은 방식입니다.",
|
||||
fontSize = 11.sp, color = MlSecondaryText,
|
||||
)
|
||||
|
||||
// 경로 전환. 값이 달라질 수 있으므로 기록에는 반드시 어느 쪽인지 같이 남겨야
|
||||
// 한다 — BvPanel 이 매 값에 붙인다.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(
|
||||
checked = useReference,
|
||||
@@ -180,8 +191,7 @@ fun BvMeasureSection(
|
||||
fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
"이 화면에만 적용됩니다. 끄면 기존 경로로 계산합니다 — " +
|
||||
"두 경로는 같은 신호에 다른 값을 낼 수 있습니다.",
|
||||
"이 화면에만 적용됩니다. 끄면 기존 경로로 계산합니다.",
|
||||
fontSize = 10.sp, color = MlSecondaryText,
|
||||
)
|
||||
}
|
||||
@@ -190,11 +200,11 @@ fun BvMeasureSection(
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Button(
|
||||
onClick = { if (mode == Mode.SPOT) mode = null else mode = Mode.SPOT },
|
||||
onClick = { mode = if (mode == Mode.SPOT) null else Mode.SPOT },
|
||||
enabled = isConnected && mode != Mode.CONTINUOUS,
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) { Text(if (mode == Mode.SPOT) "측정 중…" else "1회 측정", fontWeight = FontWeight.Bold) }
|
||||
) { Text(if (mode == Mode.SPOT) "측정 중…" else "단일 측정", fontWeight = FontWeight.Bold) }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { mode = if (mode == Mode.CONTINUOUS) null else Mode.CONTINUOUS },
|
||||
@@ -204,60 +214,69 @@ fun BvMeasureSection(
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (mode == Mode.CONTINUOUS) MlCritical else MlSuccess),
|
||||
) {
|
||||
Text(if (mode == Mode.CONTINUOUS) "연속 중지" else "연속 측정",
|
||||
Text(if (mode == Mode.CONTINUOUS) "자동 측정 중지" else "자동 측정",
|
||||
color = Color.White, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
if (running) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { progress / AVG_CYCLES.toFloat() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
error?.let {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(it, fontSize = 12.sp, color = MlCritical)
|
||||
}
|
||||
|
||||
// ── 자동 모드 대표값 ──
|
||||
// 메인 화면과 같다: 최근 10개의 **절사평균**(최대·최소 하나씩 버림), 5개 이상 쌓여야 표시.
|
||||
if (mode == Mode.CONTINUOUS || window.isNotEmpty()) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(
|
||||
Modifier.fillMaxWidth()
|
||||
.background(MlPrimary.copy(alpha = 0.07f), RoundedCornerShape(10.dp))
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Column {
|
||||
if (window.size < WINDOW_MIN) {
|
||||
Text(
|
||||
"대표값 계산 중 (${window.size}/$WINDOW_MIN)",
|
||||
fontSize = 14.sp, fontWeight = FontWeight.Bold,
|
||||
color = MlSecondaryText,
|
||||
)
|
||||
} else {
|
||||
val trimmed = trimmedMean(window)
|
||||
val mean = window.average()
|
||||
val sd = kotlin.math.sqrt(
|
||||
window.sumOf { (it - mean) * (it - mean) } / window.size)
|
||||
Text(
|
||||
"%.1f mL".format(trimmed),
|
||||
fontSize = 26.sp, fontWeight = FontWeight.Bold, color = MlPrimary,
|
||||
)
|
||||
Text("최근 ${window.size}회 절사평균 (최대·최소 제외)",
|
||||
fontSize = 11.sp, color = MlSecondaryText)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
// 흔들림을 같이 낸다. 정렬 위치가 최적인지 판단하려면 값 하나가
|
||||
// 아니라 재현성이 필요하다 — 편차가 크면 그 위치는 못 쓴다.
|
||||
// 메인 화면에는 없는 것이지만, 검증 항목 ③(위치 민감도)이
|
||||
// 위치끼리 BV 를 비교하는 일이라 흔들림 없이는 판단이 안 된다.
|
||||
Text(
|
||||
"편차 %.1f mL (CV %.1f%%) · 범위 %.1f ~ %.1f"
|
||||
.format(sd, if (mean > 0) sd / mean * 100 else 0.0,
|
||||
window.min(), window.max()),
|
||||
fontSize = 11.sp, color = MlSecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
BvPanel(
|
||||
outcome = outcome,
|
||||
caption = when (mode) {
|
||||
Mode.CONTINUOUS -> "연속 측정 중"
|
||||
Mode.CONTINUOUS -> "직전 회차 (${tick}번째)"
|
||||
Mode.SPOT -> "측정 중"
|
||||
null -> if (outcome == null) "측정을 시작하면 값이 나옵니다." else "마지막 측정"
|
||||
},
|
||||
)
|
||||
|
||||
// ── 연속 측정 재현성 ──
|
||||
// 정렬 위치가 최적인지 판단하려면 값 하나가 아니라 **흔들림**을 봐야 한다.
|
||||
// 평균만 보이면 "200mL 나왔다"로 끝나는데, 편차가 30mL 면 그 위치는 못 쓴다.
|
||||
if (history.size >= 2) {
|
||||
val mean = history.average()
|
||||
val sd = kotlin.math.sqrt(history.sumOf { (it - mean) * (it - mean) } / history.size)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Box(
|
||||
Modifier.fillMaxWidth()
|
||||
.background(MlPrimary.copy(alpha = 0.07f), RoundedCornerShape(10.dp))
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"최근 ${history.size}회 — 평균 %.1f mL · 표준편차 %.1f mL (CV %.1f%%)"
|
||||
.format(mean, sd, if (mean > 0) sd / mean * 100 else 0.0),
|
||||
fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
"범위 %.1f ~ %.1f mL".format(history.min(), history.max()),
|
||||
fontSize = 11.sp, color = MlSecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 파형 + 벽 마커 ──
|
||||
// BV 가 안 나올 때 원인을 여기서 본다. 초록·주황 세로선이 전벽·후벽이다.
|
||||
if (live.isNotEmpty()) {
|
||||
@@ -275,4 +294,11 @@ fun BvMeasureSection(
|
||||
}
|
||||
}
|
||||
|
||||
/** 최대·최소 하나씩 버린 평균. 메인 화면 `trimmedMean` 과 같은 식. */
|
||||
internal fun trimmedMean(values: List<Double>): Double {
|
||||
if (values.size <= 2) return values.average()
|
||||
val sorted = values.sorted()
|
||||
return sorted.subList(1, sorted.size - 1).average()
|
||||
}
|
||||
|
||||
private enum class Mode { SPOT, CONTINUOUS }
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.medithings.vesiscan.ui.views.clinical
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 자동 측정 대표값 — 메인 화면과 **같은 식**이어야 한다.
|
||||
*
|
||||
* 원본: `PiezoMonitoringView.trimmedMean` (정렬·최대최소 제외·평균).
|
||||
*
|
||||
* 두 화면이 다른 식을 쓰면 같은 프로브·같은 자리에서 다른 수가 나오고, 검증 첫 항목
|
||||
* ("기존 초음파 측정기와 비교")에서 그 차이가 기기 탓인지 계산 탓인지 알 수 없게 된다.
|
||||
*/
|
||||
class TrimmedMeanTest {
|
||||
|
||||
@Test fun `최대와 최소를 하나씩 버린다`() {
|
||||
// 1 과 100 이 빠지고 2·3·4 의 평균.
|
||||
assertEquals(3.0, trimmedMean(listOf(1.0, 2.0, 3.0, 4.0, 100.0)), 1e-9)
|
||||
}
|
||||
|
||||
@Test fun `튀는 값 하나에 끌려가지 않는다`() {
|
||||
// cycle 단위 BV 는 벽 오검출로 가끔 크게 튄다. 산술평균은 그 한 번에 무너진다.
|
||||
val v = listOf(180.0, 182.0, 179.0, 181.0, 900.0)
|
||||
assertEquals(324.4, v.average(), 1e-9) // 산술평균은 못 쓴다
|
||||
assertEquals(181.0, trimmedMean(v), 1e-9) // 절사평균은 버틴다
|
||||
}
|
||||
|
||||
@Test fun `두 개 이하면 그냥 평균이다`() {
|
||||
// 버릴 것을 버리면 남는 게 없다 — 메인 화면도 같은 예외를 둔다.
|
||||
assertEquals(5.0, trimmedMean(listOf(5.0)), 1e-9)
|
||||
assertEquals(7.5, trimmedMean(listOf(5.0, 10.0)), 1e-9)
|
||||
}
|
||||
|
||||
@Test fun `같은 값이 여러 개여도 하나씩만 버린다`() {
|
||||
// 최소가 여럿이라고 다 버리면 표본이 줄어든다.
|
||||
assertEquals(3.0, trimmedMean(listOf(3.0, 3.0, 3.0, 3.0)), 1e-9)
|
||||
assertEquals(2.0, trimmedMean(listOf(1.0, 1.0, 3.0, 3.0)), 1e-9)
|
||||
}
|
||||
|
||||
@Test fun `입력 순서와 무관하다`() {
|
||||
val a = listOf(1.0, 2.0, 3.0, 4.0, 100.0)
|
||||
assertEquals(trimmedMean(a), trimmedMean(a.reversed()), 1e-9)
|
||||
assertEquals(trimmedMean(a), trimmedMean(a.shuffled()), 1e-9)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user