fix(algo): 실리콘 전용 cap 억제(cap_d_adaptive) 구현
레퍼런스 `runners.estimate_bv` 는 `cap_d_adaptive="auto"` 로 hw 버전을 보고
실리콘(r*)이면 `CAP_D_ADAPTIVE_SILICON = ("aspect", 80.0, 2.0)` 을 켠다. 코틀린엔
이 개념이 아예 없었다.
규칙: cap 지름 D = 2h 가 80mm 를 넘으면 `h · (80/D)^2` 로 누른다. 큰 방광에서 끝단
외삽이 과대해지는 것을 막는 장치다. Python 과 같은 자리(`_bv_core` 의 fallback cap
분기, R_eff 가 없는 경우)에만 적용한다 — shrink 경로는 이미 R_eff 로 높이가 구속된다.
## 왜 지금인가
어제까지의 대조는 **v1(abs) 데이터로만** 했다. 실리콘은 그 데이터셋에 없어서
"완벽 일치"가 실리콘까지 담보되지 않았는데, 실리콘 기기로 임상을 진행하려는
시점이라 이 격차를 먼저 메운다. abs(v*)에는 종전과 동일하게 적용되지 않는다.
CapDAdaptiveTest 신규 — Python `_apply_cap_d_adaptive` 를 그대로 돌려 뽑은 8점
(경계 D=D0 포함)과 대조하고, 꺼졌을 때 항등인 것까지 확인한다.
남은 실리콘 격차: `NCH2_SIBEAM_CLAMP_NCH` / `_sibeam_cap_bounds`(유효 채널 2개일 때의
si_beam cap 상한)와 F83 cap 보간(실리콘·supine 전용)은 아직 미포팅이다. 둘 다
좁은 조건에서만 발동하지만 실리콘 데이터가 생기면 대조해야 한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -584,6 +584,29 @@ const val ELLIPSE_CAP_R_MAX = 0.92
|
||||
/** Python `volume/config_vol.TOP_CAP_EDGE_K` — 최상단 채널이 최광폭일 때 top cap 제한. */
|
||||
const val TOP_CAP_EDGE_K = 0.6
|
||||
|
||||
/**
|
||||
* Python `runners.CAP_D_ADAPTIVE_SILICON` = ("aspect", 80.0, 2.0).
|
||||
*
|
||||
* **실리콘(r*) 기기에서만** 켜지는 큰 방광 cap 억제. 레퍼런스가 `cap_d_adaptive="auto"`
|
||||
* 로 hw 버전을 보고 자동 결정한다 — abs(v*)에는 적용하지 않는다.
|
||||
*/
|
||||
const val CAP_D_ADAPTIVE_D0 = 80.0
|
||||
const val CAP_D_ADAPTIVE_BETA = 2.0
|
||||
|
||||
/**
|
||||
* cap 높이를 aspect-ratio 상한으로 억제 — `h · min(1, (D0/D)^β)`, `D = 2h`.
|
||||
*
|
||||
* cap 이 지름 80mm(= 높이 40mm)를 넘어서면 그때부터 눌러 준다. 큰 방광에서 끝단
|
||||
* 외삽이 과대해지는 것을 막는 장치다. [enabled] 가 false 면 아무 것도 하지 않는다.
|
||||
*/
|
||||
internal fun applyCapDAdaptive(h: Double, enabled: Boolean): Double {
|
||||
if (!enabled || h <= 0.0) return h
|
||||
val d = 2.0 * h
|
||||
return if (d > CAP_D_ADAPTIVE_D0)
|
||||
h * minOf(1.0, Math.pow(CAP_D_ADAPTIVE_D0 / d, CAP_D_ADAPTIVE_BETA))
|
||||
else h
|
||||
}
|
||||
|
||||
/** Python `runners.CAP_AXIS_RATIO_MAX / CAP_AXIS_DMAX_MAX` — cap 축 붕괴 자기진단. */
|
||||
const val CAP_AXIS_RATIO_MAX = 0.85
|
||||
const val CAP_AXIS_DMAX_MAX = 100.0
|
||||
@@ -1162,6 +1185,15 @@ fun estimateBladderVolume(
|
||||
topKind = "shrink cone"
|
||||
} else {
|
||||
// fallback (ellipse fit 실패): 기존 hemisphere 공식.
|
||||
//
|
||||
// 이 분기에서만 D-adaptive cap 억제가 걸린다(Python `_bv_core` 의 else 분기와
|
||||
// 같은 자리). shrink 경로는 이미 R_eff 로 높이가 구속되므로 적용하지 않는다.
|
||||
val capDAdaptive = PiezoHW.activePreset.let {
|
||||
it == PiezoHW.DevicePreset.R100 || it == PiezoHW.DevicePreset.R200 ||
|
||||
it == PiezoHW.DevicePreset.R300
|
||||
}
|
||||
hCapBot = applyCapDAdaptive(hCapBot, capDAdaptive)
|
||||
hCapTop = applyCapDAdaptive(hCapTop, capDAdaptive)
|
||||
vBottom = sS[0] * hCapBot / 2.0 + Math.PI * hCapBot * hCapBot * hCapBot / 6.0
|
||||
vTop = sS[n - 1] * hCapTop / 3.0
|
||||
bottomKind = "$capKind sphere"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 실리콘 전용 cap 억제 `_apply_cap_d_adaptive` 대조.
|
||||
*
|
||||
* 기대값은 Python `_apply_cap_d_adaptive(h, CAP_D_ADAPTIVE_SILICON)` 을 그대로 돌려 뽑았다.
|
||||
* 규칙: `D = 2h` 가 D0(80) 을 넘을 때만 `h · (D0/D)^2` 로 누른다.
|
||||
*
|
||||
* 이 함수는 **실리콘(r*) 기기에서만** 켜진다. abs(v*) 에 켜면 BV 가 근거 없이 줄어든다.
|
||||
*/
|
||||
class CapDAdaptiveTest {
|
||||
|
||||
@Test
|
||||
fun matchesPythonReference() {
|
||||
val cases = listOf(
|
||||
0.0 to 0.0,
|
||||
10.0 to 10.0,
|
||||
39.9 to 39.9,
|
||||
40.0 to 40.0, // D=80 = D0 — 경계는 억제하지 않는다(D > D0 만)
|
||||
40.1 to 39.9002493766,
|
||||
50.0 to 32.0,
|
||||
60.0 to 26.6666666667,
|
||||
80.0 to 20.0,
|
||||
)
|
||||
for ((h, want) in cases) {
|
||||
assertEquals("h=$h (실리콘)", want, applyCapDAdaptive(h, true), 1e-9)
|
||||
// 꺼져 있으면 항등이어야 한다 — abs 기기에서 값이 변하면 안 된다.
|
||||
assertEquals("h=$h (abs)", h, applyCapDAdaptive(h, false), 1e-12)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user