From d66b69506f9e07a9de840099ffba2b8ab5b3032c Mon Sep 17 00:00:00 2001 From: jjangddu Date: Wed, 9 Sep 2026 13:55:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(clinical):=20=EC=A0=95=EB=A0=AC=EC=9D=B4?= =?UTF-8?q?=20=EC=95=88=20=EB=81=9D=EB=82=98=EB=8F=84=20=EC=9E=84=EC=83=81?= =?UTF-8?q?=20=EC=B8=A1=EC=A0=95=EC=9C=BC=EB=A1=9C=20=EB=84=98=EC=96=B4?= =?UTF-8?q?=EA=B0=88=20=EC=88=98=20=EC=9E=88=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실제 인체에서 정렬 로직이 끝까지 안 맞을 수 있다. CH3 가 어느 위치에서도 안 잡히거나 (REATTACH), 상한까지 올라가도 nch 가 붕괴하지 않아 종료 조건이 안 걸린다. 그런데 진행 버튼이 두 군데서 막혀 있었다. 탐색 중(!done) 버튼이 **없다** — 측정 버튼만 있다 REATTACH 판정 버튼이 **비활성** (enabled = action == STOP) 후자가 특히 나쁘다. REATTACH 도 done 이라 2단계 화면으로 넘어가는데, 거기서 진행 버튼이 회색이고 "처음부터 다시 정렬" 밖에 없다. 환자는 누워 있고 방광은 계속 차는데 앱이 다음 화면을 안 내주는 막다른 길이다. 진행 버튼을 단계 밖으로 빼서 **항상 활성**으로 두었다. 막는 조건은 측정·좌우 스캔 중 뿐이다 — 그때 나가면 프로브 설정 복원이 끊겨 그 위치 데이터가 반쪽이 된다. 넘기는 위치는 `last?.anchorCm ?: cm`, 둘 다 "프로브가 지금 있는 자리"라 화면 숫자와 기록이 어긋나지 않는다. **막지 않는 대신 근거를 같이 들고 간다.** AnchorBasis 를 새로 만들었다: algorithm 알고리즘이 best 확정 (STOP) reattach_override 재부착 권고를 무시하고 진행 manual 탐색 도중 사람이 결정 이게 이 커밋에서 제일 중요한 부분이다. 위치(cm)만 들고 가면 사람이 고른 자리와 알고리즘이 고른 자리가 기록에서 한 덩어리가 된다. 그러면 "기존 초음파 측정기 대비 정확도"를 낼 때 사람이 고른 자리의 오차가 알고리즘의 오차로 계산되고, 나중에 둘을 가를 단서가 없어 **데이터 전체가 주장을 받치지 못한다.** 그래서 근거를 세 곳에 남긴다: appState(화면), 600 매니페스트, 601 align_result.json (`anchor_basis` · `proceed_anchor_cm`). LABDB_DATATYPES.md 에 "정확도 분석에는 algorithm 만 추려야 한다"를 표로 박았다 — 서버 쪽이 세션 목록에서 필터를 걸 수 있다. 화면도 근거에 따라 갈린다. 진행 버튼은 algorithm 일 때만 초록이고 나머지는 주황, AnchorCard 도 같은 규칙이다. 같은 색으로 두면 간호사가 "정렬 끝났다"로 읽는다. 버튼 아래 한 줄로 무엇을 건너뛰는지 말한다(proceedNote) — 다 맞췄으면 아무 말도 안 한다. 늘 뜨는 경고는 곧 무시당한다. lateral 요약에 `confirmed` 를 추가했다. 좌우를 맞추는 중에 넘어가면 commit 은 있어도 확인은 없는데, 기존 `done` 하나로는 그 둘이 구분되지 않아 확인한 세션으로 잘못 읽힌다. 테스트 110개 통과(신규 11). AnchorAction 이 늘어나면 조용히 MANUAL 로 떨어지므로 entries.size 를 고정해 그때 이 결정을 다시 보게 했다. Co-Authored-By: Claude Opus 5 --- .../java/com/medithings/vesiscan/AppState.kt | 10 ++ .../vesiscan/managers/AnchorBasis.kt | 59 +++++++++ .../ui/views/clinical/AnchorAlignView.kt | 113 +++++++++++++++--- .../ui/views/clinical/HospitalModeView.kt | 41 ++++++- .../vesiscan/managers/AnchorBasisTest.kt | 57 +++++++++ .../ui/views/clinical/ProceedNoteTest.kt | 54 +++++++++ docs/LABDB_DATATYPES.md | 26 +++- 7 files changed, 334 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/medithings/vesiscan/managers/AnchorBasis.kt create mode 100644 app/src/test/java/com/medithings/vesiscan/managers/AnchorBasisTest.kt create mode 100644 app/src/test/java/com/medithings/vesiscan/ui/views/clinical/ProceedNoteTest.kt diff --git a/app/src/main/java/com/medithings/vesiscan/AppState.kt b/app/src/main/java/com/medithings/vesiscan/AppState.kt index e216b05..c8d116c 100644 --- a/app/src/main/java/com/medithings/vesiscan/AppState.kt +++ b/app/src/main/java/com/medithings/vesiscan/AppState.kt @@ -46,6 +46,16 @@ class AppState(private val context: Context) { */ var anchorCm: Int? by mutableStateOf(null) + /** + * [anchorCm] 을 **누가** 정했는지. null = 정렬을 거치지 않았다. + * + * 정렬은 실제 인체에서 끝까지 안 맞을 수 있어 진행을 막지 않는다. 그래서 위치만 + * 들고 다니면 알고리즘이 고른 자리와 사람이 고른 자리가 한 덩어리로 쌓이고, 나중에 + * 정확도 검증의 모집단을 가려낼 수 없다. **항상 [anchorCm] 과 같이 비우고 같이 + * 넣는다** — 하나만 남으면 그 순간부터 둘이 어긋난다. + */ + var anchorBasis: com.medithings.vesiscan.managers.AnchorBasis? by mutableStateOf(null) + /** * 병원 임상 환자명. 화면 상태가 아니라 여기 둔다. * diff --git a/app/src/main/java/com/medithings/vesiscan/managers/AnchorBasis.kt b/app/src/main/java/com/medithings/vesiscan/managers/AnchorBasis.kt new file mode 100644 index 0000000..12e8f75 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/managers/AnchorBasis.kt @@ -0,0 +1,59 @@ +/* + * AnchorBasis — 임상 측정에 쓰는 부착 위치를 **누가** 정했는지. + */ +package com.medithings.vesiscan.managers + +/** + * 부착 위치가 어떤 근거로 정해졌는가. + * + * ## 왜 필요한가 + * 정렬 알고리즘은 실제 인체에서 끝까지 안 맞을 수 있다. 체형·방광 충만도·젤 도포에 따라 + * CH3 가 끝내 안 잡히거나([AnchorAction.REATTACH]), 탐색 상한까지 가도 nch 가 붕괴하지 + * 않아 종료 조건이 안 걸린다. 그때 진행을 막으면 **임상 자체가 멈춘다** — 환자는 누워 + * 있고 방광은 계속 차는데 앱이 다음 화면을 안 내주는 상황이다. + * + * 그래서 언제든 진행할 수 있게 뚫어 두되, 그렇게 정한 위치와 알고리즘이 확정한 위치를 + * **같은 것으로 쌓지 않는다.** 검증의 주장이 "알고리즘이 고른 위치에서 이런 BV 가 + * 나왔다"인데 거기에 사람이 고른 위치가 섞이면, 나중에 둘을 가를 방법이 없어 데이터 + * 전체가 주장을 받치지 못한다. + * + * 위치(cm)와 이 근거는 **항상 같이** 다닌다 — 화면 표시, 600 매니페스트, 601 요약. + */ +enum class AnchorBasis { + /** 알고리즘이 best 를 확정했다([AnchorAction.STOP]). */ + ALGORITHM, + + /** 알고리즘은 재부착을 요구했는데([AnchorAction.REATTACH]) 그대로 진행했다. */ + REATTACH_OVERRIDE, + + /** 탐색이 끝나기 전에 사람이 이 위치로 결정했다. */ + MANUAL; + + /** 기록에 쓰는 값. 분석 스크립트가 문자열로 비교하므로 고정이다. */ + val wire: String get() = name.lowercase() + + /** 사람이 읽는 한 줄. 화면과 기록이 같은 말을 쓴다. */ + val label: String get() = when (this) { + ALGORITHM -> "알고리즘 확정" + REATTACH_OVERRIDE -> "재부착 권고 무시" + MANUAL -> "수동 지정 (탐색 미완료)" + } + + /** + * 알고리즘이 확정한 위치인가. + * + * 정확도 검증(항목 ①)의 모집단은 **이것이 true 인 세션만**이다. 나머지는 "진행을 + * 막지 않아 데이터가 남았다"는 의미이지 알고리즘의 성능 근거가 아니다. + */ + val isAlgorithm: Boolean get() = this == ALGORITHM + + companion object { + /** 정렬 상태기의 마지막 판정으로부터. `null` = 한 위치도 재지 않았다. */ + fun of(action: AnchorAction?): AnchorBasis = when (action) { + AnchorAction.STOP -> ALGORITHM + AnchorAction.REATTACH -> REATTACH_OVERRIDE + // MOVE_UP·MOVE_DOWN·null — 탐색이 아직 답을 내지 않은 상태다. + else -> MANUAL + } + } +} 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 70518f8..eb1852e 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 @@ -48,6 +48,7 @@ import com.medithings.vesiscan.ble.BleManager import com.medithings.vesiscan.managers.AlignAction import com.medithings.vesiscan.managers.AlignmentConstants import com.medithings.vesiscan.managers.AnchorAction +import com.medithings.vesiscan.managers.AnchorBasis import com.medithings.vesiscan.managers.AnchorConfig import com.medithings.vesiscan.managers.AnchorGuide import com.medithings.vesiscan.managers.AnchorPosRecord @@ -409,6 +410,7 @@ fun AnchorAlignView(appState: AppState) { onClick = { if (isConnected) { appState.anchorCm = null + appState.anchorBasis = null bleManager.disconnect() } else { appState.connectReturnScreen = AppScreen.ANCHOR_ALIGN @@ -627,32 +629,70 @@ fun AnchorAlignView(appState: AppState) { buildAlignSummary( saveName, appState.hospitalPatient, deviceName, bleManager.firmwareVersion.value, guide.records, s, - lrCommit, + lrCommit, lateralConfirmed = true, ), ) } } }, ) - Spacer(Modifier.height(16.dp)) + } - Button( - onClick = { - appState.anchorCm = last?.anchorCm - appState.currentScreen = AppScreen.HOSPITAL_MODE - }, - enabled = last?.action == AnchorAction.STOP && !lrRunning, - modifier = Modifier.fillMaxWidth().height(52.dp), - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.buttonColors(containerColor = MlSuccess), - ) { Text("이 위치로 임상 측정 진행", fontSize = 16.sp, fontWeight = FontWeight.Bold) } - // 막지는 않는다. lateral 이 끝내 안 잡히는 환자도 있는데 그때 진행을 - // 못 하게 하면 임상 자체가 멈춘다 — 상태만 분명히 보여 준다. - if (!lrConfirmed) { - Spacer(Modifier.height(6.dp)) - Text("좌우 정렬을 아직 확인하지 않았습니다.", - fontSize = 12.sp, color = MlSecondaryText) - } + Spacer(Modifier.height(16.dp)) + + // -- 임상 측정으로 넘어가기 ------------------------------------------ + // **단계와 무관하게 항상 누를 수 있다.** 정렬이 실제 인체에서 끝까지 안 맞는 + // 경우가 있다 - CH3 가 어느 위치에서도 안 잡히거나, 상한까지 올라가도 nch 가 + // 붕괴하지 않아 종료가 안 걸린다. 그때 막으면 환자를 눕혀 둔 채 임상이 멈춘다. + // + // 대신 **근거를 같이 들고 간다**([AnchorBasis]). 사람이 고른 위치와 알고리즘이 + // 확정한 위치가 기록에서 구분되지 않으면, 정확도 검증의 모집단을 나중에 + // 가려낼 수 없어 데이터 전체가 주장을 받치지 못한다. + val basis = AnchorBasis.of(last?.action) + // 확정됐으면 그 값(best+offset), 아니면 지금 화면의 위치. 둘 다 "프로브가 + // 지금 있는 자리"라는 뜻이라 간호사가 보는 숫자와 기록이 어긋나지 않는다. + val proceedCm = last?.anchorCm ?: cm + Button( + onClick = { + appState.anchorCm = proceedCm + appState.anchorBasis = basis + // 근거를 요약에도 박는다. 화면에만 있으면 나중에 파일을 보는 사람이 + // 이 위치가 어떻게 정해졌는지 알 길이 없다. + last?.let { s -> + runCatching { + val dir = HospitalRunStore.alignDir(saveName, runStartedAt) + HospitalRunStore.writeAlignSummary( + dir, + buildAlignSummary( + saveName, appState.hospitalPatient, deviceName, + bleManager.firmwareVersion.value, guide.records, s, + lrCommit, lrConfirmed, basis, proceedCm, + ), + ) + } + } + appState.currentScreen = AppScreen.HOSPITAL_MODE + }, + // 측정·좌우 스캔 중에만 막는다. 그때 나가면 프로브 설정 복원이 끊기고 + // 그 위치 데이터가 반쪽이 된다. + enabled = !measuring && !lrRunning && !uploadBusy, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(14.dp), + // 알고리즘 확정일 때만 초록이다. 사람이 고른 경우를 같은 색으로 두면 + // 간호사가 "정렬이 끝났다"고 읽는다. + colors = ButtonDefaults.buttonColors( + containerColor = if (basis.isAlgorithm) MlSuccess else MlWarning, + ), + ) { + Text("이 위치(${proceedCm}cm)로 임상 측정 진행", + fontSize = 16.sp, fontWeight = FontWeight.Bold) + } + proceedNote(basis, lrConfirmed)?.let { note -> + Spacer(Modifier.height(6.dp)) + Text(note, fontSize = 12.sp, color = MlSecondaryText) + } + + if (done) { Spacer(Modifier.height(8.dp)) TextButton( onClick = { @@ -858,6 +898,25 @@ private fun RecordRow(r: AnchorPosRecord, isBest: Boolean) { Spacer(Modifier.height(4.dp)) } +/** + * 진행 버튼 아래 한 줄 - **무엇을 모르고 넘어가는지** 말한다. + * + * 버튼을 항상 열어 두기로 한 대가다. 막지 않는 대신, 누르는 사람이 자기가 무엇을 + * 건너뛰는지는 알아야 한다. 알고리즘이 확정하고 좌우까지 확인했으면 할 말이 없다(null). + */ +internal fun proceedNote(basis: AnchorBasis, lateralConfirmed: Boolean): String? { + val head = when (basis) { + AnchorBasis.ALGORITHM -> null + AnchorBasis.REATTACH_OVERRIDE -> + "알고리즘은 재부착을 권고했습니다 - 그대로 진행하면 기록에 그 사실이 남습니다." + AnchorBasis.MANUAL -> + "정렬 탐색이 끝나지 않았습니다 - 이 위치는 사람이 고른 것으로 기록됩니다." + } + val tail = if (lateralConfirmed) null else "좌우 정렬은 아직 확인하지 않았습니다." + val parts = listOfNotNull(head, tail) + return if (parts.isEmpty()) null else parts.joinToString(" ") +} + /** * 정렬 결과 요약 JSON. 위치별 지표와 선택을 **판정 근거 그대로** 남긴다. * @@ -874,6 +933,15 @@ private fun buildAlignSummary( step: AnchorStep, /** 좌우 정렬 결과. null = 아직 돌리지 않았음(= 상하만 맞춘 세션). */ lateral: LateralGuide.Commit? = null, + /** 간호사가 "좌우 확인 완료"를 눌렀는가. [lateral] 이 있는 것과 다른 사실이다. */ + lateralConfirmed: Boolean = false, + /** + * 이 위치로 임상을 진행한 근거. null = 이 화면에서 진행하지 않았다(측정 중 갱신본). + * 진행 버튼을 누르는 순간 덮어쓰므로, 남은 null 은 "정렬만 하고 나갔다"는 뜻이다. + */ + basis: AnchorBasis? = null, + /** 실제로 임상에 넘긴 cm. [AnchorStep.anchorCm] 이 null 인 채 진행할 수 있다. */ + proceedCm: Int? = null, ): JSONObject = JSONObject().apply { put("patient", patient) put("save_name", saveName) @@ -902,8 +970,15 @@ private fun buildAlignSummary( // 좌우 정렬 (SPEC §8 "좌우(LR) 정렬" · LateralGuide). // done=false 는 "안 맞췄다"가 아니라 "확인 단계를 거치지 않았다"는 뜻이다. // lateral 이 끝내 안 잡히는 환자도 있어 진행을 막지 않기 때문에 둘을 구분해야 한다. + // 이 위치를 누가 정했나. 정확도 검증의 모집단은 algorithm 인 세션만이다 - + // 나머지는 "진행을 막지 않아 데이터가 남았다"는 뜻이지 성능 근거가 아니다. + put("anchor_basis", basis?.wire ?: JSONObject.NULL) + put("proceed_anchor_cm", proceedCm ?: JSONObject.NULL) put("lateral", JSONObject().apply { put("done", lateral != null) + // 사람의 확인과 값의 존재는 다른 사실이다. 좌우를 맞추는 중에 임상으로 넘어가면 + // commit 은 있어도 확인은 없다 - 둘을 합치면 확인한 세션으로 잘못 읽힌다. + put("confirmed", lateralConfirmed) put("lat_tol", AlignmentConstants.LAT_TOL) put("accum_k", LateralGuide.ACCUM_K_DEFAULT) put("action", lateral?.action?.name ?: JSONObject.NULL) 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 4529288..28031e6 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 @@ -37,6 +37,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.medithings.vesiscan.managers.AnchorBasis import com.medithings.vesiscan.managers.ClinicalBv import com.medithings.vesiscan.ui.components.ChannelDetachPanel import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid @@ -315,6 +316,10 @@ fun HospitalModeView(appState: AppState) { // 정렬로 정한 부착 위치. 자세·용량이 바뀌어도 고정이므로 // 분석 때 "이 데이터는 몇 cm 에서 받았나" 를 되살릴 수 있어야 한다. put("anchor_cm", appState.anchorCm ?: JSONObject.NULL) + // 그 위치를 누가 정했나. 정렬이 실제 인체에서 안 맞을 때 사람이 고른 + // 자리로도 진행할 수 있는데, 그렇게 받은 데이터를 알고리즘 성능의 근거로 + // 쓰면 안 된다 - 분석 때 모집단을 가를 유일한 단서다([AnchorBasis]). + put("anchor_basis", appState.anchorBasis?.wire ?: JSONObject.NULL) put("combinations", results) }, startedAt) pendingUploads = HospitalLabdbUploader.pending(dir).size @@ -361,6 +366,7 @@ fun HospitalModeView(appState: AppState) { // 서로 달라(0/-7/-14/-21 vs 0/-9/-18/-27) 같은 자리에서도 // nch·cap_frac 이 달라진다 — 최적 부착 위치 자체가 다르다. appState.anchorCm = null + appState.anchorBasis = null bleManager.disconnect() } else { appState.connectReturnScreen = AppScreen.HOSPITAL_MODE @@ -383,7 +389,10 @@ fun HospitalModeView(appState: AppState) { if (!running) { // 환자가 바뀌면 정렬도 무효다. 앞 환자의 cm 을 그대로 들고 가면 // 매니페스트에 틀린 위치가 기록된다. - if (it != patient) appState.anchorCm = null + if (it != patient) { + appState.anchorCm = null + appState.anchorBasis = null + } patient = it } }, @@ -396,6 +405,7 @@ fun HospitalModeView(appState: AppState) { AnchorCard( anchorCm = appState.anchorCm, + basis = appState.anchorBasis, enabled = !running && isConnected, onAlign = { appState.currentScreen = AppScreen.ANCHOR_ALIGN }, ) @@ -839,9 +849,17 @@ private fun ProgressPanel( * `anchor_cm: null` 로 정직하게 남긴다. */ @Composable -private fun AnchorCard(anchorCm: Int?, enabled: Boolean, onAlign: () -> Unit) { +private fun AnchorCard( + anchorCm: Int?, + /** 그 위치를 누가 정했는지. null = 정렬을 거치지 않았다. */ + basis: AnchorBasis?, + enabled: Boolean, + onAlign: () -> Unit, +) { val set = anchorCm != null - val tone = if (set) MlSuccess else MlWarning + // 알고리즘이 확정한 위치만 초록이다. 사람이 고른 위치를 같은 색으로 두면 간호사가 + // "정렬 끝났다"로 읽고, 그 세션이 정확도 근거로 쓰여도 아무도 모른다. + val tone = if (set && basis?.isAlgorithm == true) MlSuccess else MlWarning Column( Modifier.fillMaxWidth() .background(tone.copy(alpha = 0.10f), RoundedCornerShape(12.dp)) @@ -850,7 +868,11 @@ private fun AnchorCard(anchorCm: Int?, enabled: Boolean, onAlign: () -> Unit) { ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - if (set) "부착 위치 ${anchorCm}cm" else "부착 위치 미정렬", + when { + !set -> "부착 위치 미정렬" + basis?.isAlgorithm == true -> "부착 위치 ${anchorCm}cm" + else -> "부착 위치 ${anchorCm}cm · ${basis?.label ?: "근거 불명"}" + }, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = tone, modifier = Modifier.weight(1f), ) @@ -859,8 +881,15 @@ private fun AnchorCard(anchorCm: Int?, enabled: Boolean, onAlign: () -> Unit) { } } Text( - if (set) "이 환자의 전 자세·전 용량에서 이 위치를 그대로 씁니다." - else "정렬하지 않아도 측정은 되지만, 기록에 부착 위치가 남지 않습니다.", + when { + !set -> "정렬하지 않아도 측정은 되지만, 기록에 부착 위치가 남지 않습니다." + basis?.isAlgorithm == true -> + "이 환자의 전 자세·전 용량에서 이 위치를 그대로 씁니다." + // 막지 않는 대신 무엇이 빠진 상태인지는 말해 준다. 이 세션은 정확도 + // 검증(기존 측정기 대조)의 모집단이 아니다. + else -> "이 위치를 그대로 쓰되, 알고리즘이 확정한 자리가 아니라는 사실이 " + + "기록에 남습니다. 정확도 비교에는 쓰지 마세요." + }, fontSize = 12.sp, color = MlSecondaryText, ) } diff --git a/app/src/test/java/com/medithings/vesiscan/managers/AnchorBasisTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/AnchorBasisTest.kt new file mode 100644 index 0000000..229477b --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/AnchorBasisTest.kt @@ -0,0 +1,57 @@ +package com.medithings.vesiscan.managers + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 부착 위치를 누가 정했는지. + * + * 정렬이 실제 인체에서 안 맞을 수 있어 임상 진행을 막지 않는다. 막지 않는 대신 **근거가 + * 기록에 남아야** 한다 — 사람이 고른 자리에서 받은 데이터가 알고리즘 성능의 근거로 + * 섞여 들어가면, 나중에 둘을 가를 방법이 없어 정확도 검증 전체가 무효가 된다. + */ +class AnchorBasisTest { + + @Test fun `STOP 만 알고리즘 확정이다`() { + assertEquals(AnchorBasis.ALGORITHM, AnchorBasis.of(AnchorAction.STOP)) + assertTrue(AnchorBasis.of(AnchorAction.STOP).isAlgorithm) + } + + @Test fun `REATTACH 로 진행하면 무시한 것으로 남는다`() { + // CH3 가 어느 위치에서도 안 잡힌 경우다. 진행은 되지만 알고리즘이 고른 자리가 + // 아니므로 성능 근거가 될 수 없다. + val b = AnchorBasis.of(AnchorAction.REATTACH) + assertEquals(AnchorBasis.REATTACH_OVERRIDE, b) + assertFalse(b.isAlgorithm) + } + + @Test fun `탐색 도중과 한 번도 안 잰 것은 모두 수동이다`() { + // 사람이 "지금 이 자리로 간다"고 결정한 것이라는 점에서 같다. + assertEquals(AnchorBasis.MANUAL, AnchorBasis.of(AnchorAction.MOVE_UP)) + assertEquals(AnchorBasis.MANUAL, AnchorBasis.of(AnchorAction.MOVE_DOWN)) + assertEquals(AnchorBasis.MANUAL, AnchorBasis.of(null)) + assertFalse(AnchorBasis.of(null).isAlgorithm) + } + + @Test fun `모든 AnchorAction 이 근거로 매핑된다`() { + // action 이 늘어났는데 매핑을 안 고치면 조용히 MANUAL 로 떨어진다. 그 자체는 + // 안전한 기본값이지만, 새 action 이 추가되면 이 테스트를 보고 판단하게 만든다. + assertEquals(4, AnchorAction.entries.size) + AnchorAction.entries.forEach { AnchorBasis.of(it) } + } + + @Test fun `기록에 쓰는 문자열은 고정이다`() { + // 분석 스크립트가 이 값으로 세션을 가른다. 바뀌면 과거 데이터와 대조가 끊긴다. + assertEquals("algorithm", AnchorBasis.ALGORITHM.wire) + assertEquals("reattach_override", AnchorBasis.REATTACH_OVERRIDE.wire) + assertEquals("manual", AnchorBasis.MANUAL.wire) + } + + @Test fun `근거마다 사람이 읽는 말이 다르다`() { + val labels = AnchorBasis.entries.map { it.label } + assertEquals(labels.size, labels.toSet().size) + labels.forEach { assertTrue(it.isNotBlank()) } + } +} diff --git a/app/src/test/java/com/medithings/vesiscan/ui/views/clinical/ProceedNoteTest.kt b/app/src/test/java/com/medithings/vesiscan/ui/views/clinical/ProceedNoteTest.kt new file mode 100644 index 0000000..4ea5ca3 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/ui/views/clinical/ProceedNoteTest.kt @@ -0,0 +1,54 @@ +package com.medithings.vesiscan.ui.views.clinical + +import com.medithings.vesiscan.managers.AnchorBasis +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 진행 버튼 아래 안내 — 버튼을 항상 열어 둔 대가다. + * + * 정렬을 끝내지 않아도 임상으로 넘어갈 수 있게 했다. 실제 인체에서 알고리즘이 끝까지 + * 안 맞을 수 있고, 그때 막으면 환자를 눕혀 둔 채 임상이 멈추기 때문이다. 막지 않는 + * 대신 **누르는 사람이 무엇을 건너뛰는지는 알아야** 한다. + */ +class ProceedNoteTest { + + @Test fun `다 맞췄으면 할 말이 없다`() { + // 알고리즘 확정 + 좌우 확인. 여기서 경고를 띄우면 늘 뜨는 글이 되어 무시당한다. + assertNull(proceedNote(AnchorBasis.ALGORITHM, lateralConfirmed = true)) + } + + @Test fun `좌우만 안 했으면 그것만 말한다`() { + val n = proceedNote(AnchorBasis.ALGORITHM, lateralConfirmed = false)!! + assertTrue(n.contains("좌우")) + assertTrue("상하는 확정됐으므로 탐색을 탓하면 안 된다", !n.contains("탐색")) + } + + @Test fun `탐색 미완료는 사람이 고른 것으로 기록된다고 말한다`() { + val n = proceedNote(AnchorBasis.MANUAL, lateralConfirmed = false)!! + assertTrue(n.contains("탐색")) + assertTrue("기록에 남는다는 사실이 핵심이다", n.contains("기록")) + assertTrue("좌우도 빠졌으면 같이 말한다", n.contains("좌우")) + } + + @Test fun `재부착 권고를 무시한 경우를 따로 말한다`() { + // CH3 가 어느 위치에서도 안 잡힌 상태다. 탐색이 덜 끝난 것과 원인이 다르므로 + // 같은 문구로 묶으면 간호사가 프로브를 다시 붙여 볼 기회를 놓친다. + val re = proceedNote(AnchorBasis.REATTACH_OVERRIDE, lateralConfirmed = true)!! + val man = proceedNote(AnchorBasis.MANUAL, lateralConfirmed = true)!! + assertTrue(re.contains("재부착")) + assertTrue(re != man) + } + + @Test fun `모든 조합에 문구가 있다 - 확정·확인 된 경우만 빼고`() { + AnchorBasis.entries.forEach { b -> + listOf(true, false).forEach { lr -> + val n = proceedNote(b, lr) + if (b == AnchorBasis.ALGORITHM && lr) assertNull(n) + else assertNotNull("$b / lr=$lr 에 할 말이 없으면 안 된다", n) + } + } + } +} diff --git a/docs/LABDB_DATATYPES.md b/docs/LABDB_DATATYPES.md index 2a2b04a..aea5c04 100644 --- a/docs/LABDB_DATATYPES.md +++ b/docs/LABDB_DATATYPES.md @@ -67,6 +67,27 @@ | `repeats_saved` | int | 실제로 저장된 반복 수 | | `source_file` | string | 원본 CSV 파일명 | | `avg`, `delay_us`, `samples` | number | 측정 파라미터(매니페스트에 있을 때만) | +| `anchor_cm` | int / null | 정렬로 정한 부착 위치(치골 위 cm) | +| `anchor_basis` | string / null | 그 위치를 **누가** 정했나 — 아래 | + +#### `anchor_basis` — 정확도 분석 전에 반드시 보셔야 하는 값 + +정렬 알고리즘은 실제 인체에서 끝까지 안 맞을 수 있습니다(CH3 가 어느 위치에서도 안 +잡히거나, 탐색 상한까지 가도 종료 조건이 안 걸림). 그때 진행을 막으면 환자를 눕혀 둔 +채 임상이 멈추므로, **간호사가 현재 위치로 그냥 진행할 수 있게** 열어 두었습니다. + +그래서 부착 위치에는 세 가지 출처가 섞여 있습니다: + +| 값 | 뜻 | 정확도 분석 | +|---|---|---| +| `algorithm` | 알고리즘이 best 를 확정 (`action == STOP`) | **모집단에 포함** | +| `reattach_override` | 알고리즘은 재부착 권고, 그대로 진행 | 제외 | +| `manual` | 탐색이 끝나기 전에 사람이 이 위치로 결정 | 제외 | +| `null` | 정렬을 거치지 않고 측정 | 제외 | + +"기존 초음파 측정기 대비 정확도" 를 낼 때 **`algorithm` 만 추려야** 합니다. 나머지를 +섞으면 사람이 고른 자리의 오차가 알고리즘의 오차로 계산됩니다. 세션 목록에서 이 값을 +필터로 걸어 주시면 그 실수를 막을 수 있습니다. ### `records[]` @@ -151,6 +172,8 @@ | `selection_verified_against_reference` | bool | 앱 판정이 Python 레퍼런스와 대조 완료인가 | | `positions` | array | 위치별 지표 — 아래 | | `lateral` | object | 좌우 정렬 결과 — 아래 | +| `anchor_basis` | string / null | 이 위치로 임상을 진행한 근거. 600 의 같은 필드와 같은 값입니다. **null = 정렬만 하고 임상으로 넘어가지 않음** | +| `proceed_anchor_cm` | int / null | 실제로 임상에 넘긴 cm. `action != STOP` 인 채 진행하면 `anchor_cm` 은 null 인데 이 값은 들어 있습니다 | > `params` 는 `align_result.json` 을 **통째로** 옮깁니다(`patient` → `subject`, > `save_name` 만 이름이 바뀝니다). 앱에서 요약에 필드를 추가하면 별도 작업 없이 @@ -174,7 +197,8 @@ | 필드 | 설명 | |---|---| -| `done` | 좌우 확인 단계를 거쳤는가. **false 는 "안 맞췄다"가 아니라 "확인 안 했다"** | +| `done` | 좌우 판정값(commit)이 있는가 | +| `confirmed` | 간호사가 "좌우 확인 완료"를 눌렀는가. **`done` 과 다른 사실입니다** — 좌우를 맞추는 중에 임상으로 넘어가면 `done=true, confirmed=false` 가 됩니다 | | `lat_tol` | 허용 오차 (8) | | `accum_k` | 판정에 쓴 프레임 수 (10) | | `action` | `STOP` / `MOVE_LEFT` / `MOVE_RIGHT` / `PROBE_LR` / `MOVE_UP` |