feat(align): 부착 오프셋 0 — best 자리에서 바로 좌우 밸런스 (이번 임상부터 모든 자세에서 정렬)

임상팀 결정(2026-10-01, e.yu · kai.han): 모든 자세에서 정렬을 하므로 "best + 1cm 올리고
거기서 좌우" 단계를 빼고, 확인 측정한 best 자리에서 바로 좌우를 맞추고 그대로 붙인다.

  · AnchorConfig.FINAL_OFFSET_CM = 1 → 0. anchor_cm = best_cm. 2026-10-01 이전 세션
    (final_offset_cm: 1)과 대조할 때는 Python select_supine_anchor(offset_cm=0).
  · 지시문: 확인 측정 뒤 "치골 위 N cm 그대로 — 좌우 맞추기 / 옮기지 말고". 오프셋이
    0 이 아닐 때의 "N+offset cm 에 붙이세요" 는 남겨 둔다(상수만 되돌리면 그대로 동작).
  · AnchorGuide STOP 문구도 "이 자리(N cm)에 그대로 부착합니다".
  · 재부착 확인의 기준(좌우 확인 창)은 이제 확인 측정과 같은 높이 — "떼기 직전" 이라 그대로 둔다.

시험 AlignInstructionTest 오프셋 0 문구 1건 추가 — 전체 192건 · failures 0.
문서: CLINICAL_ALGORITHM §3.5/3.6/상수표, LABDB_DATATYPES final_offset_cm/anchor_cm, README.

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