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 e99d89c..6406dd3 100644 --- a/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt +++ b/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt @@ -94,10 +94,17 @@ val Pair.progressKey: String * * ## 왜 두께로 고르는가 * 주파수는 투과 깊이와 맞바꾸는 값이다. 낮은 주파수(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조합 순회를 왜 그만두는가 * 종전에는 주파수 2 × cycle 3 = 6조합을 전부 돌았다. 한 단계에 20회 × 6 = 120회, * 자세·충만도까지 곱하면 환자 한 명이 너무 오래 눕는다. cycle 은 3 으로 고정한다 — @@ -110,7 +117,7 @@ enum class AbdomenThickness( val label: String, val combos: List>, ) { - 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( ProbeFrequency.F_1_8 to ProbeCycle.C3, ProbeFrequency.F_2_3 to ProbeCycle.C3, diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt index 9b5b9ab..1c9dcbf 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt @@ -601,66 +601,6 @@ fun AnchorAlignView(appState: AppState) { 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 이 한 번만 // 기록되므로 절반이 다른 기기의 데이터인데 한 기기로 라벨링된다. if (disconnectedMidRun && isConnected) { @@ -676,6 +616,12 @@ fun AnchorAlignView(appState: AppState) { Spacer(Modifier.height(10.dp)) } + // ── 동작 버튼을 파형보다 위에 둔다 ────────────────────────────── + // 종전에는 BV·접촉·파형 아래에 있어 한 위치를 잴 때마다 스크롤을 내려야 + // 했다. 프로브를 한 손에 잡은 채 쓰는 화면이라 그 한 번이 매번 든다 + // (2026-09-10 피드백). 지시(카드) 바로 아래 누를 것을 둔다. + // + // 아래쪽은 **근거**다 — BV·접촉·파형은 "왜 이 값인가"를 볼 때만 내려다본다. if (!done) { Button( onClick = { measuring = true }, @@ -817,6 +763,67 @@ fun AnchorAlignView(appState: AppState) { ) { 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)) if (records.isNotEmpty()) { 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 79d1eea..66375bb 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 @@ -880,14 +880,18 @@ private fun AnchorCard( Text( when { !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, modifier = Modifier.weight(1f), ) TextButton(onClick = onAlign, enabled = enabled) { - Text(if (set) "다시 정렬" else "정렬 시작", color = MlPrimary) + // "다시 정렬"은 **해야 하는 일**로 읽힌다. 이 버튼은 선택이다 — + // 위치가 이미 정해져 있고, 다시 찾고 싶을 때만 누른다. + Text(if (set) "위치 다시 찾기" else "정렬 시작", color = MlPrimary) } } Text( 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 44ac8ab..39001ac 100644 --- a/app/src/test/java/com/medithings/vesiscan/services/HospitalProgressTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/services/HospitalProgressTest.kt @@ -37,25 +37,38 @@ class HospitalProgressTest { .map { combo(it.first.freqOption, it.second.cycles, saved = saved) } .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} @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) } - @Test fun `얇으면 1_8MHz 하나로 완료다`() { + @Test fun `얇으면 2_3MHz 하나로 완료다`() { 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]) } @Test fun `두꺼우면 같은 데이터가 아직 미완이다`() { - // 1.8MHz 만 받았다. 두꺼운 환자는 2.3MHz 도 받아야 나중에 고를 수 있다. + // 2.3MHz 만 받았다. 두꺼운 환자는 1.8MHz 도 받아야 나중에 고를 수 있다 — + // 2.3MHz 가 뒤벽까지 못 닿았을 수 있고, 그건 받아 보고야 안다. 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]) } @@ -72,8 +85,8 @@ class HospitalProgressTest { @Test fun `나눠 재도 다 채우면 완료다`() { val r = HospitalRunStore.progressFrom(listOf( - manifest("Supine", 40, combo(0, 3)), manifest("Supine", 40, combo(5, 3)), + manifest("Supine", 40, combo(0, 3)), ), thick) assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 40]) } @@ -91,22 +104,22 @@ class HospitalProgressTest { @Test fun `계획을 못 채운 조합은 안 센다`() { // 20회 중 3회에서 끊긴 것을 완료로 보면 안 된다 — 그게 제일 위험하다. 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]) } @Test fun `끊긴 뒤 다시 채우면 완료다`() { val r = HospitalRunStore.progressFrom(listOf( - manifest("Supine", 20, combo(0, 3, saved = 2)), - manifest("Supine", 20, combo(0, 3, saved = 20)), + manifest("Supine", 20, combo(5, 3, saved = 2)), + manifest("Supine", 20, combo(5, 3, saved = 20)), ), thin) assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 20]) } @Test fun `조건이 다르면 섞이지 않는다`() { val r = HospitalRunStore.progressFrom(listOf( - manifest("Supine", 40, combo(0, 3)), - manifest("Sitting", 40, combo(5, 3)), + manifest("Supine", 40, combo(5, 3)), + manifest("Sitting", 40, combo(0, 3)), ), thin) assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 40]) assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Sitting" to 40]) @@ -116,14 +129,14 @@ class HospitalProgressTest { @Test fun `planned 가 없으면 채운 것으로 보지 않는다`() { // 옛 매니페스트나 깨진 파일. 모르는 것을 완료로 올리면 안 된다. 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]) } @Test fun `깨진 JSON 은 그 파일만 건너뛴다`() { val r = HospitalRunStore.progressFrom(listOf( "not json at all", - manifest("Supine", 100, combo(0, 3)), + manifest("Supine", 100, combo(5, 3)), ), thin) assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 100]) }