feat(hospital): 프로브 기울기 상시 표시 · Sitting 35°~55° 측정 각도 안내
## 요청 (2026-09-28 · 임상 데이터 분석)
Sitting 정확도 개선을 위해 특정 각도 범위의 데이터가 필요하다. 사내 임상 분석 결과로
Sitting 은 35°~55° 에서 재야 한다. 임상 앱에 ① IMU 각도 상시 표시(+ IMU 저장) ②
Sitting 단계에서 그 범위가 측정 가능 각도라는 안내.
## 각도의 정의 — 분석팀 숫자와 같은 식이어야 한다
`ImuPostureClassifier.classify().pitchDeg` 를 그대로 쓴다: IMU 15샘플 평균으로
atan(√(ax²+ay²)/|az|). 0° = 평평(누워서 배 위) · 90° = 세로. 앱이 자세 판정에 쓰는
것과 같은 정의라 저장된 `*_imu.csv` 원시값에서 다시 계산해도 같은 숫자가 나온다.
(`PostureTilt` KDoc 에 식을 적어 두었다 — 분석팀이 쓴 정의가 이것과 다르면 여기서
맞춘다.)
## 상시 표시는 어디서 값을 받나
간호사가 프로브를 대고 각도를 맞추는 것은 **측정 전**이다. 측정 중에만 오는 mtb 의
IMU 로는 늦다. 그래서 연결돼 있고 루프가 안 돌 때 `mim`(IMU 만, 무음)을 800ms 마다
보내 기울기를 띄우고, 루프가 돌면 회차마다 오는 mtb 의 IMU 로 갱신한다.
폴링과 루프가 같은 `imuCollector.onComplete` 를 쓰므로 둘 다 ImuSidecar 를 통해
걸고 푼다. `running` 이 켜지면 폴링 효과가 취소되는데, 취소된 코루틴의 finally(restore)
가 루프의 install 뒤에 실행되는 순서가 가능하다 — ImuSidecar.restore 가 "자기
콜백일 때만 되돌림"(00fef4a9)이라 이 순서에서 루프의 훅이 살아남는다. 그 방어가
바로 여기서 쓰인다.
## 안내지 차단이 아니다
범위 밖이어도 측정은 막지 않는다. 요청이 "안내(표시)"이고, 막으면 범위를 벗어난
대조 데이터를 일부러 받을 길이 없어진다. 대신 사후에 걸러낼 수 있게 남긴다:
· 회차별 IMU — 이미 `*_imu.csv` (2026-09-21)
· 실행 요약 JSON — tilt_deg_mean/min/max/n · tilt_deg_required (신규)
## 화면
`ImuTiltCard` — 연결 상태 줄 바로 아래, 항상. 큰 숫자 + 출처("실시간"/"측정 회차마다
갱신"/"연결 안 됨"). 자세에 요구 범위가 있으면 0~90° 축 위에 띠와 현재 위치 눈금,
"범위 안" 또는 "N° 더 세워야/눕혀야 함". 자세 칩 아래에도 한 줄 힌트.
## 시험
PostureTiltTest 2건 — 경계(35·55) 포함, Sitting 외 자세는 범위 없음. 범위 숫자 자체는
분석팀 결정이라 시험이 옳고 그름을 말하지는 않는다.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -155,3 +155,42 @@ object HospitalFixedParams {
|
||||
/** echo sample 개수 (허용 80~117). 앱의 채널 버퍼가 100 샘플 기준이다. */
|
||||
const val SAMPLES = 100
|
||||
}
|
||||
|
||||
/**
|
||||
* 자세별 **프로브 기울기** 요구 범위 (2026-09-28 요청 · 사내 임상 데이터 분석 기준).
|
||||
*
|
||||
* ## 무엇을 재나
|
||||
* 기울기 = [com.medithings.vesiscan.managers.ImuPostureClassifier.classify] 의 `pitchDeg`.
|
||||
* IMU 15 샘플 평균으로 `atan(√(ax²+ay²) / |az|)` — 프로브 Z축이 중력과 벌어진 각도다.
|
||||
* 0° = 평평(누워서 배 위) · 90° = 세로. 앱이 자세 판정에 쓰는 것과 **같은 정의**라,
|
||||
* 저장된 `*_imu.csv` 의 원시값에서 분석팀이 다시 계산해도 같은 숫자가 나온다.
|
||||
*
|
||||
* ## 왜 Sitting 만 있나
|
||||
* Sitting 정확도 개선을 위해 35°~55° 구간의 데이터가 필요하다는 요청이다. 다른 자세는
|
||||
* 아직 범위가 정해지지 않았다 — 정해지면 [rangeFor] 에 한 줄 더 넣는다.
|
||||
*
|
||||
* ## 안내지 차단이 아니다
|
||||
* 범위 밖이어도 측정은 막지 않는다. 요청이 "안내(표시)"이고, 막으면 범위를 벗어난
|
||||
* 대조 데이터를 일부러 받을 길이 없어진다. 대신 기울기는 회차마다 `*_imu.csv` 에,
|
||||
* 실행 평균은 요약 JSON(`tilt_deg_mean`)에 남아 사후에 걸러낼 수 있다.
|
||||
*/
|
||||
object PostureTilt {
|
||||
/** Sitting 측정 가능 기울기 (도). */
|
||||
val SITTING_DEG: ClosedFloatingPointRange<Float> = 35f..55f
|
||||
|
||||
fun rangeFor(posture: ClinicalPosture): ClosedFloatingPointRange<Float>? = when (posture) {
|
||||
ClinicalPosture.SITTING -> SITTING_DEG
|
||||
ClinicalPosture.SUPINE, ClinicalPosture.STANDING -> null
|
||||
}
|
||||
|
||||
enum class Status { NO_RANGE, BELOW, IN_RANGE, ABOVE }
|
||||
|
||||
fun status(tiltDeg: Float, posture: ClinicalPosture): Status {
|
||||
val r = rangeFor(posture) ?: return Status.NO_RANGE
|
||||
return when {
|
||||
tiltDeg < r.start -> Status.BELOW
|
||||
tiltDeg > r.endInclusive -> Status.ABOVE
|
||||
else -> Status.IN_RANGE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.managers.AnchorBasis
|
||||
import com.medithings.vesiscan.managers.ClinicalBv
|
||||
import com.medithings.vesiscan.managers.ImuPostureClassifier
|
||||
import com.medithings.vesiscan.ui.components.ChannelDetachPanel
|
||||
import com.medithings.vesiscan.ui.components.LabdbStatusCard
|
||||
import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid
|
||||
@@ -75,6 +76,13 @@ 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) {
|
||||
@@ -107,6 +115,11 @@ fun HospitalModeView(appState: AppState) {
|
||||
var configFailed by remember { mutableStateOf<String?>(null) }
|
||||
/** 프로브 설정 복원 결과. 측정이 끝난 뒤 화면에 한 줄로 남긴다. */
|
||||
var restoreNote by remember { mutableStateOf<String?>(null) }
|
||||
/**
|
||||
* 프로브 기울기(도) — 상시 표시용 (2026-09-28 요청). 쉬는 동안은 아래 폴링이,
|
||||
* 측정 중에는 루프가 회차마다 채운다. 정의는 [PostureTilt] 참조.
|
||||
*/
|
||||
var liveTilt by remember { mutableStateOf<Float?>(null) }
|
||||
/**
|
||||
* 방금 받은 6채널 원본. 아래쪽 파형 그래프가 이걸 그린다.
|
||||
*
|
||||
@@ -197,6 +210,34 @@ fun HospitalModeView(appState: AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 기울기 폴링 (쉬는 동안) ──────────────────────────────────────────
|
||||
// Sitting 은 35°~55° 에서 재야 한다(사내 임상 분석 · 2026-09-28). 간호사가 프로브를
|
||||
// 대고 각도를 맞추는 것은 **측정 전**이라, 측정 중에만 오는 mtb 의 IMU 로는 늦다.
|
||||
// 그래서 연결돼 있고 루프가 안 돌 때 `mim` 으로 IMU 만 받아 기울기를 띄운다.
|
||||
// `running` 이 켜지면 이 효과는 취소되고 루프가 자기 사이드카를 건다 — 두 사이드카가
|
||||
// 겹치는 순서(취소된 쪽의 restore 가 새 install 뒤에 옴)는 restore 의 "자기 콜백일
|
||||
// 때만 되돌림" 이 막는다.
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 측정 루프 ────────────────────────────────────────────────────────
|
||||
LaunchedEffect(running) {
|
||||
if (!running) return@LaunchedEffect
|
||||
@@ -226,6 +267,8 @@ fun HospitalModeView(appState: AppState) {
|
||||
// 가를 근거가 필요하다.
|
||||
val imuSide = ImuSidecar(bleManager)
|
||||
imuSide.install()
|
||||
// 회차별 기울기 — 실행 요약에 평균·범위를 남겨 사후에 각도로 걸러낼 수 있게.
|
||||
val runTilts = mutableListOf<Float>()
|
||||
|
||||
try {
|
||||
// 복부 두께가 정한 조합. 순서는 [AbdomenThickness] 가 박아 두었다 — 매번
|
||||
@@ -311,6 +354,10 @@ fun HospitalModeView(appState: AppState) {
|
||||
// IMU 는 piezo 뒤에 온다. 없어도 측정은 성립하므로 실패로 세지 않는다.
|
||||
imuSide.await()?.let { imu ->
|
||||
HospitalRunStore.appendImuSamples(file, sid, r, imu)
|
||||
// 측정 중에도 화면의 기울기가 살아 있게 — 이 회차의 자세다.
|
||||
val tilt = ImuPostureClassifier.classify(imu).pitchDeg
|
||||
liveTilt = tilt
|
||||
runTilts += tilt
|
||||
}
|
||||
if (saved) { ok++; okCount++ } else { fail++; failCount++ }
|
||||
lastFile = file.name
|
||||
@@ -354,6 +401,18 @@ fun HospitalModeView(appState: AppState) {
|
||||
HospitalRunStore.writeSummary(dir, JSONObject().apply {
|
||||
put("patient", patient)
|
||||
put("posture", posture.label)
|
||||
// 프로브 기울기(도) — 회차 IMU 평균의 실행 통계. 회차별 값은 `*_imu.csv` 에.
|
||||
// Sitting 요구 범위(35~55)는 안내일 뿐 차단하지 않으므로, 범위 밖 실행을
|
||||
// 나중에 걸러내려면 여기 숫자가 있어야 한다.
|
||||
if (runTilts.isNotEmpty()) {
|
||||
put("tilt_deg_mean", runTilts.average())
|
||||
put("tilt_deg_min", runTilts.min())
|
||||
put("tilt_deg_max", runTilts.max())
|
||||
put("tilt_deg_n", runTilts.size)
|
||||
}
|
||||
PostureTilt.rangeFor(posture)?.let { r ->
|
||||
put("tilt_deg_required", "${r.start}-${r.endInclusive}")
|
||||
}
|
||||
put("fill_pct", fill.percent)
|
||||
put("repeats", repeats)
|
||||
// 돌 조합을 이것이 정했다. 조합만 기록하면 "왜 1개만 돌았나"를 나중에
|
||||
@@ -442,6 +501,15 @@ fun HospitalModeView(appState: AppState) {
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// 프로브 기울기 — 상시 (2026-09-28 요청). Sitting 을 고르면 요구 범위가 함께 뜬다.
|
||||
ImuTiltCard(
|
||||
tiltDeg = liveTilt,
|
||||
posture = posture,
|
||||
connected = isConnected,
|
||||
running = running,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
@@ -483,6 +551,9 @@ fun HospitalModeView(appState: AppState) {
|
||||
Label("자세")
|
||||
ChipRow(ClinicalPosture.entries, posture, { posture = it }, !running,
|
||||
progressOf = { postureProgress(it) }) { it.label }
|
||||
PostureTilt.rangeFor(posture)?.let { r ->
|
||||
Hint("${posture.label} 은 프로브 기울기 ${r.start.toInt()}°~${r.endInclusive.toInt()}° 에서 잰다 — 위 카드로 맞춘다")
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Label("방광 채움 정도 — 간호사가 채워 둔 값")
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* ImuTiltCard — 병원 임상 화면의 **프로브 기울기 상시 표시** (2026-09-28 요청).
|
||||
*
|
||||
* 무엇을 보여주나:
|
||||
* · 지금 기울기(도). 값이 없으면 "—" 와 이유(연결 안 됨 / 수신 대기).
|
||||
* · 고른 자세에 요구 범위가 있으면(지금은 Sitting 35°~55°) 그 띠와 현재 위치,
|
||||
* 범위 안/밖과 얼마나 벗어났는지.
|
||||
*
|
||||
* 값의 출처는 화면이 정한다 — 쉬는 동안은 `mim` 폴링, 측정 중에는 회차마다 오는
|
||||
* `mtb` 의 IMU. 이 카드는 그리기만 한다.
|
||||
*/
|
||||
package com.medithings.vesiscan.ui.views.clinical
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.models.ClinicalPosture
|
||||
import com.medithings.vesiscan.models.PostureTilt
|
||||
import java.util.Locale
|
||||
|
||||
private val InRange = Color(0xFF2E7D32)
|
||||
private val OutRange = Color(0xFFD32F2F)
|
||||
private val Neutral = Color(0xFF546E7A)
|
||||
|
||||
/**
|
||||
* @param tiltDeg 지금 기울기. null 이면 아직 값이 없다.
|
||||
* @param connected 프로브 연결 여부 — 값이 없을 때 이유를 적기 위해.
|
||||
* @param running 측정 루프 중인가 — 값의 출처 설명을 바꾼다.
|
||||
*/
|
||||
@Composable
|
||||
fun ImuTiltCard(
|
||||
tiltDeg: Float?,
|
||||
posture: ClinicalPosture,
|
||||
connected: Boolean,
|
||||
running: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val range = PostureTilt.rangeFor(posture)
|
||||
val status = tiltDeg?.let { PostureTilt.status(it, posture) }
|
||||
val accent = when (status) {
|
||||
PostureTilt.Status.IN_RANGE -> InRange
|
||||
PostureTilt.Status.BELOW, PostureTilt.Status.ABOVE -> OutRange
|
||||
else -> Neutral
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.background(accent.copy(alpha = 0.08f), RoundedCornerShape(10.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("IMU 기울기", fontSize = 12.sp, color = Neutral, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
if (tiltDeg == null) "—" else String.format(Locale.US, "%.0f°", tiltDeg),
|
||||
fontSize = 26.sp, fontWeight = FontWeight.Bold, color = accent,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
when {
|
||||
tiltDeg == null && !connected -> "연결 안 됨"
|
||||
tiltDeg == null -> "수신 대기"
|
||||
running -> "측정 회차마다 갱신"
|
||||
else -> "실시간"
|
||||
},
|
||||
fontSize = 11.sp, color = Neutral,
|
||||
)
|
||||
}
|
||||
|
||||
if (range != null) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
TiltBand(tiltDeg, range, accent)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
buildString {
|
||||
append(posture.label)
|
||||
append(" 측정 가능 각도 ")
|
||||
append(String.format(Locale.US, "%.0f°~%.0f°", range.start, range.endInclusive))
|
||||
when (status) {
|
||||
PostureTilt.Status.IN_RANGE -> append(" · 범위 안")
|
||||
PostureTilt.Status.BELOW ->
|
||||
append(String.format(Locale.US, " · %.0f° 더 세워야 함", range.start - tiltDeg!!))
|
||||
PostureTilt.Status.ABOVE ->
|
||||
append(String.format(Locale.US, " · %.0f° 더 눕혀야 함", tiltDeg!! - range.endInclusive))
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = accent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 0°~90° 축 위에 요구 범위 띠와 현재 위치 눈금을 그린다. */
|
||||
@Composable
|
||||
private fun TiltBand(tiltDeg: Float?, range: ClosedFloatingPointRange<Float>, accent: Color) {
|
||||
val axisMax = 90f
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(10.dp)
|
||||
.background(Neutral.copy(alpha = 0.15f), RoundedCornerShape(5.dp)),
|
||||
) {
|
||||
// 요구 범위 띠
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Spacer(Modifier.weight((range.start / axisMax).coerceAtLeast(0.001f)))
|
||||
Box(
|
||||
Modifier
|
||||
.weight(((range.endInclusive - range.start) / axisMax).coerceAtLeast(0.001f))
|
||||
.height(10.dp)
|
||||
.background(InRange.copy(alpha = 0.35f), RoundedCornerShape(5.dp)),
|
||||
)
|
||||
Spacer(Modifier.weight(((axisMax - range.endInclusive) / axisMax).coerceAtLeast(0.001f)))
|
||||
}
|
||||
// 현재 위치 눈금
|
||||
if (tiltDeg != null) {
|
||||
val f = (tiltDeg / axisMax).coerceIn(0f, 1f)
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
if (f > 0f) Spacer(Modifier.weight(f))
|
||||
Box(Modifier.width(3.dp).height(10.dp).background(accent, RoundedCornerShape(1.dp)))
|
||||
if (f < 1f) Spacer(Modifier.weight(1f - f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* PostureTiltTest — Sitting 기울기 범위(35°~55°) 판정을 잠근다.
|
||||
*
|
||||
* 범위 자체는 분석팀이 정한 숫자라 여기서 "맞다"고 할 수 없다. 잠그는 것은 두 가지다:
|
||||
* 1. 경계값이 포함이다 — 35.0° 와 55.0° 는 범위 안. 경계에서 색이 뒤집히면
|
||||
* 간호사가 "안"인지 "밖"인지 화면마다 다르게 본다.
|
||||
* 2. Sitting 이외 자세에는 범위가 없다 — 있으면 없는 요구를 화면이 만들어 낸다.
|
||||
*/
|
||||
package com.medithings.vesiscan.models
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class PostureTiltTest {
|
||||
|
||||
@Test
|
||||
fun `Sitting 범위는 35에서 55 이고 경계는 포함이다`() {
|
||||
val r = PostureTilt.rangeFor(ClinicalPosture.SITTING)!!
|
||||
assertEquals(35f, r.start, 0f)
|
||||
assertEquals(55f, r.endInclusive, 0f)
|
||||
|
||||
assertEquals(PostureTilt.Status.BELOW, PostureTilt.status(34.9f, ClinicalPosture.SITTING))
|
||||
assertEquals(PostureTilt.Status.IN_RANGE, PostureTilt.status(35f, ClinicalPosture.SITTING))
|
||||
assertEquals(PostureTilt.Status.IN_RANGE, PostureTilt.status(45f, ClinicalPosture.SITTING))
|
||||
assertEquals(PostureTilt.Status.IN_RANGE, PostureTilt.status(55f, ClinicalPosture.SITTING))
|
||||
assertEquals(PostureTilt.Status.ABOVE, PostureTilt.status(55.1f, ClinicalPosture.SITTING))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `다른 자세에는 범위가 없다`() {
|
||||
assertNull(PostureTilt.rangeFor(ClinicalPosture.SUPINE))
|
||||
assertNull(PostureTilt.rangeFor(ClinicalPosture.STANDING))
|
||||
assertEquals(PostureTilt.Status.NO_RANGE, PostureTilt.status(45f, ClinicalPosture.SUPINE))
|
||||
assertEquals(PostureTilt.Status.NO_RANGE, PostureTilt.status(45f, ClinicalPosture.STANDING))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user