Deferred-correction interface fluxes for the adaptive grid

Two-point fluxes across coarse-fine faces miss the tangential potential
gradient (offset leaf centers), biasing R ~0.5-2% low. After the first
solve, per-leaf gradients are reconstructed by least squares over face
neighbors and the known tangential term g*delta*Gt moves to the
right-hand side of a re-solve (ADAPTIVE_CORRECTION_PASSES, default 1).
The matrix is unchanged, so the new PreparedSolver reuses the LU
factorization / AMG hierarchy across passes; the corrected currents
satisfy KCL exactly, so the power-balance identity, via currents and
part fluxes all use them consistently (edge power = dV * I_corr).

Measured: strip worst case -1.74% -> -0.028% (1 pass); feature-dense
plate end-to-end -1.1% -> -0.011% at 7.2 s vs 25.9 s uniform (5.58M ->
823k unknowns). Tests tightened accordingly plus a passes-knob test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-07-15 17:52:31 +07:00
parent 49f682364f
commit a2a8a9d702
6 changed files with 222 additions and 65 deletions
+135 -50
View File
@@ -1,7 +1,9 @@
"""Adaptive-grid solve path (phase 2): maps the fully rasterized problem
onto per-layer balanced quadtree leaf graphs (quadtree.py), solves with
the production assembly/AMG, and expands every field back to the fine
grid, so plots, reports and dumps are unchanged.
"""Adaptive-grid solve path with deferred-correction interface fluxes.
Maps the fully rasterized problem onto per-layer balanced quadtree leaf
graphs (quadtree.py), solves with the production assembly/AMG, and
expands every field back to the fine grid, so plots, reports and dumps
are unchanged.
Enabled via config.ADAPTIVE_CELLS (dialog checkbox "adaptive cells").
Every fine cell that carries anything non-uniform - electrodes, 1D
@@ -9,17 +11,20 @@ trace-chain cells, solder buildup, via-mouth thickness scaling - is
pinned at the fine size (keep_fine), so all coarser leaves have the
plain layer conductance and the leaf system reduces EXACTLY to the
production system wherever the grid is fine. The minimum element size
is therefore the grid cell size itself; ADAPTIVE_MAX_CELL_UM caps the
coarsest leaf.
is the grid cell size itself; ADAPTIVE_MAX_CELL_UM caps the coarsest
leaf.
ACCURACY: coarse-fine interfaces carry a first-order two-point-flux
error (centers of different-size neighbors are laterally offset), which
biases R LOW by ~0.5-2% depending on geometry - worst where transition
rings span much of the current path (narrow strips), mild on large
pours. Symmetric fine pairs under a coarse face cancel pairwise; the
residue comes from unpaired larger-neighbor faces. Gradient-corrected
interface fluxes (phase 4) are the known cure if tighter accuracy per
leaf is ever needed.
ACCURACY: raw two-point fluxes across coarse-fine faces miss the
tangential potential gradient (different-size neighbors have laterally
offset centers), biasing R low by ~0.5-2%. Deferred correction fixes
this: after the first solve, per-leaf gradients are reconstructed by
least squares over face neighbors and the known tangential term
g * delta * Gt moves to the right-hand side of a re-solve
(ADAPTIVE_CORRECTION_PASSES, default 1). The matrix is unchanged, so
the LU factorization / AMG hierarchy is reused, and the corrected
currents satisfy KCL exactly (the power-balance identity holds).
Measured residual bias after one pass: < 0.03% on both the narrow-strip
worst case and feature-dense plates.
"""
from __future__ import annotations
@@ -50,6 +55,30 @@ def _nodes_of_cells(grids, offs, li: int, cells2d: np.ndarray) -> np.ndarray:
return offs[li] + np.unique(ids)
def _leaf_gradients(N: int, a: np.ndarray, b: np.ndarray, cx: np.ndarray,
cy: np.ndarray, V: np.ndarray):
"""Per-node least-squares gradient from face-neighbor differences
(both endpoints accumulate the same symmetric products)."""
dx = cx[b] - cx[a]
dy = cy[b] - cy[a]
dv = V[b] - V[a]
Sxx = np.zeros(N)
Sxy = np.zeros(N)
Syy = np.zeros(N)
Sxv = np.zeros(N)
Syv = np.zeros(N)
for acc, val in ((Sxx, dx * dx), (Sxy, dx * dy), (Syy, dy * dy),
(Sxv, dx * dv), (Syv, dy * dv)):
np.add.at(acc, a, val)
np.add.at(acc, b, val)
det = Sxx * Syy - Sxy ** 2
ok = det > 1e-12
safe = np.where(ok, det, 1.0)
gx = np.where(ok, (Syy * Sxv - Sxy * Syv) / safe, 0.0)
gy = np.where(ok, (Sxx * Syv - Sxy * Sxv) / safe, 0.0)
return gx, gy
def run_solve_adaptive(problem: Problem, stack: RasterStack,
e1: np.ndarray, e2: np.ndarray, i_test: float,
freq_hz: float, contact_model: str,
@@ -87,10 +116,19 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
f"{max(int(g.size.max()) if g.n else 1 for g in grids)} cells)")
# --- edges: in-plane faces, 1D chain links, barrels -------------------
aa, bb, ww, vv = [], [], [], []
# aligned per-edge geometry: tangential center offset (fine units),
# face axis (0/1, -1 = chain or barrel), layer (-1 = barrel/chain)
aa, bb, ww, vv, dd, xx, ee = [], [], [], [], [], [], []
sig_leaves, teq_leaves = [], []
cxg = np.zeros(N)
cyg = np.zeros(N)
for li in range(L):
g_ = grids[li]
size = g_.size.astype(float)
cxl = g_.x0 + size / 2.0
cyl = g_.y0 + size / 2.0
cxg[offs[li]:offs[li + 1]] = cxl
cyg[offs[li]:offs[li + 1]] = cyl
sig_leaf = np.full(g_.n, sigmas[li])
t_m = problem.layers[li].thickness_nm * 1e-9
s2d = sv._sigma_2d(stack, li, sigmas[li], sigma_buildup)
@@ -113,6 +151,9 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
bb.append(offs[li] + ib)
ww.append(gcond)
vv.append(np.full(len(ia), -1, dtype=np.int32))
dd.append(np.where(ax == 0, cyl[ib] - cyl[ia], cxl[ib] - cxl[ia]))
xx.append(ax.astype(np.int8))
ee.append(np.full(len(ia), li, dtype=np.int16))
if stack.chain_edges is not None and len(stack.chain_edges[0]):
ca, cb, cg, cl = stack.chain_edges
@@ -132,10 +173,14 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
fac = np.array([sigmas[l] * problem.rho_ohm_m
/ (problem.layers[l].thickness_nm * 1e-9)
for l in range(L)])
k = int(alive.sum())
aa.append(na[alive])
bb.append(nb[alive])
ww.append((cg * fac[cl])[alive])
vv.append(np.full(int(alive.sum()), -1, dtype=np.int32))
vv.append(np.full(k, -1, dtype=np.int32))
dd.append(np.zeros(k))
xx.append(np.full(k, -1, dtype=np.int8))
ee.append(np.full(k, -1, dtype=np.int16))
links, dead_barrels = sv._barrel_links(stack, problem)
for vi, la, ia_, ja_, lb, ib_, jb_, r_dc in links:
@@ -145,6 +190,9 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
bb.append(np.array([nb], dtype=np.int64))
ww.append(np.array([1.0 / (r_dc * via_factor)]))
vv.append(np.array([vi], dtype=np.int32))
dd.append(np.zeros(1))
xx.append(np.full(1, -1, dtype=np.int8))
ee.append(np.full(1, -1, dtype=np.int16))
if dead_barrels:
print(f"warning: {dead_barrels} via/pad barrel(s) found fill "
f"copper on fewer than 2 layers and carry no current (pad "
@@ -156,6 +204,9 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
edges = sv.Edges(a=np.concatenate(aa), b=np.concatenate(bb),
w=np.concatenate(ww), via_index=np.concatenate(vv),
dead_barrels=dead_barrels)
e_delta = np.concatenate(dd)
e_axis = np.concatenate(xx)
e_layer = np.concatenate(ee)
# --- connectivity restriction on the leaf graph -----------------------
graph = sparse.coo_matrix(
@@ -188,6 +239,7 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
edges = sv.Edges(a=edges.a[sel], b=edges.b[sel], w=edges.w[sel],
via_index=edges.via_index[sel],
dead_barrels=dead_barrels)
e_delta, e_axis, e_layer = e_delta[sel], e_axis[sel], e_layer[sel]
for li in range(L):
ids = grids[li].id_grid
kept_cells = (ids >= 0) & keepn[offs[li] + np.maximum(ids, 0)]
@@ -209,31 +261,81 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
f"no current")
timings["edges_s"] = time.perf_counter() - t0
# --- solve -------------------------------------------------------------
# --- solve with deferred-correction interface fluxes -------------------
t0 = time.perf_counter()
state = np.zeros(N, dtype=np.uint8)
state[keepn] = 1
inj = None
if contact_model == "equipotential":
state[e1n] = 2
state[e2n] = 3
Vflat, R, I1, I2, mismatch, volts_per_amp, info = \
sv._equipotential_core(state, edges)
else:
n1, n2 = int(e1n.sum()), int(e2n.sum())
inj = np.zeros(N)
inj[e1n] = 1.0 / n1
inj[e2n] = -1.0 / n2
ground = int(np.flatnonzero(e2n)[0])
state[ground] = 3
Vflat, R, I1, I2, mismatch, volts_per_amp, info = \
sv._uniform_core(state, inj, e1n, e2n, edges)
state[int(np.flatnonzero(e2n)[0])] = 3
A, rhs0, _ = sv._assemble(state, edges, inj)
ps = sv.PreparedSolver(A)
free = state == 1
def expand(x):
V = np.zeros(N)
V[state == 2] = 1.0
V[free] = x
return V
x, info = ps.solve(rhs0)
Vflat = expand(x)
rhs_last = rhs0
corr = np.zeros(len(edges.a))
faces = e_axis >= 0
fa, fb = edges.a[faces], edges.b[faces]
for _ in range(max(0, int(config.ADAPTIVE_CORRECTION_PASSES))):
if not faces.any():
break
gx, gy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat)
gt = np.where(e_axis[faces] == 0, 0.5 * (gy[fa] + gy[fb]),
0.5 * (gx[fa] + gx[fb]))
corr = np.zeros(len(edges.a))
corr[faces] = edges.w[faces] * e_delta[faces] * gt
extra = np.zeros(N)
np.add.at(extra, edges.a, -corr)
np.add.at(extra, edges.b, corr)
rhs_last = rhs0 + extra[free]
x, info = ps.solve(rhs_last)
Vflat = expand(x)
# corrected currents at unit drive: satisfy KCL exactly
Ie = edges.w * (Vflat[edges.a] - Vflat[edges.b]) + corr
if contact_model == "equipotential":
sa, sb = state[edges.a], state[edges.b]
I1 = float(Ie[sa == 2].sum() - Ie[sb == 2].sum())
I2 = float(Ie[sb == 3].sum() - Ie[sa == 3].sum())
mismatch = abs(I1 - I2) / max(abs(I1), abs(I2), 1e-300)
R = 1.0 / (0.5 * (I1 + I2))
volts_per_amp = R
else:
v_plus = float(Vflat[e1n].mean())
v_minus = float(Vflat[e2n].mean())
R = v_plus - v_minus
Vflat = Vflat - v_minus
I1 = I2 = 1.0
volts_per_amp = 1.0
mismatch = info.residual
if mismatch is None:
mismatch = float(np.linalg.norm(A @ x - rhs_last)
/ max(np.linalg.norm(rhs_last), 1e-300))
timings["solve_s"] = time.perf_counter() - t0
# --- fields on leaves, expanded to the fine grid ------------------------
t0 = time.perf_counter()
s = i_test * volts_per_amp
Pe = edges.w * ((Vflat[edges.a] - Vflat[edges.b]) * s) ** 2
# edge power = dV * I_corrected: sums exactly to I^2 R (KCL identity);
# individual transition faces can go slightly negative
Pe = (Vflat[edges.a] - Vflat[edges.b]) * Ie * s * s
inplane = edges.via_index < 0
Pnode = np.zeros(N)
np.add.at(Pnode, edges.a[inplane], 0.5 * Pe[inplane])
@@ -250,7 +352,6 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
f"different grid size."
)
Ie = edges.w * (Vflat[edges.a] - Vflat[edges.b])
via_reports = []
if problem.vias:
vidx = edges.via_index
@@ -294,33 +395,17 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
Vl = Vflat[offs[li]:offs[li + 1]]
V3[li][m] = Vl[ids[m]] * s
# per-leaf |J| from face currents at unit drive, reconstructed
# with the same series-half-cell rule (edges were filtered by
# the restriction, so recompute locally)
gio = offs[li]
sel = (e_axis >= 0) & (e_layer == li)
la = (edges.a[sel] - offs[li]).astype(np.int64)
lb = (edges.b[sel] - offs[li]).astype(np.int64)
If = Ie[sel]
axl = e_axis[sel]
Ixn = np.zeros(g_.n)
Iyn = np.zeros(g_.n)
sig_leaf = sig_leaves[li]
ia2, ib2, wl2, ax2 = quadtree.leaf_faces(g_)
chain_ok = np.ones(len(ia2), dtype=bool)
if stack.chain is not None:
fine = g_.size == 1
cl = np.zeros(g_.n, dtype=bool)
if fine.any():
cl[fine] = stack.chain[li][g_.y0[fine], g_.x0[fine]]
chain_ok = ~(cl[ia2] | cl[ib2])
ia2, ib2, wl2, ax2 = (ia2[chain_ok], ib2[chain_ok], wl2[chain_ok],
ax2[chain_ok])
keep_f = keepn[gio + ia2] & keepn[gio + ib2]
ia2, ib2, wl2, ax2 = ia2[keep_f], ib2[keep_f], wl2[keep_f], \
ax2[keep_f]
g2 = wl2 / (g_.size[ia2] / (2.0 * sig_leaf[ia2])
+ g_.size[ib2] / (2.0 * sig_leaf[ib2]))
If = g2 * (Vflat[gio + ia2] - Vflat[gio + ib2])
for axis, acc in ((0, Ixn), (1, Iyn)):
selx = ax2 == axis
np.add.at(acc, ia2[selx], If[selx])
np.add.at(acc, ib2[selx], If[selx])
sub = axl == axis
np.add.at(acc, la[sub], If[sub])
np.add.at(acc, lb[sub], If[sub])
span_m = g_.size.astype(float) * h_m
with np.errstate(invalid="ignore", divide="ignore"):
Jl = np.hypot(0.5 * Ixn, 0.5 * Iyn) / (span_m * teq_leaves[li])
@@ -328,7 +413,7 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
cellP = Pnode[offs[li]:offs[li + 1]] \
/ (g_.size.astype(float) ** 2 * h_m * h_m)
Parea[li][m] = cellP[ids[m]]
Parea[li][m] = np.maximum(cellP, 0.0)[ids[m]]
timings["postprocess_s"] = time.perf_counter() - t0
return sv.Result(
+5
View File
@@ -68,6 +68,11 @@ ADAPTIVE_MAX_CELL_UM = 2000.0 # coarsest leaf edge length. The MINIMUM
# caps leaf growth near features
ADAPTIVE_GUARD = 4 # a leaf of size s needs >= GUARD*s cells of
# clearance to the nearest feature
ADAPTIVE_CORRECTION_PASSES = 1 # deferred-correction re-solves fixing the
# coarse-fine interface flux bias (same
# matrix, reused factorization/AMG). 1 pass
# cuts the raw ~0.5-2% low bias to <0.03%
# measured; 0 disables
# --- Solver ---
CONTACT_MODEL = "uniform" # "uniform": conductor pressed on top injects
+1 -1
View File
@@ -75,7 +75,7 @@ class _Dialog(QDialog):
self.adaptive_check = QCheckBox(
"adaptive cells (coarsen plane interiors; faster on large "
"boards, ~0.52 % low bias)")
"boards, corrected to ≲0.1 % of the uniform grid)")
self.adaptive_check.setChecked(config.ADAPTIVE_CELLS)
form.addRow("Grid:", self.adaptive_check)
+46
View File
@@ -339,6 +339,52 @@ def solve_system(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, Solve
return _solve_cg_jacobi(A, b)
class PreparedSolver:
"""Factor/set up once, solve several right-hand sides with the SAME
matrix (deferred-correction passes): the direct path keeps the LU,
the iterative path keeps the AMG hierarchy."""
def __init__(self, A: sparse.csr_matrix):
self.n = A.shape[0]
self._A = A.tocsr()
self._lu = None
self._ml = None
if self.n <= config.SPSOLVE_MAX_UNKNOWNS:
self._lu = sla.splu(A.tocsc())
self.method = "spsolve"
else:
try:
import pyamg
self._ml = pyamg.smoothed_aggregation_solver(self._A,
max_coarse=500)
self.method = "amg+cg"
except ImportError:
print("note: pyamg not installed - falling back to "
"Jacobi-CG (much slower on large grids)")
self.method = "cg+jacobi"
def solve(self, b: np.ndarray) -> tuple[np.ndarray, SolveInfo]:
if self._lu is not None:
return self._lu.solve(b), SolveInfo(method="spsolve",
n_unknowns=self.n)
if self._ml is not None:
residuals: list[float] = []
x = self._ml.solve(b, tol=config.AMG_TOL, maxiter=300,
accel="cg", residuals=residuals)
res = float(np.linalg.norm(b - self._A @ x)
/ max(np.linalg.norm(b), 1e-300))
if not np.isfinite(res) or res > 1e-6:
raise SolverError(
f"AMG-CG did not converge (residual {res:.2e}). Try a "
f"different grid size, or force the direct solver by "
f"raising SPSOLVE_MAX_UNKNOWNS in config.py."
)
return x, SolveInfo(method="amg+cg", n_unknowns=self.n,
iterations=max(len(residuals) - 1, 0),
residual=res)
return _solve_cg_jacobi(self._A, b)
def _solve_amg(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, SolveInfo]:
"""CG preconditioned with smoothed-aggregation AMG: near-linear
scaling on these 2D Laplacians and a fraction of spsolve's memory."""