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.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<List<com.medithings.vesiscan.ble.PiezoChannelData>>(emptyList())
|
||||
}
|
||||
// ── 채널 접촉 ────────────────────────────────────────────────────────────
|
||||
// 정렬은 "어디에 붙일지"를 정하는 단계라, 떠 있는 채널이 있으면 그 위치의 지표
|
||||
// (nch·ch3·cap_frac) 자체가 의미를 잃는다. 위치를 고르기 **전에** 알아야 한다.
|
||||
val detachWatcher = remember { DetachWatcher() }
|
||||
var detachSnapshot by remember { mutableStateOf<DetachWatcher.Snapshot?>(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<com.medithings.vesiscan.ble.PiezoChannelData>>(
|
||||
@@ -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)
|
||||
|
||||
@@ -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<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 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),
|
||||
|
||||
Reference in New Issue
Block a user