feat(clinical): 임상 화면에 BV · 채널별 전후벽 · Spot/Continuous 측정
알고리즘은 이미 이식돼 있었다(MethodDRunner + estimateBv, AnchorGuide 가 cap_frac 에 쓰고 있다). 없던 것은 그 결과를 화면에 꺼내는 부분이다. **ClinicalBv** — 검출→BV 조합을 한 군데로 묶는다. 조합을 화면마다 다시 쓰면 반드시 갈린다: CCC on/off, missingOutside 전달 여부, ant vs antRefined 중 무엇을 넘기는가. 셋 다 결과를 바꾼다. AnchorGuide 의 cap_frac 경로를 그대로 쓰고, 테스트가 두 경로의 volumeMl 이 1e-9 안에서 같은지 실측 trace 로 고정한다 — 갈리면 정렬이 고른 위치의 근거와 화면의 용적이 다른 계산이 되어 이 기능의 목적(위치 검증)이 무너진다. **실패를 값으로 돌려준다.** 이 화면들의 관심사는 "왜 용적이 안 나오나"다. null 만 주면 화면은 "—" 밖에 못 쓴다. 검출 0개(부착 문제) / 2개 미만(위치 문제) / 검출은 됐는데 기하가 안 풀림을 각각 다른 문구로 낸다 — 대응이 다르기 때문이다. **BvPanel** — 용적 + 채널별 전벽·후벽 표. 샘플 인덱스(파형에서 보이는 것)·mm·직경 (BV 가 실제로 쓴 값)을 나란히 둔다. "파형엔 벽이 보이는데 용적이 이상하다"에서 어느 단계가 틀렸는지 갈리려면 셋이 같이 있어야 한다. 직경이 "제외"면 검출은 됐지만 BV 에서 빠진 채널이다. 실패해도 검출된 만큼은 그대로 보여준다 — 어느 채널이 빠졌는지가 답이다. **BvMeasureSection** — Spot / Continuous. Spot 은 "지금 얼마인가", Continuous 는 **흔들리는지**를 본다. 한 번 재서 나온 200mL 가 진짜인지는 한 번으로 알 수 없고, 정렬 위치가 최적인지 판단하려면 재현성이 필요하다. 그래서 연속 모드는 최근 20회의 평균·표준편차·CV·범위를 같이 낸다. 5 cycle 을 모아 mean-scan 후 1회 계산한다 — 1 cycle 로 재면 노이즈가 그대로 벽으로 잡힌다. 측정 조건은 정렬과 같게 고정(2.3MHz·cycle 3). 검출 문턱이 절대값 기준이라 조건이 바뀌면 nch 가 달라지고, 정렬이 고른 위치의 근거와 다른 조건의 BV 가 된다. 붙인 곳: · 부착 위치 정렬 — 위치마다 판정과 **같은 mean-scan** 으로 BV. 따로 재면 그 위치의 지표와 용적이 다른 데이터가 되어 대조가 성립하지 않는다. · 병원 임상 모드 — 대기 중 Spot/Continuous, 측정 중 직전 회차 BV. · 두 화면의 파형에 전벽(초록)·후벽(주황) 세로선. 마커가 검출 인덱스와 같은지도 테스트. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -102,6 +102,32 @@ class BleManager private constructor(private val context: Context) {
|
||||
// 2026-08-04: 10 → 5 축소. 지속 실패 시 사용자에게 명확한 안내가 낫고 무한 시도로
|
||||
// 상단 배너/로그 스팸 방지. 실패 후 사용자가 명시적으로 재시도 (앱 재시작 · 재연결).
|
||||
private val MAX_RECONNECT_ATTEMPTS = 5
|
||||
|
||||
/**
|
||||
* 무한 재연결 모드 — **장시간 무인 측정 전용**.
|
||||
*
|
||||
* 평소에는 5회(약 1분)에서 포기하는 것이 맞다. 사용자가 옆에 있으니 "재연결 실패,
|
||||
* 기기를 확인하세요"가 무한 스캔보다 낫고, 무한 스캔은 폰 배터리를 계속 먹는다.
|
||||
*
|
||||
* 그런데 밤새 걸어 두는 부하 테스트에서는 그 정책이 그대로 실패가 된다 — 2026-09-08
|
||||
* 실측에서 프로브 펌웨어가 3시간 18분 만에 얼어붙었고, 앱이 1분 뒤 포기한 뒤로
|
||||
* **39분을 아무것도 안 하고 흘려보냈다.** 프로브가 리셋되어 살아나도 다시 붙지 않는다.
|
||||
*
|
||||
* 이 모드에서는 포기하지 않고 [FOREVER_SLOW_INTERVAL_MS] 간격으로 계속 시도한다.
|
||||
* 복구되면 부하가 자동 재개되고, 언제 죽고 언제 살아났는지가 로그와 CSV 에 남는다.
|
||||
*
|
||||
* 끄는 것을 잊으면 일반 사용에서도 무한 스캔이 도므로, 켠 쪽이 반드시 되돌린다
|
||||
* (BatteryDrainTester.start/stop).
|
||||
*/
|
||||
@Volatile var reconnectForever: Boolean = false
|
||||
|
||||
/**
|
||||
* 무한 모드에서 초기 5회를 넘긴 뒤의 재시도 간격.
|
||||
*
|
||||
* 3초 간격을 밤새 유지하면 스캔이 1,200회/시간이라 폰 배터리와 로그를 모두 태운다.
|
||||
* 프로브가 죽은 뒤 살아나는 것은 분 단위 사건이므로 30초면 충분하다.
|
||||
*/
|
||||
private val FOREVER_SLOW_INTERVAL_MS = 30_000L
|
||||
private var reconnectTimer: Runnable? = null
|
||||
@Volatile private var lastRxTimestamp: Long = 0
|
||||
private var watchdogTimer: Runnable? = null
|
||||
@@ -1242,14 +1268,18 @@ class BleManager private constructor(private val context: Context) {
|
||||
// ── Auto Reconnect ──
|
||||
|
||||
private fun scheduleAutoReconnect() {
|
||||
logd { "Auto-reconnect attempt ${reconnectAttempt + 1} in ${reconnectIntervalMs}ms" }
|
||||
debugLogger.reconnecting(reconnectAttempt + 1, -1, reconnectIntervalMs)
|
||||
// 무한 모드에서 초기 시도를 넘기면 간격을 늘린다 — 3초 간격을 밤새 유지하면
|
||||
// 스캔이 시간당 1,200회라 폰 배터리와 로그를 같이 태운다.
|
||||
val delay = if (reconnectForever && reconnectAttempt >= MAX_RECONNECT_ATTEMPTS)
|
||||
FOREVER_SLOW_INTERVAL_MS else reconnectIntervalMs
|
||||
logd { "Auto-reconnect attempt ${reconnectAttempt + 1} in ${delay}ms" }
|
||||
debugLogger.reconnecting(reconnectAttempt + 1, -1, delay)
|
||||
isReconnecting.value = true
|
||||
|
||||
cancelReconnectTimer()
|
||||
val timer = Runnable { attemptAutoReconnect() }
|
||||
reconnectTimer = timer
|
||||
handler.postDelayed(timer, reconnectIntervalMs)
|
||||
handler.postDelayed(timer, delay)
|
||||
}
|
||||
|
||||
private var reconnectScanCallback: ScanCallback? = null
|
||||
@@ -1264,7 +1294,9 @@ class BleManager private constructor(private val context: Context) {
|
||||
return
|
||||
}
|
||||
// max 시도 가드 — 무한 루프 방지. 초과 시 사용자가 명시적으로 재시도하도록.
|
||||
if (reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
|
||||
// 단 [reconnectForever] 면 포기하지 않는다(장시간 무인 측정). 간격은
|
||||
// scheduleAutoReconnect 가 늦춰 두었으므로 여기서는 통과만 시킨다.
|
||||
if (!reconnectForever && reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
|
||||
debugLogger.warn("RECONNECT_GIVE_UP after ${reconnectAttempt} attempts — manual retry required")
|
||||
isReconnecting.value = false
|
||||
reconnectAttempt = 0
|
||||
|
||||
@@ -143,6 +143,8 @@ object BatteryDrainTester {
|
||||
override fun run() {
|
||||
val b = ble
|
||||
if (b == null || !isRunning.value) return
|
||||
// 1초마다 도는 이 자리가 연결 전이를 가장 촘촘히 잡는 지점이다.
|
||||
noteConnection(b.isConnected.value)
|
||||
when {
|
||||
!b.isConnected.value -> imuSkipped.value++
|
||||
// mtb/mbb 응답 stream 진행 중 — imuCollector.reset() 이 그 rim 을 파괴하므로 skip.
|
||||
@@ -170,6 +172,27 @@ object BatteryDrainTester {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 직전에 관측한 연결 상태. null = 아직 한 번도 안 봄.
|
||||
*
|
||||
* `connected` 열만으로도 끊긴 구간은 보이지만, tick 이 1분 간격이라 **정확히 언제**
|
||||
* 끊기고 언제 붙었는지가 ±60초로 흐려진다. 50시간에서 회복이 몇 번 있었는지 세려면
|
||||
* 전이 시각이 필요하다.
|
||||
*/
|
||||
private var lastConnected: Boolean? = null
|
||||
|
||||
/** 연결 상태가 바뀐 순간에만 행 하나. [imuTick] 이 1초마다 불러 초 단위로 잡는다. */
|
||||
private fun noteConnection(connected: Boolean) {
|
||||
if (lastConnected == connected) return
|
||||
val first = lastConnected == null
|
||||
lastConnected = connected
|
||||
if (first) return // 시작 시점은 tick 이 이미 남긴다
|
||||
csvRow(if (connected) "reconnect" else "disconnect")
|
||||
ble?.debugLogger?.info(
|
||||
"BATT_DRAIN ${if (connected) "RECONNECTED" else "DISCONNECTED"} t=${elapsedMs() / 1000}s"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 1분 heartbeat — CSV 한 행 + BLE 로그 한 줄.
|
||||
*
|
||||
@@ -248,6 +271,10 @@ object BatteryDrainTester {
|
||||
startedAtMs.value = System.currentTimeMillis()
|
||||
isRunning.value = true
|
||||
bleManager.onResponseFrame = frameTap
|
||||
// 밤새 걸어 두는 측정이라 재연결을 포기하면 안 된다. 기본 정책(5회 ≈ 1분)은
|
||||
// 사용자가 옆에 있을 때 맞는 것이고, 여기서는 그 뒤로 몇 시간을 그냥 흘려보낸다
|
||||
// (2026-09-08: 프로브 freeze 뒤 39분 손실). 반드시 stop 에서 되돌린다.
|
||||
bleManager.reconnectForever = true
|
||||
openCsv(startedAtMs.value)
|
||||
bleManager.debugLogger.info(
|
||||
"BATT_DRAIN start · mim=${IMU_INTERVAL_MS}ms mbb=${FULL_INTERVAL_MS}ms " +
|
||||
@@ -268,6 +295,8 @@ object BatteryDrainTester {
|
||||
handler.removeCallbacks(sampleTick)
|
||||
closeCsv()
|
||||
ble?.let { b ->
|
||||
// 켠 쪽이 되돌린다 — 안 끄면 일반 사용에서도 무한 스캔이 돈다.
|
||||
b.reconnectForever = false
|
||||
if (b.onResponseFrame === frameTap) b.onResponseFrame = null
|
||||
b.debugLogger.info(
|
||||
"BATT_DRAIN stop · elapsed=${elapsedMs() / 1000}s " +
|
||||
@@ -288,6 +317,7 @@ object BatteryDrainTester {
|
||||
lastBatteryMv.value = 0; firstBatteryMv.value = 0
|
||||
lastTempC.value = Float.NaN
|
||||
lastRxAtMs.value = 0L
|
||||
lastConnected = null
|
||||
}
|
||||
|
||||
fun elapsedMs(): Long =
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* ClinicalBv — 6채널 신호 → 벽 검출 → 방광용적(BV). 임상 화면들이 공유하는 단일 진입점.
|
||||
*/
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import com.medithings.vesiscan.walldetect.MethodDRunner
|
||||
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDParams
|
||||
|
||||
/**
|
||||
* 임상 화면에서 BV 를 뽑는 **하나뿐인 통로**.
|
||||
*
|
||||
* ## 왜 필요한가
|
||||
* 검출과 BV 는 이미 다 있다([MethodDRunner] · [estimateBv]). 그런데 그것을 **올바른
|
||||
* 순서와 옵션으로 엮는 부분**이 [AnchorGuide.measure] 안에 묻혀 있어, 화면에서 BV 를
|
||||
* 보려면 그 조합을 다시 써야 했다. 조합을 두 번 쓰면 반드시 갈린다 — CCC on/off,
|
||||
* `missingOutside` 전달, `antRefined` vs `ant` 중 어느 것을 쓰는지가 전부 결과를 바꾼다.
|
||||
*
|
||||
* 그래서 AnchorGuide 의 `cap_frac` 산출과 **같은 경로**를 여기 한 번만 적고 둘 다 쓴다.
|
||||
* (SPEC §8.2: `cap_frac` 은 mean-scan 1회 검출 · **CCC on**.)
|
||||
*
|
||||
* ## 실패를 값으로 돌려준다
|
||||
* BV 가 안 나오는 것이 이 화면들의 주된 관심사다 — "왜 용적이 안 뜨나"를 그 자리에서
|
||||
* 답해야 한다. null 만 돌려주면 화면은 "—" 밖에 못 쓴다. [Outcome.failure] 가 사람이
|
||||
* 읽을 이유를 담고, [Outcome.walls] 는 실패했더라도 **검출된 만큼**을 그대로 준다.
|
||||
* 파형 위의 전벽·후벽 마커가 그 값을 쓴다.
|
||||
*/
|
||||
object ClinicalBv {
|
||||
|
||||
/**
|
||||
* 한 채널의 검출 결과. 화면이 파형 위에 그리고, 표로도 보여준다.
|
||||
*
|
||||
* [ant]·[post] 는 **샘플 인덱스**(정수)다 — 파형 x축이 샘플이므로 그대로 그린다.
|
||||
* [antMm]·[postMm] 는 같은 값을 거리로 옮긴 것이고, [diameterMm] 는 BV 가 실제로
|
||||
* 쓰는 단면 직경이다(각도 보정 포함). 셋을 같이 두는 이유는 "파형에서는 벽이
|
||||
* 보이는데 용적이 이상하다"는 상황에서 어느 단계가 틀렸는지 갈라야 하기 때문이다.
|
||||
*/
|
||||
data class ChannelWall(
|
||||
val channel: Int,
|
||||
val ant: Int,
|
||||
val post: Int,
|
||||
val antMm: Double,
|
||||
val postMm: Double,
|
||||
/** BV 파이프라인이 산출한 단면 직경. BV 실패 시 null. */
|
||||
val diameterMm: Double?,
|
||||
/** BV 계산에 실제로 쓰였는가. 검출됐어도 제외될 수 있다. */
|
||||
val usedInBv: Boolean,
|
||||
)
|
||||
|
||||
data class Outcome(
|
||||
/** null = 계산 실패. 이유는 [failure]. */
|
||||
val volumeMl: Double?,
|
||||
val walls: List<ChannelWall?>,
|
||||
/** 검출된 채널 수(CH0~CH3 기준). 화면이 "4채널 중 n" 로 쓴다. */
|
||||
val detectedCenter: Int,
|
||||
val failure: String?,
|
||||
/** 파형 마커용 (전벽, 후벽) 인덱스. 미검출 채널은 null. */
|
||||
val markers: List<Pair<Int, Int>?>,
|
||||
val raw: BVResult?,
|
||||
) {
|
||||
val ok: Boolean get() = volumeMl != null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param signals 채널 순으로 정렬된 6채널 신호. 여러 cycle 을 평균한 것을 넣는다
|
||||
* ([meanScan]) — 한 cycle 만으로 재면 노이즈가 그대로 벽으로 잡힌다.
|
||||
*/
|
||||
fun compute(
|
||||
signals: List<DoubleArray>,
|
||||
params: MethodDParams = MethodDParams.DEFAULT,
|
||||
supine: Boolean = false,
|
||||
): Outcome {
|
||||
if (signals.size < PiezoHW.centerCh.size) {
|
||||
return Outcome(null, emptyList(), 0, "채널 부족 (${signals.size}/6)", emptyList(), null)
|
||||
}
|
||||
|
||||
// AnchorGuide.measure 의 cap_frac 경로와 **같은 호출**이다. CCC on.
|
||||
val outcome = MethodDRunner.detectMultichannelWithEvidence(
|
||||
signals, params, applyTgc = true, applyCross = true,
|
||||
)
|
||||
val detected = outcome.walls
|
||||
val markers = detected.map { r -> r?.let { it.ant to it.post } }
|
||||
val centerN = PiezoHW.centerCh.count { it < detected.size && detected[it] != null }
|
||||
|
||||
val spans = detected.map { r ->
|
||||
r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) }
|
||||
}
|
||||
val bv = estimateBv(spans, missingOutside = outcome.missingOutside, supine = supine)
|
||||
|
||||
// 직경은 BV 가 valid 로 잡은 채널만 낸다. 순서는 validChannels 와 1:1이다.
|
||||
val dByCh = HashMap<Int, Double>()
|
||||
bv?.let { r ->
|
||||
r.validChannels.forEachIndexed { i, ch ->
|
||||
if (i < r.dMm.size) dByCh[ch] = r.dMm[i]
|
||||
}
|
||||
}
|
||||
|
||||
val dps = PiezoHW.distancePerSample
|
||||
val off = PiezoHW.delayOffsetMm
|
||||
val walls = detected.mapIndexed { ch, r ->
|
||||
r?.let {
|
||||
ChannelWall(
|
||||
channel = ch,
|
||||
ant = it.ant,
|
||||
post = it.post,
|
||||
antMm = it.antRefined * dps + off,
|
||||
postMm = it.postRefined * dps + off,
|
||||
diameterMm = dByCh[ch],
|
||||
usedInBv = bv?.validChannels?.contains(ch) == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return Outcome(
|
||||
volumeMl = bv?.volumeMl,
|
||||
walls = walls,
|
||||
detectedCenter = centerN,
|
||||
failure = failureOf(bv, centerN, detected.size),
|
||||
markers = markers,
|
||||
raw = bv,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* BV 가 안 나온 이유를 한 줄로.
|
||||
*
|
||||
* "측정 실패"로 뭉뚱그리면 간호사도 개발자도 다음 행동을 못 정한다. 검출이 안 된
|
||||
* 것(부착 문제)과 검출은 됐는데 기하가 안 풀린 것(위치·자세 문제)은 대응이 다르다.
|
||||
*/
|
||||
private fun failureOf(bv: BVResult?, centerN: Int, chCount: Int): String? = when {
|
||||
bv != null -> null
|
||||
centerN == 0 -> "벽이 하나도 검출되지 않았습니다 — 부착 상태를 확인하세요."
|
||||
centerN < 2 -> "검출된 채널이 ${centerN}개뿐입니다 (최소 2개 필요) — 위치를 조정하세요."
|
||||
else -> "채널 ${centerN}개가 검출됐지만 용적이 계산되지 않았습니다 — " +
|
||||
"벽 위치가 기하적으로 맞지 않습니다."
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 cycle 의 채널별 평균. [AnchorGuide] 의 mean-scan 과 같은 규약이다.
|
||||
*
|
||||
* @param cycles (cycle, 채널, 샘플)
|
||||
*/
|
||||
fun meanScan(cycles: List<List<DoubleArray>>): List<DoubleArray> {
|
||||
if (cycles.isEmpty()) return emptyList()
|
||||
val nCh = cycles[0].size
|
||||
return List(nCh) { ch ->
|
||||
val len = cycles.minOf { it[ch].size }
|
||||
DoubleArray(len) { i ->
|
||||
var s = 0.0
|
||||
for (c in cycles) s += c[ch][i]
|
||||
s / cycles.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** UShort 버퍼(BLE 수신 형식)를 계산용 실수 배열로. */
|
||||
fun toSignals(channels: List<List<UShort>>): List<DoubleArray> =
|
||||
channels.map { buf -> DoubleArray(buf.size) { buf[it].toDouble() } }
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* BvPanel — 방광용적과 채널별 전벽·후벽. 임상 화면들이 공유한다.
|
||||
*/
|
||||
package com.medithings.vesiscan.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.ClinicalBv
|
||||
import com.medithings.vesiscan.ui.theme.MlCritical
|
||||
import com.medithings.vesiscan.ui.theme.MlPrimary
|
||||
import com.medithings.vesiscan.ui.theme.MlSecondaryText
|
||||
import com.medithings.vesiscan.ui.theme.MlSuccess
|
||||
|
||||
/**
|
||||
* 용적 + 채널별 벽.
|
||||
*
|
||||
* ## 실패했을 때가 더 중요하다
|
||||
* 이 화면을 보는 이유는 "용적이 왜 안 나오나"이지 정상값 확인이 아니다. 그래서 실패
|
||||
* 사유를 숫자 자리에 그대로 띄우고, 채널별 표는 **실패해도 검출된 만큼** 보여준다 —
|
||||
* 어느 채널이 빠졌는지가 곧 답이다.
|
||||
*
|
||||
* ## 표에 세 가지를 같이 둔다
|
||||
* 샘플 인덱스(파형에서 보이는 것) · mm(거리로 옮긴 것) · 직경(BV 가 실제로 쓴 것).
|
||||
* "파형에는 벽이 보이는데 용적이 이상하다"는 상황에서 어느 단계가 틀렸는지 갈리려면
|
||||
* 셋이 나란히 있어야 한다. 직경이 비어 있으면 그 채널은 검출은 됐지만 BV 계산에서
|
||||
* 빠진 것이다.
|
||||
*/
|
||||
@Composable
|
||||
fun BvPanel(
|
||||
outcome: ClinicalBv.Outcome?,
|
||||
modifier: Modifier = Modifier,
|
||||
/** 위에 붙일 라벨. 화면마다 다르다(정렬 위치 / Spot / 연속). */
|
||||
caption: String? = null,
|
||||
compact: Boolean = false,
|
||||
) {
|
||||
val tone = when {
|
||||
outcome == null -> MlSecondaryText
|
||||
outcome.ok -> MlSuccess
|
||||
else -> MlCritical
|
||||
}
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.White, RoundedCornerShape(12.dp))
|
||||
.border(1.5.dp, tone.copy(alpha = 0.45f), RoundedCornerShape(12.dp))
|
||||
.padding(12.dp)
|
||||
) {
|
||||
caption?.let {
|
||||
Text(it, fontSize = 12.sp, color = MlSecondaryText)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// ── 용적 ──
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
outcome?.volumeMl?.let { "%.1f".format(it) } ?: "—",
|
||||
fontSize = if (compact) 30.sp else 38.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = tone,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("mL", fontSize = 15.sp, color = MlSecondaryText,
|
||||
modifier = Modifier.padding(bottom = 5.dp))
|
||||
Spacer(Modifier.weight(1f))
|
||||
outcome?.let {
|
||||
Text("검출 ${it.detectedCenter}/4", fontSize = 13.sp, color = MlSecondaryText,
|
||||
modifier = Modifier.padding(bottom = 6.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── 실패 사유 ──
|
||||
// "측정 실패"로 뭉뚱그리면 다음 행동을 못 정한다. 검출이 안 된 것(부착 문제)과
|
||||
// 검출은 됐는데 기하가 안 풀린 것(위치 문제)은 대응이 다르다.
|
||||
outcome?.failure?.let {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(it, fontSize = 12.sp, color = MlCritical)
|
||||
}
|
||||
|
||||
if (outcome == null || outcome.walls.isEmpty()) return@Column
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// ── 채널별 벽 ──
|
||||
Row(Modifier.fillMaxWidth().height(20.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
HeadCell("", 38.dp); HeadCell("전벽", 62.dp); HeadCell("후벽", 62.dp)
|
||||
HeadCell("두께", 66.dp); HeadCell("직경", 62.dp)
|
||||
}
|
||||
outcome.walls.forEachIndexed { ch, w ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(21.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("CH$ch", fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = if (w == null) MlSecondaryText else MlPrimary,
|
||||
modifier = Modifier.width(38.dp))
|
||||
if (w == null) {
|
||||
Text("미검출", fontSize = 12.sp, color = MlCritical)
|
||||
} else {
|
||||
Cell("${w.ant}", 62.dp)
|
||||
Cell("${w.post}", 62.dp)
|
||||
Cell("%.1f".format(w.postMm - w.antMm), 66.dp)
|
||||
// 직경이 비었으면 검출은 됐지만 BV 에서 빠진 채널이다.
|
||||
Cell(w.diameterMm?.let { "%.1f".format(it) } ?: "제외", 62.dp,
|
||||
color = if (w.usedInBv) MlSecondaryText else MlCritical)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("전벽·후벽은 샘플 번호, 두께·직경은 mm. 직경이 \"제외\"면 검출은 됐지만 용적 계산에서 빠진 채널입니다.",
|
||||
fontSize = 10.sp, color = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeadCell(text: String, w: androidx.compose.ui.unit.Dp) {
|
||||
Text(text, fontSize = 10.sp, color = MlSecondaryText, modifier = Modifier.width(w))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Cell(text: String, w: androidx.compose.ui.unit.Dp, color: Color = MlSecondaryText) {
|
||||
Text(text, fontSize = 12.sp, color = color, modifier = Modifier.width(w))
|
||||
}
|
||||
@@ -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.BvPanel
|
||||
import com.medithings.vesiscan.ui.components.ChannelDetachPanel
|
||||
import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid
|
||||
import com.medithings.vesiscan.AppScreen
|
||||
@@ -51,6 +52,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.ClinicalBv
|
||||
import com.medithings.vesiscan.managers.DetachWatcher
|
||||
import com.medithings.vesiscan.managers.LateralGuide
|
||||
import com.medithings.vesiscan.models.HospitalFixedParams
|
||||
@@ -118,6 +120,12 @@ fun AnchorAlignView(appState: AppState) {
|
||||
/** 위치를 한 번이라도 저장했는가 — 올릴 것이 있는지 판단. */
|
||||
var hasAlignData by remember { mutableStateOf(false) }
|
||||
|
||||
// ── 방광 용적 ────────────────────────────────────────────────────────────
|
||||
// 정렬은 지표(nch·ch3·cap_frac)로 위치를 고르는데, 그 지표가 좋다고 BV 가
|
||||
// 타당한지는 별개다. 위치마다 실제 용적을 같이 보여줘야 "이 자리가 맞나"를
|
||||
// 사람이 판단할 수 있다 — 알고리즘이 고른 위치의 검증이 이 화면의 목적이다.
|
||||
var bv by remember { mutableStateOf<ClinicalBv.Outcome?>(null) }
|
||||
|
||||
// ── 채널 접촉 ────────────────────────────────────────────────────────────
|
||||
// 정렬은 "어디에 붙일지"를 정하는 단계라, 떠 있는 채널이 있으면 그 위치의 지표
|
||||
// (nch·ch3·cap_frac) 자체가 의미를 잃는다. 위치를 고르기 **전에** 알아야 한다.
|
||||
@@ -162,6 +170,7 @@ fun AnchorAlignView(appState: AppState) {
|
||||
progress = 0
|
||||
// 위치를 옮겨 다시 재는 것이므로 옛 확정 상태는 무효다.
|
||||
detachWatcher.reset(); detachSnapshot = null
|
||||
bv = null
|
||||
|
||||
val inbox = kotlinx.coroutines.channels.Channel<
|
||||
List<com.medithings.vesiscan.ble.PiezoChannelData>>(
|
||||
@@ -232,6 +241,10 @@ fun AnchorAlignView(appState: AppState) {
|
||||
error = "정렬 원시 데이터 저장 실패 (${cm}cm) — 저장 공간을 확인하세요."
|
||||
}
|
||||
|
||||
// 판정과 **같은 mean-scan** 으로 BV 를 낸다. 따로 재면 그 위치의 지표와
|
||||
// 용적이 다른 데이터에서 나온 값이 되어 대조가 성립하지 않는다.
|
||||
bv = ClinicalBv.compute(ClinicalBv.meanScan(cycles))
|
||||
|
||||
val step = guide.step(cycles, cm)
|
||||
last = step
|
||||
records = guide.records
|
||||
@@ -508,6 +521,14 @@ fun AnchorAlignView(appState: AppState) {
|
||||
// ── 채널 파형 ───────────────────────────────────────────────────
|
||||
// 측정 중에도, 끝난 뒤에도 보여준다. 정렬은 위치를 옮겨 가며 반복하는
|
||||
// 작업이라 "방금 이 위치에서 뭐가 잡혔는지"를 보고 다음을 정한다.
|
||||
if (bv != null) {
|
||||
Text("이 위치의 방광 용적", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = MlSecondaryText)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
BvPanel(bv, caption = "${cm}cm · 20회 평균")
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (detachSnapshot != null) {
|
||||
Text("채널 접촉", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = MlSecondaryText)
|
||||
@@ -543,6 +564,9 @@ fun AnchorAlignView(appState: AppState) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
SixChannelWaveformGrid(
|
||||
channels = liveChannels,
|
||||
// 전벽·후벽 마커. BV 가 안 나올 때 어느 채널의 벽이 어디로 잡혔는지가
|
||||
// 원인 분석의 출발점이다.
|
||||
walls = bv?.markers ?: emptyList(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
cellHeight = 140.dp,
|
||||
compact = true,
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* BvMeasureSection — Spot / Continuous 방광용적 측정. 병원 임상 화면에 붙는다.
|
||||
*/
|
||||
package com.medithings.vesiscan.ui.views.clinical
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
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.ble.BleManager
|
||||
import com.medithings.vesiscan.ble.PiezoChannelData
|
||||
import com.medithings.vesiscan.managers.ClinicalBv
|
||||
import com.medithings.vesiscan.models.HospitalFixedParams
|
||||
import com.medithings.vesiscan.models.ProbeCycle
|
||||
import com.medithings.vesiscan.models.ProbeFrequency
|
||||
import com.medithings.vesiscan.ui.components.BvPanel
|
||||
import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* 한 번 재는 것(Spot)과 계속 재는 것(Continuous).
|
||||
*
|
||||
* ## 왜 두 모드인가
|
||||
* **Spot** 은 "지금 이 자세·이 충만도에서 얼마인가"를 한 번 확인하는 것이다. 정렬이
|
||||
* 맞는지, 프로토콜을 돌리기 전에 값이 말이 되는지를 본다.
|
||||
*
|
||||
* **Continuous** 는 값이 **흔들리는지**를 본다. 한 번 재서 나온 200mL 가 진짜인지,
|
||||
* 아니면 그 순간만 그런지는 한 번으로 알 수 없다. 정렬 위치가 최적인지 판단하려면
|
||||
* 반복 재현성이 필요하고, 그건 연속으로 재야 보인다.
|
||||
*
|
||||
* ## 평균 몇 회로 재는가
|
||||
* [AVG_CYCLES] 회를 모아 mean-scan 한 뒤 한 번 계산한다. 1 cycle 로 재면 노이즈가
|
||||
* 그대로 벽으로 잡혀 값이 튄다 — 정렬(20 cycle)과 같은 이유이고, 실시간성을 위해
|
||||
* 그보다 적게 잡았다.
|
||||
*
|
||||
* ## 측정 조건은 정렬과 같게 고정한다
|
||||
* 2.3MHz · cycle 3. 검출 문턱이 절대값 기준이라 조건이 바뀌면 `nch` 가 달라지고,
|
||||
* 그러면 정렬이 고른 위치의 근거와 여기서 나온 BV 가 다른 조건의 값이 된다.
|
||||
*/
|
||||
private val BV_FREQ = ProbeFrequency.F_2_3
|
||||
private val BV_CYCLE = ProbeCycle.C3
|
||||
|
||||
/** 한 번의 BV 산출에 모으는 cycle 수. */
|
||||
private const val AVG_CYCLES = 5
|
||||
|
||||
/** 연속 모드에서 한 회 산출을 마친 뒤 쉬는 시간. */
|
||||
private const val CONTINUOUS_GAP_MS = 500L
|
||||
|
||||
private const val MEASURE_TIMEOUT_MS = 3_000L
|
||||
private const val CONFIG_TIMEOUT_MS = 3_000L
|
||||
private const val SETTLE_MS = 600L
|
||||
private const val CYCLE_GAP_MS = 300L
|
||||
|
||||
/** 연속 모드가 화면에 들고 있는 최근 값 개수. */
|
||||
private const val HISTORY_MAX = 20
|
||||
|
||||
@Composable
|
||||
fun BvMeasureSection(
|
||||
bleManager: BleManager,
|
||||
isConnected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
/** 자세가 누움이면 BV 파이프라인이 nch=2 clamp 와 F83 에서 그것을 본다. */
|
||||
supine: Boolean = false,
|
||||
) {
|
||||
var mode by remember { mutableStateOf<Mode?>(null) } // null = 정지
|
||||
var outcome by remember { mutableStateOf<ClinicalBv.Outcome?>(null) }
|
||||
var live by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) }
|
||||
var progress by remember { mutableIntStateOf(0) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var history by remember { mutableStateOf<List<Double>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(mode) {
|
||||
val m = mode ?: return@LaunchedEffect
|
||||
error = null
|
||||
val inbox = kotlinx.coroutines.channels.Channel<List<PiezoChannelData>>(
|
||||
kotlinx.coroutines.channels.Channel.CONFLATED)
|
||||
// 이 구간이 프로브 설정을 바꾸므로 끝나면 되돌린다 — 정렬·병원 모드와 같은 이유.
|
||||
val guard = ProbeConfigGuard(bleManager)
|
||||
guard.capture()
|
||||
val prev = bleManager.piezoCollector.onMultiChannelComplete
|
||||
bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) }
|
||||
try {
|
||||
bleManager.piezoConfigEcho.value = null
|
||||
bleManager.sendPiezoConfig(
|
||||
freq = BV_FREQ.freqOption, cycles = BV_CYCLE.cycles,
|
||||
avg = HospitalFixedParams.AVG, delayUs = HospitalFixedParams.DELAY_US,
|
||||
samples = HospitalFixedParams.SAMPLES,
|
||||
)
|
||||
val echo = withTimeoutOrNull(CONFIG_TIMEOUT_MS) {
|
||||
while (bleManager.piezoConfigEcho.value == null) delay(20)
|
||||
bleManager.piezoConfigEcho.value
|
||||
}
|
||||
if (echo?.matches(
|
||||
BV_FREQ.freqOption, BV_CYCLE.cycles, HospitalFixedParams.AVG,
|
||||
HospitalFixedParams.DELAY_US, HospitalFixedParams.SAMPLES,
|
||||
) != true
|
||||
) {
|
||||
error = "프로브 설정 실패 — ${echo?.error ?: "응답 없음"}"
|
||||
mode = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
delay(SETTLE_MS)
|
||||
|
||||
do {
|
||||
val cycles = ArrayList<List<DoubleArray>>(AVG_CYCLES)
|
||||
progress = 0
|
||||
var aborted = false
|
||||
for (i in 1..AVG_CYCLES) {
|
||||
if (mode == null) { aborted = true; break }
|
||||
while (inbox.tryReceive().isSuccess) { /* 늦게 온 것 비우기 */ }
|
||||
bleManager.sendMtb()
|
||||
val got = withTimeoutOrNull(MEASURE_TIMEOUT_MS) { inbox.receive() }
|
||||
if (got == null) {
|
||||
// 한 회 놓쳤다고 멈추지 않는다 — 연속 모드는 계속 돌아야 한다.
|
||||
error = "응답 시간 초과 — 다시 시도합니다."
|
||||
aborted = true
|
||||
break
|
||||
}
|
||||
val sorted = got.sortedBy { it.channel }
|
||||
live = sorted
|
||||
cycles.add(ClinicalBv.toSignals(sorted.map { it.buffer }))
|
||||
progress = i
|
||||
delay(CYCLE_GAP_MS)
|
||||
}
|
||||
if (!aborted && cycles.size == AVG_CYCLES) {
|
||||
error = null
|
||||
val r = ClinicalBv.compute(ClinicalBv.meanScan(cycles), supine = supine)
|
||||
outcome = r
|
||||
r.volumeMl?.let { v ->
|
||||
history = (history + v).takeLast(HISTORY_MAX)
|
||||
}
|
||||
}
|
||||
if (m == Mode.SPOT) break
|
||||
delay(CONTINUOUS_GAP_MS)
|
||||
} while (mode == Mode.CONTINUOUS)
|
||||
} finally {
|
||||
bleManager.piezoCollector.onMultiChannelComplete = prev
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { guard.restore() }
|
||||
inbox.close()
|
||||
if (mode == Mode.SPOT) mode = null
|
||||
}
|
||||
}
|
||||
|
||||
val running = mode != null
|
||||
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("방광 용적 (BV)", fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("${BV_FREQ.label}MHz · cycle ${BV_CYCLE.cycles} · ${AVG_CYCLES}회 평균",
|
||||
fontSize = 11.sp, color = MlSecondaryText)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Button(
|
||||
onClick = { if (mode == Mode.SPOT) mode = null else mode = Mode.SPOT },
|
||||
enabled = isConnected && mode != Mode.CONTINUOUS,
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) { Text(if (mode == Mode.SPOT) "측정 중…" else "1회 측정", fontWeight = FontWeight.Bold) }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { mode = if (mode == Mode.CONTINUOUS) null else Mode.CONTINUOUS },
|
||||
enabled = isConnected && mode != Mode.SPOT,
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (mode == Mode.CONTINUOUS) MlCritical else MlSuccess),
|
||||
) {
|
||||
Text(if (mode == Mode.CONTINUOUS) "연속 중지" else "연속 측정",
|
||||
color = Color.White, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
if (running) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { progress / AVG_CYCLES.toFloat() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
error?.let {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(it, fontSize = 12.sp, color = MlCritical)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
BvPanel(
|
||||
outcome = outcome,
|
||||
caption = when (mode) {
|
||||
Mode.CONTINUOUS -> "연속 측정 중"
|
||||
Mode.SPOT -> "측정 중"
|
||||
null -> if (outcome == null) "측정을 시작하면 값이 나옵니다." else "마지막 측정"
|
||||
},
|
||||
)
|
||||
|
||||
// ── 연속 측정 재현성 ──
|
||||
// 정렬 위치가 최적인지 판단하려면 값 하나가 아니라 **흔들림**을 봐야 한다.
|
||||
// 평균만 보이면 "200mL 나왔다"로 끝나는데, 편차가 30mL 면 그 위치는 못 쓴다.
|
||||
if (history.size >= 2) {
|
||||
val mean = history.average()
|
||||
val sd = kotlin.math.sqrt(history.sumOf { (it - mean) * (it - mean) } / history.size)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Box(
|
||||
Modifier.fillMaxWidth()
|
||||
.background(MlPrimary.copy(alpha = 0.07f), RoundedCornerShape(10.dp))
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"최근 ${history.size}회 — 평균 %.1f mL · 표준편차 %.1f mL (CV %.1f%%)"
|
||||
.format(mean, sd, if (mean > 0) sd / mean * 100 else 0.0),
|
||||
fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
"범위 %.1f ~ %.1f mL".format(history.min(), history.max()),
|
||||
fontSize = 11.sp, color = MlSecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 파형 + 벽 마커 ──
|
||||
// BV 가 안 나올 때 원인을 여기서 본다. 초록·주황 세로선이 전벽·후벽이다.
|
||||
if (live.isNotEmpty()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("채널 파형 · 전벽(초록) 후벽(주황)", fontSize = 12.sp, color = MlSecondaryText)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
SixChannelWaveformGrid(
|
||||
channels = live,
|
||||
walls = outcome?.markers ?: emptyList(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
cellHeight = 150.dp,
|
||||
compact = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class Mode { SPOT, CONTINUOUS }
|
||||
@@ -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.managers.ClinicalBv
|
||||
import com.medithings.vesiscan.ui.components.ChannelDetachPanel
|
||||
import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid
|
||||
import com.medithings.vesiscan.AppScreen
|
||||
@@ -127,6 +128,11 @@ fun HospitalModeView(appState: AppState) {
|
||||
var uploadBusy by remember { mutableStateOf(false) }
|
||||
var uploadMsg by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// 측정 중 마지막 회차의 벽·용적. 프로토콜 루프는 저장이 목적이라 BV 를 쓰지
|
||||
// 않지만, 값이 말이 되는지는 그 자리에서 보여야 한다 — 20회를 다 돌린 뒤에
|
||||
// 알면 그 단계는 다시 못 잰다.
|
||||
var runBv by remember { mutableStateOf<ClinicalBv.Outcome?>(null) }
|
||||
|
||||
val detachWatcher = remember { com.medithings.vesiscan.managers.DetachWatcher() }
|
||||
var detachSnapshot by remember {
|
||||
mutableStateOf<com.medithings.vesiscan.managers.DetachWatcher.Snapshot?>(null)
|
||||
@@ -244,6 +250,12 @@ fun HospitalModeView(appState: AppState) {
|
||||
// 접촉 판정도 같은 데이터로 낸다 — 화면 파형과 판정이 어긋나면
|
||||
// 간호사가 어느 쪽을 믿어야 할지 알 수 없다.
|
||||
detachWatcher.push(raw)?.let { detachSnapshot = it }
|
||||
// 한 회차 신호로 바로 낸다. 여기서 여러 회를 모아 평균하면
|
||||
// 저장 루프의 주기가 흔들려 회차 간격이 달라진다.
|
||||
runBv = ClinicalBv.compute(
|
||||
ClinicalBv.toSignals(raw),
|
||||
supine = posture == ClinicalPosture.SUPINE,
|
||||
)
|
||||
val saved = HospitalRunStore.appendMeasurement(
|
||||
file = file,
|
||||
scanId = AdcCsvLogger.nextScanId(),
|
||||
@@ -487,6 +499,12 @@ fun HospitalModeView(appState: AppState) {
|
||||
//
|
||||
// 셀을 작게 잡는다(140dp). 이 화면의 주인공은 진행 상황과 중지 버튼이고
|
||||
// 파형은 곁눈질로 보는 것이라, 크게 잡으면 중지 버튼이 화면 밖으로 밀린다.
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Label("방광 용적")
|
||||
Spacer(Modifier.height(6.dp))
|
||||
com.medithings.vesiscan.ui.components.BvPanel(
|
||||
runBv, caption = "직전 회차", compact = true)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Label("채널 접촉")
|
||||
Spacer(Modifier.height(6.dp))
|
||||
@@ -497,6 +515,7 @@ fun HospitalModeView(appState: AppState) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
SixChannelWaveformGrid(
|
||||
channels = liveChannels,
|
||||
walls = runBv?.markers ?: emptyList(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
cellHeight = 140.dp,
|
||||
compact = true,
|
||||
@@ -506,12 +525,23 @@ fun HospitalModeView(appState: AppState) {
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
} else {
|
||||
// ── BV 측정 (Spot / Continuous) ─────────────────────────────
|
||||
// 프로토콜을 돌리기 **전에** 값이 말이 되는지 확인하는 자리다.
|
||||
// 정렬이 고른 위치가 실제로 최적인지는 그 위치의 BV 로만 판단할 수 있다.
|
||||
BvMeasureSection(
|
||||
bleManager = bleManager,
|
||||
isConnected = isConnected,
|
||||
supine = posture == ClinicalPosture.SUPINE,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
comboIdx = 0; repeatIdx = 0
|
||||
// 지난 실행의 확정 상태가 남으면 새 부착 위치의 첫 판정이
|
||||
// 거기에 끌려간다 — 프로브를 다시 붙였을 수 있다.
|
||||
detachWatcher.reset(); detachSnapshot = null
|
||||
runBv = null
|
||||
running = true
|
||||
},
|
||||
enabled = canStart,
|
||||
|
||||
Reference in New Issue
Block a user