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:
@@ -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 coarse–fine interfaces carry a
|
speed/memory wins on big pours. The raw coarse–fine interface flux
|
||||||
first-order flux error that biases R **low by ~0.5–2 %** depending on
|
bias (~0.5–2 % 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
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.5–2 % 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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user