test(bv): golden test framework — Python vs Kotlin 33 cases 자동 대조

사용자 제안 (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 추가만으로 자동 검사 대상 편입.
This commit is contained in:
2026-07-21 09:20:38 +09:00
parent 9d3d1c4b6d
commit 10809af7b5
@@ -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<List<DoubleArray>> {
val root = JsonParser.parseReader(File(path).bufferedReader()).asJsonObject
val recs = root.getAsJsonArray("records")
val cycles = mutableListOf<List<DoubleArray>>()
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<Int, DoubleArray>()
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<DoubleArray>>): List<DoubleArray> {
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<DoubleArray>): Map<String, Any?> {
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<Any>(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<String, Any?>? = bv?.let {
LinkedHashMap<String, Any?>().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<String, Any?>()
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<Map<String, Any?>>()
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)")
}
}