Files
kicad-zone-resistance/tests/test_quadtree.py
T
grabowski 0afb55216b Fix swarm-review findings: empty-layer crashes, teardrop fills, Jmag
- adaptive: skip layers with zero quadtree leaves in the connectivity
  restriction and mesh-boundary loops (IndexError on boards where a
  selected layer has no copper)
- board_io: accept ZT_TEARDROP zones as conducting copper (KiCad types
  teardrop fills ZT_TEARDROP, never ZT_COPPER, so they were dropped)
- geometry: copper_bbox uses the exact stroke bbox (centerline extrema
  + half width) instead of a 100 um chord tessellation that could
  undershoot arc/cap extrema past the raster guard margin
- solver/adaptive: reference |J| to the conduction-equivalent thickness
  sigma*rho in every branch (the uniform branch used geometric t, so AC
  plots changed scale ~rs_ratio depending on unrelated per-cell maps)
- solver/adaptive/raster: chain cells no longer show phantom sheet-face
  currents; store dl per chain link and overlay the true 1D density
  |dV|/(rho*dl) (exact at any frequency: AC scaling of link conductance
  and cross-section cancels)
- test_quadtree: compare edge lists pair-for-pair (the independent
  column sort destroyed endpoint association)
2026-07-15 19:43:21 +07:00

142 lines
5.6 KiB
Python

"""Quadtree grid engine tests (phase 1): exact uniform limit against the
production graph and solver, partition/alignment/balance invariants, and
adaptive-vs-fine R agreement."""
import numpy as np
import pytest
from scipy import ndimage
from fill_resistance import quadtree, raster, solver
from tests.util import NM, make_problem, strip_problem
def _plate_with_holes(n=5, size_mm=40.0):
holes = []
pitch = size_mm / n
for i in range(n):
for j in range(n):
x, y = pitch * (i + 0.4), pitch * (j + 0.4)
holes.append([(x, y), (x + 1, y), (x + 1, y + 1), (x, y + 1)])
outline = [(0, 0), (size_mm, 0), (size_mm, size_mm), (0, size_mm)]
return make_problem([(outline, holes)],
rect1_mm=(0, 15, 2, 25),
rect2_mm=(size_mm - 2, 15, size_mm, 25))
def _solve_on_leaves(problem, stack, e1, e2, grid):
"""Equipotential mini-solve on the leaf graph, using the production
assembly and linear solver."""
ia, ib, g = quadtree.leaf_edges(grid, problem.sigma_s(0))
state = np.ones(grid.n, dtype=np.uint8)
for e, code in ((e1[0], 2), (e2[0], 3)):
ids = grid.id_grid[e]
state[ids[ids >= 0]] = code
edges = solver.Edges(a=ia, b=ib, w=g,
via_index=np.full(len(ia), -1, dtype=np.int32))
A, rhs, _ = solver._assemble(state, edges, None)
x, _ = solver.solve_system(A, rhs)
V = np.zeros(grid.n)
V[state == 2] = 1.0
V[state == 1] = x
Ie = g * (V[ia] - V[ib])
sa, sb = state[ia], state[ib]
I1 = float(Ie[sa == 2].sum() - Ie[sb == 2].sum())
I2 = float(Ie[sb == 3].sum() - Ie[sa == 3].sum())
return 1.0 / (0.5 * (I1 + I2))
def test_uniform_limit_graph_identical():
"""max_block=1: one leaf per cell, and the edge list matches the
production in-plane graph exactly (same pairs, conductance sigma)."""
p = _plate_with_holes()
stack = raster.rasterize_stack(p, 0.5 * NM)
grid = quadtree.build_leaves(stack.masks[0], max_block=1)
assert int(stack.masks.sum()) == grid.n
assert (grid.size == 1).all()
ny, nx = stack.shape2d
flat_of_leaf = grid.y0.astype(np.int64) * nx + grid.x0
ia, ib, g = quadtree.leaf_edges(grid, p.sigma_s(0))
ours = np.stack([
np.minimum(flat_of_leaf[ia], flat_of_leaf[ib]),
np.maximum(flat_of_leaf[ia], flat_of_leaf[ib])], axis=1)
edges = solver.build_edges(stack, p, [p.sigma_s(0)])
ref = np.stack([np.minimum(edges.a, edges.b),
np.maximum(edges.a, edges.b)], axis=1)
assert ours.shape == ref.shape
assert np.array_equal(np.sort(ours.view("i8,i8"), order=["f0", "f1"],
axis=0),
np.sort(ref.view("i8,i8"), order=["f0", "f1"],
axis=0))
assert np.allclose(g, p.sigma_s(0), rtol=0, atol=0)
def test_uniform_limit_R_matches_production():
p = strip_problem(length=50, width=10, e_len=5)
stack = raster.rasterize_stack(p, 0.25 * NM)
e1, e2 = raster.electrode_masks(stack, p)
ref = solver.run_solve(p, stack, e1, e2, 1.0,
contact_model="equipotential")
stack2 = raster.rasterize_stack(p, 0.25 * NM)
e1b, e2b = raster.electrode_masks(stack2, p)
grid = quadtree.build_leaves(stack2.masks[0], max_block=1)
R = _solve_on_leaves(p, stack2, e1b, e2b, grid)
assert R == pytest.approx(ref.R_ohm, rel=1e-12)
def test_partition_alignment_and_balance():
p = _plate_with_holes()
stack = raster.rasterize_stack(p, 0.1 * NM)
mask = stack.masks[0]
grid = quadtree.build_leaves(mask, max_block=32)
# exact partition of the copper
assert int((grid.size.astype(np.int64) ** 2).sum()) == int(mask.sum())
assert (grid.id_grid >= 0).sum() == int(mask.sum())
assert not (grid.id_grid[~mask] >= 0).any()
counts = np.bincount(grid.id_grid[grid.id_grid >= 0], minlength=grid.n)
assert np.array_equal(counts, grid.size.astype(np.int64) ** 2)
# power-of-two sizes, aligned to their own size
assert np.array_equal(grid.size & (grid.size - 1),
np.zeros_like(grid.size))
assert (grid.y0 % grid.size == 0).all()
assert (grid.x0 % grid.size == 0).all()
# 2:1 balance and real coarsening (max size is geometry-limited by
# the guard distance, not by max_block, on this feature-dense plate)
assert quadtree.balanced(grid)
assert grid.n < 0.5 * int(mask.sum())
assert grid.size.max() >= 4
def test_boundary_and_keep_fine_stay_fine():
p = _plate_with_holes()
stack = raster.rasterize_stack(p, 0.1 * NM)
mask = stack.masks[0]
keep = np.zeros_like(mask)
keep[50:60, 50:60] = True
grid = quadtree.build_leaves(mask, keep_fine=keep, max_block=32)
boundary = mask & ndimage.binary_dilation(~mask)
assert (grid.size[grid.id_grid[boundary]] == 1).all()
assert (grid.size[grid.id_grid[keep & mask]] == 1).all()
def test_adaptive_R_close_to_fine():
"""Adaptive leaves reproduce the fine-uniform R within 1% on the
holey plate (features everywhere - the adversarial case)."""
p = _plate_with_holes()
stack = raster.rasterize_stack(p, 0.1 * NM)
e1, e2 = raster.electrode_masks(stack, p)
ref = solver.run_solve(p, stack, e1, e2, 1.0,
contact_model="equipotential")
stack2 = raster.rasterize_stack(p, 0.1 * NM)
e1b, e2b = raster.electrode_masks(stack2, p)
grid = quadtree.build_leaves(stack2.masks[0],
keep_fine=(e1b[0] | e2b[0]))
R = _solve_on_leaves(p, stack2, e1b, e2b, grid)
assert grid.n < 0.4 * ref.solve_info.n_unknowns
assert R == pytest.approx(ref.R_ohm, rel=0.01)