fix(bv): post_outlier_filter default false — cm=3 CH0 부당 drop 해소 (Δ=0)

Python `_bv_core` (bv_estimation.py:1138) 는 `post_outlier_filter=False`
default. Kotlin `estimateBladderVolume` 은 이 flag 없이 **무조건 실행**하는
버그로 CH0 이 median 25% 벗어나면 부당하게 dropped 되어 nch=3 대신 2 처리.

이 divergence 는 특정 trace 에서만 발현 (data123 cm=3 trace 0-4, 9-10):
  - Python : valid_channels = [0,1,2,3]  (CH0 D_mm=63.43 포함)
  - Kotlin : valid_channels = [1,2,3]    (CH0 부당 drop) → BV +125 mL 편차

원인: trace 0 case 로 검증
  centerWalls inset : (15,48), (15,67), (15,67), null
  d_post_mm       : [99.78, 136.5, 136.5]
  median          : 136.5
  post_tol_mm     : max(136.5·0.25, 5·1.936) = 34.125
  |99.78-136.5|   : 36.72 → tolerance 초과 → CH0 drop
  하지만 Python default (filter off) 는 CH0 유지 → 올바른 nch=3.

수정:
- `estimateBladderVolume(Double)` 에 `postOutlierFilter: Boolean = false` 추가
  (Python default 매칭). block 안에서 flag 체크.
- `estimateBladderVolume(Int)` legacy 오버로드는 default true 유지
  (PiezoMonitoring 기존 동작 보존, phantom QC 회귀 방지).

검증 (data123 cm=3 11 trace):
  trace 0  py=283.156 → kt=283.156  Δ=0.000
  trace 1  py=439.303 → kt=439.303  Δ=0.000
  ... (전체 11 trace bit-perfect)
V3 CenterAligner:
  cm=0 bv_cv : 0.183 (py) == 0.183 (kt)  ★
  cm=1 bv_cv : 0.260 (py) == 0.260 (kt)  ★
  cm=3 bv_cv : 0.130 (py) == 0.130 (kt)  ★
  selected_cm = 3 일치, target 완전 동일.

CenterAlignerValidationTest tolerance 0.10 → 0.001 로 강화. 추후 회귀 방지.
Cm3PerTraceDumpTest.kt 신규 — cm=3 per-trace bit-perfect 회귀 게이트.
This commit is contained in:
2026-07-21 09:14:58 +09:00
parent 31527830e5
commit 9d3d1c4b6d
3 changed files with 141 additions and 16 deletions
@@ -631,7 +631,8 @@ private fun evalOnce(
}
}
/** Int 오버로드 — legacy 호환. */
/** Int 오버로드 — legacy 호환. 기본 postOutlierFilter=true 로 기존 PiezoMonitoring 경로
* 동작 보존 (Python default 는 false 지만 legacy Kotlin 은 무조건 실행이었음). */
@JvmName("estimateBladderVolumeInt")
fun estimateBladderVolume(
walls: List<Pair<Int, Int>?>,
@@ -644,6 +645,7 @@ fun estimateBladderVolume(
applyAngleCorrection: Boolean = true,
edgeCh: Int = walls.size - 1,
edgeRefChannels: List<Int> = listOf(walls.size - 2, walls.size - 3),
postOutlierFilter: Boolean = true,
): BVResult? = estimateBladderVolume(
walls = walls.map { it?.let { p -> Pair(p.first.toDouble(), p.second.toDouble()) } },
distancePerSample = distancePerSample,
@@ -655,6 +657,7 @@ fun estimateBladderVolume(
applyAngleCorrection = applyAngleCorrection,
edgeCh = edgeCh,
edgeRefChannels = edgeRefChannels,
postOutlierFilter = postOutlierFilter,
)
fun estimateBladderVolume(
@@ -672,6 +675,10 @@ fun estimateBladderVolume(
bSiFloorRatio: Double? = null,
/** Python `b_si_floor_edge_min`. edge 채널이 이 비율 이상 넓어야 floor 발동. */
bSiFloorEdgeMin: Double = 0.5,
/** Python `post_outlier_filter` (bv_estimation.py:1138). **default false** — Python
* 기본 동작과 매칭. true 였을 때 CH0 이 median 25% 벗어나면 부당하게 dropped 됨
* (data123 cm=3 trace 0 실증). */
postOutlierFilter: Boolean = false,
): BVResult? {
// 1) Edge channel filter — cap 방식 결정용 플래그
@@ -735,7 +742,9 @@ fun estimateBladderVolume(
var n = validChannels.size
// 2-1) Post median outlier 제거 (#21 merge)
if (n >= 3) {
// Python `_bv_core` (bv_estimation.py:1199) — `post_outlier_filter=False` default.
// 기본으론 실행 안 함. flag true 일 때만.
if (postOutlierFilter && n >= 3) {
val posts = dPost.toDoubleArray()
val med = posts.sorted()[posts.size / 2]
val postTol = max(med * 0.25, 5.0 * distancePerSample)