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:
2026-10-13 11:41:46 +09:00
parent f0f8f8b9d8
commit 64026b122d
8 changed files with 503 additions and 6 deletions
@@ -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)
}
}