Files
kicad-zone-resistance/fill_resistance/solver.py
T
janik 06c62e04f8 Initial import: KiCad zone resistance plugin
DC/AC resistance, power dissipation, and via/injection-area currents of
copper zone fills. KiCad 10 IPC-API plugin (kicad-python/kipy):
multi-layer via-coupled FDM solver, multi-part terminals via User.1/User.2
marker layers, pads as contacts, uniform-injection and equipotential
contact models, per-foil skin effect, optional solder/copper buildup on
mask openings. 54-case test suite incl. exact analytic references.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:22:00 +07:00

550 lines
22 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 vertically
aligned cells of the layers they span AND reach copper on. A barrel
passing an 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
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
@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."""
if stack.buildup is None or sigma_buildup <= 0 \
or not stack.buildup[li].any():
return None
s = np.full(stack.shape2d, sigma_layer)
s[stack.buildup[li]] += sigma_buildup
return s
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]
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))
for vi, via in enumerate(problem.vias):
cell = stack.cell_of(via.x, via.y)
if cell is None:
continue
i, j = cell
present = [li for li, layer in enumerate(problem.layers)
if via.spans(layer.z_nm) and stack.masks[li, i, j]]
for la, lb 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 + i * nx + j], dtype=np.int64))
bb.append(np.array([lb * plane + i * nx + j], dtype=np.int64))
ww.append(np.array([1.0 / r]))
vv.append(np.array([vi], 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))
def connected_restrict(stack: RasterStack, e1: np.ndarray, e2: np.ndarray,
edges: Edges) -> bool:
"""Keep only components (through-plane AND through-via) touching both
terminals. Mutates stack.masks / e1 / e2. Returns True if anything
was dropped (caller must rebuild edges)."""
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
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)
# 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.
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 RuntimeError(
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 _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)
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 _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)
x, info = solve_system(A, rhs)
Vflat = np.zeros(n)
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 _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 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
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}")
t0 = time.perf_counter()
edges = build_edges(stack, problem, sigmas, via_factor, sigma_buildup)
if connected_restrict(stack, e1, e2, edges):
edges = build_edges(stack, problem, sigmas, via_factor, sigma_buildup)
if stack.buildup is not None:
stack.buildup &= stack.masks
for _, m in (parts1 or []) + (parts2 or []):
m &= stack.masks # follow the component restriction
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)
# 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,
)