From 10809af7b5d95784aa4b51a3cf66c7667614d798 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 21 Jul 2026 09:20:38 +0900 Subject: [PATCH] =?UTF-8?q?test(bv):=20golden=20test=20framework=20?= =?UTF-8?q?=E2=80=94=20Python=20vs=20Kotlin=2033=20cases=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=8C=80=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 제안 (2026-07-21) : 최종 BV 보정계수보다 최초 divergence 단계부터 원인 을 찾아 일치. 단계별 중간값 저장 + 자동 diff + 단계별 tolerance. 파일 구성: - scratchpad/golden_dump_python.py — Python 기준 dump (33 case) - GoldenDumpTest.kt — Kotlin dump - scratchpad/golden_diff.py — 단계별 tolerance 자동 비교 Dump 단계 (stage 명명): E_walls_cross : MethodD detect + cross-channel 후 walls (refined + span) M_bv : 최종 BV (volume_ml/mm3, valid_channels, D_mm, cap_*) # 향후 확장 가능 : B (preproc), C (TGC), G (yw/zw), H (Halir 중간), etc. 허용 오차 (단계·필드별): E_walls_cross : 1e-12 (double eps 근처, ULP 노이즈 흡수) M_bv.volume_ml : 1e-3 (sub-microliter) M_bv.cap_b_si/c_ap/y0/z0 : 1e-6 (Halir 결과) M_bv.cap_mean_residual : 1e-9 (극도 정확) M_bv.valid_channels : bit-exact (정수/카테고리) ... (총 21 fields) 실행: 1. python scratchpad/golden_dump_python.py 2. ./gradlew testDevDebugUnitTest --tests "*GoldenDumpTest*" 3. python scratchpad/golden_diff.py Fixture (data123, 총 33 case): cm0 (VBT26050202 supine 0CM) × 11 trace cm1 (VBT26050202 supine 1CM) × 11 trace cm3 (VBT26040302 supine 3CM) × 11 trace 현재 상태: - 21 필드 × 33 케이스 = 693 검사 지점 모두 tolerance 내 PASS - 실질 divergence 0. BV max_diff = 3.7e-9 mL (nano-mL 수준). 향후 stage 추가 (B/C/G/H) 는 필요 시 EllipseFitSpecific.debug* 처럼 각 함수의 intermediate 를 노출하는 방식으로 확장. golden_diff.py 의 TOLERANCES 사전에 key 추가만으로 자동 검사 대상 편입. --- .../vesiscan/managers/GoldenDumpTest.kt | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 app/src/test/java/com/medithings/vesiscan/managers/GoldenDumpTest.kt diff --git a/app/src/test/java/com/medithings/vesiscan/managers/GoldenDumpTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/GoldenDumpTest.kt new file mode 100644 index 0000000..d94757b --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/GoldenDumpTest.kt @@ -0,0 +1,136 @@ +package com.medithings.vesiscan.managers + +import com.google.gson.Gson +import com.google.gson.JsonParser +import com.medithings.vesiscan.walldetect.MethodDRunner +import com.medithings.vesiscan.walldetect.algo.methodd.MethodDParams +import org.junit.Test +import java.io.File + +/** + * Golden test — Kotlin. `estimateBv` pipeline 각 단계 중간값을 dump. + * 3 세션 (cm=0/1/3) × 11 trace = 33 cases 를 Python golden 과 대조. + * + * 실행: + * 1. Python golden 생성: python scratchpad/golden_dump_python.py + * 2. Kotlin dump: ./gradlew testDevDebugUnitTest --tests "*GoldenDumpTest*" + * 3. diff : python scratchpad/golden_diff.py + */ +class GoldenDumpTest { + + 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 val outPath = "C:/Users/장동우/AppData/Local/Temp/claude/c--Projects-medilightv2android/76311e3b-2bb5-4c52-ab8a-8d89cb1052c2/scratchpad/golden_kotlin.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 + } + } + } + + private fun dumpCase(sessionId: String, traceIdx: Int, trace: List): Map { + val params = MethodDParams.DEFAULT + val results = MethodDRunner.detectMultichannel(trace, params, applyCross = true) + + // Stage E — walls_cross (refined + span) + val wallsE = results.map { r -> + r?.let { + listOf(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) + } + } + + // Stage M — final BV + val wallsForBv = results.map { r -> + r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) } + } + val bv = estimateBv(wallsForBv) + + val bvMap: Map? = bv?.let { + LinkedHashMap().apply { + put("volume_ml", it.volumeMl) + put("volume_mm3", it.volumeMm3) + put("valid_channels", it.validChannels) + put("d_mm", it.dMm.toList()) + put("d_ant_mm", it.dAntMm.toList()) + put("d_post_mm", it.dPostMm.toList()) + put("sorted_channels", it.sortedChannels) + put("bottom_h_mm", it.bottomHMm) + put("top_h_mm", it.topHMm) + put("V_bottom_mm3", it.vBottomMm3) + put("V_top_mm3", it.vTopMm3) + put("V_core_mm3", it.vCoreMm3) + put("cap_fit_status", it.capFitStatus) + put("cap_fit_points", it.capFitPoints) + put("cap_b_si_mm", it.capBSiMm) + put("cap_c_ap_mm", it.capCApMm) + put("cap_y0_mm", it.capY0Mm) + put("cap_z0_mm", it.capZ0Mm) + put("cap_mean_residual", it.capMeanResidual) + put("lr_ratio", it.lrRatio) + } + } + + val stages = LinkedHashMap() + stages["E_walls_cross"] = wallsE + stages["M_bv"] = bvMap + + return mapOf( + "session_id" to sessionId, + "trace_idx" to traceIdx, + "stages" to stages, + ) + } + + @Test + fun `golden dump all sessions`() { + PiezoHW.activePreset = PiezoHW.DevicePreset.V1 + val cases = mutableListOf>() + val win = 10 + for ((sid, path) in sessions) { + val cycles = loadCycles(path, 20) + if (cycles.size < win) { + println(" skip $sid: only ${cycles.size} cycles") + continue + } + for (i in 0..cycles.size - win) { + val tr = meanScan(cycles.subList(i, i + win)) + cases.add(dumpCase(sid, i, tr)) + println(" dumped $sid trace $i") + } + } + File(outPath).writeText(Gson().toJson(mapOf("cases" to cases))) + println("\nsaved: $outPath (${cases.size} cases)") + } +}