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>
This commit is contained in:
+45
-124
@@ -1,27 +1,25 @@
|
||||
"""Prototype: quadtree-adaptive grid for the fill-resistance solver.
|
||||
"""Benchmark for the adaptive quadtree grid engine
|
||||
(fill_resistance/quadtree.py, phase 1 of the adaptive-cell work).
|
||||
|
||||
Coarsens the existing fine raster bottom-up (power-of-two blocks that are
|
||||
fully copper away from electrodes, erosion-graded per level), builds the
|
||||
leaf graph fully vectorized via an id-grid (face conductance
|
||||
g = sigma * overlap / mean-size, which reduces EXACTLY to the production
|
||||
sigma in the uniform limit), and reuses the production assembly + AMG
|
||||
solver. Run from the repo root: .venv/Scripts/python tools/adaptive_proto.py
|
||||
.venv/Scripts/python tools/adaptive_proto.py
|
||||
|
||||
Measured 2026-07-15 (120x120 mm plate, 400 holes, h = 50 um; adversarial:
|
||||
features everywhere, so geometric refinement has no smooth interior):
|
||||
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.
|
||||
|
||||
uniform R = 0.508504 mOhm 5.58M unknowns 27 s (reference)
|
||||
max_block=4 R = 0.506368 mOhm 474k unknowns 3 s -0.42% 12x
|
||||
max_block=8 R = 0.503386 mOhm 253k unknowns 1 s -1.0% 22x
|
||||
max_block=16 R = 0.497873 mOhm 213k unknowns 1 s -2.1% 26x
|
||||
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.
|
||||
|
||||
Coarsening biases R low (coarse cells overestimate conductance where the
|
||||
field curves); a production version needs true 2:1 balancing + a guard
|
||||
band, and optionally one residual-driven refine pass, to push the
|
||||
max_block=4 accuracy to larger blocks. On big-pour boards (smooth
|
||||
interiors) the unknown ratios are far higher than on this geometry.
|
||||
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
|
||||
@@ -31,127 +29,49 @@ import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from fill_resistance import raster, solver
|
||||
from fill_resistance import quadtree, raster, solver
|
||||
from tests.util import NM, make_problem, strip_problem
|
||||
|
||||
MAX_BLOCK = 32 # coarsest leaf = 32 x 32 fine cells
|
||||
|
||||
|
||||
def build_leaves(mask, e1, e2, max_block=MAX_BLOCK):
|
||||
"""Greedy top-down coarsening. Returns (y0, x0, size) per leaf plus
|
||||
an id_grid at fine resolution (-1 = empty)."""
|
||||
ny, nx = mask.shape
|
||||
pad_y = (-ny) % max_block
|
||||
pad_x = (-nx) % max_block
|
||||
m = np.pad(mask, ((0, pad_y), (0, pad_x)))
|
||||
coars = m & ~np.pad(e1 | e2, ((0, pad_y), (0, pad_x)))
|
||||
NY, NX = m.shape
|
||||
|
||||
from scipy import ndimage
|
||||
id_grid = np.full((NY, NX), -1, dtype=np.int64)
|
||||
covered = np.zeros((NY, NX), dtype=bool)
|
||||
y0s, x0s, sizes = [], [], []
|
||||
nid = 0
|
||||
levels = []
|
||||
red = coars.copy()
|
||||
s = 1
|
||||
while s < max_block:
|
||||
red = red.reshape(red.shape[0] // 2, 2, red.shape[1] // 2, 2
|
||||
).all(axis=(1, 3))
|
||||
s *= 2
|
||||
# grading: a size-s block must sit in an all-copper 3x3 block
|
||||
# neighborhood at its own level, so leaf sizes step down smoothly
|
||||
# toward boundaries (guard band + approximate 2:1 balance)
|
||||
graded = ndimage.binary_erosion(red, np.ones((3, 3), dtype=bool))
|
||||
levels.append((s, graded))
|
||||
for s, allc in reversed(levels):
|
||||
cov_k = covered.reshape(NY // s, s, NX // s, s).any(axis=(1, 3))
|
||||
cand = allc & ~cov_k
|
||||
ii, jj = np.nonzero(cand)
|
||||
for i_, j_ in zip(ii, jj):
|
||||
id_grid[i_ * s:(i_ + 1) * s, j_ * s:(j_ + 1) * s] = nid
|
||||
y0s.append(i_ * s)
|
||||
x0s.append(j_ * s)
|
||||
sizes.append(s)
|
||||
nid += 1
|
||||
covered |= np.repeat(np.repeat(cand, s, axis=0), s, axis=1)
|
||||
fi, fj = np.nonzero(m & ~covered)
|
||||
n_fine = len(fi)
|
||||
id_grid[fi, fj] = nid + np.arange(n_fine)
|
||||
y0s.extend(fi.tolist())
|
||||
x0s.extend(fj.tolist())
|
||||
sizes.extend([1] * n_fine)
|
||||
return (np.array(y0s), np.array(x0s), np.array(sizes),
|
||||
id_grid[:ny, :nx])
|
||||
|
||||
|
||||
def leaf_edges(id_grid, sizes, sigma):
|
||||
"""All leaf-leaf face conductances, vectorized: count shared fine
|
||||
faces per leaf pair (= overlap length w), g = sigma * w / mean(sa, sb).
|
||||
Uniform limit: w = 1, sizes 1 -> g = sigma (identical to production)."""
|
||||
aa, bb, ww = [], [], []
|
||||
n = len(sizes)
|
||||
for sl_a, sl_b in ((np.s_[:, :-1], np.s_[:, 1:]),
|
||||
(np.s_[:-1, :], np.s_[1:, :])):
|
||||
a = id_grid[sl_a].ravel()
|
||||
b = id_grid[sl_b].ravel()
|
||||
ok = (a >= 0) & (b >= 0) & (a != b)
|
||||
key = a[ok] * n + b[ok]
|
||||
uniq, counts = np.unique(key, return_counts=True)
|
||||
ia = uniq // n
|
||||
ib = uniq % n
|
||||
g = sigma * counts / (0.5 * (sizes[ia] + sizes[ib]))
|
||||
aa.append(ia)
|
||||
bb.append(ib)
|
||||
ww.append(g)
|
||||
return (np.concatenate(aa), np.concatenate(bb), np.concatenate(ww))
|
||||
|
||||
|
||||
def solve_adaptive(problem, h_mm, max_block=MAX_BLOCK):
|
||||
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()
|
||||
y0, x0, sizes, id_grid = build_leaves(stack.masks[0], e1[0], e2[0],
|
||||
max_block)
|
||||
a, b, w = leaf_edges(id_grid, sizes, problem.sigma_s(0))
|
||||
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
|
||||
|
||||
n = len(sizes)
|
||||
state = np.ones(n, dtype=np.uint8)
|
||||
fine_ids = id_grid[e1[0]]
|
||||
state[fine_ids[fine_ids >= 0]] = 2
|
||||
fine_ids = id_grid[e2[0]]
|
||||
state[fine_ids[fine_ids >= 0]] = 3
|
||||
|
||||
edges = solver.Edges(a=a, b=b, w=w,
|
||||
via_index=np.full(len(a), -1, dtype=np.int32))
|
||||
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, idx = solver._assemble(state, edges, None)
|
||||
A, rhs, _ = solver._assemble(state, edges, None)
|
||||
x, info = solver.solve_system(A, rhs)
|
||||
t_solve = time.perf_counter() - t0
|
||||
|
||||
V = np.zeros(n)
|
||||
V = np.zeros(grid.n)
|
||||
V[state == 2] = 1.0
|
||||
V[state == 1] = x
|
||||
Ie = w * (V[a] - V[b])
|
||||
sa, sb = state[a], state[b]
|
||||
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())
|
||||
R = 1.0 / (0.5 * (I1 + I2))
|
||||
mismatch = abs(I1 - I2) / max(abs(I1), abs(I2))
|
||||
return R, n, info, t_build, t_solve, mismatch
|
||||
return 1.0 / (0.5 * (I1 + I2)), grid, info, t_build, t_solve
|
||||
|
||||
|
||||
# --- 1) uniform-limit correctness: max_block=1 must equal production ---
|
||||
# --- 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, n_u, *_ = solve_adaptive(p, 0.25, max_block=1)
|
||||
print(f"uniform-limit check: production R={res.R_ohm:.12g}, "
|
||||
f"prototype R={R_u:.12g}, rel diff {abs(R_u / res.R_ohm - 1):.2e}")
|
||||
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) the payoff case: 120x120 plate, 400 holes, h = 50 um ---
|
||||
# --- 2) feature-dense plate at h = 50 um ---
|
||||
holes = []
|
||||
for i in range(20):
|
||||
for j in range(20):
|
||||
@@ -167,13 +87,14 @@ 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"\nuniform 50um : R = {ref.R_ohm * 1e3:.6g} mOhm, "
|
||||
f"{ref.solve_info.n_unknowns} unknowns, {t_ref:.1f} s total "
|
||||
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})")
|
||||
|
||||
for mb in (4, 8, 16, 32):
|
||||
R_a, n_a, info, t_b, t_s, mm = solve_adaptive(big, 0.05, max_block=mb)
|
||||
print(f"adaptive mb={mb:2d}: R = {R_a * 1e3:.6g} mOhm, "
|
||||
f"{info.n_unknowns:8d} unknowns, build {t_b:.1f} s + "
|
||||
f"solve {t_s:.1f} s, rel diff {abs(R_a / ref.R_ohm - 1):.2e}, "
|
||||
f"ratio {ref.solve_info.n_unknowns / info.n_unknowns:.0f}x")
|
||||
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)}")
|
||||
|
||||
Reference in New Issue
Block a user