Wire the adaptive quadtree grid into the solve path (phase 2)

config.ADAPTIVE_CELLS (dialog checkbox "adaptive cells", off by
default; standalone --adaptive) routes run_solve through
fill_resistance/adaptive.py: per-layer balanced leaf grids where every
non-uniform fine cell (electrodes, 1D chain cells, buildup, via-mouth
thickness map) is pinned at the fine size, leaf faces via the
series-half-cell rule, chain links and barrels re-attached by node id,
connectivity restriction and both contact models on the leaf graph via
solver cores extracted for reuse (_equipotential_core, _uniform_core,
_conductance_params, _barrel_links). All fields (V, |J|, power density)
are computed per leaf and expanded to the fine grid, so plots, summary
and dumps are unchanged.

Element sizes: minimum = the grid cell size itself (auto / dialog /
CELL_UM_OVERRIDE); maximum = ADAPTIVE_MAX_CELL_UM (2 mm default);
ADAPTIVE_GUARD sets the clearance a block needs to grow.

Measured end-to-end (feature-dense 120x120 plate, h=50um): 25.9 s ->
5.4 s, 5.58M -> 823k unknowns, R -1.1%. Accuracy documented honestly:
coarse-fine interfaces carry a first-order tangential flux error
biasing R low by ~0.5-2% depending on geometry (worst on narrow
strips); the earlier assumption that linear fields solve exactly on the
leaf graph was wrong - offset centers across size transitions leave an
unpaired residue. Gradient-corrected interface fluxes remain as phase 4
if tighter accuracy per leaf is needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-07-15 17:23:41 +07:00
parent 44ffa7b6d1
commit a9233fde32
9 changed files with 690 additions and 104 deletions
+131 -92
View File
@@ -134,6 +134,56 @@ def _sigma_2d(stack: RasterStack, li: int, sigma_layer: float,
return s
def _barrel_links(stack: RasterStack, problem: Problem
) -> tuple[list, int]:
"""Vertical barrel links on the fine grid, shared by the uniform and
adaptive paths: [(via_index, layer_a, i_a, j_a, layer_b, i_b, j_b,
r_dc), ...] plus the count of dead barrels (span >= 2 layers but
reached copper on < 2). Connection cell per layer: the cell under
the barrel, or the nearest copper cell whose center lies within the
pad footprint (+1 cell of rasterization slop) - fills joined to the
barrel by thermal-relief spokes still connect, wider antipads do not
(the barrel then bridges the layers above/below)."""
L, ny, nx = stack.masks.shape
h = stack.h_nm
links = []
dead = 0
for vi, via in enumerate(problem.vias):
cell = stack.cell_of(via.x, via.y)
if cell is None:
continue
i, j = cell
span = [li for li, layer in enumerate(problem.layers)
if via.spans(layer.z_nm)]
r_nm = max(via.pad_nm, via.drill_nm + 300_000) / 2.0 + h
win = int(r_nm // h) + 1
i0, i1 = max(0, i - win), min(ny, i + win + 1)
j0, j1 = max(0, j - win), min(nx, j + win + 1)
xs = stack.x0_nm + (np.arange(j0, j1) + 0.5) * h - via.x
ys = stack.y0_nm + (np.arange(i0, i1) + 0.5) * h - via.y
d2 = ys[:, None] ** 2 + xs[None, :] ** 2
d2 = np.where(d2 <= r_nm * r_nm, d2, np.inf)
present = [] # (layer, i, j) per layer
for li in span:
if stack.masks[li, i, j]:
present.append((li, i, j))
continue
dc = np.where(stack.masks[li, i0:i1, j0:j1], d2, np.inf)
ci, cj = np.unravel_index(int(np.argmin(dc)), dc.shape)
if np.isfinite(dc[ci, cj]):
present.append((li, i0 + ci, j0 + cj))
if len(span) >= 2 and len(present) < 2:
dead += 1
for (la, ia, ja), (lb, ib, jb) in zip(present[:-1], present[1:]):
length = problem.layers[lb].z_nm - problem.layers[la].z_nm
if length <= 0:
continue
r_dc = via.barrel_resistance(length, problem.rho_ohm_m,
problem.plating_nm)
links.append((vi, la, ia, ja, lb, ib, jb, r_dc))
return links, dead
def build_edges(stack: RasterStack, problem: Problem, sigmas: list[float],
via_factor: float = 1.0,
sigma_buildup: float = 0.0) -> Edges:
@@ -175,49 +225,12 @@ def build_edges(stack: RasterStack, problem: Problem, sigmas: list[float],
ww.append(2.0 * s_a * s_b / (s_a + s_b))
vv.append(np.full(len(a), -1, dtype=np.int32))
h = stack.h_nm
dead_barrels = 0
for vi, via in enumerate(problem.vias):
cell = stack.cell_of(via.x, via.y)
if cell is None:
continue
i, j = cell
span = [li for li, layer in enumerate(problem.layers)
if via.spans(layer.z_nm)]
# Connection cell per layer: the cell under the barrel, or the
# nearest copper cell whose center lies within the pad footprint
# (+1 cell of rasterization slop) - fills joined to the barrel by
# thermal-relief spokes still connect, wider antipads do not (the
# barrel then bridges the layers above/below as before).
r_nm = max(via.pad_nm, via.drill_nm + 300_000) / 2.0 + h
win = int(r_nm // h) + 1
i0, i1 = max(0, i - win), min(ny, i + win + 1)
j0, j1 = max(0, j - win), min(nx, j + win + 1)
xs = stack.x0_nm + (np.arange(j0, j1) + 0.5) * h - via.x
ys = stack.y0_nm + (np.arange(i0, i1) + 0.5) * h - via.y
d2 = ys[:, None] ** 2 + xs[None, :] ** 2
d2 = np.where(d2 <= r_nm * r_nm, d2, np.inf)
present = [] # (layer, i, j) per layer
for li in span:
if stack.masks[li, i, j]:
present.append((li, i, j))
continue
dc = np.where(stack.masks[li, i0:i1, j0:j1], d2, np.inf)
ci, cj = np.unravel_index(int(np.argmin(dc)), dc.shape)
if np.isfinite(dc[ci, cj]):
present.append((li, i0 + ci, j0 + cj))
if len(span) >= 2 and len(present) < 2:
dead_barrels += 1
for (la, ia, ja), (lb, ib, jb) in zip(present[:-1], present[1:]):
length = problem.layers[lb].z_nm - problem.layers[la].z_nm
if length <= 0:
continue
r = via.barrel_resistance(length, problem.rho_ohm_m,
problem.plating_nm) * via_factor
aa.append(np.array([la * plane + ia * nx + ja], dtype=np.int64))
bb.append(np.array([lb * plane + ib * nx + jb], dtype=np.int64))
ww.append(np.array([1.0 / r]))
vv.append(np.array([vi], dtype=np.int32))
links, dead_barrels = _barrel_links(stack, problem)
for vi, la, ia, ja, lb, ib, jb, r_dc in links:
aa.append(np.array([la * plane + ia * nx + ja], dtype=np.int64))
bb.append(np.array([lb * plane + ib * nx + jb], dtype=np.int64))
ww.append(np.array([1.0 / (r_dc * via_factor)]))
vv.append(np.array([vi], dtype=np.int32))
if stack.chain_edges is not None and len(stack.chain_edges[0]):
ca, cb, cg, cl = stack.chain_edges
@@ -409,22 +422,11 @@ def _face_current_density(V2: np.ndarray, mask2: np.ndarray, sigma: float,
return Jmag
def _solve_equipotential(stack, e1, e2, edges):
"""Dirichlet terminals at 1 V / 0 V. Returns (Vflat_unit, R, I1, I2,
mismatch, volts_per_amp, info). Fields at 1 V drive; scale by
i_test * R to get volts at I_test."""
if (layer := electrodes_touch(stack, e1, e2)) is not None:
raise ElectrodeError(
f"The terminals touch on {layer}. With the equipotential "
f"contact model at least one cell of copper must separate "
f"them; the uniform-injection model allows touching contacts."
)
state = np.zeros(stack.masks.size, dtype=np.uint8)
state[stack.masks.ravel()] = 1
state[e1.ravel()] = 2
state[e2.ravel()] = 3
A, rhs, idx = _assemble(state, edges, None)
def _equipotential_core(state: np.ndarray, edges: Edges):
"""Dirichlet solve on any node space (fine cells or leaves): state
codes 0 off / 1 free / 2 V+ / 3 V-. Returns (Vflat_unit, R, I1, I2,
mismatch, volts_per_amp, info); fields at 1 V drive."""
A, rhs, _ = _assemble(state, edges, None)
x, info = solve_system(A, rhs)
Vflat = np.zeros(state.size)
@@ -440,29 +442,14 @@ def _solve_equipotential(stack, e1, e2, edges):
return Vflat, R, I1, I2, mismatch, R, info
def _solve_uniform(stack, e1, e2, edges):
"""Uniform orthogonal injection: every contact cell sources (sinks)
1 A / N. Grounded at one V- cell. Returns like _solve_equipotential;
fields are at 1 A drive, so volts_per_amp = 1."""
n = stack.masks.size
e1f, e2f = e1.ravel(), e2.ravel()
n1, n2 = int(e1f.sum()), int(e2f.sum())
inj = np.zeros(n)
inj[e1f] = 1.0 / n1
inj[e2f] = -1.0 / n2
state = np.zeros(n, dtype=np.uint8)
state[stack.masks.ravel()] = 1
ground = int(np.flatnonzero(e2f)[0])
state[ground] = 3 # single Dirichlet 0 V reference;
# its sink share is exactly the flux that exits through the reference,
# so the grounded solution equals the pure-Neumann one
A, rhs, idx = _assemble(state, edges, inj)
def _uniform_core(state: np.ndarray, inj: np.ndarray, e1f: np.ndarray,
e2f: np.ndarray, edges: Edges):
"""Uniform-injection solve on any node space. state must carry the
single ground node (code 3); inj the per-node current shares."""
A, rhs, _ = _assemble(state, edges, inj)
x, info = solve_system(A, rhs)
Vflat = np.zeros(n)
Vflat = np.zeros(state.size)
Vflat[state == 1] = x
v_plus = float(Vflat[e1f].mean())
@@ -478,6 +465,42 @@ def _solve_uniform(stack, e1, e2, edges):
return Vflat, R, 1.0, 1.0, res, 1.0, info
def _solve_equipotential(stack, e1, e2, edges):
"""Dirichlet terminals at 1 V / 0 V on the uniform grid. Fields at
1 V drive; scale by i_test * R to get volts at I_test."""
if (layer := electrodes_touch(stack, e1, e2)) is not None:
raise ElectrodeError(
f"The terminals touch on {layer}. With the equipotential "
f"contact model at least one cell of copper must separate "
f"them; the uniform-injection model allows touching contacts."
)
state = np.zeros(stack.masks.size, dtype=np.uint8)
state[stack.masks.ravel()] = 1
state[e1.ravel()] = 2
state[e2.ravel()] = 3
return _equipotential_core(state, edges)
def _solve_uniform(stack, e1, e2, edges):
"""Uniform orthogonal injection on the uniform grid: every contact
cell sources (sinks) 1 A / N, grounded at one V- cell (its sink
share is exactly the flux that exits through the reference, so the
grounded solution equals the pure-Neumann one). Fields at 1 A."""
n = stack.masks.size
e1f, e2f = e1.ravel(), e2.ravel()
n1, n2 = int(e1f.sum()), int(e2f.sum())
inj = np.zeros(n)
inj[e1f] = 1.0 / n1
inj[e2f] = -1.0 / n2
state = np.zeros(n, dtype=np.uint8)
state[stack.masks.ravel()] = 1
ground = int(np.flatnonzero(e2f)[0])
state[ground] = 3
return _uniform_core(state, inj, e1f, e2f, edges)
def _part_currents(parts, Ie, edges, e_flat, scale,
i_test, contact_model, n_terminal_cells):
"""Current through each contact part @ I_test. Equipotential: exact
@@ -498,18 +521,12 @@ def _part_currents(parts, Ie, edges, e_flat, scale,
return out
def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
e2: np.ndarray, i_test: float, freq_hz: float = 0.0,
contact_model: str | None = None,
parts1: list | None = None,
parts2: list | None = None) -> Result:
timings = {}
L, ny, nx = stack.masks.shape
h_m = stack.h_nm * 1e-9
if contact_model is None:
contact_model = config.CONTACT_MODEL
# effective (AC) sheet conductances and barrel factor
def _conductance_params(problem: Problem, stack: RasterStack,
freq_hz: float):
"""Effective (possibly AC) sheet conductances per layer, Rs ratios,
barrel factor and buildup conductance - shared by the uniform-grid
and adaptive solve paths."""
L = stack.nlayers
sigmas = [
1.0 / skin.sheet_resistance_ac(
problem.layers[li].thickness_nm * 1e-9, freq_hz,
@@ -544,6 +561,28 @@ def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
f"per-layer Rs ratio "
f"{', '.join(f'{r:.2f}' for r in rs_ratios)}, "
f"via factor {via_factor:.2f}")
return sigmas, rs_ratios, via_factor, sigma_buildup
def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
e2: np.ndarray, i_test: float, freq_hz: float = 0.0,
contact_model: str | None = None,
parts1: list | None = None,
parts2: list | None = None) -> Result:
if contact_model is None:
contact_model = config.CONTACT_MODEL
if config.ADAPTIVE_CELLS:
from . import adaptive
return adaptive.run_solve_adaptive(problem, stack, e1, e2, i_test,
freq_hz, contact_model,
parts1, parts2)
timings = {}
L, ny, nx = stack.masks.shape
h_m = stack.h_nm * 1e-9
sigmas, rs_ratios, via_factor, sigma_buildup = \
_conductance_params(problem, stack, freq_hz)
t0 = time.perf_counter()
edges = build_edges(stack, problem, sigmas, via_factor, sigma_buildup)