/* * 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> = ProbeFrequency.entries.flatMap { f -> ProbeCycle.entries.map { c -> f to c } } /** 진행 판정에 쓰는 조합 식별자. 매니페스트의 `freq_option`/`cycles` 와 같은 모양이다. */ val Pair.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>, ) { 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 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 = 35f..55f fun rangeFor(posture: ClinicalPosture): ClosedFloatingPointRange? = 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 } } }