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.Handler
import android.os.Looper import android.os.Looper
import android.util.Log
import com.example.medilightv2android.AppScreen import com.example.medilightv2android.AppScreen
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -20,8 +22,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow 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.Brush
import androidx.compose.ui.graphics.Color 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.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -29,6 +36,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.medilightv2android.AppState import com.example.medilightv2android.AppState
import com.example.medilightv2android.ble.BleManager 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.BladderLevel
import com.example.medilightv2android.models.UrgencyLevel import com.example.medilightv2android.models.UrgencyLevel
import com.example.medilightv2android.ui.components.BladdyRiveView import com.example.medilightv2android.ui.components.BladdyRiveView
@@ -56,6 +67,14 @@ fun PiezoMonitoringView(appState: AppState) {
var showDisconnectionBanner by remember { mutableStateOf(false) } var showDisconnectionBanner by remember { mutableStateOf(false) }
var reconnectionState by remember { mutableStateOf(BleReconnectionState.DISCONNECTED) } var reconnectionState by remember { mutableStateOf(BleReconnectionState.DISCONNECTED) }
var isAutoMeasuring by remember { mutableStateOf(false) } 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 screenWidth = LocalConfiguration.current.screenWidthDp
val isCompact = screenWidth < 400 val isCompact = screenWidth < 400
@@ -68,10 +87,100 @@ fun PiezoMonitoringView(appState: AppState) {
fun measure() { fun measure() {
isMeasuring = true 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({ handler.postDelayed({
appState.incrementLevel() if (!isMeasuring) return@postDelayed
isMeasuring = false if (measureRetryCount < maxMeasureRetries) {
}, 1500) 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() { fun stopAutoMeasure() {
@@ -128,6 +237,7 @@ fun PiezoMonitoringView(appState: AppState) {
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
appState.resetLevel() appState.resetLevel()
resetMeasurement()
showCatheterizeConfirm = false showCatheterizeConfirm = false
}) { Text("Catheterize", color = Color.Red) } }) { Text("Catheterize", color = Color.Red) }
}, },
@@ -429,7 +539,9 @@ fun PiezoMonitoringView(appState: AppState) {
// Info cards // Info cards
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { 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("Fill", String.format("%.0f", appState.fillPercentage), "%", urgency.accentColor, Modifier.weight(1f))
InfoCard("Level", "${appState.bladderLevel.currentLevel}/${BladderLevel.MAX_LEVEL}", "", 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, fontSize = 11.sp,
color = if (isAutoMeasuring) Color.Red.copy(alpha = 0.7f) else MlSecondaryText 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))
} }
} }
} }