From d1fd314e05a25ca2d39e122df938fa77272de1a3 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Thu, 1 Oct 2026 17:14:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(align):=20=EC=9C=84=EC=B9=98=20=EC=B9=A9(0?= =?UTF-8?q?~4cm)=EC=9C=BC=EB=A1=9C=20=ED=95=9C=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=EB=A7=8C=20=EB=8B=A4=EC=8B=9C=20=EC=9E=AC=EA=B8=B0=20=E2=80=94?= =?UTF-8?q?=20=EB=AF=B8=EB=B6=80=EC=B0=A9=20=EC=8B=A0=ED=98=B8=EA=B0=80=20?= =?UTF-8?q?=EB=9C=A8=EB=A9=B4=20=EA=B7=B8=20=EC=9E=90=EB=A6=AC=EC=97=90=20?= =?UTF-8?q?=EB=A8=B8=EB=AC=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 임상(2026-10-01): 0~2cm 재고 3cm 에서 미부착 신호가 떠서 0cm 부터 2~3번 다시 쟀다. 한 자리만 다시 잴 길이 없었다. 병원 화면의 채움 정도 칩처럼 정렬도 위치마다 따로 (다시) 잰다. · 정렬 화면에 0~4cm 위치 칩. 잰 자리 회색 ✓(재측정이면 ×n), 미부착 신호가 뜬 자리 빨강 ⚠, 판정된 best 초록 테두리, 지금 잴 자리 파랑. 아무 칩이나 눌러 그 자리로 간다. · 측정에 미부착 신호(탈착 감시)가 뜨면 자동으로 다음 위치로 넘어가지 않고 "치골 위 N cm 다시 측정 — 미부착 신호" 를 띄운다(확정보다 우선). 버튼은 "N cm 다시 측정하기 (2회째)". · 재측정 파일은 덮어쓰지 않고 `align_{n}cm_r2.csv`(+IMU) 로 따로 남긴다 — labdb 가 같은 rowIndex 를 중복으로 무시해 옛 데이터만 남는 일을 막고, 앞 측정도 분석 대상이다. 판정은 AnchorGuide 가 같은 cm 을 덮어 마지막 것을 쓴다. · 탐색 완료 기준을 "최대 cm 까지 올렸나"에서 "0~4cm 에 빈 자리가 없나"로. 확인 측정은 재기 전 상태로 판정된 best 자리를 잴 때만. 확정 뒤 다른 자리를 다시 재면 확정을 푼다. · 601: 레코드 `attempt`(1 = 첫), 위치 수는 서로 다른 cm 만. 요약에 `attempts`· `detached_positions`. 미업로드 대상 판정(첫 파일 기준)은 그대로. 시험: 미부착 지시문 · attempt 파일 이름/파싱 · 위치 수 — 전체 195건 · failures 0. Co-Authored-By: Claude Fable 5.1 --- .../vesiscan/services/HospitalRunStore.kt | 14 +- .../services/labdb/AlignLabdbPayload.kt | 18 +- .../ui/views/clinical/AnchorAlignView.kt | 184 +++++++++++++++--- .../HospitalRunStoreAlignFilesTest.kt | 13 ++ .../services/labdb/AlignLabdbPayloadTest.kt | 15 ++ .../ui/views/clinical/AlignInstructionTest.kt | 14 ++ docs/CLINICAL_ALGORITHM.md | 5 + docs/LABDB_DATATYPES.md | 4 + 8 files changed, 239 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt index b30db15..7867a5b 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt @@ -195,9 +195,10 @@ object HospitalRunStore { */ fun writeAlignImu( dir: File, cm: Int, cyclesImu: List>, kind: AlignFileKind = AlignFileKind.SWEEP, + attempt: Int = 1, ): Boolean = try { if (cyclesImu.all { it.isEmpty() }) false else { - val name = "align_%dcm%s_imu.csv".format(cm, kind.suffix) + val name = "align_%dcm%s%s_imu.csv".format(cm, kind.suffix, attemptSuffix(attempt)) File(dir, name).bufferedWriter().use { w -> w.write("cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps") w.newLine() @@ -346,6 +347,9 @@ object HospitalRunStore { return dir } + /** `_r2`, `_r3`… 같은 위치의 재측정 번호. 첫 측정은 빈 문자열. */ + fun attemptSuffix(attempt: Int): String = if (attempt > 1) "_r$attempt" else "" + fun alignDirName(posture: com.medithings.vesiscan.models.ClinicalPosture): String = "align_" + posture.name.lowercase() @@ -365,8 +369,14 @@ object HospitalRunStore { fun writeAlignCycles( dir: File, cm: Int, cycles: List>, kind: AlignFileKind = AlignFileKind.SWEEP, + /** + * 같은 위치의 몇 번째 측정인가(2026-10-01, 위치 재측정). 1 이면 접미사 없음, 2 부터 + * `_r2`. 덮어쓰지 않는다 — 미부착 신호가 떠서 다시 잰 경우 앞 측정도 분석 대상이고, + * 덮어쓰면 labdb 가 같은 rowIndex 를 중복으로 무시해 옛 데이터만 남는다. + */ + attempt: Int = 1, ): Boolean = try { - val name = "align_%dcm%s.csv".format(cm, kind.suffix) + val name = "align_%dcm%s%s.csv".format(cm, kind.suffix, attemptSuffix(attempt)) File(dir, name).bufferedWriter().use { w -> cycles.forEachIndexed { ci, chans -> chans.forEachIndexed { ch, buf -> diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt index 33d0ffb..fce51ba 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt @@ -40,6 +40,7 @@ import java.util.Locale * rowIndex : 0..N 연속 (labdb 규약) * align_cm : 그 cycle 을 잰 위치 * phase : "sweep" | "confirm" | "lateral" | "lateral_stream" | "reattach" + * attempt : 같은 위치·phase 의 몇 번째 측정인가 (1 = 첫, `_r2` 파일 = 2 …) ← 2026-10-01 * ← 2026-09-18 추가 · lateral/lateral_stream/reattach 2026-09-30 * lateral : (lateral_stream 만) {attempt, u4, u5, imbalance, action, ch3} — 그 프레임의 판정 * cycle_idx : 그 위치 안에서의 순번 @@ -86,7 +87,10 @@ object AlignLabdbPayload { * 확인 측정 파일이 하나도 안 잡혔다. */ private val NAME_RE = - Regex("""^align_(-?\d+)cm(_confirm|_lateral_stream|_lateral|_reattach)?\.csv$""", RegexOption.IGNORE_CASE) + Regex("""^align_(-?\d+)cm(_confirm|_lateral_stream|_lateral|_reattach)?(_r(\d+))?\.csv$""", RegexOption.IGNORE_CASE) + + /** `_r3` → 3. 없으면 1(첫 측정). 같은 위치를 다시 잰 것(2026-10-01)은 뒤에 붙는다. */ + private fun attemptOf(m: MatchResult): Int = m.groupValues[4].toIntOrNull() ?: 1 /** 파일명 접미사 → phase. 순서는 실제 측정 순서(탐색 → 확인 → 좌우 → 좌우 흐름 → 재부착)다. */ private fun phaseOf(suffix: String): String = when (suffix.lowercase()) { @@ -118,6 +122,7 @@ object AlignLabdbPayload { compareBy( { NAME_RE.find(it.name)!!.groupValues[1].toInt() }, { phaseRank(NAME_RE.find(it.name)!!.groupValues[2]) }, + { attemptOf(NAME_RE.find(it.name)!!) }, ) ) ?: return null @@ -135,6 +140,7 @@ object AlignLabdbPayload { // 같은 align_cm 으로 여러 벌이 올라간다 — 이 값이 없으면 받는 쪽이 구분 못 한다. // lateral 은 좌우 확인 완료 순간의 마지막 프레임 창(2026-09-30). cm 은 부착 위치. val phase = phaseOf(m.groupValues[2]) + val attempt = attemptOf(m) // 같은 위치·같은 phase 의 IMU 형제 파일. 없으면 빈 맵. val imuByCycle = readAlignImu( File(f.parentFile, f.name.removeSuffix(".csv") + "_imu.csv") @@ -194,6 +200,9 @@ object AlignLabdbPayload { put("align_cm", cm) put("phase", phase) put("cycle_idx", ci) + // 같은 위치·같은 phase 를 다시 잰 것은 attempt 로 가른다(1 = 첫 측정). + // 판정은 마지막 시도를 썼다 — 앞 시도는 미부착 신호 등으로 버린 것. + put("attempt", attempt) metaByCycle[ci]?.let { put("lateral", it) } put("sensor", LabdbSensor.of(imuByCycle[ci])) put("commandType", "MTB") @@ -214,8 +223,11 @@ object AlignLabdbPayload { put("save_name", saveName) // 2026-09-18: `files.size` 를 그대로 쓰면 확인 측정이 위치 하나로 더해져 // "5군데 쟀다"가 6이 된다. 실제로 **서로 다른 위치**를 센다. - put("positions_measured", files.count { phaseOf(NAME_RE.find(it.name)!!.groupValues[2]) == "sweep" }) - put("confirm_positions", files.count { phaseOf(NAME_RE.find(it.name)!!.groupValues[2]) == "confirm" }) + // 위치 수는 **서로 다른 cm** 을 센다 — 재측정(_r2…)은 같은 위치다. + put("positions_measured", files.filter { phaseOf(NAME_RE.find(it.name)!!.groupValues[2]) == "sweep" } + .map { NAME_RE.find(it.name)!!.groupValues[1] }.distinct().size) + put("confirm_positions", files.filter { phaseOf(NAME_RE.find(it.name)!!.groupValues[2]) == "confirm" } + .map { NAME_RE.find(it.name)!!.groupValues[1] }.distinct().size) // 좌우·재부착 프레임 수(0 = 그 단계를 안 했거나 옛 앱). 위치 수에는 안 더한다. fun countPhase(name: String) = (0 until records.length()).count { records.getJSONObject(it).optString("phase") == name diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt index 5709080..88d2e89 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt @@ -186,6 +186,16 @@ fun AnchorAlignView(appState: AppState) { // (nch·ch3·cap_frac) 자체가 의미를 잃는다. 위치를 고르기 **전에** 알아야 한다. val detachWatcher = remember { DetachWatcher() } var detachSnapshot by remember { mutableStateOf(null) } + /** + * 위치별 측정 횟수(2026-10-01, 위치 재측정). 1 = 한 번. 재측정 파일은 `_r2`, `_r3`… + * 로 따로 남고 판정은 [AnchorGuide] 가 마지막 것을 쓴다(같은 cm 은 덮어쓴다). + */ + val attempts = remember { mutableStateMapOf() } + /** + * 마지막 측정에서 미부착 신호가 뜬 위치. 임상(2026-10-01)에서 3cm 에 미부착이 떠 + * 0cm 부터 2~3번 다시 쟀다 — 그 위치만 다시 재면 된다. 다시 재서 정상이면 지운다. + */ + val detachedCms = remember { mutableStateMapOf() } // ── 좌우(LR) 정렬 ──────────────────────────────────────────────────────── // 상하(anchor)가 확정된 **뒤** 도는 단계다. AnchorGuide 의 지표(nch·ch3·cap_frac)는 @@ -323,6 +333,14 @@ fun AnchorAlignView(appState: AppState) { // 위치를 옮겨 다시 재는 것이므로 옛 확정 상태는 무효다. detachWatcher.reset(); detachSnapshot = null bv = null + // 이 측정이 무엇인지는 **재기 전** 상태로 정한다. 다 잰 뒤 판정된 자리를 재면 확인 + // 측정, 그 밖은 탐색(재측정 포함). 재측정은 같은 cm 의 몇 번째인지로 파일을 가른다. + val measuredBefore = guide.records.map { it.cm }.toSet() + val unmeasuredBefore = (0..AnchorConfig.SEARCH_MAX_CM).filter { it !in measuredBefore } + val sweepingAtStart = !sweepStopped && unmeasuredBefore.isNotEmpty() + val targetAtStart = if (sweepingAtStart) null + else AnchorSelection.selectBest(guide.records.filter { it.capFrac.isFinite() }).bestCm + val thisAttempt = (attempts[cm] ?: 0) + 1 val inbox = kotlinx.coroutines.channels.Channel< List>( @@ -402,14 +420,18 @@ fun AnchorAlignView(appState: AppState) { alignDirty = true // 판정이 끝난 뒤 같은 위치를 다시 재는 것 = 부착 위치 확인 측정. // 판정에 쓰인 파일을 덮지 않도록 별 이름으로 남긴다. - val isConfirm = !guide.searching && records.any { it.cm == cm } + val isConfirm = !sweepingAtStart && targetAtStart != null && cm == targetAtStart val kind = if (isConfirm) HospitalRunStore.AlignFileKind.CONFIRM else HospitalRunStore.AlignFileKind.SWEEP - if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles, kind)) { + attempts[cm] = thisAttempt + if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles, kind, thisAttempt)) { error = "정렬 원시 데이터 저장 실패 (${cm}cm) — 저장 공간을 확인하세요." } // IMU 는 없을 수 있다(펌웨어·설정). 없다고 오류로 만들지 않는다 — // 파형이 남았으면 측정은 성립한다. - HospitalRunStore.writeAlignImu(dir, cm, cyclesImu, kind) + HospitalRunStore.writeAlignImu(dir, cm, cyclesImu, kind, thisAttempt) + // 미부착 신호 — 이 위치는 다시 재야 한다. 자동으로 넘어가지 않고 같은 자리에 둔다. + val detachedNow = detachSnapshot?.let { it.aggregateDetached || it.detachedChannels.isNotEmpty() } == true + if (detachedNow) detachedCms[cm] = true else detachedCms.remove(cm) // 확인 측정의 IMU 평균 기울기 — Supine 이면 Sitting 요구 범위의 기준각 m 이 된다 // (2026-10-01, PostureTilt 참조). 요약에도 남겨 앱을 다시 켜도 되살린다. if (isConfirm) { @@ -445,22 +467,32 @@ fun AnchorAlignView(appState: AppState) { dir, buildAlignSummary(saveName, appState.hospitalPatient, deviceName, bleManager.firmwareVersion.value, guide.records, step, - decidedAtCm = decidedAtCm, startedAt = runStartedAt, posture = appState.clinicalPosture.label, confirmTiltDeg = confirmTiltDeg), + decidedAtCm = decidedAtCm, startedAt = runStartedAt, posture = appState.clinicalPosture.label, confirmTiltDeg = confirmTiltDeg, attempts = attempts.toMap(), detachedCms = detachedCms.filterValues { it }.keys.sorted()), ) // ── 다음 위치 ────────────────────────────────────────────────── - // **0~SEARCH_MAX_CM 를 무조건 다 잰다.** guide 의 안내(MOVE_UP/MOVE_DOWN)는 - // 여기서 보지 않는다 — 판정을 전 위치로 하므로 중간 안내가 의미가 없다. - // 다 재고 나면 판정된 위치로 내려가 확인 측정 한 번, 그 뒤 +1cm 에서 좌우. - val swept = guide.records.maxOfOrNull { it.cm } ?: cm - if (!sweepStopped && swept < AnchorConfig.SEARCH_MAX_CM) { - cm = swept + 1 + // **0~SEARCH_MAX_CM 를 다 잰다.** guide 의 안내(MOVE_UP/MOVE_DOWN)는 여기서 + // 보지 않는다 — 판정을 전 위치로 하므로 중간 안내가 의미가 없다. 순서는 안 + // 잰 위치 중 가장 아래(칩으로 다른 위치를 다시 쟀어도 빈 자리로 돌아간다). + // 다 재고 나면 판정된 위치로 가 확인 측정 한 번, 그 자리에서 좌우. + // + // 미부착 신호가 떴으면 **넘어가지 않는다** — 같은 자리를 다시 재라고 한다. + if (detachedNow) { + // 자리 유지. 확인 측정이었으면 확정도 아니다. } else { - // 수집 끝 — 전 위치로 판정한 자리로 간다. - val target = AnchorSelection.selectBest( - guide.records.filter { it.capFrac.isFinite() }).bestCm - if (target != null) { - // 방금 잰 자리가 곧 판정된 자리면 그 측정이 확인 측정이다. - if (cm == target) confirmDone = true else cm = target + val unmeasured = (0..AnchorConfig.SEARCH_MAX_CM) + .filter { c -> guide.records.none { it.cm == c } } + if (!sweepStopped && unmeasured.isNotEmpty()) { + cm = unmeasured.min() + } else { + // 수집 끝 — 전 위치로 판정한 자리로 간다. 재측정으로 판정이 바뀌었을 수 + // 있으므로 확정은 다시 판단한다. + val target = AnchorSelection.selectBest( + guide.records.filter { it.capFrac.isFinite() }).bestCm + if (target != null) { + // 방금 잰 자리가 곧 판정된 자리면 그 측정이 확인 측정이다. + if (cm == target && isConfirm) confirmDone = true + else { cm = target; if (!isConfirm) confirmDone = false } + } } } } finally { @@ -756,15 +788,18 @@ fun AnchorAlignView(appState: AppState) { val singlePassDisagrees = finalBest != null && singlePassBest != null && finalBest != singlePassBest - // 수집 단계인가 — 판정이 났어도 SEARCH_MAX_CM 까지는 계속 올린다. - val sweptTo = records.maxOfOrNull { it.cm } ?: -1 - val sweeping = !sweepStopped && sweptTo < AnchorConfig.SEARCH_MAX_CM + // 수집 단계인가 — 판정이 났어도 0~SEARCH_MAX_CM 에 빈 자리가 있으면 계속 잰다. + // (2026-10-01 까지는 "최대 cm 까지 올렸나"였다. 칩으로 중간 위치를 다시 재도 + // 빈 자리 기준이라야 흐름이 안 꼬인다.) + val sweeping = !sweepStopped && + (0..AnchorConfig.SEARCH_MAX_CM).any { c -> records.none { it.cm == c } } val instr = instructionOf( last, cm, AnchorConfig.FINAL_OFFSET_CM, sweeping = sweeping, decidedAtCm = decidedAtCm, confirmTarget = if (sweeping) null else finalBest, confirmDone = confirmDone, needReattach = needReattach && !sweeping, + detachedHere = detachedCms[cm] == true, ) Box( Modifier.fillMaxWidth() @@ -844,13 +879,33 @@ fun AnchorAlignView(appState: AppState) { Spacer(Modifier.height(10.dp)) } + // ── 위치 칩 (2026-10-01) ────────────────────────────────────── + // 병원 화면의 채움 정도 칩처럼 0~4cm 를 늘어놓는다. 잰 자리는 회색 ✓, 미부착 + // 신호가 뜬 자리는 빨강 ⚠, 지금 잴 자리는 파랑. **아무 칩이나 눌러 그 자리를 + // 다시 잴 수 있다** — 3cm 에 미부착이 떴다고 0cm 부터 다시 하지 않는다. + if (!measuring && !lrRunning) { + PositionChips( + selected = cm, + measured = records.map { it.cm }.toSet(), + detached = detachedCms.filterValues { it }.keys, + attempts = attempts, + best = if (sweeping) null else finalBest, + onSelect = { c -> + cm = c + // 확정 뒤에 다른 자리를 다시 재면 판정이 바뀔 수 있다 — 확정을 푼다. + if (confirmDone && c != finalBest) confirmDone = false + }, + ) + Spacer(Modifier.height(10.dp)) + } + // ── 동작 버튼을 파형보다 위에 둔다 ────────────────────────────── // 종전에는 BV·접촉·파형 아래에 있어 한 위치를 잴 때마다 스크롤을 내려야 // 했다. 프로브를 한 손에 잡은 채 쓰는 화면이라 그 한 번이 매번 든다 // (2026-09-10 피드백). 지시(카드) 바로 아래 누를 것을 둔다. // // 아래쪽은 **근거**다 — BV·접촉·파형은 "왜 이 값인가"를 볼 때만 내려다본다. - if (!confirmDone) { + if (!confirmDone || detachedCms[cm] == true) { Button( onClick = { measuring = true }, enabled = isConnected && !measuring, @@ -860,7 +915,13 @@ fun AnchorAlignView(appState: AppState) { // 버튼 "1cm 측정하기". 한때 버튼을 "여기서 측정"으로 두었는데 "여기"가 // 어디인지 알 수 없다는 피드백이 왔다(2026-09-10). 둘 다 숫자를 쓰면 // 서로를 확인해 준다 — 카드대로 붙였으면 버튼의 숫자가 지금 자리와 맞는다. - ) { Text("${cm}cm 측정하기", fontSize = 16.sp, fontWeight = FontWeight.Bold) } + ) { + Text( + if ((attempts[cm] ?: 0) > 0) "${cm}cm 다시 측정하기 (${(attempts[cm] ?: 0) + 1}회째)" + else "${cm}cm 측정하기", + fontSize = 16.sp, fontWeight = FontWeight.Bold, + ) + } // 수집을 중단하는 문. 두 경우를 한 버튼으로 덮는다: // · 아직 판정이 안 났다 → forceFinish 로 강제 확정 @@ -947,7 +1008,7 @@ fun AnchorAlignView(appState: AppState) { saveName, appState.hospitalPatient, deviceName, bleManager.firmwareVersion.value, guide.records, s, lrCommit, lateralConfirmed = true, - decidedAtCm = decidedAtCm, startedAt = runStartedAt, posture = appState.clinicalPosture.label, confirmTiltDeg = confirmTiltDeg, + decidedAtCm = decidedAtCm, startedAt = runStartedAt, posture = appState.clinicalPosture.label, confirmTiltDeg = confirmTiltDeg, attempts = attempts.toMap(), detachedCms = detachedCms.filterValues { it }.keys.sorted(), ), ) } @@ -996,7 +1057,7 @@ fun AnchorAlignView(appState: AppState) { saveName, appState.hospitalPatient, deviceName, bleManager.firmwareVersion.value, guide.records, s, lrCommit, lrConfirmed, basis, proceedCm, decidedAtCm, - startedAt = runStartedAt, posture = appState.clinicalPosture.label, confirmTiltDeg = confirmTiltDeg, + startedAt = runStartedAt, posture = appState.clinicalPosture.label, confirmTiltDeg = confirmTiltDeg, attempts = attempts.toMap(), detachedCms = detachedCms.filterValues { it }.keys.sorted(), confirmChannels = appState.anchorRefChannels, ), ) @@ -1041,6 +1102,7 @@ fun AnchorAlignView(appState: AppState) { guide.reset(); cm = 0; last = null; records = emptyList(); error = null lateral.reset(); lrCommit = null; lrAccum = 0; lrRecent.clear(); lrAttempt++ lrRefChannels = null; confirmTiltDeg = null + attempts.clear(); detachedCms.clear() lrConfirmed = false; lrError = null; lrRunning = false disconnectedMidRun = false decidedAtCm = null; sweepStopped = false; confirmDone = false @@ -1286,6 +1348,67 @@ private fun LateralSection( } } +/** + * 0~SEARCH_MAX cm 위치 칩(2026-10-01). 병원 화면의 채움 정도 칩과 같은 생김새 — 잰 자리는 + * 회색 ✓(재측정이면 ×n), 미부착 신호가 뜬 자리는 빨강 ⚠, 판정된 best 는 테두리, 지금 잴 + * 자리는 파랑. 아무 칩이나 눌러 그 자리로 간다. + */ +@Composable +private fun PositionChips( + selected: Int, + measured: Set, + detached: Set, + attempts: Map, + best: Int?, + onSelect: (Int) -> Unit, +) { + Text("측정 위치 — 누르면 그 자리를 (다시) 잽니다", fontSize = 12.sp, color = MlSecondaryText) + Row( + Modifier.fillMaxWidth().padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + for (c in 0..AnchorConfig.SEARCH_MAX_CM) { + val on = c == selected + val bad = c in detached + val done = c in measured + val n = attempts[c] ?: 0 + val bg = when { + on -> MlPrimary + bad -> MlCritical.copy(alpha = 0.12f) + done -> MlSecondaryText.copy(alpha = 0.16f) + else -> Color.White + } + val fg = when { + on -> Color.White + bad -> MlCritical + else -> MlSecondaryText + } + Box( + Modifier + .weight(1f) + .background(bg, RoundedCornerShape(10.dp)) + .then(if (c == best && !on) Modifier.border(1.5.dp, MlSuccess, RoundedCornerShape(10.dp)) else Modifier) + .clickable { onSelect(c) } + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("${c}cm", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = fg) + Text( + when { + bad -> "⚠ 미부착" + done && n > 1 -> "✓ ×$n" + done -> "✓" + else -> "–" + }, + fontSize = 10.sp, color = fg, + ) + } + } + } + } +} + /** * 채널마다 벽이 잡혔는지 한 줄로(2026-09-30). CH0~3 이 판정 채널, CH4·5 는 좌우용이라 * 회색으로 구분한다. 잡힌 채널은 초록, 안 잡힌 판정 채널은 빨강 — "CH2 없음"이 파형을 @@ -1413,11 +1536,20 @@ internal fun instructionOf( confirmDone: Boolean = false, /** 후보가 하나도 없어 재부착이 필요한가. */ needReattach: Boolean = false, + /** 지금 자리([nextCm])의 마지막 측정에 미부착 신호가 떴는가 — 같은 자리를 다시 잰다(2026-10-01). */ + detachedHere: Boolean = false, ): AlignInstruction = when { step == null -> AlignInstruction("치골 위 0cm 에 붙이고 측정", "젤을 바르고, 프로브를 치골 바로 위에 밀착시킵니다.") // 확인 측정까지 끝났다 — 오프셋 자리에 붙이고 좌우로. 그 자리는 재지 않는다. + // 미부착 신호 — 0cm 부터가 아니라 **이 자리만** 다시. 2026-10-01 임상에서 3cm 에 떠서 + // 처음부터 2~3번 다시 쟀다. 앞 측정은 파일로 남는다. 확정보다 먼저다. + detachedHere -> AlignInstruction( + "치골 위 ${nextCm}cm 다시 측정 — 미부착 신호", + "접촉이 끊긴 신호가 잡혔습니다. 젤을 보고 프로브를 다시 밀착한 뒤 같은 자리를 다시 재세요. " + + "지난 측정도 파일로 남습니다.") + confirmDone -> { val attach = (confirmTarget ?: step.record?.cm ?: nextCm) + offsetCm // 오프셋 0(2026-10-01~): 확인 측정한 그 자리에서 바로 좌우. 옮기라는 말을 하면 안 된다. @@ -1519,7 +1651,13 @@ private fun buildAlignSummary( posture: String? = null, /** 확인 측정의 IMU 평균 기울기(도). Supine 이면 Sitting 기준각. 앱 재시작 뒤 복구에 쓴다. */ confirmTiltDeg: Float? = null, + /** 위치별 측정 횟수(2026-10-01). 2 이상이면 그 위치에 `_r2`… 파일이 더 있다. */ + attempts: Map = emptyMap(), + /** 마지막 측정에 미부착 신호가 남아 있는 위치. 비어 있어야 정상이다. */ + detachedCms: List = emptyList(), ): JSONObject = JSONObject().apply { + if (attempts.isNotEmpty()) put("attempts", JSONObject().apply { attempts.forEach { (c, n) -> put(c.toString(), n) } }) + put("detached_positions", JSONArray(detachedCms)) posture?.let { put("posture", it) } confirmTiltDeg?.let { put("confirm_tilt_deg_mean", it.toDouble()) } startedAt?.let { put("started_at", SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(it)) } diff --git a/app/src/test/java/com/medithings/vesiscan/services/HospitalRunStoreAlignFilesTest.kt b/app/src/test/java/com/medithings/vesiscan/services/HospitalRunStoreAlignFilesTest.kt index 884c4eb..c421488 100644 --- a/app/src/test/java/com/medithings/vesiscan/services/HospitalRunStoreAlignFilesTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/services/HospitalRunStoreAlignFilesTest.kt @@ -37,6 +37,19 @@ class HospitalRunStoreAlignFilesTest { ) } + @Test fun `같은 위치를 다시 재면 _r2 로 따로 남고 첫 파일은 그대로다`() { + val d = tmp.newFolder("align") + assertTrue(HospitalRunStore.writeAlignCycles(d, 3, frames(1), attempt = 1)) + assertTrue(HospitalRunStore.writeAlignCycles(d, 3, frames(2), attempt = 2)) + assertTrue(HospitalRunStore.writeAlignImu(d, 3, listOf(listOf(sample(0.9f))), attempt = 2)) + assertEquals( + listOf("align_3cm.csv", "align_3cm_r2.csv", "align_3cm_r2_imu.csv"), + d.listFiles()!!.map { it.name }.sorted(), + ) + assertEquals(6, File(d, "align_3cm.csv").readLines().size) + assertEquals(12, File(d, "align_3cm_r2.csv").readLines().size) + } + @Test fun `좌우 프레임 창은 cycle,channel,s0… 으로 프레임마다 6줄이다`() { val d = tmp.newFolder("align") HospitalRunStore.writeAlignCycles(d, 3, frames(10), HospitalRunStore.AlignFileKind.LATERAL) diff --git a/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt b/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt index a61d9f5..c5d17cb 100644 --- a/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt @@ -202,6 +202,21 @@ class AlignLabdbPayloadTest { assertEquals(1, params.getInt("reattach_frames")) } + @Test fun `같은 위치를 다시 잰 파일은 attempt 로 가르고 위치 수에는 안 더한다`() { + // 2026-10-01: 미부착 신호로 3cm 를 다시 잼 → align_3cm.csv(첫) + align_3cm_r2.csv. + val d = tmp.newFolder("align") + writeCycles(d, 2, cycles = 1) + writeCycles(d, 3, cycles = 1) + File(d, "align_3cm_r2.csv").writeText((0 until 6).joinToString("") { ch -> "0,$ch,900,900,3500,900\n" }, Charsets.UTF_8) + val p = AlignLabdbPayload.build(d)!! + val recs = p.getJSONArray("records") + assertEquals(3, p.getInt("recordCount")) + assertEquals(listOf(2, 3, 3), (0 until 3).map { recs.getJSONObject(it).getInt("align_cm") }) + assertEquals(listOf(1, 1, 2), (0 until 3).map { recs.getJSONObject(it).getInt("attempt") }) + assertEquals("sweep", recs.getJSONObject(2).getString("phase")) + assertEquals(2, p.getJSONObject("params").getInt("positions_measured")) + } + @Test fun `좌우 프레임이 없으면 lateral_frames 는 0 이다`() { val d = tmp.newFolder("align") writeCycles(d, 0, cycles = 1) diff --git a/app/src/test/java/com/medithings/vesiscan/ui/views/clinical/AlignInstructionTest.kt b/app/src/test/java/com/medithings/vesiscan/ui/views/clinical/AlignInstructionTest.kt index 606a00b..687401b 100644 --- a/app/src/test/java/com/medithings/vesiscan/ui/views/clinical/AlignInstructionTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/ui/views/clinical/AlignInstructionTest.kt @@ -141,6 +141,20 @@ class AlignInstructionTest { assertEquals("치골 위 3cm 에 붙이세요", i.headline) } + @Test fun `미부착 신호가 뜬 자리는 그 자리만 다시 재라고 한다`() { + // 2026-10-01 임상: 3cm 에 미부착이 떠서 0cm 부터 2~3번 다시 쟀다. + val i = instructionOf(step(3), nextCm = 3, offsetCm = 0, sweeping = true, detachedHere = true) + assertEquals("치골 위 3cm 다시 측정 — 미부착 신호", i.headline) + assertTrue(i.sub!!.contains("같은 자리")) + assertFalse(i.done) + assertNoPositionWords(i) + // 미부착은 확정보다도 먼저다 — 확인 측정에서 떠도 다시 재야 한다. + val j = instructionOf(step(2, searching = false, bestCm = 2), + nextCm = 2, offsetCm = 0, confirmTarget = 2, confirmDone = true, detachedHere = true) + assertTrue(j.headline.contains("다시 측정")) + assertFalse(j.done) + } + @Test fun `오프셋이 0 이면 그 자리에서 바로 좌우로 가라고 하고 옮기라는 말을 안 한다`() { // 2026-10-01: 모든 자세에서 정렬 → best 자리에서 바로 좌우. "붙이세요"가 나오면 // 간호사가 프로브를 뗀다. diff --git a/docs/CLINICAL_ALGORITHM.md b/docs/CLINICAL_ALGORITHM.md index 16e5820..2ea4707 100644 --- a/docs/CLINICAL_ALGORITHM.md +++ b/docs/CLINICAL_ALGORITHM.md @@ -407,6 +407,11 @@ val anchorCm: Int? get() = bestCm?.let { it + offsetCm } // offsetCm = 0 (2026 [여기까지만 재고 판정하기] 로 수집을 중단할 수 있습니다 — 그 시점까지의 위치로 판정합니다. +**위치 재측정 (2026-10-01).** 화면의 0~4cm 위치 칩을 누르면 그 자리를 다시 잽니다. 측정에 +미부착 신호(탈착 감시)가 뜨면 앱이 다음 위치로 넘어가지 않고 "같은 자리 다시 측정"을 띄웁니다 +— 임상에서 3cm 에 미부착이 떠 0cm 부터 2~3번 다시 쟀던 일의 후속. 재측정은 `align_{n}cm_r2.csv` +처럼 따로 남고 판정은 마지막 것을 씁니다. + #### 판정 범위가 바뀌었습니다 (2026-09-10) | | 종전 | 현재 | diff --git a/docs/LABDB_DATATYPES.md b/docs/LABDB_DATATYPES.md index d6069a3..c56758c 100644 --- a/docs/LABDB_DATATYPES.md +++ b/docs/LABDB_DATATYPES.md @@ -335,6 +335,10 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다( 판정(앱이 화면에 보인 것). `attempt` 는 [처음부터 다시 정렬] 마다 1씩 오릅니다. - `phase: "reattach"` (2026-09-30~) 은 정렬 뒤 마킹·떼기·크래들 부착을 거친 자리에서 병원 화면이 20 cycle 잰 **재부착 확인 측정**입니다. 판정은 `params.reattach` 에. +- `attempt` (2026-10-01~) 는 같은 위치·같은 phase 의 몇 번째 측정인지입니다(1 = 첫). 미부착 + 신호 등으로 한 위치만 다시 재면 `align_3cm_r2.csv` 가 추가되고 attempt=2 로 올라갑니다. + **판정은 마지막 시도**를 썼습니다. `params.attempts` 에 위치별 횟수, `params.detached_positions` + 에 마지막 측정에서 미부착 신호가 남은 위치가 있습니다(비어 있어야 정상). ## ⚠ 없으면 `sensor` 는 빈 객체입니다 — 0 으로 채우지 마십시오