e6ef235784
PostureTilt KDoc 에 "분석팀 정의가 다르면 여기서 맞춘다" 는 열린 질문이 있었다. 요청자가 같은 정의(atan(√(ax²+ay²)/|az|) · 0° 평평 · 90° 세로)로 낸 값이라고 확인했으므로 닫는다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
198 lines
9.3 KiB
Kotlin
198 lines
9.3 KiB
Kotlin
/*
|
||
* 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
|
||
}
|
||
}
|
||
}
|