feat(hospital): Supine 확인 측정의 IMU 평균을 기준각으로 — Sitting 요구 범위를 35+m~55+m 로 민다

35°~55° 는 Supine 에서 프로브가 0° 라는 가정이다. 배가 나온 환자는 누워서도 20° 쯤
나오고, 그러면 Sitting 최적 구간은 55°~75° 다(2026-10-01 제안).

  · Supine 정렬의 확인 측정(N cm, 20 cycle)에서 IMU 평균 기울기 m 을 재
    AppState.supineTiltBaseline 에 두고, 요약 align_result.json 에 confirm_tilt_deg_mean 으로
    남긴다(어느 자세든). 앱을 다시 켜면 그날 Supine 정렬 요약에서 되살린다.
  · PostureTilt.rangeFor/status 가 기준각을 받아 Sitting 범위를 (35+m)..(55+m) 로 민다.
    기준각이 없으면 절대 각도 그대로.
  · 카드: Sitting 에서 "IMU 기울기 n° · Supine 대비 +k°", 띠는 밀린 범위, 문구
    "Sitting 측정 가능 각도 55°~75° (Supine 20° + 35~55)". 기준각이 없으면 "Supine 기준각
    없음" 을 적는다. 다른 자세에서는 "Supine 기준각 20° 저장됨 (Sitting 범위 55°~75°)".
  · run 매니페스트: tilt_deg_required 는 밀린 값, tilt_baseline_supine 추가.
  · 환자가 바뀌면 기준각도 비운다.

시험 PostureTiltTest 기준각 1건 추가 — 전체 191건 · failures 0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 09:43:54 +09:00
parent 6f118f3ee4
commit 346f647857
8 changed files with 122 additions and 14 deletions
@@ -88,8 +88,19 @@ class AppState(private val context: Context) {
/** 지금 자세의 부착 위치와 딸린 것을 비운다. */
fun clearAnchor() { anchors.remove(clinicalPosture) }
/**
* Supine 정렬 확인 측정의 IMU 평균 기울기(도) — Sitting 요구 범위의 기준각 m
* ([com.medithings.vesiscan.models.PostureTilt] 참조, 2026-10-01). null = 아직 없음.
* 환자가 바뀌면 비운다. 앱을 다시 켜면 그날 Supine 정렬 요약(`confirm_tilt_deg_mean`)에서
* 되살린다([com.medithings.vesiscan.services.HospitalRunStore.readConfirmTilt]).
*/
var supineTiltBaseline: Float? by mutableStateOf(null)
/** 전 자세를 비운다 — 환자가 바뀌거나 프로브가 바뀔 때(빔 각도가 달라 정렬이 무효). */
fun clearAllAnchors() { anchors.clear() }
fun clearAllAnchors() {
anchors.clear()
supineTiltBaseline = null
}
/**
* 병원 임상 자세. **정렬 화면과 병원 모드가 같은 값을 봐야 한다.**
@@ -174,6 +174,13 @@ object HospitalFixedParams {
* Sitting 정확도 개선을 위해 35°~55° 구간의 데이터가 필요하다는 요청이다. 다른 자세는
* 아직 범위가 정해지지 않았다 — 정해지면 [rangeFor] 에 한 줄 더 넣는다.
*
* ## Supine 기준각 (2026-10-01)
* 35°~55° 는 **Supine 에서 프로브가 0°** 라는 가정이다. 배가 나온 환자는 누워서도 20° 쯤
* 나오고, 그러면 Sitting 의 최적 구간은 55°~75° 다. 그래서 Supine 정렬의 **확인 측정**
* (N cm, 20 cycle)에서 IMU 평균 기울기 m 을 재 두고, Sitting 요구 범위를 `35+m ~ 55+m` 로
* 민다 — 화면이 보는 것은 `n − m` 이 35~55 안인가다. 기준각이 없으면(Supine 정렬을 안 했거나
* 옛 앱) 절대 각도 35~55 그대로이고 화면이 그 사실을 적는다.
*
* ## 안내지 차단이 아니다
* 범위 밖이어도 측정은 막지 않는다. 요청이 "안내(표시)"이고, 막으면 범위를 벗어난
* 대조 데이터를 일부러 받을 길이 없어진다. 대신 기울기는 회차마다 `*_imu.csv` 에,
@@ -198,15 +205,23 @@ object PostureTilt {
return Math.toDegrees(kotlin.math.atan2(horizontal, az)).toFloat()
}
fun rangeFor(posture: ClinicalPosture): ClosedFloatingPointRange<Float>? = when (posture) {
ClinicalPosture.SITTING -> SITTING_DEG
ClinicalPosture.SUPINE, ClinicalPosture.STANDING -> null
}
/**
* 자세의 요구 범위(절대 각도). [supineBaseline] 이 있으면 Sitting 범위를 그만큼 민다 —
* `(35+m)..(55+m)`. 없으면 `35..55` 그대로.
*/
fun rangeFor(posture: ClinicalPosture, supineBaseline: Float? = null): ClosedFloatingPointRange<Float>? =
when (posture) {
ClinicalPosture.SITTING -> {
val m = supineBaseline ?: 0f
(SITTING_DEG.start + m)..(SITTING_DEG.endInclusive + m)
}
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
fun status(tiltDeg: Float, posture: ClinicalPosture, supineBaseline: Float? = null): Status {
val r = rangeFor(posture, supineBaseline) ?: return Status.NO_RANGE
return when {
tiltDeg < r.start -> Status.BELOW
tiltDeg > r.endInclusive -> Status.ABOVE
@@ -497,6 +497,19 @@ object HospitalRunStore {
?.sortedBy { it.name }
?: emptyList()
/**
* 그날 그 환자의 자세 정렬 요약에서 확인 측정 IMU 평균 기울기(`confirm_tilt_deg_mean`).
* 없으면 null. 앱을 다시 켠 뒤 Supine 기준각을 되살리는 데 쓴다(2026-10-01).
*/
fun readConfirmTilt(patient: String, day: Date, posture: com.medithings.vesiscan.models.ClinicalPosture): Float? {
val run = runDirIfExists(patient, day) ?: return null
val f = File(File(run, alignDirName(posture)), "align_result.json")
if (!f.isFile) return null
val v = runCatching { JSONObject(f.readText(Charsets.UTF_8)).optDouble("confirm_tilt_deg_mean") }
.getOrNull() ?: return null
return if (v.isNaN()) null else v.toFloat()
}
/** 읽기 전용 조회 — [runDir] 과 달리 폴더를 만들지 않는다. */
private fun runDirIfExists(patient: String, day: Date): File? =
File(File(downloads(), ROOT), "${dayFmt.format(day)}_${safe(patient)}").takeIf { it.isDirectory }
@@ -213,6 +213,8 @@ fun AnchorAlignView(appState: AppState) {
* 지표가 다른 쪽이라 N cm 기준으로 대조하면 멀쩡한 재부착도 "채널 없음"이 될 수 있다.
*/
var lrRefChannels by remember { mutableStateOf<List<Boolean>?>(null) }
/** 확인 측정(N cm)의 IMU 평균 기울기. Supine 이면 Sitting 기준각이 된다(2026-10-01). */
var confirmTiltDeg by remember { mutableStateOf<Float?>(null) }
/** 간호사가 "좌우 확인 완료"를 누른 적이 있는가. 저장·다음 단계 안내에 쓴다. */
var lrConfirmed by remember { mutableStateOf(false) }
@@ -405,6 +407,14 @@ fun AnchorAlignView(appState: AppState) {
// IMU 는 없을 수 있다(펌웨어·설정). 없다고 오류로 만들지 않는다 —
// 파형이 남았으면 측정은 성립한다.
HospitalRunStore.writeAlignImu(dir, cm, cyclesImu, kind)
// 확인 측정의 IMU 평균 기울기 — Supine 이면 Sitting 요구 범위의 기준각 m 이 된다
// (2026-10-01, PostureTilt 참조). 요약에도 남겨 앱을 다시 켜도 되살린다.
if (isConfirm) {
PostureTilt.degrees(cyclesImu.flatten())?.let { t ->
confirmTiltDeg = t
if (appState.clinicalPosture == ClinicalPosture.SUPINE) appState.supineTiltBaseline = t
}
}
// 판정과 **같은 mean-scan** 으로 BV 를 낸다. 따로 재면 그 위치의 지표와
// 용적이 다른 데이터에서 나온 값이 되어 대조가 성립하지 않는다.
@@ -432,7 +442,7 @@ fun AnchorAlignView(appState: AppState) {
dir,
buildAlignSummary(saveName, appState.hospitalPatient, deviceName,
bleManager.firmwareVersion.value, guide.records, step,
decidedAtCm = decidedAtCm, startedAt = runStartedAt, posture = appState.clinicalPosture.label),
decidedAtCm = decidedAtCm, startedAt = runStartedAt, posture = appState.clinicalPosture.label, confirmTiltDeg = confirmTiltDeg),
)
// ── 다음 위치 ──────────────────────────────────────────────────
// **0~SEARCH_MAX_CM 를 무조건 다 잰다.** guide 의 안내(MOVE_UP/MOVE_DOWN)는
@@ -651,6 +661,7 @@ fun AnchorAlignView(appState: AppState) {
posture = appState.clinicalPosture,
connected = isConnected,
running = measuring || lrRunning,
supineBaseline = appState.supineTiltBaseline,
)
Spacer(Modifier.height(10.dp))
@@ -933,7 +944,7 @@ fun AnchorAlignView(appState: AppState) {
saveName, appState.hospitalPatient, deviceName,
bleManager.firmwareVersion.value, guide.records, s,
lrCommit, lateralConfirmed = true,
decidedAtCm = decidedAtCm, startedAt = runStartedAt, posture = appState.clinicalPosture.label,
decidedAtCm = decidedAtCm, startedAt = runStartedAt, posture = appState.clinicalPosture.label, confirmTiltDeg = confirmTiltDeg,
),
)
}
@@ -982,7 +993,7 @@ fun AnchorAlignView(appState: AppState) {
saveName, appState.hospitalPatient, deviceName,
bleManager.firmwareVersion.value, guide.records, s,
lrCommit, lrConfirmed, basis, proceedCm, decidedAtCm,
startedAt = runStartedAt, posture = appState.clinicalPosture.label,
startedAt = runStartedAt, posture = appState.clinicalPosture.label, confirmTiltDeg = confirmTiltDeg,
confirmChannels = appState.anchorRefChannels,
),
)
@@ -1026,7 +1037,7 @@ fun AnchorAlignView(appState: AppState) {
runStartedAt = Date()
guide.reset(); cm = 0; last = null; records = emptyList(); error = null
lateral.reset(); lrCommit = null; lrAccum = 0; lrRecent.clear(); lrAttempt++
lrRefChannels = null
lrRefChannels = null; confirmTiltDeg = null
lrConfirmed = false; lrError = null; lrRunning = false
disconnectedMidRun = false
decidedAtCm = null; sweepStopped = false; confirmDone = false
@@ -1499,8 +1510,11 @@ private fun buildAlignSummary(
confirmChannels: List<Boolean>? = null,
/** 이 정렬의 자세(라벨). 자세마다 정렬이 따로라 요약에도 박는다(2026-10-01). */
posture: String? = null,
/** 확인 측정의 IMU 평균 기울기(도). Supine 이면 Sitting 기준각. 앱 재시작 뒤 복구에 쓴다. */
confirmTiltDeg: Float? = null,
): JSONObject = JSONObject().apply {
posture?.let { put("posture", it) }
confirmTiltDeg?.let { put("confirm_tilt_deg_mean", it.toDouble()) }
startedAt?.let { put("started_at", SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(it)) }
confirmChannels?.let { put("confirm_channels", JSONArray(it)) }
// 앱이 쓴 값 = **잰 위치 전부**로 고른 것. 선택 규칙은 Python `select_supine_anchor`
@@ -202,6 +202,12 @@ fun HospitalModeView(appState: AppState) {
runCatching { HospitalRunStore.scanProgress(patient, Date(), thickness.expectedKeys) }
.getOrDefault(emptyMap())
}
// Supine 기준각 — 앱을 다시 켰으면 그날 Supine 정렬 요약에서 되살린다(2026-10-01).
if (appState.supineTiltBaseline == null && patient.isNotBlank()) {
withContext(Dispatchers.IO) {
runCatching { HospitalRunStore.readConfirmTilt(patient, Date(), ClinicalPosture.SUPINE) }.getOrNull()
}?.let { appState.supineTiltBaseline = it }
}
}
// 자세 칩은 그 자세의 6단계가 전부 끝났을 때만 완료로 본다.
fun postureProgress(p: ClinicalPosture): HospitalRunStore.RunProgress {
@@ -400,9 +406,12 @@ fun HospitalModeView(appState: AppState) {
put("tilt_deg_max", runTilts.max())
put("tilt_deg_n", runTilts.size)
}
PostureTilt.rangeFor(posture)?.let { r ->
// 요구 범위는 Supine 기준각이 있으면 밀린 값(55-75 식)이다. 기준각도 같이 남겨
// 분석 쪽이 n − m 을 되살릴 수 있게 한다(2026-10-01).
PostureTilt.rangeFor(posture, appState.supineTiltBaseline)?.let { r ->
put("tilt_deg_required", "${r.start}-${r.endInclusive}")
}
appState.supineTiltBaseline?.let { put("tilt_baseline_supine", it.toDouble()) }
put("fill_pct", fill.percent)
put("repeats", repeats)
// 돌 조합을 이것이 정했다. 조합만 기록하면 "왜 1개만 돌았나"를 나중에
@@ -598,6 +607,7 @@ fun HospitalModeView(appState: AppState) {
posture = posture,
connected = isConnected,
running = running,
supineBaseline = appState.supineTiltBaseline,
)
Spacer(Modifier.height(12.dp))
@@ -50,9 +50,14 @@ fun ImuTiltCard(
connected: Boolean,
running: Boolean,
modifier: Modifier = Modifier,
/**
* Supine 정렬 확인 측정의 기울기 m (2026-10-01). 있으면 Sitting 범위가 `35+m~55+m` 로
* 밀리고 카드가 `n − m` 을 같이 보인다. 없으면 절대 각도 기준이고 그 사실을 적는다.
*/
supineBaseline: Float? = null,
) {
val range = PostureTilt.rangeFor(posture)
val status = tiltDeg?.let { PostureTilt.status(it, posture) }
val range = PostureTilt.rangeFor(posture, supineBaseline)
val status = tiltDeg?.let { PostureTilt.status(it, posture, supineBaseline) }
val accent = when (status) {
PostureTilt.Status.IN_RANGE -> InRange
PostureTilt.Status.BELOW, PostureTilt.Status.ABOVE -> OutRange
@@ -72,6 +77,14 @@ fun ImuTiltCard(
if (tiltDeg == null) "—" else String.format(Locale.US, "%.0f°", tiltDeg),
fontSize = 26.sp, fontWeight = FontWeight.Bold, color = accent,
)
// Sitting 에 기준각이 있으면 "Supine 대비 몇 도" — 판정이 보는 숫자가 이것이다.
if (tiltDeg != null && supineBaseline != null && posture == ClinicalPosture.SITTING) {
Spacer(Modifier.width(8.dp))
Text(
String.format(Locale.US, "Supine 대비 %+.0f°", tiltDeg - supineBaseline),
fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = accent,
)
}
Spacer(Modifier.weight(1f))
Text(
when {
@@ -83,6 +96,14 @@ fun ImuTiltCard(
fontSize = 11.sp, color = Neutral,
)
}
// 기준각은 어느 자세에서든 작게 — Supine 정렬 뒤에 "저장됐다"가 보여야 한다.
if (supineBaseline != null && posture != ClinicalPosture.SITTING) {
Text(
String.format(Locale.US, "Supine 기준각 %.0f° 저장됨 (Sitting 범위 %.0f°~%.0f°)",
supineBaseline, PostureTilt.SITTING_DEG.start + supineBaseline, PostureTilt.SITTING_DEG.endInclusive + supineBaseline),
fontSize = 11.sp, color = Neutral, modifier = Modifier.padding(top = 2.dp),
)
}
if (range != null) {
Spacer(Modifier.height(6.dp))
@@ -93,6 +114,12 @@ fun ImuTiltCard(
append(posture.label)
append(" 측정 가능 각도 ")
append(String.format(Locale.US, "%.0f°~%.0f°", range.start, range.endInclusive))
if (supineBaseline != null) {
append(String.format(Locale.US, " (Supine %.0f° + %.0f~%.0f)",
supineBaseline, PostureTilt.SITTING_DEG.start, PostureTilt.SITTING_DEG.endInclusive))
} else {
append(" · Supine 기준각 없음")
}
when (status) {
PostureTilt.Status.IN_RANGE -> append(" · 범위 안")
PostureTilt.Status.BELOW ->
@@ -53,6 +53,23 @@ class PostureTiltTest {
assertNull(PostureTilt.degrees(emptyList()))
}
@Test
fun `Supine 기준각이 있으면 Sitting 범위가 그만큼 밀린다`() {
// 2026-10-01: 배가 나와 Supine 에서 20° 면 Sitting 최적 구간은 55~75 다.
val r = PostureTilt.rangeFor(ClinicalPosture.SITTING, 20f)!!
assertEquals(55f, r.start, 1e-4f)
assertEquals(75f, r.endInclusive, 1e-4f)
assertEquals(PostureTilt.Status.BELOW, PostureTilt.status(50f, ClinicalPosture.SITTING, 20f))
assertEquals(PostureTilt.Status.IN_RANGE, PostureTilt.status(55f, ClinicalPosture.SITTING, 20f))
assertEquals(PostureTilt.Status.IN_RANGE, PostureTilt.status(75f, ClinicalPosture.SITTING, 20f))
assertEquals(PostureTilt.Status.ABOVE, PostureTilt.status(76f, ClinicalPosture.SITTING, 20f))
// 기준각이 없으면 절대 각도 그대로.
assertEquals(35f, PostureTilt.rangeFor(ClinicalPosture.SITTING, null)!!.start, 0f)
// 다른 자세는 기준각이 있어도 범위가 없다.
assertNull(PostureTilt.rangeFor(ClinicalPosture.SUPINE, 20f))
assertNull(PostureTilt.rangeFor(ClinicalPosture.STANDING, 20f))
}
@Test
fun `다른 자세에는 범위가 없다`() {
assertNull(PostureTilt.rangeFor(ClinicalPosture.SUPINE))