Files
janik 14cdf4c7ee Add balanced quadtree grid engine (adaptive cells, phase 1)
fill_resistance/quadtree.py decomposes a layer's fine copper mask into
2:1-balanced power-of-two leaves: boundary and keep-fine cells stay at
the fine size, interiors coarsen with their Chebyshev distance to the
nearest feature (guard factor, default 4), and an explicit enforcement
pass splits any leaf more than twice an edge-adjacent neighbor. Face
conductances use the series-half-cell rule, which reduces to the
production harmonic mean for equal sizes and EXACTLY to sigma in the
uniform limit - verified edge-for-edge against solver.build_edges and
to rel 1e-12 in R against run_solve, so the exact-value test suite
stays authoritative for this engine.

Measured (feature-dense 120x120 plate, h=50um, production AMG solver):
uniform 5.58M unknowns ~35s; adaptive guard=4 823k / ~8s at -1.1%;
guard=8 1.74M / ~12s at -0.47%. tools/adaptive_proto.py now benchmarks
the engine itself.

Not yet wired into the pipeline: phase 2 ports electrodes, barrels,
1D chains, buildup and field output onto leaves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 17:05:31 +07:00

101 lines
3.9 KiB
Python

"""Benchmark for the adaptive quadtree grid engine
(fill_resistance/quadtree.py, phase 1 of the adaptive-cell work).
.venv/Scripts/python tools/adaptive_proto.py
Solves a feature-dense 120x120 mm plate (400 holes) at h = 50 um on the
uniform grid and on the balanced quadtree (engine defaults: guard=4,
max_block=32), using the production assembly + solver for both.
Measured 2026-07-15 on this machine:
uniform R = 0.508504 mOhm 5.58M unknowns ~30-40 s
adaptive guard=4 R = 0.502793 mOhm 823k unknowns ~8 s -1.1%
adaptive guard=8 R = 0.506102 mOhm 1.74M unknowns ~12 s -0.47%
uniform-limit check (max_block=1): rel diff 0.00e+00 vs production.
This is the ADVERSARIAL case (features at 6 mm pitch everywhere, no
smooth interior); big-pour boards coarsen far more aggressively. The
residual bias is the first-order coarse-fine interface flux - phase 4
(gradient-corrected fluxes / solution-adaptive refinement) is the
lever if tighter accuracy per leaf is needed.
"""
import sys
import time
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from fill_resistance import quadtree, raster, solver
from tests.util import NM, make_problem, strip_problem
def solve_adaptive(problem, h_mm, **kw):
stack = raster.rasterize_stack(problem, h_mm * NM)
e1, e2 = raster.electrode_masks(stack, problem)
t0 = time.perf_counter()
grid = quadtree.build_leaves(stack.masks[0],
keep_fine=(e1[0] | e2[0]), **kw)
ia, ib, g = quadtree.leaf_edges(grid, problem.sigma_s(0))
t_build = time.perf_counter() - t0
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))
t0 = time.perf_counter()
A, rhs, _ = solver._assemble(state, edges, None)
x, info = solver.solve_system(A, rhs)
t_solve = time.perf_counter() - t0
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)), grid, info, t_build, t_solve
# --- 1) uniform-limit correctness ---
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)
res = solver.run_solve(p, stack, e1, e2, 1.0, contact_model="equipotential")
R_u, *_ = solve_adaptive(p, 0.25, max_block=1)
print(f"uniform-limit check: rel diff {abs(R_u / res.R_ohm - 1):.2e}")
# --- 2) feature-dense plate at h = 50 um ---
holes = []
for i in range(20):
for j in range(20):
x, y = 3 + 6 * i, 3 + 6 * j
holes.append([(x, y), (x + 1, y), (x + 1, y + 1), (x, y + 1)])
outline = [(0, 0), (120, 0), (120, 120), (0, 120)]
big = make_problem([(outline, holes)],
rect1_mm=(0, 55, 2, 65), rect2_mm=(118, 55, 120, 65))
t0 = time.perf_counter()
stack = raster.rasterize_stack(big, 0.05 * NM)
e1, e2 = raster.electrode_masks(stack, big)
ref = solver.run_solve(big, stack, e1, e2, 1.0,
contact_model="equipotential")
t_ref = time.perf_counter() - t0
print(f"uniform : R = {ref.R_ohm * 1e3:.6g} mOhm, "
f"{ref.solve_info.n_unknowns} unknowns, {t_ref:.1f} s "
f"({ref.solve_info.method})")
R_a, grid, info, t_b, t_s = solve_adaptive(big, 0.05)
print(f"adaptive: R = {R_a * 1e3:.6g} mOhm, {info.n_unknowns} unknowns, "
f"build {t_b:.1f} s + solve {t_s:.1f} s ({info.method})")
print(f"rel diff {abs(R_a / ref.R_ohm - 1):.2e}, "
f"{ref.solve_info.n_unknowns / info.n_unknowns:.1f}x fewer unknowns, "
f"max leaf {int(grid.size.max())} cells, balanced="
f"{quadtree.balanced(grid)}")