feat(clinical): 부착 위치 정렬(AnchorGuide) — 임상 프로토콜 전 단계

piezo-phantom-test `vesiscan_test` 의 단일 pass 정렬을 이식한다. 규칙은
`alignment_selection.select_supine_anchor`(tie='cap'), 절차는
`alignment_runners.AnchorGuide`, 계약은 `porting/SPEC.md` §8.

- managers/AnchorGuide.kt  알고리즘 코어(측정·종료판정·선택 위임·안내)
- ui/.../AnchorAlignView.kt 위치별 측정 화면 + 원시 데이터 저장
- HospitalRunStore        align/ 폴더에 위치별 raw cycle + 결과 요약
- 병원 모드에 정렬 카드 · 매니페스트에 anchor_cm

기존 AlignmentAdvisorV3 는 같은 저장소의 **구 알고리즘**(2-pass CenterAligner ·
bvcv tie · 오프셋 없음)이고 호출처가 0이라 그대로 두었다. Python 쪽에서도 이미
주석 처리됐다. 제품 경로는 새 파일이다.

## 검증
- 선택 규칙: Python 무작위 200 케이스 전수 대조 **200/200 일치**
  (CH3 2단 게이트 · fallback · NaN=+inf · 동률 min cm 경계 포함)
- trace 분할: 22 cycle · win 10 → 11 trace, Python 과 일치
- 실기기: 20 cycle 수집 → 판정 → 안내 → 저장까지 완주 확인

## 알려진 불일치 — 화면에도 적었다
앱의 **벽 검출**(MethodDRunner)이 현재 Python 레퍼런스와 어긋난다. 실측
(HUMAN-kai VBT26050202, v1):
  cm=0 CH2 ant 13.913 vs 18.559 · span 24~61 vs 33~60
  cm=1 CH1 ant  4.486 vs 13.188 · CH3 는 Python 검출 / 앱 미검출
  cm=1 판정  nch=4 ch3=O  vs  nch=3 ch3=X
  cm=0 cap_frac 0.63899 vs 0.67479 · BV 410.6 vs 470.5 mL
cm=1 은 레퍼런스에서 유력 후보인데 앱에서는 후보 자격조차 없다. 정렬 선택이 이
값들 위에 서 있으므로 **앱의 추천 위치는 아직 레퍼런스의 답이 아니다.**

그래서 (1) 화면에 검증 전임을 명시하고, (2) 위치별 raw cycle 을 반드시 남긴다
(align_{n}cm.csv · Python 대조 스크립트가 바로 읽는 형식). 앱 판정이 틀려도
나중에 Python 으로 다시 고를 수 있어야 그 환자를 다시 부르지 않는다.
AnchorMeasureParityTest 는 이 불일치를 표로 기록한 채 @Ignore 로 격리했다 —
기대값을 코틀린 값으로 낮추면 대조 시험의 존재 이유가 사라진다.

BV **코어** 자체는 맞다. 벽을 고정해 넣으면 Python estimate_bv
(ellipse_cap_height=true · cap_fit='specific')와 마지막 자리까지 일치한다.

## 곁가지
- gradle.properties 에서 -Dfile.encoding=UTF-8 제거. 한글 사용자 폴더에서
  테스트 워커가 클래스패스를 못 찾아(GradleWorkerMain) **단위시험이 통째로
  실행되지 않고 있었다**. 신 저장소에서 같은 원인을 이미 확인했다.
- 환자명을 AppState 로 올렸다. 정렬 화면을 다녀오면 remember 가 초기화돼,
  이름을 다시 치는 순간 "환자가 바뀌었다"로 보여 방금 맞춘 정렬이 지워졌다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 10:57:17 +09:00
parent 5a7e2cb918
commit 2126aa9361
13 changed files with 1636 additions and 3 deletions
@@ -18,6 +18,7 @@ enum class AppScreen {
CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼)
CLINICAL_LIVE, // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture
HOSPITAL_MODE, // ★ Dev 모드 한정 — 병원 임상 측정 (주파수×cycle 자동 순회)
ANCHOR_ALIGN, // ★ Dev 모드 한정 — 부착 위치 정렬 (병원 임상 전 단계)
FIRMWARE_UPDATE // ★ 2026-08-03 (cloud-mvp 672e679 이식) — VBT 프로브 firmware DFU
}
@@ -37,6 +38,23 @@ class AppState(private val context: Context) {
* 한 번 쓰면 비운다([consumeConnectReturn]). 안 비우면 다음에 다른 경로로 들어온
* 연결 화면까지 엉뚱한 곳으로 보낸다.
*/
/**
* 정렬로 정한 부착 위치(cm). 병원 임상 모드가 표시·기록에 쓴다.
*
* 자세·용량이 바뀌어도 이 위치는 **고정**이라 한 환자 동안 유지된다.
* 환자가 바뀜면 다시 정렬해야 하므로 병원 모드에서 환자명을 바꿀 때 비운다.
*/
var anchorCm: Int? by mutableStateOf(null)
/**
* 병원 임상 환자명. 화면 상태가 아니라 여기 둔다.
*
* 정렬 화면을 다녀오면 [ui.views.clinical.HospitalModeView] 가 파기되어 remember 가
* 초기화된다. 그러면 돌아와서 이름을 다시 치는 순간 "환자가 바뀌었다"로 보여
* 방금 맞춘 정렬 결과까지 지워진다. 정렬 화면도 이 이름으로 저장 폴더를 잡는다.
*/
var hospitalPatient: String by mutableStateOf("")
var connectReturnScreen: AppScreen? = null
/** 복귀 화면을 꺼내며 지운다. 없으면 null. */
@@ -178,6 +178,8 @@ fun MedilightApp(modifier: Modifier = Modifier) {
AppScreen.VOIDING_DIARY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
AppScreen.REMINDER_SETTINGS -> appState.currentScreen = AppScreen.PIEZO_MONITORING
AppScreen.FIRMWARE_UPDATE -> appState.currentScreen = AppScreen.HOME
// 정렬은 병원 임상 모드에서 들어온다 — 돌아갈 곳도 거기다.
AppScreen.ANCHOR_ALIGN -> appState.currentScreen = AppScreen.HOSPITAL_MODE
// 기타
AppScreen.SENSOR_SELECT -> appState.currentScreen = AppScreen.HOME
// 온보딩/등록 (뒤로가기 막지 않되 Home으로)
@@ -210,6 +212,7 @@ fun MedilightApp(modifier: Modifier = Modifier) {
)
AppScreen.CLINICAL_HOME -> ClinicalHomeView(appState)
AppScreen.HOSPITAL_MODE -> com.medithings.vesiscan.ui.views.clinical.HospitalModeView(appState)
AppScreen.ANCHOR_ALIGN -> com.medithings.vesiscan.ui.views.clinical.AnchorAlignView(appState)
AppScreen.CLINICAL_LIVE -> ClinicalLiveView(appState)
AppScreen.FIRMWARE_UPDATE -> com.medithings.vesiscan.firmware.ui.FirmwareUpdateScreen(
onBack = { appState.currentScreen = AppScreen.HOME }
@@ -0,0 +1,442 @@
/*
* AnchorGuide — 단일 pass 부착 위치(anchor) 정렬.
*
* Python 원본: piezophantomtest `vesiscan_test/library/alignment_runners.py:AnchorGuide`
* 선택 규칙 : `vesiscan_test/library/alignment/alignment_selection.py:select_supine_anchor`
* 상수 : `vesiscan_test/library/alignment/config_al.py`
* 계약 문서 : `vesiscan_test/porting/SPEC.md` §8
*
* ## 왜 새로 쓰나 — [AlignmentAdvisorV3] 와의 차이
* V3 는 같은 저장소의 **구 알고리즘**(`CenterAligner`)을 옮긴 것이고, Python 쪽에서
* 이미 주석 처리됐다("2-pass 라 실사용이 어렵다"). 바뀐 곳:
*
* | | V3 (구) | 이 파일 (현행) |
* |---|---|---|
* | 절차 | 2-pass (전수 스캔 후 재측정) | **단일 pass** (올라가며 결정) |
* | tie | `bv_cv` 최소 | **`cap_frac` 최소** |
* | 오프셋 | 없음 | **best + 1cm** |
* | 종료 | 없음(5위치 전수) | nch 붕괴 / ch3 소실 / 상한 |
*
* V3 는 호출처가 없어 그대로 둔다(대조용). 제품 경로는 이 파일이다.
*
* ## 절차
* 0cm 부터 1cm 씩 올리며 위치마다 [step] 을 한 번 부른다.
* · 측정 : 20 cycle → nch·ch3·cap_frac
* · 갱신 : max nch 를 **온라인** 갱신 (전역 최대를 미리 알 수 없다)
* · 종료 : 커버리지 붕괴 / ch3 소실 / 4cm 상한
* · 선택 : [AnchorSelection.selectBest] 에 위임 — 규칙을 여기서 재구현하지 않는다
* · 안내 : MOVE_UP / MOVE_DOWN / STOP / REATTACH
*
* ## 지표 산출 단위 (여기가 제일 틀리기 쉽다 — SPEC §8.2)
* nch·ch3 mean-scan 1회 검출 · **CCC off** ← 검출 커버리지를 재는 값
* ch3 검출률 **trace 별**(10-cycle 슬라이딩) · CCC off
* cap_frac mean-scan 1회 검출 · **CCC on** ← 기하를 재는 값
*
* 한쪽으로 통일하면 안 된다. 그리고 `cap_frac` 은 BV 파이프라인([estimateBv])을 부르므로
* BV 포팅이 맞아야 이 정렬이 의미를 갖는다.
*/
package com.medithings.vesiscan.managers
import com.medithings.vesiscan.walldetect.MethodDRunner
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDParams
/** [AnchorGuide.step] 이 돌려주는 다음 행동. */
enum class AnchorAction {
/** 1cm 올려 다시 측정. */
MOVE_UP,
/** best 를 지나쳤다 — 치골 방향으로 내려가 다시 측정. */
MOVE_DOWN,
/** best 도달. 여기서 `+offsetCm` 올려 부착한다. */
STOP,
/** CH3 가 잡히는 위치가 하나도 없었다 — 더 치골 쪽으로 재부착. */
REATTACH,
}
/**
* 한 위치의 지표. Python `PosRecord` / `position_records()` 레코드와 같은 값을 낸다.
*
* `nchTrace`·`nchSd` 는 **진단 표시 전용**이다. 판정에 쓰지 않는다 — ch3 검출률을 재느라
* 어차피 trace 별로 검출하므로 추가 비용 없이 얻는 값이다.
*/
data class AnchorPosRecord(
val cm: Int,
val nTrace: Int,
/** center(CH0~CH3) 검출 채널 수 — mean-scan · CCC off. */
val nch: Int,
/** mean-scan 에서 CH3 검출 여부 (= Python 레코드의 `ch3 == 'O'`). */
val ch3Mean: Boolean,
/** CH3 가 검출된 trace 비율. */
val ch3Rate: Double,
/** cap 외삽 비중 — mean-scan · CCC on. 작을수록 검출 단면이 방광을 직접 덮는다. */
val capFrac: Double,
val nchTrace: Double = Double.NaN,
val nchSd: Double = Double.NaN,
) {
/**
* 후보 자격 — **2단 게이트**다.
*
* mean-scan 에서 CH3 가 잡혔고(`ch3Mean`) **동시에** trace 검출률이 임계 이상이어야
* 한다. 율만 보면 mean-scan 에서 CH3 가 안 잡힌 위치가 통과한다
* (Python 실측 `p4_1 v1 cm2`: ch3='X' 인데 율 81%).
*/
val eligible: Boolean get() = ch3Mean && ch3Rate >= AnchorConfig.CH3_HIT_MIN
val ch3Hit: Int get() = Math.round(ch3Rate * nTrace).toInt()
}
/** 상수 — Python `alignment/config_al.py` 와 1:1. 값을 바꾸려면 그쪽부터 고칠 것. */
object AnchorConfig {
/** 후보 자격: per-trace CH3 검출률 하한. */
const val CH3_HIT_MIN = 0.80
/** 최종 안내 = best + 이 값. **선택이 아니라 안내**다(아래 [AnchorGuide.anchorCm] 참고). */
const val FINAL_OFFSET_CM = 1
/** 하강 전환 문턱. `nch < max_nch − NCH_DROP` 이면 지나친 것으로 본다. */
const val NCH_DROP = 1.0
/** 탐색 상한(cm). */
const val SEARCH_MAX_CM = 4
/** trace 슬라이딩 window(cycle). 분석 파이프라인과 동일 규약. */
const val TRACE_WIN = 10
/**
* 정렬 위치당 cycle 수 — **고정 20**.
*
* 실측(병원 모드 반복 횟수)은 화면에서 바꾸지만 정렬은 고정이다. [TRACE_WIN] 이 10 이라
* cycle 수가 곧 trace 수를 정하고(20 → 11 trace), trace 수가 줄면 `ch3Rate` 의 해상도가
* 거칠어져 0.8 임계가 불안정해진다(6 trace 면 5/6=83% 와 4/6=67% 사이에 값이 없다).
*/
const val ALIGN_CYCLES = 20
private const val CH3_INDEX = 3
/** center 채널 중 전벽·후벽이 **모두** 검출된 채널 수 (Python `_center_nch`). */
fun centerNch(walls: List<com.medithings.vesiscan.walldetect.MethodDResult?>): Int =
(0..CH3_INDEX).count { it < walls.size && walls[it] != null }
/** CH3 검출 여부 (Python `ch3_ok`). */
fun ch3Ok(walls: List<com.medithings.vesiscan.walldetect.MethodDResult?>): Boolean =
walls.size > CH3_INDEX && walls[CH3_INDEX] != null
}
/**
* cap 외삽 비중 = (top_h + bottom_h) / (측정 SI span + cap). Python `runners.cap_fraction`.
*
* 작을수록 검출 단면이 방광을 직접 덮어(외삽이 적어) 곡률이 잘 구속된다 = 신뢰도가 높다.
* 그래서 nch 동률일 때 **최소값**을 고른다.
*
* BV 를 못 내면 1.0(최악)을 돌려준다 — Python 과 같다. 후보 정렬에서 자연히 뒤로 밀린다.
*/
fun capFraction(walls: List<WallWithSpan?>): Double {
val r = estimateBv(walls) ?: return 1.0
val y = r.sortedYMm
val ySpan = if (y.size >= 2) (y.max() - y.min()) else 0.0
val top = if (r.topHMm.isFinite()) r.topHMm else 0.0
val bot = if (r.bottomHMm.isFinite()) r.bottomHMm else 0.0
val cap = top + bot
val denom = ySpan + cap
return if (denom > 0.0) cap / denom else 1.0
}
/** 선택 결과. Python `AnchorDecision`. */
data class AnchorDecision(
/** null = 유효 위치 없음(재부착). */
val bestCm: Int?,
val nCandidate: Int,
val record: AnchorPosRecord?,
)
/**
* 선택 규칙 — **단일 진실원천**. Python `select_supine_anchor`(tie='cap', offset=0).
*
* 오프셋은 여기서 다루지 않는다. Python 의 `select_supine_anchor(offset_cm=)` 은 측정된
* 위치로 스냅하는 **회고 분석 전용** 동작이고, 실시간 경로는 단순 덧셈이다(SPEC §8 ④).
*/
object AnchorSelection {
/** 조건 ①: CH3 검출 위치만. 검출률 임계 통과분 우선, 없으면 `ch3='O'` 전체 fallback. */
fun eligibleCh3(records: List<AnchorPosRecord>): List<AnchorPosRecord> {
val cand = records.filter { it.ch3Mean }
val passed = cand.filter { it.nTrace > 0 && it.ch3Rate >= AnchorConfig.CH3_HIT_MIN }
return if (passed.isNotEmpty()) passed else cand
}
/**
* ① ch3 필수 → ② max nch → ③ cap_frac 최소 (동률이면 min cm).
*
* `cap_frac` 이 유한하지 않으면 +∞ 로 취급해 맨 뒤로 보낸다(Python `_tie_val`).
*/
fun selectBest(records: List<AnchorPosRecord>): AnchorDecision {
if (records.isEmpty()) return AnchorDecision(null, 0, null)
val cand = eligibleCh3(records)
if (cand.isEmpty()) return AnchorDecision(null, 0, null)
val maxNch = cand.maxOf { it.nch }
val top = cand.filter { it.nch == maxNch }
val best = top.minWithOrNull(
compareBy(
{ if (it.capFrac.isFinite()) it.capFrac else Double.POSITIVE_INFINITY },
{ it.cm },
)
)
return AnchorDecision(best?.cm, cand.size, best)
}
}
/** [AnchorGuide.step] 반환값. */
data class AnchorStep(
val action: AnchorAction,
/** 사용자에게 보여줄 안내 문구. */
val message: String,
val record: AnchorPosRecord?,
val bestCm: Int?,
/** 최종 부착 위치 = best + offset. best 미확정이면 null. */
val anchorCm: Int?,
val maxNch: Double,
val searching: Boolean,
val stopReason: String?,
) {
val done: Boolean get() = action == AnchorAction.STOP || action == AnchorAction.REATTACH
}
/**
* 단일 pass 정렬 상태기. 위치마다 [step] 을 한 번 부른다.
*
* ```
* val g = AnchorGuide()
* for (cm in 0..4) {
* val s = g.step(cyclesAt(cm), cm)
* if (s.done) break
* }
* val s = if (!g.settled) g.finish() else null // 위치 소진 시 강제 확정
* ```
*/
class AnchorGuide(
private val offsetCm: Int = AnchorConfig.FINAL_OFFSET_CM,
private val nchDrop: Double = AnchorConfig.NCH_DROP,
private val maxCm: Int? = AnchorConfig.SEARCH_MAX_CM,
private val traceWin: Int = AnchorConfig.TRACE_WIN,
private val methodDParams: MethodDParams = MethodDParams.DEFAULT,
) {
private val recs = LinkedHashMap<Int, AnchorPosRecord>()
/** ② 온라인 갱신되는 최대 nch. 전역 최대를 미리 알 수 없다. */
var maxNch: Double = 0.0
private set
var searching: Boolean = true
private set
var bestCm: Int? = null
private set
var stopReason: String? = null
private set
val records: List<AnchorPosRecord> get() = recs.values.sortedBy { it.cm }
/** 탐색이 끝나 best 가 정해졌는지. */
val settled: Boolean get() = !searching
/**
* 최종 부착 위치 = **best + offsetCm** (단순 덧셈).
*
* 알고리즘이 하는 일은 best 를 고르는 것까지다. 오프셋은 그 뒤 사용자에게 "1cm
* 올리세요" 라고 **안내**하는 값이지, 후보 중에서 다시 고르는 선택이 아니다.
* **오프셋 위치는 측정하지 않는다** — 설계상 그 자리는 지표가 나쁘므로, 측정해서
* 문턱으로 쓰면 anchor 에 도달하기 전에 멈춘다.
*/
val anchorCm: Int? get() = bestCm?.let { it + offsetCm }
// ── 측정 ────────────────────────────────────────────────────────────────
/**
* 한 위치의 cycles → [AnchorPosRecord]. Python `AnchorGuide.measure` 와 같은 산출식.
*
* @param cycles (cycle, 6채널, sample). 정렬은 [AnchorConfig.ALIGN_CYCLES] 개가 표준.
*/
fun measure(cycles: List<List<DoubleArray>>, cm: Int): AnchorPosRecord {
val meanScan = meanOf(cycles)
// 검출 커버리지(nch·ch3)는 CCC **off**, 기하(cap_frac)는 CCC **on**.
// 같은 mean-scan 을 두 번 검출한다 — 통일하면 안 된다(SPEC §8.2-1).
val wallsBase = MethodDRunner.detectMultichannel(
meanScan, methodDParams, applyTgc = true, applyCross = false)
val wallsCross = MethodDRunner.detectMultichannel(
meanScan, methodDParams, applyTgc = true, applyCross = true)
val capFrac = capFraction(wallsCross.map { r ->
r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) }
})
// ch3 검출률은 trace 단위다. 그 김에 trace 별 nch 통계도 얻는다(진단용).
val traces = slidingTraces(cycles, traceWin)
var hit = 0
val nchs = ArrayList<Int>(traces.size)
for (tr in traces) {
val w = MethodDRunner.detectMultichannel(
tr, methodDParams, applyTgc = true, applyCross = false)
nchs.add(AnchorConfig.centerNch(w))
if (AnchorConfig.ch3Ok(w)) hit++
}
val n = traces.size
val mean = if (nchs.isNotEmpty()) nchs.average() else Double.NaN
val sd = if (nchs.size > 1)
Math.sqrt(nchs.map { (it - mean) * (it - mean) }.average()) else 0.0
return AnchorPosRecord(
cm = cm,
nTrace = n,
nch = AnchorConfig.centerNch(wallsBase),
ch3Mean = AnchorConfig.ch3Ok(wallsBase),
ch3Rate = if (n > 0) hit.toDouble() / n else 0.0,
capFrac = capFrac,
nchTrace = mean,
nchSd = sd,
)
}
// ── 진행 ────────────────────────────────────────────────────────────────
/** 한 위치 측정 → 안내. */
fun step(cycles: List<List<DoubleArray>>, cm: Int): AnchorStep {
val rec = measure(cycles, cm)
recs[cm] = rec
if (searching) {
remember(rec)
val reason = terminator(rec)
if (reason == null) {
return out(AnchorAction.MOVE_UP, "1cm 올리고 다시 측정하세요.\n${describe(rec)}", rec)
}
searching = false
stopReason = reason
bestCm = AnchorSelection.selectBest(selectableRecords()).bestCm
if (bestCm == null) {
return out(
AnchorAction.REATTACH,
"CH3 가 잡히는 위치가 없습니다. 더 치골 쪽으로 다시 부착하세요.\n($reason)",
rec,
)
}
}
return guideToBest(rec)
}
/**
* 더 올릴 위치가 없을 때 탐색을 강제 종료하고 best 를 확정한다.
*
* 하강 전환은 "지나쳐야" 발동하므로, 올라가는 도중 위치가 소진되면(기구적 상한·
* 피험자 체형) 영영 발동하지 않는다. 그때 호출부가 이걸 부른다.
*/
fun finish(): AnchorStep {
if (searching) {
searching = false
stopReason = stopReason ?: "탐색 위치 소진"
bestCm = AnchorSelection.selectBest(selectableRecords()).bestCm
}
val best = bestCm
?: return out(
AnchorAction.REATTACH,
"CH3 가 잡히는 위치가 없습니다. 더 치골 쪽으로 다시 부착하세요.", null)
val last = recs.keys.maxOrNull() ?: best
return guideToBest(recs[last])
}
fun reset() {
recs.clear(); maxNch = 0.0; searching = true; bestCm = null; stopReason = null
}
// ── 내부 ────────────────────────────────────────────────────────────────
/** ② max nch 갱신 — **후보 자격을 갖춘 위치만** 센다. */
private fun remember(rec: AnchorPosRecord) {
if (!rec.eligible) return
if (rec.nch > maxNch) maxNch = rec.nch.toDouble()
}
/** ③ 종료 사유(없으면 null). */
private fun terminator(rec: AnchorPosRecord): String? {
if (maxNch > 0 && rec.nch < maxNch - nchDrop) {
return "커버리지 붕괴 nch ${rec.nch} < 최대 ${fmtNch(maxNch)}−${fmtNch(nchDrop)}"
}
if (!rec.eligible && recs.values.any { it.eligible }) {
return "ch3 소실 ${pct(rec.ch3Rate)}<${pct(AnchorConfig.CH3_HIT_MIN)}"
}
if (maxCm != null && rec.cm >= maxCm) return "탐색 상한 ${maxCm}cm"
return null
}
/** cap_frac 이 유한한 위치만 선택 입력으로 넘긴다(Python `_records`). */
private fun selectableRecords(): List<AnchorPosRecord> =
recs.values.filter { it.capFrac.isFinite() }
private fun guideToBest(rec: AnchorPosRecord?): AnchorStep {
val best = bestCm!!
if (rec != null && rec.cm > best) {
return out(
AnchorAction.MOVE_DOWN,
"치골 방향으로 ${rec.cm - best}cm 내리세요. (최적 ${best}cm · $stopReason)",
rec,
)
}
if (rec != null && rec.cm < best) {
return out(AnchorAction.MOVE_UP,
"머리 방향으로 ${best - rec.cm}cm 올리세요. (최적 ${best}cm)", rec)
}
val b = recs[best]
return out(
AnchorAction.STOP,
"정렬 완료 — 최적 위치 ${best}cm" +
(b?.let { " (nch ${it.nch} · cap ${fmt3(it.capFrac)})" } ?: "") +
"\n여기서 ${offsetCm}cm 더 올려 ${anchorCm}cm 에 부착하세요.",
rec,
)
}
private fun out(action: AnchorAction, msg: String, rec: AnchorPosRecord?) = AnchorStep(
action = action, message = msg, record = rec, bestCm = bestCm, anchorCm = anchorCm,
maxNch = maxNch, searching = searching, stopReason = stopReason,
)
private fun describe(r: AnchorPosRecord): String =
"${r.cm}cm · 채널 ${r.nch}/4 · CH3 ${if (r.ch3Mean) "O" else "X"} ${pct(r.ch3Rate)}" +
" · cap ${fmt3(r.capFrac)}" + if (r.eligible) "" else " (CH3 미달)"
private fun meanOf(cycles: List<List<DoubleArray>>): List<DoubleArray> {
val nCh = cycles[0].size
return List(nCh) { ch ->
val len = cycles[0][ch].size
DoubleArray(len) { i ->
var s = 0.0
for (c in cycles) s += c[ch][i]
s / cycles.size
}
}
}
/**
* cycles → trace 리스트 (10-cycle 슬라이딩 평균). Python `_traces`.
*
* cycle 수가 window 이하면 전체 평균 1개다(`C-w+1 = 1`).
*/
private fun slidingTraces(
cycles: List<List<DoubleArray>>, win: Int,
): List<List<DoubleArray>> {
val n = cycles.size
val w = minOf(win, n)
return if (n <= w) listOf(meanOf(cycles))
else (0..n - w).map { s -> meanOf(cycles.subList(s, s + w)) }
}
companion object {
private fun pct(v: Double) = "${Math.round(v * 100)}%"
private fun fmt3(v: Double) = if (v.isFinite()) "%.3f".format(v) else "-"
private fun fmtNch(v: Double) =
if (v == Math.floor(v)) "%.0f".format(v) else "%.1f".format(v)
}
}
@@ -150,6 +150,55 @@ object HospitalRunStore {
}
}
// ── 정렬(anchor) ─────────────────────────────────────────────────────
//
// 위치마다 잰 raw cycle 을 그대로 남긴다.
//
// 왜 지표만이 아니라 raw 인가: 앱의 벽 검출이 Python 레퍼런스와 아직 어긋나 있어
// (2026-09-03 실측 · AnchorMeasureParityTest KDoc 참조) 앱이 고른 위치가 레퍼런스가
// 고를 위치와 다를 수 있다. raw 가 남아 있으면 나중에 Python 으로 다시 판정할 수 있고,
// 없으면 그 환자는 다시 부를 수밖에 없다.
/** 정렬 raw 는 임상 데이터와 같은 환자·날짜 폴더 밑 `align/` 에 둔다. */
fun alignDir(patient: String, startedAt: Date): File {
val dir = File(runDir(patient, startedAt), "align")
if (!dir.exists()) dir.mkdirs()
return dir
}
/**
* 한 위치의 cycle 전부를 CSV 로 저장한다.
*
* 형식은 Python 대조 스크립트가 바로 읽도록 `cycle,channel,s0..sN` 으로 맞췄다
* (`app/src/test/resources/align_cm*.csv` 와 동일).
*/
fun writeAlignCycles(
dir: File, cm: Int, cycles: List<List<DoubleArray>>,
): Boolean = try {
File(dir, "align_%dcm.csv".format(cm)).bufferedWriter().use { w ->
cycles.forEachIndexed { ci, chans ->
chans.forEachIndexed { ch, buf ->
w.write(buildString {
append(ci); append(','); append(ch)
for (v in buf) { append(','); append(v.toInt()) }
})
w.newLine()
}
}
}
true
} catch (e: Exception) {
android.util.Log.w("HospitalRunStore", "align cycles 저장 실패 cm=$cm: ${e.message}")
false
}
/** 정렬 결과 요약(위치별 지표 + 선택). 판정 근거를 남겨 두어야 나중에 대조할 수 있다. */
fun writeAlignSummary(dir: File, json: JSONObject) {
runCatching {
File(dir, "align_result.json").writeText(json.toString(2), Charsets.UTF_8)
}
}
// ── 진행 현황 ────────────────────────────────────────────────────────
//
// 간호사가 자세·방광 단계를 하나씩 올려 가며 재는데, 중간에 자리를 비우거나
@@ -0,0 +1,412 @@
/*
* 부착 위치 정렬 (anchor) — 병원 임상 프로토콜 **전** 단계.
*
* 치골 위 0cm 부터 1cm 씩 올리며 위치를 하나 고르고, 그 위치를 이후 전 자세·전 용량에서
* 고정해 재사용한다. 규칙은 [AnchorGuide] 가 소유한다 — 이 파일은 화면과 BLE 만 맡는다.
*
* ## 측정 조건은 정렬 전용으로 고정한다
* 2.3MHz · cycle 3 · 위치당 20 cycle. 병원 모드가 6조합을 순회하는 것과 달리 정렬은
* 한 설정으로만 잰다 — 검출 문턱이 절대값 기준이라 설정이 바뀌면 `nch` 가 달라지고,
* 그러면 위치끼리 비교가 성립하지 않는다.
*
* 20 cycle 은 고정이다(실측 반복 횟수만 화면에서 바꾼다). 슬라이딩 window 가 10 이라
* cycle 수가 곧 trace 수를 정하는데(20 → 11 trace), trace 가 줄면 CH3 검출률의 해상도가
* 거칠어져 0.8 임계가 불안정해진다.
*
* ## 화면 흐름
* [0cm 에서 측정] → 안내(1cm 올리세요) → [1cm 에서 측정] → ... →
* 정렬 완료: "최적 2cm · 여기서 1cm 더 올려 3cm 에 부착하세요"
*
* 오프셋 위치(+1cm)는 **측정하지 않는다**. 설계상 그 자리는 지표가 나쁘므로, 재확인하면
* 반드시 "미달"이 떠서 anchor 에 도달하기 전에 멈추게 된다.
*/
package com.medithings.vesiscan.ui.views.clinical
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.AppScreen
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.managers.AnchorAction
import com.medithings.vesiscan.managers.AnchorConfig
import com.medithings.vesiscan.managers.AnchorGuide
import com.medithings.vesiscan.managers.AnchorPosRecord
import com.medithings.vesiscan.managers.AnchorStep
import com.medithings.vesiscan.models.HospitalFixedParams
import com.medithings.vesiscan.services.HospitalRunStore
import org.json.JSONArray
import org.json.JSONObject
import java.util.Date
import com.medithings.vesiscan.models.ProbeCycle
import com.medithings.vesiscan.models.ProbeFrequency
import com.medithings.vesiscan.ui.theme.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
/** 정렬 측정 조건 — 병원 모드의 조합 순회와 무관하게 고정. */
private val ALIGN_FREQ = ProbeFrequency.F_2_3
private val ALIGN_CYCLE = ProbeCycle.C3
private const val MEASURE_TIMEOUT_MS = 3_000L
private const val CONFIG_TIMEOUT_MS = 3_000L
private const val SETTLE_MS = 800L
private const val CYCLE_GAP_MS = 600L
@Composable
fun AnchorAlignView(appState: AppState) {
val context = LocalContext.current
val bleManager = remember { BleManager.getInstance(context) }
val isConnected by bleManager.isConnected
val deviceName = bleManager.connectedDeviceName.value
val guide = remember { AnchorGuide() }
var cm by remember { mutableIntStateOf(0) }
var measuring by remember { mutableStateOf(false) }
var progress by remember { mutableIntStateOf(0) }
var last by remember { mutableStateOf<AnchorStep?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var records by remember { mutableStateOf<List<AnchorPosRecord>>(emptyList()) }
// 위치를 다 썼는데 종료 조건이 안 걸리면 호출부가 강제 확정한다(AnchorGuide.finish).
var forceFinish by remember { mutableStateOf(false) }
// 한 정렬 세션의 시작 시각 — 저장 폴더가 위치마다 갈리지 않게 고정한다.
val runStartedAt = remember { Date() }
val done = last?.done == true
LaunchedEffect(measuring, forceFinish) {
if (forceFinish) {
last = guide.finish()
records = guide.records
forceFinish = false
return@LaunchedEffect
}
if (!measuring) return@LaunchedEffect
error = null
progress = 0
val inbox = kotlinx.coroutines.channels.Channel<
List<com.medithings.vesiscan.ble.PiezoChannelData>>(
kotlinx.coroutines.channels.Channel.CONFLATED)
val prev = bleManager.piezoCollector.onMultiChannelComplete
bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) }
try {
// 정렬 파라미터를 프로브에 넣고 **echo 로 확인한 뒤** 잰다. 설정이 거부돼도
// 프로브는 옛 설정으로 계속 측정하므로, 확인하지 않으면 다른 조건의 신호로
// 위치를 고르게 된다 — 병원 모드와 같은 이유다.
bleManager.piezoConfigEcho.value = null
bleManager.sendPiezoConfig(
freq = ALIGN_FREQ.freqOption,
cycles = ALIGN_CYCLE.cycles,
avg = HospitalFixedParams.AVG,
delayUs = HospitalFixedParams.DELAY_US,
samples = HospitalFixedParams.SAMPLES,
)
val echo = withTimeoutOrNull(CONFIG_TIMEOUT_MS) {
while (bleManager.piezoConfigEcho.value == null) delay(20)
bleManager.piezoConfigEcho.value
}
val ok = echo?.matches(
ALIGN_FREQ.freqOption, ALIGN_CYCLE.cycles,
HospitalFixedParams.AVG, HospitalFixedParams.DELAY_US,
HospitalFixedParams.SAMPLES,
) == true
if (!ok) {
error = "프로브 설정 실패 — ${echo?.error ?: "응답 없음 또는 설정 불일치"}"
return@LaunchedEffect
}
delay(SETTLE_MS)
// 20 cycle 수집. 한 번이라도 놓치면 그 위치는 버린다 — trace 수가 달라지면
// 검출률 임계의 의미가 위치마다 달라져 비교가 깨진다.
val cycles = ArrayList<List<DoubleArray>>(AnchorConfig.ALIGN_CYCLES)
for (i in 1..AnchorConfig.ALIGN_CYCLES) {
while (inbox.tryReceive().isSuccess) { /* 늦게 도착한 직전 결과 비우기 */ }
bleManager.sendMtb()
val got = withTimeoutOrNull(MEASURE_TIMEOUT_MS) { inbox.receive() }
if (got == null) {
error = "측정 시간 초과 (${i}/${AnchorConfig.ALIGN_CYCLES}) — 다시 측정하세요."
return@LaunchedEffect
}
cycles.add(got.sortedBy { it.channel }.map { ch ->
DoubleArray(ch.buffer.size) { k -> ch.buffer[k].toDouble() }
})
progress = i
delay(CYCLE_GAP_MS)
}
// 판정 **전에** raw 를 남긴다. 앱의 검출이 레퍼런스와 어긋나 있는 동안에는
// 이 파일이 정답을 되살릴 유일한 수단이다 — 판정이 실패해도 데이터는 남아야 한다.
val patient = appState.hospitalPatient
if (patient.isNotBlank()) {
val dir = HospitalRunStore.alignDir(patient, runStartedAt)
if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles)) {
error = "정렬 원시 데이터 저장 실패 (${cm}cm) — 저장 공간을 확인하세요."
}
}
val step = guide.step(cycles, cm)
last = step
records = guide.records
// 결과 요약도 매 위치마다 갱신한다. 중간에 앱이 죽어도 여기까지는 남는다.
if (patient.isNotBlank()) {
HospitalRunStore.writeAlignSummary(
HospitalRunStore.alignDir(patient, runStartedAt),
buildAlignSummary(patient, deviceName, bleManager.firmwareVersion.value,
guide.records, step),
)
}
// 안내대로 다음 측정 위치를 미리 맞춰 둔다. 사용자는 프로브만 옮기면 된다.
when (step.action) {
AnchorAction.MOVE_UP -> cm += 1
AnchorAction.MOVE_DOWN -> step.bestCm?.let { cm = it }
else -> Unit
}
} finally {
bleManager.piezoCollector.onMultiChannelComplete = prev
inbox.close()
measuring = false
}
}
Column(
Modifier.fillMaxSize().background(MlBackground).verticalScroll(rememberScrollState())
) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = { if (!measuring) appState.currentScreen = AppScreen.HOSPITAL_MODE }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MlPrimary)
}
Text("부착 위치 정렬", fontSize = 18.sp, fontWeight = FontWeight.Bold)
}
Column(Modifier.padding(horizontal = 16.dp)) {
StatusChip(isConnected, deviceName)
Spacer(Modifier.height(12.dp))
Text(
"치골 위 0cm 에서 시작해 안내대로 1cm 씩 올리며 측정합니다.\n" +
"측정 조건 ${ALIGN_FREQ.label}MHz · cycle ${ALIGN_CYCLE.cycles} · " +
"위치당 ${AnchorConfig.ALIGN_CYCLES}회 (고정)",
fontSize = 13.sp, color = MlSecondaryText,
)
Spacer(Modifier.height(10.dp))
// 앱의 벽 검출이 Python 레퍼런스와 아직 어긋나 있다(2026-09-03 실측).
// 화면에 적어 두지 않으면 현장에서는 검증된 결과로 받아들이게 된다.
Box(
Modifier.fillMaxWidth()
.background(MlWarning.copy(alpha = 0.10f), RoundedCornerShape(10.dp))
.padding(10.dp)
) {
Text(
"⚠ 이 추천 위치는 아직 검증 전입니다. 앱의 벽 검출이 분석 도구와 " +
"일치하지 않는 구간이 있어, 최종 판단은 임상의 · 검사자가 하십시오. " +
"각 위치의 원시 데이터는 저장되므로 나중에 다시 판정할 수 있습니다.",
fontSize = 12.sp, color = MlWarning,
)
}
Spacer(Modifier.height(16.dp))
// 현재 위치 — 크게. 간호사가 프로브를 잡은 채 멀리서 봐야 한다.
Box(
Modifier.fillMaxWidth()
.background(Color.White, RoundedCornerShape(14.dp))
.padding(vertical = 20.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(if (done) "부착 위치" else "지금 측정할 위치",
fontSize = 13.sp, color = MlSecondaryText)
Spacer(Modifier.height(4.dp))
Text(
if (done) "${last?.anchorCm ?: "-"} cm" else "$cm cm",
fontSize = 40.sp, fontWeight = FontWeight.Bold,
color = if (done) MlSuccess else MlPrimary,
)
}
}
Spacer(Modifier.height(12.dp))
last?.let { s ->
val tone = when (s.action) {
AnchorAction.STOP -> MlSuccess
AnchorAction.REATTACH -> MlCritical
else -> MlPrimary
}
Box(
Modifier.fillMaxWidth()
.border(1.5.dp, tone, RoundedCornerShape(12.dp))
.padding(12.dp)
) { Text(s.message, fontSize = 14.sp, color = tone) }
Spacer(Modifier.height(12.dp))
}
error?.let {
Text(it, fontSize = 13.sp, color = MlCritical)
Spacer(Modifier.height(8.dp))
}
if (measuring) {
LinearProgressIndicator(
progress = { progress / AnchorConfig.ALIGN_CYCLES.toFloat() },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(6.dp))
Text("${cm}cm 측정 중 $progress/${AnchorConfig.ALIGN_CYCLES}",
fontSize = 13.sp, color = MlSecondaryText)
Spacer(Modifier.height(12.dp))
}
if (!done) {
Button(
onClick = { measuring = true },
enabled = isConnected && !measuring,
modifier = Modifier.fillMaxWidth().height(52.dp),
shape = RoundedCornerShape(14.dp),
) { Text("${cm}cm 측정", fontSize = 16.sp, fontWeight = FontWeight.Bold) }
// 탐색 상한(4cm)까지 갔는데도 종료가 안 걸릴 수 있다 — 그때 쓰는 문이다.
if (guide.records.isNotEmpty() && !guide.settled) {
Spacer(Modifier.height(8.dp))
TextButton(
onClick = { forceFinish = true },
enabled = !measuring,
modifier = Modifier.fillMaxWidth(),
) { Text("여기까지로 결정하기", color = MlSecondaryText) }
}
} else {
Button(
onClick = {
appState.anchorCm = last?.anchorCm
appState.currentScreen = AppScreen.HOSPITAL_MODE
},
enabled = last?.action == AnchorAction.STOP,
modifier = Modifier.fillMaxWidth().height(52.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = MlSuccess),
) { Text("이 위치로 임상 측정 진행", fontSize = 16.sp, fontWeight = FontWeight.Bold) }
Spacer(Modifier.height(8.dp))
TextButton(
onClick = {
guide.reset(); cm = 0; last = null; records = emptyList(); error = null
},
modifier = Modifier.fillMaxWidth(),
) { Text("처음부터 다시 정렬", color = MlSecondaryText) }
}
Spacer(Modifier.height(20.dp))
if (records.isNotEmpty()) {
Text("측정한 위치", fontSize = 13.sp, fontWeight = FontWeight.Bold,
color = MlSecondaryText)
Spacer(Modifier.height(6.dp))
records.forEach { r -> RecordRow(r, isBest = r.cm == last?.bestCm) }
}
Spacer(Modifier.height(24.dp))
}
}
}
@Composable
private fun StatusChip(connected: Boolean, name: String?) {
val bg = if (connected) MlSuccess.copy(alpha = 0.15f) else MlCritical.copy(alpha = 0.12f)
val fg = if (connected) MlSuccess else MlCritical
Box(Modifier.background(bg, RoundedCornerShape(10.dp)).padding(horizontal = 12.dp, vertical = 8.dp)) {
Text(
if (connected) "연결됨 · ${name ?: "-"}" else "기기 연결 필요",
fontSize = 13.sp, color = fg, fontWeight = FontWeight.Bold,
)
}
}
/** 한 위치의 지표. 판정 근거를 그대로 보여준다 — 나중에 왜 그 위치였는지 물어보게 된다. */
@Composable
private fun RecordRow(r: AnchorPosRecord, isBest: Boolean) {
Row(
Modifier.fillMaxWidth()
.background(
if (isBest) MlSuccess.copy(alpha = 0.12f) else Color.White,
RoundedCornerShape(8.dp),
)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("${r.cm}cm", fontSize = 14.sp, fontWeight = FontWeight.Bold,
modifier = Modifier.width(52.dp),
color = if (isBest) MlSuccess else MlSecondaryText)
Text(
"채널 ${r.nch}/4 · CH3 ${if (r.ch3Mean) "O" else "X"} " +
"${Math.round(r.ch3Rate * 100)}% · cap " +
(if (r.capFrac.isFinite()) "%.3f".format(r.capFrac) else "-") +
if (r.eligible) "" else " (CH3 미달)",
fontSize = 13.sp, color = MlSecondaryText,
)
}
Spacer(Modifier.height(4.dp))
}
/**
* 정렬 결과 요약 JSON. 위치별 지표와 선택을 **판정 근거 그대로** 남긴다.
*
* 나중에 Python 으로 다시 판정할 때 "앱은 무엇을 보고 그렇게 골랐나" 를 대조하는 데 쓴다.
*/
private fun buildAlignSummary(
patient: String,
deviceName: String?,
firmware: String?,
records: List<AnchorPosRecord>,
step: AnchorStep,
): JSONObject = JSONObject().apply {
put("patient", patient)
put("device", deviceName ?: "")
put("firmware_version", firmware ?: "")
put("hw_preset", com.medithings.vesiscan.managers.PiezoHW.activePreset.name)
put("freq_mhz", ALIGN_FREQ.label)
put("freq_option", ALIGN_FREQ.freqOption)
put("cycles_per_position", AnchorConfig.ALIGN_CYCLES)
put("probe_cycles", ALIGN_CYCLE.cycles)
put("avg", HospitalFixedParams.AVG)
put("delay_us", HospitalFixedParams.DELAY_US)
put("samples", HospitalFixedParams.SAMPLES)
put("ch3_hit_min", AnchorConfig.CH3_HIT_MIN)
put("final_offset_cm", AnchorConfig.FINAL_OFFSET_CM)
put("best_cm", step.bestCm ?: JSONObject.NULL)
put("anchor_cm", step.anchorCm ?: JSONObject.NULL)
put("action", step.action.name)
put("stop_reason", step.stopReason ?: JSONObject.NULL)
put("max_nch", step.maxNch)
// 앱 검출이 Python 레퍼런스와 불일치인 상태로 낸 값이라는 사실을 데이터와 함께 남긴다.
put("selection_verified_against_reference", false)
put("positions", JSONArray().apply {
records.forEach { r ->
put(JSONObject().apply {
put("align_cm", r.cm)
put("n_trace", r.nTrace)
put("nch", r.nch)
put("ch3", if (r.ch3Mean) "O" else "X")
put("ch3_hit", r.ch3Hit)
put("ch3_tot", r.nTrace)
put("ch3_rate", r.ch3Rate)
put("cap_frac", if (r.capFrac.isFinite()) r.capFrac else JSONObject.NULL)
put("eligible", r.eligible)
})
}
})
}
@@ -73,7 +73,8 @@ fun HospitalModeView(appState: AppState) {
val isConnected by bleManager.isConnected
val deviceName = bleManager.connectedDeviceName.value
var patient by remember { mutableStateOf("") }
// 환자명은 AppState 에 둔다 — 정렬 화면을 다녀와도 살아있어야 한다.
var patient by appState::hospitalPatient
var posture by remember { mutableStateOf(ClinicalPosture.SUPINE) }
var fill by remember { mutableStateOf(BladderFill.P0) }
var repeatsText by remember { mutableStateOf(HOSPITAL_DEFAULT_REPEATS.toString()) }
@@ -223,6 +224,9 @@ fun HospitalModeView(appState: AppState) {
put("delay_us", HospitalFixedParams.DELAY_US)
put("samples", HospitalFixedParams.SAMPLES)
put("params_persist_in_probe_nvs", true)
// 정렬로 정한 부착 위치. 자세·용량이 바뀌어도 고정이므로
// 분석 때 "이 데이터는 몇 cm 에서 받았나" 를 되살릴 수 있어야 한다.
put("anchor_cm", appState.anchorCm ?: JSONObject.NULL)
put("combinations", results)
}, startedAt)
running = false
@@ -265,7 +269,14 @@ fun HospitalModeView(appState: AppState) {
OutlinedTextField(
value = patient,
onValueChange = { if (!running) patient = it },
onValueChange = {
if (!running) {
// 환자가 바뀌면 정렬도 무효다. 앞 환자의 cm 을 그대로 들고 가면
// 매니페스트에 틀린 위치가 기록된다.
if (it != patient) appState.anchorCm = null
patient = it
}
},
label = { Text("환자명") },
singleLine = true,
enabled = !running,
@@ -273,6 +284,13 @@ fun HospitalModeView(appState: AppState) {
)
Spacer(Modifier.height(12.dp))
AnchorCard(
anchorCm = appState.anchorCm,
enabled = !running && isConnected,
onAlign = { appState.currentScreen = AppScreen.ANCHOR_ALIGN },
)
Spacer(Modifier.height(12.dp))
Label("자세")
ChipRow(ClinicalPosture.entries, posture, { posture = it }, !running,
progressOf = { postureProgress(it) }) { it.label }
@@ -480,3 +498,38 @@ private fun ProgressPanel(
}
}
}
/**
* 정렬 상태 카드 — 프로토콜을 돌기 전에 부착 위치를 정했는지 보여준다.
*
* 정렬 없이도 측정은 되게 뒀다. 급할 때 막아 세우면 결국 앱을 안 쓰게 되고, 그러면
* 위치 기록이 아예 안 남는다. 대신 안 한 상태가 눈에 띄게 해 두고, 매니페스트에는
* `anchor_cm: null` 로 정직하게 남긴다.
*/
@Composable
private fun AnchorCard(anchorCm: Int?, enabled: Boolean, onAlign: () -> Unit) {
val set = anchorCm != null
val tone = if (set) MlSuccess else MlWarning
Column(
Modifier.fillMaxWidth()
.background(tone.copy(alpha = 0.10f), RoundedCornerShape(12.dp))
.border(1.dp, tone.copy(alpha = 0.5f), RoundedCornerShape(12.dp))
.padding(12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
if (set) "부착 위치 ${anchorCm}cm" else "부착 위치 미정렬",
fontSize = 15.sp, fontWeight = FontWeight.Bold, color = tone,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onAlign, enabled = enabled) {
Text(if (set) "다시 정렬" else "정렬 시작", color = MlPrimary)
}
}
Text(
if (set) "이 환자의 전 자세·전 용량에서 이 위치를 그대로 씁니다."
else "정렬하지 않아도 측정은 되지만, 기록에 부착 위치가 남지 않습니다.",
fontSize = 12.sp, color = MlSecondaryText,
)
}
}