diff --git a/app/src/main/java/com/medithings/vesiscan/managers/DetachWatcher.kt b/app/src/main/java/com/medithings/vesiscan/managers/DetachWatcher.kt new file mode 100644 index 0000000..7567eb4 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/managers/DetachWatcher.kt @@ -0,0 +1,159 @@ +/* + * DetachWatcher — 채널별 미부착 상태를 간호사에게 보여주기 위한 관찰기. + * + * 판정식 자체는 [DetachmentDetection] 이 소유한다(Python + * `library/alignment/detachment_detection.py` 포팅). 이 파일이 더하는 것은 두 가지다: + * ① **채널별** 판정 — 레퍼런스는 4채널 평균 하나만 낸다 + * ② **연속 확인(히스테리시스)** — 한 프레임 결과를 그대로 띄우면 화면이 깜빡인다 + * + * ## 채널별 판정은 레퍼런스에 없다 — 왜 여기서 만드나 + * 레퍼런스는 CH0~CH3 의 feature 를 **평균 낸 뒤** 임계와 비교한다. 주석에 이유가 + * 적혀 있다 — "부분 탈착 노이즈에 robust". 즉 한 채널이 잠깐 죽어도 전체 판정이 + * 흔들리지 않게 하려는 설계다. + * + * 그런데 간호사에게 필요한 정보는 정반대다. "지금 붙어 있나"가 아니라 **"어느 쪽이 + * 떴나"** 를 알아야 프로브를 어디로 다시 눌러야 할지 안다. 평균만 보면 CH0 이 완전히 + * 죽어도 나머지 셋이 멀쩡하면 아무 표시가 안 뜬다. + * + * 그래서 **알고리즘 판정은 레퍼런스 그대로 두고**(=[Snapshot.aggregateDetached]), + * 채널별 값은 **표시 전용**으로 따로 낸다. 둘을 섞지 않는다 — 채널별 flag 로 측정을 + * 막거나 LED 를 바꾸면 그때부터는 레퍼런스에서 벗어난 알고리즘이 된다. + * + * ## CH4·CH5 는 판정하지 않는다 + * 레퍼런스가 `N_CENTER_CH = 4` 로 못 박고 4채널 미만이면 예외를 던진다. 임계값 + * 100 도 **CH0~CH3 실측 분포**(미부착 max 33.7 vs incorrect min 254.8)에서 나온 + * 값이라, lateral 채널에 그대로 적용할 근거가 없다. 근거 없는 판정을 화면에 띄우면 + * 간호사는 그것을 사실로 믿는다 — CH4·CH5 는 판정 없이 값만 보여준다. + */ +package com.medithings.vesiscan.managers + +/** + * 프레임을 [push] 로 넣으면 채널별 상태를 갱신한다. + * + * @param confirmFrames 상태를 뒤집는 데 필요한 연속 프레임 수. 양방향 모두에 적용한다 — + * 붙었다고 알릴 때도 같은 수를 요구해야, 접촉이 불안정한 구간에서 "떴다/붙었다"가 + * 번갈아 뜨는 것을 막는다. + */ +class DetachWatcher(private val confirmFrames: Int = CONFIRM_FRAMES_DEFAULT) { + + /** 한 채널의 표시 상태. */ + data class ChannelState( + val channel: Int, + /** 확정된 미부착 여부. 판정 대상이 아닌 채널(CH4·CH5)은 null. */ + val detached: Boolean?, + val meanAbs: Double, + val std: Double, + /** 상태가 뒤집히기까지 남은 연속 프레임 수. 0 = 안정. 화면의 "확인 중" 표시용. */ + val pending: Int, + ) + + data class Snapshot( + /** 레퍼런스 판정(4채널 평균). 알고리즘·기록에 쓰는 값은 이것이다. */ + val aggregateDetached: Boolean, + val meanAbs: Double, + val std: Double, + /** CH0~CH5. CH4·CH5 는 `detached = null`. */ + val channels: List, + ) { + /** 확정 미부착 채널 번호. 화면 문구가 이걸 그대로 읽는다. */ + val detachedChannels: List get() = channels.filter { it.detached == true }.map { it.channel } + } + + private val confirmed = BooleanArray(DetachmentDetection.N_CENTER_CH) + private val streak = IntArray(DetachmentDetection.N_CENTER_CH) + private var seenAny = false + + var snapshot: Snapshot? = null + private set + + fun reset() { + confirmed.fill(false) + streak.fill(0) + seenAny = false + snapshot = null + } + + /** + * 6채널 프레임 1개 입력. 채널 수가 모자라면 무시한다(갱신하지 않음). + * + * @return 갱신된 스냅샷, 입력이 부적합하면 null. + */ + fun push(signals: List>): Snapshot? { + if (signals.size < DetachmentDetection.N_CENTER_CH) return null + val r = DetachmentDetection.detect(signals) + + val states = ArrayList(6) + for (ch in 0 until DetachmentDetection.N_CENTER_CH) { + val raw = isDetachedRaw(r.meanAbsPerCh[ch], r.stdPerCh[ch]) + if (raw == confirmed[ch]) { + streak[ch] = 0 + } else { + streak[ch]++ + if (streak[ch] >= confirmFrames) { + confirmed[ch] = raw + streak[ch] = 0 + } + } + // 첫 프레임은 연속 확인을 기다리지 않는다 — 처음부터 떠 있는 채널을 + // confirmFrames 만큼 "정상"으로 보여주면 그 사이에 측정이 시작된다. + if (!seenAny) { confirmed[ch] = raw; streak[ch] = 0 } + states += ChannelState( + channel = ch, + detached = confirmed[ch], + meanAbs = r.meanAbsPerCh[ch], + std = r.stdPerCh[ch], + pending = if (streak[ch] > 0) confirmFrames - streak[ch] else 0, + ) + } + // lateral — 판정 없이 값만. 레퍼런스가 4채널만 계산하므로 여기서 직접 잰다. + for (ch in DetachmentDetection.N_CENTER_CH until minOf(6, signals.size)) { + val (m, s) = featureOf(signals[ch]) + states += ChannelState(ch, detached = null, meanAbs = m, std = s, pending = 0) + } + seenAny = true + + return Snapshot( + aggregateDetached = r.isDetached, + meanAbs = r.meanAbs, + std = r.stdVal, + channels = states, + ).also { snapshot = it } + } + + /** + * 채널 하나의 원시 판정. 집계식과 **같은 임계·같은 규칙**을 쓴다. + * + * 규칙이 OR 인 것은 2026-08-11 결정을 따른 것이다(공기 중 EMI 로 std 가 높게 + * 나와 판정이 늦던 문제). 채널별 표시가 집계와 다른 규칙을 쓰면 "전체는 정상인데 + * 채널은 전부 빨강" 같은 모순이 생긴다. + */ + private fun isDetachedRaw(meanAbs: Double, std: Double): Boolean = + meanAbs < DetachmentDetection.THR_MEAN || std < DetachmentDetection.THR_STD + + /** [DetachmentDetection] 과 같은 창·같은 baseline 규약. */ + private fun featureOf(raw: List): Pair { + val n = raw.size + val ws = DetachmentDetection.WINDOW_START.coerceAtMost(n) + val we = DetachmentDetection.WINDOW_END.coerceAtMost(n) + val bs = DetachmentDetection.BASELINE_WINDOW_START.coerceAtMost(n) + val be = DetachmentDetection.BASELINE_WINDOW_END.coerceAtMost(n) + if (we <= ws || be <= bs) return 0.0 to 0.0 + val baseSlice = (bs until be).map { raw[it].toDouble() }.sorted() + val baseline = baseSlice[baseSlice.size / 2] + val y = DoubleArray(we - ws) { raw[ws + it].toDouble() - baseline } + val meanAbs = y.map { kotlin.math.abs(it) }.average() + val mean = y.average() + val std = kotlin.math.sqrt(y.map { (it - mean) * (it - mean) }.average()) + return meanAbs to std + } + + companion object { + /** + * 기본 연속 확인 프레임 수. + * + * 정렬은 프레임이 초당 1~2개라 3이면 2~3초다. 이보다 짧으면 프로브를 누르는 + * 손동작마다 표시가 뒤집히고, 길면 진짜로 뜬 것을 늦게 안다. + */ + const val CONFIRM_FRAMES_DEFAULT = 3 + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/components/ChannelDetachPanel.kt b/app/src/main/java/com/medithings/vesiscan/ui/components/ChannelDetachPanel.kt new file mode 100644 index 0000000..e23de4f --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/ui/components/ChannelDetachPanel.kt @@ -0,0 +1,160 @@ +/* + * ChannelDetachPanel — 채널별 미부착 표시. 임상 화면들이 공유한다. + */ +package com.medithings.vesiscan.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.medithings.vesiscan.managers.DetachWatcher +import com.medithings.vesiscan.ui.theme.MlCritical +import com.medithings.vesiscan.ui.theme.MlSecondaryText +import com.medithings.vesiscan.ui.theme.MlSuccess + +/** + * 채널 접촉 상태 칸. + * + * ## 왜 파형 옆에 두나 + * 파형만 보고 "이 채널이 떴다"를 읽으려면 눈이 익어야 한다. 간호사는 파형 전문가가 + * 아니고, 20회를 다 돌린 뒤에 파일을 열어 보는 것은 늦다 — 그때는 방광을 비웠거나 + * 자세가 바뀌어 그 단계를 다시 못 잰다. 그래서 판정을 글자로 같이 띄운다. + * + * ## 맨 윗줄이 결론이다 + * 문제가 없으면 한 줄만 보고 지나갈 수 있어야 한다. 채널별 숫자는 그 아래 접어 두듯 + * 작게 둔다 — 필요한 사람만 본다. + * + * ## CH4·CH5 에는 판정이 없다 + * 미부착 판정식이 CH0~CH3 실측 분포에서 나온 것이라 lateral 채널에 적용할 근거가 없다 + * ([DetachWatcher] 참조). 값은 보여주되 "—" 로 두어, 판정을 안 한 것과 정상을 + * 구분한다. 빈칸으로 두면 정상으로 읽힌다. + */ +@Composable +fun ChannelDetachPanel( + snapshot: DetachWatcher.Snapshot?, + modifier: Modifier = Modifier, +) { + val detached = snapshot?.detachedChannels ?: emptyList() + val tone = when { + snapshot == null -> MlSecondaryText + detached.isNotEmpty() || snapshot.aggregateDetached -> MlCritical + else -> MlSuccess + } + + Column( + modifier = modifier + .fillMaxWidth() + .background(Color.White, RoundedCornerShape(12.dp)) + .border(1.5.dp, tone.copy(alpha = 0.45f), RoundedCornerShape(12.dp)) + .padding(12.dp) + ) { + // ── 결론 한 줄 ── + Text( + when { + snapshot == null -> "채널 접촉 — 측정을 시작하면 표시됩니다." + detached.isNotEmpty() -> + "접촉 확인 필요 — " + detached.joinToString(", ") { "CH$it" } + snapshot.aggregateDetached -> + "신호가 전반적으로 약합니다 — 부착 상태를 확인하세요." + else -> "채널 접촉 정상" + }, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = tone, + ) + + snapshot ?: return@Column + + // 채널별 판정과 전체 판정이 갈릴 수 있다. 둘 다 보여주고, 어느 쪽이 + // 알고리즘이 쓰는 값인지 밝힌다 — 나중에 기록과 화면이 다르다는 말이 나온다. + Spacer(Modifier.height(3.dp)) + Text( + "전체 판정 ${if (snapshot.aggregateDetached) "미부착" else "정상"} " + + "(4채널 평균 mean ${fmt(snapshot.meanAbs)} · std ${fmt(snapshot.std)})", + fontSize = 11.sp, color = MlSecondaryText, + ) + + Spacer(Modifier.height(8.dp)) + + // ── 채널별 ── + snapshot.channels.forEach { c -> + Row( + Modifier.fillMaxWidth().height(22.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("CH${c.channel}", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, + modifier = Modifier.width(40.dp), color = MlSecondaryText) + Box(Modifier.width(64.dp)) { + Text( + when { + c.detached == null -> "—" + c.detached -> "미부착" + else -> "정상" + }, + fontSize = 12.sp, + fontWeight = if (c.detached == true) FontWeight.Bold else FontWeight.Normal, + color = when (c.detached) { + null -> MlSecondaryText + true -> MlCritical + false -> MlSuccess + }, + ) + } + Text("mean ${fmt(c.meanAbs)}", fontSize = 11.sp, color = MlSecondaryText, + modifier = Modifier.width(92.dp)) + Text("std ${fmt(c.std)}", fontSize = 11.sp, color = MlSecondaryText) + Spacer(Modifier.weight(1f)) + // 뒤집히는 중이면 그 사실을 보여준다. 표시가 안 바뀌는 것과 + // "바뀌는 중"을 구분하지 못하면 고장으로 오해한다. + if (c.pending > 0) { + Text("확인 중 ${c.pending}", fontSize = 10.sp, color = MlSecondaryText) + } + } + } + + Spacer(Modifier.height(6.dp)) + Text( + "CH4·CH5 는 미부착 판정 기준이 없어 값만 표시합니다.", + fontSize = 10.sp, color = MlSecondaryText, + ) + } +} + +private fun fmt(v: Double): String = if (v.isNaN()) "-" else "%.0f".format(v) + +/** 파형 그리드와 나란히 쓰는 간격. 두 곳에서 같은 값을 쓰도록 상수로 둔다. */ +val DETACH_PANEL_GAP = 10.dp + +/** 한 줄 요약만 필요한 화면용(진행 중 배너 등). */ +@Composable +fun DetachInlineWarning(snapshot: DetachWatcher.Snapshot?, modifier: Modifier = Modifier) { + val detached = snapshot?.detachedChannels ?: return + if (detached.isEmpty()) return + Box( + modifier + .fillMaxWidth() + .background(MlCritical.copy(alpha = 0.12f), RoundedCornerShape(10.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text("접촉 확인 필요", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MlCritical) + Text(detached.joinToString(", ") { "CH$it" }, fontSize = 13.sp, color = MlCritical) + } + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt index 5992b81..fd1a49f 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.medithings.vesiscan.ui.components.ChannelDetachPanel import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid import com.medithings.vesiscan.AppScreen import com.medithings.vesiscan.AppState @@ -50,6 +51,7 @@ import com.medithings.vesiscan.managers.AnchorConfig import com.medithings.vesiscan.managers.AnchorGuide import com.medithings.vesiscan.managers.AnchorPosRecord import com.medithings.vesiscan.managers.AnchorStep +import com.medithings.vesiscan.managers.DetachWatcher import com.medithings.vesiscan.managers.LateralGuide import com.medithings.vesiscan.models.HospitalFixedParams import com.medithings.vesiscan.services.HospitalRunStore @@ -102,6 +104,12 @@ fun AnchorAlignView(appState: AppState) { var liveChannels by remember { mutableStateOf>(emptyList()) } + // ── 채널 접촉 ──────────────────────────────────────────────────────────── + // 정렬은 "어디에 붙일지"를 정하는 단계라, 떠 있는 채널이 있으면 그 위치의 지표 + // (nch·ch3·cap_frac) 자체가 의미를 잃는다. 위치를 고르기 **전에** 알아야 한다. + val detachWatcher = remember { DetachWatcher() } + var detachSnapshot by remember { mutableStateOf(null) } + // ── 좌우(LR) 정렬 ──────────────────────────────────────────────────────── // 상하(anchor)가 확정된 **뒤** 도는 단계다. AnchorGuide 의 지표(nch·ch3·cap_frac)는 // 전부 CH0~CH3 기준이라 좌우가 틀어져 있어도 상하는 정해진다 — 그 자리에서 CH4·CH5 @@ -138,6 +146,8 @@ fun AnchorAlignView(appState: AppState) { if (!measuring) return@LaunchedEffect error = null progress = 0 + // 위치를 옮겨 다시 재는 것이므로 옛 확정 상태는 무효다. + detachWatcher.reset(); detachSnapshot = null val inbox = kotlinx.coroutines.channels.Channel< List>( @@ -189,6 +199,7 @@ fun AnchorAlignView(appState: AppState) { } val sorted = got.sortedBy { it.channel } liveChannels = sorted // 화면 파형 — 저장·판정에 쓰는 것과 같은 데이터 + detachWatcher.push(sorted.map { it.buffer })?.let { d -> detachSnapshot = d } cycles.add(sorted.map { ch -> DoubleArray(ch.buffer.size) { k -> ch.buffer[k].toDouble() } }) @@ -281,6 +292,7 @@ fun AnchorAlignView(appState: AppState) { } val sorted = got.sortedBy { it.channel } liveChannels = sorted + detachWatcher.push(sorted.map { it.buffer })?.let { d -> detachSnapshot = d } val frame = sorted.map { ch -> DoubleArray(ch.buffer.size) { k -> ch.buffer[k].toDouble() } } @@ -434,6 +446,14 @@ fun AnchorAlignView(appState: AppState) { // ── 채널 파형 ─────────────────────────────────────────────────── // 측정 중에도, 끝난 뒤에도 보여준다. 정렬은 위치를 옮겨 가며 반복하는 // 작업이라 "방금 이 위치에서 뭐가 잡혔는지"를 보고 다음을 정한다. + if (detachSnapshot != null) { + Text("채널 접촉", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, + color = MlSecondaryText) + Spacer(Modifier.height(6.dp)) + ChannelDetachPanel(detachSnapshot) + Spacer(Modifier.height(16.dp)) + } + if (liveChannels.isNotEmpty()) { Text("채널 파형", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText) diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt index bafefdd..44d9140 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.medithings.vesiscan.ui.components.ChannelDetachPanel import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid import com.medithings.vesiscan.AppScreen import com.medithings.vesiscan.AppState @@ -111,6 +112,14 @@ fun HospitalModeView(appState: AppState) { mutableStateOf>(emptyList()) } + // 채널 접촉 상태 — 파형 옆에 글자로 같이 띄운다. 파형만으로 "이 채널이 떴다"를 + // 읽으려면 눈이 익어야 하고, 20회를 다 돌린 뒤에 알면 그 단계는 다시 못 잰다 + // (방광을 비웠거나 자세가 바뀐다). + val detachWatcher = remember { com.medithings.vesiscan.managers.DetachWatcher() } + var detachSnapshot by remember { + mutableStateOf(null) + } + val repeats = repeatsText.toIntOrNull()?.coerceIn(1, 200) ?: HOSPITAL_DEFAULT_REPEATS val canStart = isConnected && patient.isNotBlank() && !running @@ -219,6 +228,9 @@ fun HospitalModeView(appState: AppState) { val sorted = got.sortedBy { it.channel } liveChannels = sorted // 화면 파형 — 저장값과 같은 데이터 val raw = sorted.map { it.buffer } + // 접촉 판정도 같은 데이터로 낸다 — 화면 파형과 판정이 어긋나면 + // 간호사가 어느 쪽을 믿어야 할지 알 수 없다. + detachWatcher.push(raw)?.let { detachSnapshot = it } val saved = HospitalRunStore.appendMeasurement( file = file, scanId = AdcCsvLogger.nextScanId(), @@ -451,6 +463,11 @@ fun HospitalModeView(appState: AppState) { // // 셀을 작게 잡는다(140dp). 이 화면의 주인공은 진행 상황과 중지 버튼이고 // 파형은 곁눈질로 보는 것이라, 크게 잡으면 중지 버튼이 화면 밖으로 밀린다. + Spacer(Modifier.height(12.dp)) + Label("채널 접촉") + Spacer(Modifier.height(6.dp)) + ChannelDetachPanel(detachSnapshot) + Spacer(Modifier.height(12.dp)) Label("채널 파형") Spacer(Modifier.height(6.dp)) @@ -466,7 +483,13 @@ fun HospitalModeView(appState: AppState) { Spacer(Modifier.height(16.dp)) } else { Button( - onClick = { comboIdx = 0; repeatIdx = 0; running = true }, + onClick = { + comboIdx = 0; repeatIdx = 0 + // 지난 실행의 확정 상태가 남으면 새 부착 위치의 첫 판정이 + // 거기에 끌려간다 — 프로브를 다시 붙였을 수 있다. + detachWatcher.reset(); detachSnapshot = null + running = true + }, enabled = canStart, modifier = Modifier.fillMaxWidth().height(56.dp), shape = RoundedCornerShape(14.dp), diff --git a/app/src/test/java/com/medithings/vesiscan/managers/DetachWatcherTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/DetachWatcherTest.kt new file mode 100644 index 0000000..e23074b --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/DetachWatcherTest.kt @@ -0,0 +1,115 @@ +package com.medithings.vesiscan.managers + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.random.Random + +/** + * 채널별 접촉 표시 — 히스테리시스와 표시 규약. + * + * 판정식([DetachmentDetection])은 레퍼런스 포팅본이라 여기서 다시 검증하지 않는다. + * 이 테스트가 지키는 것은 **표시 계층의 약속**이다: + * · 첫 프레임은 즉시 반영한다(처음부터 떠 있는 채널을 몇 초간 "정상"으로 보이면 안 됨) + * · 그 뒤로는 연속 N 프레임이 같아야 뒤집는다(손동작마다 깜빡이면 무시당한다) + * · CH4·CH5 는 판정하지 않는다(근거 없는 판정을 화면에 띄우면 사실로 믿는다) + */ +class DetachWatcherTest { + + /** 미부착처럼 보이는 신호 — 진폭이 임계보다 훨씬 작다. */ + private fun quiet(n: Int = 120): List = + List(n) { (2000 + Random(it).nextInt(-1, 2)).toUShort() } + + /** 부착 신호 — 창 구간에 큰 진폭. 임계(30)를 확실히 넘긴다. */ + private fun live(n: Int = 120): List = List(n) { i -> + val base = 2000 + val amp = if (i in DetachmentDetection.WINDOW_START until DetachmentDetection.WINDOW_END) + (if (i % 2 == 0) 900 else -900) else 0 + (base + amp).coerceIn(0, 4095).toUShort() + } + + private fun frame(vararg per: List) = per.toList() + private fun allLive() = List(6) { live() } + private fun allQuiet() = List(6) { quiet() } + + @Test fun `첫 프레임은 곧바로 반영한다`() { + // 프로브를 붙이기 전에 화면을 켜 두는 경우가 흔하다. 그때 confirmFrames 만큼 + // "정상"으로 보이면 그 사이에 측정을 시작한다. + val w = DetachWatcher() + val s = w.push(allQuiet())!! + assertEquals(listOf(0, 1, 2, 3), s.detachedChannels) + } + + @Test fun `단발 변화로는 뒤집지 않는다`() { + val w = DetachWatcher(confirmFrames = 3) + w.push(allLive()) // 기준: 전부 정상 + assertEquals(emptyList(), w.snapshot!!.detachedChannels) + + // CH0 만 한 프레임 죽었다 — 손이 스친 정도. 아직 바뀌면 안 된다. + val s1 = w.push(frame(quiet(), live(), live(), live(), live(), live()))!! + assertEquals(emptyList(), s1.detachedChannels) + assertTrue(s1.channels[0].pending > 0) // 바뀌는 중이라는 표시는 나와야 한다 + + // 정상으로 돌아오면 streak 이 풀린다. + val s2 = w.push(allLive())!! + assertEquals(0, s2.channels[0].pending) + } + + @Test fun `연속 N 프레임이면 뒤집는다`() { + val w = DetachWatcher(confirmFrames = 3) + w.push(allLive()) + val detachedFrame = frame(quiet(), live(), live(), live(), live(), live()) + repeat(2) { + assertEquals(emptyList(), w.push(detachedFrame)!!.detachedChannels) + } + assertEquals(listOf(0), w.push(detachedFrame)!!.detachedChannels) + } + + @Test fun `복귀에도 같은 수를 요구한다`() { + // 한쪽 방향만 히스테리시스를 걸면, 접촉이 불안정한 구간에서 "떴다"가 붙는 즉시 + // 사라졌다 다시 뜨기를 반복한다. + val w = DetachWatcher(confirmFrames = 3) + w.push(allQuiet()) // 시작부터 미부착 + assertEquals(listOf(0, 1, 2, 3), w.snapshot!!.detachedChannels) + repeat(2) { w.push(allLive()) } + assertEquals(listOf(0, 1, 2, 3), w.snapshot!!.detachedChannels) // 아직 유지 + assertEquals(emptyList(), w.push(allLive())!!.detachedChannels) + } + + @Test fun `CH4 CH5 는 판정하지 않고 값만 낸다`() { + val w = DetachWatcher() + val s = w.push(allQuiet())!! + assertEquals(6, s.channels.size) + assertNull(s.channels[4].detached) + assertNull(s.channels[5].detached) + // 값은 나와야 한다 — 판정을 안 하는 것이지 재지 않는 것이 아니다. + assertTrue(s.channels[4].meanAbs.isFinite()) + assertTrue(s.channels[5].std.isFinite()) + // 그리고 detachedChannels 에 섞이면 안 된다. + assertTrue(s.detachedChannels.none { it >= DetachmentDetection.N_CENTER_CH }) + } + + @Test fun `집계 판정은 레퍼런스 그대로 낸다`() { + // 채널별 표시와 별개로, 알고리즘·기록이 쓰는 값은 4채널 평균 판정이다. + val w = DetachWatcher() + assertEquals(true, w.push(allQuiet())!!.aggregateDetached) + w.reset() + assertEquals(false, w.push(allLive())!!.aggregateDetached) + } + + @Test fun `채널이 모자란 프레임은 무시한다`() { + val w = DetachWatcher() + assertNull(w.push(List(3) { live() })) + assertNull(w.snapshot) + } + + @Test fun `reset 은 확정 상태를 지운다`() { + // 프로브를 다시 붙였을 수 있다 — 옛 확정이 새 위치의 첫 판정을 끌면 안 된다. + val w = DetachWatcher() + w.push(allQuiet()) + w.reset() + assertNull(w.snapshot) + assertEquals(emptyList(), w.push(allLive())!!.detachedChannels) + } +}