feat(clinical): Clinical Live 에 실시간 BV 표시 + Method 전환 chip

시험용 앱 (Clinical mode 6ch) 에서 지금까지 BV 추정값이 표시되지 않아 시험
중 참고 불가능. 6ch grid 아래에 BV 카드 신설.

- 3 가지 BvMethod 를 cycle 마다 병행 계산:
  * V41     : Method C SphereFit (V4.1 detector 이미 계산한 값 재사용)
  * FRUSTUM : V4.1 walls → estimateBladderVolume6ch (Tanaka frustum)
  * METHOD_D: MethodDRunner walls (apply_cross=true) → estimateBladderVolume6ch
             (임상 Python `for_app_share` 파이프라인 1:1)
- 카드 상단: 선택된 method 의 BV 를 26sp ExtraBold 로 크게 표시
- 하단 FilterChip 3 개: method 이름 + 각 method BV 값 요약 (예: "V41: 342")
  chip 탭 시 즉시 큰 숫자 갱신 + GreenZoneConstants.bvMethod 동기 업데이트
  → 다른 화면 (측정 모드) 에도 전역 반영

session log 에는 저장 안 함 (표시 전용). 저장 필요 시 별도 이슈.
This commit is contained in:
2026-07-15 16:51:20 +09:00
parent 909eac9ca3
commit a8ec8e053d
@@ -81,6 +81,17 @@ fun ClinicalLiveView(appState: AppState) {
// V4.1 wall detection — 채널별 ant/post peak 표시용 (사내 테스트 가시화 요청) // V4.1 wall detection — 채널별 ant/post peak 표시용 (사내 테스트 가시화 요청)
val v41 = remember { com.medithings.vesiscan.walldetect.V41Detector() } val v41 = remember { com.medithings.vesiscan.walldetect.V41Detector() }
var channelWalls by remember { mutableStateOf<List<Pair<Int, Int>?>>(List(6) { null }) } var channelWalls by remember { mutableStateOf<List<Pair<Int, Int>?>>(List(6) { null }) }
// BV 실시간 표시 — cycle 마다 3 가지 method 병행 계산 (2026-07-15 사용자 요청).
// V41 = SphereFit (Method C)
// FRUSTUM = legacy analyzer walls + estimateBladderVolume6ch (Tanaka frustum)
// METHOD_D = Method D walls + apply_cross + estimateBladderVolume6ch (임상 Python 1:1)
var bvV41 by remember { mutableStateOf<Double?>(null) }
var bvFrustum by remember { mutableStateOf<Double?>(null) }
var bvMethodD by remember { mutableStateOf<Double?>(null) }
// 사용자가 chip 으로 선택한 표시 method (GreenZoneConstants.bvMethod 와 동기).
var selectedBvMethod by remember {
mutableStateOf(com.medithings.vesiscan.managers.GreenZoneConstants.bvMethod)
}
DisposableEffect(Unit) { DisposableEffect(Unit) {
bleManager.piezoCollector.onMultiChannelComplete = { channels -> bleManager.piezoCollector.onMultiChannelComplete = { channels ->
@@ -109,6 +120,24 @@ fun ClinicalLiveView(appState: AppState) {
val a = r?.antIdx; val p = r?.postIdx val a = r?.antIdx; val p = r?.postIdx
if (a != null && p != null) Pair(a, p) else null if (a != null && p != null) Pair(a, p) else null
} }
// BV[V41] = SphereFit 결과 (V4.1 detector 가 이미 계산).
bvV41 = result.summary.bvDispatch?.bvMl?.toDouble()
// BV[FRUSTUM] = V4.1 walls 를 frustum 알고리즘에 통과.
bvFrustum = com.medithings.vesiscan.managers.estimateBladderVolume6ch(channelWalls)
?.volumeMl?.takeIf { it.isFinite() && it > 0 }
// BV[METHOD_D] = MethodD walls (apply_cross=true) + frustum.
val signalsD = (0..5).map { ch ->
channels.find { it.channel == ch }?.buffer?.let { buf ->
DoubleArray(buf.size) { buf[it].toDouble() }
} ?: DoubleArray(100)
}
val mdResults = com.medithings.vesiscan.walldetect.MethodDRunner
.detectMultichannel(signalsD, applyCross = true)
val mdWalls: List<Pair<Int, Int>?> = mdResults.map { r ->
r?.let { Pair(it.ant, it.post) }
}
bvMethodD = com.medithings.vesiscan.managers.estimateBladderVolume6ch(mdWalls)
?.volumeMl?.takeIf { it.isFinite() && it > 0 }
} }
} }
bleManager.imuCollector.onComplete = { imuSamples -> bleManager.imuCollector.onComplete = { imuSamples ->
@@ -181,6 +210,59 @@ fun ClinicalLiveView(appState: AppState) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// ── BV 실시간 표시 + Method 선택 chip (2026-07-15) ──
// 6ch grid 아래에 세 method (V41/FRUSTUM/METHOD_D) 를 chip 으로 전환하며
// 선택된 method 의 BV 를 큼직하게 표시. session log 에는 저장 안 함 (표시 전용).
val bvByMethod = mapOf(
com.medithings.vesiscan.managers.BvMethod.V41 to bvV41,
com.medithings.vesiscan.managers.BvMethod.FRUSTUM to bvFrustum,
com.medithings.vesiscan.managers.BvMethod.METHOD_D to bvMethodD,
)
val currentBv = bvByMethod[selectedBvMethod]
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("BV", fontSize = 12.sp, color = MlSecondaryText,
fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.width(10.dp))
Text(currentBv?.let { "${"%.1f".format(it)} mL" } ?: "— mL",
fontSize = 26.sp, fontWeight = FontWeight.ExtraBold,
color = MlPrimary)
Spacer(modifier = Modifier.weight(1f))
Text(selectedBvMethod.name, fontSize = 11.sp,
color = MlSecondaryText, fontWeight = FontWeight.Medium)
}
Spacer(modifier = Modifier.height(6.dp))
// Method 전환 chip row — 클릭 시 GreenZoneConstants.bvMethod 도 동기 갱신.
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
com.medithings.vesiscan.managers.BvMethod.values().forEach { m ->
val selected = m == selectedBvMethod
val bv = bvByMethod[m]
FilterChip(
selected = selected,
onClick = {
selectedBvMethod = m
com.medithings.vesiscan.managers.GreenZoneConstants.bvMethod = m
},
label = {
Text(
"${m.name}: ${bv?.let { "%.0f".format(it) } ?: "-"}",
fontSize = 11.sp,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
)
},
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
// ── IMU 정보 ── // ── IMU 정보 ──
if (lastImu.isNotEmpty()) { if (lastImu.isNotEmpty()) {
val posture = remember(lastImu) { ImuAnalysis.estimatePosture(lastImu) } val posture = remember(lastImu) { ImuAnalysis.estimatePosture(lastImu) }