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:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user