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:
2026-09-28 10:58:39 +09:00
parent 00fef4a99d
commit 812c0a2ae7
4 changed files with 288 additions and 0 deletions
@@ -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
}
}
}