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