feat(clinical): standing 확인 측정 — supine 부착 위치를 standing 에서 그대로 써도 되는지 두 조건으로 가른다
알고리즘 팀 스펙(ej.won, 2026-10-13 11:29). 임상 순서 supine → standing → sitting. supine 100%
뒤 자세를 Standing 으로 바꾸면 병원 화면 정렬 카드 위에 확인 카드가 뜬다. supine 부착 위치에서
정렬 조건(2.3MHz · c3 · 20 cycle)으로 한 번 재고 순서대로 본다:
① CH3 가 방광 영역을 잡는가 — 정렬 후보 자격과 같은 2단 게이트(mean-scan O + 검출률 ≥ 0.8)
② 지금 nch ≥ supine 정렬 nch − 1
둘 다 OK → standing 부착 위치를 supine 것으로 승계(정렬 건너뜀). 아니면 standing 정렬로 안내.
막지 않는다 — 안내만. sitting 은 해당 없음(ej.won: 각도 때문에 논외).
- StandingConfirm.judge(rec, supineNch, channels, bv) — 순수 판정. nch·CH3·검출률은 정렬과 같은
AnchorGuide.measure 로 낸다(비교 대상이 정렬 nch 라 다른 식이면 안 된다).
- 기준 nch: 정렬이 "임상 측정으로 넘어가기"에서 기록하는 그 자리에서 잰 nch 를 AnchorState.nch
로 같이 넘긴다(알고리즘·폴백·이대로 진행 모두 같다). 기준이 없으면 승계하지 않는다.
- 승계하면 AnchorState(cm=supine, basis=supine 것, refChannels=지금 채널, carriedFrom=SUPINE).
실패하면 위치를 비워 정렬 카드가 "미정렬"로 보이고 판정만 남긴다.
- 기록: standing 정렬 폴더 align_{cm}cm_standing_confirm.csv + IMU(시도마다 _rN), align_result.json
`standing_confirm`(+승계면 anchor_cm·carried_from), 601 세션 phase=standing_confirm
(standing_confirm_frames), run 매니페스트 anchor_nch · anchor_carried_from · anchor_standing_confirm.
정렬 요약이 통째로 덮어쓸 때 standing_confirm 은 보존한다("확인 안 돼서 정렬했다"는 맥락).
- 테스트: StandingConfirmTest 6건. 전체 235건 통과.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,12 @@ class AppState(private val context: Context) {
|
||||
val basis: com.medithings.vesiscan.managers.AnchorBasis? = null,
|
||||
val refChannels: List<Boolean>? = null,
|
||||
val reattach: com.medithings.vesiscan.managers.ReattachCheck.Result? = null,
|
||||
/** 정렬이 기록에 남긴 그 자리에서 잰 중앙 채널 수(nch) — standing 확인 측정의 기준(2026-10-13). */
|
||||
val nch: Int? = null,
|
||||
/** 이 자세의 위치가 다른 자세에서 승계된 것이면 그 자세(standing ← supine). null = 직접 정렬. */
|
||||
val carriedFrom: com.medithings.vesiscan.models.ClinicalPosture? = null,
|
||||
/** standing 확인 측정 결과([com.medithings.vesiscan.managers.StandingConfirm]). null = 아직. */
|
||||
val standingConfirm: com.medithings.vesiscan.managers.StandingConfirm.Result? = null,
|
||||
)
|
||||
|
||||
/** 자세별 부착 위치. [clinicalPosture] 가 가리키는 항목을 아래 접근자가 읽고 쓴다. */
|
||||
@@ -86,6 +92,27 @@ class AppState(private val context: Context) {
|
||||
get() = anchorNow().reattach
|
||||
set(v) = updateAnchor { it.copy(reattach = v) }
|
||||
|
||||
/** 지금 자세의 [AnchorState.nch]. */
|
||||
var anchorNch: Int?
|
||||
get() = anchorNow().nch
|
||||
set(v) = updateAnchor { it.copy(nch = v) }
|
||||
|
||||
/** 지금 자세의 [AnchorState.carriedFrom]. */
|
||||
var anchorCarriedFrom: com.medithings.vesiscan.models.ClinicalPosture?
|
||||
get() = anchorNow().carriedFrom
|
||||
set(v) = updateAnchor { it.copy(carriedFrom = v) }
|
||||
|
||||
/** 지금 자세의 [AnchorState.standingConfirm]. */
|
||||
var anchorStandingConfirm: com.medithings.vesiscan.managers.StandingConfirm.Result?
|
||||
get() = anchorNow().standingConfirm
|
||||
set(v) = updateAnchor { it.copy(standingConfirm = v) }
|
||||
|
||||
/** 다른 자세의 부착 상태 — standing 확인 측정이 supine 것을 읽는다(2026-10-13). */
|
||||
fun anchorOf(p: com.medithings.vesiscan.models.ClinicalPosture): AnchorState = anchors[p] ?: AnchorState()
|
||||
|
||||
/** 다른 자세의 부착 상태를 통째로 쓴다 — 승계(standing ← supine)에 쓴다. */
|
||||
fun setAnchor(p: com.medithings.vesiscan.models.ClinicalPosture, s: AnchorState) { anchors[p] = s }
|
||||
|
||||
/** 지금 자세의 부착 위치와 딸린 것을 비운다. */
|
||||
fun clearAnchor() { anchors.remove(clinicalPosture) }
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* StandingConfirm — supine 정렬 위치를 standing 에서 그대로 써도 되는지 (2026-10-13, ej.won).
|
||||
*
|
||||
* 임상 순서는 supine → standing → sitting 이다. supine 100% 측정이 끝나면 환자를 세우고, supine
|
||||
* 부착 위치에서 정렬 조건(2.3MHz · c3 · 20 cycle)으로 한 번 재서 두 가지를 **순서대로** 본다.
|
||||
*
|
||||
* ① CH3 가 방광 영역을 잡는가 — 정렬 후보 자격과 같은 2단 게이트
|
||||
* (mean-scan 에서 CH3 O **그리고** trace 검출률 ≥ [AnchorConfig.CH3_HIT_MIN]).
|
||||
* ② 지금 nch ≥ supine 정렬 nch − [NCH_SLACK].
|
||||
*
|
||||
* 둘 다 맞으면 OK — standing 은 supine 위치를 **승계**하고 정렬을 건너뛴다. 하나라도 아니면
|
||||
* standing 정렬을 진행하라고 안내한다(막지 않는다 — 안내만). sitting 은 해당 없다(앉은 자세는
|
||||
* 각도 때문에 CH3 가 잘 안 잡혀 논외 · ej.won).
|
||||
*
|
||||
* 기준이 되는 "supine 정렬 nch" 는 supine 정렬이 **기록에 남긴 그 자리**에서 잰 nch 다
|
||||
* ([com.medithings.vesiscan.AppState.AnchorState.nch]). 알고리즘이 고른 자리든, 폴백이든,
|
||||
* 간호사가 "이대로 진행"한 자리든 같다 — 그 자리에서 실제로 잰 값이다. 기준이 없으면
|
||||
* (옛 앱으로 정렬 · 한 자리도 안 재고 진행) 승계를 허락하지 않는다 — 기준 없이 "같다"고
|
||||
* 말하지 않는다.
|
||||
*
|
||||
* 결과는 standing 정렬 폴더의 `align_{cm}cm_standing_confirm.csv` + IMU, `align_result.json`
|
||||
* 의 `standing_confirm`, 정렬 세션(601)의 phase=standing_confirm, run 매니페스트의
|
||||
* `anchor_standing_confirm` / `anchor_carried_from` 에 남는다.
|
||||
*/
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
object StandingConfirm {
|
||||
|
||||
/** ② 의 여유. supine nch 가 3 이면 standing 에서 2 까지 OK. */
|
||||
const val NCH_SLACK = 1
|
||||
|
||||
data class Result(
|
||||
val ok: Boolean,
|
||||
/** ① CH3 2단 게이트 통과. */
|
||||
val ch3Ok: Boolean,
|
||||
val ch3Rate: Double,
|
||||
/** 지금 잰 중앙 채널 수(CH0~3, mean-scan). */
|
||||
val nch: Int,
|
||||
/** 기준 — supine 정렬 자리에서 잰 nch. null = 기준 없음(승계 불가). */
|
||||
val supineNch: Int?,
|
||||
/** ② nch ≥ supineNch − NCH_SLACK. 기준이 없으면 false. */
|
||||
val nchOk: Boolean,
|
||||
/** 지금 잡힌 채널(CH0~3). 승계 시 재부착 확인의 기준이 된다. */
|
||||
val channels: List<Boolean>,
|
||||
val bvMl: Double?,
|
||||
/** 잰 자리 = supine 부착 위치. */
|
||||
val cm: Int,
|
||||
val at: Long,
|
||||
) {
|
||||
/** 화면과 기록이 같은 말을 쓴다. */
|
||||
val label: String get() = when {
|
||||
ok -> "OK — supine 위치 ${cm}cm 그대로 측정 (CH3 ${pct(ch3Rate)} · 채널 $nch/${supineNch})"
|
||||
supineNch == null -> "supine 정렬 기준(nch)이 없어 승계 불가 — standing 정렬 진행"
|
||||
!ch3Ok -> "CH3 안 잡힘 (${pct(ch3Rate)}) — standing 정렬 진행"
|
||||
else -> "채널 ${nch}개 < supine ${supineNch}−$NCH_SLACK — standing 정렬 진행"
|
||||
}
|
||||
|
||||
fun toJson(): JSONObject = JSONObject().apply {
|
||||
put("ok", ok)
|
||||
put("ch3_ok", ch3Ok)
|
||||
put("ch3_rate", ch3Rate)
|
||||
put("nch", nch)
|
||||
put("supine_nch", supineNch ?: JSONObject.NULL)
|
||||
put("nch_ok", nchOk)
|
||||
put("nch_slack", NCH_SLACK)
|
||||
put("channels", JSONArray(channels))
|
||||
put("bv_ml", bvMl ?: JSONObject.NULL)
|
||||
put("cm", cm)
|
||||
put("at", at)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param rec standing 자세에서 supine 부착 위치를 정렬 조건으로 잰 기록([AnchorGuide.measure]).
|
||||
* @param supineNch supine 정렬이 그 자리에서 잰 nch. null 이면 승계하지 않는다.
|
||||
* @param channels 지금 잡힌 채널(CH0~3 이상이면 앞 4개만 본다).
|
||||
*/
|
||||
fun judge(
|
||||
rec: AnchorPosRecord,
|
||||
supineNch: Int?,
|
||||
channels: List<Boolean>,
|
||||
bvMl: Double?,
|
||||
at: Long = System.currentTimeMillis(),
|
||||
): Result {
|
||||
val ch3Ok = rec.eligible
|
||||
val nchOk = supineNch != null && rec.nch >= supineNch - NCH_SLACK
|
||||
return Result(
|
||||
ok = ch3Ok && nchOk,
|
||||
ch3Ok = ch3Ok,
|
||||
ch3Rate = rec.ch3Rate,
|
||||
nch = rec.nch,
|
||||
supineNch = supineNch,
|
||||
nchOk = nchOk,
|
||||
channels = channels.take(ReattachCheck.CENTER_CHANNELS),
|
||||
bvMl = bvMl,
|
||||
cm = rec.cm,
|
||||
at = at,
|
||||
)
|
||||
}
|
||||
|
||||
private fun pct(rate: Double): String = "%.0f%%".format(rate * 100)
|
||||
}
|
||||
@@ -261,6 +261,12 @@ object HospitalRunStore {
|
||||
* 20 cycle 재고 정렬 확인 측정과 채널별로 대조한 것. cm 은 부착 위치. `align_3cm_reattach.csv`
|
||||
*/
|
||||
REATTACH("_reattach"),
|
||||
/**
|
||||
* standing 확인 측정(2026-10-13). supine 100% 뒤 standing 자세에서 supine 부착 위치를
|
||||
* 20 cycle 재고 ① CH3 ② nch ≥ supine−1 을 본 것. cm 은 supine 부착 위치.
|
||||
* standing 정렬 폴더에 `align_2cm_standing_confirm.csv`.
|
||||
*/
|
||||
STANDING_CONFIRM("_standing_confirm"),
|
||||
}
|
||||
|
||||
/** `align_result.json` 을 읽어 고쳐 쓴다. 없으면 빈 객체에서 시작한다(옛 앱으로 정렬한 폴더). */
|
||||
@@ -397,7 +403,14 @@ object HospitalRunStore {
|
||||
/** 정렬 결과 요약(위치별 지표 + 선택). 판정 근거를 남겨 두어야 나중에 대조할 수 있다. */
|
||||
fun writeAlignSummary(dir: File, json: JSONObject) {
|
||||
runCatching {
|
||||
File(dir, "align_result.json").writeText(json.toString(2), Charsets.UTF_8)
|
||||
val f = File(dir, "align_result.json")
|
||||
// standing 확인 판정(2026-10-13)은 정렬 **앞**에 일어난다 — "확인이 안 돼서 정렬했다"는
|
||||
// 맥락이라 정렬 요약이 통째로 덮어쓸 때 같이 보존한다. 새 요약에 이미 있으면 그대로.
|
||||
if (!json.has("standing_confirm") && f.exists()) {
|
||||
runCatching { JSONObject(f.readText(Charsets.UTF_8)) }.getOrNull()
|
||||
?.opt("standing_confirm")?.let { json.put("standing_confirm", it) }
|
||||
}
|
||||
f.writeText(json.toString(2), Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,21 +87,26 @@ object AlignLabdbPayload {
|
||||
* 확인 측정 파일이 하나도 안 잡혔다.
|
||||
*/
|
||||
private val NAME_RE =
|
||||
Regex("""^align_(-?\d+)cm(_confirm|_lateral_stream|_lateral|_reattach)?(_r(\d+))?\.csv$""", RegexOption.IGNORE_CASE)
|
||||
Regex("""^align_(-?\d+)cm(_confirm|_lateral_stream|_lateral|_reattach|_standing_confirm)?(_r(\d+))?\.csv$""", RegexOption.IGNORE_CASE)
|
||||
|
||||
/** `_r3` → 3. 없으면 1(첫 측정). 같은 위치를 다시 잰 것(2026-10-01)은 뒤에 붙는다. */
|
||||
private fun attemptOf(m: MatchResult): Int = m.groupValues[4].toIntOrNull() ?: 1
|
||||
|
||||
/** 파일명 접미사 → phase. 순서는 실제 측정 순서(탐색 → 확인 → 좌우 → 좌우 흐름 → 재부착)다. */
|
||||
/**
|
||||
* 파일명 접미사 → phase. 순서는 실제 측정 순서(탐색 → 확인 → 좌우 → 좌우 흐름 → 재부착)다.
|
||||
* standing_confirm(2026-10-13)은 standing 정렬 폴더에서 정렬보다 **먼저** 일어나지만, 정렬 없이
|
||||
* 승계로 끝나는 세션이 대부분이라 맨 뒤에 둔다 — 받는 쪽은 phase 로 가른다.
|
||||
*/
|
||||
private fun phaseOf(suffix: String): String = when (suffix.lowercase()) {
|
||||
"" -> "sweep"
|
||||
"_confirm" -> "confirm"
|
||||
"_lateral" -> "lateral"
|
||||
"_lateral_stream" -> "lateral_stream"
|
||||
"_standing_confirm" -> "standing_confirm"
|
||||
else -> "reattach"
|
||||
}
|
||||
private fun phaseRank(suffix: String): Int = when (phaseOf(suffix)) {
|
||||
"sweep" -> 0; "confirm" -> 1; "lateral" -> 2; "lateral_stream" -> 3; else -> 4
|
||||
"sweep" -> 0; "confirm" -> 1; "lateral" -> 2; "lateral_stream" -> 3; "reattach" -> 4; else -> 5
|
||||
}
|
||||
|
||||
private val isoOut: SimpleDateFormat
|
||||
@@ -235,6 +240,7 @@ object AlignLabdbPayload {
|
||||
put("lateral_frames", countPhase("lateral"))
|
||||
put("lateral_stream_frames", countPhase("lateral_stream"))
|
||||
put("reattach_frames", countPhase("reattach"))
|
||||
put("standing_confirm_frames", countPhase("standing_confirm"))
|
||||
put("cycles_total", records.length())
|
||||
// 판정 근거를 **통째로** 넣는다. 개발자가 답해야 할 질문이 "왜 이 위치인가"라,
|
||||
// 지표(nch·ch3·cap_frac)와 선택 결과가 파형과 같이 있어야 한다.
|
||||
|
||||
@@ -1048,6 +1048,11 @@ fun AnchorAlignView(appState: AppState) {
|
||||
onClick = {
|
||||
appState.anchorCm = proceedCm
|
||||
appState.anchorBasis = basis
|
||||
// standing 확인 측정의 기준(2026-10-13): 기록에 남는 그 자리에서 잰 nch. 직접
|
||||
// 정렬한 위치이므로 승계 표시와 standing 확인 결과는 비운다.
|
||||
appState.anchorNch = guide.records.firstOrNull { it.cm == proceedCm }?.nch
|
||||
appState.anchorCarriedFrom = null
|
||||
appState.anchorStandingConfirm = null
|
||||
// 재부착 확인의 기준 — 확인 측정에서 잡힌 채널(CH0~3). 확인 측정 없이
|
||||
// 진행하면 기준 없음(null)이다. 새 위치이므로 지난 재부착 결과는 비운다.
|
||||
// 기준은 좌우 확인 창(부착 위치와 같은 높이)이 우선, 없으면 확인 측정(N cm).
|
||||
|
||||
@@ -437,6 +437,10 @@ fun HospitalModeView(appState: AppState) {
|
||||
// 재부착 확인(2026-09-30). null = 안 했다. ok=false 인 채 시작한 세션은 그
|
||||
// 채널 없이 잰 것이므로 분석에서 걸러야 한다.
|
||||
put("anchor_reattach", appState.anchorReattach?.toJson() ?: JSONObject.NULL)
|
||||
// standing 확인(2026-10-13): 이 자리의 정렬 nch, 승계 출처(supine), 확인 판정. null = 해당 없음.
|
||||
put("anchor_nch", appState.anchorNch ?: JSONObject.NULL)
|
||||
put("anchor_carried_from", appState.anchorCarriedFrom?.label?.lowercase() ?: JSONObject.NULL)
|
||||
put("anchor_standing_confirm", appState.anchorStandingConfirm?.toJson() ?: JSONObject.NULL)
|
||||
put("combinations", results)
|
||||
}, startedAt)
|
||||
// 정렬(601)은 정렬 화면을 떠나는 순간 한 번만 올리고, 조합 CSV 와 달리 뒤에
|
||||
@@ -576,12 +580,27 @@ fun HospitalModeView(appState: AppState) {
|
||||
progressOf = { postureProgress(it) }) { it.label }
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// 4-1. standing 확인 측정(2026-10-13, ej.won) — supine 100% 뒤 standing 으로 바꾸면
|
||||
// supine 부착 위치에서 한 번 재서 ① CH3 ② nch ≥ supine−1 을 본다. OK 면 standing 은
|
||||
// supine 위치를 승계하고 정렬을 건너뛴다. supine 부착 위치가 있을 때만 그린다.
|
||||
if (posture == ClinicalPosture.STANDING && appState.anchorOf(ClinicalPosture.SUPINE).cm != null) {
|
||||
StandingConfirmCard(
|
||||
appState = appState,
|
||||
bleManager = bleManager,
|
||||
patient = patient,
|
||||
connected = isConnected,
|
||||
enabled = !running,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// 5. 부착 위치 정렬 — **지금 고른 자세의** 정렬. 연결 여부로 막지 않는다
|
||||
// (2026-09-28). 정렬 화면에 [기기 연결] 이 있고 측정 버튼은 거기서 연결을 본다.
|
||||
AnchorCard(
|
||||
anchorCm = appState.anchorCm,
|
||||
basis = appState.anchorBasis,
|
||||
posture = posture,
|
||||
carriedFrom = appState.anchorCarriedFrom,
|
||||
enabled = true,
|
||||
onAlign = { appState.currentScreen = AppScreen.ANCHOR_ALIGN },
|
||||
)
|
||||
@@ -1060,6 +1079,8 @@ private fun AnchorCard(
|
||||
posture: ClinicalPosture,
|
||||
enabled: Boolean,
|
||||
onAlign: () -> Unit,
|
||||
/** 다른 자세에서 승계한 위치면 그 자세(standing ← supine, 2026-10-13). */
|
||||
carriedFrom: ClinicalPosture? = null,
|
||||
) {
|
||||
val set = anchorCm != null
|
||||
// 알고리즘이 확정한 위치만 초록이다. 사람이 고른 위치를 같은 색으로 두면 간호사가
|
||||
@@ -1077,8 +1098,10 @@ private fun AnchorCard(
|
||||
!set -> "${posture.label} 부착 위치 미정렬"
|
||||
// 정렬을 마치고 넘어온 직후다. 체크 표시가 없으면 옆의 [위치 다시
|
||||
// 찾기] 버튼이 "다시 해야 한다"는 지시로 읽힌다(2026-09-10 피드백).
|
||||
basis?.isAlgorithm == true -> "✓ ${posture.label} 부착 위치 ${anchorCm}cm 확정"
|
||||
else -> "✓ ${posture.label} 부착 위치 ${anchorCm}cm · ${basis?.label ?: "근거 불명"}"
|
||||
basis?.isAlgorithm == true -> "✓ ${posture.label} 부착 위치 ${anchorCm}cm 확정" +
|
||||
(carriedFrom?.let { " · ${it.label} 승계" } ?: "")
|
||||
else -> "✓ ${posture.label} 부착 위치 ${anchorCm}cm · ${basis?.label ?: "근거 불명"}" +
|
||||
(carriedFrom?.let { " · ${it.label} 승계" } ?: "")
|
||||
},
|
||||
fontSize = 15.sp, fontWeight = FontWeight.Bold, color = tone,
|
||||
modifier = Modifier.weight(1f),
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* StandingConfirmCard — 병원 임상 측정 화면의 **standing 확인 측정** (2026-10-13, ej.won).
|
||||
*
|
||||
* supine → standing → sitting. supine 100% 가 끝나 자세 칩을 Standing 으로 바꾸면 이 카드가
|
||||
* 정렬 카드 위에 뜬다. supine 부착 위치에서 정렬 조건(2.3MHz · c3 · 20 cycle)으로 한 번 재고
|
||||
* [StandingConfirm.judge] 로 ① CH3 → ② nch ≥ supine nch − 1 을 본다. OK 면 standing 부착
|
||||
* 위치를 supine 것으로 **승계**하고(정렬 건너뜀), 아니면 standing 정렬로 안내한다.
|
||||
*
|
||||
* 측정 루프는 재부착 확인([measureForReattach])과 같다 — 조건이 같아야 nch·CH3 가 정렬과
|
||||
* 같은 식으로 나온다. nch·CH3 판정 자체는 정렬과 똑같이 [AnchorGuide.measure] 로 낸다.
|
||||
*
|
||||
* 저장: standing 정렬 폴더에 `align_{n}cm_standing_confirm.csv` + IMU, `align_result.json`
|
||||
* 의 `standing_confirm`(+승계면 `anchor_cm`·`carried_from`), 정렬 세션(601)에
|
||||
* phase=standing_confirm 으로 올라간다. run 매니페스트에는 병원 화면이
|
||||
* `anchor_standing_confirm` / `anchor_carried_from` 으로 넣는다.
|
||||
*/
|
||||
package com.medithings.vesiscan.ui.views.clinical
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.AppState
|
||||
import com.medithings.vesiscan.ble.BleManager
|
||||
import com.medithings.vesiscan.managers.AnchorConfig
|
||||
import com.medithings.vesiscan.managers.AnchorGuide
|
||||
import com.medithings.vesiscan.managers.ReattachCheck
|
||||
import com.medithings.vesiscan.managers.StandingConfirm
|
||||
import com.medithings.vesiscan.models.ClinicalPosture
|
||||
import com.medithings.vesiscan.services.HospitalRunStore
|
||||
import com.medithings.vesiscan.services.labdb.HospitalLabdbUploader
|
||||
import com.medithings.vesiscan.ui.theme.MlCritical
|
||||
import com.medithings.vesiscan.ui.theme.MlPrimary
|
||||
import com.medithings.vesiscan.ui.theme.MlSecondaryText
|
||||
import com.medithings.vesiscan.ui.theme.MlSuccess
|
||||
import com.medithings.vesiscan.ui.theme.MlWarning
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 자세가 Standing 이고 supine 부착 위치가 있을 때만 그린다. standing 부착 위치가 이미 승계돼
|
||||
* 있으면 결과를 보여 주고 [다시 확인] 만 남긴다.
|
||||
*/
|
||||
@Composable
|
||||
fun StandingConfirmCard(
|
||||
appState: AppState,
|
||||
bleManager: BleManager,
|
||||
patient: String,
|
||||
connected: Boolean,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
val supine = appState.anchorOf(ClinicalPosture.SUPINE)
|
||||
val supineCm = supine.cm ?: return
|
||||
val supineNch = supine.nch
|
||||
val standing = appState.anchorOf(ClinicalPosture.STANDING)
|
||||
val result = standing.standingConfirm
|
||||
var running by remember { mutableStateOf(false) }
|
||||
var progress by remember { mutableIntStateOf(0) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(running) {
|
||||
if (!running) return@LaunchedEffect
|
||||
error = null
|
||||
progress = 0
|
||||
// standing 이므로 supine 분기는 끈다 — 정렬 화면이 standing 자세로 재는 것과 같다.
|
||||
val (m, err) = measureForReattach(bleManager, supine = false) { progress = it }
|
||||
if (m == null) {
|
||||
error = err
|
||||
running = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
// nch·CH3·검출률은 정렬과 같은 계산으로 — 정렬 nch 와 비교하는 값이라 다른 식이면 안 된다.
|
||||
val rec = AnchorGuide().measure(m.cycles, supineCm)
|
||||
val channels = m.outcome.walls.take(ReattachCheck.CENTER_CHANNELS).map { it != null }
|
||||
val r = StandingConfirm.judge(rec, supineNch, channels, m.outcome.volumeMl)
|
||||
if (r.ok) {
|
||||
// 승계: 위치·근거는 supine 것, 재부착 기준은 지금 잡힌 채널, nch 는 지금 잰 값.
|
||||
appState.setAnchor(
|
||||
ClinicalPosture.STANDING,
|
||||
AppState.AnchorState(
|
||||
cm = supineCm, basis = supine.basis, refChannels = channels, reattach = null,
|
||||
nch = rec.nch, carriedFrom = ClinicalPosture.SUPINE, standingConfirm = r,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
// 승계 불가 — 위치를 비워 정렬 카드가 "미정렬"로 보이게 한다. 판정은 남긴다.
|
||||
appState.setAnchor(ClinicalPosture.STANDING, AppState.AnchorState(standingConfirm = r))
|
||||
}
|
||||
// standing 정렬 폴더에 남긴다 — 정렬 세션(601)에 phase=standing_confirm 으로 올라간다.
|
||||
runCatching {
|
||||
val dir = HospitalRunStore.alignDir(patient.ifBlank { "unnamed" }, Date(), ClinicalPosture.STANDING)
|
||||
val attempt = (dir.listFiles()?.count {
|
||||
it.name.startsWith("align_${supineCm}cm_standing_confirm") && it.name.endsWith(".csv") &&
|
||||
!it.name.endsWith("_imu.csv")
|
||||
} ?: 0) + 1
|
||||
HospitalRunStore.writeAlignCycles(dir, supineCm, m.cycles, HospitalRunStore.AlignFileKind.STANDING_CONFIRM, attempt)
|
||||
HospitalRunStore.writeAlignImu(dir, supineCm, m.cyclesImu, HospitalRunStore.AlignFileKind.STANDING_CONFIRM, attempt)
|
||||
HospitalRunStore.updateAlignSummary(dir) { j ->
|
||||
if (!j.has("started_at")) {
|
||||
j.put("started_at", SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date()))
|
||||
}
|
||||
if (!j.has("patient")) j.put("patient", appState.hospitalPatient)
|
||||
if (!j.has("save_name")) j.put("save_name", patient.ifBlank { "unnamed" })
|
||||
j.put("posture", ClinicalPosture.STANDING.label)
|
||||
j.put("standing_confirm", r.toJson())
|
||||
if (r.ok) {
|
||||
j.put("anchor_cm", supineCm)
|
||||
j.put("anchor_basis", supine.basis?.wire ?: JSONObject.NULL)
|
||||
j.put("carried_from", ClinicalPosture.SUPINE.label.lowercase())
|
||||
}
|
||||
}
|
||||
HospitalLabdbUploader.uploadAlignAsync(dir)
|
||||
}
|
||||
running = false
|
||||
}
|
||||
|
||||
val tone = when {
|
||||
running -> MlPrimary
|
||||
result == null -> MlWarning
|
||||
result.ok -> MlSuccess
|
||||
else -> MlCritical
|
||||
}
|
||||
val canMeasure = connected && enabled && !running && supineNch != null
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.background(tone.copy(alpha = 0.08f), RoundedCornerShape(12.dp))
|
||||
.border(1.dp, tone.copy(alpha = 0.5f), RoundedCornerShape(12.dp))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
when {
|
||||
running -> "Standing 확인 측정 중"
|
||||
result == null -> "Standing 확인 측정 필요"
|
||||
result.ok -> "✓ Standing 확인 — ${result.label}"
|
||||
else -> "⚠ Standing 확인 — ${result.label}"
|
||||
},
|
||||
fontSize = 15.sp, fontWeight = FontWeight.Bold, color = tone,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = { running = true }, enabled = canMeasure) {
|
||||
Text(
|
||||
if (result == null) "확인 측정" else "다시 확인",
|
||||
color = if (canMeasure) MlPrimary else Color.Unspecified,
|
||||
)
|
||||
}
|
||||
}
|
||||
when {
|
||||
running -> {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { progress / AnchorConfig.ALIGN_CYCLES.toFloat() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"supine 부착 위치 ${supineCm}cm 에서 측정 중 $progress/${AnchorConfig.ALIGN_CYCLES}",
|
||||
fontSize = 12.sp, color = MlSecondaryText,
|
||||
)
|
||||
}
|
||||
result != null -> {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ConditionRow(result)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
buildString {
|
||||
result.bvMl?.let { append("BV %.0f mL · ".format(it)) }
|
||||
append(
|
||||
if (result.ok) "supine 위치를 승계했습니다. 재부착 확인 뒤 standing 측정을 시작하세요."
|
||||
else "supine 위치를 쓸 수 없습니다. 아래 [정렬 시작]으로 standing 정렬을 진행하세요.",
|
||||
)
|
||||
},
|
||||
fontSize = 12.sp, color = MlSecondaryText,
|
||||
)
|
||||
}
|
||||
supineNch == null -> {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"supine 정렬이 이 자리에서 잰 채널 수(nch)가 없어 확인 측정을 할 수 없습니다 " +
|
||||
"(옛 앱 정렬이거나 재지 않고 진행). standing 정렬을 진행하세요.",
|
||||
fontSize = 12.sp, color = MlSecondaryText,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"환자를 세운 뒤 supine 부착 위치 ${supineCm}cm 에서 ${AnchorConfig.ALIGN_CYCLES}회 재서 " +
|
||||
"① CH3 가 잡히는지 ② 채널 수가 supine(${supineNch}) − ${StandingConfirm.NCH_SLACK} 이상인지 봅니다. " +
|
||||
"둘 다 맞으면 정렬 없이 그 자리에서 standing 을 측정합니다.",
|
||||
fontSize = 12.sp, color = MlSecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
error?.let {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(it, fontSize = 12.sp, color = MlCritical)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** ① CH3 · ② nch 두 조건을 나란히 — 통과는 초록, 실패는 빨강. */
|
||||
@Composable
|
||||
private fun ConditionRow(r: StandingConfirm.Result) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
ConditionBox(
|
||||
Modifier.weight(1f), r.ch3Ok,
|
||||
"① CH3 " + (if (r.ch3Ok) "✓" else "✗"),
|
||||
"검출률 %.0f%% (기준 %.0f%%)".format(r.ch3Rate * 100, AnchorConfig.CH3_HIT_MIN * 100),
|
||||
)
|
||||
ConditionBox(
|
||||
Modifier.weight(1f), r.nchOk,
|
||||
"② 채널 ${r.nch}개 " + (if (r.nchOk) "✓" else "✗"),
|
||||
r.supineNch?.let { "기준 supine $it − ${StandingConfirm.NCH_SLACK}" } ?: "supine 기준 없음",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConditionBox(modifier: Modifier, ok: Boolean, title: String, sub: String) {
|
||||
val c = if (ok) MlSuccess else MlCritical
|
||||
Box(
|
||||
modifier
|
||||
.background(c.copy(alpha = 0.12f), RoundedCornerShape(8.dp))
|
||||
.padding(vertical = 6.dp, horizontal = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(title, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = c)
|
||||
Text(sub, fontSize = 10.sp, color = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* standing 확인 측정 판정 (2026-10-13, ej.won): ① CH3 2단 게이트 → ② nch ≥ supine nch − 1.
|
||||
*/
|
||||
class StandingConfirmTest {
|
||||
|
||||
private fun rec(nch: Int, ch3: Boolean, rate: Double, cm: Int = 2) = AnchorPosRecord(
|
||||
cm = cm, nTrace = 11, nch = nch, ch3Mean = ch3, ch3Rate = rate, capFrac = 0.5,
|
||||
)
|
||||
private val four = listOf(true, true, true, true)
|
||||
|
||||
@Test
|
||||
fun `both conditions pass - carry supine position`() {
|
||||
val r = StandingConfirm.judge(rec(3, ch3 = true, rate = 0.91), supineNch = 4, channels = four, bvMl = 210.0)
|
||||
assertTrue(r.ok); assertTrue(r.ch3Ok); assertTrue(r.nchOk)
|
||||
assertEquals(2, r.cm)
|
||||
assertTrue(r.label.startsWith("OK"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nch may drop by exactly one but not two`() {
|
||||
assertTrue(StandingConfirm.judge(rec(2, true, 1.0), 3, four, null).ok)
|
||||
assertFalse(StandingConfirm.judge(rec(1, true, 1.0), 3, four, null).ok)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CH3 gate is checked first - mean-scan O but rate below 80 percent fails`() {
|
||||
val r = StandingConfirm.judge(rec(4, ch3 = true, rate = 0.72), supineNch = 4, channels = four, bvMl = null)
|
||||
assertFalse(r.ok); assertFalse(r.ch3Ok); assertTrue(r.nchOk)
|
||||
assertTrue(r.label.contains("CH3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CH3 missing in mean-scan fails even with a high trace rate`() {
|
||||
assertFalse(StandingConfirm.judge(rec(3, ch3 = false, rate = 0.85), 3, four, null).ch3Ok)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no supine reference means no carry-over`() {
|
||||
val r = StandingConfirm.judge(rec(4, true, 1.0), supineNch = null, channels = four, bvMl = null)
|
||||
assertFalse(r.ok); assertFalse(r.nchOk)
|
||||
assertTrue(r.label.contains("기준"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `json carries the verdict and both inputs`() {
|
||||
val j = StandingConfirm.judge(rec(2, true, 0.9), 3, listOf(true, false, true, true, true, true), 180.5).toJson()
|
||||
assertEquals(true, j.getBoolean("ok"))
|
||||
assertEquals(2, j.getInt("nch"))
|
||||
assertEquals(3, j.getInt("supine_nch"))
|
||||
assertEquals(1, j.getInt("nch_slack"))
|
||||
assertEquals(4, j.getJSONArray("channels").length()) // CH0~3 만
|
||||
assertEquals(180.5, j.getDouble("bv_ml"), 1e-9)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user