From ea19b74c00f4e6e78858ff83f9999ed2b648fcdd Mon Sep 17 00:00:00 2001 From: jjangddu Date: Fri, 24 Apr 2026 15:21:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20DetachmentDetection=20=ED=8F=AC?= =?UTF-8?q?=ED=8C=85=20+=20Placement=20LED=204/5/6=20=EB=8F=99=EC=A0=81=20?= =?UTF-8?q?=EC=A0=9C=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DetachmentDetection (detachment_detection.py 1:1 포팅): - ADC [20:80] 구간에서 baseline([80:100] median) 차감 - mean(|y|) < 100 AND std(y) < 100 → 미부착(detached) - center 4채널(CH0~CH3) 기준 PlacementGuide LED 제어: - 미부착 → mls?(4) DETACH_WARNING + "Sensor detached" 메시지 - 탐색 중 (CV > threshold) → mls?(5) ALIGN_SEARCHING - 최적 위치 (PASS) → mls?(6) ALIGN_COMPLETE - 스캔 종료 → mls?(0) OFF Co-Authored-By: Claude Opus 4.6 (1M context) --- .../managers/DetachmentDetection.kt | 93 +++++++++++++++++++ .../ui/views/monitoring/PlacementGuideView.kt | 41 ++++---- 2 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/example/medilightv2android/managers/DetachmentDetection.kt diff --git a/app/src/main/java/com/example/medilightv2android/managers/DetachmentDetection.kt b/app/src/main/java/com/example/medilightv2android/managers/DetachmentDetection.kt new file mode 100644 index 0000000..93818bb --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/managers/DetachmentDetection.kt @@ -0,0 +1,93 @@ +package com.example.medilightv2android.managers + +import kotlin.math.abs +import kotlin.math.sqrt + +/** + * 센서 미부착 감지 — detachment_detection.py 1:1 포팅 + * + * ADC 신호의 [20:80] 구간에서 baseline([80:100] median)을 빼고, + * mean(|y|)과 std(y)가 모두 threshold 미만이면 미부착으로 판정. + * + * 미부착 시 LED state 4 (DETACH_WARNING) 전송. + */ +object DetachmentDetection { + + const val WINDOW_START = 20 + const val WINDOW_END = 80 + const val BASELINE_WINDOW_START = 80 + const val BASELINE_WINDOW_END = 100 + const val THR_MEAN = 100.0 + const val THR_STD = 100.0 + const val N_CENTER_CH = 4 + + data class DetachmentResult( + val isDetached: Boolean, + val meanAbs: Double, + val stdVal: Double, + val meanAbsPerCh: DoubleArray, + val stdPerCh: DoubleArray + ) + + /** + * 4채널(CH0~CH3) ADC 신호에서 미부착 여부 판정. + * + * @param signals 6채널 ADC 데이터 (List>) + * @return DetachmentResult + */ + fun detect(signals: List>): DetachmentResult { + if (signals.size < N_CENTER_CH) { + return DetachmentResult(true, 0.0, 0.0, DoubleArray(N_CENTER_CH), DoubleArray(N_CENTER_CH)) + } + + val nSample = signals[0].size + val ws = WINDOW_START.coerceAtMost(nSample) + val we = WINDOW_END.coerceAtMost(nSample) + val bs = BASELINE_WINDOW_START.coerceAtMost(nSample) + val be = BASELINE_WINDOW_END.coerceAtMost(nSample) + + if (we <= ws || be <= bs) { + return DetachmentResult(true, 0.0, 0.0, DoubleArray(N_CENTER_CH), DoubleArray(N_CENTER_CH)) + } + + val meanAbsPerCh = DoubleArray(N_CENTER_CH) + val stdPerCh = DoubleArray(N_CENTER_CH) + + for (ch in 0 until N_CENTER_CH) { + val raw = signals[ch] + if (raw.size < be) { + meanAbsPerCh[ch] = 0.0 + stdPerCh[ch] = 0.0 + continue + } + + // Baseline = median of [bs:be] + val baselineSlice = (bs until be).map { raw[it].toDouble() }.sorted() + val baseline = baselineSlice[baselineSlice.size / 2] + + // y = signal[ws:we] - baseline + val y = DoubleArray(we - ws) { raw[ws + it].toDouble() - baseline } + + // mean(|y|) + meanAbsPerCh[ch] = y.map { abs(it) }.average() + + // std(y) + val mean = y.average() + stdPerCh[ch] = sqrt(y.map { (it - mean) * (it - mean) }.average()) + } + + val meanAbs = meanAbsPerCh.average() + val stdVal = stdPerCh.average() + + // "and" rule: 둘 다 threshold 미만이면 미부착 + val isDetached = meanAbs < THR_MEAN && stdVal < THR_STD + + return DetachmentResult( + isDetached = isDetached, + meanAbs = meanAbs, + stdVal = stdVal, + meanAbsPerCh = meanAbsPerCh, + stdPerCh = stdPerCh + ) + } +} 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 59daf37..010fc99 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 @@ -36,6 +36,7 @@ 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.DetachmentDetection import com.example.medilightv2android.managers.PiezoEchoAnalyzer import com.example.medilightv2android.managers.PiezoHW import com.example.medilightv2android.managers.UrinAI @@ -318,13 +319,29 @@ fun PlacementGuideView(appState: AppState) { } lastChannelData = channels - val guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount) - directionHint = guideResult.hint - directionIcon = guideResult.icon - placementScore = guideResult.score + // Detachment check (미부착 감지) + val buffers = channels.sortedBy { it.channel }.map { it.buffer } + val detachResult = DetachmentDetection.detect(buffers) - if (guideResult.isPass) { - isLocked = true + if (detachResult.isDetached) { + directionHint = "Sensor detached — check contact" + directionIcon = "exclamationmark.triangle" + placementScore = 0 + bleManager.sendLedMode(4) // DETACH_WARNING + Log.w("PlacementGuide", "DETACHED mean=${ + "%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") + } else { + val guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount) + directionHint = guideResult.hint + directionIcon = guideResult.icon + placementScore = guideResult.score + + if (guideResult.isPass) { + isLocked = true + bleManager.sendLedMode(6) // ALIGN_COMPLETE + } else { + bleManager.sendLedMode(5) // ALIGN_SEARCHING + } } Log.d("PlacementGuide", "Scan #$scanCount: ${detected.count { it }}/6, score=$placementScore%") @@ -338,23 +355,15 @@ fun PlacementGuideView(appState: AppState) { bleManager.sendAllChannels() } - // Continuous scan loop (3초 간격) + LED 모드 + // Continuous scan loop (3초 간격) — LED는 스캔 콜백에서 동적 제어 LaunchedEffect(isContinuousMode) { if (isContinuousMode) { - bleManager.sendLedMode(5) // ALIGN_SEARCHING performScan() while (isContinuousMode) { kotlinx.coroutines.delay(3000) if (isContinuousMode && !isScanning) performScan() } - bleManager.sendLedMode(0) // OFF - } - } - - // PASS 시 LED 완료 표시 - LaunchedEffect(isLocked) { - if (isLocked) { - bleManager.sendLedMode(6) // ALIGN_COMPLETE + bleManager.sendLedMode(0) // OFF on stop } }