Files
VesiscanClinicalAndroid/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt
T
dw.jang e6ef235784 docs(hospital): Sitting 35°~55° 가 앱의 기울기 정의와 같은 값임을 요청자가 확인 (2026-09-28)
PostureTilt KDoc 에 "분석팀 정의가 다르면 여기서 맞춘다" 는 열린 질문이 있었다.
요청자가 같은 정의(atan(√(ax²+ay²)/|az|) · 0° 평평 · 90° 세로)로 낸 값이라고
확인했으므로 닫는다.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 11:45:11 +09:00

198 lines
9.3 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* HospitalProtocol — 병원 임상 측정 프로토콜 정의.
*
* ## 무엇을 하는 모드인가
* 간호사가 방광을 정해진 정도까지 채워 두면, 조작자는 **채움 정도와 복부 두께만** 고르고
* [측정 시작]을 누른다. 그 뒤로는 앱이 [AbdomenThickness] 가 정한 조합을 순회하며 각
* 조합마다 6채널을 n회 측정하고, 조합별로 파일 하나씩 저장한다.
*
* 사람이 개입하는 지점을 그 둘로 줄인 것이 핵심이다 — 조합을 손으로 바꾸면 반드시
* 빠뜨리거나 잘못 기록한다.
*
* 2026-09-09 까지는 주파수 2종 × cycle 3종 = 6조합을 전부 돌았다([HOSPITAL_COMBINATIONS]).
* 기존 72세션이 그 격자다.
*
* ## 측정 파라미터 (2026-09-02 펌웨어팀 확인)
* `mcs?` 는 **다섯** 개를 받는다 — freq · cycles · avg · delay_us · samples.
*
* 요청: mcs? [tag 4B] [freq 2B][cycles 2B][avg 2B][delay_us 2B][samples 2B] [crc 2B] = 16B
* 응답: rcs: 같은 구성으로 **저장된 값을 echo**
*
* | 필드 | 범위 | 뜻 |
* |---|---|---|
* | freq | 0~5 | 0=1.8 · 1=1.9 · 2=2.0 · 3=2.1 · 4=2.2 · 5=2.3 MHz |
* | cycles | 3~7 | burst cycle 수 |
* | avg | 1~10 | averaging 횟수 |
* | delay_us | 0~50 | burst 후 capture delay |
* | samples | 80~117 | echo sample 개수 |
*
* ## ⚠ 설정은 프로브에 **영구 저장**된다 (NVS)
* 한 번 보내면 전원을 껐다 켜도 유지된다. 즉 이 모드로 측정하고 나면 **일반 측정
* 화면도 마지막으로 설정한 값으로 동작한다.** 임상이 끝나면 원래 값으로 되돌리든지,
* 적어도 무엇으로 바꿔 놓았는지 알고 있어야 한다 — run json 에 남긴다.
*
* ## 응답을 반드시 확인한다
* 실패해도 `rcs:` 는 온다. 구분은 freq 값이다:
* · 0xFFFF — 데이터 길이 부족 또는 파라미터 범위 초과
* · 0xFFFD — 검증은 통과했으나 NVS 저장 실패
* 확인하지 않으면 **설정이 거부된 채 옛 설정으로 측정**하고, 파일에는 요청한 값이
* 적힌다. 데이터가 조용히 잘못 라벨링되는 가장 나쁜 경우다.
*/
package com.medithings.vesiscan.models
/**
* 프로브 송신 주파수. [freqOption] 이 `mcs?` 의 첫 인자다.
*
* 펌웨어는 0~5 여섯 단계를 지원하지만(0=1.8 … 5=2.3, 0.1MHz 간격) 이 프로토콜은 양 끝
* 둘만 쓴다. 중간값이 필요해지면 여기 항목만 더하면 된다.
*/
enum class ProbeFrequency(val label: String, val freqOption: Int) {
F_1_8("1.8", 0),
F_2_3("2.3", 5),
;
/** 파일명에 쓸 표기 — MHz 와 실제 보낸 값을 함께 남긴다. */
val fileTag: String get() = "${label}MHz-f$freqOption"
}
/** 버스트 cycle 수. `mcs?` 의 둘째 인자로 그대로 나간다 (허용 3~7). */
enum class ProbeCycle(val cycles: Int) {
C3(3), C5(5), C7(7);
val fileTag: String get() = "c$cycles"
}
/**
* 방광 채움 정도 — 간호사가 채워 둔 값을 조작자가 고른다.
*
* 앱이 재는 값이 아니라 **사람이 아는 값**이다. 이 모드의 목적이 "채움 정도가 알려진
* 상태에서 6채널 원신호를 모으는 것"이라, 이 값이 곧 정답 라벨이 된다.
*/
enum class BladderFill(val percent: Int) {
P0(0), P20(20), P40(40), P60(60), P80(80), P100(100);
val label: String get() = "$percent%"
val fileTag: String get() = "%03dpct".format(percent)
}
/**
* 한 번의 [측정 시작]이 도는 조합 목록 — 주파수 2 × cycle 3 = 6.
*
* 순서를 고정해 둔다. 매번 같은 순서로 돌아야 나중에 파일을 비교할 때 조건이 섞이지
* 않는다. 주파수를 바깥 고리에 둔 이유는 주파수 전환이 cycle 전환보다 프로브에 더 큰
* 변화라, 한 주파수 안에서 cycle 셋을 몰아 끝내는 편이 안정적이기 때문이다.
*/
val HOSPITAL_COMBINATIONS: List<Pair<ProbeFrequency, ProbeCycle>> =
ProbeFrequency.entries.flatMap { f -> ProbeCycle.entries.map { c -> f to c } }
/** 진행 판정에 쓰는 조합 식별자. 매니페스트의 `freq_option`/`cycles` 와 같은 모양이다. */
val Pair<ProbeFrequency, ProbeCycle>.progressKey: String
get() = "${first.freqOption}/${second.cycles}"
/**
* 복부 두께 — **이번 실행에서 돌 조합을 이것이 정한다.**
*
* ## 왜 두께로 고르는가
* 주파수는 투과 깊이와 맞바꾸는 값이다. 낮은 주파수(1.8MHz)가 더 깊이 들어가고, 높은
* 주파수(2.3MHz)는 얕지만 분해능이 좋다.
*
* 복부가 얇으면 **2.3MHz 하나**면 된다 — 그 깊이까지 닿고 분해능이 더 좋다. 두꺼우면
* 2.3MHz 가 뒤벽까지 못 닿을 수 있는데 어느 쪽이 그 환자에게 맞는지 미리 알 수 없어
* **둘 다 받아 두고 나중에 고른다.**
*
* 얇은 쪽이 2.3MHz 인 것은 정렬과도 맞아떨어진다. 정렬은 2.3MHz·cycle 3 고정이라,
* 40mm 이하 환자는 **정렬 BV 와 프로토콜 BV 가 같은 조건**이 되어 직접 비교할 수 있다
* (표본 수만 다르다 — 정렬 20 cycle 평균, 프로토콜 1 cycle).
* 40mm 초과는 1.8MHz 회차가 섞이므로 2.3MHz 회차만 비교해야 한다.
*
* ## 6조합 순회를 왜 그만두는가
* 종전에는 주파수 2 × cycle 3 = 6조합을 전부 돌았다. 한 단계에 20회 × 6 = 120회,
* 자세·충만도까지 곱하면 환자 한 명이 너무 오래 눕는다. cycle 은 3 으로 고정한다 —
* 6조합 데이터(72세션)에서 cycle 을 늘려 얻는 것이 측정 시간을 정당화하지 못했다.
*
* [HOSPITAL_COMBINATIONS] 는 남겨 둔다. 기존 72세션이 그 격자로 쌓여 있어, 과거 데이터를
* 읽는 쪽이 그 정의를 계속 참조한다.
*/
enum class AbdomenThickness(
val label: String,
val combos: List<Pair<ProbeFrequency, ProbeCycle>>,
) {
UPTO_40("40mm 이하", listOf(ProbeFrequency.F_2_3 to ProbeCycle.C3)),
OVER_40("40mm 초과", listOf(
ProbeFrequency.F_1_8 to ProbeCycle.C3,
ProbeFrequency.F_2_3 to ProbeCycle.C3,
)),
;
/** 화면에 쓰는 조합 요약 — 조작자가 무엇을 돌지 눈으로 확인해야 한다. */
val comboLabel: String
get() = combos.joinToString(" · ") { "${it.first.label}MHz c${it.second.cycles}" }
/** 진행 판정용 기대 조합 집합. */
val expectedKeys: Set<String> get() = combos.map { it.progressKey }.toSet()
/** 기록에 쓰는 값. 분석 스크립트가 문자열로 비교하므로 고정이다. */
val wire: String get() = if (this == UPTO_40) "upto_40mm" else "over_40mm"
}
/** 한 조합당 기본 반복 횟수. 화면에서 바꿀 수 있다. */
const val HOSPITAL_DEFAULT_REPEATS = 20
/**
* 이 프로토콜이 고정으로 쓰는 나머지 측정 파라미터.
*
* 프로토콜이 바꾸는 것은 주파수와 cycle 뿐이다. 나머지 셋은 **모든 조합에서 같아야**
* 조합 간 비교가 성립하므로 여기 한 곳에 박아 둔다. 값은 펌웨어팀 예시
* (`00 05 00 03 00 0A 00 0A 00 64`)를 따랐다.
*
* ⚠ 이 값들도 프로브에 영구 저장된다. 바꾸면 이후 모든 측정에 영향을 준다.
*/
object HospitalFixedParams {
/** averaging 횟수 (허용 1~10). */
const val AVG = 10
/** burst 후 capture delay, µs (허용 0~50). */
const val DELAY_US = 10
/** 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` 의 원시값에서 분석팀이 다시 계산해도 같은 숫자가 나온다.
* **2026-09-28 요청자 확인: 35°~55° 는 이 정의로 낸 값이 맞다.**
*
* ## 왜 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
}
}
}