feat(hospital): 부착 위치 정렬 화면에도 프로브 기울기 카드를 둔다

Sitting 은 35°~55° 에서 재는데, 프로브를 붙이고 각도를 잡는 일은 정렬 단계에서
일어난다. 카드가 병원 임상 측정 화면에만 있으면 정렬을 끝내고 돌아와서야 각도가
틀린 것을 알게 된다 — 그 자리에서 보여야 한다.

정렬 화면(AnchorAlignView):
  · 연결 상태 줄 아래에 ImuTiltCard. 자세는 이 화면에서 고르지 않으므로
    AppState.clinicalPosture 기준임을 한 줄로 적는다.
  · 쉬는 동안 `mim` 폴링, 위치 측정(20 cycle) 중에는 회차 IMU, 좌우 정렬(연속
    스트리밍) 중에는 프레임 IMU 로 갱신. 좌우 루프에도 ImuSidecar 를 걸되 저장은 안
    한다 — 좌우는 원래 프레임을 저장하지 않는 단계다.
  · 폴링은 measuring·lrRunning 어느 쪽이 돌든 끈다. `mim` 이 끼면 `imuCollector.reset()`
    이 `mtb` 의 IMU 를 지운다.

공통:
  · 폴링 루프를 LiveTilt.kt `pollLiveTilt()` 로 뽑아 두 화면이 같은 것을 쓴다.
    HospitalModeView 의 자체 루프·IDLE_TILT_POLL_MS 는 이것으로 대체.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 15:18:32 +09:00
parent e6ef235784
commit c667597e2b
4 changed files with 99 additions and 28 deletions
@@ -69,6 +69,7 @@ import com.medithings.vesiscan.managers.AnchorSelection
import com.medithings.vesiscan.managers.AnchorStep import com.medithings.vesiscan.managers.AnchorStep
import com.medithings.vesiscan.managers.ClinicalBv import com.medithings.vesiscan.managers.ClinicalBv
import com.medithings.vesiscan.managers.DetachWatcher import com.medithings.vesiscan.managers.DetachWatcher
import com.medithings.vesiscan.managers.ImuPostureClassifier
import com.medithings.vesiscan.managers.LateralGuide import com.medithings.vesiscan.managers.LateralGuide
import com.medithings.vesiscan.models.ClinicalPosture import com.medithings.vesiscan.models.ClinicalPosture
import com.medithings.vesiscan.models.HospitalFixedParams import com.medithings.vesiscan.models.HospitalFixedParams
@@ -107,6 +108,14 @@ fun AnchorAlignView(appState: AppState) {
var cm by remember { mutableIntStateOf(0) } var cm by remember { mutableIntStateOf(0) }
var measuring by remember { mutableStateOf(false) } var measuring by remember { mutableStateOf(false) }
var progress by remember { mutableIntStateOf(0) } var progress by remember { mutableIntStateOf(0) }
/**
* 프로브 기울기(도) — 상시 표시 (2026-09-28 요청, 병원 화면과 같은 카드). 쉬는 동안은
* 아래 폴링이, 위치 측정·좌우 정렬 중에는 각 루프가 회차마다 채운다.
*
* 정렬 화면에 두는 이유: Sitting 은 35°~55° 에서 재는데, 프로브를 붙이고 각도를 잡는
* 일은 **정렬에서** 일어난다. 여기서 각도를 맞춰 두면 임상 측정은 그 자리 그대로다.
*/
var liveTilt by remember { mutableStateOf<Float?>(null) }
var last by remember { mutableStateOf<AnchorStep?>(null) } var last by remember { mutableStateOf<AnchorStep?>(null) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var records by remember { mutableStateOf<List<AnchorPosRecord>>(emptyList()) } var records by remember { mutableStateOf<List<AnchorPosRecord>>(emptyList()) }
@@ -237,6 +246,17 @@ fun AnchorAlignView(appState: AppState) {
if (records.isNotEmpty()) disconnectedMidRun = true if (records.isNotEmpty()) disconnectedMidRun = true
} }
// ── 기울기 폴링 (쉬는 동안) ──────────────────────────────────────────
// 위치 측정(20 cycle)·좌우 정렬(연속 스트리밍) 어느 쪽이 돌든 끈다 — 둘 다 `mtb` 를
// 보내고 그 응답의 IMU 로 기울기를 채우므로, `mim` 이 끼면 그쪽 IMU 가 지워진다.
LaunchedEffect(isConnected, measuring, lrRunning) {
if (!isConnected || measuring || lrRunning) {
if (!isConnected) liveTilt = null
return@LaunchedEffect
}
pollLiveTilt(bleManager) { liveTilt = it }
}
// ── 떠날 때 정렬을 올린다 ─────────────────────────────────────────────── // ── 떠날 때 정렬을 올린다 ───────────────────────────────────────────────
// 종전에는 정렬 업로드가 **수동**이었다. 그 버튼의 원래 용도가 "파형이 이상할 때 // 종전에는 정렬 업로드가 **수동**이었다. 그 버튼의 원래 용도가 "파형이 이상할 때
// 개발자에게 보내기"(메모를 받는 다이얼로그)라, 정상적으로 정렬을 끝내면 601 이 // 개발자에게 보내기"(메모를 받는 다이얼로그)라, 정상적으로 정렬을 끝내면 601 이
@@ -328,6 +348,7 @@ fun AnchorAlignView(appState: AppState) {
// IMU 는 piezo 뒤에 온다. 없으면 빈 목록 — 측정을 실패로 돌리지 않는다. // IMU 는 piezo 뒤에 온다. 없으면 빈 목록 — 측정을 실패로 돌리지 않는다.
val imu = imuSide.await() ?: emptyList() val imu = imuSide.await() ?: emptyList()
cyclesImu.add(imu) cyclesImu.add(imu)
if (imu.isNotEmpty()) liveTilt = ImuPostureClassifier.classify(imu).pitchDeg
android.util.Log.d("AnchorAlign", "cycle $i/${AnchorConfig.ALIGN_CYCLES} imu=${imu.size}") android.util.Log.d("AnchorAlign", "cycle $i/${AnchorConfig.ALIGN_CYCLES} imu=${imu.size}")
val sorted = got.sortedBy { it.channel } val sorted = got.sortedBy { it.channel }
liveChannels = sorted // 화면 파형 — 저장·판정에 쓰는 것과 같은 데이터 liveChannels = sorted // 화면 파형 — 저장·판정에 쓰는 것과 같은 데이터
@@ -426,6 +447,11 @@ fun AnchorAlignView(appState: AppState) {
guard.capture() guard.capture()
val prev = bleManager.piezoCollector.onMultiChannelComplete val prev = bleManager.piezoCollector.onMultiChannelComplete
bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) } bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) }
// 좌우 정렬은 사람이 프로브를 움직이는 중이라 기울기가 제일 많이 바뀌는 때다.
// mtb 응답의 IMU 로 회차마다 카드를 갱신한다 — 저장은 하지 않는다(좌우는 원래
// 프레임을 저장하지 않는 단계다).
val lrImu = ImuSidecar(bleManager)
lrImu.install()
try { try {
bleManager.piezoConfigEcho.value = null bleManager.piezoConfigEcho.value = null
bleManager.sendPiezoConfig( bleManager.sendPiezoConfig(
@@ -452,6 +478,7 @@ fun AnchorAlignView(appState: AppState) {
while (lrRunning) { while (lrRunning) {
while (inbox.tryReceive().isSuccess) { /* 늦게 온 직전 결과 비우기 */ } while (inbox.tryReceive().isSuccess) { /* 늦게 온 직전 결과 비우기 */ }
lrImu.drain()
bleManager.sendMtb() bleManager.sendMtb()
val got = withTimeoutOrNull(MEASURE_TIMEOUT_MS) { inbox.receive() } val got = withTimeoutOrNull(MEASURE_TIMEOUT_MS) { inbox.receive() }
if (got == null) { if (got == null) {
@@ -459,6 +486,9 @@ fun AnchorAlignView(appState: AppState) {
// 중이라 간헐적 미수신이 정상이다. 계속 돌리고 다음 프레임을 기다린다. // 중이라 간헐적 미수신이 정상이다. 계속 돌리고 다음 프레임을 기다린다.
continue continue
} }
lrImu.await()?.takeIf { it.isNotEmpty() }?.let {
liveTilt = ImuPostureClassifier.classify(it).pitchDeg
}
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 } detachWatcher.push(sorted.map { it.buffer })?.let { d -> detachSnapshot = d }
@@ -471,6 +501,7 @@ fun AnchorAlignView(appState: AppState) {
} }
} finally { } finally {
bleManager.piezoCollector.onMultiChannelComplete = prev bleManager.piezoCollector.onMultiChannelComplete = prev
lrImu.restore()
kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) {
guard.restore() guard.restore()
} }
@@ -565,6 +596,22 @@ fun AnchorAlignView(appState: AppState) {
) )
} }
} }
Spacer(Modifier.height(8.dp))
// 프로브 기울기 — 상시. 자세는 이 화면에서 고르지 않는다(AppState.clinicalPosture
// 참조). 병원 화면에서 고른 값으로 요구 범위를 보이되, 어느 자세 기준인지 적는다 —
// 안 적으면 Supine 으로 들어와 띠가 없을 때 "왜 안 뜨나"가 된다.
ImuTiltCard(
tiltDeg = liveTilt,
posture = appState.clinicalPosture,
connected = isConnected,
running = measuring || lrRunning,
)
Spacer(Modifier.height(4.dp))
Text(
"자세 ${appState.clinicalPosture.label} 기준 — 병원 임상 측정 화면에서 고른 값",
fontSize = 11.sp, color = MlSecondaryText,
)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( Text(
@@ -76,14 +76,6 @@ private const val COMBINATION_SETTLE_MS = 800L
/** 회차 사이 간격 — 기존 임상 라이브(600ms)와 같은 값. */ /** 회차 사이 간격 — 기존 임상 라이브(600ms)와 같은 값. */
private const val REPEAT_GAP_MS = 600L private const val REPEAT_GAP_MS = 600L
/**
* 쉬는 동안 기울기 표시용 IMU 폴링 주기. `mim` 한 번이 15 샘플·300ms 창이라 이보다
* 촘촘할 이유가 없고, 워치독의 유휴 `mim`(10초 무응답 시)보다는 훨씬 잦아 그쪽이 끼어들
* 틈이 없다. 측정 루프 중에는 폴링하지 않는다 — `sendImuFifoQuery` 의 `imuCollector.reset()`
* 이 `mtb` 의 IMU 를 지운다.
*/
private const val IDLE_TILT_POLL_MS = 800L
@Composable @Composable
fun HospitalModeView(appState: AppState) { fun HospitalModeView(appState: AppState) {
val context = LocalContext.current val context = LocalContext.current
@@ -213,29 +205,15 @@ fun HospitalModeView(appState: AppState) {
// ── 기울기 폴링 (쉬는 동안) ────────────────────────────────────────── // ── 기울기 폴링 (쉬는 동안) ──────────────────────────────────────────
// Sitting 은 35°~55° 에서 재야 한다(사내 임상 분석 · 2026-09-28). 간호사가 프로브를 // Sitting 은 35°~55° 에서 재야 한다(사내 임상 분석 · 2026-09-28). 간호사가 프로브를
// 대고 각도를 맞추는 것은 **측정 전**이라, 측정 중에만 오는 mtb 의 IMU 로는 늦다. // 대고 각도를 맞추는 것은 **측정 전**이라, 측정 중에만 오는 mtb 의 IMU 로는 늦다.
// 그래서 연결돼 있고 루프가 안 돌 때 `mim` 으로 IMU 만 받아 기울기를 띄운다. // 그래서 연결돼 있고 루프가 안 돌 때 `mim` 으로 IMU 만 받아 기울기를 띄운다
// `running` 이 켜지면 이 효과는 취소되고 루프가 자기 사이드카를 건다 — 두 사이드카가 // ([pollLiveTilt] — 정렬 화면과 같은 루프). `running` 이 켜지면 이 효과는 취소되고
// 겹치는 순서(취소된 쪽의 restore 가 새 install 뒤에 옴)는 restore 의 "자기 콜백일 // 루프가 자기 사이드카를 건다.
// 때만 되돌림" 이 막는다.
LaunchedEffect(isConnected, running) { LaunchedEffect(isConnected, running) {
if (!isConnected || running) { if (!isConnected || running) {
if (!isConnected) liveTilt = null if (!isConnected) liveTilt = null
return@LaunchedEffect return@LaunchedEffect
} }
val side = ImuSidecar(bleManager) pollLiveTilt(bleManager) { liveTilt = it }
side.install()
try {
while (true) {
side.drain()
bleManager.sendImuFifoQuery()
side.await(IDLE_TILT_POLL_MS + 300)?.let { samples ->
liveTilt = ImuPostureClassifier.classify(samples).pitchDeg
}
delay(IDLE_TILT_POLL_MS)
}
} finally {
side.restore()
}
} }
// ── 측정 루프 ──────────────────────────────────────────────────────── // ── 측정 루프 ────────────────────────────────────────────────────────
@@ -6,8 +6,9 @@
* · 고른 자세에 요구 범위가 있으면(지금은 Sitting 35°~55°) 그 띠와 현재 위치, * · 고른 자세에 요구 범위가 있으면(지금은 Sitting 35°~55°) 그 띠와 현재 위치,
* 범위 안/밖과 얼마나 벗어났는지. * 범위 안/밖과 얼마나 벗어났는지.
* *
* 값의 출처는 화면이 정한다 — 쉬는 동안은 `mim` 폴링, 측정 중에는 회차마다 오는 * 값의 출처는 화면이 정한다 — 쉬는 동안은 `mim` 폴링([pollLiveTilt]), 측정 중에는
* `mtb` 의 IMU. 이 카드는 그리기만 한다. * 회차마다 오는 `mtb` 의 IMU. 이 카드는 그리기만 한다. 병원 임상 측정 화면과 부착 위치
* 정렬 화면이 같이 쓴다.
*/ */
package com.medithings.vesiscan.ui.views.clinical package com.medithings.vesiscan.ui.views.clinical
@@ -0,0 +1,45 @@
/*
* LiveTilt — 쉬는 동안 프로브 기울기를 `mim` 폴링으로 받아 오는 루프.
*
* 병원 임상 측정 화면과 부착 위치 정렬 화면이 **같은 것**을 쓴다(2026-09-28). 정렬도
* 자세에 매인 일이라(Sitting 은 35°~55° 에서 잰다) 프로브를 붙이는 그 자리에서 기울기가
* 보여야 한다 — 병원 화면에만 있으면 정렬을 끝내고 돌아와서야 각도가 틀린 것을 안다.
*
* 측정 루프가 도는 동안은 호출부가 이 루프를 **끄고** 자기 사이드카로 `mtb` 의 IMU 를
* 받는다. 겹치면 `sendImuFifoQuery` 의 `imuCollector.reset()` 이 `mtb` 의 IMU 를 지운다.
* 두 사이드카의 install/restore 순서는 [ImuSidecar.restore] 의 "자기 콜백일 때만" 이 막는다.
*/
package com.medithings.vesiscan.ui.views.clinical
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.managers.ImuPostureClassifier
import kotlinx.coroutines.delay
/**
* 폴링 주기. `mim` 한 번이 15 샘플·300ms 창이라 이보다 촘촘할 이유가 없고, 워치독의
* 유휴 `mim`(10초 무응답 시)보다는 훨씬 잦아 그쪽이 끼어들 틈이 없다.
*/
const val IDLE_TILT_POLL_MS = 800L
/**
* 취소될 때까지 돈다 — `LaunchedEffect` 안에서 부르고, 키가 바뀌면 효과가 취소되며
* 사이드카가 되돌아간다. IMU 가 안 오는 회차는 그냥 건너뛴다(값 유지).
*
* @param onTilt 새 기울기(도). 정의는 [com.medithings.vesiscan.models.PostureTilt] 참조.
*/
suspend fun pollLiveTilt(ble: BleManager, onTilt: (Float) -> Unit) {
val side = ImuSidecar(ble)
side.install()
try {
while (true) {
side.drain()
ble.sendImuFifoQuery()
side.await(IDLE_TILT_POLL_MS + 300)?.let { samples ->
onTilt(ImuPostureClassifier.classify(samples).pitchDeg)
}
delay(IDLE_TILT_POLL_MS)
}
} finally {
side.restore()
}
}