fix(clinical): 40mm 이하는 2.3MHz · 동작 버튼을 파형 위로 · 부착 위치 문구

## ① 복부 두께 40mm 이하 → 2.3MHz c3 (freq 5)

    40mm 이하   freq 5 + c3                  ← 1.8MHz 에서 바뀜
    40mm 초과   freq 0 + c3, freq 5 + c3      (변경 없음)

얇으면 2.3MHz 로도 뒤벽까지 닿고 분해능이 더 좋다. 두꺼우면 2.3MHz 가 못 닿을 수 있는데
어느 쪽이 맞는지 받아 보고야 알므로 둘 다 받는다.

덤이 하나 있다. 정렬이 2.3MHz·c3 고정이라 **40mm 이하 환자는 정렬 BV 와 프로토콜 BV 가
같은 조건**이 된다(표본 수만 다르다 — 정렬 20 cycle 평균, 프로토콜 1 cycle). 40mm 초과는
1.8MHz 회차가 섞이므로 2.3MHz 회차만 비교해야 한다.

진행 판정 테스트의 기대값을 새 조합으로 옮겼다. 하나 추가했다 — 40mm 이하 기준에서
1.8MHz 만 받으면 완료가 아니라는 것. 두께를 잘못 고르고 측정하면 칩이 완료로 안 바뀌어
그 자리에서 드러난다.

## ② 동작 버튼을 파형보다 위로

종전 순서: 지시 카드 → BV → 접촉 → 업로드 → 파형 → **[측정]** → [진행]

한 위치를 잴 때마다 스크롤을 내려야 했다. 프로브를 한 손에 잡은 채 쓰는 화면이라 그 한
번이 매번 든다. 지시 바로 아래 누를 것을 둔다:

    지시 카드 → 근거 한 줄 → 진행 표시 → [측정]/[좌우] → [진행] → BV → 접촉 → 파형

아래쪽은 **근거**다 — "왜 이 값인가"를 볼 때만 내려다본다. 연결 끊김 경고도 동작 버튼과
같이 올렸다(그 경고가 바로 [처음부터 다시 정렬] 을 가리킨다).

## ③ "다시 정렬"이 지시문으로 읽혔다

정렬을 마치고 넘어오면 부착 위치 카드에 [다시 정렬] 이 떴다. 상태는 정상이다 —
anchorCm 은 연결 해제·환자명 변경 때만 비워지고 진행 버튼 경로에서는 그대로 넘어온다.
문구가 "다시 해야 한다"로 읽힌 것이다.

    ✓ 부착 위치 3cm 확정        [위치 다시 찾기]

체크 표시로 끝난 상태임을 먼저 말하고, 버튼은 **선택**임이 드러나는 말로 바꿨다.

테스트 135개 통과(신규 1).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 09:58:36 +09:00
parent f04bcf63f0
commit 8167b5536d
4 changed files with 111 additions and 80 deletions
@@ -94,10 +94,17 @@ val Pair<ProbeFrequency, ProbeCycle>.progressKey: String
* *
* ## 왜 두께로 고르는가 * ## 왜 두께로 고르는가
* 주파수는 투과 깊이와 맞바꾸는 값이다. 낮은 주파수(1.8MHz)가 더 깊이 들어가고, 높은 * 주파수는 투과 깊이와 맞바꾸는 값이다. 낮은 주파수(1.8MHz)가 더 깊이 들어가고, 높은
* 주파수(2.3MHz)는 얕지만 분해능이 좋다. 복부가 얇으면 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조합 순회를 왜 그만두는가 * ## 6조합 순회를 왜 그만두는가
* 종전에는 주파수 2 × cycle 3 = 6조합을 전부 돌았다. 한 단계에 20회 × 6 = 120회, * 종전에는 주파수 2 × cycle 3 = 6조합을 전부 돌았다. 한 단계에 20회 × 6 = 120회,
* 자세·충만도까지 곱하면 환자 한 명이 너무 오래 눕는다. cycle 은 3 으로 고정한다 — * 자세·충만도까지 곱하면 환자 한 명이 너무 오래 눕는다. cycle 은 3 으로 고정한다 —
@@ -110,7 +117,7 @@ enum class AbdomenThickness(
val label: String, val label: String,
val combos: List<Pair<ProbeFrequency, ProbeCycle>>, val combos: List<Pair<ProbeFrequency, ProbeCycle>>,
) { ) {
UPTO_40("40mm 이하", listOf(ProbeFrequency.F_1_8 to ProbeCycle.C3)), UPTO_40("40mm 이하", listOf(ProbeFrequency.F_2_3 to ProbeCycle.C3)),
OVER_40("40mm 초과", listOf( OVER_40("40mm 초과", listOf(
ProbeFrequency.F_1_8 to ProbeCycle.C3, ProbeFrequency.F_1_8 to ProbeCycle.C3,
ProbeFrequency.F_2_3 to ProbeCycle.C3, ProbeFrequency.F_2_3 to ProbeCycle.C3,
@@ -601,66 +601,6 @@ fun AnchorAlignView(appState: AppState) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
// ── 채널 파형 ───────────────────────────────────────────────────
// 측정 중에도, 끝난 뒤에도 보여준다. 정렬은 위치를 옮겨 가며 반복하는
// 작업이라 "방금 이 위치에서 뭐가 잡혔는지"를 보고 다음을 정한다.
if (bv != null) {
Text("이 위치의 방광 용적", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = MlSecondaryText)
Spacer(Modifier.height(6.dp))
BvPanel(bv, caption = "${cm}cm · ${ALIGN_FREQ.label}MHz " +
"c${ALIGN_CYCLE.cycles} · ${AnchorConfig.ALIGN_CYCLES}회 평균")
Spacer(Modifier.height(16.dp))
}
if (detachSnapshot != null) {
Text("채널 접촉", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = MlSecondaryText)
Spacer(Modifier.height(6.dp))
ChannelDetachPanel(detachSnapshot)
Spacer(Modifier.height(16.dp))
}
// ── 파형이 이상할 때 ───────────────────────────────────────────
// 판단은 사람이 한다. 이 화면에서 파형을 보는 사람이 유일하게 환자와 프로브를
// 같이 보고 있는 사람이라, 앱이 지표로 거르는 것보다 정확하다.
// 자동 업로드와 별개로 남겨 둔다. 이쪽은 **메모를 붙여 지금 보내는** 길이다 —
// 파형이 이상할 때 개발자가 바로 보고 답해야 하므로, 화면을 떠날 때까지
// 기다릴 수 없다.
if (hasAlignData && LabdbCredentials.isRegistered) {
TextButton(
onClick = { showUpload = true },
enabled = !measuring && !lrRunning && !uploadBusy,
modifier = Modifier.fillMaxWidth(),
) {
Text(
if (uploadBusy) "업로드 중…" else "파형이 이상합니다 — labdb 로 보내기",
color = MlPrimary, fontWeight = FontWeight.SemiBold,
)
}
uploadMsg?.let {
Text(it, fontSize = 12.sp, color = MlSecondaryText,
modifier = Modifier.fillMaxWidth())
}
Spacer(Modifier.height(12.dp))
}
if (liveChannels.isNotEmpty()) {
Text("채널 파형", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = MlSecondaryText)
Spacer(Modifier.height(6.dp))
SixChannelWaveformGrid(
channels = liveChannels,
// 전벽·후벽 마커. BV 가 안 나올 때 어느 채널의 벽이 어디로 잡혔는지가
// 원인 분석의 출발점이다.
walls = bv?.markers ?: emptyList(),
modifier = Modifier.fillMaxWidth(),
cellHeight = 140.dp,
compact = true,
)
Spacer(Modifier.height(16.dp))
}
// 프로브를 바꿔 끼웠으면 이 세션을 이어 쓸 수 없다. hw_preset 이 한 번만 // 프로브를 바꿔 끼웠으면 이 세션을 이어 쓸 수 없다. hw_preset 이 한 번만
// 기록되므로 절반이 다른 기기의 데이터인데 한 기기로 라벨링된다. // 기록되므로 절반이 다른 기기의 데이터인데 한 기기로 라벨링된다.
if (disconnectedMidRun && isConnected) { if (disconnectedMidRun && isConnected) {
@@ -676,6 +616,12 @@ fun AnchorAlignView(appState: AppState) {
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
} }
// ── 동작 버튼을 파형보다 위에 둔다 ──────────────────────────────
// 종전에는 BV·접촉·파형 아래에 있어 한 위치를 잴 때마다 스크롤을 내려야
// 했다. 프로브를 한 손에 잡은 채 쓰는 화면이라 그 한 번이 매번 든다
// (2026-09-10 피드백). 지시(카드) 바로 아래 누를 것을 둔다.
//
// 아래쪽은 **근거**다 — BV·접촉·파형은 "왜 이 값인가"를 볼 때만 내려다본다.
if (!done) { if (!done) {
Button( Button(
onClick = { measuring = true }, onClick = { measuring = true },
@@ -817,6 +763,67 @@ fun AnchorAlignView(appState: AppState) {
) { Text("처음부터 다시 정렬", color = MlSecondaryText) } ) { Text("처음부터 다시 정렬", color = MlSecondaryText) }
} }
// ── 채널 파형 ───────────────────────────────────────────────────
// 측정 중에도, 끝난 뒤에도 보여준다. 정렬은 위치를 옮겨 가며 반복하는
// 작업이라 "방금 이 위치에서 뭐가 잡혔는지"를 보고 다음을 정한다.
if (bv != null) {
Text("이 위치의 방광 용적", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = MlSecondaryText)
Spacer(Modifier.height(6.dp))
BvPanel(bv, caption = "${cm}cm · ${ALIGN_FREQ.label}MHz " +
"c${ALIGN_CYCLE.cycles} · ${AnchorConfig.ALIGN_CYCLES}회 평균")
Spacer(Modifier.height(16.dp))
}
if (detachSnapshot != null) {
Text("채널 접촉", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = MlSecondaryText)
Spacer(Modifier.height(6.dp))
ChannelDetachPanel(detachSnapshot)
Spacer(Modifier.height(16.dp))
}
// ── 파형이 이상할 때 ───────────────────────────────────────────
// 판단은 사람이 한다. 이 화면에서 파형을 보는 사람이 유일하게 환자와 프로브를
// 같이 보고 있는 사람이라, 앱이 지표로 거르는 것보다 정확하다.
// 자동 업로드와 별개로 남겨 둔다. 이쪽은 **메모를 붙여 지금 보내는** 길이다 —
// 파형이 이상할 때 개발자가 바로 보고 답해야 하므로, 화면을 떠날 때까지
// 기다릴 수 없다.
if (hasAlignData && LabdbCredentials.isRegistered) {
TextButton(
onClick = { showUpload = true },
enabled = !measuring && !lrRunning && !uploadBusy,
modifier = Modifier.fillMaxWidth(),
) {
Text(
if (uploadBusy) "업로드 중…" else "파형이 이상합니다 — labdb 로 보내기",
color = MlPrimary, fontWeight = FontWeight.SemiBold,
)
}
uploadMsg?.let {
Text(it, fontSize = 12.sp, color = MlSecondaryText,
modifier = Modifier.fillMaxWidth())
}
Spacer(Modifier.height(12.dp))
}
if (liveChannels.isNotEmpty()) {
Text("채널 파형", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = MlSecondaryText)
Spacer(Modifier.height(6.dp))
SixChannelWaveformGrid(
channels = liveChannels,
// 전벽·후벽 마커. BV 가 안 나올 때 어느 채널의 벽이 어디로 잡혔는지가
// 원인 분석의 출발점이다.
walls = bv?.markers ?: emptyList(),
modifier = Modifier.fillMaxWidth(),
cellHeight = 140.dp,
compact = true,
)
Spacer(Modifier.height(16.dp))
}
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
if (records.isNotEmpty()) { if (records.isNotEmpty()) {
@@ -880,14 +880,18 @@ private fun AnchorCard(
Text( Text(
when { when {
!set -> "부착 위치 미정렬" !set -> "부착 위치 미정렬"
basis?.isAlgorithm == true -> "부착 위치 ${anchorCm}cm" // 정렬을 마치고 넘어온 직후다. 체크 표시가 없으면 옆의 [위치 다시
else -> "부착 위치 ${anchorCm}cm · ${basis?.label ?: "근거 불명"}" // 찾기] 버튼이 "다시 해야 한다"는 지시로 읽힌다(2026-09-10 피드백).
basis?.isAlgorithm == true -> "✓ 부착 위치 ${anchorCm}cm 확정"
else -> "✓ 부착 위치 ${anchorCm}cm · ${basis?.label ?: "근거 불명"}"
}, },
fontSize = 15.sp, fontWeight = FontWeight.Bold, color = tone, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = tone,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
TextButton(onClick = onAlign, enabled = enabled) { TextButton(onClick = onAlign, enabled = enabled) {
Text(if (set) "다시 정렬" else "정렬 시작", color = MlPrimary) // "다시 정렬"은 **해야 하는 일**로 읽힌다. 이 버튼은 선택이다 —
// 위치가 이미 정해져 있고, 다시 찾고 싶을 때만 누른다.
Text(if (set) "위치 다시 찾기" else "정렬 시작", color = MlPrimary)
} }
} }
Text( Text(
@@ -37,25 +37,38 @@ class HospitalProgressTest {
.map { combo(it.first.freqOption, it.second.cycles, saved = saved) } .map { combo(it.first.freqOption, it.second.cycles, saved = saved) }
.toTypedArray() .toTypedArray()
private val thin = AbdomenThickness.UPTO_40.expectedKeys // {0/3} private val thin = AbdomenThickness.UPTO_40.expectedKeys // {5/3}
private val thick = AbdomenThickness.OVER_40.expectedKeys // {0/3, 5/3} private val thick = AbdomenThickness.OVER_40.expectedKeys // {0/3, 5/3}
@Test fun `두께가 요구 조합을 정한다`() { @Test fun `두께가 요구 조합을 정한다`() {
// 이 두 집합이 이 파일 전체의 전제다. 바뀌면 아래 기대값이 전부 의미를 잃는다. // 이 두 집합이 이 파일 전체의 전제다. 바뀌면 아래 기대값이 전부 의미를 잃는다.
assertEquals(setOf("0/3"), thin) //
// freq_option: 0 = 1.8MHz · 5 = 2.3MHz.
// 40mm 이하 2.3MHz c3 (얇으면 그 깊이까지 닿고 분해능이 낫다)
// 40mm 초과 1.8MHz c3 + 2.3MHz c3 (어느 쪽이 맞는지 미리 알 수 없다)
assertEquals(setOf("5/3"), thin)
assertEquals(setOf("0/3", "5/3"), thick) assertEquals(setOf("0/3", "5/3"), thick)
} }
@Test fun `얇으면 1_8MHz 하나로 완료다`() { @Test fun `얇으면 2_3MHz 하나로 완료다`() {
val r = HospitalRunStore.progressFrom( val r = HospitalRunStore.progressFrom(
listOf(manifest("Supine", 40, combo(0, 3))), thin) listOf(manifest("Supine", 40, combo(5, 3))), thin)
assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 40]) assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 40])
} }
@Test fun `두꺼우면 같은 데이터가 아직 미완이다`() { @Test fun `두꺼우면 같은 데이터가 아직 미완이다`() {
// 1.8MHz 만 받았다. 두꺼운 환자는 2.3MHz 도 받아야 나중에 고를 수 있다. // 2.3MHz 만 받았다. 두꺼운 환자는 1.8MHz 도 받아야 나중에 고를 수 있다 —
// 2.3MHz 가 뒤벽까지 못 닿았을 수 있고, 그건 받아 보고야 안다.
val r = HospitalRunStore.progressFrom( val r = HospitalRunStore.progressFrom(
listOf(manifest("Supine", 40, combo(0, 3))), thick) listOf(manifest("Supine", 40, combo(5, 3))), thick)
assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Supine" to 40])
}
@Test fun `얇은 두께 기준으로는 1_8MHz 만으로 완료가 아니다`() {
// 40mm 이하의 요구 조합은 2.3MHz 다. 1.8MHz 를 받아도 그 조건은 안 채워진다 —
// 두께를 잘못 고르고 측정하면 완료로 잡히지 않아 그 자리에서 드러난다.
val r = HospitalRunStore.progressFrom(
listOf(manifest("Supine", 40, combo(0, 3))), thin)
assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Supine" to 40]) assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Supine" to 40])
} }
@@ -72,8 +85,8 @@ class HospitalProgressTest {
@Test fun `나눠 재도 다 채우면 완료다`() { @Test fun `나눠 재도 다 채우면 완료다`() {
val r = HospitalRunStore.progressFrom(listOf( val r = HospitalRunStore.progressFrom(listOf(
manifest("Supine", 40, combo(0, 3)),
manifest("Supine", 40, combo(5, 3)), manifest("Supine", 40, combo(5, 3)),
manifest("Supine", 40, combo(0, 3)),
), thick) ), thick)
assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 40]) assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 40])
} }
@@ -91,22 +104,22 @@ class HospitalProgressTest {
@Test fun `계획을 못 채운 조합은 안 센다`() { @Test fun `계획을 못 채운 조합은 안 센다`() {
// 20회 중 3회에서 끊긴 것을 완료로 보면 안 된다 — 그게 제일 위험하다. // 20회 중 3회에서 끊긴 것을 완료로 보면 안 된다 — 그게 제일 위험하다.
val r = HospitalRunStore.progressFrom( val r = HospitalRunStore.progressFrom(
listOf(manifest("Supine", 0, combo(0, 3, planned = 20, saved = 3))), thin) listOf(manifest("Supine", 0, combo(5, 3, planned = 20, saved = 3))), thin)
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, combo(0, 3, saved = 2)), manifest("Supine", 20, combo(5, 3, saved = 2)),
manifest("Supine", 20, combo(0, 3, saved = 20)), manifest("Supine", 20, combo(5, 3, saved = 20)),
), thin) ), thin)
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, combo(0, 3)), manifest("Supine", 40, combo(5, 3)),
manifest("Sitting", 40, combo(5, 3)), manifest("Sitting", 40, combo(0, 3)),
), thin) ), 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])
@@ -116,14 +129,14 @@ class HospitalProgressTest {
@Test fun `planned 가 없으면 채운 것으로 보지 않는다`() { @Test fun `planned 가 없으면 채운 것으로 보지 않는다`() {
// 옛 매니페스트나 깨진 파일. 모르는 것을 완료로 올리면 안 된다. // 옛 매니페스트나 깨진 파일. 모르는 것을 완료로 올리면 안 된다.
val r = HospitalRunStore.progressFrom( val r = HospitalRunStore.progressFrom(
listOf(manifest("Supine", 80, """{"freq_option":0,"cycles":3,"saved":20}""")), thin) listOf(manifest("Supine", 80, """{"freq_option":5,"cycles":3,"saved":20}""")), thin)
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, combo(0, 3)), manifest("Supine", 100, combo(5, 3)),
), thin) ), thin)
assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 100]) assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 100])
} }