feat(clinical): 채널별 접촉 상태를 글자로 표시

임상 화면에는 미부착 판정이 **아예 없었다**. DetachmentDetection 호출부가 일반 측정
화면과 일반 정렬 화면 두 곳뿐이라, 병원 임상 모드와 부착 위치 정렬은 파형만 보고
사람이 읽어야 했다. 파형으로 "이 채널이 떴다"를 읽으려면 눈이 익어야 하고, 20회를
다 돌린 뒤에 알면 그 단계는 다시 못 잰다 — 방광을 비웠거나 자세가 바뀐다.

DetachWatcher 를 둔다. 판정식은 DetachmentDetection(레퍼런스 포팅본) 그대로 쓰고 두
가지만 더한다.

**채널별 판정** — 레퍼런스는 CH0~CH3 feature 를 평균 낸 뒤 임계와 비교한다. 주석에
이유가 있다("부분 탈착 노이즈에 robust"). 그런데 간호사에게 필요한 것은 정반대다.
"붙어 있나"가 아니라 **어느 쪽이 떴나**를 알아야 어디를 다시 누를지 안다. 평균만 보면
CH0 이 완전히 죽어도 나머지가 멀쩡하면 아무 표시가 없다.

그래서 알고리즘 판정은 레퍼런스 그대로 두고(aggregateDetached), 채널별은 **표시 전용**
으로 따로 낸다. 둘을 섞지 않는다 — 채널별 flag 로 측정을 막거나 LED 를 바꾸면 그때부터
레퍼런스에서 벗어난 알고리즘이 된다. 화면에도 둘을 같이 띄워 어느 쪽이 기록에 들어가는
값인지 밝힌다.

**연속 확인** — 한 프레임 결과를 그대로 띄우면 손동작마다 깜빡이고 곧 무시당한다.
연속 3프레임이 같아야 뒤집는다. 복귀에도 같은 수를 요구한다 — 한 방향만 걸면 접촉이
불안정한 구간에서 "떴다"가 붙는 즉시 사라졌다 다시 뜬다. 다만 **첫 프레임은 즉시**
반영한다: 프로브를 붙이기 전에 화면을 켜 두는 일이 흔한데, 그때 3프레임 동안 "정상"
으로 보이면 그 사이에 측정을 시작한다.

CH4·CH5 는 판정하지 않는다. 레퍼런스가 N_CENTER_CH=4 로 못 박고 4채널 미만이면 예외를
던진다. 임계값도 CH0~CH3 실측 분포에서 나온 값이라 lateral 에 적용할 근거가 없다.
값은 보여주되 "—" 로 두어 판정을 안 한 것과 정상을 구분한다 — 빈칸이면 정상으로 읽힌다.

붙인 곳: 병원 임상 모드(측정 중), 부착 위치 정렬(위치 측정 + 좌우 정렬 양쪽).
프로브를 다시 붙였을 수 있는 시점(실행 시작·위치 재측정)에는 확정 상태를 리셋한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 13:38:05 +09:00
parent b1975408e3
commit 965714306d
5 changed files with 478 additions and 1 deletions
@@ -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<ChannelState>,
) {
/** 확정 미부착 채널 번호. 화면 문구가 이걸 그대로 읽는다. */
val detachedChannels: List<Int> 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<List<UShort>>): Snapshot? {
if (signals.size < DetachmentDetection.N_CENTER_CH) return null
val r = DetachmentDetection.detect(signals)
val states = ArrayList<ChannelState>(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<UShort>): Pair<Double, Double> {
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
}
}
@@ -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)
}
}
}
@@ -39,6 +39,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.ui.components.ChannelDetachPanel
import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid
import com.medithings.vesiscan.AppScreen import com.medithings.vesiscan.AppScreen
import com.medithings.vesiscan.AppState 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.AnchorGuide
import com.medithings.vesiscan.managers.AnchorPosRecord import com.medithings.vesiscan.managers.AnchorPosRecord
import com.medithings.vesiscan.managers.AnchorStep import com.medithings.vesiscan.managers.AnchorStep
import com.medithings.vesiscan.managers.DetachWatcher
import com.medithings.vesiscan.managers.LateralGuide import com.medithings.vesiscan.managers.LateralGuide
import com.medithings.vesiscan.models.HospitalFixedParams import com.medithings.vesiscan.models.HospitalFixedParams
import com.medithings.vesiscan.services.HospitalRunStore import com.medithings.vesiscan.services.HospitalRunStore
@@ -102,6 +104,12 @@ fun AnchorAlignView(appState: AppState) {
var liveChannels by remember { var liveChannels by remember {
mutableStateOf<List<com.medithings.vesiscan.ble.PiezoChannelData>>(emptyList()) mutableStateOf<List<com.medithings.vesiscan.ble.PiezoChannelData>>(emptyList())
} }
// ── 채널 접촉 ────────────────────────────────────────────────────────────
// 정렬은 "어디에 붙일지"를 정하는 단계라, 떠 있는 채널이 있으면 그 위치의 지표
// (nch·ch3·cap_frac) 자체가 의미를 잃는다. 위치를 고르기 **전에** 알아야 한다.
val detachWatcher = remember { DetachWatcher() }
var detachSnapshot by remember { mutableStateOf<DetachWatcher.Snapshot?>(null) }
// ── 좌우(LR) 정렬 ──────────────────────────────────────────────────────── // ── 좌우(LR) 정렬 ────────────────────────────────────────────────────────
// 상하(anchor)가 확정된 **뒤** 도는 단계다. AnchorGuide 의 지표(nch·ch3·cap_frac)는 // 상하(anchor)가 확정된 **뒤** 도는 단계다. AnchorGuide 의 지표(nch·ch3·cap_frac)는
// 전부 CH0~CH3 기준이라 좌우가 틀어져 있어도 상하는 정해진다 — 그 자리에서 CH4·CH5 // 전부 CH0~CH3 기준이라 좌우가 틀어져 있어도 상하는 정해진다 — 그 자리에서 CH4·CH5
@@ -138,6 +146,8 @@ fun AnchorAlignView(appState: AppState) {
if (!measuring) return@LaunchedEffect if (!measuring) return@LaunchedEffect
error = null error = null
progress = 0 progress = 0
// 위치를 옮겨 다시 재는 것이므로 옛 확정 상태는 무효다.
detachWatcher.reset(); detachSnapshot = null
val inbox = kotlinx.coroutines.channels.Channel< val inbox = kotlinx.coroutines.channels.Channel<
List<com.medithings.vesiscan.ble.PiezoChannelData>>( List<com.medithings.vesiscan.ble.PiezoChannelData>>(
@@ -189,6 +199,7 @@ fun AnchorAlignView(appState: AppState) {
} }
val sorted = got.sortedBy { it.channel } val sorted = got.sortedBy { it.channel }
liveChannels = sorted // 화면 파형 — 저장·판정에 쓰는 것과 같은 데이터 liveChannels = sorted // 화면 파형 — 저장·판정에 쓰는 것과 같은 데이터
detachWatcher.push(sorted.map { it.buffer })?.let { d -> detachSnapshot = d }
cycles.add(sorted.map { ch -> cycles.add(sorted.map { ch ->
DoubleArray(ch.buffer.size) { k -> ch.buffer[k].toDouble() } DoubleArray(ch.buffer.size) { k -> ch.buffer[k].toDouble() }
}) })
@@ -281,6 +292,7 @@ fun AnchorAlignView(appState: AppState) {
} }
val sorted = got.sortedBy { it.channel } val sorted = got.sortedBy { it.channel }
liveChannels = sorted liveChannels = sorted
detachWatcher.push(sorted.map { it.buffer })?.let { d -> detachSnapshot = d }
val frame = sorted.map { ch -> val frame = sorted.map { ch ->
DoubleArray(ch.buffer.size) { k -> ch.buffer[k].toDouble() } 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()) { if (liveChannels.isNotEmpty()) {
Text("채널 파형", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, Text("채널 파형", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = MlSecondaryText) color = MlSecondaryText)
@@ -37,6 +37,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.ui.components.ChannelDetachPanel
import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid
import com.medithings.vesiscan.AppScreen import com.medithings.vesiscan.AppScreen
import com.medithings.vesiscan.AppState import com.medithings.vesiscan.AppState
@@ -111,6 +112,14 @@ fun HospitalModeView(appState: AppState) {
mutableStateOf<List<com.medithings.vesiscan.ble.PiezoChannelData>>(emptyList()) mutableStateOf<List<com.medithings.vesiscan.ble.PiezoChannelData>>(emptyList())
} }
// 채널 접촉 상태 — 파형 옆에 글자로 같이 띄운다. 파형만으로 "이 채널이 떴다"를
// 읽으려면 눈이 익어야 하고, 20회를 다 돌린 뒤에 알면 그 단계는 다시 못 잰다
// (방광을 비웠거나 자세가 바뀐다).
val detachWatcher = remember { com.medithings.vesiscan.managers.DetachWatcher() }
var detachSnapshot by remember {
mutableStateOf<com.medithings.vesiscan.managers.DetachWatcher.Snapshot?>(null)
}
val repeats = repeatsText.toIntOrNull()?.coerceIn(1, 200) ?: HOSPITAL_DEFAULT_REPEATS val repeats = repeatsText.toIntOrNull()?.coerceIn(1, 200) ?: HOSPITAL_DEFAULT_REPEATS
val canStart = isConnected && patient.isNotBlank() && !running val canStart = isConnected && patient.isNotBlank() && !running
@@ -219,6 +228,9 @@ fun HospitalModeView(appState: AppState) {
val sorted = got.sortedBy { it.channel } val sorted = got.sortedBy { it.channel }
liveChannels = sorted // 화면 파형 — 저장값과 같은 데이터 liveChannels = sorted // 화면 파형 — 저장값과 같은 데이터
val raw = sorted.map { it.buffer } val raw = sorted.map { it.buffer }
// 접촉 판정도 같은 데이터로 낸다 — 화면 파형과 판정이 어긋나면
// 간호사가 어느 쪽을 믿어야 할지 알 수 없다.
detachWatcher.push(raw)?.let { detachSnapshot = it }
val saved = HospitalRunStore.appendMeasurement( val saved = HospitalRunStore.appendMeasurement(
file = file, file = file,
scanId = AdcCsvLogger.nextScanId(), scanId = AdcCsvLogger.nextScanId(),
@@ -451,6 +463,11 @@ fun HospitalModeView(appState: AppState) {
// //
// 셀을 작게 잡는다(140dp). 이 화면의 주인공은 진행 상황과 중지 버튼이고 // 셀을 작게 잡는다(140dp). 이 화면의 주인공은 진행 상황과 중지 버튼이고
// 파형은 곁눈질로 보는 것이라, 크게 잡으면 중지 버튼이 화면 밖으로 밀린다. // 파형은 곁눈질로 보는 것이라, 크게 잡으면 중지 버튼이 화면 밖으로 밀린다.
Spacer(Modifier.height(12.dp))
Label("채널 접촉")
Spacer(Modifier.height(6.dp))
ChannelDetachPanel(detachSnapshot)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Label("채널 파형") Label("채널 파형")
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
@@ -466,7 +483,13 @@ fun HospitalModeView(appState: AppState) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
} else { } else {
Button( Button(
onClick = { comboIdx = 0; repeatIdx = 0; running = true }, onClick = {
comboIdx = 0; repeatIdx = 0
// 지난 실행의 확정 상태가 남으면 새 부착 위치의 첫 판정이
// 거기에 끌려간다 — 프로브를 다시 붙였을 수 있다.
detachWatcher.reset(); detachSnapshot = null
running = true
},
enabled = canStart, enabled = canStart,
modifier = Modifier.fillMaxWidth().height(56.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
@@ -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<UShort> =
List(n) { (2000 + Random(it).nextInt(-1, 2)).toUShort() }
/** 부착 신호 — 창 구간에 큰 진폭. 임계(30)를 확실히 넘긴다. */
private fun live(n: Int = 120): List<UShort> = 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<UShort>) = 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<Int>(), w.snapshot!!.detachedChannels)
// CH0 만 한 프레임 죽었다 — 손이 스친 정도. 아직 바뀌면 안 된다.
val s1 = w.push(frame(quiet(), live(), live(), live(), live(), live()))!!
assertEquals(emptyList<Int>(), 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<Int>(), 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<Int>(), 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<Int>(), w.push(allLive())!!.detachedChannels)
}
}