From c136d646657677f86e3e29b27aaf7472d01d0663 Mon Sep 17 00:00:00 2001 From: dwjang Date: Tue, 21 Apr 2026 16:38:33 +0900 Subject: [PATCH] feat: full Piezo measurement pipeline + waveform charts in PiezoMonitoringView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major update matching iOS PiezoMonitoringView: measure() — real BLE measurement via maa: - sendAllChannels → piezoCollector → analyzeMultiChannel - Build walls → estimateBladderVolume (Frustum) - Auto-retry on timeout (max 2, 15s each) - Volume-based bladder level update UI additions: - Max Volume info card (orange) - Volume card shows measured value - Developer Tools (collapsible): - 6-Channel Analysis with Measure button - Zone indicator (GOOD/PARTIAL/ADJUST) - Channel status bar (green/red, urine length) - Per-channel waveform charts (Canvas): - Raw (light blue) + Denoised (blue) overlay - Threshold line (1600, orange dashed) - Low-echo span (yellow) - Urine region (cyan) with ant(green)/post(red) markers - Y-axis 0-2500 fixed, X-axis depth in mm resetMeasurement() on catheterize Co-Authored-By: Claude Opus 4.6 --- .../views/monitoring/PiezoMonitoringView.kt | 431 +++++++++++++++++- 1 file changed, 427 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index 80625e2..dce03d6 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -2,8 +2,10 @@ package com.example.medilightv2android.ui.views.monitoring import android.os.Handler import android.os.Looper +import android.util.Log import com.example.medilightv2android.AppScreen import androidx.compose.material3.OutlinedTextField +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -20,8 +22,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight @@ -29,6 +36,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.medilightv2android.AppState import com.example.medilightv2android.ble.BleManager +import com.example.medilightv2android.ble.PiezoChannelData +import com.example.medilightv2android.managers.PiezoEchoAnalyzer +import com.example.medilightv2android.managers.PiezoConstants +import com.example.medilightv2android.managers.estimateBladderVolume import com.example.medilightv2android.models.BladderLevel import com.example.medilightv2android.models.UrgencyLevel import com.example.medilightv2android.ui.components.BladdyRiveView @@ -56,6 +67,14 @@ fun PiezoMonitoringView(appState: AppState) { var showDisconnectionBanner by remember { mutableStateOf(false) } var reconnectionState by remember { mutableStateOf(BleReconnectionState.DISCONNECTED) } var isAutoMeasuring by remember { mutableStateOf(false) } + var showDevPanels by remember { mutableStateOf(false) } + var isPlacementTesting by remember { mutableStateOf(false) } + var measureRetryCount by remember { mutableIntStateOf(0) } + val maxMeasureRetries = 2 + var lastVolumeMl by remember { mutableStateOf(0.0) } + var maxVolumeMl by remember { mutableStateOf(0.0) } + val lastMultiChannelResult = remember { mutableStateListOf() } + val analyzer = remember { PiezoEchoAnalyzer.shared } val screenWidth = LocalConfiguration.current.screenWidthDp val isCompact = screenWidth < 400 @@ -68,10 +87,100 @@ fun PiezoMonitoringView(appState: AppState) { fun measure() { isMeasuring = true + + // Simulation / demo mode — just increment level + if (isDemoMode) { + handler.postDelayed({ + appState.incrementLevel() + isMeasuring = false + }, 500) + return + } + + // Real BLE measurement — 6-channel + Log.d("PiezoMonitor", "Sending maa (all channels)...") + + bleManager.piezoCollector.onMultiChannelComplete = { channels -> + handler.post { + lastMultiChannelResult.clear() + lastMultiChannelResult.addAll(channels) + + val validChannels = channels.filter { it.isValid } + if (validChannels.isNotEmpty()) { + val analysisResult = analyzer.analyzeMultiChannel(validChannels) + + // Build walls list for BV estimation (5 channels) + val walls: MutableList?> = MutableList(5) { null } + for (ch in analysisResult.channels) { + if (ch.channel < 5 && ch.result != null) { + walls[ch.channel] = Pair(ch.result.ant, ch.result.post) + } + } + + // Frustum BV estimation + val bvResult = estimateBladderVolume(walls = walls) + if (bvResult != null) { + lastVolumeMl = bvResult.volumeMl + maxVolumeMl = maxOf(maxVolumeMl, bvResult.volumeMl) + Log.d("PiezoMonitor", "BV: ${"%.1f".format(bvResult.volumeMl)}ml (frustum, ${bvResult.validChannels.size}ch)") + } else { + Log.d("PiezoMonitor", "BV: estimation failed (< 2 valid channels)") + } + + // Log per-channel detail + for (ch in analysisResult.channels) { + if (ch.isValid && ch.result != null) { + Log.d("PiezoMonitor", " CH${ch.channel}: ant=${ch.result.ant} post=${ch.result.post} urine=${ch.result.urineLen} score=${"%.1f".format(ch.result.score)}") + } + } + } else { + Log.d("PiezoMonitor", "No valid channels — volume unchanged") + } + + // Update level based on measured volume + if (lastVolumeMl > 0) { + val maxVol = appState.piezoSettings.maxVolume.toDouble() + val pct = if (maxVol > 0) (lastVolumeMl / maxVol).coerceAtMost(1.0) else 0.0 + val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(1, BladderLevel.MAX_LEVEL) + appState.bladderLevel.currentLevel = level + } + isMeasuring = false + measureRetryCount = 0 + } + } + bleManager.piezoCollector.onLog = { msg -> Log.d("PiezoCollector", msg) } + + bleManager.sendAllChannels() + + // Timeout with auto-retry (15s) handler.postDelayed({ - appState.incrementLevel() - isMeasuring = false - }, 1500) + if (!isMeasuring) return@postDelayed + if (measureRetryCount < maxMeasureRetries) { + measureRetryCount++ + Log.w("PiezoMonitor", "Timeout — auto-retry $measureRetryCount/$maxMeasureRetries") + bleManager.piezoCollector.reset() + bleManager.sendAllChannels() + // Second timeout for retry + handler.postDelayed({ + if (!isMeasuring) return@postDelayed + Log.w("PiezoMonitor", "Retry $measureRetryCount also timed out") + isMeasuring = false + measureRetryCount = 0 + }, 15_000) + } else { + Log.e("PiezoMonitor", "Measurement failed after $maxMeasureRetries retries") + isMeasuring = false + measureRetryCount = 0 + } + }, 15_000) + } + + fun resetMeasurement() { + lastVolumeMl = 0.0 + maxVolumeMl = 0.0 + lastMultiChannelResult.clear() + measureRetryCount = 0 + isMeasuring = false } fun stopAutoMeasure() { @@ -128,6 +237,7 @@ fun PiezoMonitoringView(appState: AppState) { confirmButton = { TextButton(onClick = { appState.resetLevel() + resetMeasurement() showCatheterizeConfirm = false }) { Text("Catheterize", color = Color.Red) } }, @@ -429,7 +539,9 @@ fun PiezoMonitoringView(appState: AppState) { // Info cards Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - InfoCard("Volume", "${appState.estimatedVolume}", "ml", urgency.accentColor, Modifier.weight(1f)) + val displayVol = if (lastVolumeMl > 0) "%.0f".format(lastVolumeMl) else "${appState.estimatedVolume}" + InfoCard("Volume", displayVol, "ml", urgency.accentColor, Modifier.weight(1f)) + InfoCard("Max Vol", if (maxVolumeMl > 0) "%.0f".format(maxVolumeMl) else "—", "ml", Color(0xFFFF9500), Modifier.weight(1f)) InfoCard("Fill", String.format("%.0f", appState.fillPercentage), "%", urgency.accentColor, Modifier.weight(1f)) InfoCard("Level", "${appState.bladderLevel.currentLevel}/${BladderLevel.MAX_LEVEL}", "", urgency.accentColor, Modifier.weight(1f)) } @@ -493,6 +605,317 @@ fun PiezoMonitoringView(appState: AppState) { fontSize = 11.sp, color = if (isAutoMeasuring) Color.Red.copy(alpha = 0.7f) else MlSecondaryText ) + + // ── Developer Tools toggle ── + Button( + onClick = { showDevPanels = !showDevPanels }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color.Gray.copy(alpha = 0.7f)) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon(Icons.Default.Build, "Dev", tint = Color.White, modifier = Modifier.size(14.dp)) + Text("Developer Tools", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color.White) + Spacer(modifier = Modifier.weight(1f)) + Icon( + if (showDevPanels) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + "Toggle", tint = Color.White, modifier = Modifier.size(14.dp) + ) + } + } + + if (showDevPanels) { + ChannelPanel( + lastMultiChannelResult = lastMultiChannelResult, + analyzer = analyzer, + isPlacementTesting = isPlacementTesting, + onMeasure = { + if (!isPlacementTesting && !isMeasuring) { + isPlacementTesting = true + measure() + // Reset placement flag when measurement completes + handler.postDelayed({ isPlacementTesting = false }, 20_000) + } + } + ) + } + } + } +} + +// ── 6-Channel Analysis Panel ── + +@Composable +private fun ChannelPanel( + lastMultiChannelResult: List, + analyzer: PiezoEchoAnalyzer, + isPlacementTesting: Boolean, + onMeasure: () -> Unit +) { + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = MlCardBackground), + modifier = Modifier + .fillMaxWidth() + .shadow(4.dp, RoundedCornerShape(12.dp), spotColor = Color.Black.copy(alpha = 0.04f)) + ) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Header + Row(verticalAlignment = Alignment.CenterVertically) { + Text("6-Channel Analysis", fontSize = 13.sp, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = onMeasure, + enabled = !isPlacementTesting, + modifier = Modifier.height(28.dp), + shape = RoundedCornerShape(6.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (isPlacementTesting) Color.Gray else MlPrimary + ), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp) + ) { + Text( + if (isPlacementTesting) "Measuring..." else "Measure", + fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = Color.White + ) + } + } + + if (lastMultiChannelResult.isEmpty()) { + // Empty state + Column( + modifier = Modifier.fillMaxWidth().height(80.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon(Icons.Default.GraphicEq, "Sensor", tint = Color.Gray.copy(alpha = 0.3f), modifier = Modifier.size(32.dp)) + Spacer(modifier = Modifier.height(4.dp)) + Text("Tap Measure or Spot to capture 6-channel data", fontSize = 11.sp, color = Color.Gray) + } + } else { + // Zone indicator + val analyses = lastMultiChannelResult.map { analyzer.analyzeChannel(it.buffer, it.channel) } + val detectedCount = analyses.count { it.isValid } + val zoneColor = when { + detectedCount >= 4 -> Color.Green + detectedCount >= 2 -> Color.Yellow + else -> Color.Red + } + val zoneText = when { + detectedCount >= 4 -> "GOOD ($detectedCount/6)" + detectedCount >= 2 -> "PARTIAL ($detectedCount/6)" + else -> "ADJUST POSITION" + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(zoneColor.copy(alpha = 0.1f)) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box(modifier = Modifier.size(14.dp).clip(CircleShape).background(zoneColor)) + Text(zoneText, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = zoneColor) + } + + // Channel status bar (6 boxes) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + for (ch in 0 until 6) { + val chAnalysis = analyses.firstOrNull { it.channel == ch } + val isValid = chAnalysis?.isValid == true + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(24.dp) + .clip(RoundedCornerShape(4.dp)) + .background(if (isValid) Color.Green else Color.Red.copy(alpha = 0.4f)), + contentAlignment = Alignment.Center + ) { + val label = chAnalysis?.result?.let { "u=${it.urineLen}" } ?: "—" + Text(label, fontSize = 7.sp, fontWeight = FontWeight.Bold, color = Color.White) + } + Text("CH$ch", fontSize = 8.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText) + } + } + } + + // Per-channel waveforms + for (chData in lastMultiChannelResult.filter { it.buffer.isNotEmpty() }) { + val chAnalysis = analyzer.analyzeChannel(chData.buffer, chData.channel) + + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + // Channel header + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (chAnalysis.isValid) Color.Green else Color.Gray.copy(alpha = 0.4f)) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("CH${chData.channel}", fontSize = 12.sp, fontWeight = FontWeight.Bold) + + if (chAnalysis.result != null) { + val r = chAnalysis.result + Spacer(modifier = Modifier.width(6.dp)) + Text( + "urine idx ${r.ant}–${r.post}", + fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = Color.Cyan + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + "(${"%.0f".format(r.antDepthMm)}–${"%.0f".format(r.postDepthMm)}mm)", + fontSize = 9.sp, color = MlSecondaryText + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + "len=${r.urineLen}", + fontSize = 9.sp, fontWeight = FontWeight.SemiBold, color = Color.Cyan + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + Text("no urine", fontSize = 9.sp, color = Color.Gray) + } + } + + // Waveform chart + WaveformChart( + buffer = chData.buffer, + peakIndex = chData.peakIndex.toInt(), + analyzer = analyzer, + antIndex = chAnalysis.result?.ant, + postIndex = chAnalysis.result?.post, + lowStart = chAnalysis.result?.lowStart, + lowEnd = chAnalysis.result?.lowEnd, + modifier = Modifier.fillMaxWidth().height(120.dp) + ) + } + + if (chData.channel < 5) { + HorizontalDivider(color = Color.Gray.copy(alpha = 0.3f)) + } + } + } + } + } +} + +// ── Waveform Chart (Canvas-based) ── + +@Composable +private fun WaveformChart( + buffer: List, + peakIndex: Int, + analyzer: PiezoEchoAnalyzer, + antIndex: Int?, + postIndex: Int?, + lowStart: Int?, + lowEnd: Int?, + modifier: Modifier = Modifier +) { + val count = buffer.size + if (count < 2) return + + val raw = buffer.map { it.toDouble() }.toDoubleArray() + val denoised = analyzer.denoise(raw) + val maxVal = 2500f + val threshold = analyzer.lowEchoAmpDefault.toFloat() + + Canvas(modifier = modifier) { + val w = size.width + val h = size.height + val stepX = w / (count - 1).toFloat() + + // Background grid (4 horizontal lines) + for (i in 0..4) { + val y = h * i / 4f + drawLine( + color = Color.Gray.copy(alpha = 0.15f), + start = Offset(0f, y), end = Offset(w, y), + strokeWidth = 0.5f + ) + } + + // Threshold line (orange dashed at 1600) + val threshY = h - (threshold / maxVal * h) + drawLine( + color = Color(0xFFFF9500), + start = Offset(0f, threshY), end = Offset(w, threshY), + strokeWidth = 1f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 6f)) + ) + + // Low-echo span highlight (yellow) + if (lowStart != null && lowEnd != null && lowEnd > lowStart) { + val lsX = lowStart * stepX + val leX = lowEnd * stepX + drawRect( + color = Color.Yellow.copy(alpha = 0.15f), + topLeft = Offset(lsX, 0f), + size = Size((leX - lsX).coerceAtLeast(1f), h) + ) + } + + // Urine region shading (cyan) + if (antIndex != null && postIndex != null && antIndex >= 0 && postIndex > antIndex && postIndex < count) { + val antX = antIndex * stepX + val postX = postIndex * stepX + drawRect( + color = Color.Cyan.copy(alpha = 0.15f), + topLeft = Offset(antX, 0f), + size = Size((postX - antX).coerceAtLeast(1f), h) + ) + } + + // Raw waveform (light blue) + val rawPath = Path() + for (i in raw.indices) { + val x = i * stepX + val y = h - (raw[i].toFloat() / maxVal * h) + if (i == 0) rawPath.moveTo(x, y) else rawPath.lineTo(x, y) + } + drawPath(rawPath, color = Color.Blue.copy(alpha = 0.3f), style = Stroke(width = 1.5f)) + + // Denoised waveform (solid blue) + val denoisedPath = Path() + for (i in denoised.indices) { + val x = i * stepX + val y = h - (denoised[i].toFloat() / maxVal * h) + if (i == 0) denoisedPath.moveTo(x, y) else denoisedPath.lineTo(x, y) + } + drawPath(denoisedPath, color = Color.Blue, style = Stroke(width = 2.5f)) + + // Anterior wall marker (green line) + if (antIndex != null && antIndex in 0 until count) { + val antX = antIndex * stepX + drawLine(color = Color.Green, start = Offset(antX, 0f), end = Offset(antX, h), strokeWidth = 3f) + val antY = h - (denoised.getOrElse(antIndex) { 0.0 }.toFloat() / maxVal * h) + drawCircle(color = Color.Green, radius = 5f, center = Offset(antX, antY)) + } + + // Posterior wall marker (red line) + if (postIndex != null && postIndex in 0 until count) { + val postX = postIndex * stepX + drawLine(color = Color.Red, start = Offset(postX, 0f), end = Offset(postX, h), strokeWidth = 3f) + val postY = h - (denoised.getOrElse(postIndex) { 0.0 }.toFloat() / maxVal * h) + drawCircle(color = Color.Red, radius = 5f, center = Offset(postX, postY)) + } else if (peakIndex in 0 until count) { + // Fallback: show peak dot if no walls + val px = peakIndex * stepX + val py = h - (raw[peakIndex].toFloat() / maxVal * h) + drawCircle(color = Color.Red, radius = 4f, center = Offset(px, py)) } } }