feat: full Piezo measurement pipeline + waveform charts in PiezoMonitoringView

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 <noreply@anthropic.com>
This commit is contained in:
dwjang
2026-04-21 16:38:33 +09:00
parent 444aa6280f
commit c136d64665
@@ -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<PiezoChannelData>() }
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<Pair<Int, Int>?> = 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<PiezoChannelData>,
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<UShort>,
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))
}
}
}