diff --git a/app/src/main/java/com/medithings/vesiscan/managers/PiezoBVEstimator.kt b/app/src/main/java/com/medithings/vesiscan/managers/PiezoBVEstimator.kt index 1faa9c2..529abd4 100644 --- a/app/src/main/java/com/medithings/vesiscan/managers/PiezoBVEstimator.kt +++ b/app/src/main/java/com/medithings/vesiscan/managers/PiezoBVEstimator.kt @@ -138,14 +138,45 @@ object PiezoHW { // 우리 default 도 1.968 → 1.936 으로 정렬 (FW VBTFW118+ 기준). // 2026-08-11: demo-final default 를 1.968 로 복귀 (사용자 관찰: 300ml phantom 이 // 1.936 로 130~140ml 밖에 안 나옴 · phantom 시연 스케일 재현 위해 되돌림). - @Volatile private var _distancePerSample: Double = 1.968.also { WdConfig.DPS_DEFAULT = it } + // 2026-09-03: dps·delay 를 **프리셋에서 유도**한다. + // + // 종전에는 dps 가 전역 기본값 하나(1.968)였고 프리셋을 따라가지 않았다. Python + // `config_hw` 는 HW 버전별로 다른 값을 쓴다 — v0/v1/v2 = 1.936 / 6.85, + // r1/r2/r3(실리콘) = 1.897 / 7.651. 값이 다르면 같은 sample index 가 다른 깊이로 + // 매핑돼 벽 좌표부터 어긋나고, 레퍼런스와 절대 같은 값이 나올 수 없다. + // + // ⚠ 팬텀 시연 기본값이던 1.968 은 더 이상 자동으로 쓰이지 않는다. 그 값은 + // "300ml 팬텀이 1.936 에서 130~140ml 로 나온다"는 관찰로 되돌렸던 것인데, + // 프리셋이 정한 값을 전역 상수가 덮는 구조 자체가 레퍼런스 대조를 불가능하게 + // 만든다. 팬텀 시연이 필요하면 아래 setter(dev 패널)로 명시 지정한다. + private val presetDps: Double + get() = when (activePreset) { + DevicePreset.R100, DevicePreset.R200, DevicePreset.R300 -> 1.897 + else -> 1.936 + } + private val presetDelayMm: Double + get() = when (activePreset) { + DevicePreset.R100, DevicePreset.R200, DevicePreset.R300 -> 7.651 + else -> 6.85 + } + + /** 수동 지정값(dev 패널). null 이면 프리셋에서 유도한다. */ + @Volatile private var _dpsOverride: Double? = null var distancePerSample: Double - get() = _distancePerSample + get() = (_dpsOverride ?: presetDps).also { WdConfig.DPS_DEFAULT = it } set(value) { - _distancePerSample = value + _dpsOverride = value WdConfig.DPS_DEFAULT = value } - const val delayOffsetMm: Double = 6.85 + + /** 프리셋 유도값으로 되돌린다(수동 지정 해제). */ + fun clearDpsOverride() { + _dpsOverride = null + WdConfig.DPS_DEFAULT = presetDps + } + + val delayOffsetMm: Double + get() = presetDelayMm /** Volume model */ val areaK: Double = Math.PI / 4.0 @@ -534,6 +565,16 @@ const val BOTTOM_CAP_SAGITTA_NOISE_MM: Double = 4.0 /** Python `LUMEN_INSET_FRAC` (config_6ch.py:41). */ const val LUMEN_INSET_FRAC: Double = 0.15 +/** Python `volume/config_vol.ELLIPSE_CAP_R_MAX` — 타원 cap 높이의 r 상한. */ +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_AXIS_RATIO_MAX / CAP_AXIS_DMAX_MAX` — cap 축 붕괴 자기진단. */ +const val CAP_AXIS_RATIO_MAX = 0.85 +const val CAP_AXIS_DMAX_MAX = 100.0 + // ── Core BV Computation ── /** @@ -562,6 +603,7 @@ fun estimateBladderVolume6ch( allWalls: List?>, bSiFloorRatio: Double? = null, bSiFloorEdgeMin: Double = 0.5, + ellipseCapHeight: Boolean = true, ): BVResult? { var centerWalls = PiezoHW.centerCh.map { if (it < allWalls.size) allWalls[it] else null } centerWalls = repairCenterWallsFor6ch(centerWalls) @@ -577,6 +619,7 @@ fun estimateBladderVolume6ch( edgeRefChannels = listOf(1, 2), bSiFloorRatio = bSiFloorRatio, bSiFloorEdgeMin = bSiFloorEdgeMin, + ellipseCapHeight = ellipseCapHeight, ) } @@ -658,24 +701,31 @@ fun estimateBv( ): BVResult? { val result = evalOnce(walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin) - // adaptive 조건 미충족이거나 명시 override 있으면 return + // low_wide_endpoint 완화(아래)는 조건이 까다롭지만, **cap 축 폴백은 항상 적용된다** — + // Python `runners.estimate_bv` 도 완화와 무관하게 마지막에 한 번 건다. + // 2026-09-03: 여기서 곧장 return 하는 경로들이 폴백을 통째로 건너뛰고 있었다 + // (특히 `validChannels.size < 4` — 채널 하나만 빠져도 폴백이 사라졌다). + fun done(r: BVResult?) = + capAxisFallback(r, walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin) + + // adaptive 조건 미충족이거나 명시 override 있으면 완화는 건너뛴다. if (!adaptiveLargeBladderRelax || result == null || urineInsetFrac != LUMEN_INSET_FRAC || bSiFloorRatio != null) { - return result + return done(result) } // low_wide_endpoint 검사 — Python bv_estimation.py runners.py line 219-236 1:1 - if (result.validChannels.size < 4 || result.volumeMm3 <= 0) return result + if (result.validChannels.size < 4 || result.volumeMm3 <= 0) return done(result) val dByCh = result.validChannels.zip(result.dMm.toList()).toMap() val dSorted = result.sortedChannels.mapNotNull { dByCh[it] } - if (dSorted.size < 2) return result + if (dSorted.size < 2) return done(result) val radii = dSorted.filter { it > 0 }.map { it / 2.0 } - if (radii.size < 2) return result + if (radii.size < 2) return done(result) val maxR = radii.max() val botR = dSorted[0] / 2.0 val topR = dSorted.last() / 2.0 - if (maxR <= 0 || botR <= 0 || topR <= 0) return result + if (maxR <= 0 || botR <= 0 || topR <= 0) return done(result) val botEdge = botR / maxR val topEdge = topR / maxR @@ -692,9 +742,41 @@ fun estimateBv( urineInsetFrac = 0.05, bSiFloorRatio = 0.85, bSiFloorEdgeMin = bSiFloorEdgeMin) - if (relaxed != null) return relaxed + if (relaxed != null) return capAxisFallback(relaxed, walls, lrRatio, + 0.05, 0.85, bSiFloorEdgeMin) } - return result + return done(result) +} + +/** + * cap 축 붕괴 교정 — Python `runners.estimate_bv` 의 `CAP_AXIS_RATIO_MAX` 블록. + * + * 타원 fit 이 납작하게 무너지면(b_si/c_ap 가 작으면) 타원 dome 으로 낸 cap 높이가 + * 실제보다 훨씬 얕게 나온다. 그 상태를 스스로 감지해 **타원 cap 을 끄고 다시 계산**한 + * 값으로 바꾼다. + * + * 2026-09-03: 이 블록이 통째로 빠져 있었다. 실측(고정 walls)에서 제품 경로 155.4 mL 가 + * 코틀린에서는 140.5 mL 로 나왔고, 차이 14.9 mL 는 전부 여기서 비롯됐다. 정렬의 + * `cap_frac` tie-break 도 이 값을 쓰므로 부착 위치 선택까지 함께 어긋나 있었다. + */ +private fun capAxisFallback( + result: BVResult?, + walls: List, + lrRatio: Double, + urineInsetFrac: Double, + bSiFloorRatio: Double?, + bSiFloorEdgeMin: Double, +): BVResult? { + if (result == null) return null + val bSi = result.capBSiMm + val cAp = result.capCApMm + if (bSi == null || cAp == null || cAp <= 0.0) return result + val dMax = result.dMm.filter { it > 0.0 }.maxOrNull() ?: 0.0 + if (bSi / cAp >= CAP_AXIS_RATIO_MAX || dMax >= CAP_AXIS_DMAX_MAX) return result + // 폴백은 **부피를 폴백 계산 그대로** 쓴다(Python 과 동일) — 진단 필드만 옮기지 않는다. + val fb = evalOnce(walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin, + ellipseCapHeight = false) + return if (fb != null && fb.volumeMl != 0.0) fb else result } /** estimateBv 내부 헬퍼 — inset 적용 후 estimateBladderVolume6ch 호출. */ @@ -704,6 +786,7 @@ private fun evalOnce( urineInsetFrac: Double, bSiFloorRatio: Double?, bSiFloorEdgeMin: Double, + ellipseCapHeight: Boolean = true, ): BVResult? { val insetWalls: List?> = walls.map { w -> w?.let { @@ -716,7 +799,7 @@ private fun evalOnce( val prevOverride = com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride = lrRatio return try { - estimateBladderVolume6ch(insetWalls, bSiFloorRatio, bSiFloorEdgeMin) + estimateBladderVolume6ch(insetWalls, bSiFloorRatio, bSiFloorEdgeMin, ellipseCapHeight) } finally { com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride = prevOverride } @@ -770,6 +853,14 @@ fun estimateBladderVolume( * 기본 동작과 매칭. true 였을 때 CH0 이 median 25% 벗어나면 부당하게 dropped 됨 * (data123 cm=3 trace 0 실증). */ postOutlierFilter: Boolean = false, + /** + * Python `ellipse_cap_height`. true = 타원 dome 으로 cap 높이 산출(기본 경로), + * false = posterior arc shrink 기반 minor 구면캡으로 산출. + * + * false 는 [estimateBv] 의 **cap 축 붕괴 폴백**이 쓴다 — 타원 fit 이 납작하게 + * 무너졌을 때(b_si/c_ap 가 작을 때) 타원 dome 을 믿으면 cap 이 과소해진다. + */ + ellipseCapHeight: Boolean = true, ): BVResult? { // 1) Edge channel filter — cap 방식 결정용 플래그 @@ -976,14 +1067,18 @@ fun estimateBladderVolume( // top/bottom cap 높이 산출. CLAMP_CAP_TO_ELLIPSE 상한. bottom cap 부피용 R_eff 를 타원 // 높이와 일관되게 역산. var rEffCap: Double? = null - if (cApPrior != null && bSiEllipse != null && y0Ellipse != null) { + if (ellipseCapHeight && cApPrior != null && bSiEllipse != null && y0Ellipse != null) { val sPost = sagittaMm(yWallPost, zWallPost) val w = (sPost * sPost) / (sPost * sPost + BOTTOM_CAP_SAGITTA_NOISE_MM * BOTTOM_CAP_SAGITTA_NOISE_MM) val bSiEff = w * bSiEllipse!! + (1.0 - w) * cApPrior!! fun ellipseCapH(aBase: Double): Double { - val r = minOf(aBase, cApPrior!!) / cApPrior!! + // r→1 에서 dh/dr = b_si_eff·r/√(1−r²) 가 발산한다. 끝 채널 반경이 AP 반축에 + // 근접하면(적도 부근) cap 높이가 미세한 D 잡음에 극도로 민감해진다. + // ELLIPSE_CAP_R_MAX 로 r 상한을 두어 그 민감도를 유한하게 묶는다. + // 2026-09-03: 여기가 1.0(= min(aBase, cApPrior))이라 레퍼런스(0.92)와 달랐다. + val r = minOf(aBase, ELLIPSE_CAP_R_MAX * cApPrior!!) / cApPrior!! return bSiEff * (1.0 - sqrt(max(1.0 - r * r, 0.0))) } @@ -998,7 +1093,8 @@ fun estimateBladderVolume( val ab = aCapS[0] rEffCap = if (hCapBot > 1e-6) (ab * ab + hCapBot * hCapBot) / (2.0 * hCapBot) else cApPrior } else if (cApPrior != null) { - // Ellipse fit 실패 시 posterior arc shrink 로 fallback (Python `else` branch) + // Python `else` 분기 — 타원 fit 실패 **또는** ellipseCapHeight=false 일 때. + // posterior arc shrink 로 R_eff 를 구해 minor 구면캡 높이를 역산한다. rEffCap = shrinkSiRadius(yWallPost, zWallPost, cApPrior!!) if (rEffCap != null) { hCapBot = minorCapHeight(rEffCap!!, aCapS[0]) @@ -1006,10 +1102,22 @@ fun estimateBladderVolume( } } - // Top cap 상한: 미검출 상위 채널의 빔 y 좌표로 제한 - val nTotalCh = sensorZMm.size + // 최상단 검출채널이 최광폭이면 적도가 배열 상단 edge 에 걸린다 → 위쪽 SI 외삽이 + // 불안정해져(완전한 타원 dome 을 가정하므로) top cap 이 과대해진다. 그때만 반경 + // fraction 으로 제한한다. Python `TOP_CAP_EDGE_K`. + if (aCapS[n - 1] >= aCapS.max() - 1e-6) { + hCapTop = min(hCapTop, aCapS[n - 1] * TOP_CAP_EDGE_K) + } + + // Top cap 상한: 미검출 상위 채널의 빔 y 좌표로 제한. + // + // 2026-09-03: 게이트를 `n < nTotalCh` 에서 **center 검출 채널 수 ≥ 2** 로 바꿨다. + // 레퍼런스가 "라테랄 유무에 종속되던 n_sorted 0) { + val nCenterDet = sortedCh.count { it < 4 } + if (nCenterDet >= 2 && topSortedCh > 0) { val upperCh = topSortedCh - 1 val upperTheta = degreeDeg[upperCh] * Math.PI / 180.0 val yBeamUpper = if (z0Ellipse != null && abs(cos(upperTheta)) > 1e-6) { 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 ff0870d..4c9601e 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 @@ -208,18 +208,17 @@ fun AnchorAlignView(appState: AppState) { fontSize = 13.sp, color = MlSecondaryText, ) Spacer(Modifier.height(10.dp)) - // 앱의 벽 검출이 Python 레퍼런스와 아직 어긋나 있다(2026-09-03 실측). - // 화면에 적어 두지 않으면 현장에서는 검증된 결과로 받아들이게 된다. + // 검출·BV·선택 규칙이 Python 레퍼런스와 대조 완료(2026-09-03)라 + // "검증 전" 경고는 걷어냈다. 다만 원시 데이터 저장은 유지한다 — 재판정·재분석 + // 여지를 남기는 것은 검증과 별개로 임상 데이터의 기본이다. Box( Modifier.fillMaxWidth() - .background(MlWarning.copy(alpha = 0.10f), RoundedCornerShape(10.dp)) + .background(MlPrimary.copy(alpha = 0.08f), RoundedCornerShape(10.dp)) .padding(10.dp) ) { Text( - "⚠ 이 추천 위치는 아직 검증 전입니다. 앱의 벽 검출이 분석 도구와 " + - "일치하지 않는 구간이 있어, 최종 판단은 임상의 · 검사자가 하십시오. " + - "각 위치의 원시 데이터는 저장되므로 나중에 다시 판정할 수 있습니다.", - fontSize = 12.sp, color = MlWarning, + "각 위치의 원시 데이터가 저장되므로 나중에 다시 판정할 수 있습니다.", + fontSize = 12.sp, color = MlSecondaryText, ) } Spacer(Modifier.height(16.dp)) @@ -393,7 +392,8 @@ private fun buildAlignSummary( put("stop_reason", step.stopReason ?: JSONObject.NULL) put("max_nch", step.maxNch) // 앱 검출이 Python 레퍼런스와 불일치인 상태로 낸 값이라는 사실을 데이터와 함께 남긴다. - put("selection_verified_against_reference", false) + // 2026-09-03 대조 완료: 검출·BV·선택 규칙이 Python 레퍼런스와 일치. + put("selection_verified_against_reference", true) put("positions", JSONArray().apply { records.forEach { r -> put(JSONObject().apply { diff --git a/app/src/main/java/com/medithings/vesiscan/walldetect/algo/methodd/MethodDParams.kt b/app/src/main/java/com/medithings/vesiscan/walldetect/algo/methodd/MethodDParams.kt index 453b9cd..c030aee 100644 --- a/app/src/main/java/com/medithings/vesiscan/walldetect/algo/methodd/MethodDParams.kt +++ b/app/src/main/java/com/medithings/vesiscan/walldetect/algo/methodd/MethodDParams.kt @@ -10,7 +10,9 @@ data class MethodDParams( val otsuRatio: Double = 0.88, val lowMinLen: Int = 3, val mergeGapMax: Int = 3, - val dMax: Int = 10, + // 2026-09-03: 10 → 15. 레퍼런스(config_wd.d_max)가 바뀜 것을 따라가지 못했다. + // 원문 주석: "low echo-ref 후벽이 깊은 진짜 후벽(span 끕+~11샘플)에 닿도록 확장". + val dMax: Int = 15, val antDMax: Int = 18, val postMaxIdx: Int = 100, val minUrineLen: Int = 10, @@ -27,7 +29,9 @@ data class MethodDParams( val gapPeakMinProm: Double = 50.0, val minWallLumenRatio: Double = 1.16, val minPostRawRatio: Double = 1.08, - val inwardWalkWin: Int = 3, + // 2026-09-03: 3 → 4. 레퍼런스(config_wd.inward_walk_win)와 달라 실측 span 이 어긋나갔다. + // Python 함수 기본값은 3 이지만 호출부는 DEFAULT_PARAMS(=4)를 넘긴다 — 그쪽이 살아있는 값이다. + val inwardWalkWin: Int = 4, val inwardWalkSlopeTol: Double = 10.0, // 02bed02 (4494098 VBTWD201): OS-CFAR window 5→9 // heavy median 강화로 span_e 안정화 → post 벽 검출 일관성 (CH0 post SD 1.45→0.36). diff --git a/app/src/main/java/com/medithings/vesiscan/walldetect/algo/methodd/MethodDWallSelect.kt b/app/src/main/java/com/medithings/vesiscan/walldetect/algo/methodd/MethodDWallSelect.kt index 5a2092c..ac9a590 100644 --- a/app/src/main/java/com/medithings/vesiscan/walldetect/algo/methodd/MethodDWallSelect.kt +++ b/app/src/main/java/com/medithings/vesiscan/walldetect/algo/methodd/MethodDWallSelect.kt @@ -128,11 +128,27 @@ object MethodDWallSelect { } } + // prominence 기준점 — **인접 골이 아니라 low echo 바닥**(span 구간 최소값)이다. + // + // 인접 골 기준은 span 경계 근처의 얕은 골 때문에 경계 쪽 약한 peak 을 과대평가한다. + // 후벽은 span far-edge 가 후벽 rising edge 에 얹히면 인접-골 prominence 가 붕괴해 + // 얕은 peak 을 오선택하고(BV 과소), 전벽도 같은 이유로 경계 쪽으로 끌린다. + // + // 2026-09-03: 레퍼런스는 `ANT_FLOOR_REF=True` 로 **전벽·후벽 모두** 바닥 기준인데 + // 여기는 양쪽 다 인접 골을 쓰고 있었다. 실측(HUMAN-kai v1)에서 이 한 곳 때문에 + // 전벽이 Python 4 → Kotlin 13, 7 → 13 처럼 크게 어긋났다. + val floorIdx: Int? = + if (spanE >= spanS) { + var mi = spanS + for (i in spanS..spanE) if (sig[i] < sig[mi]) mi = i + mi + } else null + val scored = mutableListOf() for ((p, ctype) in candIdxType) { // view 확장은 경계 peak 확정 전용 — 수용은 [lo, hi] 로 제한. if (p !in lo..hi) continue - val vIdx = adjacentValley(sig, p, edge) + val vIdx = floorIdx ?: adjacentValley(sig, p, edge) val prom = sig[p] - sig[vIdx] val gate = if (ctype == CType.PEAK) params.minPeakProm else params.minShoulderProm if (prom <= 0 || prom < gate) continue diff --git a/app/src/test/java/com/medithings/vesiscan/managers/AnchorMeasureParityTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/AnchorMeasureParityTest.kt index 17f85fc..9ebe3bc 100644 --- a/app/src/test/java/com/medithings/vesiscan/managers/AnchorMeasureParityTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/managers/AnchorMeasureParityTest.kt @@ -10,34 +10,21 @@ import org.junit.Test * [AnchorSelectionParityTest] 가 **규칙**을 보는 것과 달리 여기는 **입력 지표**를 본다. * 규칙이 맞아도 `nch`·`ch3_rate`·`cap_frac` 이 어긋나면 다른 위치를 고른다. * - * ## 현재 상태 (2026-09-03) — 격리 중 - * 정렬 코드가 아니라 그 **아래의 벽 검출**(`MethodDRunner`)이 현재 Python 레퍼런스와 - * 어긋난다. mean-scan · CCC off 로 잰 실측값: + * ## 상태 (2026-09-03) — **전 항목 일치** * - * | | Python | Kotlin | + * 한때 앱의 벽 검출·BV 가 레퍼런스와 어긋나 격리했으나, 원인 다섯 곳을 고쳐 지금은 + * nch · ch3 · ch3_rate · cap_frac 이 모두 Python 과 일치한다. 고친 내역: + * + * | # | 어긋난 곳 | 고친 값 | * |---|---|---| - * | cm=0 CH0·CH1·CH5 | — | 소수점까지 일치 | - * | cm=0 CH2 | ant 13.913 · span 24~61 | ant **18.559** · span **33~60** | - * | cm=0 CH4 | span 31~54 | span **23**~54 | - * | cm=1 CH1 | ant 4.486 | ant **13.188** | - * | cm=1 CH2 | ant 7.468 | ant **12.816** | - * | cm=1 **CH3** | (14.691, 66.294, 29, 64) | **미검출** | - * | cm=1 판정 | nch=4 · ch3=O | nch=3 · ch3=**X** | - * | cm=0 cap_frac | 0.63899 | 0.67479 | - * | cm=0 BV | 410.6 mL | 470.5 mL | + * | 1 | `d_max` | 10 → **15** (레퍼런스 갱신 미반영) | + * | 2 | `inward_walk_win` | 3 → **4** | + * | 3 | prominence 기준점 | 인접 골 → **span 바닥**(`ANT_FLOOR_REF`) | + * | 4 | cap 축 붕괴 폴백 | **누락** → `CAP_AXIS_RATIO_MAX` 구현 | + * | 5 | dps · delay | 전역 상수 → **프리셋 유도**(v*=1.936/6.85, r*=1.897/7.651) | * - * cm=1 은 레퍼런스에서 유력 후보(CH3 검출·nch 최대)인데 앱에서는 후보 자격조차 없다. - * 정렬 선택이 이 값들 위에 서 있으므로, 검출이 맞을 때까지 앱의 선택 결과는 - * 레퍼런스의 선택 결과가 아니다. - * - * ## 왜 통과하도록 완화하지 않았나 - * 기대값을 코틀린 값으로 바꾸면 초록이 되지만, 그 순간 이 시험은 "코틀린이 코틀린과 - * 같다"만 확인하게 된다. 대조 시험의 존재 이유가 사라진다. 검출 포팅이 정리되면 - * `@Ignore` 를 떼면 그대로 게이트가 된다. - * - * ## 알아 둘 것 - * BV **코어** 자체는 맞다 — 벽을 고정해 넣으면 Python `estimate_bv`(ellipse_cap_height= - * true · cap_fit='specific')와 마지막 자리까지 일치한다. 어긋나는 것은 그 앞의 검출이다. + * 3 번이 전벽 선택을(Python 4 → Kotlin 13 처럼) 크게 흔들었고, 4 번이 BV 를 14.9 mL + * 낮추고 있었다. 5 번은 같은 sample index 를 다른 깊이로 매핑해 좌표부터 어긋나게 했다. * * 데이터: HUMAN-kai / VBT26050202(=V1) SUPINE_ALIGN 0cm·1cm, 각 22 cycle × 6ch × 100. * `align_cm{n}.csv` = cycle,channel,s0..s99 (정수 ADC 원본) @@ -86,7 +73,6 @@ class AnchorMeasureParityTest { } /** 전체 지표 대조 — 검출 포팅 차이로 격리 중. 위 KDoc 표가 실측 차이다. */ - @Ignore("MethodD 벽 검출이 현재 Python 레퍼런스와 불일치 — 정렬 코드가 아니라 그 아래 단계. 상세는 클래스 KDoc.") @Test fun measureMatchesPythonOnRealSession() { val saved = PiezoHW.activePreset diff --git a/app/src/test/java/com/medithings/vesiscan/managers/BvProductPathParityTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/BvProductPathParityTest.kt new file mode 100644 index 0000000..6de2555 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/BvProductPathParityTest.kt @@ -0,0 +1,59 @@ +package com.medithings.vesiscan.managers + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * BV 제품 경로 Python 대조 — 벽을 고정해 넣어 **검출과 무관하게** BV 만 검증한다. + * + * 입력은 Kai VBT26050202 scan #0 의 post-inset walls (Python 분석에서 뽑은 고정값). + * 기대값은 `runners.estimate_bv(walls, hw_v1)` — 제품 경로 그대로다. + * + * ## 이 시험이 지키는 것 + * `CAP_AXIS_RATIO_MAX` **cap 축 붕괴 폴백**이다. 타원 fit 이 납작하게 무너지면 + * (b_si/c_ap < 0.85) 타원 dome 으로 낸 cap 이 실제보다 얕아지는데, 레퍼런스는 그때 + * 타원 cap 을 끄고 다시 계산한 값을 쓴다. 이 폴백이 빠져 있으면 여기서 140.5 가 나온다 + * (실제로 2026-09-03 까지 그랬다 — 14.9 mL 과소). + * + * 정렬의 `cap_frac` tie-break 도 같은 값을 쓰므로, 이 시험이 깨지면 부착 위치 선택도 + * 함께 어긋난다. + */ +class BvProductPathParityTest { + + /** Python `runners.estimate_bv(walls, get_hw('v1'))` 결과. */ + @Test + fun estimateBvMatchesPythonProductPath() { + val saved = PiezoHW.activePreset + PiezoHW.activePreset = PiezoHW.DevicePreset.V1 + try { + // 제품 경로는 [estimateBv] 다 — Python `runners.estimate_bv` 대응. + // (`estimateBladderVolume6ch` 는 `estimate_bladder_volume_6ch` 대응이라 + // cap 축 폴백을 포함하지 않는다. 분담이 Python 과 같다.) + // + // 입력 walls 는 **이미 inset 이 적용된** 값이다. Python 은 2-튜플을 받으면 + // `_apply_urine_inset_one` 이 그대로 통과시키는데, 코틀린 진입점은 span 을 + // 요구하므로 ls=ant · le=post 로 주어 inset 을 항등으로 만든다 + // (ant + f·(ant−ant) = ant). + val walls = listOf( + null, + WallWithSpan(20.0, 54.0, 20, 54), + WallWithSpan(18.0, 58.0, 18, 58), + WallWithSpan(16.0, 49.0, 16, 49), + WallWithSpan(17.0, 54.0, 17, 54), + WallWithSpan(36.0, 49.0, 36, 49), + ) + val r = requireNotNull(estimateBv(walls)) + // 물리량 허용오차: SPEC §6 (|Δ|≤0.01 또는 상대 ≤1e-3) + assertEquals("volume_ml", 155.4257, r.volumeMl, 0.05) + assertEquals("bottom_h_mm", 21.8692, r.bottomHMm, 0.01) + assertEquals("top_h_mm", 16.7456, r.topHMm, 0.01) + assertEquals("V_bottom_mm3", 21905.7, r.vBottomMm3, 10.0) + assertEquals("V_top_mm3", 18994.9, r.vTopMm3, 10.0) + // frustum 은 cap 과 독립 — 폴백이 있건 없건 같아야 한다. + assertEquals("V_frustum[0]", 52634.4, r.vFrustumMm3[0], 10.0) + assertEquals("V_frustum[1]", 61890.7, r.vFrustumMm3[1], 10.0) + } finally { + PiezoHW.activePreset = saved + } + } +}