Files
kicad-zone-resistance/fill_resistance/solver.py
T
janik a9233fde32 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>
2026-07-15 17:24:10 +07:00

704 lines
29 KiB
Python

"""Coupled multi-layer finite-difference solver.
Each included copper layer is a 2D 5-point sheet with per-layer face
conductance sigma_s = t/rho [S] (square cells: independent of h); via and
plated-through-pad barrels add vertical conductances between the layers
they span AND reach copper on. Per layer the barrel attaches to the cell
under it, or to the nearest copper cell within the pad footprint (+1
cell) - fills joined by thermal-relief spokes still connect. A barrel
passing a (wider) antipad still bridges the layers above/below it with
the full barrel length. At freq > 0 the per-layer sheet conductances and the
barrel walls get the 1D skin-effect correction (see skin.py; AC results
are a rigorous lower bound - lateral redistribution is not modeled).
Two contact models for the terminals (each terminal = merged parts):
- "uniform" (default): a conductor pressed onto the contact area injects
the current orthogonally with UNIFORM surface density: every contact
cell sources (sinks) I/N. The in-plane current density ramps across
the contact instead of being zero. The pure-Neumann system is grounded
at one V- cell (that cell's sink share is exactly the flux that exits
through the ground reference, so the solution equals the singular
system's). R = (<V over V+ cells> - <V over V- cells>) / I; because
the injection and averaging weights coincide, sum(edge powers) = I^2 R
holds exactly and remains the consistency check.
- "equipotential": ideal bonded lug; contact cells are Dirichlet
(V+ = 1 V, V- = 0). R from the exact discrete electrode flux. Touching
terminals are rejected (a direct face would short the Dirichlet
regions); with "uniform" contacts touching is physically fine.
The two models bracket a real contact: R_equipotential <= R_real <=
R_uniform. Missing neighbors give no matrix term = insulated boundary.
Current density per layer comes from face currents (np.gradient across
the NaN staircase boundary would pollute the field). Power density per
layer distributes each in-plane edge's dissipation half to each endpoint
cell. All reported fields are rescaled to the test current I_test.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
import numpy as np
from scipy import sparse
from scipy.sparse import csgraph
from scipy.sparse import linalg as sla
from . import config, skin
from .errors import ConnectivityError, ElectrodeError, SolverError
from .geometry import Problem
from .raster import RasterStack, electrodes_touch
@dataclass
class SolveInfo:
method: str # "spsolve" | "cg+jacobi"
n_unknowns: int
iterations: int | None = None
residual: float | None = None
@dataclass
class Edges:
a: np.ndarray # int64 flat cell ids
b: np.ndarray
w: np.ndarray # conductance [S]
via_index: np.ndarray # int32; -1 = in-plane edge
dead_barrels: int = 0 # barrels spanning >=2 layers that found
# fill copper on fewer than 2 of them
@dataclass
class ViaReport:
x_mm: float
y_mm: float
kind: str
drill_mm: float
current_a: float # max barrel-segment current @ I_test
power_w: float # total barrel dissipation @ I_test
@dataclass
class Result:
R_ohm: float
i_test: float
V: np.ndarray # (L, ny, nx) volts @ I_test, NaN off-copper
Jmag: np.ndarray # (L, ny, nx) A/m^2 @ I_test
Parea: np.ndarray # (L, ny, nx) W/m^2 @ I_test
layer_names: list[str]
P_total: float # I_test^2 * R
P_layers: list[float] # in-plane dissipation per layer @ I_test
P_vias: float # total barrel dissipation @ I_test
power_balance_rel: float # |sum(edge powers) - I^2 R| / I^2 R
via_reports: list[ViaReport] # sorted by current, descending
I1_a: float # electrode currents (unit drive)
I2_a: float
mismatch_rel: float
n_free: int
solve_info: SolveInfo
# per-part terminal currents @ I_test: [(label, amps), ...];
# computed flux for "equipotential", prescribed area share for "uniform"
part_currents1: list = field(default_factory=list)
part_currents2: list = field(default_factory=list)
contact_model: str = "uniform"
freq_hz: float = 0.0
skin_depth_um: float | None = None
rs_ratios: list[float] = field(default_factory=list) # R_AC/R_DC per layer
timings: dict = field(default_factory=dict)
def _shifts2d():
return [
((slice(None), slice(None, -1)), (slice(None), slice(1, None))),
((slice(None, -1), slice(None)), (slice(1, None), slice(None))),
]
def _sigma_2d(stack: RasterStack, li: int, sigma_layer: float,
sigma_buildup: float) -> np.ndarray | None:
"""Per-cell sheet conductance for one layer, or None if uniform.
Combines the via-mouth thickness map (cap-thin / partially drilled
cells) with the solder-buildup addition."""
have_b = (stack.buildup is not None and sigma_buildup > 0
and stack.buildup[li].any())
have_t = (stack.thick_scale is not None
and bool((stack.thick_scale[li] != 1.0).any()))
if not have_b and not have_t:
return None
s = np.full(stack.shape2d, sigma_layer)
if have_t:
s *= stack.thick_scale[li]
if have_b:
s[stack.buildup[li]] += sigma_buildup
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:
"""All copper-copper conductances: in-plane faces + via barrels.
sigmas: effective (possibly AC) sheet conductance per layer;
via_factor: R_AC/R_DC of the barrel wall; sigma_buildup: extra sheet
conductance on solder-buildup cells. Faces between cells of unequal
conductance use the harmonic mean (series half-cells), which reduces
exactly to sigma for uniform regions."""
L, ny, nx = stack.masks.shape
plane = ny * nx
aa, bb, ww, vv = [], [], [], []
for li in range(L):
m = stack.masks[li]
if stack.chain is not None:
# chain-only cells connect through their explicit 1D links,
# never through sheet faces (their copper is narrower than h)
m = m & ~stack.chain[li]
sig = sigmas[li]
scell = _sigma_2d(stack, li, sig, sigma_buildup)
base = li * plane
for src, dst in _shifts2d():
pair = m[src] & m[dst]
ii, jj = np.nonzero(pair)
if src[0] == slice(None): # horizontal: j, j+1
a = base + ii * nx + jj
b = a + 1
else: # vertical: i, i+1
a = base + ii * nx + jj
b = a + nx
aa.append(a.astype(np.int64))
bb.append(b.astype(np.int64))
if scell is None:
ww.append(np.full(len(a), sig))
else:
s_a = scell[src][pair]
s_b = scell[dst][pair]
ww.append(2.0 * s_a * s_b / (s_a + s_b))
vv.append(np.full(len(a), -1, 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
alive = stack.masks.ravel()[ca] & stack.masks.ravel()[cb]
if alive.any():
# skin correction: scale like the layer's sheet conductance
fac = np.array([sigmas[l] * problem.rho_ohm_m
/ (problem.layers[l].thickness_nm * 1e-9)
for l in range(L)])
aa.append(ca[alive])
bb.append(cb[alive])
ww.append((cg * fac[cl])[alive])
vv.append(np.full(int(alive.sum()), -1, dtype=np.int32))
if not aa:
raise ConnectivityError("No copper found on the selected layers.")
return Edges(a=np.concatenate(aa), b=np.concatenate(bb),
w=np.concatenate(ww), via_index=np.concatenate(vv),
dead_barrels=dead_barrels)
def connected_restrict(stack: RasterStack, e1: np.ndarray, e2: np.ndarray,
edges: Edges) -> tuple[bool, int]:
"""Keep only components (through-plane AND through-via) touching both
terminals. Mutates stack.masks / e1 / e2. Returns (changed,
n_components): whether anything was dropped (caller must rebuild
edges) and how many disjoint copper groups survive."""
n = stack.masks.size
graph = sparse.coo_matrix(
(np.ones(len(edges.a)), (edges.a, edges.b)), shape=(n, n))
_, labels = csgraph.connected_components(graph, directed=False)
labels3 = labels.reshape(stack.masks.shape)
common = np.intersect1d(np.unique(labels3[e1]), np.unique(labels3[e2]))
if len(common) == 0:
raise ConnectivityError(
"The two terminals are not connected by the selected fill "
"layers (not even through vias). Check the layer selection and "
"that the fills are up to date."
)
keep = np.isin(labels3, common) & stack.masks
changed = bool((stack.masks & ~keep).any())
stack.masks &= keep
e1 &= keep
e2 &= keep
return changed, len(common)
def _assemble(state: np.ndarray, edges: Edges, rhs_extra: np.ndarray | None):
"""Weighted-Laplacian assembly with Dirichlet elimination.
state: 0 off, 1 free, 2 Dirichlet@1V, 3 Dirichlet@0V.
rhs_extra: per-flat-cell current injection [A] added for free cells."""
n = state.size
sa, sb = state[edges.a], state[edges.b]
short = ((sa == 2) & (sb == 3)) | ((sa == 3) & (sb == 2))
if short.any():
n_via = int((edges.via_index[short] >= 0).sum())
raise ElectrodeError(
f"The terminals are directly connected by {int(short.sum())} "
f"conductance(s) ({n_via} via barrel(s)) without any free copper "
f"in between - move the contacts apart."
)
free = state == 1
n_free = int(free.sum())
if n_free == 0:
raise ElectrodeError(
"No free copper cells remain between the terminals - the "
"contacts cover the whole fill at this grid resolution."
)
idx = np.full(n, -1, dtype=np.int64)
idx[free] = np.arange(n_free)
diag = np.zeros(n_free)
rhs = np.zeros(n_free)
fa, fb = sa == 1, sb == 1
np.add.at(diag, idx[edges.a[fa]], edges.w[fa])
np.add.at(diag, idx[edges.b[fb]], edges.w[fb])
r1a = fa & (sb == 2)
r1b = fb & (sa == 2)
np.add.at(rhs, idx[edges.a[r1a]], edges.w[r1a])
np.add.at(rhs, idx[edges.b[r1b]], edges.w[r1b])
if rhs_extra is not None:
rhs += rhs_extra[free]
ff = fa & fb
rows = np.concatenate([idx[edges.a[ff]], idx[edges.b[ff]],
np.arange(n_free)])
cols = np.concatenate([idx[edges.b[ff]], idx[edges.a[ff]],
np.arange(n_free)])
vals = np.concatenate([-edges.w[ff], -edges.w[ff], diag])
A = sparse.coo_matrix((vals, (rows, cols)),
shape=(n_free, n_free)).tocsr()
return A, rhs, idx
def solve_system(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, SolveInfo]:
n = A.shape[0]
if n <= config.SPSOLVE_MAX_UNKNOWNS:
x = sla.spsolve(A.tocsc(), b)
return x, SolveInfo(method="spsolve", n_unknowns=n)
try:
return _solve_amg(A, b)
except ImportError:
print("note: pyamg not installed - falling back to Jacobi-CG "
"(much slower on large grids)")
return _solve_cg_jacobi(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."""
import pyamg
n = A.shape[0]
ml = pyamg.smoothed_aggregation_solver(A.tocsr(), max_coarse=500)
residuals: list[float] = []
x = ml.solve(b, tol=config.AMG_TOL, maxiter=300, accel="cg",
residuals=residuals)
res = float(np.linalg.norm(b - 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 raising "
f"SPSOLVE_MAX_UNKNOWNS in config.py."
)
return x, SolveInfo(method="amg+cg", n_unknowns=n,
iterations=max(len(residuals) - 1, 0), residual=res)
def _solve_cg_jacobi(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, SolveInfo]:
# The matrix is SPD, so CG is guaranteed to converge. Jacobi is the
# only preconditioner in scipy that keeps the preconditioned operator
# SPD without a factorization that can break down at this scale.
n = A.shape[0]
d = A.diagonal()
M = sla.LinearOperator((n, n), lambda v: v / d)
iters = 0
def count(_):
nonlocal iters
iters += 1
try:
x, code = sla.cg(A, b, M=M, rtol=config.CG_TOL,
maxiter=config.CG_MAXITER, callback=count)
except TypeError: # scipy < 1.12 uses tol=
x, code = sla.cg(A, b, M=M, tol=config.CG_TOL,
maxiter=config.CG_MAXITER, callback=count)
if code != 0:
raise SolverError(
f"CG did not converge in {config.CG_MAXITER} iterations "
f"(code {code}). Try a coarser grid or raise CG_MAXITER."
)
res = float(np.linalg.norm(b - A @ x) / np.linalg.norm(b))
return x, SolveInfo(method="cg+jacobi", n_unknowns=n, iterations=iters,
residual=res)
def _face_current_density(V2: np.ndarray, mask2: np.ndarray, sigma: float,
h_m: float, t_m: float,
sig2d: np.ndarray | None = None,
rho: float | None = None) -> np.ndarray:
"""|J| (A/m^2) for one layer from face currents; V2 in volts.
With a per-cell conductance map (buildup), face currents use the
harmonic mean and J is referenced to the conductance-equivalent
copper thickness t_eq = sigma_cell * rho (equals the geometric t for
plain DC copper)."""
ny, nx = mask2.shape
face_x = mask2[:, :-1] & mask2[:, 1:]
face_y = mask2[:-1, :] & mask2[1:, :]
if sig2d is None:
wx = wy = sigma
teq = np.full((ny, nx), t_m)
else:
wx = 2.0 * sig2d[:, :-1] * sig2d[:, 1:] / (sig2d[:, :-1] + sig2d[:, 1:])
wy = 2.0 * sig2d[:-1, :] * sig2d[1:, :] / (sig2d[:-1, :] + sig2d[1:, :])
teq = sig2d * rho
with np.errstate(invalid="ignore"):
Ix = np.where(face_x, (V2[:, :-1] - V2[:, 1:]) * wx, 0.0)
Iy = np.where(face_y, (V2[:-1, :] - V2[1:, :]) * wy, 0.0)
IxP = np.zeros((ny, nx + 1))
IxP[:, 1:nx] = Ix
IyP = np.zeros((ny + 1, nx))
IyP[1:ny, :] = Iy
Jx = 0.5 * (IxP[:, :-1] + IxP[:, 1:])
Jy = 0.5 * (IyP[:-1, :] + IyP[1:, :])
Jmag = np.hypot(Jx, Jy) / (h_m * teq)
Jmag[~mask2] = np.nan
return Jmag
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)
Vflat[state == 2] = 1.0
Vflat[state == 1] = x
Ie = edges.w * (Vflat[edges.a] - Vflat[edges.b])
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))
return Vflat, R, I1, I2, mismatch, R, info
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(state.size)
Vflat[state == 1] = x
v_plus = float(Vflat[e1f].mean())
v_minus = float(Vflat[e2f].mean())
R = (v_plus - v_minus) / 1.0
Vflat = Vflat - v_minus # display reference: <V-> = 0
# quality: KCL residual of the solved system
res = info.residual
if res is None:
res = float(np.linalg.norm(A @ x - rhs)
/ max(np.linalg.norm(rhs), 1e-300))
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
discrete flux out of the part's cells (same-terminal internal edges
carry zero, opposite-terminal edges are forbidden). Uniform: the
injection is prescribed, so a part carries exactly its cell share."""
out = []
for label, mask3 in parts:
pf = mask3.ravel() & e_flat
n = int(pf.sum())
if contact_model == "uniform":
amps = i_test * n / max(n_terminal_cells, 1)
else:
ina = pf[edges.a]
inb = pf[edges.b]
amps = abs(float(Ie[ina].sum() - Ie[inb].sum())) * scale
out.append((label, amps))
return out
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,
problem.rho_ohm_m, config.SKIN_SIDES)
for li in range(L)
]
rs_ratios = [
skin.resistance_factor(problem.layers[li].thickness_nm * 1e-9,
freq_hz, problem.rho_ohm_m, config.SKIN_SIDES)
for li in range(L)
]
via_factor = skin.resistance_factor(problem.plating_nm * 1e-9, freq_hz,
problem.rho_ohm_m, sides=2)
sigma_buildup = 0.0
if problem.buildups and stack.buildup is not None \
and stack.buildup.any():
sigma_buildup = 1.0 / skin.sheet_resistance_ac(
problem.solder_thickness_nm * 1e-9, freq_hz,
problem.solder_rho_ohm_m, config.SKIN_SIDES)
if problem.extra_cu_nm > 0:
sigma_buildup += 1.0 / skin.sheet_resistance_ac(
problem.extra_cu_nm * 1e-9, freq_hz, problem.rho_ohm_m,
config.SKIN_SIDES)
eq_um = sigma_buildup * problem.rho_ohm_m * 1e6
print(f"solder buildup: {problem.solder_thickness_nm / 1000:.0f} um "
f"solder + {problem.extra_cu_nm / 1000:.0f} um Cu on "
f"{int(stack.buildup.sum())} cells "
f"(= {eq_um:.1f} um equivalent copper)")
if freq_hz > 0:
depth = skin.skin_depth_m(freq_hz, problem.rho_ohm_m)
print(f"AC @ {freq_hz:g} Hz: skin depth {depth * 1e6:.0f} um, "
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)
changed, n_groups = connected_restrict(stack, e1, e2, edges)
if changed:
edges = build_edges(stack, problem, sigmas, via_factor, sigma_buildup)
if edges.dead_barrels:
print(f"warning: {edges.dead_barrels} via/pad barrel(s) found fill "
f"copper on fewer than 2 layers and carry no current (pad "
f"copper is not modeled; a finer grid may pick up thermal "
f"spokes)")
if n_groups > 1 and contact_model != "equipotential":
raise ConnectivityError(
f"The selected fills form {n_groups} disconnected copper groups "
f"that each touch both terminals. The uniform-injection contact "
f"model cannot determine the current split between disconnected "
f"sheets - switch to the equipotential contact model (bonded "
f"lug), or include the layers/vias that join them."
)
if stack.buildup is not None:
stack.buildup &= stack.masks
if stack.chain is not None:
stack.chain &= stack.masks
for label, m in (parts1 or []) + (parts2 or []):
had = bool(m.any())
m &= stack.masks # follow the component restriction
if had and not m.any():
print(f"warning: contact part '{label}' only touches copper "
f"that is not connected to both terminals - it carries "
f"no current")
timings["edges_s"] = time.perf_counter() - t0
t0 = time.perf_counter()
if contact_model == "equipotential":
Vflat, R, I1, I2, mismatch, volts_per_amp, info = \
_solve_equipotential(stack, e1, e2, edges)
else:
Vflat, R, I1, I2, mismatch, volts_per_amp, info = \
_solve_uniform(stack, e1, e2, edges)
timings["solve_s"] = time.perf_counter() - t0
t0 = time.perf_counter()
s = i_test * volts_per_amp # unit-drive volts -> volts @ I_test
# per-edge power @ I_test; distribute in-plane power to endpoint cells
Pe = edges.w * ((Vflat[edges.a] - Vflat[edges.b]) * s) ** 2
inplane = edges.via_index < 0
Pflat = np.zeros(Vflat.size)
np.add.at(Pflat, edges.a[inplane], 0.5 * Pe[inplane])
np.add.at(Pflat, edges.b[inplane], 0.5 * Pe[inplane])
Parea = Pflat.reshape(L, ny, nx) / (h_m * h_m)
Parea[~stack.masks] = np.nan
plane = ny * nx
P_layers = [float(Pflat[li * plane:(li + 1) * plane].sum())
for li in range(L)]
P_vias = float(Pe[~inplane].sum())
P_total = i_test ** 2 * R
balance = abs((sum(P_layers) + P_vias) - P_total) / max(P_total, 1e-300)
if not np.isfinite(balance) or balance > 1e-3:
raise SolverError(
f"Inconsistent solve: R = {R:.6g} ohm with power-balance error "
f"{balance:.2e} (sum of edge powers vs I^2*R). The result is "
f"not trustworthy - try the equipotential contact model or a "
f"different grid size."
)
# via reports: max segment current + total power per via
Ie = edges.w * (Vflat[edges.a] - Vflat[edges.b]) # amps at unit drive
via_reports = []
if problem.vias:
vidx = edges.via_index
for vi in np.unique(vidx[vidx >= 0]):
sel = vidx == vi
via = problem.vias[vi]
via_reports.append(ViaReport(
x_mm=via.x * 1e-6, y_mm=via.y * 1e-6, kind=via.kind,
drill_mm=via.drill_nm * 1e-6,
current_a=float(np.abs(Ie[sel]).max()) * s,
power_w=float(Pe[sel].sum()),
))
via_reports.sort(key=lambda v: v.current_a, reverse=True)
# per-injection-area currents
part_currents1 = _part_currents(
parts1 or [], Ie, edges, e1.ravel(), s, i_test,
contact_model, int(e1.sum()))
part_currents2 = _part_currents(
parts2 or [], Ie, edges, e2.ravel(), s, i_test,
contact_model, int(e2.sum()))
# embedded potential + per-layer current density @ I_test
V3 = np.full((L, ny, nx), np.nan)
V3[stack.masks] = Vflat.reshape(L, ny, nx)[stack.masks] * s
J3 = np.stack([
_face_current_density(
np.nan_to_num(V3[li]), stack.masks[li], sigmas[li],
h_m, problem.layers[li].thickness_nm * 1e-9,
sig2d=_sigma_2d(stack, li, sigmas[li], sigma_buildup),
rho=problem.rho_ohm_m)
for li in range(L)
])
timings["postprocess_s"] = time.perf_counter() - t0
return Result(
R_ohm=R, i_test=i_test, V=V3, Jmag=J3, Parea=Parea,
layer_names=list(stack.layer_names),
P_total=P_total, P_layers=P_layers, P_vias=P_vias,
power_balance_rel=balance, via_reports=via_reports,
I1_a=I1, I2_a=I2, mismatch_rel=mismatch,
n_free=info.n_unknowns, solve_info=info,
part_currents1=part_currents1, part_currents2=part_currents2,
contact_model=contact_model,
freq_hz=freq_hz,
skin_depth_um=(skin.skin_depth_m(freq_hz, problem.rho_ohm_m) * 1e6
if freq_hz > 0 else None),
rs_ratios=rs_ratios,
timings=timings,
)