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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user