diff --git a/README.md b/README.md index e26ec9e..9130502 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ VesiScan **병원 임상 측정 앱** (Android · Kotlin · Compose). 정해진 ## 화면 흐름 (병원 임상) 연결 → labdb 등록 확인 → 환자명 · 자세 → **그 자세의** 정렬(치골 위 0~4cm 탐색 → N cm 확인 → -N+1cm 좌우) → 마킹·떼기·크래들 → **재부착 확인** → 채움 정도마다 측정 → 다음 자세는 정렬부터 다시. 자세한 것은 +N cm 에서 바로 좌우) → 마킹·떼기·크래들 → **재부착 확인** → 채움 정도마다 측정 → 다음 자세는 정렬부터 다시. 자세한 것은 [`docs/CLINICAL_ALGORITHM.md`](docs/CLINICAL_ALGORITHM.md) §3.6, labdb 적재 모양은 [`docs/LABDB_DATATYPES.md`](docs/LABDB_DATATYPES.md) (600 측정 · 601 정렬). diff --git a/app/src/main/java/com/medithings/vesiscan/managers/AnchorGuide.kt b/app/src/main/java/com/medithings/vesiscan/managers/AnchorGuide.kt index d47c956..9592633 100644 --- a/app/src/main/java/com/medithings/vesiscan/managers/AnchorGuide.kt +++ b/app/src/main/java/com/medithings/vesiscan/managers/AnchorGuide.kt @@ -92,8 +92,15 @@ object AnchorConfig { /** 후보 자격: per-trace CH3 검출률 하한. */ const val CH3_HIT_MIN = 0.80 - /** 최종 안내 = best + 이 값. **선택이 아니라 안내**다(아래 [AnchorGuide.anchorCm] 참고). */ - const val FINAL_OFFSET_CM = 1 + /** + * 최종 안내 = best + 이 값. **선택이 아니라 안내**다(아래 [AnchorGuide.anchorCm] 참고). + * + * **0 (2026-10-01~).** 이번 임상부터 모든 자세에서 정렬을 하기로 하면서(e.yu · kai.han) + * "best + 1cm 올리고 거기서 좌우" 를 빼고 **best 자리에서 바로 좌우 밸런스**를 맞춘다. + * 그 전까지는 1 이었다 — 2026-10-01 이전 세션의 `anchor_cm` 은 `best_cm + 1` 이고, + * Python `select_supine_anchor(offset_cm=)` 로 대조할 때는 0 을 넘겨야 같다. + */ + const val FINAL_OFFSET_CM = 0 /** 하강 전환 문턱. `nch < max_nch − NCH_DROP` 이면 지나친 것으로 본다. */ const val NCH_DROP = 1.0 @@ -398,7 +405,8 @@ class AnchorGuide( AnchorAction.STOP, "정렬 완료 — 최적 위치 ${best}cm" + (b?.let { " (nch ${it.nch} · cap ${fmt3(it.capFrac)})" } ?: "") + - "\n여기서 ${offsetCm}cm 더 올려 ${anchorCm}cm 에 부착하세요.", + (if (offsetCm == 0) "\n이 자리(${anchorCm}cm)에 그대로 부착합니다." + else "\n여기서 ${offsetCm}cm 더 올려 ${anchorCm}cm 에 부착하세요."), rec, ) } 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 09c9e05..5709080 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 @@ -27,10 +27,12 @@ * ... * 치골 위 1cm 에 다시 붙이고 측정 [1cm 측정하기] (확인 측정) * 치골 위 1cm 에서 한 번 더 측정 [1cm 측정하기] (이미 그 자리) - * **치골 위 2cm 에 붙이세요 (재지 말고 좌우로)** — 버튼 없음 + * **치골 위 1cm 그대로 — 좌우 맞추기** — 버튼 없음 (오프셋 0, 2026-10-01~) * * 방향(올리세요/내리세요·화살표)은 어디에도 쓰지 않는다 — 치골 위 N cm 하나면 된다. * + * 2026-10-01 부터 오프셋이 0 이다 — 확인 측정한 best 자리에서 바로 좌우를 맞추고 그대로 + * 붙인다(모든 자세에서 정렬하기로 한 임상 결정). 아래 문단은 오프셋이 1 이던 때의 설명이다: * 마지막 +1cm 자리는 **측정하지 않는다**. 설계상 그 자리는 지표가 나쁘므로, 재확인하면 * 반드시 "미달"이 떠서 도달 전에 멈추게 된다. */ @@ -209,8 +211,9 @@ fun AnchorAlignView(appState: AppState) { var lrAttempt by remember { mutableIntStateOf(1) } /** * 좌우 확인 완료 순간의 창(부착 위치 N+1cm)에서 잡힌 채널 CH0~3 — 재부착 확인의 기준. - * 확인 측정(N cm)이 아니라 **같은 높이**의 마지막 프레임으로 잡는다. 오프셋 자리는 설계상 - * 지표가 다른 쪽이라 N cm 기준으로 대조하면 멀쩡한 재부착도 "채널 없음"이 될 수 있다. + * 확인 측정(N cm)이 아니라 **좌우를 맞춘 높이**의 마지막 프레임으로 잡는다 — 떼기 직전의 + * 신호다. (오프셋이 1 이던 때는 높이가 달라 N cm 기준으로 대조하면 멀쩡한 재부착도 + * "채널 없음"이 될 수 있었다. 지금은 오프셋 0 이라 같은 높이지만 "떼기 직전"이 더 맞다.) */ var lrRefChannels by remember { mutableStateOf?>(null) } /** 확인 측정(N cm)의 IMU 평균 기울기. Supine 이면 Sitting 기준각이 된다(2026-10-01). */ @@ -1184,10 +1187,10 @@ private fun LateralSection( } Spacer(Modifier.height(10.dp)) - // 1단계가 끝난 시점에 프로브는 **최적 위치**에 있고, 부착할 자리는 그보다 1cm - // 위다(오프셋 자리는 측정하지 않는다). 좌우 균형은 그 높이의 단면에서 정해지는 - // 값이라 높이를 바꾸면 u4·u5 가 달라진다 — 옮기기 **전에** 맞추면 헛일이 된다. - // 화면이 이걸 말해 주지 않으면 간호사마다 순서가 갈린다. + // 1단계가 끝난 시점에 프로브는 **최적 위치**에 있고, 부착할 자리는 거기서 + // FINAL_OFFSET_CM 만큼 위다 — 2026-10-01 부터 0 이라 같은 자리다. 좌우 균형은 그 + // 높이의 단면에서 정해지는 값이라 높이를 바꾸면 u4·u5 가 달라진다 — 부착할 높이에서 + // 맞춰야 한다. 화면이 이걸 말해 주지 않으면 간호사마다 순서가 갈린다. Box( Modifier.fillMaxWidth() .background(MlPrimary.copy(alpha = 0.10f), RoundedCornerShape(10.dp)) @@ -1384,7 +1387,7 @@ private fun RecordRow( * | 첫 측정 전 | 치골 위 0cm 에 붙이고 측정 | * | 탐색 중 | 치골 위 N cm 에 붙이고 측정 | * | 판정된 자리로 | 치골 위 N cm 에 다시 붙이고 측정 (이미 그 자리면 "에서 한 번 더 측정") | - * | 확정 | 치골 위 N+오프셋 cm 에 붙이세요 — **재지 말고** 좌우로 | + * | 확정 | 오프셋 0: 치골 위 N cm 그대로 — 좌우 맞추기 / 오프셋>0: 치골 위 N+오프셋 cm 에 붙이세요 | * | 재부착 | 더 아래(치골 쪽)에 다시 붙이기 — 갈 자리가 없어 이것만 상대적이다 | * * @param nextCm 화면이 다음에 재려는 위치(치골 위 cm). 호출부가 판정 뒤 이미 갱신해 둔다. @@ -1417,7 +1420,11 @@ internal fun instructionOf( // 확인 측정까지 끝났다 — 오프셋 자리에 붙이고 좌우로. 그 자리는 재지 않는다. confirmDone -> { val attach = (confirmTarget ?: step.record?.cm ?: nextCm) + offsetCm - AlignInstruction("치골 위 ${attach}cm 에 붙이세요", + // 오프셋 0(2026-10-01~): 확인 측정한 그 자리에서 바로 좌우. 옮기라는 말을 하면 안 된다. + if (offsetCm == 0) AlignInstruction("치골 위 ${attach}cm 그대로 — 좌우 맞추기", + "이 자리의 측정은 끝났습니다. 프로브를 옮기지 말고 아래 좌우 맞추기로 넘어갑니다.", + done = true) + else AlignInstruction("치골 위 ${attach}cm 에 붙이세요", "여기서는 측정하지 않습니다. 붙인 뒤 좌우 맞추기로 넘어갑니다.", done = true) } 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 67fb450..606a00b 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,19 @@ class AlignInstructionTest { assertEquals("치골 위 3cm 에 붙이세요", i.headline) } + @Test fun `오프셋이 0 이면 그 자리에서 바로 좌우로 가라고 하고 옮기라는 말을 안 한다`() { + // 2026-10-01: 모든 자세에서 정렬 → best 자리에서 바로 좌우. "붙이세요"가 나오면 + // 간호사가 프로브를 뗀다. + val i = instructionOf(step(2, searching = false, bestCm = 2), + nextCm = 2, offsetCm = 0, confirmTarget = 2, confirmDone = true) + assertEquals("치골 위 2cm 그대로 — 좌우 맞추기", i.headline) + assertFalse(i.headline.contains("붙이세요")) + assertTrue(i.sub!!.contains("옮기지 말고")) + assertTrue(i.sub!!.contains("좌우")) + assertTrue(i.done) + assertNoPositionWords(i) + } + @Test fun `오프셋이 바뀌면 문구도 따라간다`() { // FINAL_OFFSET_CM 을 2 로 바꾸면 화면도 2cm 이라고 말해야 한다. 박아 두면 거짓이 된다. val i = instructionOf(step(1, searching = false, bestCm = 1), diff --git a/docs/CLINICAL_ALGORITHM.md b/docs/CLINICAL_ALGORITHM.md index 58d4b39..16e5820 100644 --- a/docs/CLINICAL_ALGORITHM.md +++ b/docs/CLINICAL_ALGORITHM.md @@ -359,12 +359,17 @@ eligible = ch3Mean && ch3Rate >= 0.80 입력은 **`cap_frac` 이 유한한 위치만**입니다 (`selectableRecords()`). -### 3.5 부착 위치 = 최적 + 1cm, 그 자리는 재지 않습니다 +### 3.5 부착 위치 = 최적 (오프셋 0 · 2026-10-01~) ```kotlin -val anchorCm: Int? get() = bestCm?.let { it + offsetCm } // offsetCm = 1 +val anchorCm: Int? get() = bestCm?.let { it + offsetCm } // offsetCm = 0 (2026-10-01 전에는 1) ``` +**2026-10-01 부터 오프셋이 0 입니다.** 이번 임상부터 모든 자세에서 정렬을 하기로 하면서 +(e.yu · kai.han) "best + 1cm 올리고 거기서 좌우" 단계를 빼고, **best 자리(확인 측정한 +그 자리)에서 바로 좌우 밸런스**를 맞추고 그대로 붙입니다. 아래는 오프셋이 1 이던 때의 설명이며 +그때 세션(`final_offset_cm: 1`)을 읽을 때만 해당합니다. + **단순 덧셈**입니다. 측정된 위치로 스냅하지 않습니다 — Python 의 `select_supine_anchor(offset_cm=)` 이 그 스냅을 하지만 그건 **회고 분석 전용**이고, 실시간 안내로는 틀립니다(실측 `p2_7 v2`: best=1 → 4cm 로 튐). @@ -387,9 +392,9 @@ val anchorCm: Int? get() = bestCm?.let { it + offsetCm } // offsetCm = 1 ↓ 치골 위 N cm 에 다시 붙이고 측정 (확인 측정 = 확정 신호 · 이미 그 자리면 "에서 한 번 더 측정") ↓ -치골 위 N+1 cm 에 붙이세요 (초록 · 여기서는 측정하지 않음) +치골 위 N cm 그대로 — 좌우 맞추기 (초록 · 오프셋 0, 2026-10-01~) ↓ -좌우 정렬 (치골 위 N+1 cm 그대로) ← 흐름 전체 + 확인 완료 순간의 창을 저장 +좌우 정렬 (치골 위 N cm 그대로) ← 흐름 전체 + 확인 완료 순간의 창을 저장 ↓ (위치 마킹 → 프로브 떼기 → 크래들로 다시 붙이기) ↓ @@ -618,7 +623,7 @@ Stage 5 소수점 보정 | dps / delay (R계열) | **1.897** mm / **7.651** mm | | CH3 검출률 임계 | **0.80** | | nch 붕괴 문턱 | **1.0** | -| 탐색 상한 / 오프셋 | **4** cm / **+1** cm | +| 탐색 상한 / 오프셋 | **4** cm / **0** cm (2026-10-01~, 그 전 +1) | | 정렬 cycle / 슬라이딩 창 | **20** / **10** (→ 11 trace) | | 좌우 허용 \|Δu\| / 누적 프레임 | **8** 샘플 / **10** | | 연속 측정 창 / 최소 표시 | **10** / **5** | diff --git a/docs/LABDB_DATATYPES.md b/docs/LABDB_DATATYPES.md index fe0e3c2..d6069a3 100644 --- a/docs/LABDB_DATATYPES.md +++ b/docs/LABDB_DATATYPES.md @@ -188,9 +188,9 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다( | `cycles_per_position` | int | 20 고정 | | `avg`, `delay_us`, `samples` | number | | | `ch3_hit_min` | number | CH3 검출률 하한 (0.8) | -| `final_offset_cm` | int | best 에 더하는 오프셋 (1) | +| `final_offset_cm` | int | best 에 더하는 오프셋 — **0** (2026-10-01~), 그 전 세션은 1 | | `best_cm` | int / null | 알고리즘이 고른 최적 위치 | -| `anchor_cm` | int / null | 실제 부착 위치 = `best_cm + final_offset_cm` | +| `anchor_cm` | int / null | 실제 부착 위치 = `best_cm + final_offset_cm` (2026-10-01~ 는 best 그 자리) | | `action` | string | `STOP` / `REATTACH` / … | | `stop_reason` | string / null | 탐색 종료 사유 | | `max_nch` | int | 관측된 최대 검출 채널 수 |