From 1592a1454e727152dd3c134e505d24dd00cac11b Mon Sep 17 00:00:00 2001 From: jjangddu Date: Wed, 7 Oct 2026 16:49:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(align):=20nch=3D4=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=EA=B0=80=203=EA=B3=B3=20=EC=9D=B4=EC=83=81=EC=9D=B4=EB=A9=B4?= =?UTF-8?q?=20cap=5Ffrac=20=EC=B5=9C=EC=86=8C=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=EA=B0=80=EC=9A=B4=EB=8D=B0=EB=A5=BC=20=EA=B3=A0=EB=A5=B8?= =?UTF-8?q?=EB=8B=A4=20=E2=80=94=20=EB=B0=A9=EA=B4=91=20=EB=81=9D=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=84=A0=ED=83=9D=EC=9C=BC=EB=A1=9C=20BV?= =?UTF-8?q?=20=EA=B3=BC=EC=86=8C=20=EC=B6=94=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 알고리즘 팀(2026-10-07): nch=4 인 위치가 여럿이라는 것은 방광이 빔 커버 범위보다 크다는 뜻이다. 그때 cap_frac 최소 규칙은 외삽을 최소화하는 자리를 찾으려 하지만, 방광 끝에 가까운 위치일수록 cap_frac 이 오히려 작게 계산된다 — 한쪽 cap 은 끝 채널이 방광을 스쳐 작아지고, 반대쪽 cap 은 높이 상한에 걸려 그만큼 커지지 못한다. 그 결과 끝 위치가 뽑히고 BV 가 과소 추정됐다. 규칙 ①′: 후보(ch3 게이트 통과) 중 nch=4 가 3곳 이상이면 cm 순 가운데, 짝수면 가운데 둘 중 아래쪽(치골 쪽, 작은 cm). 2곳까지는 종전 규칙(nch 최대 → cap_frac 최소 → 낮은 cm) 그대로. 이 가지는 Python select_supine_anchor 에 아직 없다. 레퍼런스 케이스 200개에는 이 가지를 타는 케이스가 없어(확인) 대조가 그대로 유효하고, 패리티 시험이 그 가정을 검사한다. 새 가지는 AnchorSelectionCenterRuleTest 7건이 잠근다. 어느 가지로 골랐는지 AnchorDecision.rule 로 돌려주고, 요약·labdb 601 params 에 select_rule 로 남긴다 — nch4_center 세션은 selection_verified_against_reference=false. 정렬 완료 안내 문구에도 "채널 4/4 위치 N곳 — 가운데" 를 덧붙인다(화면은 종전대로 "치골 위 N cm" 만 보여 준다). Co-Authored-By: Claude Fable 5.1 --- .../vesiscan/managers/AnchorGuide.kt | 61 +++++++++++++++-- .../ui/views/clinical/AnchorAlignView.kt | 10 ++- .../managers/AnchorSelectionCenterRuleTest.kt | 68 +++++++++++++++++++ .../managers/AnchorSelectionParityTest.kt | 7 ++ docs/CLINICAL_ALGORITHM.md | 19 +++++- docs/LABDB_DATATYPES.md | 3 +- 6 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionCenterRuleTest.kt 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 9592633..97835ae 100644 --- a/app/src/main/java/com/medithings/vesiscan/managers/AnchorGuide.kt +++ b/app/src/main/java/com/medithings/vesiscan/managers/AnchorGuide.kt @@ -14,6 +14,7 @@ * |---|---|---| * | 절차 | 2-pass (전수 스캔 후 재측정) | **단일 pass** (올라가며 결정) | * | tie | `bv_cv` 최소 | **`cap_frac` 최소** | + * | nch=4 다수 | — | **3곳 이상이면 가운데** (2026-10-07 · Python 에 아직 없음) | * | 오프셋 | 없음 | **best + 1cm** | * | 종료 | 없음(5위치 전수) | nch 붕괴 / ch3 소실 / 상한 | * @@ -92,6 +93,16 @@ object AnchorConfig { /** 후보 자격: per-trace CH3 검출률 하한. */ const val CH3_HIT_MIN = 0.80 + /** center 채널(CH0~CH3) 전부 검출 = nch 4. */ + const val NCH_FULL = 4 + + /** + * nch=4 인 후보가 이 수 이상이면 cap_frac 대신 **가운데 위치**를 고른다(2026-10-07). + * 2곳까지는 종전 규칙(cap_frac 최소). Python `config_al.py` 에는 아직 없는 값이다 — + * [AnchorSelection.selectBest] 주석 참조. + */ + const val NCH4_CENTER_MIN = 3 + /** * 최종 안내 = best + 이 값. **선택이 아니라 안내**다(아래 [AnchorGuide.anchorCm] 참고). * @@ -151,11 +162,27 @@ fun capFraction(walls: List, missingOutside: Set? = null): D } /** 선택 결과. Python `AnchorDecision`. */ +/** [AnchorSelection.selectBest] 가 어느 가지로 골랐나 — 요약(`select_rule`)과 안내 문구에 쓴다. */ +enum class AnchorSelectRule { + /** 후보 없음(재부착). */ + NONE, + + /** nch=4 후보 3곳 이상 → 가운데 (2026-10-07). Python 레퍼런스에 **없는** 가지. */ + NCH4_CENTER, + + /** 종전 규칙: nch 최대 → cap_frac 최소 → 낮은 cm. Python `select_supine_anchor` 와 1:1. */ + MAX_NCH_MIN_CAP; + + /** 기록에 쓰는 값. 분석 스크립트가 문자열로 비교하므로 고정이다. */ + val wire: String get() = name.lowercase() +} + data class AnchorDecision( /** null = 유효 위치 없음(재부착). */ val bestCm: Int?, val nCandidate: Int, val record: AnchorPosRecord?, + val rule: AnchorSelectRule = AnchorSelectRule.NONE, ) /** @@ -174,7 +201,19 @@ object AnchorSelection { } /** - * ① ch3 필수 → ② max nch → ③ cap_frac 최소 (동률이면 min cm). + * ① ch3 필수 → ①′ nch=4 후보가 3곳 이상이면 **가운데** → ② max nch → ③ cap_frac 최소 + * (동률이면 min cm). + * + * ①′ (2026-10-07, 알고리즘 팀): nch=4 인 위치가 여럿이라는 것은 방광이 빔 커버 범위보다 + * 크다는 뜻이다. 그때 cap_frac 최소 규칙은 외삽을 최소화하는 자리를 찾으려 하지만, 방광 + * **끝**에 가까운 위치일수록 cap_frac 이 오히려 작게 나온다 — 한쪽 cap 은 끝 채널이 방광을 + * 스쳐 작아지고, 반대쪽 cap 은 높이 상한에 걸려 그만큼 커지지 못한다. 그래서 끝 위치가 + * 뽑히고 BV 가 과소 추정됐다. cm 순으로 늘어놓은 **가운데**를 고르고, 짝수면 가운데 둘 중 + * **아래쪽(치골 쪽, 작은 cm)** 이다. 2곳까지는 종전 규칙 그대로다. + * + * 이 가지는 Python `select_supine_anchor` 에 아직 없다. 레퍼런스 케이스 200개에는 이 + * 가지를 타는 케이스가 없어 대조가 그대로 유효하고(AnchorSelectionParityTest 가 확인), + * 이 가지로 고른 세션은 요약의 `select_rule: nch4_center` 로 가른다. * * `cap_frac` 이 유한하지 않으면 +∞ 로 취급해 맨 뒤로 보낸다(Python `_tie_val`). */ @@ -182,6 +221,11 @@ object AnchorSelection { if (records.isEmpty()) return AnchorDecision(null, 0, null) val cand = eligibleCh3(records) if (cand.isEmpty()) return AnchorDecision(null, 0, null) + val full = cand.filter { it.nch == AnchorConfig.NCH_FULL }.sortedBy { it.cm } + if (full.size >= AnchorConfig.NCH4_CENTER_MIN) { + val mid = full[(full.size - 1) / 2] // 짝수면 가운데 둘 중 아래쪽(작은 cm) + return AnchorDecision(mid.cm, cand.size, mid, AnchorSelectRule.NCH4_CENTER) + } val maxNch = cand.maxOf { it.nch } val top = cand.filter { it.nch == maxNch } val best = top.minWithOrNull( @@ -190,7 +234,7 @@ object AnchorSelection { { it.cm }, ) ) - return AnchorDecision(best?.cm, cand.size, best) + return AnchorDecision(best?.cm, cand.size, best, AnchorSelectRule.MAX_NCH_MIN_CAP) } } @@ -241,6 +285,10 @@ class AnchorGuide( var bestCm: Int? = null private set + /** [bestCm] 을 고른 가지. 안내 문구에 "가운데" 근거를 적는다. */ + var bestRule: AnchorSelectRule = AnchorSelectRule.NONE + private set + var stopReason: String? = null private set @@ -327,7 +375,7 @@ class AnchorGuide( } searching = false stopReason = reason - bestCm = AnchorSelection.selectBest(selectableRecords()).bestCm + AnchorSelection.selectBest(selectableRecords()).let { bestCm = it.bestCm; bestRule = it.rule } if (bestCm == null) { return out( AnchorAction.REATTACH, @@ -349,7 +397,7 @@ class AnchorGuide( if (searching) { searching = false stopReason = stopReason ?: "탐색 위치 소진" - bestCm = AnchorSelection.selectBest(selectableRecords()).bestCm + AnchorSelection.selectBest(selectableRecords()).let { bestCm = it.bestCm; bestRule = it.rule } } val best = bestCm ?: return out( @@ -361,6 +409,7 @@ class AnchorGuide( fun reset() { recs.clear(); maxNch = 0.0; searching = true; bestCm = null; stopReason = null + bestRule = AnchorSelectRule.NONE } // ── 내부 ──────────────────────────────────────────────────────────────── @@ -405,6 +454,10 @@ class AnchorGuide( AnchorAction.STOP, "정렬 완료 — 최적 위치 ${best}cm" + (b?.let { " (nch ${it.nch} · cap ${fmt3(it.capFrac)})" } ?: "") + + (if (bestRule == AnchorSelectRule.NCH4_CENTER) { + val n = selectableRecords().count { it.eligible && it.nch == AnchorConfig.NCH_FULL } + "\n채널 4/4 위치가 ${n}곳 — 가운데를 골랐습니다." + } else "") + (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 021cd22..a5c573b 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 @@ -67,6 +67,7 @@ import com.medithings.vesiscan.managers.AlignAction import com.medithings.vesiscan.managers.AlignmentConstants import com.medithings.vesiscan.managers.AnchorAction import com.medithings.vesiscan.managers.AnchorBasis +import com.medithings.vesiscan.managers.AnchorSelectRule import com.medithings.vesiscan.managers.AnchorConfig import com.medithings.vesiscan.managers.AnchorGuide import com.medithings.vesiscan.managers.AnchorPosRecord @@ -258,6 +259,8 @@ fun AnchorAlignView(appState: AppState) { // `select_supine_anchor` 와 1:1 이고 200 케이스로 대조돼 있다(AnchorSelectionParityTest). // 바뀌는 것은 **후보 집합**뿐이고, Python 의 회고 분석 경로가 이미 전 위치로 판정하므로 // 같은 방식으로 재현·대조할 수 있다. + // (2026-10-07: nch=4 후보 3곳 이상이면 가운데 — Python 에 아직 없는 가지다. 요약의 + // `select_rule` 로 가르고, 그 세션은 `selection_verified_against_reference=false` 다.) // // 종료 조건(붕괴·ch3 소실·상한)은 **진단용**으로 남는다 — 지표가 어디서 무너졌는지는 // 여전히 알아야 한다. `decided_at_cm` 으로 기록에 남는다. @@ -1666,7 +1669,8 @@ private fun buildAlignSummary( confirmChannels?.let { put("confirm_channels", JSONArray(it)) } // 앱이 쓴 값 = **잰 위치 전부**로 고른 것. 선택 규칙은 Python `select_supine_anchor` // 그대로이고(AnchorSelectionParityTest 200케이스), 바뀐 것은 후보 집합뿐이다. - val fullBest = AnchorSelection.selectBest(records.filter { it.capFrac.isFinite() }).bestCm + val fullDecision = AnchorSelection.selectBest(records.filter { it.capFrac.isFinite() }) + val fullBest = fullDecision.bestCm // ── 판정 범위 ────────────────────────────────────────────────────────── // 2026-09-10 부터 앱은 **잰 위치 전부**로 판정한다. `best_cm`/`anchor_cm` 은 그 값이다. @@ -1703,7 +1707,9 @@ private fun buildAlignSummary( put("max_nch", step.maxNch) // 앱 검출이 Python 레퍼런스와 불일치인 상태로 낸 값이라는 사실을 데이터와 함께 남긴다. // 2026-09-03 대조 완료: 검출·BV·선택 규칙이 Python 레퍼런스와 일치. - put("selection_verified_against_reference", true) + // 2026-10-07: nch4_center 가지는 Python 레퍼런스에 아직 없다 — 그 가지로 고른 세션은 대조 미완이다. + put("select_rule", fullDecision.rule.wire) + put("selection_verified_against_reference", fullDecision.rule != AnchorSelectRule.NCH4_CENTER) // 좌우 정렬 (SPEC §8 "좌우(LR) 정렬" · LateralGuide). // done=false 는 "안 맞췄다"가 아니라 "확인 단계를 거치지 않았다"는 뜻이다. // lateral 이 끝내 안 잡히는 환자도 있어 진행을 막지 않기 때문에 둘을 구분해야 한다. diff --git a/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionCenterRuleTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionCenterRuleTest.kt new file mode 100644 index 0000000..2940023 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionCenterRuleTest.kt @@ -0,0 +1,68 @@ +package com.medithings.vesiscan.managers + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * ①′ nch=4 후보가 3곳 이상이면 가운데 (2026-10-07, 알고리즘 팀). + * + * 방광이 빔 커버 범위보다 크면 nch=4 인 위치가 여럿 나오고, 그때 cap_frac 최소 규칙은 방광 + * **끝** 위치를 고른다(끝 채널이 방광을 스쳐 한쪽 cap 이 작아지고, 반대쪽 cap 은 높이 상한에 + * 걸려 그만큼 커지지 못한다) → BV 과소 추정. 그래서 가운데, 짝수면 아래쪽(작은 cm). + * 아래 케이스는 일부러 **끝 위치의 cap 을 제일 작게** 둬서 종전 규칙이 끝을 고르게 만든다. + */ +class AnchorSelectionCenterRuleTest { + + private fun rec(cm: Int, nch: Int = 4, cap: Double = 0.30, rate: Double = 1.0) = AnchorPosRecord( + cm = cm, nTrace = 11, nch = nch, ch3Mean = nch >= 4 || nch >= 1, ch3Rate = rate, capFrac = cap, + ) + + @Test + fun `three nch4 positions pick the middle even when the end has the smallest cap`() { + val d = AnchorSelection.selectBest(listOf(rec(1, cap = 0.40), rec(2, cap = 0.30), rec(3, cap = 0.05))) + assertEquals(2, d.bestCm) + assertEquals(AnchorSelectRule.NCH4_CENTER, d.rule) + } + + @Test + fun `four nch4 positions pick the lower of the two middles`() { + val d = AnchorSelection.selectBest((0..3).map { rec(it, cap = if (it == 3) 0.05 else 0.30) }) + assertEquals(1, d.bestCm) + assertEquals(AnchorSelectRule.NCH4_CENTER, d.rule) + } + + @Test + fun `five nch4 positions pick the center`() { + val d = AnchorSelection.selectBest((0..4).map { rec(it, cap = if (it == 4) 0.05 else 0.30) }) + assertEquals(2, d.bestCm) + } + + @Test + fun `middle is by cm order, not by index of measurement, and gaps are allowed`() { + // 잰 순서가 뒤섞여도, 0·2·4 처럼 띄엄띄엄이어도 cm 순 가운데다. + assertEquals(2, AnchorSelection.selectBest(listOf(rec(4, cap = 0.05), rec(0), rec(2))).bestCm) + assertEquals(1, AnchorSelection.selectBest(listOf(rec(4, cap = 0.05), rec(1), rec(0))).bestCm) + } + + @Test + fun `two nch4 positions keep the old rule (min cap)`() { + val d = AnchorSelection.selectBest(listOf(rec(1, cap = 0.40), rec(2, cap = 0.10), rec(3, nch = 3, cap = 0.05))) + assertEquals(2, d.bestCm) + assertEquals(AnchorSelectRule.MAX_NCH_MIN_CAP, d.rule) + } + + @Test + fun `an nch4 position that fails the ch3 gate does not count`() { + // 2cm 는 nch=4 지만 검출률 50% 로 자격 미달 → nch=4 후보는 1·3cm 두 곳 → 종전 규칙(cap 최소 = 3cm). + val d = AnchorSelection.selectBest(listOf(rec(1, cap = 0.40), rec(2, rate = 0.5), rec(3, cap = 0.05))) + assertEquals(3, d.bestCm) + assertEquals(AnchorSelectRule.MAX_NCH_MIN_CAP, d.rule) + } + + @Test + fun `no candidate stays a reattach`() { + val d = AnchorSelection.selectBest(listOf(rec(0, nch = 2, rate = 0.0).copy(ch3Mean = false))) + assertEquals(null, d.bestCm) + assertEquals(AnchorSelectRule.NONE, d.rule) + } +} diff --git a/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionParityTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionParityTest.kt index 6bc13f5..9a89eb9 100644 --- a/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionParityTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/managers/AnchorSelectionParityTest.kt @@ -58,6 +58,13 @@ class AnchorSelectionParityTest { if (want == null) nullBest++ } assertEquals("케이스 수", 200, lines.size) + // 2026-10-07 ①′(nch=4 후보 3곳 이상 → 가운데)는 Python 에 아직 없다. 이 파일에는 그 가지를 + // 타는 케이스가 없어 대조가 그대로 유효하다. 파일을 다시 뽑아 이 가정이 깨지면 Python 쪽에도 + // 같은 규칙을 넣은 뒤 이 검사를 지운다. 새 가지 자체는 AnchorSelectionCenterRuleTest 가 잠근다. + val centerCases = lines.count { + AnchorSelection.selectBest(parse(it).second).rule == AnchorSelectRule.NCH4_CENTER + } + assertEquals("nch4_center 가지를 타는 레퍼런스 케이스", 0, centerCases) // 재부착 판정이 실제로 섞여 있어야 대조가 의미 있다. assertTrue("재부착 케이스가 없다 — 데이터가 한쪽으로 쏠렸다", nullBest > 0) } diff --git a/docs/CLINICAL_ALGORITHM.md b/docs/CLINICAL_ALGORITHM.md index 2ea4707..1bde99e 100644 --- a/docs/CLINICAL_ALGORITHM.md +++ b/docs/CLINICAL_ALGORITHM.md @@ -341,7 +341,7 @@ eligible = ch3Mean && ch3Rate >= 0.80 > ②는 "앞에 eligible 이 있을 때만" 걸립니다. 한 번도 CH3 가 안 잡혔으면 ③(상한)까지 갑니다. -### 3.4 최적 선택 — 3단 규칙 +### 3.4 최적 선택 — 3단 규칙 (+ nch=4 다수 예외) `AnchorSelection.selectBest()` (`AnchorGuide.kt:174`) @@ -352,6 +352,9 @@ eligible = ch3Mean && ch3Rate >= 0.80 ① 후보 자격 ch3 == "O" AND ch3_rate ≥ 0.8 → 통과가 0개면 → REATTACH (재부착) +①′ nch=4 다수 nch=4 인 후보가 **3곳 이상**이면 그중 **가운데** (cm 순 · 짝수면 아래쪽=치골 쪽) + → 여기서 끝. 2곳까지는 아래로. (2026-10-07~) + ② 규칙 A 남은 후보 중 nch 최대 ③ 동률이면 cap_frac 최소 → 그래도 같으면 낮은 cm @@ -359,6 +362,18 @@ eligible = ch3Mean && ch3Rate >= 0.80 입력은 **`cap_frac` 이 유한한 위치만**입니다 (`selectableRecords()`). +**①′ 는 2026-10-07 에 추가됐습니다(알고리즘 팀).** nch=4 인 위치가 여럿이라는 것은 방광이 +빔 커버 범위보다 크다는 뜻입니다. 이때 ③의 cap_frac 최소 규칙은 외삽 영역을 최소화하는 +위치를 찾으려 하지만, **방광 끝에 가까운 위치일수록 cap_frac 이 오히려 작게** 계산됩니다 — +한쪽 cap 은 끝 채널이 방광을 스쳐 작아지고, 반대쪽 cap 은 높이 상한에 걸려 그만큼 커지지 +못합니다. 그 결과 방광 끝 위치가 선택되고 BV 가 과소 추정됐습니다. 그래서 nch=4 위치가 +3곳 이상이면 가운데를 고릅니다. 짝수면 가운데 둘 중 아래쪽(작은 cm)입니다. + +이 가지는 Python `select_supine_anchor` 에 **아직 없습니다.** 레퍼런스 케이스 200개에는 이 +가지를 타는 케이스가 없어 대조는 그대로 유효하고(`AnchorSelectionParityTest` 가 확인), 새 +가지는 `AnchorSelectionCenterRuleTest` 가 잠급니다. 이 가지로 고른 세션은 요약에 +`select_rule: "nch4_center"` · `selection_verified_against_reference: false` 로 남습니다. + ### 3.5 부착 위치 = 최적 (오프셋 0 · 2026-10-01~) ```kotlin @@ -433,6 +448,7 @@ val anchorCm: Int? get() = bestCm?.let { it + offsetCm } // offsetCm = 0 (2026 **레퍼런스는 어떻게 되나.** 선택 규칙 `selectBest` 는 Python `select_supine_anchor` 와 1:1 이고 200 케이스로 대조돼 있습니다(`AnchorSelectionParityTest`) — **손대지 않았습니다.** +(2026-10-07 에 ①′ nch=4 다수 예외가 더해졌습니다 — [3.4](#34-최적-선택--3단-규칙--nch4-다수-예외).) 바뀐 것은 후보 집합뿐이고, **Python 의 회고 분석 경로가 이미 전 위치로 판정**하므로 같은 방식으로 재현·대조할 수 있습니다. 다만 최종 보고서의 "단일 pass" 기술은 갱신이 필요합니다. @@ -440,6 +456,7 @@ val anchorCm: Int? get() = bestCm?.let { it + offsetCm } // offsetCm = 0 (2026 ```json "selection_scope": "full_sweep", +"select_rule": "max_nch_min_cap", // 2026-10-07~ · "nch4_center" 면 Python 미대조 가지 "best_cm": 3, // 앱이 쓴 값 = 전 위치 판정 "anchor_cm": 4, // best + 1 "best_cm_single_pass": 0, // 옛 단일 pass 가 냈을 답 diff --git a/docs/LABDB_DATATYPES.md b/docs/LABDB_DATATYPES.md index c56758c..7801022 100644 --- a/docs/LABDB_DATATYPES.md +++ b/docs/LABDB_DATATYPES.md @@ -190,12 +190,13 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다( | `ch3_hit_min` | number | CH3 검출률 하한 (0.8) | | `final_offset_cm` | int | best 에 더하는 오프셋 — **0** (2026-10-01~), 그 전 세션은 1 | | `best_cm` | int / null | 알고리즘이 고른 최적 위치 | +| `select_rule` | string | 어느 가지로 골랐나 (2026-10-07~): `max_nch_min_cap` 종전 3단 규칙 · `nch4_center` nch=4 후보 3곳 이상 → 가운데 · `none` 후보 없음 | | `anchor_cm` | int / null | 실제 부착 위치 = `best_cm + final_offset_cm` (2026-10-01~ 는 best 그 자리) | | `action` | string | `STOP` / `REATTACH` / … | | `stop_reason` | string / null | 탐색 종료 사유 | | `max_nch` | int | 관측된 최대 검출 채널 수 | | `patient_unnamed` | bool | 환자명 없이 잰 데이터인가(폴더가 `unnamed_*`) | -| `selection_verified_against_reference` | bool | 앱 판정이 Python 레퍼런스와 대조 완료인가 | +| `selection_verified_against_reference` | bool | 앱 판정이 Python 레퍼런스와 대조 완료인가 — `nch4_center` 가지는 Python 에 아직 없어 **false** | | `positions` | array | 위치별 지표 — 아래 | | `lateral` | object | 좌우 정렬 결과 — 아래 | | `anchor_basis` | string / null | 이 위치로 임상을 진행한 근거. 600 의 같은 필드와 같은 값입니다. **null = 정렬만 하고 임상으로 넘어가지 않음** |