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