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:
2026-04-24 15:21:23 +09:00
parent 994d5e0c46
commit ea19b74c00
2 changed files with 118 additions and 16 deletions
@@ -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
)
}
}