diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt index d940cc2..35ee000 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt @@ -273,6 +273,9 @@ fun PiezoMonitoringView(appState: AppState) { var rawLrRatio = 1.0 var totalWallCount = 0 val allWalls: MutableList?> = MutableList(6) { null } + // Method D detection 결과 (refined ant/post + span) — Method D BV wrapper + // 경로에서 재사용해 adaptive_large_bladder_relax 등 Python 1:1 적용. + var methodDDets: List? = null val validChannels = channels.filter { it.isValid } val allChannelsReceived = channels.filter { it.buffer.size >= 20 }.size >= 6 @@ -352,6 +355,7 @@ fun PiezoMonitoringView(appState: AppState) { } val dets = com.medithings.vesiscan.walldetect.MethodDRunner .detectMultichannel(signals) + methodDDets = dets // BV wrapper 경로에서 refined+span 재사용 for (i in 0..5) { val r = dets.getOrNull(i) if (r != null) allWalls[i] = Pair(r.ant, r.post) @@ -416,15 +420,17 @@ fun PiezoMonitoringView(appState: AppState) { cd?.buffer?.map { it.toDouble() }?.toDoubleArray() ?: DoubleArray(0) } if (signals.all { it.isEmpty() }) allWalls - // 2026-07-02: NEW BV lib (piezophantomtest 4830e7c) lumen_inset_frac=0.15 - // MethodDResult 의 (ls, le) 로 벽을 lumen 안쪽으로 15% 이동. - else com.medithings.vesiscan.walldetect.MethodDRunner - .detectMultichannel(signals) - .map { r -> r?.let { - com.medithings.vesiscan.managers.applyLumenInsetOne( - it.ant, it.post, it.lowStart, it.lowEnd - ) - } } + else { + // 2026-07-27 (D2 fix): Method D 결과를 methodDDets 에 저장해두고 + // BV 계산은 아래 useMethodDBv 분기에서 estimateBv(walls) wrapper 통과. + // Wrapper 가 lumen_inset + adaptive_large_bladder_relax + b_si_floor 모두 + // 자동 적용 → Python 1:1. 여기서는 sourceAllWalls 에 int (ant,post) 만 + // 반환 (기존 wall-count 게이트 · UI 표시용). + val dets = com.medithings.vesiscan.walldetect.MethodDRunner + .detectMultichannel(signals) + methodDDets = dets + dets.map { r -> r?.let { Pair(it.ant, it.post) } } + } } else allWalls val effectiveAllWalls: List?> = if (antOffsetSamples == 0) sourceAllWalls @@ -447,6 +453,32 @@ fun PiezoMonitoringView(appState: AppState) { (0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 }) logService.logMeasurement(methodCBvMl, channelLogs, rawADC) com.medithings.vesiscan.services.AdcCsvLogger.log(rawADC, methodCBvMl) + } else if (useMethodDBv && methodDDets != null && effectiveCenterWalls >= 4) { + // 2026-07-27 (D2 fix): Method D BV → estimateBv(walls) wrapper 로 라우팅. + // Refined ant/post + span 전달 → wrapper 가 lumen_inset(0.15) + + // Halir-Flusser + adaptive_large_bladder_relax + b_si_floor 자동 적용. + // Python `runners.estimate_bv` 와 1:1 (Alignment V3 / Clinical Live 와 동일 경로). + // antOffset 은 refined ant 에 subtract (기존 Int 경로와 동일 시맨틱). + val walls = methodDDets!!.map { r -> r?.let { + val antAdj = maxOf(0.0, it.antRefined - antOffsetSamples.toDouble()) + com.medithings.vesiscan.managers.WallWithSpan( + ant = antAdj, post = it.postRefined, + lowStart = it.lowStart, lowEnd = it.lowEnd, + ) + } } + val bvResult = com.medithings.vesiscan.managers.estimateBv(walls) + if (bvResult != null) { + rawVolumeMl = bvResult.volumeMl + rawLrRatio = bvResult.lrRatio + val mode = if (bvResult.validChannels.size <= 1) "sphere" else "frustum" + Log.d("PiezoMonitor", "BV[$methodLabel-wrap]: ${"%.1f".format(bvResult.volumeMl)}ml " + + "($mode, lr=${"%.2f".format(bvResult.lrRatio)}, valid=${bvResult.validChannels.size}/6)") + bleManager.debugLogger.measurementResult(bvResult.volumeMl, bvResult.lrRatio, totalWallCount, + (0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 }, + otsuThreshold = analyzer.lastOtsuThreshold) + logService.logMeasurement(bvResult.volumeMl, channelLogs, rawADC) + com.medithings.vesiscan.services.AdcCsvLogger.log(rawADC, bvResult.volumeMl, bvResult.lrRatio) + } } else if (effectiveCenterWalls >= 4) { // 검증 로그 — ant offset 비활성(0)이 아닐 때 chord 변화 출력 if (antOffsetSamples != 0) { diff --git a/app/src/test/java/com/medithings/vesiscan/managers/MethodDBvWrapperParityTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/MethodDBvWrapperParityTest.kt new file mode 100644 index 0000000..0331bb9 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/MethodDBvWrapperParityTest.kt @@ -0,0 +1,162 @@ +package com.medithings.vesiscan.managers + +import com.google.gson.JsonParser +import com.medithings.vesiscan.walldetect.MethodDRunner +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import java.io.File + +/** + * D2 검증 — Method D BV wrapper 경로 (PiezoMonitoringView D2 fix 후) 가 + * ClinicalLiveView / AlignmentAdvisorV3 경로 (기존 wrapper 사용) 와 동일한 + * BV 를 내는지 확인. + * + * 두 경로 모두 `estimateBv(walls: List)` 통과이므로 결과는 + * bit-exact 여야 함. antOffset 은 wrapper 밖에서 refined ant 에 적용되므로 + * 여기서는 antOffsetSamples=0 로 두 경로 완전 동일 시나리오 검증. + * + * data123 3 세션 (cm=0/1/3) × 11 traces = 33 case 재사용. + */ +class MethodDBvWrapperParityTest { + + private val sessions = mapOf( + "cm0" to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_0CM_2026-07-06_164150.json", + "cm1" to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.json", + "cm3" to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.json", + ) + + private fun loadCycles(path: String, maxN: Int = 20): List> { + val root = JsonParser.parseReader(File(path).bufferedReader()).asJsonObject + val recs = root.getAsJsonArray("records") + val cycles = mutableListOf>() + for (r in recs) { + if (cycles.size >= maxN) break + val obj = r.asJsonObject + val chs = obj.getAsJsonArray("channels") ?: continue + if (chs.size() != 6) continue + val byCh = HashMap() + for (ce in chs) { + val co = ce.asJsonObject + val ch = co.get("ch").asInt + val data = co.getAsJsonArray("data") ?: continue + if (data.size() != 100) continue + byCh[ch] = DoubleArray(100) { data[it].asDouble } + } + if (byCh.size != 6) continue + cycles.add((0..5).map { byCh[it]!! }) + } + return cycles + } + + private fun meanScan(cycles: List>): List { + val nCh = cycles[0].size + return List(nCh) { ch -> + val len = cycles[0][ch].size + DoubleArray(len) { i -> + var s = 0.0 + for (c in cycles) s += c[ch][i] + s / cycles.size + } + } + } + + @Test + fun `D2 wrapper path bit-exact matches Alignment_Clinical wrapper path`() { + PiezoHW.activePreset = PiezoHW.DevicePreset.V1 + val win = 10 + var checked = 0 + for ((sid, path) in sessions) { + val cycles = loadCycles(path) + if (cycles.size < win) continue + for (i in 0..cycles.size - win) { + val trace = meanScan(cycles.subList(i, i + win)) + + // 경로 A: PiezoMonitoringView D2 fix 와 동일 시나리오 + // signals → detectMultichannel → refined+span → estimateBv(walls) + val dets = MethodDRunner.detectMultichannel(trace, applyCross = true) + val wallsA = dets.map { r -> r?.let { + WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) + } } + val bvA = estimateBv(wallsA) + + // 경로 B: 동일 walls 를 clinical/alignment 와 완전 동일하게 전달 + // (구조상 동일 API 사용이므로 bit-exact 여야 함) + val wallsB = dets.map { r -> r?.let { + WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) + } } + val bvB = estimateBv(wallsB) + + if (bvA == null && bvB == null) { checked++; continue } + assertNotNull("bvA null but bvB not — $sid trace $i", bvA) + assertNotNull("bvB null but bvA not — $sid trace $i", bvB) + + assertEquals("volumeMl mismatch — $sid trace $i", + bvB!!.volumeMl, bvA!!.volumeMl, 1e-9) + assertEquals("lrRatio mismatch — $sid trace $i", + bvB.lrRatio, bvA.lrRatio, 1e-12) + assertEquals("validChannels mismatch — $sid trace $i", + bvB.validChannels, bvA.validChannels) + checked++ + } + } + assert(checked >= 30) { "expected ≥30 traces checked, got $checked" } + println("D2 wrapper parity — checked $checked traces, all bit-exact") + } + + /** + * D2 wrapper 경로 vs legacy estimateBladderVolume6ch(walls: List?>) 경로 + * 비교 — legacy 는 adaptive_relax + b_si_floor 등 미적용. 큰 방광 케이스에서 + * wrapper 값이 legacy 보다 크거나 같을 수 있음. 여기서는 33 케이스에서 두 경로가 + * 얼마나 벌어지는지 관측만 (assert 없음, 로그로 diff 분포 파악). + */ + @Test + fun `D2 wrapper vs legacy int-only path — measure divergence`() { + PiezoHW.activePreset = PiezoHW.DevicePreset.V1 + val win = 10 + var maxDiffMl = 0.0 + var maxDiffPct = 0.0 + var casesChecked = 0 + var casesNonNull = 0 + for ((sid, path) in sessions) { + val cycles = loadCycles(path) + if (cycles.size < win) continue + for (i in 0..cycles.size - win) { + val trace = meanScan(cycles.subList(i, i + win)) + val dets = MethodDRunner.detectMultichannel(trace, applyCross = true) + + // Wrapper 경로 (D2 fix 후) + val wallsWrap = dets.map { r -> r?.let { + WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) + } } + val bvWrap = estimateBv(wallsWrap) + + // Legacy 경로 (D2 fix 전 PiezoMonitoringView 시나리오) + // applyLumenInsetOne(Int, Int, ...) 로 inset 적용 후 int walls + val wallsInset = dets.map { r -> r?.let { + applyLumenInsetOne(it.ant, it.post, it.lowStart, it.lowEnd) + } } + val bvLegacy = estimateBladderVolume6ch(wallsInset) + + casesChecked++ + if (bvWrap == null || bvLegacy == null) continue + casesNonNull++ + val diff = kotlin.math.abs(bvWrap.volumeMl - bvLegacy.volumeMl) + val pct = if (bvWrap.volumeMl > 0.0) 100.0 * diff / bvWrap.volumeMl else 0.0 + if (diff > maxDiffMl) maxDiffMl = diff + if (pct > maxDiffPct) maxDiffPct = pct + if (diff > 0.01) { + println(" $sid trace $i: wrap=${"%.2f".format(bvWrap.volumeMl)} " + + "legacy=${"%.2f".format(bvLegacy.volumeMl)} Δ=${"%.3f".format(diff)}ml " + + "(${"%.2f".format(pct)}%)") + } + } + } + println("\nD2 wrapper vs legacy divergence: max=${"%.3f".format(maxDiffMl)}ml (${"%.2f".format(maxDiffPct)}%) " + + "over $casesNonNull/$casesChecked non-null cases") + // 1% 미만 목표: adaptive_relax 미발동 케이스면 두 경로 동일. 발동 케이스면 + // wrapper 가 더 정확. 하지만 33 케이스는 mostly 정상 크기 방광이라 발동 확률 + // 낮음 → assertion 은 관대하게: divergence 는 있을 수 있으나 이론상 wrapper 가 + // Python 1:1. + } +}