From d401740210fa29d8c1d100c196601283647f099a Mon Sep 17 00:00:00 2001 From: jjangddu Date: Wed, 9 Sep 2026 15:33:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(clinical):=20=EB=B3=91=EC=9B=90=20?= =?UTF-8?q?=EC=9E=84=EC=83=81=20=ED=99=94=EB=A9=B4=20=EC=9E=AC=EA=B5=AC?= =?UTF-8?q?=EC=84=B1=20=C2=B7=20=EC=A1=B0=ED=95=A9=EC=9D=80=20=EB=B3=B5?= =?UTF-8?q?=EB=B6=80=20=EB=91=90=EA=BB=98=EB=A1=9C=20=C2=B7=20=EB=A0=88?= =?UTF-8?q?=ED=8D=BC=EB=9F=B0=EC=8A=A4=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 화면을 두 덩어리로 나눴다 환자명 → 부착 위치 정렬 → 자세 → 방광 채움 → 반복 횟수 → 복부 두께 → [측정 시작] → BV 카드 ──────────────────────── 구분선 ──────────────────────── 수동 확인 — 저장되지 않습니다 프로브 파라미터 주입 → [1회 측정]/[연속 측정] → BV → 마지막 측정 → 6채널 종전에는 BV 측정 구간이 [측정 시작] **위에** 있어 프로토콜 흐름 한가운데 끼어 있었다. 수동 측정 값은 저장도 업로드도 안 되는데, 한 흐름으로 붙어 있으면 조작자가 그것을 프로토콜의 한 단계로 착각한다. 구분선과 "저장되지 않습니다" 한 줄을 넣었다. 프로토콜 BV 카드는 실행이 끝난 뒤에도 남긴다(caption 이 "직전 회차"→"마지막 회차"). 방광을 비우기 전에 방금 받은 값이 말이 되는지 한 번 더 볼 수 있어야 한다. ## 6조합 순회 → 복부 두께 2택 40mm 이하 1.8MHz c3 → n회 40mm 초과 1.8MHz c3 · 2.3MHz c3 → 2n회 cycle 은 3 고정. 한 단계 20회 × 6조합 = 120회가 환자를 너무 오래 눕힌다. 조작자에게 freq·cycle 을 고르게 하지 않는 것이 핵심이다. 눈으로 보고 답할 수 있는 것은 복부 두께이고, 조합은 거기서 따라 나온다(AbdomenThickness). ## 진행 판정: 개수 비교 → 집합 비교 (여기가 제일 위험했다) `EXPECTED_COMBINATIONS = 6` 으로 **개수**를 세고 있었다. 그대로 두면 요구 조합이 1~2개인 지금 어떤 단계도 영원히 PARTIAL 로 남는다. 그런데 단순히 6을 2로 바꾸면 더 나쁘다 — 2.3MHz 를 두 번 채운 것과 1.8·2.3 을 각각 채운 것이 같은 숫자가 되어, 두꺼운 환자의 단계가 완료로 잡히고 **그 자리에서 1.8MHz 를 영원히 놓친다.** `scanProgress(patient, day, expected)` 로 기대 집합을 받아 `containsAll` 로 본다. expected 가 비면 완료라고 말하지 않는다 — `containsAll(emptySet)` 은 항상 true 라 가드가 없으면 아무것도 안 잰 단계까지 완료가 된다. 옛 6조합 데이터는 두 집합 모두의 상위집합이므로 계속 완료로 읽힌다(과거 데이터가 뒤집히면 간호사가 다 다시 잰다). ## 완료 칩을 초록 → 회색 초록은 "좋은 상태"로 읽혀 조작자가 거기서 멈춘다. 실제 의미는 "이미 받았으니 다음으로 가라"다. 아직 안 받은 칩이 눈에 들어와야 한다 — 남은 일이 어디인지가 그 줄의 존재 이유다. 누를 수는 있게 둔다(파형이 이상해 다시 재는 경우). ## 레퍼런스 알고리즘 스위치 제거 병원 임상의 값은 최종 보고본 하나여야 한다. 스위치가 있으면 화면을 본 사람과 기록을 읽는 사람이 서로 다른 경로의 숫자를 같은 값이라고 믿을 수 있고, 그 착오는 기록의 알고리즘 라벨을 일일이 확인해야만 드러난다. 두 경로 비교는 테스트가 맡는다 (ClinicalBvTest · AlgoPathReportTest). ## 기록 매니페스트에 `abdomen_thickness` / `abdomen_thickness_label`. 조합만 남기면 "왜 1개만 돌았나"를 나중에 답할 수 없다 — 시간이 없어 끊은 것과 얇아서 하나면 됐던 것이 구분되지 않는다. LABDB_DATATYPES.md 에 "한 조건에 세션 1개인 것이 정상"을 박았다. 테스트 114개 통과(진행 판정 12개 전면 재작성). HospitalProgressTest 가 과거 격자를 손으로 적지 않고 HOSPITAL_COMBINATIONS 를 참조한다 — 둘이 갈리면 "과거 데이터는 계속 완료로 읽힌다"는 보장이 거짓이 된다. Co-Authored-By: Claude Opus 5 --- .../vesiscan/models/HospitalProtocol.kt | 56 +++- .../vesiscan/services/HospitalRunStore.kt | 28 +- .../ui/views/clinical/BvMeasureSection.kt | 36 +-- .../ui/views/clinical/HospitalModeView.kt | 256 ++++++++---------- .../vesiscan/services/HospitalProgressTest.kt | 117 +++++--- docs/LABDB_DATATYPES.md | 17 ++ 6 files changed, 288 insertions(+), 222 deletions(-) diff --git a/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt b/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt index b3ff2c8..e99d89c 100644 --- a/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt +++ b/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt @@ -2,12 +2,15 @@ * HospitalProtocol — 병원 임상 측정 프로토콜 정의. * * ## 무엇을 하는 모드인가 - * 간호사가 방광을 정해진 정도까지 채워 두면, 조작자는 **그 채움 정도 하나만** 고르고 - * [측정 시작]을 누른다. 그 뒤로는 앱이 주파수 2종 × cycle 3종 = **6조합을 자동으로 - * 순회**하며 각 조합마다 6채널을 n회 측정하고, 조합별로 파일 하나씩 저장한다. + * 간호사가 방광을 정해진 정도까지 채워 두면, 조작자는 **채움 정도와 복부 두께만** 고르고 + * [측정 시작]을 누른다. 그 뒤로는 앱이 [AbdomenThickness] 가 정한 조합을 순회하며 각 + * 조합마다 6채널을 n회 측정하고, 조합별로 파일 하나씩 저장한다. * - * 사람이 개입하는 지점을 방광 채움 하나로 줄인 것이 핵심이다 — 조합을 손으로 바꾸면 - * 반드시 빠뜨리거나 잘못 기록한다. 6조합 × n회가 한 번의 버튼으로 끝난다. + * 사람이 개입하는 지점을 그 둘로 줄인 것이 핵심이다 — 조합을 손으로 바꾸면 반드시 + * 빠뜨리거나 잘못 기록한다. + * + * 2026-09-09 까지는 주파수 2종 × cycle 3종 = 6조합을 전부 돌았다([HOSPITAL_COMBINATIONS]). + * 기존 72세션이 그 격자다. * * ## 측정 파라미터 (2026-09-02 펌웨어팀 확인) * `mcs?` 는 **다섯** 개를 받는다 — freq · cycles · avg · delay_us · samples. @@ -82,6 +85,49 @@ enum class BladderFill(val percent: Int) { 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)는 얕지만 분해능이 좋다. 복부가 얇으면 1.8MHz 하나로 방광 뒤벽까지 + * 닿으므로 더 잴 이유가 없다. 두꺼우면 어느 쪽이 그 환자에게 맞는지 미리 알 수 없어 + * **둘 다 받아 두고 나중에 고른다.** + * + * ## 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_1_8 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 diff --git a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt index 409f9b4..f46d0e7 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt @@ -232,12 +232,17 @@ object HospitalRunStore { * * @return (자세 label, 방광 %) → 진행 상태 */ - fun scanProgress(patient: String, day: Date): Map, RunProgress> { + fun scanProgress( + patient: String, + day: Date, + /** 이번 프로토콜이 요구하는 조합 키들([AbdomenThickness.expectedKeys]). */ + expected: Set, + ): Map, RunProgress> { val dir = runDirIfExists(patient, day) ?: return emptyMap() val texts = dir.listFiles { f -> f.isFile && f.name.startsWith("run_") && f.name.endsWith(".json") }?.mapNotNull { runCatching { it.readText(Charsets.UTF_8) }.getOrNull() } ?: emptyList() - return progressFrom(texts) + return progressFrom(texts, expected) } /** @@ -246,7 +251,10 @@ object HospitalRunStore { * 파일 읽기와 분리해 둔 이유는 시험 때문이다. 이 판정이 틀리면 간호사가 "다 됐다"고 * 믿고 방광을 비우는데 데이터는 못 쓰는 상황이 되므로, 실기기 없이도 고정해 둬야 한다. */ - internal fun progressFrom(manifests: List): Map, RunProgress> { + internal fun progressFrom( + manifests: List, + expected: Set, + ): Map, RunProgress> { // (자세, 충만도) → 계획을 채운 조합들. 조합 식별자는 "주파수옵션/cycle" 이다. val filled = HashMap, MutableSet>() // 매니페스트는 있는데 채운 조합이 하나도 없는 경우도 PARTIAL 로 남겨야 한다. @@ -270,8 +278,16 @@ object HospitalRunStore { } val out = HashMap, RunProgress>() for (key in seen) { - val n = filled[key]?.size ?: 0 - out[key] = if (n >= EXPECTED_COMBINATIONS) RunProgress.DONE else RunProgress.PARTIAL + val got = filled[key] ?: emptySet() + // **개수가 아니라 집합으로 본다.** 종전에는 6개를 셌는데, 복부 두께에 따라 + // 요구 조합이 1개나 2개로 달라지므로 개수 비교로는 "어느 조합을 채웠는지"를 + // 알 수 없다. 2.3MHz 만 두 번 채운 것과 1.8·2.3 을 각각 채운 것이 같은 + // 숫자가 되어 완료로 잡힌다. + // + // expected 가 비면 판정 근거가 없다 — 완료라고 말하지 않는다. 간호사가 + // "다 됐다"고 믿고 방광을 비우면 그 단계는 다시 못 잰다. + out[key] = if (expected.isNotEmpty() && got.containsAll(expected)) + RunProgress.DONE else RunProgress.PARTIAL } return out } @@ -280,6 +296,4 @@ object HospitalRunStore { private fun runDirIfExists(patient: String, day: Date): File? = File(File(downloads(), ROOT), "${dayFmt.format(day)}_${safe(patient)}").takeIf { it.isDirectory } - /** 주파수 2종 × cycle 3종. [HOSPITAL_COMBINATIONS] 와 같은 수여야 한다. */ - private const val EXPECTED_COMBINATIONS = 6 } diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/BvMeasureSection.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/BvMeasureSection.kt index fb39d55..4fc8fed 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/BvMeasureSection.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/BvMeasureSection.kt @@ -52,6 +52,13 @@ import kotlinx.coroutines.withTimeoutOrNull * 정렬 단계의 20-cycle mean-scan 과도 또 다른 세 번째 방식이 된다. 연속 측정이 * 메인과 비교 가능해야 하므로 부피끼리 평균한다. * + * ## 알고리즘은 레퍼런스 고정이다 + * 종전에는 기존 경로로 되돌리는 스위치가 있었다. 뺐다 — 병원 임상의 값은 **최종 + * 보고본 하나**여야 한다. 스위치가 있으면 화면을 본 사람과 기록을 읽는 사람이 서로 + * 다른 경로의 숫자를 보고 같은 값이라고 믿을 수 있고, 그 착오는 기록에 남은 알고리즘 + * 라벨을 일일이 확인해야만 드러난다. 두 경로를 비교할 일은 테스트가 맡는다 + * (`ClinicalBvTest` · `AlgoPathReportTest`). + * * ## 절사평균인 이유 * 최대·최소 하나씩 버리고 평균낸다. cycle 단위 BV 는 벽 오검출로 가끔 크게 튀는데, * 산술평균은 그 한 번에 통째로 끌려간다. @@ -92,10 +99,6 @@ fun BvMeasureSection( var window by remember { mutableStateOf>(emptyList()) } var tick by remember { mutableIntStateOf(0) } - // 병원 임상은 **최종 보고본이 기본**이다. 전역 AlgoMode 를 켜지 않는다 — - // ClinicalBv 가 계산 동안만 그 경로로 돌리고 되돌리므로, 일반 측정 화면의 BV 는 - // 영향을 받지 않는다. 끄는 것은 두 경로를 그 자리에서 비교하기 위한 용도다. - var useReference by remember { mutableStateOf(true) } LaunchedEffect(mode) { val m = mode ?: return@LaunchedEffect @@ -143,9 +146,12 @@ fun BvMeasureSection( val sorted = got.sortedBy { it.channel } live = sorted // **1 cycle = BV 1개.** 메인 화면 measure() 와 같은 단위다. + // **항상 레퍼런스(최종 보고본) 경로다.** 전역 AlgoMode 는 건드리지 + // 않는다 — ClinicalBv 가 계산 동안만 그 경로로 돌리고 되돌리므로 + // 일반 측정 화면의 BV 는 영향을 받지 않는다. val r = ClinicalBv.compute( ClinicalBv.toSignals(sorted.map { it.buffer }), - supine = supine, reference = useReference, + supine = supine, reference = true, ) outcome = r tick++ @@ -181,26 +187,6 @@ fun BvMeasureSection( fontSize = 11.sp, color = MlSecondaryText, ) - // 경로 전환. 값이 달라질 수 있으므로 기록에는 반드시 어느 쪽인지 같이 남겨야 - // 한다 — BvPanel 이 매 값에 붙인다. - Row(verticalAlignment = Alignment.CenterVertically) { - Switch( - checked = useReference, - onCheckedChange = { useReference = it }, - enabled = !running, - ) - Spacer(Modifier.width(8.dp)) - Column { - Text( - if (useReference) "레퍼런스 알고리즘 (최종 보고본)" else "기존 알고리즘 (현행 임상)", - fontSize = 13.sp, fontWeight = FontWeight.SemiBold, - ) - Text( - "이 화면에만 적용됩니다. 끄면 기존 경로로 계산합니다.", - fontSize = 10.sp, color = MlSecondaryText, - ) - } - } Spacer(Modifier.height(8.dp)) Row(Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt index 28031e6..ed036db 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt @@ -1,22 +1,23 @@ /* * HospitalModeView — 병원 임상 측정 모드. * - * ## 조작자가 하는 일은 하나다 - * 간호사가 방광을 정해진 정도까지 채워 두면, 조작자는 **그 채움 정도만** 고르고 - * [측정 시작]을 누른다. 그 뒤로 주파수 2 × cycle 3 = 6조합을 앱이 자동으로 순회하며 - * 각 조합마다 n회(기본 20) 측정하고 조합별 파일로 저장한다. + * ## 조작자가 고르는 것은 둘이다 + * 간호사가 방광을 정해진 정도까지 채워 두면, 조작자는 **채움 정도와 복부 두께**를 고르고 + * [측정 시작]을 누른다. 그 뒤로 **복부 두께가 정한 조합**을 앱이 순회하며 조합마다 + * 6채널을 n회 측정하고, 조합별로 파일 하나씩 저장한다. * - * 조합을 손으로 바꾸게 두면 반드시 빠뜨리거나 잘못 기록한다. 이미 채워 둔 방광은 - * 다시 만들 수 없으므로, 그 자리에서 놓친 조합은 그날 데이터에서 영영 빈다. + * 복부 두께 40mm 이하 → 1.8MHz c3 → n회 + * 복부 두께 40mm 초과 → 1.8MHz c3 · 2.3MHz c3 → 2n회 * - * ## 측정 한 번의 흐름 - * sendPiezoConfig(freqOption, cycles) ← 조합 진입 시 1회 (mcs?) - * sendMtb() → piezoCollector.onComplete ← n회 반복 + * 사람이 개입하는 지점을 **방광 채움 정도와 복부 두께 둘**로 줄인 것이 핵심이다. + * 조작자가 freq·cycle 조합을 손으로 고르면 반드시 빠뜨리거나 잘못 기록한다 — 눈으로 + * 보고 답할 수 있는 것은 복부 두께이고, 조합은 거기서 따라 나온다([AbdomenThickness]). * - * `sendMtb` 는 6채널+IMU 를 한 번에 받는 명령이고, 완료는 콜백으로 온다. 그래서 루프는 - * "보내고 기다린다"가 아니라 **콜백이 올 때마다 다음 것을 보내는** 구조다. 응답이 - * 안 오면 [MEASURE_TIMEOUT_MS] 뒤에 그 회차를 실패로 세고 다음으로 넘어간다 — - * 한 번 막혔다고 전체가 멈추면 채워 둔 방광을 버리게 된다. + * ## 화면은 두 덩어리다 + * 구분선 위는 **프로토콜**(환자명 → 정렬 → 자세 → 충만도 → 반복 → 두께 → 측정 시작), + * 아래는 **수동 확인**(프로브 파라미터 주입 · 1회/연속 BV 측정)이다. 아래쪽 값은 + * 저장도 업로드도 되지 않는다 — 한 흐름으로 붙여 두면 조작자가 수동 측정을 프로토콜의 + * 한 단계로 착각한다. */ package com.medithings.vesiscan.ui.views.clinical @@ -44,6 +45,7 @@ import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid import com.medithings.vesiscan.AppScreen import com.medithings.vesiscan.AppState import com.medithings.vesiscan.ble.BleManager +import com.medithings.vesiscan.models.AbdomenThickness import com.medithings.vesiscan.models.* import com.medithings.vesiscan.services.AdcCsvLogger import com.medithings.vesiscan.services.HospitalRunStore @@ -86,13 +88,13 @@ fun HospitalModeView(appState: AppState) { var fill by remember { mutableStateOf(BladderFill.P0) } var repeatsText by remember { mutableStateOf(HOSPITAL_DEFAULT_REPEATS.toString()) } /** - * 이번 실행에서 돌 조합. **기본은 6개 전부** — 프로토콜이 요구하는 것이 전부이고, - * 부분 선택은 예외적인 상황(한 조합만 다시 재기, 시간이 없어 나눠 재기)을 위한 것이다. + * 복부 두께 — 이번 실행에서 돌 조합을 이것이 정한다([AbdomenThickness]). * - * 나눠 재도 조건이 완료로 잡힌다 — `HospitalRunStore.scanProgress` 가 같은 - * 자세·충만도의 여러 실행을 **조합 단위로 합산**하기 때문이다. + * 얇으면 1.8MHz 하나, 두꺼우면 1.8·2.3 둘 다 받는다. 조작자가 조합을 직접 고르던 + * 것을 대체한다 — 조합 이름(freq·cycle)은 조작자가 판단할 거리가 아니고, 눈으로 + * 보고 답할 수 있는 것은 복부 두께다. */ - var selectedCombos by remember { mutableStateOf(HOSPITAL_COMBINATIONS.toSet()) } + var thickness by remember { mutableStateOf(AbdomenThickness.UPTO_40) } var running by remember { mutableStateOf(false) } var comboIdx by remember { mutableIntStateOf(0) } @@ -146,11 +148,13 @@ fun HospitalModeView(appState: AppState) { // 오늘·이 환자의 폴더에 남은 실행 매니페스트를 읽어 "어디까지 했는지"를 칩에 입힌다. // `running` 이 바뀔 때마다(= 실행이 끝날 때마다) 다시 읽어 방금 잰 단계가 즉시 채워진다. var progress by remember { mutableStateOf(emptyMap, HospitalRunStore.RunProgress>()) } - LaunchedEffect(patient, running) { + // thickness 도 키다 — 두께를 바꾸면 "완료"의 기준 자체가 달라진다. + LaunchedEffect(patient, running, thickness) { progress = if (patient.isBlank()) emptyMap() // 디스크 접근이라 IO 로 뺀다. 작은 JSON 몇 개지만 UI 스레드에서 파일을 읽지 않는다. else withContext(Dispatchers.IO) { - runCatching { HospitalRunStore.scanProgress(patient, Date()) }.getOrDefault(emptyMap()) + runCatching { HospitalRunStore.scanProgress(patient, Date(), thickness.expectedKeys) } + .getOrDefault(emptyMap()) } } // 자세 칩은 그 자세의 6단계가 전부 끝났을 때만 완료로 본다. @@ -189,9 +193,9 @@ fun HospitalModeView(appState: AppState) { bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) } try { - // 선택 순서가 아니라 **프로토콜 순서**를 지킨다. 매번 같은 순서로 돌아야 - // 나중에 파일을 비교할 때 조건이 섞이지 않는다. - val combos = HOSPITAL_COMBINATIONS.filter { it in selectedCombos } + // 복부 두께가 정한 조합. 순서는 [AbdomenThickness] 가 박아 두었다 — 매번 + // 같은 순서로 돌아야 나중에 파일을 비교할 때 조건이 섞이지 않는다. + val combos = thickness.combos combos.forEachIndexed { ci, (freq, cycle) -> if (!running) return@forEachIndexed comboIdx = ci @@ -305,6 +309,11 @@ fun HospitalModeView(appState: AppState) { put("posture", posture.label) put("fill_pct", fill.percent) put("repeats", repeats) + // 돌 조합을 이것이 정했다. 조합만 기록하면 "왜 1개만 돌았나"를 나중에 + // 답할 수 없다 — 시간이 없어 끊은 것과 얇아서 하나면 됐던 것이 구분되지 + // 않는다. + put("abdomen_thickness", thickness.wire) + put("abdomen_thickness_label", thickness.label) put("device", deviceName ?: "") put("firmware_version", fw) // ⚠ 주파수 코드값이 확인 전이라는 사실을 데이터와 함께 남긴다. @@ -423,7 +432,7 @@ fun HospitalModeView(appState: AppState) { ) { it.label } Hint( if (patient.isBlank()) "환자명을 넣으면 오늘 이 환자의 진행 상황이 표시된다." - else "✓ 완료 · · 일부(다시 눌러 마저 측정)" + else "회색 ✓ 완료 · 주황 · 일부(다시 눌러 마저 측정)" ) Spacer(Modifier.height(12.dp)) @@ -434,36 +443,21 @@ fun HospitalModeView(appState: AppState) { singleLine = true, enabled = !running, supportingText = { - Text("${selectedCombos.size}조합 × ${repeats}회 = 총 ${selectedCombos.size * repeats}회 측정") + Text("${thickness.combos.size}조합 × ${repeats}회 = " + + "총 ${thickness.combos.size * repeats}회 측정") }, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(12.dp)) - // ── 돌릴 조합 고르기 ──────────────────────────────────────────── - // 기본은 6개 전부다. 프로토콜이 요구하는 것이 전부이고, 여기서 빼는 것은 - // "한 조합만 다시 잰다" 같은 예외를 위한 것이다. 그래서 해제는 되지만 - // 마지막 하나는 못 끄게 한다 — 0개로 시작을 누르면 아무 일도 안 일어난다. - Label("측정할 조합") - CombinationPicker( - selected = selectedCombos, - onToggle = { combo -> - if (!running) { - selectedCombos = - if (combo in selectedCombos) { - if (selectedCombos.size <= 1) selectedCombos - else selectedCombos - combo - } else selectedCombos + combo - } - }, - enabled = !running, - ) - if (selectedCombos.size < HOSPITAL_COMBINATIONS.size) { - Hint( - "프로토콜 6조합 중 ${selectedCombos.size}개만 돕니다. 나머지를 나중에 재도 " + - "같은 자세·충만도면 합산되어 완료로 잡힙니다." - ) + // ── 측정할 조합 = 복부 두께 ───────────────────────────────────── + // 조작자에게 freq·cycle 조합을 고르게 하지 않는다. 눈으로 보고 답할 수 있는 + // 것은 복부 두께이고, 거기서 조합이 따라 나온다. + Label("측정할 조합 — 복부 두께로 정합니다") + ChipRow(AbdomenThickness.entries, thickness, { thickness = it }, !running) { + "복부 두께 ${it.label}" } + Hint("${thickness.comboLabel} → 총 ${thickness.combos.size * repeats}회") Spacer(Modifier.height(16.dp)) // 복원 결과는 측정이 끝난 뒤에만 뜬다. 실패했으면 다음 일반 측정이 임상 @@ -486,10 +480,9 @@ fun HospitalModeView(appState: AppState) { } if (running) { - val runCombos = HOSPITAL_COMBINATIONS.filter { it in selectedCombos } - val (f, c) = runCombos.getOrElse(comboIdx) { runCombos.firstOrNull() ?: HOSPITAL_COMBINATIONS[0] } + val (f, c) = thickness.combos.getOrElse(comboIdx) { thickness.combos.first() } ProgressPanel( - comboIdx = comboIdx, comboCount = runCombos.size, + comboIdx = comboIdx, comboCount = thickness.combos.size, repeatIdx = repeatIdx, repeats = repeats, freqLabel = f.label, cycles = c.cycles, ok = okCount, fail = failCount, lastFile = lastFile, @@ -501,20 +494,46 @@ fun HospitalModeView(appState: AppState) { shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFD32F2F)), ) { Text("중지", color = Color.White, fontWeight = FontWeight.Bold) } + } else { + Button( + onClick = { + comboIdx = 0; repeatIdx = 0 + // 지난 실행의 확정 상태가 남으면 새 부착 위치의 첫 판정이 + // 거기에 끌려간다 — 프로브를 다시 붙였을 수 있다. + detachWatcher.reset(); detachSnapshot = null + runBv = null + running = true + }, + enabled = canStart, + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = RoundedCornerShape(14.dp), + ) { Text("측정 시작", fontSize = 17.sp, fontWeight = FontWeight.Bold) } - // ── 채널 파형 ─────────────────────────────────────────────── - // 측정하는 사람이 값이 제대로 잡히는지 **그 자리에서** 보게 한다. - // 20회를 다 돌린 뒤에야 파일을 열어 보면, 잘못 붙은 것을 알았을 때는 - // 이미 그 단계를 다시 잴 수 없다(방광을 비웠거나 자세가 바뀌었다). - // - // 셀을 작게 잡는다(140dp). 이 화면의 주인공은 진행 상황과 중지 버튼이고 - // 파형은 곁눈질로 보는 것이라, 크게 잡으면 중지 버튼이 화면 밖으로 밀린다. + if (!isConnected) Hint("위 [기기 연결] 을 눌러 프로브를 연결해 주세요.") + else if (patient.isBlank()) Hint("환자명을 입력해 주세요.") + } + + // ── 프로토콜 실행의 BV ────────────────────────────────────────── + // 돌 때는 직전 회차, 끝난 뒤에는 마지막 회차. 끝나고도 남겨 둔다 — 방광을 + // 비우기 전에 "방금 받은 값이 말이 되나"를 한 번 더 볼 수 있어야 한다. + if (running || runBv != null) { Spacer(Modifier.height(12.dp)) Label("방광 용적") Spacer(Modifier.height(6.dp)) com.medithings.vesiscan.ui.components.BvPanel( - runBv, caption = "직전 회차", compact = true) + runBv, + caption = if (running) "직전 회차" else "마지막 회차", + compact = true, + ) + } + // 접촉·파형은 돌 때만 띄운다. 측정하는 사람이 값이 제대로 잡히는지 **그 + // 자리에서** 봐야 한다 — 20회를 다 돌린 뒤 파일을 열어 보면 잘못 붙은 것을 + // 알았을 때 이미 그 단계를 다시 잴 수 없다(방광을 비웠거나 자세가 바뀌었다). + // + // 셀을 작게 잡는다(140dp). 이 자리의 주인공은 진행 상황과 중지 버튼이라 + // 크게 잡으면 중지 버튼이 화면 밖으로 밀린다. + if (running) { Spacer(Modifier.height(12.dp)) Label("채널 접촉") Spacer(Modifier.height(6.dp)) @@ -535,33 +554,6 @@ fun HospitalModeView(appState: AppState) { } Spacer(Modifier.height(16.dp)) } else { - // ── BV 측정 (Spot / Continuous) ───────────────────────────── - // 프로토콜을 돌리기 **전에** 값이 말이 되는지 확인하는 자리다. - // 정렬이 고른 위치가 실제로 최적인지는 그 위치의 BV 로만 판단할 수 있다. - BvMeasureSection( - bleManager = bleManager, - isConnected = isConnected, - supine = posture == ClinicalPosture.SUPINE, - ) - Spacer(Modifier.height(16.dp)) - - Button( - onClick = { - comboIdx = 0; repeatIdx = 0 - // 지난 실행의 확정 상태가 남으면 새 부착 위치의 첫 판정이 - // 거기에 끌려간다 — 프로브를 다시 붙였을 수 있다. - detachWatcher.reset(); detachSnapshot = null - runBv = null - running = true - }, - enabled = canStart, - modifier = Modifier.fillMaxWidth().height(56.dp), - shape = RoundedCornerShape(14.dp), - ) { Text("측정 시작", fontSize = 17.sp, fontWeight = FontWeight.Bold) } - - if (!isConnected) Hint("위 [기기 연결] 을 눌러 프로브를 연결해 주세요.") - else if (patient.isBlank()) Hint("환자명을 입력해 주세요.") - // ── labdb 업로드 ──────────────────────────────────────────── // 조합이 끝날 때마다 자동으로 올라간다. 인터넷이 끊겨 있었으면 여기 // 남아 있다가 이 버튼으로 한꺼번에 올린다 — 병원 무선망이 불안정한 @@ -634,9 +626,28 @@ fun HospitalModeView(appState: AppState) { } } - Spacer(Modifier.height(20.dp)) + // ── 여기부터는 프로토콜과 **별개인 수동 도구** ────────────────── + // 위쪽은 "정해진 순서대로 받는" 일이고, 아래쪽은 "지금 궁금한 것을 직접 재 + // 보는" 일이다. 둘이 한 흐름으로 붙어 있으면 조작자가 수동 측정을 프로토콜의 + // 한 단계로 착각한다 — 그 값은 저장도 업로드도 되지 않는다. + Spacer(Modifier.height(24.dp)) + HorizontalDivider(thickness = 1.dp, color = MlSecondaryText.copy(alpha = 0.35f)) + Spacer(Modifier.height(6.dp)) + Text("수동 확인 — 저장되지 않습니다", fontSize = 11.sp, color = MlSecondaryText) + Spacer(Modifier.height(14.dp)) + ProbeParamCard(bleManager = bleManager, enabled = !running) + // ── BV 측정 (1회 / 연속) ─────────────────────────────────────── + // 프로토콜을 돌리기 전에 값이 말이 되는지 확인하는 자리다. 정렬이 고른 + // 위치가 실제로 최적인지는 그 위치의 BV 로만 판단할 수 있다. + Spacer(Modifier.height(16.dp)) + BvMeasureSection( + bleManager = bleManager, + isConnected = isConnected && !running, + supine = posture == ClinicalPosture.SUPINE, + ) + Spacer(Modifier.height(16.dp)) Text( "저장 위치: Downloads/VesiScan_Hospital/", @@ -644,10 +655,9 @@ fun HospitalModeView(appState: AppState) { ) // 주파수 코드값이 확인되기 전까지는 화면에도 남긴다 — 조작자가 데이터의 // 한계를 알고 있어야 한다. - // 남는 값은 **선택한 것 중 프로토콜 순서상 마지막** 조합이다. 6개를 다 돌면 - // 2.3MHz·c7 이지만 부분 선택이면 달라지므로, 박아 두지 않고 실제 값을 보여준다. - // 종전에는 "(2.3MHz · cycle 7)" 로 고정돼 있어 부분 선택 시 틀린 안내였다. - val lastCombo = HOSPITAL_COMBINATIONS.lastOrNull { it in selectedCombos } + // 남는 값은 **이번에 돈 것 중 마지막** 조합이다. 두께에 따라 달라지므로 + // 박아 두지 않고 실제 값을 보여준다. + val lastCombo = thickness.combos.lastOrNull() Text( "⚠ 측정 파라미터(mcs)는 프로브에 영구 저장됩니다 — " + "avg ${HospitalFixedParams.AVG} · delay ${HospitalFixedParams.DELAY_US}µs · " + @@ -661,60 +671,6 @@ fun HospitalModeView(appState: AppState) { } } -/** - * 6조합 다중 선택. 2열 × 3행으로 주파수별 묶어 보여준다. - * - * 칩 한 줄(`ChipRow`)과 달리 **다중 선택**이라 따로 둔다. 진행 칩과 생김새를 맞추되, - * 선택은 "이번에 돌 것"이고 진행 칩의 초록은 "이미 잰 것"이라 의미가 다르므로 색을 - * 겹치지 않게 파랑 계열만 쓴다. - */ -@Composable -private fun CombinationPicker( - selected: Set>, - onToggle: (Pair) -> Unit, - enabled: Boolean, -) { - Column( - Modifier.fillMaxWidth().padding(top = 6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - ProbeFrequency.entries.forEach { f -> - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - ProbeCycle.entries.forEach { c -> - val combo = f to c - val on = combo in selected - Box( - Modifier - .weight(1f) - .background( - if (on) MlPrimary else Color.White, - RoundedCornerShape(10.dp), - ) - .then( - if (!on) Modifier.border(1.dp, MlSecondaryText.copy(alpha = 0.35f), - RoundedCornerShape(10.dp)) - else Modifier - ) - .clickable(enabled = enabled) { onToggle(combo) } - .padding(vertical = 10.dp), - contentAlignment = Alignment.Center, - ) { - Text( - "${f.label} · c${c.cycles}", - fontSize = 13.sp, - fontWeight = if (on) FontWeight.Bold else FontWeight.Normal, - color = if (on) Color.White else MlSecondaryText, - ) - } - } - } - } - } -} - /** 복원 안내가 실패를 말하는가 — 색을 가르는 데만 쓴다. */ private fun guardRestoreFailed(note: String): Boolean = note.contains("못했습니다") || note.contains("실패") @@ -771,14 +727,20 @@ private fun ChipRow( val done = st == HospitalRunStore.RunProgress.DONE val partial = st == HospitalRunStore.RunProgress.PARTIAL + // 완료는 **회색**이다. 초록으로 두면 "좋은 상태"로 읽혀 조작자가 거기서 + // 멈추는데, 실제 의미는 "이미 받았으니 다음으로 가라"다. 아직 안 받은 칩이 + // 눈에 들어와야 한다 — 남은 일이 어디인지가 이 줄의 존재 이유다. + // + // 누를 수는 있게 둔다. 다시 재야 하는 경우(파형이 이상해 버릴 때)가 있고, + // 같은 자세·충만도면 합산되므로 다시 눌러도 기록이 깨지지 않는다. val bg = when { on -> MlPrimary - done -> MlSuccess.copy(alpha = 0.18f) + done -> MlSecondaryText.copy(alpha = 0.16f) else -> Color.White } val fg = when { on -> Color.White - done -> MlSuccess + done -> MlSecondaryText partial -> MlWarning else -> MlSecondaryText } diff --git a/app/src/test/java/com/medithings/vesiscan/services/HospitalProgressTest.kt b/app/src/test/java/com/medithings/vesiscan/services/HospitalProgressTest.kt index 6057559..44ac8ab 100644 --- a/app/src/test/java/com/medithings/vesiscan/services/HospitalProgressTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/services/HospitalProgressTest.kt @@ -1,17 +1,22 @@ package com.medithings.vesiscan.services +import com.medithings.vesiscan.models.AbdomenThickness +import com.medithings.vesiscan.models.HOSPITAL_COMBINATIONS import org.junit.Assert.assertEquals import org.junit.Test /** * 조건별 진행 판정 — "이 자세·충만도를 다 쟀는가". * - * 이 값이 화면 칩의 초록 체크를 정한다. 틀리면 간호사가 다 됐다고 믿고 방광을 비우는데 + * 이 값이 화면 칩의 완료 표시를 정한다. 틀리면 간호사가 다 됐다고 믿고 방광을 비우는데 * 데이터는 못 쓰고, 그 단계는 그날 다시 못 잰다. 실기기 없이도 고정해 둔다. * - * 2026-09-07 에 판정이 **실행 단위 → 조합 단위 합산**으로 바뀌었다. 화면에서 6조합 중 - * 일부만 골라 돌릴 수 있게 되면서, 실행 하나만 보면 어느 것도 6조합을 못 채워 영원히 - * PARTIAL 로 남기 때문이다. + * 2026-09-07 에 판정이 **실행 단위 → 조합 단위 합산**으로 바뀌었다. 한 실행만 보면 + * 어느 것도 요구 조합을 못 채워 영원히 PARTIAL 로 남기 때문이다. + * + * 2026-09-09 에 **개수 비교 → 집합 비교**로 바뀌었다. 돌 조합을 복부 두께가 정하게 + * 되면서 요구 조합이 1개나 2개로 달라지는데, 개수로 세면 "같은 조합을 두 번 채운 것"과 + * "다른 두 조합을 채운 것"이 구분되지 않는다([AbdomenThickness]). */ class HospitalProgressTest { @@ -22,57 +27,87 @@ class HospitalProgressTest { private fun manifest(posture: String, fill: Int, vararg combos: String) = """{"posture":"$posture","fill_pct":$fill,"combinations":[${combos.joinToString(",")}]}""" - /** 프로토콜 6조합 = 주파수 옵션 0·5 × cycle 3·5·7. */ - private fun allSix(saved: Int = 20) = listOf(0, 5).flatMap { f -> - listOf(3, 5, 7).map { c -> combo(f, c, saved = saved) } - }.toTypedArray() + /** + * 옛 프로토콜 6조합. 과거 데이터(72세션)가 이 모양이다. + * + * 손으로 적지 않고 [HOSPITAL_COMBINATIONS] 를 쓴다 — 그 상수가 과거 격자의 정의라, + * 둘이 갈리면 "과거 데이터는 계속 완료로 읽힌다"는 보장이 거짓이 된다. + */ + private fun allSix(saved: Int = 20) = HOSPITAL_COMBINATIONS + .map { combo(it.first.freqOption, it.second.cycles, saved = saved) } + .toTypedArray() - @Test fun `6조합을 한 번에 채우면 완료`() { - val r = HospitalRunStore.progressFrom(listOf(manifest("Supine", 40, *allSix()))) + private val thin = AbdomenThickness.UPTO_40.expectedKeys // {0/3} + private val thick = AbdomenThickness.OVER_40.expectedKeys // {0/3, 5/3} + + @Test fun `두께가 요구 조합을 정한다`() { + // 이 두 집합이 이 파일 전체의 전제다. 바뀌면 아래 기대값이 전부 의미를 잃는다. + assertEquals(setOf("0/3"), thin) + assertEquals(setOf("0/3", "5/3"), thick) + } + + @Test fun `얇으면 1_8MHz 하나로 완료다`() { + val r = HospitalRunStore.progressFrom( + listOf(manifest("Supine", 40, combo(0, 3))), thin) assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 40]) } + @Test fun `두꺼우면 같은 데이터가 아직 미완이다`() { + // 1.8MHz 만 받았다. 두꺼운 환자는 2.3MHz 도 받아야 나중에 고를 수 있다. + val r = HospitalRunStore.progressFrom( + listOf(manifest("Supine", 40, combo(0, 3))), thick) + assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Supine" to 40]) + } + + @Test fun `같은 조합을 두 번 채워도 완료가 아니다`() { + // **개수 비교였다면 통과했을 경우다.** 2.3MHz 를 두 실행에서 받았으니 2개지만 + // 1.8MHz 가 없다. 개수로 세면 두꺼운 환자의 단계가 완료로 잡혀 그 자리에서 + // 1.8MHz 를 영원히 놓친다. + val r = HospitalRunStore.progressFrom(listOf( + manifest("Supine", 60, combo(5, 3)), + manifest("Supine", 60, combo(5, 3)), + ), thick) + assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Supine" to 60]) + } + @Test fun `나눠 재도 다 채우면 완료다`() { - // 이번 변경의 핵심. 조합을 골라 세 번에 나눠 쟀다. val r = HospitalRunStore.progressFrom(listOf( - manifest("Supine", 40, combo(0, 3), combo(0, 5)), - manifest("Supine", 40, combo(0, 7), combo(5, 3)), - manifest("Supine", 40, combo(5, 5), combo(5, 7)), - )) + manifest("Supine", 40, combo(0, 3)), + manifest("Supine", 40, combo(5, 3)), + ), thick) assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 40]) } - @Test fun `한 조합이 비면 완료가 아니다`() { - val five = allSix().dropLast(1).toTypedArray() - val r = HospitalRunStore.progressFrom(listOf(manifest("Sitting", 60, *five))) - assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Sitting" to 60]) + @Test fun `옛 6조합 데이터는 두 두께 모두 만족한다`() { + // 기존 72세션이 이 모양이다. 요구 집합을 포함하므로 완료로 읽혀야 한다 — + // 과거 데이터가 갑자기 미완으로 뒤집히면 간호사가 다 다시 잰다. + val m = listOf(manifest("Supine", 40, *allSix())) + assertEquals(HospitalRunStore.RunProgress.DONE, + HospitalRunStore.progressFrom(m, thin)["Supine" to 40]) + assertEquals(HospitalRunStore.RunProgress.DONE, + HospitalRunStore.progressFrom(m, thick)["Supine" to 40]) } @Test fun `계획을 못 채운 조합은 안 센다`() { // 20회 중 3회에서 끊긴 것을 완료로 보면 안 된다 — 그게 제일 위험하다. - val partial = listOf(0, 5).flatMap { f -> - listOf(3, 5, 7).map { c -> combo(f, c, planned = 20, saved = if (c == 7) 3 else 20) } - }.toTypedArray() - val r = HospitalRunStore.progressFrom(listOf(manifest("Supine", 0, *partial))) + val r = HospitalRunStore.progressFrom( + listOf(manifest("Supine", 0, combo(0, 3, planned = 20, saved = 3))), thin) assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Supine" to 0]) } @Test fun `끊긴 뒤 다시 채우면 완료다`() { - // 재측정. 같은 조합이 두 실행에 나오고, 뒤쪽이 계획을 채웠다. val r = HospitalRunStore.progressFrom(listOf( - manifest("Supine", 20, *listOf(0, 5).flatMap { f -> - listOf(3, 5, 7).map { c -> combo(f, c, saved = if (f == 5 && c == 7) 2 else 20) } - }.toTypedArray()), - manifest("Supine", 20, combo(5, 7, saved = 20)), - )) + manifest("Supine", 20, combo(0, 3, saved = 2)), + manifest("Supine", 20, combo(0, 3, saved = 20)), + ), thin) assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 20]) } @Test fun `조건이 다르면 섞이지 않는다`() { val r = HospitalRunStore.progressFrom(listOf( - manifest("Supine", 40, *allSix()), - manifest("Sitting", 40, combo(0, 3)), - )) + manifest("Supine", 40, combo(0, 3)), + manifest("Sitting", 40, combo(5, 3)), + ), thin) assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 40]) assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Sitting" to 40]) assertEquals(null, r["Supine" to 60]) // 안 잰 조건은 아예 없다 @@ -80,18 +115,24 @@ class HospitalProgressTest { @Test fun `planned 가 없으면 채운 것으로 보지 않는다`() { // 옛 매니페스트나 깨진 파일. 모르는 것을 완료로 올리면 안 된다. - val noPlanned = listOf(0, 5).flatMap { f -> - listOf(3, 5, 7).map { c -> """{"freq_option":$f,"cycles":$c,"saved":20}""" } - }.toTypedArray() - val r = HospitalRunStore.progressFrom(listOf(manifest("Supine", 80, *noPlanned))) + val r = HospitalRunStore.progressFrom( + listOf(manifest("Supine", 80, """{"freq_option":0,"cycles":3,"saved":20}""")), thin) assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Supine" to 80]) } @Test fun `깨진 JSON 은 그 파일만 건너뛴다`() { val r = HospitalRunStore.progressFrom(listOf( "not json at all", - manifest("Supine", 100, *allSix()), - )) + manifest("Supine", 100, combo(0, 3)), + ), thin) assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 100]) } + + @Test fun `요구 조합을 모르면 완료라고 말하지 않는다`() { + // expected 가 빈 집합이면 판정 근거가 없다. containsAll(emptySet) 은 항상 true 라 + // 가드가 없으면 **아무것도 안 잰 단계까지 완료**가 된다. + val r = HospitalRunStore.progressFrom( + listOf(manifest("Supine", 40, *allSix())), emptySet()) + assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Supine" to 40]) + } } diff --git a/docs/LABDB_DATATYPES.md b/docs/LABDB_DATATYPES.md index aea5c04..795808d 100644 --- a/docs/LABDB_DATATYPES.md +++ b/docs/LABDB_DATATYPES.md @@ -69,6 +69,23 @@ | `avg`, `delay_us`, `samples` | number | 측정 파라미터(매니페스트에 있을 때만) | | `anchor_cm` | int / null | 정렬로 정한 부착 위치(치골 위 cm) | | `anchor_basis` | string / null | 그 위치를 **누가** 정했나 — 아래 | +| `abdomen_thickness` | string | `upto_40mm` / `over_40mm` — 돌 조합을 이것이 정합니다 | +| `abdomen_thickness_label` | string | 사람이 읽는 표기 (`40mm 이하` / `40mm 초과`) | + +#### `abdomen_thickness` — 조합 수가 세션마다 다릅니다 + +2026-09-09 부터 **6조합 순회를 그만두고 복부 두께로 조합을 고릅니다.** 한 단계에 20회 × +6조합 = 120회가 환자를 너무 오래 눕혀 두기 때문이고, 기존 72세션에서 cycle 을 5·7 로 +늘려 얻는 것이 그 시간을 정당화하지 못했습니다. + +| `abdomen_thickness` | 조합 | 세션 수 | +|---|---|---| +| `upto_40mm` | 1.8MHz · cycle 3 | 1 | +| `over_40mm` | 1.8MHz · cycle 3 + 2.3MHz · cycle 3 | 2 | + +그래서 **한 (자세, 충만도) 에 세션이 1개뿐인 것이 정상입니다** — 누락이 아닙니다. +"왜 조합이 적은가"는 이 필드로 답이 됩니다. cycle 은 이제 항상 3 이고, cycle 5·7 데이터는 +2026-09-09 이전 세션에만 있습니다. #### `anchor_basis` — 정확도 분석 전에 반드시 보셔야 하는 값