Release 1.4.0: PDN mode, the config-file workflow, and the dialog editor
tests / archlinux:latest (push) Successful in 39s
tests / debian:12 (push) Successful in 1m17s
tests / fedora:latest (push) Successful in 1m8s
tests / ubuntu:24.04 (push) Successful in 1m23s
tests / ubuntu-latest · py3.11 (push) Successful in 1m25s
tests / ubuntu-latest · py3.13 (push) Successful in 1m5s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s

Multiple Thevenin supplies and prescribed-current loads on one net,
solved in absolute volts with the Tellegen power balance verified per
run; a source-sink pair table (effective copper resistance per
supply x load pair plus an exactly-summing proportional-sharing loss
attribution), in summary.txt and as its own figure. Bonded terminals
short a package's contacts into one lug so the per-pin split becomes
a solve outcome. Geometry dumps carry the terminal set (schema v8).

The dialog gained a Classic/PDN mode selector and a full PDN editor:
per-role supply/load tables built from the marker rectangles (or a
config's terminal set, which never pins mode or net), with Component
hints, per-terminal Layer scopes, Active checkboxes, comments, a
per-net row filter, resizable tables and a scrolling, screen-sized
dialog. Numbers accept SI suffixes (50m, 4.7k) everywhere.

fill_res_config.json fully specifies a run (classic or PDN) with
validation, comments, named side-by-side configs (the one called
default auto-loads), Load/Save buttons with an editable file name,
and saves that never drop anything drawn on the board.

347 tests, green on Python 3.13 and on the 3.9 macOS wheel stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-08-27 17:01:24 +07:00
co-authored by Claude Fable 5
parent 31ef356345
commit 26b1cfaa45
28 changed files with 7363 additions and 406 deletions
+359 -95
View File
@@ -79,23 +79,22 @@ def _leaf_gradients(N: int, a: np.ndarray, b: np.ndarray, cx: np.ndarray,
return gx, gy
def run_solve_adaptive(problem: Problem, stack: RasterStack,
e1: np.ndarray, e2: np.ndarray, i_test: float,
freq_hz: float, contact_model: str,
parts1: list | None,
parts2: list | None) -> sv.Result:
timings = {}
def _leaf_graph(problem: Problem, stack: RasterStack, sigmas: list,
via_factor: float, sigma_buildup: float,
keep_extra: np.ndarray):
"""Per-layer quadtree leaf graphs + their edge set, shared by the
classic and PDN adaptive solves (pure code motion out of
run_solve_adaptive). keep_extra: feature cells the caller pins at
the fine size (classic: e1|e2; PDN: the OR of every terminal's
contact mask, so contact nodes stay 1:1 with cells and per-node
injection equals per-cell); chain / buildup / thickness-scaled /
barrel-attachment cells are pinned here on top. Returns (grids,
offs, N, edges, e_delta, e_axis, e_layer, cxg, cyg, teq_leaves);
the dead-barrel count travels in edges.dead_barrels."""
L, ny, nx = stack.masks.shape
h_m = stack.h_nm * 1e-9
plane = ny * nx
sigmas, rs_ratios, via_factor, sigma_buildup = \
sv._conductance_params(problem, stack, freq_hz)
# --- leaves per layer -------------------------------------------------
t0 = time.perf_counter()
links, dead_barrels = sv._barrel_links(stack, problem)
keep = e1 | e2
keep = keep_extra.copy()
if stack.chain is not None:
keep |= stack.chain
if stack.buildup is not None:
@@ -213,6 +212,128 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
e_delta = np.concatenate(dd)
e_axis = np.concatenate(xx)
e_layer = np.concatenate(ee)
return (grids, offs, N, edges, e_delta, e_axis, e_layer, cxg, cyg,
teq_leaves)
def _expand_fields(problem: Problem, stack: RasterStack, grids: list,
offs: np.ndarray, N: int, edges: sv.Edges,
e_axis: np.ndarray, e_layer: np.ndarray,
cxg: np.ndarray, cyg: np.ndarray, teq_leaves: list,
Vflat: np.ndarray, Ie: np.ndarray, s: float):
"""Leaf-space powers, via reports, mesh overlay and the fine-grid
V/J/P expansion - shared by the classic and PDN adaptive solves
(pure code motion out of run_solve_adaptive). PDN appends virtual
supply nodes after N and tags its attachment edges PDN_EDGE: the
in-plane selection (via_index == -1) and the via selection (>= 0)
keep them out of every copper field and report here. Returns
(Pe, P_layers, P_vias, via_reports, V3, J3, Parea)."""
L, ny, nx = stack.masks.shape
h_m = stack.h_nm * 1e-9
# 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 == -1
Pnode = np.zeros(N)
np.add.at(Pnode, edges.a[inplane], 0.5 * Pe[inplane])
np.add.at(Pnode, edges.b[inplane], 0.5 * Pe[inplane])
P_layers = [float(Pnode[offs[li]:offs[li + 1]].sum()) for li in range(L)]
P_vias = float(Pe[edges.via_index >= 0].sum())
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(sv.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)
# leaf boundaries for the raster map: draw the coarse mesh structure
# (fine regions stay plain copper = fully resolved)
stack.mesh = np.zeros_like(stack.masks)
for li in range(L):
if grids[li].n == 0:
continue
ids = grids[li].id_grid
b = np.zeros_like(stack.masks[li])
b[:, 1:] |= ids[:, 1:] != ids[:, :-1]
b[1:, :] |= ids[1:, :] != ids[:-1, :]
coarse = grids[li].size[np.maximum(ids, 0)] >= 2
stack.mesh[li] = b & coarse & stack.masks[li]
# piecewise-LINEAR potential expansion from the leaf gradients of the
# final solution: constant-per-leaf expansion shows leaf-sized
# staircase corners in the equipotential contours on coarse interiors
faces = e_axis >= 0
fa, fb = edges.a[faces], edges.b[faces]
if faces.any():
dgx, dgy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat)
else:
dgx = dgy = np.zeros(N)
V3 = np.full((L, ny, nx), np.nan)
J3 = np.full((L, ny, nx), np.nan)
Parea = np.full((L, ny, nx), np.nan)
for li in range(L):
g_ = grids[li]
ids = g_.id_grid
m = stack.masks[li]
ii, jj = np.nonzero(m)
gid = offs[li] + ids[ii, jj]
V3[li][ii, jj] = (Vflat[gid]
+ dgx[gid] * (jj + 0.5 - cxg[gid])
+ dgy[gid] * (ii + 0.5 - cyg[gid])) * s
sel = (e_axis >= 0) & (e_layer == li)
la = (edges.a[sel] - offs[li]).astype(np.int64)
lb = (edges.b[sel] - offs[li]).astype(np.int64)
If = Ie[sel]
axl = e_axis[sel]
Ixn = np.zeros(g_.n)
Iyn = np.zeros(g_.n)
for axis, acc in ((0, Ixn), (1, Iyn)):
sub = axl == axis
np.add.at(acc, la[sub], If[sub])
np.add.at(acc, lb[sub], If[sub])
span_m = g_.size.astype(float) * h_m
with np.errstate(invalid="ignore", divide="ignore"):
Jl = np.hypot(0.5 * Ixn, 0.5 * Iyn) / (span_m * teq_leaves[li])
J3[li][m] = Jl[ids[m]] * s
cellP = Pnode[offs[li]:offs[li + 1]] \
/ (g_.size.astype(float) ** 2 * h_m * h_m)
Parea[li][m] = np.maximum(cellP, 0.0)[ids[m]]
# chain cells accumulate no leaf-face currents (their links carry
# axis -1): overlay the true 1D link density
sv.overlay_chain_density(stack, problem.rho_ohm_m, V3, J3)
return Pe, P_layers, P_vias, via_reports, V3, J3, Parea
def run_solve_adaptive(problem: Problem, stack: RasterStack,
e1: np.ndarray, e2: np.ndarray, i_test: float,
freq_hz: float, contact_model: str,
parts1: list | None,
parts2: list | None) -> sv.Result:
timings = {}
L, ny, nx = stack.masks.shape
h_m = stack.h_nm * 1e-9
sigmas, rs_ratios, via_factor, sigma_buildup = \
sv._conductance_params(problem, stack, freq_hz)
# --- leaves per layer -------------------------------------------------
t0 = time.perf_counter()
(grids, offs, N, edges, e_delta, e_axis, e_layer, cxg, cyg,
teq_leaves) = _leaf_graph(problem, stack, sigmas, via_factor,
sigma_buildup, e1 | e2)
dead_barrels = edges.dead_barrels
# --- connectivity restriction on the leaf graph -----------------------
graph = sparse.coo_matrix(
@@ -343,15 +464,9 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
t0 = time.perf_counter()
s = i_test * volts_per_amp
# 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
Pnode = np.zeros(N)
np.add.at(Pnode, edges.a[inplane], 0.5 * Pe[inplane])
np.add.at(Pnode, edges.b[inplane], 0.5 * Pe[inplane])
P_layers = [float(Pnode[offs[li]:offs[li + 1]].sum()) for li in range(L)]
P_vias = float(Pe[~inplane].sum())
Pe, P_layers, P_vias, via_reports, V3, J3, Parea = _expand_fields(
problem, stack, grids, offs, N, edges, e_axis, e_layer, cxg, cyg,
teq_leaves, Vflat, Ie, s)
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:
@@ -362,20 +477,6 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
f"different grid size."
)
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(sv.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)
def part_currents(parts, e_nodes, n_total_cells):
out = []
for label, mask3 in (parts or []):
@@ -394,64 +495,6 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
part_currents1 = part_currents(parts1, e1n, int(e1.sum()))
part_currents2 = part_currents(parts2, e2n, int(e2.sum()))
# leaf boundaries for the raster map: draw the coarse mesh structure
# (fine regions stay plain copper = fully resolved)
stack.mesh = np.zeros_like(stack.masks)
for li in range(L):
if grids[li].n == 0:
continue
ids = grids[li].id_grid
b = np.zeros_like(stack.masks[li])
b[:, 1:] |= ids[:, 1:] != ids[:, :-1]
b[1:, :] |= ids[1:, :] != ids[:-1, :]
coarse = grids[li].size[np.maximum(ids, 0)] >= 2
stack.mesh[li] = b & coarse & stack.masks[li]
# piecewise-LINEAR potential expansion from the leaf gradients of the
# final solution: constant-per-leaf expansion shows leaf-sized
# staircase corners in the equipotential contours on coarse interiors
if faces.any():
dgx, dgy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat)
else:
dgx = dgy = np.zeros(N)
V3 = np.full((L, ny, nx), np.nan)
J3 = np.full((L, ny, nx), np.nan)
Parea = np.full((L, ny, nx), np.nan)
for li in range(L):
g_ = grids[li]
ids = g_.id_grid
m = stack.masks[li]
Vl = Vflat[offs[li]:offs[li + 1]]
ii, jj = np.nonzero(m)
gid = offs[li] + ids[ii, jj]
V3[li][ii, jj] = (Vflat[gid]
+ dgx[gid] * (jj + 0.5 - cxg[gid])
+ dgy[gid] * (ii + 0.5 - cyg[gid])) * s
sel = (e_axis >= 0) & (e_layer == li)
la = (edges.a[sel] - offs[li]).astype(np.int64)
lb = (edges.b[sel] - offs[li]).astype(np.int64)
If = Ie[sel]
axl = e_axis[sel]
Ixn = np.zeros(g_.n)
Iyn = np.zeros(g_.n)
for axis, acc in ((0, Ixn), (1, Iyn)):
sub = axl == axis
np.add.at(acc, la[sub], If[sub])
np.add.at(acc, lb[sub], If[sub])
span_m = g_.size.astype(float) * h_m
with np.errstate(invalid="ignore", divide="ignore"):
Jl = np.hypot(0.5 * Ixn, 0.5 * Iyn) / (span_m * teq_leaves[li])
J3[li][m] = Jl[ids[m]] * s
cellP = Pnode[offs[li]:offs[li + 1]] \
/ (g_.size.astype(float) ** 2 * h_m * h_m)
Parea[li][m] = np.maximum(cellP, 0.0)[ids[m]]
# chain cells accumulate no leaf-face currents (their links carry
# axis -1): overlay the true 1D link density
sv.overlay_chain_density(stack, problem.rho_ohm_m, V3, J3)
timings["postprocess_s"] = time.perf_counter() - t0
return sv.Result(
@@ -469,3 +512,224 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
rs_ratios=rs_ratios,
timings=timings,
)
def run_solve_adaptive_pdn(problem: Problem, stack: RasterStack,
term_masks: list, term_parts: list,
freq_hz: float, v_nominal: float) -> sv.Result:
"""PDN solve on the leaf graph (dispatched from solver.run_solve_pdn,
which already labeled and validated the terminals). Same electrical
model as the uniform-grid path: Thevenin supplies as virtual
Dirichlet nodes appended after the leaf id space, loads as uniform
per-cell injection. Contact cells are pinned fine by _leaf_graph, so
leaf nodes and contact cells are 1:1 and the per-node quantities
match the uniform grid exactly there. The deferred-correction loop
is unchanged: supply attachment edges carry e_axis = -1 / e_delta =
0, so they are excluded from the gradient reconstruction and get
zero correction (their currents stay exactly w * dV)."""
timings = {}
L, ny, nx = stack.masks.shape
terminals = problem.terminals
sigmas, rs_ratios, via_factor, sigma_buildup = \
sv._conductance_params(problem, stack, freq_hz)
# --- leaves per layer -------------------------------------------------
t0 = time.perf_counter()
keep_extra = np.zeros_like(stack.masks)
for m in term_masks:
keep_extra |= m
(grids, offs, N, edges, e_delta, e_axis, e_layer, cxg, cyg,
teq_leaves) = _leaf_graph(problem, stack, sigmas, via_factor,
sigma_buildup, keep_extra)
dead_barrels = edges.dead_barrels
# --- connectivity restriction on the leaf graph (PDN keep rule) -------
graph = sparse.coo_matrix(
(np.ones(len(edges.a)), (edges.a, edges.b)), shape=(N, N))
_, labels = csgraph.connected_components(graph, directed=False)
term_nodes_all = []
for m in term_masks:
tn = np.zeros(N, dtype=bool)
for li in range(L):
tn[_nodes_of_cells(grids, offs, li, m[li])] = True
term_nodes_all.append(tn)
per_term = [set(np.unique(labels[tn]).tolist()) if tn.any() else set()
for tn in term_nodes_all]
kept = sv._pdn_keep_components(terminals, per_term)
keepn = np.isin(labels, sorted(kept))
if not keepn.all():
sel = keepn[edges.a] & keepn[edges.b]
edges = sv.Edges(a=edges.a[sel], b=edges.b[sel], w=edges.w[sel],
via_index=edges.via_index[sel],
dead_barrels=dead_barrels)
e_delta, e_axis, e_layer = e_delta[sel], e_axis[sel], e_layer[sel]
for li in range(L):
if grids[li].n == 0:
continue
ids = grids[li].id_grid
kept_cells = (ids >= 0) & keepn[offs[li] + np.maximum(ids, 0)]
stack.masks[li] &= kept_cells
for m in term_masks:
m[li] &= kept_cells
if stack.buildup is not None:
stack.buildup &= stack.masks
if stack.chain is not None:
stack.chain &= stack.masks
for tn in term_nodes_all:
tn &= keepn
for t, m in zip(terminals, term_masks):
if t.role == "supply" and not m.any():
print(f"warning: supply '{t.label}' only touches copper "
f"not connected to any load - it delivers 0 A")
for t, parts in zip(terminals, term_parts):
for label, m in parts:
had = bool(m.any())
m &= stack.masks
if had and not m.any():
print(f"warning: contact part '{label}' of {t.role} "
f"'{t.label}' only touches disconnected copper - "
f"it carries no current")
timings["edges_s"] = time.perf_counter() - t0
# --- solve with deferred-correction interface fluxes -------------------
t0 = time.perf_counter()
state = np.zeros(N, dtype=np.uint8)
state[keepn] = 1
term_nodes = [np.flatnonzero(tn) for tn in term_nodes_all]
state_base = state.copy() # copper-only state for _pdn_pairs
state, dirichlet_v, inj, edges_ext, attaches, merge = sv._pdn_attach(
terminals, term_nodes, state, edges, v_nominal)
n_pdn = len(edges_ext.a) - len(edges.a)
e_delta = np.concatenate([e_delta, np.zeros(n_pdn)])
e_axis = np.concatenate([e_axis, np.full(n_pdn, -1, dtype=np.int8)])
e_layer = np.concatenate([e_layer, np.full(n_pdn, -1, dtype=np.int16)])
# bonded terminals: solve on the merge-relabeled edges; contact
# cells are pinned fine, so every face touching a member is a
# fine-fine face with zero tangential offset - the deferred
# correction never fires there and the lug's mixed-position
# gradient can do no harm (it only ever multiplies delta = 0)
edges_solve = sv._pdn_solve_edges(edges_ext, merge)
A, rhs0, _ = sv._assemble(state, edges_solve, inj, dirichlet_v)
ps = sv.PreparedSolver(A)
free = state == 1
def expand(x):
V = np.where(state >= 2, dirichlet_v, 0.0)
V[free] = x
if merge is not None:
V = V[merge] # bonded members read their lug
return V
x, info = ps.solve(rhs0)
Vflat = expand(x)
corr = np.zeros(len(edges_ext.a))
faces = e_axis >= 0
fa, fb = edges_ext.a[faces], edges_ext.b[faces]
passes = max(0, int(config.ADAPTIVE_CORRECTION_PASSES))
for p in range(passes):
if not faces.any():
break
progress.stage(f"correction pass {p + 1}/{passes} ...")
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_ext.a))
corr[faces] = edges_ext.w[faces] * e_delta[faces] * gt
extra = np.zeros(state.size)
np.add.at(extra, edges_solve.a, -corr)
np.add.at(extra, edges_solve.b, corr)
x, info = ps.solve(rhs0 + extra[free])
Vflat = expand(x)
# corrected currents in absolute volts: satisfy KCL exactly
Ie = edges_ext.w * (Vflat[edges_ext.a] - Vflat[edges_ext.b]) + corr
timings["solve_s"] = time.perf_counter() - t0
# --- fields on leaves, expanded to the fine grid ------------------------
t0 = time.perf_counter()
term_part_nodes = []
for parts in term_parts:
pn = []
for pl, m3 in parts:
nodes = np.zeros(N, dtype=bool)
for li in range(L):
nodes[_nodes_of_cells(grids, offs, li, m3[li])] = True
pn.append((pl, np.flatnonzero(nodes)))
term_part_nodes.append(pn)
supplies, loads = sv._pdn_extract(terminals, term_nodes, attaches,
Vflat, Ie, edges_ext, term_part_nodes)
Pe, P_layers, P_vias, via_reports, V3, J3, Parea = _expand_fields(
problem, stack, grids, offs, N, edges_ext, e_axis, e_layer,
cxg, cyg, teq_leaves, Vflat, Ie, 1.0)
balance, mismatch, i_sup, i_loads, p_loads = sv._pdn_balance(
supplies, loads, P_layers, P_vias)
timings["postprocess_s"] = time.perf_counter() - t0
# --- source-sink pair matrix on the copper-only leaf graph -------------
# same deferred-correction loop per pattern solve, so the pair
# resistances match the uniform grid to the usual adaptive accuracy
t0 = time.perf_counter()
ebase = len(edges.a)
axb = e_axis[:ebase]
dlb = e_delta[:ebase]
facb = axb >= 0
def _pair_solver(state_g, dv, edges_pm, pmerge):
A2, rhs0p, _ = sv._assemble(state_g, edges_pm, None, dv)
ps2 = sv.PreparedSolver(A2)
freeg = state_g == 1
fa3, fb3 = edges.a[facb], edges.b[facb]
def expand_g(x2):
V = np.where(state_g >= 2, dv, 0.0)
V[freeg] = x2
if pmerge is not None:
V = V[pmerge] # members read their super-node
return V
def slv(inj_p):
x2, _ = ps2.solve(rhs0p + inj_p[freeg])
V = expand_g(x2)
for _p in range(passes):
if not facb.any():
break
gx, gy = _leaf_gradients(N, fa3, fb3, cxg, cyg, V)
gt = np.where(axb[facb] == 0,
0.5 * (gy[fa3] + gy[fb3]),
0.5 * (gx[fa3] + gx[fb3]))
corrp = np.zeros(ebase)
corrp[facb] = edges.w[facb] * dlb[facb] * gt
extra = np.zeros(state_g.size)
np.add.at(extra, edges_pm.a, -corrp)
np.add.at(extra, edges_pm.b, corrp)
x2, _ = ps2.solve(rhs0p + inj_p[freeg] + extra[freeg])
V = expand_g(x2)
return V
return slv
pairs = sv._pdn_pairs(terminals, term_nodes, attaches, merge,
state_base, edges, supplies, loads,
_pair_solver)
timings["pairs_s"] = time.perf_counter() - t0
return sv.Result(
R_ohm=float("nan"), i_test=i_loads, V=V3, Jmag=J3, Parea=Parea,
layer_names=list(stack.layer_names),
P_total=float(sum(P_layers) + P_vias),
P_layers=P_layers, P_vias=P_vias,
power_balance_rel=balance, via_reports=via_reports,
I1_a=i_sup, I2_a=i_loads, mismatch_rel=mismatch,
n_free=info.n_unknowns, solve_info=info,
contact_model="pdn",
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,
mode="pdn", supplies=supplies, loads=loads,
P_loads=p_loads,
P_supply_internal=sum(s_.p_internal_w for s_ in supplies),
v_nominal=v_nominal, pairs=pairs,
)