diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f58ed7b..7222b01 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,17 @@ "Bash(git push -u origin master)", "Bash(git pull:*)", "Bash(./gradlew assembleDebug)", - "Bash(xargs grep:*)" + "Bash(xargs grep:*)", + "Bash(./gradlew compileDebugKotlin)", + "Bash(git add:*)", + "Bash(git reset:*)", + "Bash(git commit -m ':*)", + "Bash(git push:*)", + "Bash(pip install:*)", + "Read(//c/Users/장동우/Downloads/**)", + "Bash(python3 -c ':*)", + "Bash(python -c ':*)", + "Bash(./gradlew assembleDebug --rerun-tasks)" ] } } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2a1e34d..89c3d6f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.example.medilightv2android" minSdk = 24 targetSdk = 36 - versionCode = 1 - versionName = "1.0" + versionCode = 2 + versionName = "VBTAND0101" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/home/HomeView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/home/HomeView.kt index ba07ce0..462c7a5 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/home/HomeView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/home/HomeView.kt @@ -70,6 +70,11 @@ fun HomeView(appState: AppState) { Text("Smart Bladder Monitoring", fontSize = 16.sp, color = MlSecondaryText) + Spacer(modifier = Modifier.height(4.dp)) + + val versionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName + Text(versionName ?: "", fontSize = 12.sp, color = MlSecondaryText.copy(alpha = 0.5f)) + Spacer(modifier = Modifier.weight(1f)) Button( 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 a3747e5..db55e43 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 @@ -85,6 +85,7 @@ fun PiezoMonitoringView(appState: AppState) { val spotVolumes = remember { mutableListOf() } var measureDoneSignal = remember { kotlinx.coroutines.CompletableDeferred() } val lastMultiChannelResult = remember { mutableStateListOf() } + var lastAllWalls by remember { mutableStateOf?>>(List(6) { null }) } val analyzer = remember { PiezoEchoAnalyzer.shared } val screenWidth = LocalConfiguration.current.screenWidthDp @@ -156,6 +157,7 @@ fun PiezoMonitoringView(appState: AppState) { var rawVolumeMl: Double? = null var rawLrRatio = 1.0 var totalWallCount = 0 + val allWalls: MutableList?> = MutableList(6) { null } val validChannels = channels.filter { it.isValid } val allChannelsReceived = channels.filter { it.buffer.size >= 20 }.size >= 6 @@ -163,7 +165,6 @@ fun PiezoMonitoringView(appState: AppState) { val method = com.example.medilightv2android.managers.GreenZoneConstants.detectionMethod val analyzerA = com.example.medilightv2android.managers.PiezoEchoAnalyzerA.shared - val allWalls: MutableList?> = MutableList(6) { null } var methodCBvMl: Double? = null val methodLabel: String @@ -244,6 +245,21 @@ fun PiezoMonitoringView(appState: AppState) { } val rawADC = channels.sortedBy { it.channel }.map { it.buffer } + // Cross-channel urine length outlier 제거 + // median의 1.4배 초과 또는 0.6배 미만 채널은 outlier → null 처리 + val urineLensAll = (0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 } + val validLensAll = urineLensAll.filter { it > 0 } + if (validLensAll.size >= 3) { + val medianLen = validLensAll.sorted()[validLensAll.size / 2] + for (ch in 0..5) { + val len = urineLensAll[ch] + if (len > 0 && (len > medianLen * 1.35 || len < medianLen * 0.5)) { + Log.w("PiezoMonitor", "CH$ch urine=$len OUTLIER (median=$medianLen) → removed") + allWalls[ch] = null + } + } + } + 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 @@ -281,6 +297,8 @@ fun PiezoMonitoringView(appState: AppState) { com.example.medilightv2android.services.AdcCsvLogger.log(failADC, null) } + // allWalls를 UI state에 저장 (ChannelPanel에서 사용) + handler.post { lastAllWalls = allWalls.toList() } val finalRawVol = rawVolumeMl val finalTotalWalls = totalWallCount val analysisMs = System.currentTimeMillis() - analysisStart @@ -788,11 +806,11 @@ fun PiezoMonitoringView(appState: AppState) { size = donutSize ) Column(horizontalAlignment = Alignment.CenterHorizontally) { - // Bladdy 크기: 일반 모드에서 더 크게 + // Bladdy 크기: 일반 모드에서 크게 val bladdySize = if (appState.isDevMode) { if (isCompact) 80.dp else 110.dp } else { - if (isCompact) 120.dp else 150.dp // 원 지름의 ~1/2 + if (isCompact) 180.dp else 220.dp } BladdyRiveView(urgency = urgency, size = bladdySize) Spacer(modifier = Modifier.height(4.dp)) @@ -927,6 +945,7 @@ fun PiezoMonitoringView(appState: AppState) { ChannelPanel( lastMultiChannelResult = lastMultiChannelResult, analyzer = analyzer, + allWalls = lastAllWalls, isPlacementTesting = isPlacementTesting, onMeasure = { if (!isPlacementTesting && !isMeasuring) { @@ -1173,6 +1192,7 @@ fun PiezoMonitoringView(appState: AppState) { @Composable private fun ChannelPanel( lastMultiChannelResult: List, + allWalls: List?> = List(6) { null }, analyzer: PiezoEchoAnalyzer, isPlacementTesting: Boolean, onMeasure: () -> Unit @@ -1272,7 +1292,9 @@ private fun ChannelPanel( .background(if (isValid) Color.Green else Color.Red.copy(alpha = 0.4f)), contentAlignment = Alignment.Center ) { - val label = chAnalysis?.result?.let { "u=${it.urineLen}" } ?: "—" + // allWalls의 실제 BV 계산값 표시 (post-ant) + val wallLen = allWalls.getOrNull(ch)?.let { it.second - it.first } + val label = wallLen?.let { "u=$it" } ?: "—" Text(label, fontSize = 7.sp, fontWeight = FontWeight.Bold, color = Color.White) } Text("CH$ch", fontSize = 8.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText) @@ -1287,33 +1309,39 @@ private fun ChannelPanel( else -> analyzer.analyzeChannel(chData.buffer, chData.channel) } + // 실제 BV 계산에 사용된 allWalls 값으로 표시 + val wall = allWalls.getOrNull(chData.channel) + val antIdx = wall?.first ?: chAnalysis.result?.ant + val postIdx = wall?.second ?: chAnalysis.result?.post + val wallLen = if (wall != null) wall.second - wall.first else null + 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)) + .background(if (wall != null) 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 + if (antIdx != null && postIdx != null) { Spacer(modifier = Modifier.width(6.dp)) Text( - "urine idx ${r.ant}–${r.post}", + "urine idx ${antIdx}–${postIdx}", fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = Color.Cyan ) Spacer(modifier = Modifier.width(4.dp)) + val dps = com.example.medilightv2android.managers.PiezoHW.distancePerSample + val offset = com.example.medilightv2android.managers.PiezoHW.delayOffsetMm Text( - "(${"%.0f".format(r.antDepthMm)}–${"%.0f".format(r.postDepthMm)}mm)", + "(${"%.0f".format(offset + antIdx * dps)}–${"%.0f".format(offset + postIdx * dps)}mm)", fontSize = 9.sp, color = MlSecondaryText ) Spacer(modifier = Modifier.weight(1f)) Text( - "len=${r.urineLen}", + "len=${wallLen ?: (postIdx - antIdx)}", fontSize = 9.sp, fontWeight = FontWeight.SemiBold, color = Color.Cyan ) } else { @@ -1322,13 +1350,12 @@ private fun ChannelPanel( } } - // Waveform chart WaveformChart( buffer = chData.buffer, peakIndex = chData.peakIndex.toInt(), analyzer = analyzer, - antIndex = chAnalysis.result?.ant, - postIndex = chAnalysis.result?.post, + antIndex = antIdx, + postIndex = postIdx, lowStart = chAnalysis.result?.lowStart, lowEnd = chAnalysis.result?.lowEnd, modifier = Modifier.fillMaxWidth().height(120.dp) diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index 1659a89..a8efd10 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -167,6 +167,7 @@ fun PlacementGuideView(appState: AppState) { Column( modifier = Modifier .fillMaxSize() + .then(if (appState.isDevMode) Modifier.verticalScroll(rememberScrollState()) else Modifier) .padding(horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { @@ -201,7 +202,7 @@ fun PlacementGuideView(appState: AppState) { Box( modifier = Modifier .fillMaxWidth() - .weight(1f), + .then(if (appState.isDevMode) Modifier.height(400.dp) else Modifier.weight(1f)), contentAlignment = Alignment.Center ) { // 하복부 실루엣 (Canvas — CKLaw body silhouette) @@ -354,9 +355,9 @@ fun PlacementGuideView(appState: AppState) { painter = androidx.compose.ui.res.painterResource(com.example.medilightv2android.R.drawable.sensor_device), contentDescription = "Sensor", modifier = Modifier - .size(width = 200.dp, height = 125.dp) + .size(width = 240.dp, height = 150.dp) .align(Alignment.Center) - .offset(x = 5.dp, y = 115.dp), + .offset(x = 0.dp, y = 80.dp), contentScale = androidx.compose.ui.layout.ContentScale.Fit, alpha = if (isGreenZone) 0.7f else 0.95f ) @@ -402,23 +403,21 @@ fun PlacementGuideView(appState: AppState) { } - // ── 가이드 텍스트 (일러스트 아래) ── - if (directionHint.isNotEmpty()) { - val hintGreen = isGreenZone - val hintBg = if (hintGreen) Color(0xFF4CAF50).copy(alpha = 0.12f) else Color(0xFFFFF3E0) - val hintFg = if (hintGreen) Color(0xFF4CAF50) else Color(0xFFFF9800) - Text( - text = directionHint, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - color = hintFg, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .background(hintBg, RoundedCornerShape(12.dp)) - .padding(12.dp) - ) - } + // ── 가이드 텍스트 (일러스트 아래, 항상 표시) ── + val hintGreen = isGreenZone + val hintBg = if (hintGreen) Color(0xFF4CAF50).copy(alpha = 0.12f) else Color(0xFFFFF3E0) + val hintFg = if (hintGreen) Color(0xFF4CAF50) else Color(0xFFFF9800) + Text( + text = directionHint.ifEmpty { " " }, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = hintFg, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .background(hintBg, RoundedCornerShape(12.dp)) + .padding(12.dp) + ) Spacer(modifier = Modifier.height(8.dp)) @@ -1027,8 +1026,15 @@ data class PlacementGuideResult( val nextPhase: PlacementPhase = PlacementPhase.VERTICAL ) -private const val DEFAULT_CV_THR = 0.12 -private const val DEFAULT_LR_DEV_THR = 0.35 +// CV threshold는 기기 preset에 따라 다름 +private val DEFAULT_CV_THR: Double + get() = when (com.example.medilightv2android.managers.PiezoHW.activePreset) { + com.example.medilightv2android.managers.PiezoHW.DevicePreset.V0 -> 0.07 + com.example.medilightv2android.managers.PiezoHW.DevicePreset.V1, + com.example.medilightv2android.managers.PiezoHW.DevicePreset.V2 -> 0.10 + else -> 0.08 + } +private const val DEFAULT_LR_DEV_THR = 0.20 /** * Gradient 기반 Placement Guide @@ -1130,7 +1136,7 @@ private fun computePlacementGuide( val ch4ok = detected.getOrElse(4) { false } val ch5ok = detected.getOrElse(5) { false } // 좌우도 이미 균등하면 바로 GREEN - if (ch4ok && ch5ok && kotlin.math.abs(l4 - l5) <= 10) { + if (ch4ok && ch5ok && kotlin.math.abs(l4 - l5) <= 5) { PlacementGuideResult("Position OK! Final check...", "checkmark.circle", 90, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.GREEN) @@ -1205,7 +1211,7 @@ private fun computePlacementGuide( val gradient = weightedCenter - 1.5 // 양수 = 아래쪽 무거움, 음수 = 위쪽 무거움 // gradient threshold: 균등하다고 볼 수 있는 범위 - val gradientThr = 0.4 // |gradient| < 0.4 → 상하 균등 + val gradientThr = 0.3 // |gradient| < 0.3 → 상하 균등 // 추가 조건: variation (max-min)/max 도 체크 val validLens = detectedLens.map { it.second } @@ -1217,7 +1223,7 @@ private fun computePlacementGuide( "VERTICAL gradient=%.2f (center=%.2f) variation=%.2f detect=$centerDetected lens=$centerLens" .format(gradient, weightedCenter, lenVariation)) - if (kotlin.math.abs(gradient) < gradientThr && lenVariation < 0.5) { + if (kotlin.math.abs(gradient) < gradientThr && lenVariation < 0.35) { // 상하 OK → 좌우 확인 val l4 = urineLens.getOrElse(4) { 0 } val l5 = urineLens.getOrElse(5) { 0 } @@ -1225,7 +1231,7 @@ private fun computePlacementGuide( val ch5ok = detected.getOrElse(5) { false } // 좌우도 이미 균등하면 LATERAL 건너뛰고 바로 GREEN - if (ch4ok && ch5ok && kotlin.math.abs(l4 - l5) <= 10) { + if (ch4ok && ch5ok && kotlin.math.abs(l4 - l5) <= 5) { return PlacementGuideResult("Position OK! Final check...", "checkmark.circle", 65, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.GREEN) @@ -1395,7 +1401,7 @@ private fun computePlacementGuide( if (isPass) { val score = ((1.0 - cv / cvThr).coerceIn(0.0, 1.0) * 100).toInt().coerceAtLeast(70) return PlacementGuideResult( - "Green! Optimal position (CV=${"%.2f".format(cv)})", + "Optimal position!", "checkmark.circle", score, cv, true, lrDev, lrPass, phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.GREEN) } else { @@ -1406,16 +1412,7 @@ private fun computePlacementGuide( val weightedCenter = detectedLens.sumOf { (ch, len) -> ch.toDouble() * len } / totalLen val gradient = weightedCenter - 1.5 - // 방향이 다 맞는데 CV만 미달 → 그냥 GREEN 통과 (위치는 맞음) - if (lrPass && kotlin.math.abs(gradient) <= 0.3) { - val score = ((1.0 - cv / cvThr).coerceIn(0.0, 1.0) * 100).toInt().coerceAtLeast(60) - return PlacementGuideResult( - "Green! Optimal position", - "checkmark.circle", score, cv, true, lrDev, lrPass, - phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.GREEN) - } - - // 방향이 틀리면 VERTICAL로 복귀 + // 방향이 틀리면 해당 방향으로 안내, 방향 OK면 GREEN에 머물며 재시도 val adjustHint: String; val adjustIcon: String when { !lrPass && lrDev > DEFAULT_LR_DEV_THR -> { @@ -1429,12 +1426,33 @@ private fun computePlacementGuide( gradient > 0.3 -> { adjustHint = "Lower ↓"; adjustIcon = "arrow.down" } - else -> { + gradient < -0.3 -> { adjustHint = "Raise ↑"; adjustIcon = "arrow.up" } + else -> { + // 방향 애매 + CV 미달 → ch별 len 분석으로 방향 제시 + val lens = (0..3).map { urineLens.getOrElse(it) { 0 } } + val maxCh = lens.withIndex().maxByOrNull { it.value }?.index ?: 0 + val minCh = lens.withIndex().filter { it.value > 0 }.minByOrNull { it.value }?.index + if (minCh != null && lens[maxCh] > 0 && lens[minCh].toDouble() < lens[maxCh] * 0.6) { + // 최소 채널이 최대의 60% 미만 → 약한 쪽으로 안내 + if (minCh <= 1) { + // ch0/ch1 약함 → 위로 + adjustHint = "Raise slightly ↑"; adjustIcon = "arrow.up" + } else { + // ch2/ch3 약함 → 아래로 + adjustHint = "Lower slightly ↓"; adjustIcon = "arrow.down" + } + } else { + adjustHint = "Fine-tune position"; adjustIcon = "checkmark.circle" + } + } } + // 구체적 방향(↑↓←→)이면 VERTICAL 복귀, 아니면 GREEN 유지 + val stayGreen = adjustIcon == "checkmark.circle" + val nextPh = if (stayGreen) PlacementPhase.GREEN else PlacementPhase.VERTICAL return PlacementGuideResult(adjustHint, adjustIcon, 50, cv, false, lrDev, lrPass, - phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.VERTICAL) + phase = PlacementPhase.GREEN, nextPhase = nextPh) } } diff --git a/app/src/main/res/drawable/sensor_device.png b/app/src/main/res/drawable/sensor_device.png index 5648596..5bdf821 100644 Binary files a/app/src/main/res/drawable/sensor_device.png and b/app/src/main/res/drawable/sensor_device.png differ