feat: Detection/BV 분리 설정 + Spot 5회 trimmedMean + UI 여유

- Detection(A/B/C)과 BV(Frustum/V41) 독립 설정 (개발자 모드)
  기본값: Detection=C, BV=V41
- Spot: 3회 median → 5회 1.5초간격 trimmed mean (Auto와 동일)
  최대 8회 재시도, 최소 유효 3개
- 일반 모드 도넛 320dp, 간격 28dp, 버튼 62dp (화면 여유)
- Placement mm 표시 제거 — 방향만 안내

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 17:28:04 +09:00
parent 1b70ec809d
commit e5b252b314
3 changed files with 71 additions and 27 deletions
@@ -14,10 +14,12 @@ package com.example.medilightv2android.managers
*/
enum class DetectionMethod { METHOD_A, METHOD_B, METHOD_C }
enum class PlacementGuideMode { SIMPLE, BOUNDARY, SWEEP }
enum class BvMethod { FRUSTUM, V41 }
object GreenZoneConstants {
var detectionMethod: DetectionMethod = DetectionMethod.METHOD_C
var bvMethod: BvMethod = BvMethod.V41
var placementGuideMode: PlacementGuideMode = PlacementGuideMode.SIMPLE
// ═══════════════════════════════════════════════════════════
@@ -239,9 +239,14 @@ fun PiezoMonitoringView(appState: AppState) {
}
val rawADC = channels.sortedBy { it.channel }.map { it.buffer }
if (method == com.example.medilightv2android.managers.DetectionMethod.METHOD_C && methodCBvMl != null) {
rawVolumeMl = methodCBvMl
Log.d("PiezoMonitor", "BV[C]: ${"%.1f".format(methodCBvMl)}ml (V4.1, walls=${totalWallCount}/6)")
val bvMethodSetting = com.example.medilightv2android.managers.GreenZoneConstants.bvMethod
val useV41Bv = method == com.example.medilightv2android.managers.DetectionMethod.METHOD_C
&& bvMethodSetting == com.example.medilightv2android.managers.BvMethod.V41
&& methodCBvMl != null
if (useV41Bv) {
rawVolumeMl = methodCBvMl!!
Log.d("PiezoMonitor", "BV[C-V41]: ${"%.1f".format(methodCBvMl)}ml (SphereFit, walls=${totalWallCount}/6)")
bleManager.debugLogger.measurementResult(methodCBvMl, 1.0, totalWallCount,
(0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 })
logService.logMeasurement(methodCBvMl, channelLogs, rawADC)
@@ -744,7 +749,10 @@ fun PiezoMonitoringView(appState: AppState) {
.padding(horizontal = hPad)
.padding(top = if (isCompact) 8.dp else 16.dp, bottom = if (isCompact) 16.dp else 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(if (isCompact) 12.dp else 20.dp)
verticalArrangement = if (appState.isDevMode)
Arrangement.spacedBy(if (isCompact) 12.dp else 20.dp)
else
Arrangement.spacedBy(if (isCompact) 20.dp else 28.dp)
) {
// Status badge
@@ -760,7 +768,11 @@ fun PiezoMonitoringView(appState: AppState) {
)
// Donut chart with Bladdy
val donutSize = if (isCompact) 220.dp else 280.dp
val donutSize = if (appState.isDevMode) {
if (isCompact) 220.dp else 280.dp
} else {
if (isCompact) 260.dp else 320.dp // 일반 모드: 더 크게
}
// maxVolume 기준 비율 (0.0~1.0, 부드러운 연속값)
val maxVolumeForDonut = appState.piezoSettings.maxVolume.toDouble()
val donutProgress = if (maxVolumeForDonut > 0 && maxVolumeMl > 0)
@@ -795,16 +807,17 @@ fun PiezoMonitoringView(appState: AppState) {
color = Color(0xFFFF9800))
}
} else {
val volFontSize = if (appState.isDevMode) 18.sp else 22.sp
Text(
if (maxVolumeMl > 0) "${"%.0f".format(maxVolumeMl)} ml" else "${appState.estimatedVolume} ml",
fontSize = 18.sp,
fontSize = volFontSize,
fontWeight = FontWeight.Bold,
color = urgency.accentColor,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(MlCardBackground)
.shadow(4.dp, RoundedCornerShape(12.dp), spotColor = Color.Black.copy(alpha = 0.08f))
.padding(horizontal = 14.dp, vertical = 6.dp)
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
@@ -822,11 +835,12 @@ fun PiezoMonitoringView(appState: AppState) {
}
// Control buttons: Void/Catheterize / Auto / Spot
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val btnHeight = if (appState.isDevMode) 56.dp else 62.dp
Row(horizontalArrangement = Arrangement.spacedBy(if (appState.isDevMode) 8.dp else 10.dp)) {
// Void / Catheterize
Button(
onClick = { showCatheterizeSheet = true },
modifier = Modifier.weight(1.3f).height(56.dp),
modifier = Modifier.weight(1.3f).height(btnHeight),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary.copy(alpha = 0.1f))
) {
@@ -841,7 +855,7 @@ fun PiezoMonitoringView(appState: AppState) {
onClick = {
if (isAutoMeasuring) stopAutoMeasure() else startAutoMeasure()
},
modifier = Modifier.weight(1f).height(56.dp),
modifier = Modifier.weight(1f).height(btnHeight),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (isAutoMeasuring) Color.Red else Color(0xFFFF9800)
@@ -854,7 +868,7 @@ fun PiezoMonitoringView(appState: AppState) {
}
}
// Spot (3회 측정 → median, 중간 값 화면에 안 보임)
// Spot (5회 1.5초 간격 → trimmed mean, 오류시 재시도)
var spotCooldown by remember { mutableStateOf(false) }
val spotScope = rememberCoroutineScope()
Button(
@@ -864,26 +878,23 @@ fun PiezoMonitoringView(appState: AppState) {
spotCooldown = true
spotVolumes.clear()
spotScope.launch {
// 최대 6회 시도, 유효값 3개 수집 목표
val maxAttempts = 6
val targetCount = 3
val maxAttempts = 8
val targetCount = 5
var attempts = 0
while (spotVolumes.size < targetCount && attempts < maxAttempts) {
attempts++
measure()
while (isMeasuring) { kotlinx.coroutines.delay(100) }
kotlinx.coroutines.delay(300) // 간격 여유 (BLE 겹침 방지)
kotlinx.coroutines.delay(1500)
}
Log.d("PiezoMonitor", "SPOT attempts=$attempts results=${spotVolumes.size}")
// 콜백에서 spotVolumes에 수집됨 (화면 갱신 없이)
if (spotVolumes.size >= 2) {
spotVolumes.sort()
val median = spotVolumes[spotVolumes.size / 2]
lastVolumeMl = median
maxVolumeMl = maxOf(maxVolumeMl, median)
Log.d("PiezoMonitor", "SPOT median=${"%.1f".format(median)}ml from ${spotVolumes.map { "%.0f".format(it) }}")
if (spotVolumes.size >= 3) {
val result = trimmedMean(spotVolumes.toList())
lastVolumeMl = result
maxVolumeMl = maxOf(maxVolumeMl, result)
Log.d("PiezoMonitor", "SPOT trimmedMean=${"%.1f".format(result)}ml from ${spotVolumes.map { "%.0f".format(it) }}")
val maxVol = appState.piezoSettings.maxVolume.toDouble()
val pct = if (maxVol > 0) (median / maxVol).coerceAtMost(1.0) else 0.0
val pct = if (maxVol > 0) (result / maxVol).coerceAtMost(1.0) else 0.0
val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(1, BladderLevel.MAX_LEVEL)
appState.bladderLevel = appState.bladderLevel.copy(currentLevel = level)
}
@@ -894,7 +905,7 @@ fun PiezoMonitoringView(appState: AppState) {
}
},
enabled = !isMeasuring && !isAutoMeasuring && !spotCooldown && !isSpotInProgress,
modifier = Modifier.weight(1f).height(56.dp),
modifier = Modifier.weight(1f).height(btnHeight),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(
containerColor = urgency.gradientColors.first()
@@ -1080,6 +1091,37 @@ fun PiezoMonitoringView(appState: AppState) {
HorizontalDivider()
// BV Method (Frustum / V41)
var selectedBvMethod by remember { mutableStateOf(com.example.medilightv2android.managers.GreenZoneConstants.bvMethod) }
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("BV", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.weight(1f))
val bvMethods = listOf(
com.example.medilightv2android.managers.BvMethod.FRUSTUM to "Frustum",
com.example.medilightv2android.managers.BvMethod.V41 to "V41"
)
val bvColors = listOf(MlPrimary, Color(0xFFFF5722))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
bvMethods.forEachIndexed { idx, (m, label) ->
val sel = selectedBvMethod == m
Button(
onClick = { selectedBvMethod = m; com.example.medilightv2android.managers.GreenZoneConstants.bvMethod = m },
modifier = Modifier.height(32.dp),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (sel) bvColors[idx] else Color.Gray.copy(alpha = 0.15f)
),
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp)
) {
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold,
color = if (sel) Color.White else MlSecondaryText)
}
}
}
}
HorizontalDivider()
// SG Filter
var sgEnabled by remember { mutableStateOf(analyzer.useSgFilter) }
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
@@ -1305,15 +1305,15 @@ private fun computePlacementGuide(
val absOff = kotlin.math.abs(lateralOffsetMm)
if (absOff <= 8.0) {
return PlacementGuideResult(
"LR centred (${"%.0f".format(absOff)}mm). Final check...",
"Final check...",
"checkmark.circle", 60,
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.GREEN)
}
return if (lateralOffsetMm > 0) {
PlacementGuideResult("Move right ${"%.0f".format(absOff)}mm →", "arrow.right", 45,
PlacementGuideResult("Move right →", "arrow.right", 45,
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL)
} else {
PlacementGuideResult("Move left ${"%.0f".format(absOff)}mm ←", "arrow.left", 45,
PlacementGuideResult("Move left ←", "arrow.left", 45,
phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL)
}
}