From c667597e2b4ff66b39563840d6cfca82ecf3bf87 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Mon, 28 Sep 2026 15:18:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(hospital):=20=EB=B6=80=EC=B0=A9=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=A0=95=EB=A0=AC=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=97=90=EB=8F=84=20=ED=94=84=EB=A1=9C=EB=B8=8C=20=EA=B8=B0?= =?UTF-8?q?=EC=9A=B8=EA=B8=B0=20=EC=B9=B4=EB=93=9C=EB=A5=BC=20=EB=91=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ui/views/clinical/AnchorAlignView.kt | 47 +++++++++++++++++++ .../ui/views/clinical/HospitalModeView.kt | 30 ++---------- .../vesiscan/ui/views/clinical/ImuTiltCard.kt | 5 +- .../vesiscan/ui/views/clinical/LiveTilt.kt | 45 ++++++++++++++++++ 4 files changed, 99 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/com/medithings/vesiscan/ui/views/clinical/LiveTilt.kt diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt index da8a9e7..b60a470 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt @@ -69,6 +69,7 @@ import com.medithings.vesiscan.managers.AnchorSelection import com.medithings.vesiscan.managers.AnchorStep import com.medithings.vesiscan.managers.ClinicalBv import com.medithings.vesiscan.managers.DetachWatcher +import com.medithings.vesiscan.managers.ImuPostureClassifier import com.medithings.vesiscan.managers.LateralGuide import com.medithings.vesiscan.models.ClinicalPosture import com.medithings.vesiscan.models.HospitalFixedParams @@ -107,6 +108,14 @@ fun AnchorAlignView(appState: AppState) { var cm by remember { mutableIntStateOf(0) } var measuring by remember { mutableStateOf(false) } var progress by remember { mutableIntStateOf(0) } + /** + * 프로브 기울기(도) — 상시 표시 (2026-09-28 요청, 병원 화면과 같은 카드). 쉬는 동안은 + * 아래 폴링이, 위치 측정·좌우 정렬 중에는 각 루프가 회차마다 채운다. + * + * 정렬 화면에 두는 이유: Sitting 은 35°~55° 에서 재는데, 프로브를 붙이고 각도를 잡는 + * 일은 **정렬에서** 일어난다. 여기서 각도를 맞춰 두면 임상 측정은 그 자리 그대로다. + */ + var liveTilt by remember { mutableStateOf(null) } var last by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } var records by remember { mutableStateOf>(emptyList()) } @@ -237,6 +246,17 @@ fun AnchorAlignView(appState: AppState) { 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 이 @@ -328,6 +348,7 @@ fun AnchorAlignView(appState: AppState) { // IMU 는 piezo 뒤에 온다. 없으면 빈 목록 — 측정을 실패로 돌리지 않는다. val imu = imuSide.await() ?: emptyList() cyclesImu.add(imu) + if (imu.isNotEmpty()) liveTilt = ImuPostureClassifier.classify(imu).pitchDeg android.util.Log.d("AnchorAlign", "cycle $i/${AnchorConfig.ALIGN_CYCLES} imu=${imu.size}") val sorted = got.sortedBy { it.channel } liveChannels = sorted // 화면 파형 — 저장·판정에 쓰는 것과 같은 데이터 @@ -426,6 +447,11 @@ fun AnchorAlignView(appState: AppState) { guard.capture() val prev = bleManager.piezoCollector.onMultiChannelComplete bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) } + // 좌우 정렬은 사람이 프로브를 움직이는 중이라 기울기가 제일 많이 바뀌는 때다. + // mtb 응답의 IMU 로 회차마다 카드를 갱신한다 — 저장은 하지 않는다(좌우는 원래 + // 프레임을 저장하지 않는 단계다). + val lrImu = ImuSidecar(bleManager) + lrImu.install() try { bleManager.piezoConfigEcho.value = null bleManager.sendPiezoConfig( @@ -452,6 +478,7 @@ fun AnchorAlignView(appState: AppState) { while (lrRunning) { while (inbox.tryReceive().isSuccess) { /* 늦게 온 직전 결과 비우기 */ } + lrImu.drain() bleManager.sendMtb() val got = withTimeoutOrNull(MEASURE_TIMEOUT_MS) { inbox.receive() } if (got == null) { @@ -459,6 +486,9 @@ fun AnchorAlignView(appState: AppState) { // 중이라 간헐적 미수신이 정상이다. 계속 돌리고 다음 프레임을 기다린다. continue } + lrImu.await()?.takeIf { it.isNotEmpty() }?.let { + liveTilt = ImuPostureClassifier.classify(it).pitchDeg + } val sorted = got.sortedBy { it.channel } liveChannels = sorted detachWatcher.push(sorted.map { it.buffer })?.let { d -> detachSnapshot = d } @@ -471,6 +501,7 @@ fun AnchorAlignView(appState: AppState) { } } finally { bleManager.piezoCollector.onMultiChannelComplete = prev + lrImu.restore() kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { 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)) Text( diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt index b46aa49..bbf1e29 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt @@ -76,14 +76,6 @@ private const val COMBINATION_SETTLE_MS = 800L /** 회차 사이 간격 — 기존 임상 라이브(600ms)와 같은 값. */ 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 fun HospitalModeView(appState: AppState) { val context = LocalContext.current @@ -213,29 +205,15 @@ fun HospitalModeView(appState: AppState) { // ── 기울기 폴링 (쉬는 동안) ────────────────────────────────────────── // Sitting 은 35°~55° 에서 재야 한다(사내 임상 분석 · 2026-09-28). 간호사가 프로브를 // 대고 각도를 맞추는 것은 **측정 전**이라, 측정 중에만 오는 mtb 의 IMU 로는 늦다. - // 그래서 연결돼 있고 루프가 안 돌 때 `mim` 으로 IMU 만 받아 기울기를 띄운다. - // `running` 이 켜지면 이 효과는 취소되고 루프가 자기 사이드카를 건다 — 두 사이드카가 - // 겹치는 순서(취소된 쪽의 restore 가 새 install 뒤에 옴)는 restore 의 "자기 콜백일 - // 때만 되돌림" 이 막는다. + // 그래서 연결돼 있고 루프가 안 돌 때 `mim` 으로 IMU 만 받아 기울기를 띄운다 + // ([pollLiveTilt] — 정렬 화면과 같은 루프). `running` 이 켜지면 이 효과는 취소되고 + // 루프가 자기 사이드카를 건다. LaunchedEffect(isConnected, running) { if (!isConnected || running) { if (!isConnected) liveTilt = null return@LaunchedEffect } - val side = ImuSidecar(bleManager) - 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() - } + pollLiveTilt(bleManager) { liveTilt = it } } // ── 측정 루프 ──────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ImuTiltCard.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ImuTiltCard.kt index 4c8a7a1..dd11ba7 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ImuTiltCard.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ImuTiltCard.kt @@ -6,8 +6,9 @@ * · 고른 자세에 요구 범위가 있으면(지금은 Sitting 35°~55°) 그 띠와 현재 위치, * 범위 안/밖과 얼마나 벗어났는지. * - * 값의 출처는 화면이 정한다 — 쉬는 동안은 `mim` 폴링, 측정 중에는 회차마다 오는 - * `mtb` 의 IMU. 이 카드는 그리기만 한다. + * 값의 출처는 화면이 정한다 — 쉬는 동안은 `mim` 폴링([pollLiveTilt]), 측정 중에는 + * 회차마다 오는 `mtb` 의 IMU. 이 카드는 그리기만 한다. 병원 임상 측정 화면과 부착 위치 + * 정렬 화면이 같이 쓴다. */ package com.medithings.vesiscan.ui.views.clinical diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/LiveTilt.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/LiveTilt.kt new file mode 100644 index 0000000..e26297a --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/LiveTilt.kt @@ -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() + } +}