feat(clinical): 정렬이 안 끝나도 임상 측정으로 넘어갈 수 있게
실제 인체에서 정렬 로직이 끝까지 안 맞을 수 있다. 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 <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,16 @@ class AppState(private val context: Context) {
|
|||||||
*/
|
*/
|
||||||
var anchorCm: Int? by mutableStateOf(null)
|
var anchorCm: Int? by mutableStateOf(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [anchorCm] 을 **누가** 정했는지. null = 정렬을 거치지 않았다.
|
||||||
|
*
|
||||||
|
* 정렬은 실제 인체에서 끝까지 안 맞을 수 있어 진행을 막지 않는다. 그래서 위치만
|
||||||
|
* 들고 다니면 알고리즘이 고른 자리와 사람이 고른 자리가 한 덩어리로 쌓이고, 나중에
|
||||||
|
* 정확도 검증의 모집단을 가려낼 수 없다. **항상 [anchorCm] 과 같이 비우고 같이
|
||||||
|
* 넣는다** — 하나만 남으면 그 순간부터 둘이 어긋난다.
|
||||||
|
*/
|
||||||
|
var anchorBasis: com.medithings.vesiscan.managers.AnchorBasis? by mutableStateOf(null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 병원 임상 환자명. 화면 상태가 아니라 여기 둔다.
|
* 병원 임상 환자명. 화면 상태가 아니라 여기 둔다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ import com.medithings.vesiscan.ble.BleManager
|
|||||||
import com.medithings.vesiscan.managers.AlignAction
|
import com.medithings.vesiscan.managers.AlignAction
|
||||||
import com.medithings.vesiscan.managers.AlignmentConstants
|
import com.medithings.vesiscan.managers.AlignmentConstants
|
||||||
import com.medithings.vesiscan.managers.AnchorAction
|
import com.medithings.vesiscan.managers.AnchorAction
|
||||||
|
import com.medithings.vesiscan.managers.AnchorBasis
|
||||||
import com.medithings.vesiscan.managers.AnchorConfig
|
import com.medithings.vesiscan.managers.AnchorConfig
|
||||||
import com.medithings.vesiscan.managers.AnchorGuide
|
import com.medithings.vesiscan.managers.AnchorGuide
|
||||||
import com.medithings.vesiscan.managers.AnchorPosRecord
|
import com.medithings.vesiscan.managers.AnchorPosRecord
|
||||||
@@ -409,6 +410,7 @@ fun AnchorAlignView(appState: AppState) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
appState.anchorCm = null
|
appState.anchorCm = null
|
||||||
|
appState.anchorBasis = null
|
||||||
bleManager.disconnect()
|
bleManager.disconnect()
|
||||||
} else {
|
} else {
|
||||||
appState.connectReturnScreen = AppScreen.ANCHOR_ALIGN
|
appState.connectReturnScreen = AppScreen.ANCHOR_ALIGN
|
||||||
@@ -627,32 +629,70 @@ fun AnchorAlignView(appState: AppState) {
|
|||||||
buildAlignSummary(
|
buildAlignSummary(
|
||||||
saveName, appState.hospitalPatient, deviceName,
|
saveName, appState.hospitalPatient, deviceName,
|
||||||
bleManager.firmwareVersion.value, guide.records, s,
|
bleManager.firmwareVersion.value, guide.records, s,
|
||||||
lrCommit,
|
lrCommit, lateralConfirmed = true,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
}
|
||||||
|
|
||||||
Button(
|
Spacer(Modifier.height(16.dp))
|
||||||
onClick = {
|
|
||||||
appState.anchorCm = last?.anchorCm
|
// -- 임상 측정으로 넘어가기 ------------------------------------------
|
||||||
appState.currentScreen = AppScreen.HOSPITAL_MODE
|
// **단계와 무관하게 항상 누를 수 있다.** 정렬이 실제 인체에서 끝까지 안 맞는
|
||||||
},
|
// 경우가 있다 - CH3 가 어느 위치에서도 안 잡히거나, 상한까지 올라가도 nch 가
|
||||||
enabled = last?.action == AnchorAction.STOP && !lrRunning,
|
// 붕괴하지 않아 종료가 안 걸린다. 그때 막으면 환자를 눕혀 둔 채 임상이 멈춘다.
|
||||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
//
|
||||||
shape = RoundedCornerShape(14.dp),
|
// 대신 **근거를 같이 들고 간다**([AnchorBasis]). 사람이 고른 위치와 알고리즘이
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = MlSuccess),
|
// 확정한 위치가 기록에서 구분되지 않으면, 정확도 검증의 모집단을 나중에
|
||||||
) { Text("이 위치로 임상 측정 진행", fontSize = 16.sp, fontWeight = FontWeight.Bold) }
|
// 가려낼 수 없어 데이터 전체가 주장을 받치지 못한다.
|
||||||
// 막지는 않는다. lateral 이 끝내 안 잡히는 환자도 있는데 그때 진행을
|
val basis = AnchorBasis.of(last?.action)
|
||||||
// 못 하게 하면 임상 자체가 멈춘다 — 상태만 분명히 보여 준다.
|
// 확정됐으면 그 값(best+offset), 아니면 지금 화면의 위치. 둘 다 "프로브가
|
||||||
if (!lrConfirmed) {
|
// 지금 있는 자리"라는 뜻이라 간호사가 보는 숫자와 기록이 어긋나지 않는다.
|
||||||
Spacer(Modifier.height(6.dp))
|
val proceedCm = last?.anchorCm ?: cm
|
||||||
Text("좌우 정렬을 아직 확인하지 않았습니다.",
|
Button(
|
||||||
fontSize = 12.sp, color = MlSecondaryText)
|
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))
|
Spacer(Modifier.height(8.dp))
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -858,6 +898,25 @@ private fun RecordRow(r: AnchorPosRecord, isBest: Boolean) {
|
|||||||
Spacer(Modifier.height(4.dp))
|
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. 위치별 지표와 선택을 **판정 근거 그대로** 남긴다.
|
* 정렬 결과 요약 JSON. 위치별 지표와 선택을 **판정 근거 그대로** 남긴다.
|
||||||
*
|
*
|
||||||
@@ -874,6 +933,15 @@ private fun buildAlignSummary(
|
|||||||
step: AnchorStep,
|
step: AnchorStep,
|
||||||
/** 좌우 정렬 결과. null = 아직 돌리지 않았음(= 상하만 맞춘 세션). */
|
/** 좌우 정렬 결과. null = 아직 돌리지 않았음(= 상하만 맞춘 세션). */
|
||||||
lateral: LateralGuide.Commit? = null,
|
lateral: LateralGuide.Commit? = null,
|
||||||
|
/** 간호사가 "좌우 확인 완료"를 눌렀는가. [lateral] 이 있는 것과 다른 사실이다. */
|
||||||
|
lateralConfirmed: Boolean = false,
|
||||||
|
/**
|
||||||
|
* 이 위치로 임상을 진행한 근거. null = 이 화면에서 진행하지 않았다(측정 중 갱신본).
|
||||||
|
* 진행 버튼을 누르는 순간 덮어쓰므로, 남은 null 은 "정렬만 하고 나갔다"는 뜻이다.
|
||||||
|
*/
|
||||||
|
basis: AnchorBasis? = null,
|
||||||
|
/** 실제로 임상에 넘긴 cm. [AnchorStep.anchorCm] 이 null 인 채 진행할 수 있다. */
|
||||||
|
proceedCm: Int? = null,
|
||||||
): JSONObject = JSONObject().apply {
|
): JSONObject = JSONObject().apply {
|
||||||
put("patient", patient)
|
put("patient", patient)
|
||||||
put("save_name", saveName)
|
put("save_name", saveName)
|
||||||
@@ -902,8 +970,15 @@ private fun buildAlignSummary(
|
|||||||
// 좌우 정렬 (SPEC §8 "좌우(LR) 정렬" · LateralGuide).
|
// 좌우 정렬 (SPEC §8 "좌우(LR) 정렬" · LateralGuide).
|
||||||
// done=false 는 "안 맞췄다"가 아니라 "확인 단계를 거치지 않았다"는 뜻이다.
|
// done=false 는 "안 맞췄다"가 아니라 "확인 단계를 거치지 않았다"는 뜻이다.
|
||||||
// lateral 이 끝내 안 잡히는 환자도 있어 진행을 막지 않기 때문에 둘을 구분해야 한다.
|
// lateral 이 끝내 안 잡히는 환자도 있어 진행을 막지 않기 때문에 둘을 구분해야 한다.
|
||||||
|
// 이 위치를 누가 정했나. 정확도 검증의 모집단은 algorithm 인 세션만이다 -
|
||||||
|
// 나머지는 "진행을 막지 않아 데이터가 남았다"는 뜻이지 성능 근거가 아니다.
|
||||||
|
put("anchor_basis", basis?.wire ?: JSONObject.NULL)
|
||||||
|
put("proceed_anchor_cm", proceedCm ?: JSONObject.NULL)
|
||||||
put("lateral", JSONObject().apply {
|
put("lateral", JSONObject().apply {
|
||||||
put("done", lateral != null)
|
put("done", lateral != null)
|
||||||
|
// 사람의 확인과 값의 존재는 다른 사실이다. 좌우를 맞추는 중에 임상으로 넘어가면
|
||||||
|
// commit 은 있어도 확인은 없다 - 둘을 합치면 확인한 세션으로 잘못 읽힌다.
|
||||||
|
put("confirmed", lateralConfirmed)
|
||||||
put("lat_tol", AlignmentConstants.LAT_TOL)
|
put("lat_tol", AlignmentConstants.LAT_TOL)
|
||||||
put("accum_k", LateralGuide.ACCUM_K_DEFAULT)
|
put("accum_k", LateralGuide.ACCUM_K_DEFAULT)
|
||||||
put("action", lateral?.action?.name ?: JSONObject.NULL)
|
put("action", lateral?.action?.name ?: JSONObject.NULL)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.medithings.vesiscan.managers.AnchorBasis
|
||||||
import com.medithings.vesiscan.managers.ClinicalBv
|
import com.medithings.vesiscan.managers.ClinicalBv
|
||||||
import com.medithings.vesiscan.ui.components.ChannelDetachPanel
|
import com.medithings.vesiscan.ui.components.ChannelDetachPanel
|
||||||
import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid
|
import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid
|
||||||
@@ -315,6 +316,10 @@ fun HospitalModeView(appState: AppState) {
|
|||||||
// 정렬로 정한 부착 위치. 자세·용량이 바뀌어도 고정이므로
|
// 정렬로 정한 부착 위치. 자세·용량이 바뀌어도 고정이므로
|
||||||
// 분석 때 "이 데이터는 몇 cm 에서 받았나" 를 되살릴 수 있어야 한다.
|
// 분석 때 "이 데이터는 몇 cm 에서 받았나" 를 되살릴 수 있어야 한다.
|
||||||
put("anchor_cm", appState.anchorCm ?: JSONObject.NULL)
|
put("anchor_cm", appState.anchorCm ?: JSONObject.NULL)
|
||||||
|
// 그 위치를 누가 정했나. 정렬이 실제 인체에서 안 맞을 때 사람이 고른
|
||||||
|
// 자리로도 진행할 수 있는데, 그렇게 받은 데이터를 알고리즘 성능의 근거로
|
||||||
|
// 쓰면 안 된다 - 분석 때 모집단을 가를 유일한 단서다([AnchorBasis]).
|
||||||
|
put("anchor_basis", appState.anchorBasis?.wire ?: JSONObject.NULL)
|
||||||
put("combinations", results)
|
put("combinations", results)
|
||||||
}, startedAt)
|
}, startedAt)
|
||||||
pendingUploads = HospitalLabdbUploader.pending(dir).size
|
pendingUploads = HospitalLabdbUploader.pending(dir).size
|
||||||
@@ -361,6 +366,7 @@ fun HospitalModeView(appState: AppState) {
|
|||||||
// 서로 달라(0/-7/-14/-21 vs 0/-9/-18/-27) 같은 자리에서도
|
// 서로 달라(0/-7/-14/-21 vs 0/-9/-18/-27) 같은 자리에서도
|
||||||
// nch·cap_frac 이 달라진다 — 최적 부착 위치 자체가 다르다.
|
// nch·cap_frac 이 달라진다 — 최적 부착 위치 자체가 다르다.
|
||||||
appState.anchorCm = null
|
appState.anchorCm = null
|
||||||
|
appState.anchorBasis = null
|
||||||
bleManager.disconnect()
|
bleManager.disconnect()
|
||||||
} else {
|
} else {
|
||||||
appState.connectReturnScreen = AppScreen.HOSPITAL_MODE
|
appState.connectReturnScreen = AppScreen.HOSPITAL_MODE
|
||||||
@@ -383,7 +389,10 @@ fun HospitalModeView(appState: AppState) {
|
|||||||
if (!running) {
|
if (!running) {
|
||||||
// 환자가 바뀌면 정렬도 무효다. 앞 환자의 cm 을 그대로 들고 가면
|
// 환자가 바뀌면 정렬도 무효다. 앞 환자의 cm 을 그대로 들고 가면
|
||||||
// 매니페스트에 틀린 위치가 기록된다.
|
// 매니페스트에 틀린 위치가 기록된다.
|
||||||
if (it != patient) appState.anchorCm = null
|
if (it != patient) {
|
||||||
|
appState.anchorCm = null
|
||||||
|
appState.anchorBasis = null
|
||||||
|
}
|
||||||
patient = it
|
patient = it
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -396,6 +405,7 @@ fun HospitalModeView(appState: AppState) {
|
|||||||
|
|
||||||
AnchorCard(
|
AnchorCard(
|
||||||
anchorCm = appState.anchorCm,
|
anchorCm = appState.anchorCm,
|
||||||
|
basis = appState.anchorBasis,
|
||||||
enabled = !running && isConnected,
|
enabled = !running && isConnected,
|
||||||
onAlign = { appState.currentScreen = AppScreen.ANCHOR_ALIGN },
|
onAlign = { appState.currentScreen = AppScreen.ANCHOR_ALIGN },
|
||||||
)
|
)
|
||||||
@@ -839,9 +849,17 @@ private fun ProgressPanel(
|
|||||||
* `anchor_cm: null` 로 정직하게 남긴다.
|
* `anchor_cm: null` 로 정직하게 남긴다.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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 set = anchorCm != null
|
||||||
val tone = if (set) MlSuccess else MlWarning
|
// 알고리즘이 확정한 위치만 초록이다. 사람이 고른 위치를 같은 색으로 두면 간호사가
|
||||||
|
// "정렬 끝났다"로 읽고, 그 세션이 정확도 근거로 쓰여도 아무도 모른다.
|
||||||
|
val tone = if (set && basis?.isAlgorithm == true) MlSuccess else MlWarning
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.background(tone.copy(alpha = 0.10f), RoundedCornerShape(12.dp))
|
.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) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
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,
|
fontSize = 15.sp, fontWeight = FontWeight.Bold, color = tone,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -859,8 +881,15 @@ private fun AnchorCard(anchorCm: Int?, enabled: Boolean, onAlign: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
if (set) "이 환자의 전 자세·전 용량에서 이 위치를 그대로 씁니다."
|
when {
|
||||||
else "정렬하지 않아도 측정은 되지만, 기록에 부착 위치가 남지 않습니다.",
|
!set -> "정렬하지 않아도 측정은 되지만, 기록에 부착 위치가 남지 않습니다."
|
||||||
|
basis?.isAlgorithm == true ->
|
||||||
|
"이 환자의 전 자세·전 용량에서 이 위치를 그대로 씁니다."
|
||||||
|
// 막지 않는 대신 무엇이 빠진 상태인지는 말해 준다. 이 세션은 정확도
|
||||||
|
// 검증(기존 측정기 대조)의 모집단이 아니다.
|
||||||
|
else -> "이 위치를 그대로 쓰되, 알고리즘이 확정한 자리가 아니라는 사실이 " +
|
||||||
|
"기록에 남습니다. 정확도 비교에는 쓰지 마세요."
|
||||||
|
},
|
||||||
fontSize = 12.sp, color = MlSecondaryText,
|
fontSize = 12.sp, color = MlSecondaryText,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-1
@@ -67,6 +67,27 @@
|
|||||||
| `repeats_saved` | int | 실제로 저장된 반복 수 |
|
| `repeats_saved` | int | 실제로 저장된 반복 수 |
|
||||||
| `source_file` | string | 원본 CSV 파일명 |
|
| `source_file` | string | 원본 CSV 파일명 |
|
||||||
| `avg`, `delay_us`, `samples` | number | 측정 파라미터(매니페스트에 있을 때만) |
|
| `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[]`
|
### `records[]`
|
||||||
|
|
||||||
@@ -151,6 +172,8 @@
|
|||||||
| `selection_verified_against_reference` | bool | 앱 판정이 Python 레퍼런스와 대조 완료인가 |
|
| `selection_verified_against_reference` | bool | 앱 판정이 Python 레퍼런스와 대조 완료인가 |
|
||||||
| `positions` | array | 위치별 지표 — 아래 |
|
| `positions` | array | 위치별 지표 — 아래 |
|
||||||
| `lateral` | object | 좌우 정렬 결과 — 아래 |
|
| `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`,
|
> `params` 는 `align_result.json` 을 **통째로** 옮깁니다(`patient` → `subject`,
|
||||||
> `save_name` 만 이름이 바뀝니다). 앱에서 요약에 필드를 추가하면 별도 작업 없이
|
> `save_name` 만 이름이 바뀝니다). 앱에서 요약에 필드를 추가하면 별도 작업 없이
|
||||||
@@ -174,7 +197,8 @@
|
|||||||
|
|
||||||
| 필드 | 설명 |
|
| 필드 | 설명 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `done` | 좌우 확인 단계를 거쳤는가. **false 는 "안 맞췄다"가 아니라 "확인 안 했다"** |
|
| `done` | 좌우 판정값(commit)이 있는가 |
|
||||||
|
| `confirmed` | 간호사가 "좌우 확인 완료"를 눌렀는가. **`done` 과 다른 사실입니다** — 좌우를 맞추는 중에 임상으로 넘어가면 `done=true, confirmed=false` 가 됩니다 |
|
||||||
| `lat_tol` | 허용 오차 (8) |
|
| `lat_tol` | 허용 오차 (8) |
|
||||||
| `accum_k` | 판정에 쓴 프레임 수 (10) |
|
| `accum_k` | 판정에 쓴 프레임 수 (10) |
|
||||||
| `action` | `STOP` / `MOVE_LEFT` / `MOVE_RIGHT` / `PROBE_LR` / `MOVE_UP` |
|
| `action` | `STOP` / `MOVE_LEFT` / `MOVE_RIGHT` / `PROBE_LR` / `MOVE_UP` |
|
||||||
|
|||||||
Reference in New Issue
Block a user