feat: DetachmentDetection 포팅 + Placement LED 4/5/6 동적 제어
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<List<UShort>>)
|
||||||
|
* @return DetachmentResult
|
||||||
|
*/
|
||||||
|
fun detect(signals: List<List<UShort>>): 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-10
@@ -36,6 +36,7 @@ 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.ble.PiezoChannelData
|
||||||
|
import com.example.medilightv2android.managers.DetachmentDetection
|
||||||
import com.example.medilightv2android.managers.PiezoEchoAnalyzer
|
import com.example.medilightv2android.managers.PiezoEchoAnalyzer
|
||||||
import com.example.medilightv2android.managers.PiezoHW
|
import com.example.medilightv2android.managers.PiezoHW
|
||||||
import com.example.medilightv2android.managers.UrinAI
|
import com.example.medilightv2android.managers.UrinAI
|
||||||
@@ -318,6 +319,18 @@ fun PlacementGuideView(appState: AppState) {
|
|||||||
}
|
}
|
||||||
lastChannelData = channels
|
lastChannelData = channels
|
||||||
|
|
||||||
|
// Detachment check (미부착 감지)
|
||||||
|
val buffers = channels.sortedBy { it.channel }.map { it.buffer }
|
||||||
|
val detachResult = DetachmentDetection.detect(buffers)
|
||||||
|
|
||||||
|
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)
|
val guideResult = computePlacementGuide(detected, urineLens, allWalls, scanCount)
|
||||||
directionHint = guideResult.hint
|
directionHint = guideResult.hint
|
||||||
directionIcon = guideResult.icon
|
directionIcon = guideResult.icon
|
||||||
@@ -325,6 +338,10 @@ fun PlacementGuideView(appState: AppState) {
|
|||||||
|
|
||||||
if (guideResult.isPass) {
|
if (guideResult.isPass) {
|
||||||
isLocked = true
|
isLocked = true
|
||||||
|
bleManager.sendLedMode(6) // ALIGN_COMPLETE
|
||||||
|
} else {
|
||||||
|
bleManager.sendLedMode(5) // ALIGN_SEARCHING
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d("PlacementGuide", "Scan #$scanCount: ${detected.count { it }}/6, score=$placementScore%")
|
Log.d("PlacementGuide", "Scan #$scanCount: ${detected.count { it }}/6, score=$placementScore%")
|
||||||
@@ -338,23 +355,15 @@ fun PlacementGuideView(appState: AppState) {
|
|||||||
bleManager.sendAllChannels()
|
bleManager.sendAllChannels()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continuous scan loop (3초 간격) + LED 모드
|
// Continuous scan loop (3초 간격) — LED는 스캔 콜백에서 동적 제어
|
||||||
LaunchedEffect(isContinuousMode) {
|
LaunchedEffect(isContinuousMode) {
|
||||||
if (isContinuousMode) {
|
if (isContinuousMode) {
|
||||||
bleManager.sendLedMode(5) // ALIGN_SEARCHING
|
|
||||||
performScan()
|
performScan()
|
||||||
while (isContinuousMode) {
|
while (isContinuousMode) {
|
||||||
kotlinx.coroutines.delay(3000)
|
kotlinx.coroutines.delay(3000)
|
||||||
if (isContinuousMode && !isScanning) performScan()
|
if (isContinuousMode && !isScanning) performScan()
|
||||||
}
|
}
|
||||||
bleManager.sendLedMode(0) // OFF
|
bleManager.sendLedMode(0) // OFF on stop
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PASS 시 LED 완료 표시
|
|
||||||
LaunchedEffect(isLocked) {
|
|
||||||
if (isLocked) {
|
|
||||||
bleManager.sendLedMode(6) // ALIGN_COMPLETE
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user