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
+7 -4
View File
@@ -144,10 +144,13 @@ SWIG API. Requires KiCad **10.0.1+**.
sets the clearance a block needs to grow). The **minimum element size sets the clearance a block needs to grow). The **minimum element size
is the grid cell size itself** (auto / dialog / `CELL_UM_OVERRIDE`); is the grid cell size itself** (auto / dialog / `CELL_UM_OVERRIDE`);
the uniform limit reproduces the normal grid exactly. Large the uniform limit reproduces the normal grid exactly. Large
speed/memory wins on big pours; the coarsefine interfaces carry a speed/memory wins on big pours. The raw coarsefine interface flux
first-order flux error that biases R **low by ~0.52 %** depending on bias (~0.52 % low) is removed by a **deferred-correction pass**
geometry (worst on narrow strips, mild on large planes). All fields (`ADAPTIVE_CORRECTION_PASSES`, default 1: reconstruct leaf gradients,
are expanded back to the fine grid for the maps and reports. move the tangential term to the RHS, re-solve on the reused
factorization/AMG hierarchy) — measured residual deviation from the
uniform grid ≲ 0.03 %, with the power-balance identity intact. All
fields are expanded back to the fine grid for the maps and reports.
## Offline / development ## Offline / development
+135 -50
View File
@@ -1,7 +1,9 @@
"""Adaptive-grid solve path (phase 2): maps the fully rasterized problem """Adaptive-grid solve path with deferred-correction interface fluxes.
onto per-layer balanced quadtree leaf graphs (quadtree.py), solves with
the production assembly/AMG, and expands every field back to the fine Maps the fully rasterized problem onto per-layer balanced quadtree leaf
grid, so plots, reports and dumps are unchanged. 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"). Enabled via config.ADAPTIVE_CELLS (dialog checkbox "adaptive cells").
Every fine cell that carries anything non-uniform - electrodes, 1D 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 pinned at the fine size (keep_fine), so all coarser leaves have the
plain layer conductance and the leaf system reduces EXACTLY to the plain layer conductance and the leaf system reduces EXACTLY to the
production system wherever the grid is fine. The minimum element size production system wherever the grid is fine. The minimum element size
is therefore the grid cell size itself; ADAPTIVE_MAX_CELL_UM caps the is the grid cell size itself; ADAPTIVE_MAX_CELL_UM caps the coarsest
coarsest leaf. leaf.
ACCURACY: coarse-fine interfaces carry a first-order two-point-flux ACCURACY: raw two-point fluxes across coarse-fine faces miss the
error (centers of different-size neighbors are laterally offset), which tangential potential gradient (different-size neighbors have laterally
biases R LOW by ~0.5-2% depending on geometry - worst where transition offset centers), biasing R low by ~0.5-2%. Deferred correction fixes
rings span much of the current path (narrow strips), mild on large this: after the first solve, per-leaf gradients are reconstructed by
pours. Symmetric fine pairs under a coarse face cancel pairwise; the least squares over face neighbors and the known tangential term
residue comes from unpaired larger-neighbor faces. Gradient-corrected g * delta * Gt moves to the right-hand side of a re-solve
interface fluxes (phase 4) are the known cure if tighter accuracy per (ADAPTIVE_CORRECTION_PASSES, default 1). The matrix is unchanged, so
leaf is ever needed. 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 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) 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, def run_solve_adaptive(problem: Problem, stack: RasterStack,
e1: np.ndarray, e2: np.ndarray, i_test: float, e1: np.ndarray, e2: np.ndarray, i_test: float,
freq_hz: float, contact_model: str, 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)") f"{max(int(g.size.max()) if g.n else 1 for g in grids)} cells)")
# --- edges: in-plane faces, 1D chain links, barrels ------------------- # --- 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 = [], [] sig_leaves, teq_leaves = [], []
cxg = np.zeros(N)
cyg = np.zeros(N)
for li in range(L): for li in range(L):
g_ = grids[li] 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]) sig_leaf = np.full(g_.n, sigmas[li])
t_m = problem.layers[li].thickness_nm * 1e-9 t_m = problem.layers[li].thickness_nm * 1e-9
s2d = sv._sigma_2d(stack, li, sigmas[li], sigma_buildup) 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) bb.append(offs[li] + ib)
ww.append(gcond) ww.append(gcond)
vv.append(np.full(len(ia), -1, dtype=np.int32)) 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]): if stack.chain_edges is not None and len(stack.chain_edges[0]):
ca, cb, cg, cl = stack.chain_edges 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 fac = np.array([sigmas[l] * problem.rho_ohm_m
/ (problem.layers[l].thickness_nm * 1e-9) / (problem.layers[l].thickness_nm * 1e-9)
for l in range(L)]) for l in range(L)])
k = int(alive.sum())
aa.append(na[alive]) aa.append(na[alive])
bb.append(nb[alive]) bb.append(nb[alive])
ww.append((cg * fac[cl])[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) links, dead_barrels = sv._barrel_links(stack, problem)
for vi, la, ia_, ja_, lb, ib_, jb_, r_dc in links: 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)) bb.append(np.array([nb], dtype=np.int64))
ww.append(np.array([1.0 / (r_dc * via_factor)])) ww.append(np.array([1.0 / (r_dc * via_factor)]))
vv.append(np.array([vi], dtype=np.int32)) 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: if dead_barrels:
print(f"warning: {dead_barrels} via/pad barrel(s) found fill " print(f"warning: {dead_barrels} via/pad barrel(s) found fill "
f"copper on fewer than 2 layers and carry no current (pad " 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), edges = sv.Edges(a=np.concatenate(aa), b=np.concatenate(bb),
w=np.concatenate(ww), via_index=np.concatenate(vv), w=np.concatenate(ww), via_index=np.concatenate(vv),
dead_barrels=dead_barrels) 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 ----------------------- # --- connectivity restriction on the leaf graph -----------------------
graph = sparse.coo_matrix( 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], edges = sv.Edges(a=edges.a[sel], b=edges.b[sel], w=edges.w[sel],
via_index=edges.via_index[sel], via_index=edges.via_index[sel],
dead_barrels=dead_barrels) dead_barrels=dead_barrels)
e_delta, e_axis, e_layer = e_delta[sel], e_axis[sel], e_layer[sel]
for li in range(L): for li in range(L):
ids = grids[li].id_grid ids = grids[li].id_grid
kept_cells = (ids >= 0) & keepn[offs[li] + np.maximum(ids, 0)] 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") f"no current")
timings["edges_s"] = time.perf_counter() - t0 timings["edges_s"] = time.perf_counter() - t0
# --- solve ------------------------------------------------------------- # --- solve with deferred-correction interface fluxes -------------------
t0 = time.perf_counter() t0 = time.perf_counter()
state = np.zeros(N, dtype=np.uint8) state = np.zeros(N, dtype=np.uint8)
state[keepn] = 1 state[keepn] = 1
inj = None
if contact_model == "equipotential": if contact_model == "equipotential":
state[e1n] = 2 state[e1n] = 2
state[e2n] = 3 state[e2n] = 3
Vflat, R, I1, I2, mismatch, volts_per_amp, info = \
sv._equipotential_core(state, edges)
else: else:
n1, n2 = int(e1n.sum()), int(e2n.sum()) n1, n2 = int(e1n.sum()), int(e2n.sum())
inj = np.zeros(N) inj = np.zeros(N)
inj[e1n] = 1.0 / n1 inj[e1n] = 1.0 / n1
inj[e2n] = -1.0 / n2 inj[e2n] = -1.0 / n2
ground = int(np.flatnonzero(e2n)[0]) state[int(np.flatnonzero(e2n)[0])] = 3
state[ground] = 3
Vflat, R, I1, I2, mismatch, volts_per_amp, info = \ A, rhs0, _ = sv._assemble(state, edges, inj)
sv._uniform_core(state, inj, e1n, e2n, edges) 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 timings["solve_s"] = time.perf_counter() - t0
# --- fields on leaves, expanded to the fine grid ------------------------ # --- fields on leaves, expanded to the fine grid ------------------------
t0 = time.perf_counter() t0 = time.perf_counter()
s = i_test * volts_per_amp 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 inplane = edges.via_index < 0
Pnode = np.zeros(N) Pnode = np.zeros(N)
np.add.at(Pnode, edges.a[inplane], 0.5 * Pe[inplane]) 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." f"different grid size."
) )
Ie = edges.w * (Vflat[edges.a] - Vflat[edges.b])
via_reports = [] via_reports = []
if problem.vias: if problem.vias:
vidx = edges.via_index vidx = edges.via_index
@@ -294,33 +395,17 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
Vl = Vflat[offs[li]:offs[li + 1]] Vl = Vflat[offs[li]:offs[li + 1]]
V3[li][m] = Vl[ids[m]] * s V3[li][m] = Vl[ids[m]] * s
# per-leaf |J| from face currents at unit drive, reconstructed sel = (e_axis >= 0) & (e_layer == li)
# with the same series-half-cell rule (edges were filtered by la = (edges.a[sel] - offs[li]).astype(np.int64)
# the restriction, so recompute locally) lb = (edges.b[sel] - offs[li]).astype(np.int64)
gio = offs[li] If = Ie[sel]
axl = e_axis[sel]
Ixn = np.zeros(g_.n) Ixn = np.zeros(g_.n)
Iyn = 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)): for axis, acc in ((0, Ixn), (1, Iyn)):
selx = ax2 == axis sub = axl == axis
np.add.at(acc, ia2[selx], If[selx]) np.add.at(acc, la[sub], If[sub])
np.add.at(acc, ib2[selx], If[selx]) np.add.at(acc, lb[sub], If[sub])
span_m = g_.size.astype(float) * h_m span_m = g_.size.astype(float) * h_m
with np.errstate(invalid="ignore", divide="ignore"): with np.errstate(invalid="ignore", divide="ignore"):
Jl = np.hypot(0.5 * Ixn, 0.5 * Iyn) / (span_m * teq_leaves[li]) 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]] \ cellP = Pnode[offs[li]:offs[li + 1]] \
/ (g_.size.astype(float) ** 2 * h_m * h_m) / (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 timings["postprocess_s"] = time.perf_counter() - t0
return sv.Result( 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 # caps leaf growth near features
ADAPTIVE_GUARD = 4 # a leaf of size s needs >= GUARD*s cells of ADAPTIVE_GUARD = 4 # a leaf of size s needs >= GUARD*s cells of
# clearance to the nearest feature # 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 --- # --- Solver ---
CONTACT_MODEL = "uniform" # "uniform": conductor pressed on top injects CONTACT_MODEL = "uniform" # "uniform": conductor pressed on top injects
+1 -1
View File
@@ -75,7 +75,7 @@ class _Dialog(QDialog):
self.adaptive_check = QCheckBox( self.adaptive_check = QCheckBox(
"adaptive cells (coarsen plane interiors; faster on large " "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) self.adaptive_check.setChecked(config.ADAPTIVE_CELLS)
form.addRow("Grid:", self.adaptive_check) 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) 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]: def _solve_amg(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, SolveInfo]:
"""CG preconditioned with smoothed-aggregation AMG: near-linear """CG preconditioned with smoothed-aggregation AMG: near-linear
scaling on these 2D Laplacians and a fraction of spsolve's memory.""" scaling on these 2D Laplacians and a fraction of spsolve's memory."""
+28 -10
View File
@@ -27,21 +27,39 @@ def _run(problem, h_mm, model="equipotential", adaptive=False,
def test_strip_close_both_models(monkeypatch): def test_strip_close_both_models(monkeypatch):
"""Uniform strip, both contact models. Coarse-fine interfaces carry """Uniform strip, both contact models. The raw interface flux error
a first-order tangential flux error (laterally offset leaf centers), (~1.7% low here, the worst case) is removed by the default deferred-
so adaptive R sits up to ~2% LOW of the production R - the narrow correction pass; the corrected currents keep the power identity."""
strip is the worst case (transition rings span most of the width)."""
for model in ("equipotential", "uniform"): for model in ("equipotential", "uniform"):
p = strip_problem(length=50, width=10, e_len=5) p = strip_problem(length=50, width=10, e_len=5)
ref = _run(p, 0.25, model, adaptive=False, monkeypatch=monkeypatch) ref = _run(p, 0.25, model, adaptive=False, monkeypatch=monkeypatch)
p2 = strip_problem(length=50, width=10, e_len=5) p2 = strip_problem(length=50, width=10, e_len=5)
ada = _run(p2, 0.25, model, adaptive=True, monkeypatch=monkeypatch) ada = _run(p2, 0.25, model, adaptive=True, monkeypatch=monkeypatch)
assert ada.R_ohm == pytest.approx(ref.R_ohm, rel=0.02), model assert ada.R_ohm == pytest.approx(ref.R_ohm, rel=2e-3), model
assert ada.R_ohm <= ref.R_ohm * 1.001 # bias is low, not high
assert ada.n_free < ref.n_free assert ada.n_free < ref.n_free
assert ada.power_balance_rel < 1e-9 assert ada.power_balance_rel < 1e-9
def test_correction_passes_remove_bias(monkeypatch):
"""0 passes shows the raw coarse-fine bias; the default single pass
removes it by more than an order of magnitude."""
p = strip_problem(length=50, width=10, e_len=5)
ref = _run(p, 0.25, adaptive=False, monkeypatch=monkeypatch)
monkeypatch.setattr(config, "ADAPTIVE_CORRECTION_PASSES", 0)
raw = _run(strip_problem(length=50, width=10, e_len=5), 0.25,
adaptive=True, monkeypatch=monkeypatch)
err_raw = abs(raw.R_ohm / ref.R_ohm - 1)
assert err_raw > 5e-3 # bias is real without it
monkeypatch.setattr(config, "ADAPTIVE_CORRECTION_PASSES", 1)
fix = _run(strip_problem(length=50, width=10, e_len=5), 0.25,
adaptive=True, monkeypatch=monkeypatch)
err_fix = abs(fix.R_ohm / ref.R_ohm - 1)
assert err_fix < err_raw / 10
assert err_fix < 1e-3
def test_plate_with_holes_close(monkeypatch): def test_plate_with_holes_close(monkeypatch):
holes = [] holes = []
for i in range(5): for i in range(5):
@@ -57,7 +75,7 @@ def test_plate_with_holes_close(monkeypatch):
ref = _run(prob(), 0.1, adaptive=False, monkeypatch=monkeypatch) ref = _run(prob(), 0.1, adaptive=False, monkeypatch=monkeypatch)
ada = _run(prob(), 0.1, adaptive=True, monkeypatch=monkeypatch) ada = _run(prob(), 0.1, adaptive=True, monkeypatch=monkeypatch)
assert ada.n_free < 0.5 * ref.n_free assert ada.n_free < 0.5 * ref.n_free
assert ada.R_ohm == pytest.approx(ref.R_ohm, rel=0.01) assert ada.R_ohm == pytest.approx(ref.R_ohm, rel=1e-3)
def test_via_chain_exact(monkeypatch): def test_via_chain_exact(monkeypatch):
@@ -112,7 +130,7 @@ def test_1d_trace_bridge(monkeypatch):
def test_buildup_close_on_strip(monkeypatch): def test_buildup_close_on_strip(monkeypatch):
"""Half-coverage buildup strip: buildup cells are pinned fine; the """Half-coverage buildup strip: buildup cells are pinned fine; the
remaining deviation is the interface flux bias of the plain half.""" plain half's interface bias is removed by the correction pass."""
from tests.test_buildup import _with_buildup from tests.test_buildup import _with_buildup
def prob(): def prob():
@@ -121,7 +139,7 @@ def test_buildup_close_on_strip(monkeypatch):
ref = _run(prob(), 0.5, adaptive=False, monkeypatch=monkeypatch) ref = _run(prob(), 0.5, adaptive=False, monkeypatch=monkeypatch)
ada = _run(prob(), 0.5, adaptive=True, monkeypatch=monkeypatch) ada = _run(prob(), 0.5, adaptive=True, monkeypatch=monkeypatch)
assert ada.R_ohm == pytest.approx(ref.R_ohm, rel=0.02) assert ada.R_ohm == pytest.approx(ref.R_ohm, rel=2e-3)
def test_capped_via_close(monkeypatch): def test_capped_via_close(monkeypatch):
@@ -141,7 +159,7 @@ def test_part_currents_and_ac(monkeypatch):
p2 = strip_problem(length=50, width=10, e_len=5) p2 = strip_problem(length=50, width=10, e_len=5)
ada = _run(p2, 0.5, adaptive=True, monkeypatch=monkeypatch, ada = _run(p2, 0.5, adaptive=True, monkeypatch=monkeypatch,
parts=True, freq=2e6) parts=True, freq=2e6)
assert ada.R_ohm == pytest.approx(ref.R_ohm, rel=0.02) assert ada.R_ohm == pytest.approx(ref.R_ohm, rel=2e-3)
assert ada.part_currents1[0][1] == pytest.approx( assert ada.part_currents1[0][1] == pytest.approx(
ref.part_currents1[0][1], rel=1e-9) # single part = full current ref.part_currents1[0][1], rel=1e-9) # single part = full current
assert ada.rs_ratios == ref.rs_ratios assert ada.rs_ratios == ref.rs_ratios