feat(clinical): 병원 임상 화면 재구성 · 조합은 복부 두께로 · 레퍼런스 고정

## 화면을 두 덩어리로 나눴다

    환자명 → 부착 위치 정렬 → 자세 → 방광 채움 → 반복 횟수 → 복부 두께 →
    [측정 시작] → 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 15:33:07 +09:00
parent f5afce6d17
commit d401740210
6 changed files with 288 additions and 222 deletions
@@ -2,12 +2,15 @@
* HospitalProtocol — 병원 임상 측정 프로토콜 정의. * HospitalProtocol — 병원 임상 측정 프로토콜 정의.
* *
* ## 무엇을 하는 모드인가 * ## 무엇을 하는 모드인가
* 간호사가 방광을 정해진 정도까지 채워 두면, 조작자는 **그 채움 정도 하나만** 고르고 * 간호사가 방광을 정해진 정도까지 채워 두면, 조작자는 **채움 정도와 복부 두께만** 고르고
* [측정 시작]을 누른다. 그 뒤로는 앱이 주파수 2종 × cycle 3종 = **6조합을 자동으로 * [측정 시작]을 누른다. 그 뒤로는 앱이 [AbdomenThickness] 가 정한 조합을 순회하며 각
* 순회**하며 각 조합마다 6채널을 n회 측정하고, 조합별로 파일 하나씩 저장한다. * 조합마다 6채널을 n회 측정하고, 조합별로 파일 하나씩 저장한다.
* *
* 사람이 개입하는 지점을 방광 채움 하나로 줄인 것이 핵심이다 — 조합을 손으로 바꾸면 * 사람이 개입하는 지점을 그 둘로 줄인 것이 핵심이다 — 조합을 손으로 바꾸면 반드시
* 반드시 빠뜨리거나 잘못 기록한다. 6조합 × n회가 한 번의 버튼으로 끝난다. * 빠뜨리거나 잘못 기록한다.
*
* 2026-09-09 까지는 주파수 2종 × cycle 3종 = 6조합을 전부 돌았다([HOSPITAL_COMBINATIONS]).
* 기존 72세션이 그 격자다.
* *
* ## 측정 파라미터 (2026-09-02 펌웨어팀 확인) * ## 측정 파라미터 (2026-09-02 펌웨어팀 확인)
* `mcs?` 는 **다섯** 개를 받는다 — freq · cycles · avg · delay_us · samples. * `mcs?` 는 **다섯** 개를 받는다 — freq · cycles · avg · delay_us · samples.
@@ -82,6 +85,49 @@ enum class BladderFill(val percent: Int) {
val HOSPITAL_COMBINATIONS: List<Pair<ProbeFrequency, ProbeCycle>> = val HOSPITAL_COMBINATIONS: List<Pair<ProbeFrequency, ProbeCycle>> =
ProbeFrequency.entries.flatMap { f -> ProbeCycle.entries.map { c -> f to c } } 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)는 얕지만 분해능이 좋다. 복부가 얇으면 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<Pair<ProbeFrequency, ProbeCycle>>,
) {
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<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 const val HOSPITAL_DEFAULT_REPEATS = 20
@@ -232,12 +232,17 @@ object HospitalRunStore {
* *
* @return (자세 label, 방광 %) → 진행 상태 * @return (자세 label, 방광 %) → 진행 상태
*/ */
fun scanProgress(patient: String, day: Date): Map<Pair<String, Int>, RunProgress> { fun scanProgress(
patient: String,
day: Date,
/** 이번 프로토콜이 요구하는 조합 키들([AbdomenThickness.expectedKeys]). */
expected: Set<String>,
): Map<Pair<String, Int>, RunProgress> {
val dir = runDirIfExists(patient, day) ?: return emptyMap() val dir = runDirIfExists(patient, day) ?: return emptyMap()
val texts = dir.listFiles { f -> val texts = dir.listFiles { f ->
f.isFile && f.name.startsWith("run_") && f.name.endsWith(".json") f.isFile && f.name.startsWith("run_") && f.name.endsWith(".json")
}?.mapNotNull { runCatching { it.readText(Charsets.UTF_8) }.getOrNull() } ?: emptyList() }?.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<String>): Map<Pair<String, Int>, RunProgress> { internal fun progressFrom(
manifests: List<String>,
expected: Set<String>,
): Map<Pair<String, Int>, RunProgress> {
// (자세, 충만도) → 계획을 채운 조합들. 조합 식별자는 "주파수옵션/cycle" 이다. // (자세, 충만도) → 계획을 채운 조합들. 조합 식별자는 "주파수옵션/cycle" 이다.
val filled = HashMap<Pair<String, Int>, MutableSet<String>>() val filled = HashMap<Pair<String, Int>, MutableSet<String>>()
// 매니페스트는 있는데 채운 조합이 하나도 없는 경우도 PARTIAL 로 남겨야 한다. // 매니페스트는 있는데 채운 조합이 하나도 없는 경우도 PARTIAL 로 남겨야 한다.
@@ -270,8 +278,16 @@ object HospitalRunStore {
} }
val out = HashMap<Pair<String, Int>, RunProgress>() val out = HashMap<Pair<String, Int>, RunProgress>()
for (key in seen) { for (key in seen) {
val n = filled[key]?.size ?: 0 val got = filled[key] ?: emptySet<String>()
out[key] = if (n >= EXPECTED_COMBINATIONS) RunProgress.DONE else RunProgress.PARTIAL // **개수가 아니라 집합으로 본다.** 종전에는 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 return out
} }
@@ -280,6 +296,4 @@ object HospitalRunStore {
private fun runDirIfExists(patient: String, day: Date): File? = private fun runDirIfExists(patient: String, day: Date): File? =
File(File(downloads(), ROOT), "${dayFmt.format(day)}_${safe(patient)}").takeIf { it.isDirectory } File(File(downloads(), ROOT), "${dayFmt.format(day)}_${safe(patient)}").takeIf { it.isDirectory }
/** 주파수 2종 × cycle 3종. [HOSPITAL_COMBINATIONS] 와 같은 수여야 한다. */
private const val EXPECTED_COMBINATIONS = 6
} }
@@ -52,6 +52,13 @@ import kotlinx.coroutines.withTimeoutOrNull
* 정렬 단계의 20-cycle mean-scan 과도 또 다른 세 번째 방식이 된다. 연속 측정이 * 정렬 단계의 20-cycle mean-scan 과도 또 다른 세 번째 방식이 된다. 연속 측정이
* 메인과 비교 가능해야 하므로 부피끼리 평균한다. * 메인과 비교 가능해야 하므로 부피끼리 평균한다.
* *
* ## 알고리즘은 레퍼런스 고정이다
* 종전에는 기존 경로로 되돌리는 스위치가 있었다. 뺐다 — 병원 임상의 값은 **최종
* 보고본 하나**여야 한다. 스위치가 있으면 화면을 본 사람과 기록을 읽는 사람이 서로
* 다른 경로의 숫자를 보고 같은 값이라고 믿을 수 있고, 그 착오는 기록에 남은 알고리즘
* 라벨을 일일이 확인해야만 드러난다. 두 경로를 비교할 일은 테스트가 맡는다
* (`ClinicalBvTest` · `AlgoPathReportTest`).
*
* ## 절사평균인 이유 * ## 절사평균인 이유
* 최대·최소 하나씩 버리고 평균낸다. cycle 단위 BV 는 벽 오검출로 가끔 크게 튀는데, * 최대·최소 하나씩 버리고 평균낸다. cycle 단위 BV 는 벽 오검출로 가끔 크게 튀는데,
* 산술평균은 그 한 번에 통째로 끌려간다. * 산술평균은 그 한 번에 통째로 끌려간다.
@@ -92,10 +99,6 @@ fun BvMeasureSection(
var window by remember { mutableStateOf<List<Double>>(emptyList()) } var window by remember { mutableStateOf<List<Double>>(emptyList()) }
var tick by remember { mutableIntStateOf(0) } var tick by remember { mutableIntStateOf(0) }
// 병원 임상은 **최종 보고본이 기본**이다. 전역 AlgoMode 를 켜지 않는다 —
// ClinicalBv 가 계산 동안만 그 경로로 돌리고 되돌리므로, 일반 측정 화면의 BV 는
// 영향을 받지 않는다. 끄는 것은 두 경로를 그 자리에서 비교하기 위한 용도다.
var useReference by remember { mutableStateOf(true) }
LaunchedEffect(mode) { LaunchedEffect(mode) {
val m = mode ?: return@LaunchedEffect val m = mode ?: return@LaunchedEffect
@@ -143,9 +146,12 @@ fun BvMeasureSection(
val sorted = got.sortedBy { it.channel } val sorted = got.sortedBy { it.channel }
live = sorted live = sorted
// **1 cycle = BV 1개.** 메인 화면 measure() 와 같은 단위다. // **1 cycle = BV 1개.** 메인 화면 measure() 와 같은 단위다.
// **항상 레퍼런스(최종 보고본) 경로다.** 전역 AlgoMode 는 건드리지
// 않는다 — ClinicalBv 가 계산 동안만 그 경로로 돌리고 되돌리므로
// 일반 측정 화면의 BV 는 영향을 받지 않는다.
val r = ClinicalBv.compute( val r = ClinicalBv.compute(
ClinicalBv.toSignals(sorted.map { it.buffer }), ClinicalBv.toSignals(sorted.map { it.buffer }),
supine = supine, reference = useReference, supine = supine, reference = true,
) )
outcome = r outcome = r
tick++ tick++
@@ -181,26 +187,6 @@ fun BvMeasureSection(
fontSize = 11.sp, color = MlSecondaryText, 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)) Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) {
@@ -1,22 +1,23 @@
/* /*
* HospitalModeView — 병원 임상 측정 모드. * 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?) * 조작자가 freq·cycle 조합을 손으로 고르면 반드시 빠뜨리거나 잘못 기록한다 — 눈으로
* sendMtb() → piezoCollector.onComplete ← n회 반복 * 보고 답할 수 있는 것은 복부 두께이고, 조합은 거기서 따라 나온다([AbdomenThickness]).
* *
* `sendMtb` 는 6채널+IMU 를 한 번에 받는 명령이고, 완료는 콜백으로 온다. 그래서 루프는 * ## 화면은 두 덩어리다
* "보내고 기다린다"가 아니라 **콜백이 올 때마다 다음 것을 보내는** 구조다. 응답이 * 구분선 위는 **프로토콜**(환자명 → 정렬 → 자세 → 충만도 → 반복 → 두께 → 측정 시작),
* 안 오면 [MEASURE_TIMEOUT_MS] 뒤에 그 회차를 실패로 세고 다음으로 넘어간다 — * 아래는 **수동 확인**(프로브 파라미터 주입 · 1회/연속 BV 측정)이다. 아래쪽 값은
* 한 번 막혔다고 전체가 멈추면 채워 둔 방광을 버리게 된다. * 저장도 업로드도 되지 않는다 — 한 흐름으로 붙여 두면 조작자가 수동 측정을 프로토콜의
* 한 단계로 착각한다.
*/ */
package com.medithings.vesiscan.ui.views.clinical 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.AppScreen
import com.medithings.vesiscan.AppState import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.ble.BleManager import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.models.AbdomenThickness
import com.medithings.vesiscan.models.* import com.medithings.vesiscan.models.*
import com.medithings.vesiscan.services.AdcCsvLogger import com.medithings.vesiscan.services.AdcCsvLogger
import com.medithings.vesiscan.services.HospitalRunStore import com.medithings.vesiscan.services.HospitalRunStore
@@ -86,13 +88,13 @@ fun HospitalModeView(appState: AppState) {
var fill by remember { mutableStateOf(BladderFill.P0) } var fill by remember { mutableStateOf(BladderFill.P0) }
var repeatsText by remember { mutableStateOf(HOSPITAL_DEFAULT_REPEATS.toString()) } 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 running by remember { mutableStateOf(false) }
var comboIdx by remember { mutableIntStateOf(0) } var comboIdx by remember { mutableIntStateOf(0) }
@@ -146,11 +148,13 @@ fun HospitalModeView(appState: AppState) {
// 오늘·이 환자의 폴더에 남은 실행 매니페스트를 읽어 "어디까지 했는지"를 칩에 입힌다. // 오늘·이 환자의 폴더에 남은 실행 매니페스트를 읽어 "어디까지 했는지"를 칩에 입힌다.
// `running` 이 바뀔 때마다(= 실행이 끝날 때마다) 다시 읽어 방금 잰 단계가 즉시 채워진다. // `running` 이 바뀔 때마다(= 실행이 끝날 때마다) 다시 읽어 방금 잰 단계가 즉시 채워진다.
var progress by remember { mutableStateOf(emptyMap<Pair<String, Int>, HospitalRunStore.RunProgress>()) } var progress by remember { mutableStateOf(emptyMap<Pair<String, Int>, HospitalRunStore.RunProgress>()) }
LaunchedEffect(patient, running) { // thickness 도 키다 — 두께를 바꾸면 "완료"의 기준 자체가 달라진다.
LaunchedEffect(patient, running, thickness) {
progress = if (patient.isBlank()) emptyMap() progress = if (patient.isBlank()) emptyMap()
// 디스크 접근이라 IO 로 뺀다. 작은 JSON 몇 개지만 UI 스레드에서 파일을 읽지 않는다. // 디스크 접근이라 IO 로 뺀다. 작은 JSON 몇 개지만 UI 스레드에서 파일을 읽지 않는다.
else withContext(Dispatchers.IO) { else withContext(Dispatchers.IO) {
runCatching { HospitalRunStore.scanProgress(patient, Date()) }.getOrDefault(emptyMap()) runCatching { HospitalRunStore.scanProgress(patient, Date(), thickness.expectedKeys) }
.getOrDefault(emptyMap())
} }
} }
// 자세 칩은 그 자세의 6단계가 전부 끝났을 때만 완료로 본다. // 자세 칩은 그 자세의 6단계가 전부 끝났을 때만 완료로 본다.
@@ -189,9 +193,9 @@ fun HospitalModeView(appState: AppState) {
bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) } bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) }
try { try {
// 선택 순서가 아니라 **프로토콜 순서**를 지킨다. 매번 같은 순서로 돌아야 // 복부 두께가 정한 조합. 순서는 [AbdomenThickness] 가 박아 두었다 — 매번
// 나중에 파일을 비교할 때 조건이 섞이지 않는다. // 같은 순서로 돌아야 나중에 파일을 비교할 때 조건이 섞이지 않는다.
val combos = HOSPITAL_COMBINATIONS.filter { it in selectedCombos } val combos = thickness.combos
combos.forEachIndexed { ci, (freq, cycle) -> combos.forEachIndexed { ci, (freq, cycle) ->
if (!running) return@forEachIndexed if (!running) return@forEachIndexed
comboIdx = ci comboIdx = ci
@@ -305,6 +309,11 @@ fun HospitalModeView(appState: AppState) {
put("posture", posture.label) put("posture", posture.label)
put("fill_pct", fill.percent) put("fill_pct", fill.percent)
put("repeats", repeats) put("repeats", repeats)
// 돌 조합을 이것이 정했다. 조합만 기록하면 "왜 1개만 돌았나"를 나중에
// 답할 수 없다 — 시간이 없어 끊은 것과 얇아서 하나면 됐던 것이 구분되지
// 않는다.
put("abdomen_thickness", thickness.wire)
put("abdomen_thickness_label", thickness.label)
put("device", deviceName ?: "") put("device", deviceName ?: "")
put("firmware_version", fw) put("firmware_version", fw)
// ⚠ 주파수 코드값이 확인 전이라는 사실을 데이터와 함께 남긴다. // ⚠ 주파수 코드값이 확인 전이라는 사실을 데이터와 함께 남긴다.
@@ -423,7 +432,7 @@ fun HospitalModeView(appState: AppState) {
) { it.label } ) { it.label }
Hint( Hint(
if (patient.isBlank()) "환자명을 넣으면 오늘 이 환자의 진행 상황이 표시된다." if (patient.isBlank()) "환자명을 넣으면 오늘 이 환자의 진행 상황이 표시된다."
else "✓ 완료 · · 일부(다시 눌러 마저 측정)" else "회색 ✓ 완료 · 주황 · 일부(다시 눌러 마저 측정)"
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
@@ -434,36 +443,21 @@ fun HospitalModeView(appState: AppState) {
singleLine = true, singleLine = true,
enabled = !running, enabled = !running,
supportingText = { supportingText = {
Text("${selectedCombos.size}조합 × ${repeats}회 = 총 ${selectedCombos.size * repeats}회 측정") Text("${thickness.combos.size}조합 × ${repeats}회 = " +
"총 ${thickness.combos.size * repeats}회 측정")
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
// ── 돌릴 조합 고르기 ──────────────────────────────────────────── // ── 측정할 조합 = 복부 두께 ─────────────────────────────────────
// 기본은 6개 전부다. 프로토콜이 요구하는 것이 전부이고, 여기서 빼는 것은 // 조작자에게 freq·cycle 조합을 고르게 하지 않는다. 눈으로 보고 답할 수 있는
// "한 조합만 다시 잰다" 같은 예외를 위한 것이다. 그래서 해제는 되지만 // 것은 복부 두께이고, 거기서 조합이 따라 나온다.
// 마지막 하나는 못 끄게 한다 — 0개로 시작을 누르면 아무 일도 안 일어난다. Label("측정할 조합 — 복부 두께로 정합니다")
Label("측정할 조합") ChipRow(AbdomenThickness.entries, thickness, { thickness = it }, !running) {
CombinationPicker( "복부 두께 ${it.label}"
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}개만 돕니다. 나머지를 나중에 재도 " +
"같은 자세·충만도면 합산되어 완료로 잡힙니다."
)
} }
Hint("${thickness.comboLabel} → 총 ${thickness.combos.size * repeats}회")
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// 복원 결과는 측정이 끝난 뒤에만 뜬다. 실패했으면 다음 일반 측정이 임상 // 복원 결과는 측정이 끝난 뒤에만 뜬다. 실패했으면 다음 일반 측정이 임상
@@ -486,10 +480,9 @@ fun HospitalModeView(appState: AppState) {
} }
if (running) { if (running) {
val runCombos = HOSPITAL_COMBINATIONS.filter { it in selectedCombos } val (f, c) = thickness.combos.getOrElse(comboIdx) { thickness.combos.first() }
val (f, c) = runCombos.getOrElse(comboIdx) { runCombos.firstOrNull() ?: HOSPITAL_COMBINATIONS[0] }
ProgressPanel( ProgressPanel(
comboIdx = comboIdx, comboCount = runCombos.size, comboIdx = comboIdx, comboCount = thickness.combos.size,
repeatIdx = repeatIdx, repeats = repeats, repeatIdx = repeatIdx, repeats = repeats,
freqLabel = f.label, cycles = c.cycles, freqLabel = f.label, cycles = c.cycles,
ok = okCount, fail = failCount, lastFile = lastFile, ok = okCount, fail = failCount, lastFile = lastFile,
@@ -501,20 +494,46 @@ fun HospitalModeView(appState: AppState) {
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFD32F2F)), colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFD32F2F)),
) { Text("중지", color = Color.White, fontWeight = FontWeight.Bold) } ) { 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) }
// ── 채널 파형 ─────────────────────────────────────────────── if (!isConnected) Hint("위 [기기 연결] 을 눌러 프로브를 연결해 주세요.")
// 측정하는 사람이 값이 제대로 잡히는지 **그 자리에서** 보게 한다. else if (patient.isBlank()) Hint("환자명을 입력해 주세요.")
// 20회를 다 돌린 뒤에야 파일을 열어 보면, 잘못 붙은 것을 알았을 때는 }
// 이미 그 단계를 다시 잴 수 없다(방광을 비웠거나 자세가 바뀌었다).
// // ── 프로토콜 실행의 BV ──────────────────────────────────────────
// 셀을 작게 잡는다(140dp). 이 화면의 주인공은 진행 상황과 중지 버튼이고 // 돌 때는 직전 회차, 끝난 뒤에는 마지막 회차. 끝나고도 남겨 둔다 — 방광을
// 파형은 곁눈질로 보는 것이라, 크게 잡으면 중지 버튼이 화면 밖으로 밀린다. // 비우기 전에 "방금 받은 값이 말이 되나"를 한 번 더 볼 수 있어야 한다.
if (running || runBv != null) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Label("방광 용적") Label("방광 용적")
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
com.medithings.vesiscan.ui.components.BvPanel( 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)) Spacer(Modifier.height(12.dp))
Label("채널 접촉") Label("채널 접촉")
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
@@ -535,33 +554,6 @@ fun HospitalModeView(appState: AppState) {
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
} else { } 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 업로드 ──────────────────────────────────────────── // ── 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) 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)) Spacer(Modifier.height(16.dp))
Text( Text(
"저장 위치: Downloads/VesiScan_Hospital/", "저장 위치: Downloads/VesiScan_Hospital/",
@@ -644,10 +655,9 @@ fun HospitalModeView(appState: AppState) {
) )
// 주파수 코드값이 확인되기 전까지는 화면에도 남긴다 — 조작자가 데이터의 // 주파수 코드값이 확인되기 전까지는 화면에도 남긴다 — 조작자가 데이터의
// 한계를 알고 있어야 한다. // 한계를 알고 있어야 한다.
// 남는 값은 **선택한 것 중 프로토콜 순서상 마지막** 조합이다. 6개를 다 돌면 // 남는 값은 **이번에 돈 것 중 마지막** 조합이다. 두께에 따라 달라지므로
// 2.3MHz·c7 이지만 부분 선택이면 달라지므로, 박아 두지 않고 실제 값을 보여준다. // 박아 두지 않고 실제 값을 보여준다.
// 종전에는 "(2.3MHz · cycle 7)" 로 고정돼 있어 부분 선택 시 틀린 안내였다. val lastCombo = thickness.combos.lastOrNull()
val lastCombo = HOSPITAL_COMBINATIONS.lastOrNull { it in selectedCombos }
Text( Text(
"⚠ 측정 파라미터(mcs)는 프로브에 영구 저장됩니다 — " + "⚠ 측정 파라미터(mcs)는 프로브에 영구 저장됩니다 — " +
"avg ${HospitalFixedParams.AVG} · delay ${HospitalFixedParams.DELAY_US}µs · " + "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<Pair<ProbeFrequency, ProbeCycle>>,
onToggle: (Pair<ProbeFrequency, ProbeCycle>) -> 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 = private fun guardRestoreFailed(note: String): Boolean =
note.contains("못했습니다") || note.contains("실패") note.contains("못했습니다") || note.contains("실패")
@@ -771,14 +727,20 @@ private fun <T> ChipRow(
val done = st == HospitalRunStore.RunProgress.DONE val done = st == HospitalRunStore.RunProgress.DONE
val partial = st == HospitalRunStore.RunProgress.PARTIAL val partial = st == HospitalRunStore.RunProgress.PARTIAL
// 완료는 **회색**이다. 초록으로 두면 "좋은 상태"로 읽혀 조작자가 거기서
// 멈추는데, 실제 의미는 "이미 받았으니 다음으로 가라"다. 아직 안 받은 칩이
// 눈에 들어와야 한다 — 남은 일이 어디인지가 이 줄의 존재 이유다.
//
// 누를 수는 있게 둔다. 다시 재야 하는 경우(파형이 이상해 버릴 때)가 있고,
// 같은 자세·충만도면 합산되므로 다시 눌러도 기록이 깨지지 않는다.
val bg = when { val bg = when {
on -> MlPrimary on -> MlPrimary
done -> MlSuccess.copy(alpha = 0.18f) done -> MlSecondaryText.copy(alpha = 0.16f)
else -> Color.White else -> Color.White
} }
val fg = when { val fg = when {
on -> Color.White on -> Color.White
done -> MlSuccess done -> MlSecondaryText
partial -> MlWarning partial -> MlWarning
else -> MlSecondaryText else -> MlSecondaryText
} }
@@ -1,17 +1,22 @@
package com.medithings.vesiscan.services 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.Assert.assertEquals
import org.junit.Test import org.junit.Test
/** /**
* 조건별 진행 판정 — "이 자세·충만도를 다 쟀는가". * 조건별 진행 판정 — "이 자세·충만도를 다 쟀는가".
* *
* 이 값이 화면 칩의 초록 체크를 정한다. 틀리면 간호사가 다 됐다고 믿고 방광을 비우는데 * 이 값이 화면 칩의 완료 표시를 정한다. 틀리면 간호사가 다 됐다고 믿고 방광을 비우는데
* 데이터는 못 쓰고, 그 단계는 그날 다시 못 잰다. 실기기 없이도 고정해 둔다. * 데이터는 못 쓰고, 그 단계는 그날 다시 못 잰다. 실기기 없이도 고정해 둔다.
* *
* 2026-09-07 에 판정이 **실행 단위 → 조합 단위 합산**으로 바뀌었다. 화면에서 6조합 중 * 2026-09-07 에 판정이 **실행 단위 → 조합 단위 합산**으로 바뀌었다. 한 실행만 보면
* 일부만 골라 돌릴 수 있게 되면서, 실행 하나만 보면 어느 것도 6조합을 못 채워 영원히 * 어느 것도 요구 조합을 못 채워 영원히 PARTIAL 로 남기 때문이다.
* PARTIAL 로 남기 때문이다. *
* 2026-09-09 에 **개수 비교 → 집합 비교**로 바뀌었다. 돌 조합을 복부 두께가 정하게
* 되면서 요구 조합이 1개나 2개로 달라지는데, 개수로 세면 "같은 조합을 두 번 채운 것"과
* "다른 두 조합을 채운 것"이 구분되지 않는다([AbdomenThickness]).
*/ */
class HospitalProgressTest { class HospitalProgressTest {
@@ -22,57 +27,87 @@ class HospitalProgressTest {
private fun manifest(posture: String, fill: Int, vararg combos: String) = private fun manifest(posture: String, fill: Int, vararg combos: String) =
"""{"posture":"$posture","fill_pct":$fill,"combinations":[${combos.joinToString(",")}]}""" """{"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 -> * 옛 프로토콜 6조합. 과거 데이터(72세션)가 이 모양이다.
listOf(3, 5, 7).map { c -> combo(f, c, saved = saved) } *
}.toTypedArray() * 손으로 적지 않고 [HOSPITAL_COMBINATIONS] 를 쓴다 — 그 상수가 과거 격자의 정의라,
* 둘이 갈리면 "과거 데이터는 계속 완료로 읽힌다"는 보장이 거짓이 된다.
*/
private fun allSix(saved: Int = 20) = HOSPITAL_COMBINATIONS
.map { combo(it.first.freqOption, it.second.cycles, saved = saved) }
.toTypedArray()
@Test fun `6조합을 한 번에 채우면 완료`() { private val thin = AbdomenThickness.UPTO_40.expectedKeys // {0/3}
val r = HospitalRunStore.progressFrom(listOf(manifest("Supine", 40, *allSix()))) 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]) 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 `나눠 재도 다 채우면 완료다`() { @Test fun `나눠 재도 다 채우면 완료다`() {
// 이번 변경의 핵심. 조합을 골라 세 번에 나눠 쟀다.
val r = HospitalRunStore.progressFrom(listOf( val r = HospitalRunStore.progressFrom(listOf(
manifest("Supine", 40, combo(0, 3), combo(0, 5)), manifest("Supine", 40, combo(0, 3)),
manifest("Supine", 40, combo(0, 7), combo(5, 3)), manifest("Supine", 40, combo(5, 3)),
manifest("Supine", 40, combo(5, 5), combo(5, 7)), ), thick)
))
assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 40]) assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 40])
} }
@Test fun `한 조합이 비면 완료가 아니다`() { @Test fun `옛 6조합 데이터는 두 두께 모두 만족한다`() {
val five = allSix().dropLast(1).toTypedArray() // 기존 72세션이 이 모양이다. 요구 집합을 포함하므로 완료로 읽혀야 한다 —
val r = HospitalRunStore.progressFrom(listOf(manifest("Sitting", 60, *five))) // 과거 데이터가 갑자기 미완으로 뒤집히면 간호사가 다 다시 잰다.
assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Sitting" to 60]) 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 `계획을 못 채운 조합은 안 센다`() { @Test fun `계획을 못 채운 조합은 안 센다`() {
// 20회 중 3회에서 끊긴 것을 완료로 보면 안 된다 — 그게 제일 위험하다. // 20회 중 3회에서 끊긴 것을 완료로 보면 안 된다 — 그게 제일 위험하다.
val partial = listOf(0, 5).flatMap { f -> val r = HospitalRunStore.progressFrom(
listOf(3, 5, 7).map { c -> combo(f, c, planned = 20, saved = if (c == 7) 3 else 20) } listOf(manifest("Supine", 0, combo(0, 3, planned = 20, saved = 3))), thin)
}.toTypedArray()
val r = HospitalRunStore.progressFrom(listOf(manifest("Supine", 0, *partial)))
assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Supine" to 0]) assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Supine" to 0])
} }
@Test fun `끊긴 뒤 다시 채우면 완료다`() { @Test fun `끊긴 뒤 다시 채우면 완료다`() {
// 재측정. 같은 조합이 두 실행에 나오고, 뒤쪽이 계획을 채웠다.
val r = HospitalRunStore.progressFrom(listOf( val r = HospitalRunStore.progressFrom(listOf(
manifest("Supine", 20, *listOf(0, 5).flatMap { f -> manifest("Supine", 20, combo(0, 3, saved = 2)),
listOf(3, 5, 7).map { c -> combo(f, c, saved = if (f == 5 && c == 7) 2 else 20) } manifest("Supine", 20, combo(0, 3, saved = 20)),
}.toTypedArray()), ), thin)
manifest("Supine", 20, combo(5, 7, saved = 20)),
))
assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 20]) assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 20])
} }
@Test fun `조건이 다르면 섞이지 않는다`() { @Test fun `조건이 다르면 섞이지 않는다`() {
val r = HospitalRunStore.progressFrom(listOf( val r = HospitalRunStore.progressFrom(listOf(
manifest("Supine", 40, *allSix()), manifest("Supine", 40, combo(0, 3)),
manifest("Sitting", 40, combo(0, 3)), manifest("Sitting", 40, combo(5, 3)),
)) ), thin)
assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 40]) assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 40])
assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Sitting" to 40]) assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Sitting" to 40])
assertEquals(null, r["Supine" to 60]) // 안 잰 조건은 아예 없다 assertEquals(null, r["Supine" to 60]) // 안 잰 조건은 아예 없다
@@ -80,18 +115,24 @@ class HospitalProgressTest {
@Test fun `planned 가 없으면 채운 것으로 보지 않는다`() { @Test fun `planned 가 없으면 채운 것으로 보지 않는다`() {
// 옛 매니페스트나 깨진 파일. 모르는 것을 완료로 올리면 안 된다. // 옛 매니페스트나 깨진 파일. 모르는 것을 완료로 올리면 안 된다.
val noPlanned = listOf(0, 5).flatMap { f -> val r = HospitalRunStore.progressFrom(
listOf(3, 5, 7).map { c -> """{"freq_option":$f,"cycles":$c,"saved":20}""" } listOf(manifest("Supine", 80, """{"freq_option":0,"cycles":3,"saved":20}""")), thin)
}.toTypedArray()
val r = HospitalRunStore.progressFrom(listOf(manifest("Supine", 80, *noPlanned)))
assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Supine" to 80]) assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Supine" to 80])
} }
@Test fun `깨진 JSON 은 그 파일만 건너뛴다`() { @Test fun `깨진 JSON 은 그 파일만 건너뛴다`() {
val r = HospitalRunStore.progressFrom(listOf( val r = HospitalRunStore.progressFrom(listOf(
"not json at all", "not json at all",
manifest("Supine", 100, *allSix()), manifest("Supine", 100, combo(0, 3)),
)) ), thin)
assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 100]) 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])
}
} }
+17
View File
@@ -69,6 +69,23 @@
| `avg`, `delay_us`, `samples` | number | 측정 파라미터(매니페스트에 있을 때만) | | `avg`, `delay_us`, `samples` | number | 측정 파라미터(매니페스트에 있을 때만) |
| `anchor_cm` | int / null | 정렬로 정한 부착 위치(치골 위 cm) | | `anchor_cm` | int / null | 정렬로 정한 부착 위치(치골 위 cm) |
| `anchor_basis` | string / null | 그 위치를 **누가** 정했나 — 아래 | | `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` — 정확도 분석 전에 반드시 보셔야 하는 값 #### `anchor_basis` — 정확도 분석 전에 반드시 보셔야 하는 값