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:
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user