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
+1 -1
View File
@@ -4,4 +4,4 @@ __version__ is the runtime source of truth (metadata.json and
pyproject.toml are not deployed with the plugin); a test keeps the
three in sync.
"""
__version__ = "1.3.0"
__version__ = "1.4.0"
+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,
)
+531 -16
View File
@@ -12,7 +12,7 @@ from pathlib import Path
from kipy import KiCad
from kipy.board import Board
from kipy.board_types import ArcTrack, BoardRectangle, Pad, Via
from kipy.board_types import ArcTrack, BoardRectangle, BoardText, Pad, Via
from kipy.proto.board.board_pb2 import BoardStackupLayerType
from kipy.proto.board.board_types_pb2 import ZoneType
from kipy.util.board_layer import (canonical_name, is_copper_layer,
@@ -21,9 +21,10 @@ from kipy.util.board_layer import (canonical_name, is_copper_layer,
import numpy as np
from . import config
from .errors import ApiVersionError, CandidateError, SelectionError
from .errors import (ApiVersionError, CandidateError, ConfigError,
SelectionError)
from .geometry import (Electrode, LayerFill, Polygon, Problem, Rect,
SurfaceBuildup, TrackSeg, ViaLink,
SurfaceBuildup, Terminal, TrackSeg, ViaLink,
contact_solder_buildups, linearize_ring,
tht_joint_buildups)
@@ -391,6 +392,479 @@ def get_electrodes(board: Board, stackup: StackupInfo | None = None
)
# --- config-file terminal resolution -----------------------------------------
def _pair_rect_labels(board: Board, layer: str) -> list:
"""[(BoardRectangle, name_or_None), ...] for one marker layer. A
rectangle is named by a text item on the same layer whose anchor
lies inside it (BoardRectangle itself has no name in the IPC API);
a rectangle containing several text items is ambiguous and errors.
Duplicate-name policy is the CALLER's (config lookup warns/skips
unnamed rects; the PDN editor scan needs them too)."""
rects = [s for s in board.get_shapes()
if isinstance(s, BoardRectangle)
and canonical_name(s.layer) == layer]
texts = [t for t in board.get_text()
if isinstance(t, BoardText)
and canonical_name(t.layer) == layer]
out = []
for r in rects:
tl, br = r.top_left, r.bottom_right
x0, x1 = min(tl.x, br.x), max(tl.x, br.x)
y0, y1 = min(tl.y, br.y), max(tl.y, br.y)
inside = [t for t in texts
if x0 <= t.position.x <= x1
and y0 <= t.position.y <= y1]
if len(inside) > 1:
names = ", ".join(repr(t.value) for t in inside[:4])
raise ConfigError(
f"the rectangle on {layer} at "
f"({x0 / 1e6:.1f}, {y0 / 1e6:.1f}) mm contains "
f"{len(inside)} text items ({names}) - keep "
f"exactly one so its name is unambiguous."
)
out.append((r, inside[0].value.strip() if inside else None))
return out
@dataclass
class MarkerTerminal:
"""One PDN-editor terminal candidate: one or several marker
rectangles with the role taken from the layer they sit on
(ELECTRODE_POS_LAYER = supply, ELECTRODE_NEG_LAYER = load). In PDN
mode every rectangle is its own terminal - unlike classic mode,
which merges each layer into one V+/V- contact - EXCEPT that
rectangles sharing one text-item name group into a single BONDED
terminal (a multi-pin package: total current known, per-contact
split solved through the internal bond)."""
name: str
role: str # "supply" | "load"
labeled: bool # named by a text item: saves as a
# live rect:NAME ref; unnamed rects
# save as frozen rect_mm coordinates
electrodes: list # [Electrode]; > 1 only when labeled
bonded: bool = False # grouped rects are bonded into one lug
def scan_marker_terminals(board: Board,
require_both: bool = True
) -> list[MarkerTerminal]:
"""Board-wide marker-rectangle scan for the PDN dialog editor (the
selection is deliberately ignored: PDN terminals are the drawn
rectangles, nothing else). Order is stable reading order - supplies
first, each group by the (y, x) of its first rectangle - because
the dialog's row identity is POSITIONAL; auto names S1../L1.. skip
names already taken by a label. Raises SelectionError when either
layer has no rectangle (require_both False skips that check: the
merge with a config's terminal set treats empty layers as simply
'nothing new') and ConfigError on naming problems (both just
disable the editor upstream; classic mode still runs)."""
pos_l = config.ELECTRODE_POS_LAYER
neg_l = config.ELECTRODE_NEG_LAYER
pairs = {l: _pair_rect_labels(board, l) for l in (pos_l, neg_l)}
if require_both and (not pairs[pos_l] or not pairs[neg_l]):
raise SelectionError(
f"PDN mode needs marker rectangles on both layers; found "
f"{len(pairs[pos_l])} on {pos_l} (supplies) and "
f"{len(pairs[neg_l])} on {neg_l} (loads). Draw supply "
f"rectangle(s) on {pos_l} and load rectangle(s) on {neg_l} "
f"(axis-aligned); a text item inside a rectangle names it."
)
# name uniqueness ACROSS marker layers: a name is a terminal name
# here and becomes a rect:NAME ref on save - both need exactly one
# owning layer (User.3 labels count: rect:NAME searches there too).
# WITHIN a layer a repeated name is the grouping mechanism, not an
# error: those rectangles form one bonded terminal
check_layers = []
for l in (pos_l, neg_l, config.ELECTRODE_PDN_LAYER):
if l not in check_layers:
check_layers.append(l)
seen: dict = {}
for l in check_layers:
prs = pairs[l] if l in pairs else _pair_rect_labels(board, l)
for _r, n in prs:
if n is None:
continue
if n in seen and seen[n] != l:
raise ConfigError(
f"rectangle name '{n}' exists on {seen[n]} and {l} "
f"- marker rectangle names must be unique across "
f"the marker layers."
)
seen[n] = l
def reading_order(pair):
tl, br = pair[0].top_left, pair[0].bottom_right
return (min(tl.y, br.y), min(tl.x, br.x))
taken = set(seen)
out: list = []
counter = {"supply": 0, "load": 0}
prefix = {"supply": "S", "load": "L"}
for layer, role in ((pos_l, "supply"), (neg_l, "load")):
groups: dict = {} # name -> MarkerTerminal, in reading
for r, name in sorted(pairs[layer], key=reading_order):
labeled = name is not None
if not labeled:
while True:
counter[role] += 1
name = f"{prefix[role]}{counter[role]}"
if name not in taken:
break
taken.add(name)
e = _to_electrode(board, r)
e.label = name
if labeled and name in groups:
mt = groups[name]
mt.electrodes.append(e)
mt.bonded = True # grouped = one externally bonded lug
continue
mt = MarkerTerminal(name=name, role=role, labeled=labeled,
electrodes=[e])
groups[name] = mt
out.append(mt)
return out
RECT_MATCH_TOL_MM = 1e-3 # frozen rect_mm coords are written with
# 1e-6 rounding; 1 um absorbs both that
# and the nm->mm float trip
def new_marker_terminals(specs: list, marker_terms: list
) -> list[MarkerTerminal]:
"""The scanned marker terminals NOT already referenced by the
config's TerminalSpec list: drawing a new rectangle on a marker
layer creates a new terminal even while a config provides the set.
A scanned rectangle is covered when its label appears as a
rect:NAME part (drawing MORE rects with that name extends that
very terminal at resolve time, so the scan group is not new
either) or when its geometry matches a frozen rect_mm part. A
label colliding with an unrelated config terminal name is skipped
with a printed note (rename one of the two); colliding auto names
are simply renumbered."""
covered_labels = set()
covered_rects = []
names = set()
for spec in specs:
names.add(spec.name)
for part in spec.parts:
if part.kind == "rect_label":
covered_labels.add(part.label)
elif part.kind == "rect_mm":
x0, y0, x1, y1 = part.rect_mm
covered_rects.append((min(x0, x1), min(y0, y1),
max(x0, x1), max(y0, y1)))
def frozen(e) -> bool:
r = e.rect
mm = (r.x0 / 1e6, r.y0 / 1e6, r.x1 / 1e6, r.y1 / 1e6)
return any(all(abs(a - b) <= RECT_MATCH_TOL_MM
for a, b in zip(mm, c)) for c in covered_rects)
taken = names | {mt.name for mt in marker_terms}
out = []
for mt in marker_terms:
if mt.labeled and mt.name in covered_labels:
continue
if all(frozen(e) for e in mt.electrodes):
continue
if mt.name in names:
if mt.labeled:
print(f"note: rectangle '{mt.name}' collides with the "
f"config terminal '{mt.name}' (which does not "
f"reference it) - rename one of the two to add "
f"the rectangle as a terminal")
continue
prefix = "S" if mt.role == "supply" else "L"
i = 1
while f"{prefix}{i}" in taken:
i += 1
mt.name = f"{prefix}{i}"
taken.add(mt.name)
for e in mt.electrodes:
e.label = mt.name
out.append(mt)
return out
def component_hints(board: Board, electrode_groups: list) -> list:
"""One row-identification string per electrode group for the PDN
dialog's Component column: the reference designators of footprints
with a pad intersecting any of the group's contact rectangles,
else "near <ref>" for the footprint whose pad center is closest.
Purely spatial - no net or layer filter: this identifies WHERE a
terminal sits, it plays no electrical role. Pads are approximated
by squares of their largest copper diameter (exact enough for
naming the owner). Empty string for a group when the board has no
usable footprints."""
fps = []
for fp in board.get_footprints():
try:
ref = fp.reference_field.text.value
pads = [(int(p.position.x), int(p.position.y),
_padstack_pad_nm(p) // 2)
for p in fp.definition.pads]
except Exception:
continue # identification only: skip odd
if ref and pads: # footprints, never fail the run
fps.append((ref, pads))
out = []
for electrodes in electrode_groups:
hits = []
near = None # (distance_nm, ref)
for ref, pads in fps:
best = None
for x, y, r in pads:
for e in electrodes:
rc = e.rect
# center-to-rectangle axis distances; both within
# the pad half-size = the square pad overlaps
dx = max(rc.x0 - x, x - rc.x1, 0)
dy = max(rc.y0 - y, y - rc.y1, 0)
d = 0.0 if (dx <= r and dy <= r) \
else float(dx * dx + dy * dy) ** 0.5
if best is None or d < best:
best = d
if best == 0.0:
hits.append(ref)
elif best is not None and (near is None or best < near[0]):
near = (best, ref)
if hits:
out.append(", ".join(hits[:3])
+ (f" +{len(hits) - 3}" if len(hits) > 3 else ""))
elif near is not None:
out.append(f"near {near[1]}")
else:
out.append("")
return out
class _RefContext:
"""Resolves configfile.PartRef entries against a live board. Board
queries (footprints, pads, shapes, texts, vias) are fetched once,
lazily - every map is built from the SAME get_footprints() call so
ownership comparisons stay identity-safe."""
def __init__(self, board: Board, stackup: StackupInfo | None, net: str):
self.board = board
self.stackup = stackup
self.net = net
self._by_ref: dict | None = None
self._pad_map: dict | None = None
self._pads: list | None = None
self._rects: dict = {} # layer -> {name: BoardRectangle}
self._vias: list | None = None
def _footprints(self) -> dict:
if self._by_ref is None:
fps = list(self.board.get_footprints())
self._by_ref = {}
for fp in fps:
try:
ref = fp.reference_field.text.value
except Exception:
continue
if ref:
self._by_ref.setdefault(ref, []).append(fp)
self._pad_map = _footprint_pad_map(fps)
return self._by_ref
def _board_pads(self) -> list:
if self._pads is None:
self._pads = list(self.board.get_pads())
return self._pads
def _fp_of(self, ref: str, where: str):
by_ref = self._footprints()
fps = by_ref.get(ref)
if not fps:
raise ConfigError(
f"{where}: footprint '{ref}' not found on the board."
)
if len(fps) > 1:
raise ConfigError(
f"{where}: reference '{ref}' is ambiguous - "
f"{len(fps)} footprints share it."
)
return fps[0]
def _pads_of_fp(self, fp) -> list:
self._footprints()
return [p for p in self._board_pads()
if _pad_owner(p, self._pad_map) is fp]
def _labeled_rects(self, layer: str) -> dict:
"""name -> [BoardRectangle, ...] on one marker layer (see
_pair_rect_labels). Cached per layer; unnamed rectangles are
skipped with a warning. Several rectangles sharing one name are
ONE multi-part reference (the grouping mechanism for bonded
multi-contact terminals), not an error."""
if layer not in self._rects:
named: dict = {}
for r, name in _pair_rect_labels(self.board, layer):
if name is None:
tl, br = r.top_left, r.bottom_right
print(f"config warning: unnamed rectangle on {layer} "
f"at ({min(tl.x, br.x) / 1e6:.1f}, "
f"{min(tl.y, br.y) / 1e6:.1f}) mm - place a "
f"text item inside it to use it as rect:NAME")
continue
named.setdefault(name, []).append(r)
self._rects[layer] = named
return self._rects[layer]
def _net_vias(self) -> list:
if self._vias is None:
self._vias = [v for v in self.board.get_vias()
if v.net is not None and v.net.name == self.net]
return self._vias
def resolve(self, part, where: str) -> list[Electrode]:
"""PartRef -> Electrode list (footprints can span several pads).
All errors are ConfigError with the terminal context in
`where`."""
if part.kind == "footprint":
fp = self._fp_of(part.ref, where)
pads = self._pads_of_fp(fp)
on_net = [p for p in pads
if p.net is not None and p.net.name == self.net]
if not on_net:
nets = sorted({p.net.name for p in pads
if p.net is not None})
raise ConfigError(
f"{where}: footprint '{part.ref}' has no pads on net "
f"'{self.net}'"
+ (f" (its nets: {', '.join(nets)})." if nets
else " (it has no connected pads).")
)
return [_to_electrode(self.board, p, self.stackup,
self._pad_map) for p in on_net]
if part.kind == "pad":
fp = self._fp_of(part.ref, where)
pads = self._pads_of_fp(fp)
matches = [p for p in pads if p.number == part.pad]
if not matches:
nums = ", ".join(sorted({p.number for p in pads})[:16])
raise ConfigError(
f"{where}: '{part.ref}' has no pad '{part.pad}'"
+ (f" (its pads: {nums})." if nums else ".")
)
for p in matches:
pnet = p.net.name if p.net is not None else "no net"
if pnet != self.net:
raise ConfigError(
f"{where}: pad '{part.ref}.{part.pad}' is on "
f"'{pnet}', not '{self.net}'."
)
return [_to_electrode(self.board, p, self.stackup,
self._pad_map) for p in matches]
if part.kind == "rect_label":
# rect:NAME searches every marker layer, so labeled
# PDN-editor rectangles (User.1/User.2) resolve too; a name
# existing on several layers is ambiguous and errors
layers = []
for l in (config.ELECTRODE_PDN_LAYER,
config.ELECTRODE_POS_LAYER,
config.ELECTRODE_NEG_LAYER):
if l not in layers:
layers.append(l)
hits = [(l, self._labeled_rects(l)[part.label])
for l in layers
if part.label in self._labeled_rects(l)]
if not hits:
names = ", ".join(sorted(
{n for l in layers for n in self._labeled_rects(l)}
)[:16])
raise ConfigError(
f"{where}: no rectangle named '{part.label}' on "
f"{', '.join(layers)}"
+ (f" (found: {names})." if names else
" (no named rectangles found there).")
)
if len(hits) > 1:
raise ConfigError(
f"{where}: rectangle name '{part.label}' exists on "
f"{' and '.join(l for l, _ in hits)} - marker "
f"rectangle names must be unique across layers."
)
# every same-named rectangle on the owning layer is one
# part of the reference (multi-contact terminals)
return [_to_electrode(self.board, r) for r in hits[0][1]]
if part.kind == "rect_mm":
x0, y0, x1, y1 = part.rect_mm
rect = Rect.normalized(int(x0 * 1e6), int(y0 * 1e6),
int(x1 * 1e6), int(y1 * 1e6),
"config")
return [Electrode(rect=rect,
contact=part.contact or "all",
label=f"rect({x0:g},{y0:g})")]
# via_mm
x = int(part.via_mm[0] * 1e6)
y = int(part.via_mm[1] * 1e6)
best, bd = None, 0.0
for v in self._net_vias():
d = math.hypot(v.position.x - x, v.position.y - y)
if best is None or d < bd:
best, bd = v, d
if best is None:
raise ConfigError(
f"{where}: net '{self.net}' has no vias "
f"({part.describe()})."
)
if bd > 1e6:
raise ConfigError(
f"{where}: {part.describe()} - the nearest via of "
f"'{self.net}' is {bd / 1e6:.2f} mm away (limit 1 mm)."
)
return [_to_electrode(self.board, best, self.stackup)]
def _resolve_parts(ctx: _RefContext, spec_contact: str, parts: list,
where: str) -> list[Electrode]:
"""Resolve a part list and apply the contact-scope precedence: an
explicit part-level contact wins, else the terminal-level scope
(unless 'auto' = keep what resolution decided)."""
out = []
for part in parts:
els = ctx.resolve(part, where)
for e in els:
if part.contact:
e.contact = part.contact
elif spec_contact and spec_contact != "auto":
e.contact = spec_contact
out.extend(els)
return out
def resolve_terminal_specs(board: Board, stackup: StackupInfo | None,
specs: list, net: str) -> list[Terminal]:
"""configfile.TerminalSpec list -> geometry.Terminal list, resolved
against the live board. Raises ConfigError naming the terminal and
the offending reference."""
ctx = _RefContext(board, stackup, net)
terminals = []
for spec in specs:
where = f"{spec.role} '{spec.name}'"
electrodes = _resolve_parts(ctx, spec.contact, spec.parts, where)
terminals.append(Terminal(
role=spec.role, electrodes=electrodes, label=spec.name,
i_draw_a=spec.i_draw_a, r_out_ohm=spec.r_out_ohm,
v_oc=spec.v_oc, bonded=spec.bonded,
comment=getattr(spec, "comment", "")))
return terminals
def resolve_classic_parts(board: Board, stackup: StackupInfo | None,
pos: list, neg: list, net: str
) -> tuple[list[Electrode], list[Electrode]]:
"""classic.pos / classic.neg part references -> V+/V- electrode
lists (the config file then fully replaces the board selection)."""
ctx = _RefContext(board, stackup, net)
return (_resolve_parts(ctx, "", pos, "classic.pos"),
_resolve_parts(ctx, "", neg, "classic.neg"))
# --- fills -------------------------------------------------------------------
def gather_net_fills(board: Board) -> dict[str, dict[str, list[Polygon]]]:
@@ -481,6 +955,23 @@ def nets_overlapping(fills: dict, es1: list[Electrode],
return sorted(out)
def group_nets(copper: dict, electrode_groups: list) -> list:
"""Per electrode group: the frozenset of nets whose copper overlaps
any of the group's contact rectangles (any layer - the connection
may go through vias; same permissive bbox prefilter as
nets_overlapping). The PDN editor uses this to show only the
rectangles that actually sit on the selected net."""
out = []
for electrodes in electrode_groups:
nets = set()
for net, per_layer in copper.items():
if any(_rect_overlaps(e.rect, polys) for e in electrodes
for polys in per_layer.values()):
nets.add(net)
out.append(frozenset(nets))
return out
def gather_mask_buildups(board: Board) -> dict[str, list[Polygon]]:
"""Zones on F.Mask/B.Mask (mask openings) -> fill polygons keyed by
the outer copper layer they expose."""
@@ -870,7 +1361,8 @@ def build_problem(board: Board, net: str, layer_names: list[str],
extra_cu_um: float | None = None,
tracks: dict | None = None,
vias_capped: bool | None = None,
cap_max_drill_mm: float | None = None) -> Problem:
cap_max_drill_mm: float | None = None,
terminals: list[Terminal] | None = None) -> Problem:
per_layer = fills.get(net, {})
per_layer_tracks = (tracks or {}).get(net, {})
layers = []
@@ -941,6 +1433,7 @@ def build_problem(board: Board, net: str, layer_names: list[str],
vias=vias,
electrodes1=es1,
electrodes2=es2,
terminals=terminals or [],
thickness_source=("override" if config.COPPER_THICKNESS_UM is not None
else "stackup"),
buildups=buildup_list,
@@ -961,7 +1454,7 @@ def build_problem(board: Board, net: str, layer_names: list[str],
solder_layers = contact_solder_buildups(problem)
if solder_layers:
sides = sorted({e.protrusion_side
for e in problem.electrodes1 + problem.electrodes2
for e in problem.contact_electrodes()
if e.solder and e.protrusion_side})
cone = (f", {config.THT_LEAD_PROTRUSION_MM:g} mm lead + solder cone "
f"on {', '.join(sides)}"
@@ -985,28 +1478,50 @@ def build_problem(board: Board, net: str, layer_names: list[str],
if __name__ == "__main__":
import sys
from . import configfile
from .geometry import save_problem
out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("geometry_dump.json")
_, board = connect()
stackup = get_stackup_info(board)
es1, es2, net_hint = get_electrodes(board, stackup)
cfg_path = configfile.find_config(board_dir(board),
getattr(board, "name", "") or "")
cfg = configfile.load_config(cfg_path) if cfg_path else None
pdn = cfg is not None and cfg.mode == "pdn"
terminals = None
if cfg is not None:
print(f"using config {cfg_path.name} ({cfg.mode} mode)")
configfile.apply_physics(cfg)
if pdn:
es1, es2, net_hint = [], [], cfg.net
elif cfg is not None and cfg.pos_parts is not None:
es1, es2 = resolve_classic_parts(board, stackup, cfg.pos_parts,
cfg.neg_parts, cfg.net)
net_hint = cfg.net
else:
es1, es2, net_hint = get_electrodes(board, stackup)
if any_zone_unfilled(board):
refill(board)
fills = gather_net_fills(board)
tracks = gather_net_tracks(board) if config.INCLUDE_TRACKS else {}
copper = merge_copper(fills, tracks_as_polygons(tracks))
nets = nets_overlapping(copper, es1, es2)
if len(sys.argv) > 2:
net = sys.argv[2]
elif net_hint in nets:
net = net_hint
elif len(nets) == 1:
net = nets[0]
if pdn:
net = cfg.net
terminals = resolve_terminal_specs(board, stackup, cfg.terminals,
net)
else:
print(f"candidate nets: {nets}; pass one as second argument")
sys.exit(1)
nets = nets_overlapping(copper, es1, es2)
if len(sys.argv) > 2:
net = sys.argv[2]
elif net_hint in nets:
net = net_hint
elif len(nets) == 1:
net = nets[0]
else:
print(f"candidate nets: {nets}; pass one as second argument")
sys.exit(1)
problem = build_problem(board, net, list(copper.get(net, {})), es1, es2,
stackup, fills, tracks=tracks)
stackup, fills, tracks=tracks,
terminals=terminals)
save_problem(problem, out)
print(f"wrote {out}")
+27 -2
View File
@@ -1,6 +1,9 @@
"""All tunable constants. v1 has no GUI dialog: edit here, re-run.
"""All tunable constants; the dialog exposes the common ones per run.
A future version may read overrides from <project>/fill_res_config.json.
A <board dir>/fill_res_config.json (see configfile.py) can override the
run parameters, select physics constants and the marker layers, and in
PDN mode defines the supply/load terminals. Values here remain the
defaults when no config file is present.
"""
from __future__ import annotations # KiCad's macOS Python is 3.9: without
# this, `float | None` annotations are
@@ -79,8 +82,19 @@ TRACK_1D_FACTOR = 3.0 # traces narrower than this many grid cells
LAYER_HINT: str | None = None # e.g. "F.Cu" to disambiguate candidate fills
ELECTRODE_POS_LAYER = "User.1" # rectangles on this layer mark V+ contact parts
ELECTRODE_NEG_LAYER = "User.2" # rectangles on this layer mark V- contact parts
ELECTRODE_PDN_LAYER = "User.3" # PDN mode: rectangles referenced from the
# config file as "rect:NAME" live here, named
# by a text item placed inside them (role
# comes from the config entry, so one layer
# serves supplies and loads alike)
ALWAYS_REFILL = False # refill zones even if KiCad says they are filled
# --- Configuration file ---
CONFIG_FILENAME = "fill_res_config.json"
# searched next to the board file, after the
# board-specific "<stem>.fill_res_config.json"
# (several boards can share a directory)
# --- In-KiCad result overlays (EXPERIMENTAL) ---
PUSH_OVERLAYS = False # after solving, push the per-layer |J|
# heatmaps into the open board as unlocked
@@ -146,6 +160,17 @@ ADAPTIVE_CORRECTION_PASSES = 1 # deferred-correction re-solves fixing the
# cuts the raw ~0.5-2% low bias to <0.03%
# measured; 0 disables
# --- PDN mode ---
PDN_V_NOMINAL = 3.3 # default supply open-circuit voltage [V];
# per-supply v_oc and the config file's
# run.v_nominal override it. Only shifts the
# absolute-volt reporting reference - drops
# and currents are independent of it
PDN_R_OUT_EPS = 1e-12 # supplies with r_out_ohm at or below this
# become ideal (Dirichlet) contacts: the
# exact R_out -> 0 limit, avoiding a huge
# attachment conductance in the matrix
# --- Solver ---
CONTACT_MODEL = "uniform" # "uniform": conductor pressed on top injects
# orthogonally with uniform surface density
+878
View File
@@ -0,0 +1,878 @@
"""fill_res_config.json: load / validate / save, no kipy or Qt here.
The config file fully specifies a run: the shared run parameters, the
classic setup (optionally including the terminals themselves, by board
reference), or the PDN terminal set (supplies with output resistance,
loads with prescribed draws). Several configs can be kept side by side
as "fill_res_config.<name>.json"; the one named "default" loads
automatically. Search order next to the board file:
"<board stem>.fill_res_config.json" first (several boards can share a
directory), then "fill_res_config.default.json", then plain
"fill_res_config.json" (the legacy spelling of "default"). Any other
config is pulled in per run with the dialog's "Load config..." button.
Precedence: config.py constants < config file < dialog edits - the file
pre-fills the dialog, what the dialog shows is what runs. A missing
file changes nothing; a present-but-invalid file is a fatal ConfigError.
Comments: full lines whose first non-blank characters are "//" are
stripped (replaced by blank lines, so JSON error line numbers stay
correct); keys starting with "_" are ignored everywhere ("_comment").
Schema (version 1) - every key optional unless stated:
version int, REQUIRED (currently 1)
mode "classic" | "pdn": the mode the dialog STARTS in;
inferred from `terminals` when absent. Nothing is
pinned - the dialog can always switch modes, nets and
values; the file is authoritative only for WHICH PDN
terminals exist (while it has a `terminals` section)
run
net str; PDN: REQUIRED run on this net
layers [str] subset of copper layers
include_tracks bool
vias_capped bool
cap_max_drill_mm number > 0
adaptive bool
cell_um number > 0 | null null = auto
freq_hz number >= 0 | str "142k", "1.5M", 0 = DC
contact_model "uniform" | "equipotential" (classic only)
include_buildup bool
extra_cu_um number >= 0
push_overlays bool
v_nominal number > 0 PDN: default supply v_oc
trim {enabled: bool, mode: "pct"|"abs", value: number}
classic
current_a number > 0
contact1 "auto" | "all" | layer name
contact2 "auto" | "all" | layer name
pos [partref] V+ terminal parts by board reference
neg [partref] V- parts; pos/neg only together -
when present the board selection /
marker-rectangle scan is skipped
terminals [terminal] REQUIRED in pdn mode; may also sit
in a classic-mode config - the
dialog's PDN mode then offers them,
and classic saves preserve them
name str, REQUIRED, unique
role "supply" | "load", REQUIRED
parts [partref], REQUIRED, non-empty
active bool, default true; false = the terminal stays
in the file (and in the dialog, with its
checkbox cleared) but takes no part in the run.
The editor also archives rows whose copper is
not on run.net this way - a save never drops a
drawn rectangle
i_draw_a number >= 0 active loads: REQUIRED (0 =
voltage probe); forbidden on
supplies
r_out_ohm number >= 0 active supplies: REQUIRED;
forbidden on loads
v_oc number > 0 supplies only; default run.v_nominal
contact "auto" | "all" | layer name (applied to parts
without their own contact)
comment str free-text note, shown and editable
in the dialog's Comment column
bonded bool short ALL the terminal's contact
cells into one lug (a multi-pin
package with internal metal): the
total current stays prescribed but
the per-part/per-cell split becomes
a solve outcome. Default false =
per-cell area share (loads) /
per-cell Thevenin attach (supplies)
physics config.py overrides (the hand-edit set)
rho_cu_ohm_m, copper_thickness_um, via_plating_um
markers marker layer names
pos_layer, neg_layer, pdn_layer default User.1 / User.2 / User.3
partref - a string for the common cases, an object for the rest:
"U7" every pad of footprint U7 on the run net
"U7.3" pad "3" of U7 (split at the FIRST dot; pad numbers
are strings and may contain dots themselves)
"rect:NAME" rectangle on markers.pdn_layer named NAME by a text
item placed inside it (same layer)
{"rect_mm": [x0, y0, x1, y1], "contact": "F.Cu"}
explicit rectangle, board mm; contact optional
{"via_mm": [x, y]}
the net's via nearest to (x, y), within 1 mm
Units are plain SI floats (A, ohm, V, Hz), mm for board coordinates
(_mm), um for metal thickness and cell size (_um). Any number may also
be written as a STRING with an SI suffix - "50m" = 0.05, "4.7k" =
4700, case decides m (milli) vs M (mega) - except freq_hz, which keeps
the frequency grammar ("142k", "1.5M", a lone m means MHz there).
"""
from __future__ import annotations
import copy
import json
from dataclasses import dataclass, field
from pathlib import Path
from . import config, skin
from .errors import ConfigError
SCHEMA_VERSION = 1
# --- parsed model -----------------------------------------------------------
@dataclass
class PartRef:
"""One terminal part by board reference (see the partref grammar)."""
kind: str # "footprint" | "pad" | "rect_label" |
# "rect_mm" | "via_mm"
ref: str = "" # footprint reference designator
pad: str = "" # pad number (kind "pad")
label: str = "" # rectangle name (kind "rect_label")
rect_mm: tuple | None = None # (x0, y0, x1, y1) board mm
via_mm: tuple | None = None # (x, y) board mm
contact: str = "" # part-level layer scope; "" = decide
# at resolution (terminal-level scope,
# else the part's natural layers)
def describe(self) -> str:
if self.kind == "footprint":
return self.ref
if self.kind == "pad":
return f"{self.ref}.{self.pad}"
if self.kind == "rect_label":
return f"rect:{self.label}"
if self.kind == "rect_mm":
x0, y0, x1, y1 = self.rect_mm
return f"rect ({x0:g}, {y0:g})..({x1:g}, {y1:g}) mm"
return f"via near ({self.via_mm[0]:g}, {self.via_mm[1]:g}) mm"
@dataclass
class TerminalSpec:
"""One PDN terminal as written in the config (geometry unresolved)."""
name: str
role: str # "supply" | "load"
parts: list # [PartRef]
i_draw_a: float | None = None # None: not given (inactive load)
r_out_ohm: float | None = None # None: not given (inactive supply)
v_oc: float | None = None
contact: str = "auto"
bonded: bool = False # one lug: split is a solve outcome
active: bool = True # false: kept but not part of the run
comment: str = ""
@dataclass
class RunConfig:
"""A loaded, validated config file. None = key not present (the
config.py default applies); `raw` keeps the parsed JSON so saving
can preserve sections this dataclass does not model."""
mode: str = "classic"
path: Path | None = None
raw: dict = field(default_factory=dict)
# run
net: str | None = None
layers: list | None = None
include_tracks: bool | None = None
vias_capped: bool | None = None
cap_max_drill_mm: float | None = None
adaptive: bool | None = None
cell_um: float | None = None
cell_um_given: bool = False # "cell_um": null explicitly means auto
freq_hz: float | None = None
contact_model: str | None = None
include_buildup: bool | None = None
extra_cu_um: float | None = None
push_overlays: bool | None = None
v_nominal: float | None = None
trim_enabled: bool | None = None
trim_mode: str | None = None
trim_value: float | None = None
# classic
current_a: float | None = None
contact1: str | None = None
contact2: str | None = None
pos_parts: list | None = None # [PartRef]
neg_parts: list | None = None
# pdn
terminals: list = field(default_factory=list) # [TerminalSpec]
# overrides
physics: dict = field(default_factory=dict)
markers: dict = field(default_factory=dict)
@dataclass
class DialogDefaults:
"""Everything the dialog seeds its widgets from. Built by
dialog_defaults(): config.py constants, overlaid with the config
file's values - the single precedence point."""
net: str | None = None
layers: list | None = None
include_tracks: bool = True
vias_capped: bool = True
cap_max_drill_mm: float = 0.5
adaptive: bool = True
contact_model: str = "uniform"
current_a: float = 1.0
freq_hz: float = 0.0
cell_um: float | None = None
include_buildup: bool = False
extra_cu_um: float = 0.0
push_overlays: bool = False
trim_enabled: bool = False
trim_mode: str = "pct"
trim_value: float | None = None # None = the mode's default
contact1: str | None = None # None = derived from the board
contact2: str | None = None
v_nominal: float | None = None # None = config.PDN_V_NOMINAL
# --- helpers ----------------------------------------------------------------
def named_config_filename(name: str) -> str:
"""The named-config scheme: "fill_res_config.<name>.json". Plain
"fill_res_config.json" is the legacy spelling of the config named
"default"."""
stem, suffix = config.CONFIG_FILENAME.rsplit(".", 1)
return f"{stem}.{name}.{suffix}"
def find_config(board_dir: Path, board_filename: str) -> Path | None:
"""Board-specific name first, then the config named "default" (its
plain legacy filename last); None when none exists."""
board_dir = Path(board_dir)
stem = Path(board_filename).stem
candidates = []
if stem:
candidates.append(board_dir / f"{stem}.{config.CONFIG_FILENAME}")
candidates.append(board_dir / named_config_filename("default"))
candidates.append(board_dir / config.CONFIG_FILENAME)
for c in candidates:
if c.is_file():
return c
return None
def strip_comment_lines(text: str) -> str:
"""Remove full-line // comments. Stripped lines become empty lines
so json.JSONDecodeError line numbers still point into the user's
file; inline // is NOT supported (it could sit inside a string)."""
return "\n".join("" if line.lstrip().startswith("//") else line
for line in text.split("\n"))
def _err(path: Path, keypath: str, msg: str) -> ConfigError:
return ConfigError(f"{path.name}: {keypath} {msg}")
def _warn_unknown(path: Path, keypath: str, d: dict, known: tuple) -> None:
for k in d:
if isinstance(k, str) and not k.startswith("_") and k not in known:
print(f"config warning: unknown key '{keypath}{k}' in "
f"{path.name} (ignored)")
def _bool(v, path, keypath) -> bool:
if not isinstance(v, bool):
raise _err(path, keypath, f"must be true or false (got {v!r})")
return v
def _str(v, path, keypath) -> str:
if not isinstance(v, str) or not v.strip():
raise _err(path, keypath, f"must be a non-empty string (got {v!r})")
return v
def _num(v, path, keypath, minimum=None, exclusive=False) -> float:
if isinstance(v, str):
# every number may also be a string with an SI suffix ("50m",
# "4.7k") - the same grammar the dialog fields accept
try:
v = skin.parse_engineering(v)
except ValueError as e:
raise _err(path, keypath, f"cannot parse number {v!r} "
f"({e}; examples: 0.05, \"50m\", "
f"\"4.7k\")")
if isinstance(v, bool) or not isinstance(v, (int, float)):
raise _err(path, keypath, f"must be a number (got {v!r})")
v = float(v)
if minimum is not None:
if exclusive and v <= minimum:
raise _err(path, keypath, f"must be > {minimum:g} (got {v:g})")
if not exclusive and v < minimum:
raise _err(path, keypath, f"must be >= {minimum:g} (got {v:g})")
return v
def _freq(v, path, keypath) -> float:
if isinstance(v, str):
try:
return skin.parse_frequency(v)
except ValueError as e:
raise _err(path, keypath, f"cannot parse frequency {v!r} "
f"({e}; examples: 0, \"142k\", "
f"\"1.5M\")")
return _num(v, path, keypath, minimum=0.0)
def _scope(v, path, keypath) -> str:
s = _str(v, path, keypath)
return s # "auto" / "all" / a layer name (checked on the board)
def _partref(v, path, keypath) -> PartRef:
if isinstance(v, str):
s = v.strip()
if s.startswith("rect:"):
label = s[len("rect:"):].strip()
if not label:
raise _err(path, keypath, "has an empty rectangle name "
"('rect:NAME')")
return PartRef(kind="rect_label", label=label)
if "." in s:
# first dot: pad numbers are strings and may contain dots,
# reference designators never do
ref, pad = s.split(".", 1)
if not ref or not pad:
raise _err(path, keypath, f"is not a valid reference "
f"({s!r}; expected \"U7\" or "
f"\"U7.3\")")
return PartRef(kind="pad", ref=ref, pad=pad)
if not s:
raise _err(path, keypath, "is an empty reference")
return PartRef(kind="footprint", ref=s)
if isinstance(v, dict):
_warn_unknown(path, keypath + ".", v, ("rect_mm", "via_mm",
"contact"))
contact = ""
if "contact" in v:
contact = _str(v["contact"], path, keypath + ".contact")
if "rect_mm" in v:
r = v["rect_mm"]
if (not isinstance(r, list) or len(r) != 4
or any(isinstance(x, bool)
or not isinstance(x, (int, float)) for x in r)):
raise _err(path, keypath + ".rect_mm",
"must be [x0, y0, x1, y1] in mm")
return PartRef(kind="rect_mm", rect_mm=tuple(float(x) for x in r),
contact=contact)
if "via_mm" in v:
r = v["via_mm"]
if (not isinstance(r, list) or len(r) != 2
or any(isinstance(x, bool)
or not isinstance(x, (int, float)) for x in r)):
raise _err(path, keypath + ".via_mm", "must be [x, y] in mm")
return PartRef(kind="via_mm", via_mm=tuple(float(x) for x in r),
contact=contact)
raise _err(path, keypath, "needs \"rect_mm\" or \"via_mm\"")
raise _err(path, keypath, f"must be a reference string or an object "
f"(got {v!r})")
def _partref_list(v, path, keypath) -> list:
if not isinstance(v, list) or not v:
raise _err(path, keypath, "must be a non-empty list of part "
"references")
return [_partref(x, path, f"{keypath}[{i}]") for i, x in enumerate(v)]
# --- load -------------------------------------------------------------------
def load_config(path: Path) -> RunConfig:
path = Path(path)
try:
text = path.read_text(encoding="utf-8")
except OSError as e:
raise ConfigError(f"cannot read {path.name}: {e}")
try:
raw = json.loads(strip_comment_lines(text))
except json.JSONDecodeError as e:
raise ConfigError(f"{path.name} is not valid JSON: {e.msg} at "
f"line {e.lineno}, column {e.colno}")
if not isinstance(raw, dict):
raise ConfigError(f"{path.name}: the top level must be an object")
return _validate(raw, path)
def _validate(raw: dict, path: Path) -> RunConfig:
_warn_unknown(path, "", raw, ("version", "mode", "run", "classic",
"terminals", "physics", "markers"))
if "version" not in raw:
raise _err(path, "version", "is required (currently 1)")
version = raw["version"]
if isinstance(version, bool) or not isinstance(version, int):
raise _err(path, "version", f"must be an integer (got {version!r})")
if version > SCHEMA_VERSION:
raise _err(path, "version", f"{version} is newer than this plugin "
f"understands (<= {SCHEMA_VERSION}) - "
f"update the plugin")
if version < 1:
raise _err(path, "version", f"must be >= 1 (got {version})")
cfg = RunConfig(path=path, raw=raw)
terminals_raw = raw.get("terminals")
if terminals_raw is not None and not isinstance(terminals_raw, list):
raise _err(path, "terminals", "must be a list")
has_terminals = bool(terminals_raw)
mode = raw.get("mode")
if mode is not None:
if mode not in ("classic", "pdn"):
raise _err(path, "mode", f"must be \"classic\" or \"pdn\" "
f"(got {mode!r})")
# mode only picks the STARTING mode; a classic config may
# carry a terminals section (the dialog switches freely, and
# classic saves preserve it) - but "pdn" with nothing to run
# is still a contradiction
if mode == "pdn" and not has_terminals:
raise _err(path, "mode", "is \"pdn\" but there are no "
"terminals")
cfg.mode = mode
else:
cfg.mode = "pdn" if has_terminals else "classic"
_validate_run(raw.get("run"), cfg, path)
_validate_classic(raw.get("classic"), cfg, path)
if cfg.pos_parts is not None and not cfg.net:
raise _err(path, "run.net", "is required when classic.pos/neg "
"define the terminals by reference")
if has_terminals:
_validate_terminals(terminals_raw, cfg, path)
if cfg.mode == "pdn" and not cfg.net:
raise _err(path, "run.net", "is required in PDN mode (the "
"net the terminals live on)")
_validate_physics(raw.get("physics"), cfg, path)
_validate_markers(raw.get("markers"), cfg, path)
return cfg
_RUN_KEYS = ("net", "layers", "include_tracks", "vias_capped",
"cap_max_drill_mm", "adaptive", "cell_um", "freq_hz",
"contact_model", "include_buildup", "extra_cu_um",
"push_overlays", "v_nominal", "trim")
def _validate_run(run, cfg: RunConfig, path: Path) -> None:
if run is None:
return
if not isinstance(run, dict):
raise _err(path, "run", "must be an object")
_warn_unknown(path, "run.", run, _RUN_KEYS)
if "net" in run:
cfg.net = _str(run["net"], path, "run.net")
if "layers" in run:
v = run["layers"]
if not isinstance(v, list) or not v:
raise _err(path, "run.layers", "must be a non-empty list of "
"layer names")
cfg.layers = [_str(x, path, f"run.layers[{i}]")
for i, x in enumerate(v)]
for key in ("include_tracks", "vias_capped", "adaptive",
"include_buildup", "push_overlays"):
if key in run:
setattr(cfg, key, _bool(run[key], path, f"run.{key}"))
if "cap_max_drill_mm" in run:
cfg.cap_max_drill_mm = _num(run["cap_max_drill_mm"], path,
"run.cap_max_drill_mm", 0.0,
exclusive=True)
if "cell_um" in run:
cfg.cell_um_given = True
if run["cell_um"] is not None:
cfg.cell_um = _num(run["cell_um"], path, "run.cell_um", 0.0,
exclusive=True)
if "freq_hz" in run:
cfg.freq_hz = _freq(run["freq_hz"], path, "run.freq_hz")
if "contact_model" in run:
v = run["contact_model"]
if v not in ("uniform", "equipotential"):
raise _err(path, "run.contact_model",
f"must be \"uniform\" or \"equipotential\" "
f"(got {v!r})")
cfg.contact_model = v
if "extra_cu_um" in run:
cfg.extra_cu_um = _num(run["extra_cu_um"], path,
"run.extra_cu_um", 0.0)
if "v_nominal" in run:
cfg.v_nominal = _num(run["v_nominal"], path, "run.v_nominal", 0.0,
exclusive=True)
if "trim" in run:
t = run["trim"]
if not isinstance(t, dict):
raise _err(path, "run.trim", "must be an object "
"{enabled, mode, value}")
_warn_unknown(path, "run.trim.", t, ("enabled", "mode", "value"))
if "enabled" in t:
cfg.trim_enabled = _bool(t["enabled"], path, "run.trim.enabled")
if "mode" in t:
if t["mode"] not in ("pct", "abs"):
raise _err(path, "run.trim.mode",
f"must be \"pct\" or \"abs\" (got {t['mode']!r})")
cfg.trim_mode = t["mode"]
if "value" in t:
v = _num(t["value"], path, "run.trim.value", 0.0,
exclusive=True)
if (cfg.trim_mode or config.TRIM_MODE) == "pct" and v >= 100:
raise _err(path, "run.trim.value",
"must be between 0 and 100 (% of the mean |J|)")
cfg.trim_value = v
def _validate_classic(cl, cfg: RunConfig, path: Path) -> None:
if cl is None:
return
if not isinstance(cl, dict):
raise _err(path, "classic", "must be an object")
_warn_unknown(path, "classic.", cl, ("current_a", "contact1",
"contact2", "pos", "neg"))
if "current_a" in cl:
cfg.current_a = _num(cl["current_a"], path, "classic.current_a",
0.0, exclusive=True)
if "contact1" in cl:
cfg.contact1 = _scope(cl["contact1"], path, "classic.contact1")
if "contact2" in cl:
cfg.contact2 = _scope(cl["contact2"], path, "classic.contact2")
if ("pos" in cl) != ("neg" in cl):
raise _err(path, "classic", "needs pos and neg together (or "
"neither - terminals then come from "
"the board)")
if "pos" in cl:
cfg.pos_parts = _partref_list(cl["pos"], path, "classic.pos")
cfg.neg_parts = _partref_list(cl["neg"], path, "classic.neg")
_TERMINAL_KEYS = ("name", "role", "parts", "active", "i_draw_a",
"r_out_ohm", "v_oc", "contact", "bonded", "comment")
def _validate_terminals(terms, cfg: RunConfig, path: Path) -> None:
if not terms:
raise _err(path, "terminals", "must be a non-empty list in PDN "
"mode")
names = set()
n_sup = n_load = 0
for i, t in enumerate(terms):
kp = f"terminals[{i}]"
if not isinstance(t, dict):
raise _err(path, kp, "must be an object")
_warn_unknown(path, kp + ".", t, _TERMINAL_KEYS)
if "name" not in t:
raise _err(path, kp + ".name", "is required")
name = _str(t["name"], path, kp + ".name")
if name in names:
raise _err(path, kp + ".name", f"duplicates terminal "
f"'{name}'")
names.add(name)
role = t.get("role")
if role not in ("supply", "load"):
raise _err(path, kp + ".role", f"must be \"supply\" or "
f"\"load\" (got {role!r})")
if "parts" not in t:
raise _err(path, kp + ".parts", "is required")
parts = _partref_list(t["parts"], path, kp + ".parts")
spec = TerminalSpec(name=name, role=role, parts=parts)
if "active" in t:
spec.active = _bool(t["active"], path, kp + ".active")
if "comment" in t:
# empty string allowed (unlike _str): "" simply means none
if not isinstance(t["comment"], str):
raise _err(path, kp + ".comment",
f"must be a string (got {t['comment']!r})")
spec.comment = t["comment"]
# a value is REQUIRED only while the terminal is active; an
# inactive one may stay blank (it takes no part in the run) -
# but a value that IS given must be valid either way
if role == "load":
n_load += spec.active
if "r_out_ohm" in t or "v_oc" in t:
raise _err(path, kp, "is a load: r_out_ohm/v_oc belong "
"on supplies (did you mean role "
"\"supply\"?)")
if "i_draw_a" in t:
spec.i_draw_a = _num(t["i_draw_a"], path,
kp + ".i_draw_a", 0.0)
elif spec.active:
raise _err(path, kp + ".i_draw_a", "is required for an "
"active load")
else:
n_sup += spec.active
if "i_draw_a" in t:
raise _err(path, kp, "is a supply: i_draw_a belongs on "
"loads (did you mean role "
"\"load\"?)")
if "r_out_ohm" in t:
spec.r_out_ohm = _num(t["r_out_ohm"], path,
kp + ".r_out_ohm", 0.0)
elif spec.active:
raise _err(path, kp + ".r_out_ohm", "is required for an "
"active supply")
if "v_oc" in t:
spec.v_oc = _num(t["v_oc"], path, kp + ".v_oc", 0.0,
exclusive=True)
if "contact" in t:
spec.contact = _scope(t["contact"], path, kp + ".contact")
if "bonded" in t:
spec.bonded = _bool(t["bonded"], path, kp + ".bonded")
cfg.terminals.append(spec)
if n_sup == 0:
raise _err(path, "terminals", "needs at least one active supply")
if n_load == 0:
raise _err(path, "terminals", "needs at least one active load")
def _validate_physics(ph, cfg: RunConfig, path: Path) -> None:
if ph is None:
return
if not isinstance(ph, dict):
raise _err(path, "physics", "must be an object")
_warn_unknown(path, "physics.", ph, ("rho_cu_ohm_m",
"copper_thickness_um",
"via_plating_um"))
for key in ("rho_cu_ohm_m", "copper_thickness_um", "via_plating_um"):
if key in ph:
cfg.physics[key] = _num(ph[key], path, f"physics.{key}", 0.0,
exclusive=True)
def _validate_markers(mk, cfg: RunConfig, path: Path) -> None:
if mk is None:
return
if not isinstance(mk, dict):
raise _err(path, "markers", "must be an object")
_warn_unknown(path, "markers.", mk, ("pos_layer", "neg_layer",
"pdn_layer"))
for key in ("pos_layer", "neg_layer", "pdn_layer"):
if key in mk:
cfg.markers[key] = _str(mk[key], path, f"markers.{key}")
# --- precedence / application -----------------------------------------------
def dialog_defaults(cfg: RunConfig | None = None) -> DialogDefaults:
"""The single precedence point below the dialog: config.py
constants, overlaid with the config file's values. Reads the
constants at call time (they are mutable globals)."""
d = DialogDefaults(
include_tracks=config.INCLUDE_TRACKS,
vias_capped=config.VIAS_CAPPED,
cap_max_drill_mm=config.CAP_MAX_DRILL_MM,
adaptive=config.ADAPTIVE_CELLS,
contact_model=config.CONTACT_MODEL,
current_a=config.TEST_CURRENT_A,
include_buildup=config.INCLUDE_MASK_BUILDUP,
extra_cu_um=config.BUILDUP_EXTRA_CU_UM,
push_overlays=config.PUSH_OVERLAYS,
trim_enabled=config.TRIM_ENABLED,
trim_mode=config.TRIM_MODE,
)
if cfg is None:
return d
for name in ("net", "layers", "include_tracks", "vias_capped",
"cap_max_drill_mm", "adaptive", "contact_model",
"current_a", "freq_hz", "include_buildup", "extra_cu_um",
"push_overlays", "trim_enabled", "trim_mode",
"trim_value", "contact1", "contact2", "v_nominal"):
v = getattr(cfg, name)
if v is not None:
setattr(d, name, v)
if cfg.cell_um_given:
d.cell_um = cfg.cell_um
return d
def apply_physics(cfg: RunConfig | None) -> None:
"""Push the physics/markers overrides into the config module - the
same global-mutation mechanism main() already uses for cell size
and the adaptive flag. Call before any board geometry is gathered
(the marker layers steer get_electrodes)."""
if cfg is None:
return
ph = cfg.physics
if "rho_cu_ohm_m" in ph:
config.RHO_CU_OHM_M = ph["rho_cu_ohm_m"]
if "copper_thickness_um" in ph:
config.COPPER_THICKNESS_UM = ph["copper_thickness_um"]
if "via_plating_um" in ph:
config.VIA_PLATING_UM = ph["via_plating_um"]
mk = cfg.markers
if "pos_layer" in mk:
config.ELECTRODE_POS_LAYER = mk["pos_layer"]
if "neg_layer" in mk:
config.ELECTRODE_NEG_LAYER = mk["neg_layer"]
if "pdn_layer" in mk:
config.ELECTRODE_PDN_LAYER = mk["pdn_layer"]
# --- save -------------------------------------------------------------------
def _run_section(selection) -> dict:
"""The `run` block serialized from a dialog Selection - shared by
the classic and PDN savers. v_nominal is written only when the
Selection carries one (PDN mode), so classic saves stay exactly as
before."""
run = {
"net": selection.net,
"layers": selection.layers,
"include_tracks": selection.include_tracks,
"vias_capped": selection.vias_capped,
"cap_max_drill_mm": selection.cap_max_drill_mm,
"adaptive": selection.adaptive,
"cell_um": selection.cell_um,
"freq_hz": selection.freq_hz,
"contact_model": selection.contact_model,
"include_buildup": selection.include_buildup,
"extra_cu_um": selection.extra_cu_um,
"push_overlays": selection.push_overlays,
"trim": {"enabled": selection.trim_enabled,
"mode": selection.trim_mode,
"value": selection.trim_value},
}
v_nom = getattr(selection, "v_nominal", None)
if v_nom is not None:
run["v_nominal"] = v_nom
return run
def save_classic_config(path: Path, selection) -> None:
"""Serialize the dialog's current values ("Save config...") with
mode "classic". An existing file's physics / markers / terminals /
classic.pos / classic.neg sections are preserved (load-merge-
write) - saving classic values over a PDN config keeps its whole
terminal set and only flips the STARTING mode; // comments are NOT
preserved - the file is rewritten. Refuses a file it cannot parse
(never destroy user edits); the assembled data passes the loader's
own validation before anything touches disk."""
path = Path(path)
old_raw: dict = {}
if path.exists():
old = load_config(path) # ConfigError propagates: fix first
old_raw = old.raw
data = {
"version": SCHEMA_VERSION,
"mode": "classic",
"run": _run_section(selection),
"classic": {
"current_a": selection.current_a,
"contact1": selection.contact1,
"contact2": selection.contact2,
},
}
old_classic = old_raw.get("classic") or {}
for key in ("pos", "neg"):
if key in old_classic:
data["classic"][key] = old_classic[key]
for section in ("terminals", "physics", "markers"):
if section in old_raw:
data[section] = old_raw[section]
_validate(data, path) # self-check before writing
path.write_text(json.dumps(data, indent=4) + "\n", encoding="utf-8")
def updated_terminals_json(raw_terminals: list, rows: list) -> list:
"""Config-backed PDN save: each raw terminal object is deep-copied
verbatim (parts, "_"-prefixed keys preserved) and only the
dialog-editable values - I / R_out / V_oc and the terminal-level
contact layer - are written back POSITIONALLY: the dialog never
reorders its tables, so index i is the same terminal in both lists.
A supply row's v_oc of None REMOVES the key (restoring the
defaults-to-v_nominal semantics); a contact of "auto" removes the
key too (auto is the schema default). Part-level contacts inside
`parts` stay untouched and keep winning over the terminal scope."""
out = []
for raw, row in zip(raw_terminals, rows):
t = copy.deepcopy(raw)
# value cells may be blank on an INACTIVE row - None then
# removes the key (an active row always carries a value)
if row.role == "load":
if row.i_draw_a is None:
t.pop("i_draw_a", None)
else:
t["i_draw_a"] = row.i_draw_a
else:
if row.r_out_ohm is None:
t.pop("r_out_ohm", None)
else:
t["r_out_ohm"] = row.r_out_ohm
if row.v_oc is None:
t.pop("v_oc", None)
else:
t["v_oc"] = row.v_oc
contact = getattr(row, "contact", "auto")
if contact and contact != "auto":
t["contact"] = contact
else:
t.pop("contact", None)
if getattr(row, "active", True):
t.pop("active", None) # true is the schema default
else:
t["active"] = False
comment = getattr(row, "comment", "")
if comment:
t["comment"] = comment
else:
t.pop("comment", None)
out.append(t)
return out
def rect_terminals_json(rows: list, rect_infos: list) -> list:
"""PDN-editor save: rect_infos[i] = (labeled: bool, (x0, y0, x1,
y1) board mm), parallel to rows. Labeled rectangles save as live
"rect:NAME" refs (they follow the rectangle wherever it moves and
resizes); unnamed ones freeze as rect_mm coordinates. A row's
contact layer is written as the terminal-level "contact" key; "all"
is omitted (a marker rectangle's natural scope already contacts
every selected layer)."""
out = []
for row, (labeled, rect_mm) in zip(rows, rect_infos):
if labeled:
parts: list = [f"rect:{row.name}"]
else:
parts = [{"rect_mm": [round(float(v), 6) for v in rect_mm]}]
t: dict = {"name": row.name, "role": row.role, "parts": parts}
if not getattr(row, "active", True):
t["active"] = False # true is the schema default
contact = getattr(row, "contact", "all")
if contact not in ("", "auto", "all"):
t["contact"] = contact
if getattr(row, "bonded", False):
t["bonded"] = True
# value cells may be blank on an inactive row (None: no key)
if row.role == "load":
if row.i_draw_a is not None:
t["i_draw_a"] = row.i_draw_a
else:
if row.r_out_ohm is not None:
t["r_out_ohm"] = row.r_out_ohm
if row.v_oc is not None:
t["v_oc"] = row.v_oc
comment = getattr(row, "comment", "")
if comment:
t["comment"] = comment
out.append(t)
return out
def save_pdn_config(path: Path, selection, terminals: list) -> None:
"""Serialize a PDN dialog run ("Save config..." in PDN mode).
`terminals` is the schema-shaped list from updated_terminals_json /
rect_terminals_json. Preserves an existing file's physics / markers
and its WHOLE classic section (a later hand-edit of mode back to
"classic" finds it intact); refuses a file it cannot parse. The
assembled data passes the loader's own validation before anything
touches disk, so a save can never produce a config the next launch
rejects."""
path = Path(path)
old_raw: dict = {}
if path.exists():
old = load_config(path) # ConfigError propagates: fix first
old_raw = old.raw
data = {
"version": SCHEMA_VERSION,
"mode": "pdn",
"run": _run_section(selection),
"terminals": terminals,
}
for section in ("classic", "physics", "markers"):
if section in old_raw:
data[section] = old_raw[section]
_validate(data, path) # self-check before writing
path.write_text(json.dumps(data, indent=4) + "\n", encoding="utf-8")
+753 -79
View File
@@ -2,18 +2,55 @@
contact, test current, optional cell size. PySide6 is already a plugin
dependency (matplotlib QtAgg backend); the QApplication created here is
reused by matplotlib afterwards.
Widget defaults come from a configfile.DialogDefaults (config.py
constants overlaid with the optional fill_res_config.json) - the dialog
never reads config.* seeds directly, so the file's precedence lives in
one place.
Two run modes share the dialog, chosen by a radio at the top:
- Classic: one V+ and one V- terminal (each may bundle several contact
parts), contact scopes, contact model, one test current;
- PDN: two editable terminal tables instead - one for supplies, one
for loads, each titled with its marker layer. A load row takes a
current draw, a supply row an output resistance and an optional
open-circuit voltage; every row also picks the contacted copper
layer and shows the component it belongs to (read-only, from
board_io.component_hints). The terminal set comes either from the
live marker-rectangle scan (PdnSetup.from_config False: User.1
rects are supplies, User.2 rects are loads) or from a config file's
terminals section (from_config True: the file says WHICH terminals
exist, everything else - mode, net, values, layers, active,
comments - stays editable; nothing is pinned).
"Load config…" swaps the whole setup for another config file: ask()
then returns a LoadRequest instead of a Selection and main re-derives
everything from that file and reopens the dialog.
Row identity is POSITIONAL: the tables never sort or reorder, so
main.py zips Selection.pdn_rows with its own parallel terminal list.
Rows carry the nets their contacts overlap; rows not on the active net
are hidden, and come back (like unchecked rows) with active=False -
still saved to the config, just not part of the run.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (QApplication, QCheckBox, QComboBox, QDialog,
QDialogButtonBox, QFormLayout, QHBoxLayout,
QLabel, QLineEdit, QListWidget,
QListWidgetItem, QVBoxLayout, QWidget)
from PySide6.QtWidgets import (QAbstractScrollArea, QApplication, QCheckBox,
QComboBox, QDialog, QDialogButtonBox,
QFileDialog, QFormLayout, QFrame,
QHBoxLayout, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QRadioButton, QScrollArea,
QSplitter, QTableWidget, QTableWidgetItem,
QVBoxLayout, QWidget)
from . import config, skin
from . import config, configfile, skin
from .configfile import DialogDefaults, dialog_defaults
from .errors import ConfigError
ALL_LAYERS = "All selected layers"
AUTO_CONTACT = "(auto: per contact part)"
@@ -23,6 +60,19 @@ MODEL_LABELS = {
}
def _parse_number(text: str, name: str) -> float:
"""Shared by QLineEdits and table cells: float with SI suffixes
(50m = 0.05, 4.7k = 4700) and the decimal-comma normalization,
ValueError with a user-readable message."""
try:
return skin.parse_engineering(text)
except ValueError as exc:
if "separator" in str(exc):
raise ValueError(f"{name}: {exc}")
raise ValueError(f"{name}: '{text}' is not a number "
f"(SI suffixes work: 50m, 4.7k, 2M).")
@dataclass
class Selection:
net: str
@@ -43,112 +93,282 @@ class Selection:
trim_enabled: bool = False # EXPERIMENTAL low-current copper marking
trim_mode: str = "pct" # "pct" (% of the mean |J|) or "abs" (A/mm2)
trim_value: float = 10.0 # threshold in the unit trim_mode names
mode: str = "classic" # "classic" | "pdn" (current_a then
# carries the summed load draw)
pdn_rows: list | None = None # PDN: validated PdnTerminalRow list,
# same order and LENGTH as the
# PdnSetup given in; a row's `active`
# is False when unchecked OR hidden
# by the net filter (not in the run,
# but still saved)
v_nominal: float | None = None # PDN: default supply v_oc [V]
@dataclass
class PdnTerminalRow:
"""One terminal in the PDN tables. Identity is positional (the
dialog never reorders rows), so main.py zips the returned list
against its own parallel terminal list - no key needed."""
name: str
role: str # "supply" | "load"
resolved: str # read-only geometry description
component: str = "" # read-only owner hint ("U5" /
# "near U5", board_io.component_hints)
active: bool = True # checkbox: false = the terminal is
# kept (and saved) but takes no part
# in the run; its value cells may
# then stay blank
comment: str = "" # free-text note, saved to the config
i_draw_a: float | None = None # loads; None = not entered yet
r_out_ohm: float | None = None # supplies; None = not entered yet
v_oc: float | None = None # supplies; None = v_nominal
bonded: bool = False # multi-contact lug: the TOTAL value
# applies, the per-contact split is a
# solve outcome (display/data only -
# not editable in the table)
contact: str = "all" # terminal-level layer scope: "auto"
# (per contact part - config-backed
# rows only), "all", or a layer name
from_config: bool = False # this ROW's geometry comes from the
# config file (a setup may mix file
# terminals with newly drawn
# rectangles)
nets: frozenset | None = None # nets whose copper the contacts
# overlap; the row is HIDDEN while
# the active net is not in the set
# (skipped by validation and solve,
# SAVED as active: false). None =
# always shown (no net info)
@dataclass
class LoadRequest:
"""Returned by ask() instead of a Selection when the user picked a
file with "Load config…" - main re-derives everything from that
config and reopens the dialog."""
path: Path
@dataclass
class PdnSetup:
"""The PDN side of the dialog - plain data so main.py builds it
without the dialog importing board_io."""
rows: list # [PdnTerminalRow] in display order
source: str # header: config file name, or
# "marker rectangles on User.1/User.2"
from_config: bool = False # rows come from a config file: the
# file is authoritative for WHICH
# terminals exist (structural edits
# happen there), everything else is
# editable - nothing is pinned
note: str = "" # extra hint ("selection ignored")
class _Dialog(QDialog):
def __init__(self, candidates: dict[str, list[str]], layer_order: list[str],
default_net: str, e1_label: str, e2_label: str,
contact1: str, contact2: str, buildup_layers: list[str]):
contact1: str, contact2: str, buildup_layers: list[str],
defaults: DialogDefaults | None = None,
pdn: PdnSetup | None = None,
pdn_candidates: dict | None = None,
classic_reason: str | None = None,
pdn_reason: str | None = None,
save_callback=None, save_target=None, load_dir=None,
start_mode: str = "classic"):
super().__init__()
d = defaults if defaults is not None else dialog_defaults(None)
self.setWindowTitle("Fill Resistance")
self.setWindowFlag(Qt.WindowStaysOnTopHint, True)
self._candidates = candidates
self._layer_order = layer_order
self._pdn = pdn
self._save_callback = save_callback
self._save_target = save_target # picker seed; last save wins
self._load_dir = load_dir
self._load_request: Path | None = None
self._classic_ok = classic_reason is None
self._pdn_ok = pdn is not None
self._candidates_classic = candidates
self._candidates_pdn = pdn_candidates or {}
self._candidates: dict = {}
# config-provided layer subset: applied while the dialog shows
# the net it was written for; switching nets re-checks all
self._preset_layers = d.layers
self._preset_net = default_net
form = QFormLayout()
# --- mode selector ---------------------------------------------
# "Classic", not "two-terminal": classic terminals can bundle
# many contact parts - the old label read like a 2-contact cap
self.mode_classic = QRadioButton("Classic")
self.mode_pdn = QRadioButton("PDN")
self.mode_classic.setEnabled(self._classic_ok)
self.mode_pdn.setEnabled(self._pdn_ok)
start_pdn = self._pdn_ok and (not self._classic_ok
or start_mode == "pdn")
(self.mode_pdn if start_pdn else self.mode_classic).setChecked(True)
reason = None
if not self._classic_ok and classic_reason:
reason = f"Classic unavailable: {classic_reason}"
self.mode_classic.setToolTip(classic_reason)
elif not self._pdn_ok and pdn_reason:
reason = f"PDN unavailable: {pdn_reason}"
self.mode_pdn.setToolTip(pdn_reason)
# --- shared form #1 --------------------------------------------
form1 = QFormLayout()
self.net_box = QComboBox()
for net in sorted(candidates):
self.net_box.addItem(net)
self.net_box.setCurrentText(default_net)
form.addRow("Signal (net):", self.net_box)
form1.addRow("Signal (net):", self.net_box)
self.layer_list = QListWidget()
self.layer_list.setMaximumHeight(120)
form.addRow("Layers:", self.layer_list)
form1.addRow("Layers:", self.layer_list)
self.tracks_check = QCheckBox("include the net's traces "
"(tracks + arcs)")
self.tracks_check.setChecked(config.INCLUDE_TRACKS)
form.addRow("Conductors:", self.tracks_check)
self.tracks_check.setChecked(d.include_tracks)
form1.addRow("Conductors:", self.tracks_check)
self.capped_check = QCheckBox(
f"vias filled + capped ({config.CAP_PLATING_UM:g} µm cap; "
f"off = open mouths)")
self.capped_check.setChecked(config.VIAS_CAPPED)
form.addRow("Vias:", self.capped_check)
self.capped_check.setChecked(d.vias_capped)
form1.addRow("Vias:", self.capped_check)
self.cap_drill_edit = QLineEdit(f"{config.CAP_MAX_DRILL_MM:g}")
self.cap_drill_edit.setEnabled(config.VIAS_CAPPED)
self.cap_drill_edit = QLineEdit(f"{d.cap_max_drill_mm:g}")
self.cap_drill_edit.setEnabled(d.vias_capped)
self.capped_check.toggled.connect(self.cap_drill_edit.setEnabled)
form.addRow("Capped up to drill [mm]:", self.cap_drill_edit)
form1.addRow("Capped up to drill [mm]:", self.cap_drill_edit)
self.adaptive_check = QCheckBox(
"adaptive cells (coarsen plane interiors; faster on large "
"boards, corrected to ≲0.03 % of the uniform grid)")
self.adaptive_check.setChecked(config.ADAPTIVE_CELLS)
form.addRow("Grid:", self.adaptive_check)
self.adaptive_check.setChecked(d.adaptive)
form1.addRow("Grid:", self.adaptive_check)
self.contact1_box = QComboBox()
self.contact2_box = QComboBox()
form.addRow(f"V+ ({e1_label}):", self.contact1_box)
form.addRow(f"V ({e2_label}):", self.contact2_box)
# --- classic section (only when classic mode is available) -----
# rows are CREATED conditionally, never shown-but-ignored; the
# switchable case toggles the whole section widget instead
# (portable to old Qt - QFormLayout.setRowVisible is 6.4+)
self.classic_section = None
self.contact1_box = None
self.contact2_box = None
self.model_box = None
self.current_edit = None
if self._classic_ok:
self.classic_section = QWidget()
cform = QFormLayout(self.classic_section)
cform.setContentsMargins(0, 0, 0, 0)
self.contact1_box = QComboBox()
self.contact2_box = QComboBox()
cform.addRow(f"V+ ({e1_label}):", self.contact1_box)
cform.addRow(f"V ({e2_label}):", self.contact2_box)
self.model_box = QComboBox()
for key in ("uniform", "equipotential"):
self.model_box.addItem(MODEL_LABELS[key], key)
default_index = 0 if config.CONTACT_MODEL == "uniform" else 1
self.model_box.setCurrentIndex(default_index)
form.addRow("Contact model:", self.model_box)
self.model_box = QComboBox()
for key in ("uniform", "equipotential"):
self.model_box.addItem(MODEL_LABELS[key], key)
default_index = 0 if d.contact_model == "uniform" else 1
self.model_box.setCurrentIndex(default_index)
cform.addRow("Contact model:", self.model_box)
self.current_edit = QLineEdit(f"{config.TEST_CURRENT_A:g}")
form.addRow("Test current [A]:", self.current_edit)
self.current_edit = QLineEdit(f"{d.current_a:g}")
cform.addRow("Test current [A]:", self.current_edit)
self.freq_edit = QLineEdit("")
# --- PDN section (only when a PdnSetup is given) ----------------
self.pdn_section = None
self.pdn_sup_table = None
self.pdn_load_table = None
self.pdn_splitter = None
self.pdn_totals = None
self.vnominal_edit = None
self._pdn_map: list = [] # rows[i] -> (table, table row)
self._pdn_layer_combos: list = []
self._pdn_layer_desired: list = []
self._pdn_hidden: list = [] # rows[i] not on the active net
if pdn is not None:
self.pdn_section = QWidget()
pv = QVBoxLayout(self.pdn_section)
pv.setContentsMargins(0, 0, 0, 0)
hdr = QLabel(f"PDN terminals — {pdn.source}")
hdr.setStyleSheet("font-weight: bold;")
pv.addWidget(hdr)
self._build_pdn_tables(pdn, pv)
self.pdn_totals = QLabel("")
pv.addWidget(self.pdn_totals)
hints = []
if pdn.note:
hints.append(pdn.note)
if pdn.from_config:
hints.append(f"geometry from {pdn.source}; edit the "
f"file to change terminals")
else:
# the role/layer mapping lives in the table titles now
hints.append("name from a text item inside the "
"rectangle; empty V_oc = V nominal")
hints.append("Layer = the copper the terminal contacts; "
"values take SI suffixes (50m = 0.05)")
hint = QLabel("".join(hints))
hint.setWordWrap(True)
hint.setStyleSheet("color: gray; font-size: 10px;")
pv.addWidget(hint)
pform = QFormLayout()
self.vnominal_edit = QLineEdit(
f"{d.v_nominal:g}" if d.v_nominal is not None
else f"{config.PDN_V_NOMINAL:g}")
pform.addRow("V nominal [V]:", self.vnominal_edit)
pv.addLayout(pform)
for t in (self.pdn_sup_table, self.pdn_load_table):
t.cellChanged.connect(lambda *_: self._update_totals())
self._update_totals()
# --- shared form #2 --------------------------------------------
form2 = QFormLayout()
self.freq_edit = QLineEdit(f"{d.freq_hz:g}" if d.freq_hz else "")
self.freq_edit.setPlaceholderText("0 = DC (e.g. 142k, 1.5M)")
form.addRow("Frequency [Hz]:", self.freq_edit)
form2.addRow("Frequency [Hz]:", self.freq_edit)
self.cell_edit = QLineEdit("")
self.cell_edit = QLineEdit(f"{d.cell_um:g}" if d.cell_um else "")
self.cell_edit.setPlaceholderText("auto")
form.addRow("Cell size [µm]:", self.cell_edit)
form2.addRow("Cell size [µm]:", self.cell_edit)
self.buildup_check = QCheckBox(
f"{config.SOLDER_THICKNESS_UM:g} µm solder on mask openings"
+ (f" ({', '.join(buildup_layers)})" if buildup_layers
else " (none found)"))
self.buildup_check.setChecked(bool(buildup_layers)
and config.INCLUDE_MASK_BUILDUP)
and d.include_buildup)
self.buildup_check.setEnabled(bool(buildup_layers))
form.addRow("Buildup:", self.buildup_check)
form2.addRow("Buildup:", self.buildup_check)
self.extracu_edit = QLineEdit(f"{config.BUILDUP_EXTRA_CU_UM:g}")
self.extracu_edit = QLineEdit(f"{d.extra_cu_um:g}")
self.extracu_edit.setEnabled(bool(buildup_layers))
form.addRow("Extra Cu in openings [µm]:", self.extracu_edit)
form2.addRow("Extra Cu in openings [µm]:", self.extracu_edit)
first, last = config.OVERLAY_LAYERS[0], config.OVERLAY_LAYERS[-1]
self.overlay_check = QCheckBox(
f"experimental: push per-layer |J| heatmaps into the board as "
f"reference images on {first}..{last} (replaces images there; "
f"layers must be enabled in Board Setup)")
self.overlay_check.setChecked(config.PUSH_OVERLAYS)
form.addRow("Overlays:", self.overlay_check)
self.overlay_check.setChecked(d.push_overlays)
form2.addRow("Overlays:", self.overlay_check)
tfirst, tlast = config.TRIM_LAYERS[0], config.TRIM_LAYERS[-1]
self.trim_check = QCheckBox(
f"experimental: mark copper below the threshold as polygons "
f"on {tfirst}..{tlast} (replaces polygons there; a suggestion "
f"only - removing copper shifts current elsewhere)")
self.trim_check.setChecked(config.TRIM_ENABLED)
form.addRow("Low-current copper:", self.trim_check)
self.trim_check.setChecked(d.trim_enabled)
form2.addRow("Low-current copper:", self.trim_check)
self.trim_mode_box = QComboBox()
self.trim_mode_box.addItem("% of mean |J|", "pct")
self.trim_mode_box.addItem("A/mm²", "abs")
self.trim_mode_box.setCurrentIndex(1 if config.TRIM_MODE == "abs"
else 0)
self.trim_edit = QLineEdit(self._trim_default())
self.trim_mode_box.setCurrentIndex(1 if d.trim_mode == "abs" else 0)
self.trim_edit = QLineEdit(f"{d.trim_value:g}"
if d.trim_value is not None
else self._trim_default())
for w in (self.trim_edit, self.trim_mode_box):
w.setEnabled(config.TRIM_ENABLED)
w.setEnabled(d.trim_enabled)
self.trim_check.toggled.connect(w.setEnabled)
self.trim_mode_box.currentIndexChanged.connect(
self._trim_mode_changed)
@@ -157,14 +377,42 @@ class _Dialog(QDialog):
trim_lay.setContentsMargins(0, 0, 0, 0)
trim_lay.addWidget(self.trim_edit, 1)
trim_lay.addWidget(self.trim_mode_box)
form.addRow("Threshold:", trim_row)
form2.addRow("Threshold:", trim_row)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._try_accept)
buttons.rejected.connect(self.reject)
if load_dir is not None:
load_btn = buttons.addButton("Load config…",
QDialogButtonBox.ActionRole)
load_btn.clicked.connect(self._load_config)
if save_callback is not None:
save_btn = buttons.addButton("Save config…",
QDialogButtonBox.ActionRole)
save_btn.clicked.connect(self._save_config)
lay = QVBoxLayout(self)
lay.addLayout(form)
content = QWidget()
lay = QVBoxLayout(content)
lay.setContentsMargins(0, 0, 0, 0)
mode_row = QHBoxLayout()
mode_row.addWidget(QLabel("Mode:"))
mode_row.addWidget(self.mode_classic)
mode_row.addWidget(self.mode_pdn)
mode_row.addStretch(1)
lay.addLayout(mode_row)
if reason is not None:
rl = QLabel(reason)
rl.setWordWrap(True)
rl.setStyleSheet("color: gray; font-size: 10px;")
lay.addWidget(rl)
lay.addLayout(form1)
if self.classic_section is not None:
lay.addWidget(self.classic_section)
if self.pdn_section is not None:
# stretch 1: enlarging the dialog grows the tables, not
# the form spacing
lay.addWidget(self.pdn_section, 1)
lay.addLayout(form2)
note = QLabel("Multiple layers are coupled through the net's "
"via/through-pad barrels. f > 0 applies only the "
"foil-thickness skin effect (a lower bound on the "
@@ -173,18 +421,343 @@ class _Dialog(QDialog):
note.setWordWrap(True)
note.setStyleSheet("color: gray; font-size: 10px;")
lay.addWidget(note)
# zero-stretch spacer: pools surplus height below the form
# when no table is there to absorb it (classic mode in an
# enlarged dialog) - the visible PDN section's stretch 1
# otherwise wins all of it
lay.addStretch()
# everything above scrolls when the content outgrows the
# screen-capped dialog; the error line and the buttons stay
# outside the scroll area so they are always visible
self._scroll = QScrollArea()
self._scroll.setWidgetResizable(True)
self._scroll.setFrameShape(QFrame.NoFrame)
# sizeHint tracks the content, so adjustSize() opens the
# dialog content-sized (clamped to the screen by Qt)
self._scroll.setSizeAdjustPolicy(
QAbstractScrollArea.AdjustToContents)
self._scroll.setWidget(content)
outer = QVBoxLayout(self)
outer.addWidget(self._scroll, 1)
self.error_label = QLabel("")
self.error_label.setWordWrap(True)
self.error_label.setStyleSheet("color: #b02a2a;")
self.error_label.setVisible(False)
lay.addWidget(self.error_label)
lay.addWidget(buttons)
outer.addWidget(self.error_label)
outer.addWidget(buttons)
self._selection: Selection | None = None
self._desired1, self._desired2 = contact1, contact2
self.net_box.currentTextChanged.connect(self._refresh)
self._apply_mode()
if default_net:
self.net_box.setCurrentText(default_net)
self._refresh()
self.net_box.currentTextChanged.connect(self._refresh)
# one toggled signal fires for any radio switch (auto-exclusive)
self.mode_classic.toggled.connect(lambda _c: self._apply_mode())
# --- mode handling ----------------------------------------------------
def _active_mode(self) -> str:
return "pdn" if self.mode_pdn.isChecked() else "classic"
def _apply_mode(self) -> None:
"""Toggle the mode sections and swap the net combo between the
classic and PDN candidate sets (a net present in both stays
selected across the switch)."""
pdn_mode = self._active_mode() == "pdn"
if self.classic_section is not None:
self.classic_section.setVisible(not pdn_mode)
if self.pdn_section is not None:
self.pdn_section.setVisible(pdn_mode)
cands = (self._candidates_pdn if pdn_mode
else self._candidates_classic)
if cands is not self._candidates:
current = self.net_box.currentText()
self._candidates = cands
self.net_box.blockSignals(True)
self.net_box.clear()
for net in sorted(cands):
self.net_box.addItem(net)
if current in cands:
self.net_box.setCurrentText(current)
self.net_box.blockSignals(False)
self._refresh()
self._fit_size()
def _fit_size(self) -> None:
"""Default dialog size for the active mode: content-sized, but
in PDN mode at least ~60% of the available screen height so
the tables open with real room (extra height flows into them
via the stretch; the scroll area covers whatever still does
not fit). The KiCad window itself is not reachable through the
IPC API, so the screen is the reference. Everything stays
user-resizable afterwards."""
hint = self.sizeHint()
w, h = hint.width(), hint.height()
screen = self.screen() or QApplication.primaryScreen()
if screen is not None:
avail = screen.availableGeometry()
if self._active_mode() == "pdn":
h = max(h, int(avail.height() * 0.6))
w = min(w, int(avail.width() * 0.9))
h = min(h, int(avail.height() * 0.85))
self.resize(w, h)
# --- PDN tables -------------------------------------------------------
def _build_pdn_tables(self, pdn: PdnSetup, layout: QVBoxLayout) -> None:
"""One table per role - supplies and loads carry different value
columns, so mixing them forced grayed-out cells. Each title
names its role's marker layer: that is where a NEW rectangle
becomes a new terminal, whatever the current rows' source. Row
identity stays POSITIONAL: _pdn_map[i] is (table, table row)
for PdnSetup.rows[i], and neither table ever sorts (Qt
default). The Layer combos start empty; _refresh populates
them with the active net's layers."""
titles = {
"supply": (f"Supplies — rectangles on "
f"{config.ELECTRODE_POS_LAYER}"),
"load": (f"Loads — rectangles on "
f"{config.ELECTRODE_NEG_LAYER}"),
}
# both tables live in a vertical splitter: each sizes itself
# to its rows (no fixed cap), the drag handle redistributes
# height between them, and growing the dialog grows the
# splitter (the section has stretch 1); past the screen the
# dialog's scroll area takes over
splitter = QSplitter(Qt.Vertical)
splitter.setChildrenCollapsible(False)
tables = {}
labels = {}
for role, cols in (
("supply",
["Active", "Name", "Component", "R_out [Ω]",
"V_oc [V]", "Layer", "Contact parts", "Comment"]),
("load",
["Active", "Name", "Component", "I draw [A]", "Layer",
"Contact parts", "Comment"])):
n = sum(1 for r in pdn.rows if r.role == role)
panel = QWidget()
pv = QVBoxLayout(panel)
pv.setContentsMargins(0, 0, 0, 0)
lab = QLabel(titles[role])
lab.setStyleSheet("font-weight: bold;")
pv.addWidget(lab)
labels[role] = lab
t = QTableWidget(n, len(cols))
t.setHorizontalHeaderLabels(cols)
t.verticalHeader().setVisible(False)
t.setMinimumHeight(84) # header + ~2 rows floor
t.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents)
pv.addWidget(t)
splitter.addWidget(panel)
tables[role] = t
layout.addWidget(splitter, 1)
self.pdn_splitter = splitter
fill = {"supply": 0, "load": 0}
for row in pdn.rows:
t = tables[row.role]
i = fill[row.role]
fill[row.role] += 1
self._pdn_map.append((t, i))
values = ([row.r_out_ohm, row.v_oc] if row.role == "supply"
else [row.i_draw_a])
last = t.columnCount() - 1 # ... | Layer | parts | Comment
cells = [(1, row.name, False), (2, row.component, False)]
for col, value in enumerate(values, start=3):
cells.append((col, "" if value is None else f"{value:g}",
True))
cells.append((last - 1, row.resolved, False))
cells.append((last, row.comment, True))
for col, text, editable in cells:
it = QTableWidgetItem(str(text))
it.setFlags((Qt.ItemIsEnabled | Qt.ItemIsSelectable
| Qt.ItemIsEditable) if editable
else Qt.ItemIsEnabled)
t.setItem(i, col, it)
box = QTableWidgetItem("")
box.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable)
box.setCheckState(Qt.Checked if row.active else Qt.Unchecked)
t.setItem(i, 0, box)
combo = QComboBox()
idx = len(self._pdn_layer_combos)
self._pdn_layer_combos.append(combo)
self._pdn_layer_desired.append(row.contact)
# activated fires only on a USER pick: the sticky desired
# value survives repopulation on net/mode switches
combo.activated.connect(
lambda _i, idx=idx: self._layer_picked(idx))
t.setCellWidget(i, last - 2, combo)
for t in tables.values():
t.resizeColumnsToContents()
self.pdn_sup_table = tables["supply"]
self.pdn_load_table = tables["load"]
self.pdn_sup_label = labels["supply"]
self.pdn_load_label = labels["load"]
def _layer_picked(self, idx: int) -> None:
self._pdn_layer_desired[idx] = (
self._pdn_layer_combos[idx].currentData())
def _row_hidden(self, i: int) -> bool:
return bool(self._pdn_hidden) and self._pdn_hidden[i]
def _apply_net_filter(self, net: str) -> None:
"""Hide the rows whose contacts carry no copper of the active
net: they are not part of this run (skipped by validation,
totals and the solve) but they stay in the returned row list,
so a save keeps them - as "active": false, since a saved PDN
config pins this very net. Rows with nets=None always show."""
self._pdn_hidden = []
for i, row in enumerate(self._pdn.rows):
hidden = row.nets is not None and net not in row.nets
t, r = self._pdn_map[i]
t.setRowHidden(r, hidden)
self._pdn_hidden.append(hidden)
self._update_totals()
def _refresh_layer_combos(self, layers: list) -> None:
"""Repopulate the per-terminal Layer combos for the active
net's layers; the desired value is re-selected when available,
else the combo falls back to its first entry. Config-backed
ROWS also offer "auto" - per contact part, the schema
default - which a live rectangle does not need (a rectangle's
natural scope IS all layers)."""
for idx, combo in enumerate(self._pdn_layer_combos):
combo.blockSignals(True)
combo.clear()
if self._pdn.rows[idx].from_config:
combo.addItem(AUTO_CONTACT, "auto")
combo.addItem(ALL_LAYERS, "all")
for name in layers:
combo.addItem(name, name)
i = combo.findData(self._pdn_layer_desired[idx])
combo.setCurrentIndex(i if i >= 0 else 0)
combo.blockSignals(False)
def _update_totals(self) -> None:
"""Best-effort live sum of the load draws under the tables;
unparseable cells are simply skipped (OK validates properly).
Counts only the checked rows on the active net; unchecked and
hidden rows are called out so a missing terminal is
explainable."""
total = 0.0
ns = nl = off = 0
for i, row in enumerate(self._pdn.rows):
if self._row_hidden(i):
continue
t, r = self._pdn_map[i]
box = t.item(r, 0)
if box is not None and box.checkState() != Qt.Checked:
off += 1
continue
if row.role != "load":
ns += 1
continue
nl += 1
it = t.item(r, 3)
text = it.text().strip() if it is not None else ""
if not text:
continue
try:
total += skin.parse_engineering(text)
except ValueError:
pass
text = f"{ns} supplies, {nl} loads, {total:g} A total draw"
notes = []
if off:
notes.append(f"{off} disabled")
hidden = sum(self._pdn_hidden)
if hidden:
notes.append(f"{hidden} not on this net: hidden")
if notes:
text += f" ({'; '.join(notes)})"
self.pdn_totals.setText(text)
def _read_pdn_rows(self) -> list:
"""Read + validate the tables into fresh PdnTerminalRow objects
(same order and length as PdnSetup.rows - EVERY row comes back,
so nothing in the dialog is ever lost on save); ValueError
names the offending terminal. The returned `active` records
in-run status: the checkbox AND the net filter. An off-net row
can never run under this net - and a saved PDN config pins its
net - so it is saved as "active": false while its geometry,
values and comment are all kept. Any row not in the run may
leave its value cells blank, but anything entered must still be
valid (a typo is never silently dropped on save)."""
out = []
for i, row in enumerate(self._pdn.rows):
t, r = self._pdn_map[i]
def cell(col):
it = t.item(r, col)
return it.text().strip() if it is not None else ""
new = PdnTerminalRow(name=row.name, role=row.role,
resolved=row.resolved,
component=row.component,
bonded=row.bonded,
from_config=row.from_config)
box = t.item(r, 0)
checked = (box is None
or box.checkState() == Qt.Checked)
new.active = checked and not self._row_hidden(i)
new.comment = cell(t.columnCount() - 1)
combo = self._pdn_layer_combos[i]
new.contact = (combo.currentData() if combo.count()
else row.contact)
if row.role == "load":
text = cell(3)
if not text:
if new.active:
raise ValueError(
f"Terminal '{row.name}': I draw is required "
f"(0 = voltage probe).")
else:
v = _parse_number(text,
f"Terminal '{row.name}': I draw")
if v < 0:
raise ValueError(
f"Terminal '{row.name}': I draw must be "
f"≥ 0 A.")
new.i_draw_a = v
else:
text = cell(3)
if not text:
if new.active:
raise ValueError(
f"Terminal '{row.name}': R_out is required "
f"(0 = ideal source).")
else:
v = _parse_number(text,
f"Terminal '{row.name}': R_out")
if v < 0:
raise ValueError(
f"Terminal '{row.name}': R_out must be "
f"≥ 0 Ω.")
new.r_out_ohm = v
vtext = cell(4)
if vtext:
vv = _parse_number(
vtext, f"Terminal '{row.name}': V_oc")
if vv <= 0:
raise ValueError(
f"Terminal '{row.name}': V_oc must be > 0 V "
f"(leave empty for V nominal).")
new.v_oc = vv
out.append(new)
return out
# --- shared helpers ---------------------------------------------------
def _show_error(self, msg: str) -> None:
self.error_label.setStyleSheet("color: #b02a2a;")
self.error_label.setText(msg)
self.error_label.setVisible(True)
def _show_info(self, msg: str) -> None:
self.error_label.setStyleSheet("color: #2a7a2a;")
self.error_label.setText(msg)
self.error_label.setVisible(True)
def _trim_default(self, mode: str | None = None) -> str:
mode = mode or self.trim_mode_box.currentData()
@@ -208,10 +781,19 @@ class _Dialog(QDialog):
for name in layers:
item = QListWidgetItem(name)
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Checked)
checked = True
if self._preset_layers is not None and net == self._preset_net:
checked = name in self._preset_layers
item.setCheckState(Qt.Checked if checked else Qt.Unchecked)
self.layer_list.addItem(item)
for box, desired in ((self.contact1_box, self._desired1),
(self.contact2_box, self._desired2)):
if self._pdn is not None:
self._refresh_layer_combos(layers)
self._apply_net_filter(net)
boxes = []
if self.contact1_box is not None:
boxes = [(self.contact1_box, self._desired1),
(self.contact2_box, self._desired2)]
for box, desired in boxes:
box.clear()
box.addItem(AUTO_CONTACT)
box.addItem(ALL_LAYERS)
@@ -238,17 +820,32 @@ class _Dialog(QDialog):
raise ValueError("Check at least one layer.")
def number(edit: QLineEdit, name: str) -> float:
text = edit.text().strip()
try:
return float(skin.normalize_decimal(text))
except ValueError as exc:
if "separator" in str(exc):
raise ValueError(f"{name}: {exc}")
raise ValueError(f"{name}: '{text}' is not a number.")
return _parse_number(edit.text().strip(), name)
current = number(self.current_edit, "Test current")
if current <= 0:
raise ValueError("Test current must be > 0 A.")
pdn_mode = self._active_mode() == "pdn"
pdn_rows = None
v_nominal = None
if pdn_mode:
pdn_rows = self._read_pdn_rows()
live = [r for r in pdn_rows if r.active]
for role in ("supply", "load"):
if not any(r.role == role for r in live):
raise ValueError(
f"At least one active {role} is needed - check "
f"an Active box in the {role} table.")
vtext = self.vnominal_edit.text().strip()
if not vtext:
raise ValueError("V nominal is required in PDN mode "
"(the default supply open-circuit "
"voltage).")
v_nominal = _parse_number(vtext, "V nominal")
if v_nominal <= 0:
raise ValueError("V nominal must be > 0 V.")
current = sum(r.i_draw_a for r in live if r.role == "load")
else:
current = number(self.current_edit, "Test current")
if current <= 0:
raise ValueError("Test current must be > 0 A.")
cell = None
if self.cell_edit.text().strip():
cell = number(self.cell_edit, "Cell size")
@@ -284,7 +881,9 @@ class _Dialog(QDialog):
if cap_max_drill <= 0:
raise ValueError("Capped-up-to drill must be > 0 mm.")
def contact(box: QComboBox) -> str:
def contact(box: QComboBox | None) -> str:
if box is None or pdn_mode:
return "auto" # PDN: scopes live per terminal
t = box.currentText()
if t == AUTO_CONTACT:
return "auto"
@@ -295,7 +894,9 @@ class _Dialog(QDialog):
contact2=contact(self.contact2_box),
current_a=current, cell_um=cell,
freq_hz=freq,
contact_model=self.model_box.currentData(),
contact_model=(self.model_box.currentData()
if self.model_box is not None
and not pdn_mode else "uniform"),
include_buildup=self.buildup_check.isChecked(),
extra_cu_um=extra_cu,
include_tracks=self.tracks_check.isChecked(),
@@ -304,14 +905,63 @@ class _Dialog(QDialog):
adaptive=self.adaptive_check.isChecked(),
push_overlays=self.overlay_check.isChecked(),
trim_enabled=self.trim_check.isChecked(),
trim_mode=trim_mode, trim_value=trim_value)
trim_mode=trim_mode, trim_value=trim_value,
mode="pdn" if pdn_mode else "classic",
pdn_rows=pdn_rows, v_nominal=v_nominal)
def _load_config(self) -> None:
"""'Load config…': pick a config file - a VALID pick closes the
dialog with a LoadRequest (main re-derives everything from that
file and reopens the dialog), an invalid one shows the loader's
error and stays open. Nothing in the current form is validated:
loading replaces it wholesale."""
path, _filter = QFileDialog.getOpenFileName(
self, "Load config", str(self._load_dir),
"Config files (*.json)")
if not path:
return
try:
configfile.load_config(Path(path))
except ConfigError as e:
self._show_error(str(e))
return
self._load_request = Path(path)
self.accept()
def _save_config(self) -> None:
"""'Save config…': validate like OK, ask for the target file
(name editable - seeded with the loaded config, or the default
name; the next save re-seeds with whatever was chosen), then
hand Selection + path to the callback (which writes the file
and returns its name). The dialog stays open."""
try:
sel = self._build_selection()
except ValueError as e:
self._show_error(str(e))
return
path, _filter = QFileDialog.getSaveFileName(
self, "Save config",
str(self._save_target) if self._save_target else "",
"Config files (*.json)")
if not path:
return
target = Path(path)
if target.suffix.lower() != ".json":
# non-native pickers do not append the filter's suffix
target = target.with_name(target.name + ".json")
try:
saved_to = self._save_callback(sel, target)
except Exception as e:
self._show_error(str(e))
return
self._save_target = target
self._show_info(f"saved to {saved_to}")
def _try_accept(self) -> None:
try:
self._selection = self._build_selection()
except ValueError as e:
self.error_label.setText(str(e))
self.error_label.setVisible(True)
self._show_error(str(e))
return
self.accept()
@@ -319,13 +969,37 @@ class _Dialog(QDialog):
def ask(candidates: dict[str, list[str]], layer_order: list[str],
default_net: str, e1_label: str, e2_label: str,
contact1: str, contact2: str,
buildup_layers: list[str] | None = None) -> Selection | None:
"""Show the dialog; returns None on cancel."""
buildup_layers: list[str] | None = None,
defaults: DialogDefaults | None = None,
pdn: PdnSetup | None = None,
pdn_candidates: dict | None = None,
classic_reason: str | None = None,
pdn_reason: str | None = None,
save_callback=None, save_target=None, load_dir=None,
start_mode: str = "classic"):
"""Show the dialog; returns a Selection, a LoadRequest (the user
picked another config with "Load config…" - re-derive and call ask
again), or None on cancel. defaults: widget seeds (config.py +
config file); pdn: the PDN terminal setup (editable tables);
pdn_candidates: net candidates for PDN mode (classic uses
`candidates`); classic_reason / pdn_reason: why a mode is
unavailable (its radio is disabled with the reason shown);
save_callback(selection, target_path) -> saved name string enables
the "Save config…" button in both modes (the file name is asked
per save, seeded with save_target); load_dir (the board directory)
enables the "Load config…" button; start_mode ("classic"/"pdn") is
only the STARTING radio - both stay switchable while available."""
app = QApplication.instance() or QApplication([])
dlg = _Dialog(candidates, layer_order, default_net, e1_label, e2_label,
contact1, contact2, buildup_layers or [])
contact1, contact2, buildup_layers or [],
defaults=defaults, pdn=pdn, pdn_candidates=pdn_candidates,
classic_reason=classic_reason, pdn_reason=pdn_reason,
save_callback=save_callback, save_target=save_target,
load_dir=load_dir, start_mode=start_mode)
dlg.raise_()
dlg.activateWindow()
if dlg.exec() != QDialog.Accepted:
return None
if dlg._load_request is not None:
return LoadRequest(dlg._load_request)
return dlg._selection
+6
View File
@@ -18,6 +18,12 @@ class SelectionError(UserFacingError):
pass
class ConfigError(UserFacingError):
"""fill_res_config.json is present but unreadable or invalid. Always
fatal - silently ignoring a config (and running a default setup the
user did not ask for) would be worse than stopping."""
class CandidateError(UserFacingError):
pass
+88 -3
View File
@@ -7,6 +7,9 @@ so the whole pipeline downstream of board_io runs without KiCad.
Schema v2 is multi-layer: per-layer fills at stackup depths, linked by
via/through-pad barrels. v1 dumps (single layer, no vias) still load.
Schema v7 adds PDN terminals (supplies/loads); dumps <= v6 load with
terminals=[] and run the classic two-terminal solve unchanged. v8 adds
the per-terminal `bonded` flag (v7 dumps load with bonded=False).
"""
from __future__ import annotations
@@ -17,7 +20,7 @@ from pathlib import Path
import numpy as np
JSON_SCHEMA_VERSION = 6
JSON_SCHEMA_VERSION = 8
@dataclass(frozen=True)
@@ -133,6 +136,39 @@ class Electrode:
# Problem.tht_protrusion_nm
@dataclass
class Terminal:
"""One PDN-mode terminal: a supply (Thevenin source: open-circuit
volts v_oc behind r_out_ohm) or a load (prescribed current draw
i_draw_a). Contact geometry is a list of Electrode parts. In PDN
mode Problem.terminals replaces electrodes1/electrodes2; supply
currents are solve OUTCOMES, load draws are prescribed.
bonded: all the terminal's contact cells are shorted into one
super-node (an externally bonded lug - a multi-pin package with
internal metal). The TOTAL current is prescribed as usual, but the
per-part/per-cell split becomes a solve outcome instead of the
default per-cell area share (loads) / per-cell Thevenin attachment
(supplies). The contact face is then equipotential."""
role: str # "supply" | "load"
electrodes: list[Electrode]
label: str = "" # display name; "" gets an
# S1/L1 tag at solve time
i_draw_a: float = 0.0 # loads: prescribed draw [A]
r_out_ohm: float = 0.0 # supplies: Thevenin output
# resistance [ohm]
v_oc: float | None = None # supplies: open-circuit
# volts; None -> the run's
# v_nominal at solve time
bonded: bool = False # short all contact cells
# into one lug (see above)
component: str = "" # display only: the owner
# hint ("U5" / "near U5",
# board_io.component_hints)
comment: str = "" # display only: the user's
# free-text note
@dataclass
class ViaLink:
"""A conductive barrel (via or plated through-hole pad) linking copper
@@ -201,6 +237,10 @@ class Problem:
electrodes1: list[Electrode] # V+ terminal parts (merged)
electrodes2: list[Electrode] # V- terminal parts (merged)
thickness_source: str = "stackup"
# PDN mode: non-empty replaces electrodes1/2 entirely (the pipeline
# rejects a problem carrying both) - N supplies + M loads instead of
# one driven terminal pair
terminals: list[Terminal] = field(default_factory=list)
buildups: list[SurfaceBuildup] = field(default_factory=list)
solder_thickness_nm: int = 50_000
solder_rho_ohm_m: float = 1.32e-7
@@ -231,6 +271,15 @@ class Problem:
def layer_names(self) -> list[str]:
return [l.layer_name for l in self.layers]
def contact_electrodes(self) -> list[Electrode]:
"""Every contact part regardless of mode: classic V+/V- lists
plus all PDN terminal parts (exactly one group is non-empty in a
valid problem). Use this wherever per-contact geometry features
(solder coats, lead cones) are collected, so PDN terminals get
the same treatment as classic ones."""
return (self.electrodes1 + self.electrodes2
+ [e for t in self.terminals for e in t.electrodes])
def sigma_s(self, layer_index: int) -> float:
"""Sheet conductance of one layer [S per square]."""
return (self.layers[layer_index].thickness_nm * 1e-9) / self.rho_ohm_m
@@ -262,7 +311,7 @@ def contact_solder_buildups(problem: Problem) -> list[str]:
names. Called once when the problem is built."""
included = {l.layer_name for l in problem.layers}
touched = []
for e in problem.electrodes1 + problem.electrodes2:
for e in problem.contact_electrodes():
if not e.solder or not e.polygons \
or e.protrusion_side not in included:
continue
@@ -318,7 +367,7 @@ def tht_joint_buildups(problem: Problem,
coats them with the exact pad shape. Returns the affected layer
names."""
included = {l.layer_name for l in problem.layers}
contacts = {e.center for e in problem.electrodes1 + problem.electrodes2
contacts = {e.center for e in problem.contact_electrodes()
if e.drill_nm > 0 and e.center is not None}
touched = []
for v in problem.vias:
@@ -528,6 +577,39 @@ def _electrode_from_json(d: dict) -> Electrode:
)
def _terminal_to_json(t: Terminal) -> dict:
d = {
"role": t.role,
"label": t.label,
"i_draw_a": t.i_draw_a,
"r_out_ohm": t.r_out_ohm,
"v_oc": t.v_oc,
"bonded": t.bonded,
"electrodes": [_electrode_to_json(e) for e in t.electrodes],
}
# display-only metadata, written when present (still schema v8:
# optional keys, older loaders simply ignore them)
if t.component:
d["component"] = t.component
if t.comment:
d["comment"] = t.comment
return d
def _terminal_from_json(d: dict) -> Terminal:
return Terminal(
role=d["role"],
electrodes=[_electrode_from_json(ed) for ed in d["electrodes"]],
label=d.get("label", ""),
i_draw_a=float(d.get("i_draw_a", 0.0)),
r_out_ohm=float(d.get("r_out_ohm", 0.0)),
v_oc=(None if d.get("v_oc") is None else float(d["v_oc"])),
bonded=bool(d.get("bonded", False)), # <= v7: not bonded
component=str(d.get("component", "")),
comment=str(d.get("comment", "")),
)
def problem_to_json(p: Problem) -> dict:
return {
"schema_version": JSON_SCHEMA_VERSION,
@@ -538,6 +620,7 @@ def problem_to_json(p: Problem) -> dict:
"thickness_source": p.thickness_source,
"electrodes1": [_electrode_to_json(e) for e in p.electrodes1],
"electrodes2": [_electrode_to_json(e) for e in p.electrodes2],
"terminals": [_terminal_to_json(t) for t in p.terminals],
"layers": [
{
"layer_name": l.layer_name,
@@ -629,6 +712,8 @@ def problem_from_json(d: dict) -> Problem:
electrodes2=(
[_electrode_from_json(ed) for ed in d["electrodes2"]]
if version >= 3 else [_electrode_from_json(d["electrode2"])]),
# v7: PDN terminals; dumps <= v6 predate them and load classic
terminals=[_terminal_from_json(td) for td in d.get("terminals", [])],
thickness_source=d.get("thickness_source", "unknown"),
buildups=[
SurfaceBuildup(
+410 -51
View File
@@ -1,8 +1,13 @@
"""Top-level orchestration for the KiCad-launched action.
Flow: connect -> read the two selected contacts (rectangles/pads) ->
gather fills -> selection dialog (net, layers, contacts, current, cell)
-> extract vias -> solve -> figures + report.
Flow: connect -> load the config named "default" (or the board-specific
one) -> derive BOTH modes' terminals (classic: selection / marker
rectangles / config refs; PDN: per-rectangle marker scan, or the
config's terminal set) -> gather fills -> dialog with a Classic/PDN
mode selector (classic: the two-contact form; PDN: editable per-role
terminal tables) -> extract vias -> solve -> figures + report. The
dialog's "Load config…" button loops back to the derivation with the
picked file, so a run can be set up from any saved config.
Every failure is reported twice: on stdout (lands in the KiCad status-bar
warning list) and as a matplotlib error figure, so it cannot be missed.
@@ -11,9 +16,11 @@ from __future__ import annotations
import sys
import traceback
from pathlib import Path
from . import config, pipeline, progress, report
from .errors import CandidateError, UserFacingError
from .errors import ConfigError, SelectionError, UserFacingError
from .geometry import Terminal
def _fail(message: str, outdir) -> None:
@@ -36,13 +43,21 @@ def _fail(message: str, outdir) -> None:
sys.exit(1)
# config globals a config file may override; "Load config…" re-derives
# from a fresh baseline so one file's physics/marker layers never leak
# into the next
_CFG_GLOBALS = ("RHO_CU_OHM_M", "COPPER_THICKNESS_UM", "VIA_PLATING_UM",
"ELECTRODE_POS_LAYER", "ELECTRODE_NEG_LAYER",
"ELECTRODE_PDN_LAYER")
def main() -> None:
outdir = None
try:
try:
from kipy.errors import ApiError
from . import board_io, dialog
from . import board_io, configfile, dialog
except ImportError as e:
if "cannot open shared object file" not in str(e):
raise
@@ -60,47 +75,327 @@ def main() -> None:
try:
kicad, board = board_io.connect()
stackup = board_io.get_stackup_info(board)
es1, es2, net_hint = board_io.get_electrodes(board, stackup)
if board_io.any_zone_unfilled(board) or config.ALWAYS_REFILL:
board_io.refill(board)
fills = board_io.gather_net_fills(board)
tracks = board_io.gather_net_tracks(board)
copper = board_io.merge_copper(
fills, board_io.tracks_as_polygons(tracks))
candidate_nets = board_io.nets_overlapping(copper, es1, es2)
buildups = board_io.gather_mask_buildups(board)
except ApiError as e:
raise UserFacingError(
f"KiCad API error: {e}\nIf KiCad is showing a dialog, close "
f"it and run again."
)
cfg_path = configfile.find_config(
board_io.board_dir(board),
getattr(board, "name", "") or "")
base_globals = {name: getattr(config, name)
for name in _CFG_GLOBALS}
while True:
for name, value in base_globals.items():
setattr(config, name, value)
try:
cfg = configfile.load_config(cfg_path) if cfg_path else None
# a config with a terminals section is the PDN source;
# cfg.mode is only the STARTING mode - nothing is
# pinned, the dialog switches modes and nets freely
pdn_cfg = cfg is not None and bool(cfg.terminals)
if cfg is not None:
print(f"using config {cfg_path.name} ({cfg.mode} mode)")
# before any geometry: the marker layers steer
# get_electrodes, the physics steers build_problem
configfile.apply_physics(cfg)
if not candidate_nets:
raise CandidateError(
"No copper (zone fill or trace) overlaps both contacts. "
"Check that both sit over copper of the same net and that "
"the fills are up to date (press B in the board editor)."
# BOTH terminal derivations always run; a failure only
# disables that mode's radio (with the reason shown) - the
# launch dies only when neither mode is possible
classic_reason = pdn_reason = None
es1: list = []
es2: list = []
net_hint = None
terminals: list = [] # resolved config terminals
marker_terms = None # live-scan MarkerTerminal list
new_terms: list = [] # rects not in the config yet
merge_note = ""
pdn_groups: list = [] # electrode groups, either way
pdn_hints: list = [] # per-terminal Component text
term_nets: list = [] # per-terminal overlapped nets
has_selection = bool(list(board.get_selection()))
try:
if cfg is not None and cfg.pos_parts is not None:
es1, es2 = board_io.resolve_classic_parts(
board, stackup, cfg.pos_parts,
cfg.neg_parts, cfg.net)
net_hint = cfg.net
else:
es1, es2, net_hint = board_io.get_electrodes(
board, stackup)
except SelectionError as e:
classic_reason = str(e)
try:
if pdn_cfg:
# config refs resolve against run.net; a broken
# ref disables PDN mode instead of killing the
# launch (classic may still work)
if not cfg.net:
raise ConfigError(
f"{cfg_path.name}: run.net is required "
f"to resolve the config terminals")
terminals = board_io.resolve_terminal_specs(
board, stackup, cfg.terminals, cfg.net)
# rectangles drawn since the save become NEW
# terminals - the file freezes nothing. A scan
# problem only forfeits the new ones, never
# the config set
try:
new_terms = board_io.new_marker_terminals(
cfg.terminals,
board_io.scan_marker_terminals(
board, require_both=False))
except (SelectionError, ConfigError) as e:
merge_note = (f"rectangle scan failed ({e})"
f" - new rectangles not "
f"offered")
print(f"note: {merge_note}")
pdn_groups = (
[t.electrodes for t in terminals]
+ [mt.electrodes for mt in new_terms])
else:
marker_terms = board_io.scan_marker_terminals(
board)
pdn_groups = [mt.electrodes
for mt in marker_terms]
pdn_hints = board_io.component_hints(board,
pdn_groups)
except (SelectionError, ConfigError) as e:
pdn_reason = str(e)
if board_io.any_zone_unfilled(board) or config.ALWAYS_REFILL:
board_io.refill(board)
fills = board_io.gather_net_fills(board)
tracks = board_io.gather_net_tracks(board)
copper = board_io.merge_copper(
fills, board_io.tracks_as_polygons(tracks))
classic_nets: list = []
pdn_nets: list = []
if classic_reason is None:
classic_nets = board_io.nets_overlapping(
copper, es1, es2)
if not classic_nets:
classic_reason = (
"No copper (zone fill or trace) overlaps "
"both contacts. Check that both sit over "
"copper of the same net and that the fills "
"are up to date (press B in the board "
"editor).")
if pdn_reason is None:
# per-terminal net sets drive BOTH the candidate
# list (a net qualifies with >= 1 supply and >= 1
# load terminal on it) and the dialog's row filter
# (only terminals on the selected net are shown
# and solved) - config and live sources alike
term_nets = board_io.group_nets(copper, pdn_groups)
roles = (([t.role for t in terminals]
+ [mt.role for mt in new_terms]) if pdn_cfg
else [mt.role for mt in marker_terms])
sup_nets: set = set()
load_nets: set = set()
for role, tn in zip(roles, term_nets):
(sup_nets if role == "supply"
else load_nets).update(tn)
pdn_nets = sorted(sup_nets & load_nets)
if not pdn_nets:
pdn_reason = (
"no net's copper overlaps at least one "
"supply and one load "
+ (f"terminal of {cfg_path.name}"
if pdn_cfg else "rectangle"))
elif pdn_cfg and cfg.net not in pdn_nets:
print(f"note: run.net '{cfg.net}' has no "
f"workable supply+load copper; PDN "
f"candidates: {', '.join(pdn_nets)}")
if classic_reason is not None and pdn_reason is not None:
raise SelectionError(
f"{classic_reason}\n(PDN mode is also "
f"unavailable: {pdn_reason})")
buildups = board_io.gather_mask_buildups(board)
except ApiError as e:
raise UserFacingError(
f"KiCad API error: {e}\nIf KiCad is showing a dialog, "
f"close it and run again."
)
def group_label(parts):
names = [p.label for p in parts[:3]]
more = f" +{len(parts) - 3}" if len(parts) > 3 else ""
return f"{len(parts)}× " + ", ".join(names) + more
def group_contact(parts):
contacts = {p.contact for p in parts}
return contacts.pop() if len(contacts) == 1 else "auto"
def rect_desc(e):
r = e.rect
return (f"rect ({r.x0 / 1e6:.1f}, {r.y0 / 1e6:.1f}).."
f"({r.x1 / 1e6:.1f}, {r.y1 / 1e6:.1f}) mm")
def marker_desc(mt):
if len(mt.electrodes) == 1:
return rect_desc(mt.electrodes[0])
# same-named rectangles grouped into one bonded lug
return (f"{len(mt.electrodes)}× "
f"{rect_desc(mt.electrodes[0])} … — bonded")
def live_row(mt, hint, tn):
return dialog.PdnTerminalRow(
name=mt.name, role=mt.role,
resolved=marker_desc(mt), component=hint,
bonded=mt.bonded, nets=tn)
defaults = configfile.dialog_defaults(cfg)
pdn_setup = None
if pdn_cfg and pdn_reason is None:
n_cfg = len(terminals)
rows = [dialog.PdnTerminalRow(
name=t.label, role=t.role,
resolved=(group_label(t.electrodes)
+ (" — bonded" if t.bonded else "")),
component=hint,
i_draw_a=(t.i_draw_a if t.role == "load"
else None),
r_out_ohm=(t.r_out_ohm if t.role == "supply"
else None),
v_oc=t.v_oc, bonded=t.bonded,
contact=spec.contact or "auto",
active=spec.active, comment=spec.comment,
nets=tn, from_config=True)
for t, spec, hint, tn in zip(
terminals, cfg.terminals, pdn_hints[:n_cfg],
term_nets[:n_cfg])]
# newly drawn rectangles append as live rows: a save
# writes them into the config alongside the file's set
rows += [live_row(mt, hint, tn)
for mt, hint, tn in zip(new_terms,
pdn_hints[n_cfg:],
term_nets[n_cfg:])]
notes = []
if new_terms:
notes.append(f"{len(new_terms)} new rectangle(s) "
f"not in {cfg_path.name} yet - "
f"Save config… adds them")
if merge_note:
notes.append(merge_note)
pdn_setup = dialog.PdnSetup(
rows=rows, source=cfg_path.name, from_config=True,
note="; ".join(notes))
elif pdn_reason is None:
note = ""
if has_selection:
note = ("board selection ignored in PDN mode - "
"terminals are the marker rectangles")
print(f"note: {note}")
pdn_setup = dialog.PdnSetup(
rows=[live_row(mt, hint, tn)
for mt, hint, tn in zip(marker_terms,
pdn_hints,
term_nets)],
source=(f"marker rectangles on "
f"{config.ELECTRODE_POS_LAYER}/"
f"{config.ELECTRODE_NEG_LAYER}"),
note=note)
# cfg.mode is only the starting radio - never a pin
start_pdn = ((cfg is not None and cfg.mode == "pdn"
and pdn_reason is None)
or classic_reason is not None)
if start_pdn:
default_net = (defaults.net if defaults.net in pdn_nets
else (pdn_nets[0] if pdn_nets else ""))
else:
default_net = (defaults.net if defaults.net in classic_nets
else net_hint if net_hint in classic_nets
else classic_nets[0])
def rect_infos(terms):
# unlabeled terminals are always single rectangles, so
# freezing the first rect's coordinates is exact
return [(mt.labeled,
(mt.electrodes[0].rect.x0 / 1e6,
mt.electrodes[0].rect.y0 / 1e6,
mt.electrodes[0].rect.x1 / 1e6,
mt.electrodes[0].rect.y1 / 1e6))
for mt in terms]
def save_cb(sel, target, cfg=cfg, cfg_path=cfg_path,
pdn_cfg=pdn_cfg, marker_terms=marker_terms,
new_terms=new_terms):
if sel.mode == "classic":
configfile.save_classic_config(target, sel)
elif pdn_cfg:
# the config rows update positionally; newly drawn
# rectangles append as fresh terminal entries
n = len(cfg.raw["terminals"])
tj = configfile.updated_terminals_json(
cfg.raw["terminals"], sel.pdn_rows[:n])
if sel.pdn_rows[n:]:
tj += configfile.rect_terminals_json(
sel.pdn_rows[n:], rect_infos(new_terms))
print(f"note: {len(sel.pdn_rows[n:])} new "
f"terminal(s) added to the config")
configfile.save_pdn_config(target, sel, tj)
else:
# EVERY row is saved - off-net ones arrive from the
# dialog as active: false (nothing drawn on the
# board is lost by a save); labeled (possibly
# grouped) rectangles save as rect:NAME
configfile.save_pdn_config(
target, sel,
configfile.rect_terminals_json(
sel.pdn_rows, rect_infos(marker_terms)))
print("note: the saved config now provides the "
"terminal set on later launches - labeled "
"rectangles stay live (rect:NAME), unlabeled "
"ones were frozen as coordinates; remove the "
"terminals section (or the file) to return "
"to the live rectangle scan")
print(f"config saved to {target}")
# only "default" (or its legacy plain spelling) and the
# board-stem name load on launch; other names need the
# Load config… button - say so before it surprises
auto = {config.CONFIG_FILENAME,
configfile.named_config_filename("default")}
stem = Path(getattr(board, "name", "") or "").stem
if stem:
auto.add(f"{stem}.{config.CONFIG_FILENAME}")
if target.name not in auto:
print("note: this name does not load automatically "
"- pull it in with Load config…")
return target.name
selection = dialog.ask(
candidates={n: list(copper[n].keys())
for n in classic_nets},
layer_order=stackup.names,
default_net=default_net,
e1_label=(group_label(es1) if es1 else ""),
e2_label=(group_label(es2) if es2 else ""),
contact1=((defaults.contact1 or group_contact(es1))
if es1 else "auto"),
contact2=((defaults.contact2 or group_contact(es2))
if es2 else "auto"),
buildup_layers=sorted(buildups.keys()),
defaults=defaults, pdn=pdn_setup,
pdn_candidates={n: list(copper[n].keys())
for n in pdn_nets},
classic_reason=classic_reason, pdn_reason=pdn_reason,
save_callback=save_cb,
save_target=(cfg_path if cfg_path is not None else
board_io.board_dir(board)
/ config.CONFIG_FILENAME),
load_dir=board_io.board_dir(board),
start_mode=("pdn" if start_pdn else "classic"),
)
def group_label(parts):
names = [p.label for p in parts[:3]]
more = f" +{len(parts) - 3}" if len(parts) > 3 else ""
return f"{len(parts)}× " + ", ".join(names) + more
def group_contact(parts):
contacts = {p.contact for p in parts}
return contacts.pop() if len(contacts) == 1 else "auto"
default_net = (net_hint if net_hint in candidate_nets
else candidate_nets[0])
selection = dialog.ask(
candidates={n: list(copper[n].keys()) for n in candidate_nets},
layer_order=stackup.names,
default_net=default_net,
e1_label=group_label(es1), e2_label=group_label(es2),
contact1=group_contact(es1), contact2=group_contact(es2),
buildup_layers=sorted(buildups.keys()),
)
if isinstance(selection, dialog.LoadRequest):
# re-derive everything from the picked file; its validity
# was already checked by the dialog before it closed
cfg_path = selection.path
continue
break
if selection is None:
print("cancelled")
return
@@ -108,25 +403,86 @@ def main() -> None:
# looks like it did nothing until the figures appear
progress.start()
if selection.contact1 != "auto":
for e in es1:
e.contact = selection.contact1
if selection.contact2 != "auto":
for e in es2:
e.contact = selection.contact2
run_pdn = selection.mode == "pdn"
if run_pdn:
def live_terminal(mt, row):
if row.contact not in ("", "all", "auto"):
# dialog Layer pick: this terminal's rectangles
# contact only that copper layer
for e in mt.electrodes:
e.contact = row.contact
return Terminal(
role=row.role, electrodes=mt.electrodes,
label=row.name,
i_draw_a=(row.i_draw_a
if row.i_draw_a is not None else 0.0),
r_out_ohm=(row.r_out_ohm
if row.r_out_ohm is not None else 0.0),
v_oc=row.v_oc, bonded=mt.bonded,
component=row.component, comment=row.comment)
if pdn_cfg:
cfg_rows = selection.pdn_rows[:len(terminals)]
new_rows = selection.pdn_rows[len(terminals):]
# a changed Layer scope is geometry: push it onto the
# specs and re-resolve (part-level contacts inside the
# file still win, exactly as the schema promises)
changed = False
for spec, row in zip(cfg.terminals, cfg_rows):
if (row.contact or "auto") != (spec.contact or "auto"):
spec.contact = row.contact
changed = True
if changed:
try:
terminals = board_io.resolve_terminal_specs(
board, stackup, cfg.terminals, cfg.net)
except ApiError as e:
raise UserFacingError(f"KiCad API error: {e}")
# dialog value edits win for the run: write them back
# onto the resolved terminals (positional, same order),
# then drop the unchecked ones - they stay in the file
# but take no part in the solve; newly drawn
# rectangles run as live terminals
for t, row in zip(terminals, cfg_rows):
if t.role == "load":
t.i_draw_a = row.i_draw_a
else:
t.r_out_ohm = row.r_out_ohm
t.v_oc = row.v_oc
t.component = row.component
t.comment = row.comment
terminals = (
[t for t, row in zip(terminals, cfg_rows)
if row.active]
+ [live_terminal(mt, row)
for mt, row in zip(new_terms, new_rows)
if row.active])
else:
terminals = [live_terminal(mt, row)
for mt, row in zip(marker_terms,
selection.pdn_rows)
if row.active]
else:
if selection.contact1 != "auto":
for e in es1:
e.contact = selection.contact1
if selection.contact2 != "auto":
for e in es2:
e.contact = selection.contact2
if selection.cell_um is not None:
config.CELL_UM_OVERRIDE = selection.cell_um
config.ADAPTIVE_CELLS = selection.adaptive
try:
problem = board_io.build_problem(
board, selection.net, selection.layers, es1, es2, stackup,
fills,
board, selection.net, selection.layers,
([] if run_pdn else es1), ([] if run_pdn else es2),
stackup, fills,
buildups=(buildups if selection.include_buildup else None),
extra_cu_um=selection.extra_cu_um,
tracks=(tracks if selection.include_tracks else None),
vias_capped=selection.vias_capped,
cap_max_drill_mm=selection.cap_max_drill_mm)
cap_max_drill_mm=selection.cap_max_drill_mm,
terminals=(terminals if run_pdn else None))
outdir = report.make_output_dir(board_io.board_dir(board))
except ApiError as e:
raise UserFacingError(f"KiCad API error: {e}")
@@ -145,12 +501,15 @@ def main() -> None:
trim_abs = selection.trim_value
else:
trim_pct = selection.trim_value
pipeline.run(problem, outdir, show=True, i_test=selection.current_a,
pipeline.run(problem, outdir, show=True,
i_test=(None if run_pdn else selection.current_a),
freq_hz=selection.freq_hz,
contact_model=selection.contact_model,
contact_model=(None if run_pdn
else selection.contact_model),
overlay=overlay_cb,
trim_pct=trim_pct, trim_abs=trim_abs,
trim_push=trim_cb)
trim_push=trim_cb,
v_nominal=(selection.v_nominal if run_pdn else None))
except progress.Cancelled:
print("cancelled") # user's own doing: no error figure
except UserFacingError as e:
+64 -18
View File
@@ -4,8 +4,10 @@ from __future__ import annotations
from pathlib import Path
import numpy as np
from . import config, plots, progress, raster, report, solver, trim
from .errors import UserFacingError
from .errors import ElectrodeError, UserFacingError
from .geometry import Problem
from .solver import Result
@@ -14,7 +16,7 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
i_test: float | None = None, freq_hz: float = 0.0,
contact_model: str | None = None, overlay=None,
trim_pct: float | None = None, trim_abs: float | None = None,
trim_push=None) -> Result:
trim_push=None, v_nominal: float | None = None) -> Result:
"""overlay: optional callback(stack, result) run after the solve
(EXPERIMENTAL in-KiCad overlays); its failures are non-fatal.
trim_pct / trim_abs: mark copper below this threshold (% of the
@@ -22,11 +24,22 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
areas are printed, polygons saved to
<outdir>/low_current_copper.json and handed to trim_push, an
optional callback(trim_result) that pushes them into the board
(failures non-fatal)."""
if i_test is None:
i_test = config.TEST_CURRENT_A
if i_test <= 0:
raise UserFacingError(f"Test current must be > 0 A (got {i_test:g}).")
(failures non-fatal). Problems with terminals run in PDN mode:
i_test/contact_model are ignored there (draws come from the
terminals, the contact models are fixed) and v_nominal is the
default supply open-circuit voltage."""
pdn = bool(problem.terminals)
if pdn and (problem.electrodes1 or problem.electrodes2):
raise ElectrodeError(
"The problem carries both classic V+/V- electrodes and PDN "
"terminals - exactly one terminal scheme must be used."
)
if not pdn:
if i_test is None:
i_test = config.TEST_CURRENT_A
if i_test <= 0:
raise UserFacingError(
f"Test current must be > 0 A (got {i_test:g}).")
h = raster.choose_cell_size(problem.copper_bbox(), len(problem.layers))
progress.stage(f"rasterizing {len(problem.layers)} layer(s) at cell "
f"size {h / 1000:.1f} um ...")
@@ -35,18 +48,49 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
f"{int(stack.masks.sum())} copper cells, {len(problem.vias)} "
f"via/pad barrel(s)")
e1, e2 = raster.electrode_masks(stack, problem)
parts1, parts2 = raster.electrode_partition(stack, problem)
if pdn:
if contact_model is not None:
print("PDN mode: contact models are fixed (Thevenin supplies "
"/ uniform-injection loads) - ignoring the setting")
tmasks = raster.terminal_masks(stack, problem)
tparts = raster.terminal_partition(stack, problem)
draw = sum(t.i_draw_a for t in problem.terminals
if t.role == "load")
progress.stage(f"solving PDN, {draw:g} A total draw"
+ (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC")
+ " ...")
result = solver.run_solve_pdn(problem, stack, tmasks, tparts,
freq_hz, v_nominal)
for s_ in result.supplies:
print(f" {s_.label}: {s_.i_a:.4g} A @ {s_.v_contact:.4g} V "
f"(v_oc {s_.v_oc:g} V, r_out {s_.r_out_ohm:g} ohm, "
f"P_int {s_.p_internal_w:.3g} W)")
for l_ in result.loads:
print(f" {l_.label}: {l_.i_a:.4g} A, V {l_.v_mean:.4g} V "
f"(min {l_.v_min:.4g}), P {l_.p_w:.4g} W")
# figures reuse the two-terminal color scheme: supplies as V+,
# loads as V- (masks already follow the solve's restriction)
e1 = np.zeros_like(stack.masks)
e2 = np.zeros_like(stack.masks)
for t, m in zip(problem.terminals, tmasks):
if t.role == "supply":
e1 |= m
else:
e2 |= m
else:
e1, e2 = raster.electrode_masks(stack, problem)
parts1, parts2 = raster.electrode_partition(stack, problem)
progress.stage(f"solving @ {i_test:g} A"
+ (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC") + " ...")
result = solver.run_solve(problem, stack, e1, e2, i_test, freq_hz,
contact_model, parts1, parts2)
for prefix, pcs in (("P", result.part_currents1),
("N", result.part_currents2)):
for i, (label, amps) in enumerate(pcs):
print(f" {prefix}{i + 1} ({label}): {amps:.4g} A "
f"({100 * amps / i_test:.1f}%)")
progress.stage(f"solving @ {i_test:g} A"
+ (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC")
+ " ...")
result = solver.run_solve(problem, stack, e1, e2, i_test, freq_hz,
contact_model, parts1, parts2)
for prefix, pcs in (("P", result.part_currents1),
("N", result.part_currents2)):
for i, (label, amps) in enumerate(pcs):
print(f" {prefix}{i + 1} ({label}): {amps:.4g} A "
f"({100 * amps / i_test:.1f}%)")
if outdir is not None:
outdir.mkdir(parents=True, exist_ok=True)
@@ -78,5 +122,7 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
"3_current_density"),
(plots.fig_power(result, stack, e1, e2, problem), "4_power_density"),
]
if result.mode == "pdn" and result.pairs:
figs.append((plots.fig_pdn_pairs(result), "5_source_sink_pairs"))
plots.save_and_show(figs, outdir, show=show) # closes the window itself
return result
+102 -6
View File
@@ -83,7 +83,15 @@ def _fmt_si(value: float, unit: str) -> str:
def _suptitle(problem, stack, result=None) -> str:
ny, nx = stack.shape2d
parts = []
if result is not None:
if result is not None and result.mode == "pdn":
# no single two-terminal R in PDN mode (R_ohm is NaN)
parts.append(f"PDN {len(result.supplies)}S/{len(result.loads)}L, "
f"ΣI = {result.i_test:g} A")
parts.append(f"P_Cu = {_fmt_si(result.P_total, 'W')}")
if result.freq_hz > 0:
parts.append(f"f = {result.freq_hz / 1e3:g} kHz "
f"(δ={result.skin_depth_um:.0f} µm, lower bound)")
elif result is not None:
parts.append(f"R = {result.R_ohm * 1000:.4g}")
parts.append(f"P = {_fmt_si(result.P_total, 'W')} @ "
f"{result.i_test:g} A")
@@ -286,8 +294,22 @@ def fig_raster(stack, e1, e2, problem, result=None):
if has_plug:
handles.append(Patch(
fc=_PLUG, label="solder-filled THT hole (lead + solder)"))
if result is not None and (result.part_currents1
or result.part_currents2):
if result is not None and result.mode == "pdn":
entries = ([(f"S{i + 1}", _E1_COLOR, s_.label, s_.i_a)
for i, s_ in enumerate(result.supplies)]
+ [(f"L{i + 1}", _E2_COLOR, l_.label, l_.i_a)
for i, l_ in enumerate(result.loads)])
shown = entries[:14]
for tag, color, label, amps in shown:
handles.append(Patch(
fc=color, label=f"{tag} {label}: {amps:.3g} A"))
if len(entries) > len(shown):
handles.append(Patch(
fc="#00000000",
label=f"... +{len(entries) - len(shown)} "
f"more in summary.txt"))
elif result is not None and (result.part_currents1
or result.part_currents2):
entries = ([("+", _E1_COLOR, i, amps)
for i, (_, amps) in
enumerate(result.part_currents1)]
@@ -321,9 +343,14 @@ def fig_raster(stack, e1, e2, problem, result=None):
def fig_potential(result, stack, e1, e2, problem):
vmax = float(np.nanmax(result.V))
# uniform model: <V-> = 0 is the reference, individual V- cells can
# sit slightly below it - keep them in range instead of clipping
vmin = min(0.0, float(np.nanmin(result.V)))
if result.mode == "pdn":
# absolute volts (e.g. 3.3 V nominal): anchoring the scale at
# 0 V would flatten the map into one color - auto-range instead
vmin = float(np.nanmin(result.V))
else:
# uniform model: <V-> = 0 is the reference, individual V- cells
# can sit slightly below it - keep them in range, don't clip
vmin = min(0.0, float(np.nanmin(result.V)))
unit, scale = ("mV", 1e3) if vmax < 0.1 else ("V", 1.0)
cmap = matplotlib.colormaps[config.CMAP_POTENTIAL].copy()
cmap.set_bad(_BG)
@@ -444,6 +471,75 @@ def fig_power(result, stack, e1, e2, problem):
paint_extra=paint_extra)
def _style_table(tbl):
tbl.auto_set_font_size(False)
tbl.set_fontsize(9)
tbl.scale(1.0, 1.5)
tbl.auto_set_column_width(col=sorted({c for _r, c
in tbl.get_celld()}))
for (r, _c), cell in tbl.get_celld().items():
cell.set_edgecolor("#cccccc")
if r == 0:
cell.set_text_props(fontweight="bold", color=_INK)
cell.set_facecolor("#eeeeee")
elif r % 2 == 0:
cell.set_facecolor("#f7f7f7")
def fig_pdn_pairs(result):
"""The PDN source-sink pair table as a figure: effective copper
resistance between every supply and every load plus the
proportional-sharing loss attribution - the same numbers and
conventions as the summary.txt table. Terminals are keyed by their
(unique) labels alone; a legend table underneath notes each
terminal's component hint and comment when there are any."""
header = ["supply", "load", "R (copper)", "I attributed",
"P attributed"]
rows = [[pr.supply, pr.load,
(_fmt_si(pr.r_ohm, "Ω") if pr.r_ohm is not None
else "no path"),
_fmt_si(pr.i_share_a, "A"),
_fmt_si(pr.p_w, "W")] for pr in result.pairs]
legend = [[t.label, role, t.component, t.comment]
for role, terms in (("supply", result.supplies),
("load", result.loads))
for t in terms if t.component or t.comment]
h1 = 1.8 + 0.32 * len(rows)
h2 = 0.9 + 0.30 * len(legend)
if legend:
fig, (ax, ax2) = plt.subplots(
2, 1, figsize=(9.0, h1 + h2), layout="constrained",
gridspec_kw={"height_ratios": [h1, h2]})
else:
fig, ax = plt.subplots(figsize=(9.0, h1), layout="constrained")
ax2 = None
ax.axis("off")
ax.set_title("Fill Resistance - source→sink pairs", fontsize=13,
color=_INK, loc="left")
_style_table(ax.table(cellText=rows, colLabels=header,
loc="upper center", cellLoc="left",
colLoc="left"))
p_attr = sum(pr.p_w for pr in result.pairs)
ax.text(0.0, 0.02,
f"attributed copper loss total: {_fmt_si(p_attr, 'W')} "
f"(copper loss {_fmt_si(result.P_total, 'W')})\n"
"R: effective copper resistance between the two contacts - "
"operating-point independent, source R_out excluded.\n"
"I/P attributed by proportional sharing per copper island: "
"a convention (the pair split is not unique physics), but "
"exact in total.",
transform=ax.transAxes, fontsize=8, color="#666666",
va="bottom", ha="left")
if ax2 is not None:
ax2.axis("off")
ax2.set_title("terminals", fontsize=10, color=_INK, loc="left")
_style_table(ax2.table(
cellText=legend,
colLabels=["terminal", "role", "component", "comment"],
loc="upper center", cellLoc="left", colLoc="left"))
return fig
def fig_error(message: str):
fig, ax = plt.subplots(figsize=(9, 4.5), layout="constrained")
ax.axis("off")
+53 -1
View File
@@ -281,7 +281,7 @@ def _paint_lead_fillets(stack: RasterStack, problem: Problem) -> None:
# net's populated stitching THT pads, skipping the contacts' barrels
jobs = []
seen = set()
for e in problem.electrodes1 + problem.electrodes2:
for e in problem.contact_electrodes():
if e.drill_nm <= 0:
continue
if e.center is not None:
@@ -629,6 +629,58 @@ def electrode_masks(stack: RasterStack, problem: Problem
return e1, e2
def terminal_masks(stack: RasterStack, problem: Problem) -> list:
"""PDN mode: one (L, ny, nx) contact mask per problem.terminals
entry, same order. Same part semantics as electrode_masks (every
part must land on copper); additionally NO two terminals may share
a cell - each cell's injection/attachment must belong to exactly one
terminal or the currents would be ill-defined."""
out = []
for t in problem.terminals:
m = np.zeros_like(stack.masks)
for el in t.electrodes:
part = _part_mask3d(stack, problem, el)
if not part.any():
where = ("near its barrel (drill-wall ring / pad footprint)"
if el.drill_nm > 0 else
"(or is smaller than one grid cell)")
raise ElectrodeError(
f"{t.role} '{t.label}': contact part ({el.label}) does "
f"not overlap any copper of the selected fill on "
f"contact layer(s) '{el.contact}' {where}."
)
m |= part
out.append(m)
for i, ti in enumerate(problem.terminals):
for j in range(i + 1, len(problem.terminals)):
if (out[i] & out[j]).any():
tj = problem.terminals[j]
raise ElectrodeError(
f"The contact areas of {ti.role} '{ti.label}' and "
f"{tj.role} '{tj.label}' overlap on the copper grid. "
f"Move them apart."
)
return out
def terminal_partition(stack: RasterStack, problem: Problem) -> list:
"""PDN mode: per-part cell masks for each terminal, as a list (one
entry per terminal) of [(label, mask3d), ...]. Within one terminal
overlapping parts keep the first-wins attribution of
electrode_partition, so part currents sum to the terminal current."""
out = []
for t in problem.terminals:
parts = []
claimed = np.zeros_like(stack.masks)
for el in t.electrodes:
m = _part_mask3d(stack, problem, el)
m &= ~claimed
claimed |= m
parts.append((el.label, m))
out.append(parts)
return out
def electrode_partition(stack: RasterStack, problem: Problem
) -> tuple[list, list]:
"""Per-part cell masks for both terminals, as [(label, mask3d), ...].
+209 -57
View File
@@ -42,6 +42,15 @@ def result_line(result: Result, problem: Problem, stack: RasterStack) -> str:
ny, nx = stack.shape2d
ac = (f" @ {result.freq_hz / 1e3:g} kHz (lower bound)"
if result.freq_hz > 0 else "")
if result.mode == "pdn":
vmin = min((l.v_min for l in result.loads), default=float("nan"))
return (f"PDN: {len(result.supplies)} supplies / "
f"{len(result.loads)} loads, {result.i_test:g} A total "
f"draw{ac}, worst load {vmin:.4g} V, "
f"P_copper = {result.P_total:.4g} W "
f"(net {problem.net_name}, {'+'.join(stack.layer_names)}, "
f"grid {nx}x{ny}x{stack.nlayers}, "
f"cell {stack.h_nm / 1000:.0f} um)")
return (f"R = {result.R_ohm * 1000:.4g} mOhm{ac}, "
f"P = {result.P_total:.4g} W @ {result.i_test:g} A "
f"(net {problem.net_name}, {'+'.join(stack.layer_names)}, "
@@ -55,11 +64,87 @@ def _electrode_line(e) -> str:
f"y [{r.y0 / 1e6:.2f}, {r.y1 / 1e6:.2f}] mm")
def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
result: Result) -> Path:
def _buildup_line(problem: Problem, stack: RasterStack) -> str | None:
if not (problem.buildups and stack.buildup is not None):
return None
eq_um = (problem.solder_thickness_nm / 1000
* problem.rho_ohm_m / problem.solder_rho_ohm_m
+ problem.extra_cu_nm / 1000)
cell_mm2 = (stack.h_nm * 1e-6) ** 2
per_layer = {name: float(stack.buildup[li].sum()) * cell_mm2
for li, name in enumerate(stack.layer_names)
if stack.buildup[li].any()}
areas = ", ".join(f"{n}: {a:.0f} mm^2" for n, a in per_layer.items())
return (f"solder buildup: "
f"{problem.solder_thickness_nm / 1000:.0f} um solder"
+ (f" + {problem.extra_cu_nm / 1000:.0f} um Cu"
if problem.extra_cu_nm else "")
+ f" = {eq_um:.1f} um equivalent Cu ({areas})")
def _layer_lines(problem: Problem, result: Result) -> list:
out = []
for li, layer in enumerate(problem.layers):
ac = (f" Rs_AC/Rs_DC={result.rs_ratios[li]:.2f}"
if result.freq_hz > 0 else "")
out.append(
f" {layer.layer_name:8s} t={layer.thickness_nm / 1000:5.1f} um "
f"z={layer.z_nm / 1000:7.1f} um "
f"P={result.P_layers[li]:.4g} W "
f"maxJ={float(np.nanmax(result.Jmag[li])) * 1e-6 if np.isfinite(result.Jmag[li]).any() else 0:.4g} A/mm^2"
+ ac
)
return out
def _solver_lines(stack: RasterStack, result: Result) -> list:
ny, nx = stack.shape2d
info = result.solve_info
head = f"fill_resistance {__version__} summary"
if result.contact_model == "equipotential":
quality = (f"I1/I2 @ 1V: {result.I1_a:.9g} / "
f"{result.I2_a:.9g} A "
f"(mismatch {result.mismatch_rel:.2e})")
elif result.mode == "pdn":
quality = (f"KCL residual: {result.mismatch_rel:.2e} "
f"(supplies {result.I1_a:.6g} A vs loads "
f"{result.I2_a:.6g} A)")
else:
quality = (f"solve residual: {result.mismatch_rel:.2e} "
f"(KCL, prescribed injection)")
return [
f"grid: {nx} x {ny} x {stack.nlayers} cells @ "
f"{stack.h_nm / 1000:.1f} um",
f"copper cells: {int(stack.masks.sum())}",
f"free unknowns: {result.n_free}",
f"solver: {info.method}"
+ (f", {info.iterations} iters, residual {info.residual:.2e}"
if info.iterations is not None else ""),
quality,
f"timings [s]: "
f"{', '.join(f'{k}={v:.2f}' for k, v in result.timings.items())}",
]
def _via_lines(result: Result) -> list:
if not result.via_reports:
return []
n_shown = min(10, len(result.via_reports))
lines = [
"",
f"vias/pads carrying current (top {n_shown} of "
f"{len(result.via_reports)}, @ {result.i_test:g} A):",
" x [mm] y [mm] kind drill I [A] P [W]",
]
for v in result.via_reports[:n_shown]:
lines.append(
f" {v.x_mm:8.2f} {v.y_mm:8.2f} {v.kind:5s} "
f"{v.drill_mm:5.2f} {v.current_a:8.4g} {v.power_w:.4g}"
)
return lines
def _summary_classic_lines(head: str, problem: Problem, stack: RasterStack,
result: Result) -> list:
lines = [
head,
"=" * len(head),
@@ -82,48 +167,13 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
f" in vias: {result.P_vias:.4g} W",
f" power balance: {result.power_balance_rel:.2e} (consistency)",
"",
"layers (top to bottom):",
]
if problem.buildups and stack.buildup is not None:
eq_um = (problem.solder_thickness_nm / 1000
* problem.rho_ohm_m / problem.solder_rho_ohm_m
+ problem.extra_cu_nm / 1000)
cell_mm2 = (stack.h_nm * 1e-6) ** 2
per_layer = {name: float(stack.buildup[li].sum()) * cell_mm2
for li, name in enumerate(stack.layer_names)
if stack.buildup[li].any()}
areas = ", ".join(f"{n}: {a:.0f} mm^2" for n, a in per_layer.items())
lines.insert(-1, f"solder buildup: "
f"{problem.solder_thickness_nm / 1000:.0f} um solder"
+ (f" + {problem.extra_cu_nm / 1000:.0f} um Cu"
if problem.extra_cu_nm else "")
+ f" = {eq_um:.1f} um equivalent Cu ({areas})")
for li, layer in enumerate(problem.layers):
ac = (f" Rs_AC/Rs_DC={result.rs_ratios[li]:.2f}"
if result.freq_hz > 0 else "")
lines.append(
f" {layer.layer_name:8s} t={layer.thickness_nm / 1000:5.1f} um "
f"z={layer.z_nm / 1000:7.1f} um "
f"P={result.P_layers[li]:.4g} W "
f"maxJ={float(np.nanmax(result.Jmag[li])) * 1e-6 if np.isfinite(result.Jmag[li]).any() else 0:.4g} A/mm^2"
+ ac
)
lines += [
"",
f"grid: {nx} x {ny} x {stack.nlayers} cells @ "
f"{stack.h_nm / 1000:.1f} um",
f"copper cells: {int(stack.masks.sum())}",
f"free unknowns: {result.n_free}",
f"solver: {info.method}"
+ (f", {info.iterations} iters, residual {info.residual:.2e}"
if info.iterations is not None else ""),
(f"I1/I2 @ 1V: {result.I1_a:.9g} / {result.I2_a:.9g} A "
f"(mismatch {result.mismatch_rel:.2e})"
if result.contact_model == "equipotential" else
f"solve residual: {result.mismatch_rel:.2e} "
f"(KCL, prescribed injection)"),
f"timings [s]: "
f"{', '.join(f'{k}={v:.2f}' for k, v in result.timings.items())}",
bl = _buildup_line(problem, stack)
if bl:
lines.append(bl)
lines.append("layers (top to bottom):")
lines += _layer_lines(problem, result)
lines += [""] + _solver_lines(stack, result) + [
"",
f"contact model: {result.contact_model}"
+ (" (uniform orthogonal injection; R is the upper contact bound)"
@@ -146,19 +196,121 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
tag = f"{'P' if sign == '+' else 'N'}{i + 1}"
lines.append(f" {tag:4s} {label:24s} {amps:9.4g} A "
f"({100 * amps / result.i_test:5.1f}%)")
if result.via_reports:
n_shown = min(10, len(result.via_reports))
lines += [
"",
f"vias/pads carrying current (top {n_shown} of "
f"{len(result.via_reports)}, @ {result.i_test:g} A):",
" x [mm] y [mm] kind drill I [A] P [W]",
]
for v in result.via_reports[:n_shown]:
lines.append(
f" {v.x_mm:8.2f} {v.y_mm:8.2f} {v.kind:5s} "
f"{v.drill_mm:5.2f} {v.current_a:8.4g} {v.power_w:.4g}"
)
return lines + _via_lines(result)
def _summary_pdn_lines(head: str, problem: Problem, stack: RasterStack,
result: Result) -> list:
p_src = result.P_total + result.P_supply_internal + result.P_loads
lines = [
head,
"=" * len(head),
f"board: {problem.board_path}",
f"net: {problem.net_name}",
f"mode: PDN ({len(result.supplies)} supplies / "
f"{len(result.loads)} loads)",
f"total load draw: {result.i_test:g} A",
f"nominal voltage: {result.v_nominal:g} V "
f"(default v_oc; per-supply v_oc overrides)",
f"resistivity: {problem.rho_ohm_m:.3e} ohm*m",
f"via plating: {problem.plating_nm / 1000:.0f} um",
"",
("frequency: "
+ (f"{result.freq_hz:g} Hz (skin depth {result.skin_depth_um:.0f} um)"
if result.freq_hz > 0 else "DC")),
f"COPPER LOSS: {result.P_total:.6g} W",
f" in vias: {result.P_vias:.4g} W",
f" in supply R_out: {result.P_supply_internal:.4g} W",
f" load power: {result.P_loads:.6g} W",
f" source power: {p_src:.6g} W",
f" power balance: {result.power_balance_rel:.2e} (consistency)",
]
if result.freq_hz > 0:
lines.append(
" NOTE: AC PDN assumes all load draws are IN PHASE (worst "
"case; skin resistance only - no proximity, no inductance)")
# terminal LABELS are unique (validated) and are the one key used
# everywhere - no extra positional tags, which would only collide
# with auto-names like "S1"/"L1"
def _term_note(component, comment):
parts = ([component] if component else []) \
+ ([f"# {comment}"] if comment else [])
return (" " + " ".join(parts)) if parts else ""
lines += ["", "supplies:",
" label v_oc [V] r_out [ohm]"
" I [A] V [V] P_int [W] component / # comment"]
for s_ in result.supplies:
lines.append(
f" {s_.label:28s} {s_.v_oc:8.4g} "
f"{s_.r_out_ohm:11.4g} {s_.i_a:8.4g} {s_.v_contact:8.5g} "
f"{s_.p_internal_w:9.4g}"
+ _term_note(s_.component, s_.comment))
if len(s_.part_currents) > 1:
for pl, amps in s_.part_currents:
lines.append(f" - {pl:24s} {amps:9.4g} A")
# drops are quoted against the highest open-circuit voltage: the
# reference a supply designer compares regulation against
v_ref = max((s_.v_oc for s_ in result.supplies),
default=result.v_nominal or 0.0)
lines += ["", "loads:",
" label I [A] V_mean [V]"
" V_min [V] drop [mV] P [W] component / # comment"]
for l_ in result.loads:
lines.append(
f" {l_.label:28s} {l_.i_a:7.4g} "
f"{l_.v_mean:9.5g} {l_.v_min:9.5g} "
f"{(v_ref - l_.v_mean) * 1000:9.4g} {l_.p_w:8.4g}"
+ _term_note(l_.component, l_.comment))
if len(l_.part_currents) > 1:
for pl, amps in l_.part_currents:
lines.append(f" - {pl:24s} {amps:9.4g} A")
if result.pairs:
lines += ["", "source-sink pairs (R: effective copper "
"resistance between the two contacts, "
"operating-point independent, source R_out "
"excluded; current/loss attributed by "
"proportional sharing - a convention, but exact "
"in total):",
" pair "
"R [ohm] I_attr [A] P_attr [W]"]
for pr in result.pairs:
name = f"{pr.supply} -> {pr.load}"
r_txt = (f"{pr.r_ohm:10.4g}" if pr.r_ohm is not None
else " no path")
lines.append(f" {name:38s} {r_txt} {pr.i_share_a:10.4g}"
f" {pr.p_w:10.4g}")
p_attr = sum(pr.p_w for pr in result.pairs)
lines.append(f" attributed copper loss total: {p_attr:.6g} W "
f"(copper loss {result.P_total:.6g} W)")
lines.append("")
bl = _buildup_line(problem, stack)
if bl:
lines.append(bl)
lines.append("layers (top to bottom):")
lines += _layer_lines(problem, result)
lines += [""] + _solver_lines(stack, result) + [
"",
"contact model: PDN (fixed: Thevenin supplies / "
"uniform-injection loads)",
"terminals:",
]
for t in problem.terminals:
lines.append(f" {t.role} '{t.label}' "
f"({len(t.electrodes)} contact part(s)"
+ (", bonded: per-part split is computed"
if t.bonded else "") + "):")
lines += [f" {_electrode_line(e)}" for e in t.electrodes]
return lines + _via_lines(result)
def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
result: Result) -> Path:
head = f"fill_resistance {__version__} summary"
if result.mode == "pdn":
lines = _summary_pdn_lines(head, problem, stack, result)
else:
lines = _summary_classic_lines(head, problem, stack, result)
p = outdir / "summary.txt"
p.write_text("\n".join(lines), encoding="utf-8")
return p
+19
View File
@@ -73,6 +73,25 @@ def normalize_decimal(text: str) -> str:
return text
_SI_SUFFIXES = {"p": 1e-12, "n": 1e-9, "u": 1e-6, "µ": 1e-6, "μ": 1e-6,
"m": 1e-3, "k": 1e3, "K": 1e3, "M": 1e6, "G": 1e9}
def parse_engineering(text: str) -> float:
"""General value entry: '50m' -> 0.05, '4.7k' -> 4700, '2M' ->
2e6, '3,3' -> 3.3, '10' -> 10. A trailing SI suffix scales the
number - CASE decides between m (milli) and M (mega), unlike
parse_frequency, where a lone m can only mean MHz. Raises
ValueError on garbage or an ambiguous comma (normalize_decimal's
rules)."""
t = normalize_decimal(text.strip())
mult = 1.0
if t and t[-1] in _SI_SUFFIXES:
mult = _SI_SUFFIXES[t[-1]]
t = t[:-1].strip() # allow '50 m'
return float(t) * mult # ValueError on garbage
def parse_frequency(text: str) -> float:
"""'0', '100k', '1.5M', '142500' -> Hz; empty -> 0 (DC).
Raises ValueError on unparseable, ambiguous or negative input (a
+807 -60
View File
@@ -59,12 +59,19 @@ class SolveInfo:
residual: float | None = None
# via_index tag for PDN supply-attachment edges (virtual Thevenin node
# to contact cell): excluded from the in-plane fields (== -1) AND from
# the via power/reports (>= 0); their dissipation is P_supply_internal
PDN_EDGE = -2
@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
via_index: np.ndarray # int32; -1 = in-plane edge,
# PDN_EDGE = supply attachment
dead_barrels: int = 0 # barrels spanning >=2 layers that found
# fill copper on fewer than 2 of them
@@ -79,6 +86,59 @@ class ViaReport:
power_w: float # total barrel dissipation @ I_test
@dataclass
class SupplyReport:
"""One PDN supply after the solve. The delivered current is an
OUTCOME (Thevenin split), not an input."""
label: str
v_oc: float # open-circuit volts used in the solve
r_out_ohm: float
i_a: float # delivered current [A]
v_contact: float # mean volts over the contact cells
p_internal_w: float # dissipated inside r_out
part_currents: list = field(default_factory=list) # [(label, amps)]
v_eff: float = 0.0 # current-weighted contact volts: the
# potential the delivered power sees
# (= v_contact for ideal and bonded
# contacts); makes the pair-loss
# allocation sum EXACTLY to the
# copper dissipation
component: str = "" # display: terminal's owner hint
comment: str = "" # display: terminal's free-text note
@dataclass
class LoadReport:
"""One PDN load after the solve. The draw is prescribed; the contact
voltage is the outcome of interest."""
label: str
i_a: float # prescribed draw [A]
v_mean: float # mean volts over the contact cells
v_min: float # worst-case contact cell
p_w: float # i_a * v_mean (exact: injection and
# averaging weights coincide)
part_currents: list = field(default_factory=list) # [(label, amps)]
component: str = "" # display: terminal's owner hint
comment: str = "" # display: terminal's free-text note
@dataclass
class PairReport:
"""One (supply, load) pair: the effective COPPER resistance between
the two contacts (source internals excluded; injection patterns as
in the solve - uniform per cell, or the bonded lug) and the copper
loss attributed to the pair by PROPORTIONAL SHARING
(f_ij = I_i * I_j / I_component, P_ij = f_ij * (v_eff_i - v_mean_j)).
The attribution is a convention, not unique physics - but it sums
exactly to the total copper dissipation, and R is an operating-
point-independent property of the board."""
supply: str
load: str
r_ohm: float | None # None: no common copper path
i_share_a: float # attributed current [A]
p_w: float # attributed copper loss [W]
@dataclass
class Result:
R_ohm: float
@@ -106,6 +166,18 @@ class Result:
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)
# --- PDN mode (mode == "pdn"; classic solves leave these empty) ---
# R_ohm is NaN there (no single two-terminal R); i_test carries the
# summed load draw so %-of-total displays keep working; fields V/
# Jmag/Parea are in ABSOLUTE volts / real operating current
mode: str = "classic" # "classic" | "pdn"
supplies: list = field(default_factory=list) # [SupplyReport]
loads: list = field(default_factory=list) # [LoadReport]
P_loads: float = 0.0 # sum of load powers [W]
P_supply_internal: float = 0.0 # sum of r_out dissipation
v_nominal: float | None = None # default supply v_oc used
pairs: list = field(default_factory=list) # [PairReport], every
# supply x load
def _shifts2d():
@@ -288,20 +360,116 @@ def connected_restrict(stack: RasterStack, e1: np.ndarray, e2: np.ndarray,
return changed, len(common)
def _assemble(state: np.ndarray, edges: Edges, rhs_extra: np.ndarray | None):
def _pdn_keep_components(terminals: list, per_term: list) -> set:
"""The PDN component keep rule + its diagnostics, on the label sets
each terminal's contact touches (shared by the cell graph here and
the adaptive leaf graph). Keep components holding >= 1 supply AND
(>= 1 load OR >= 2 supplies); errors for unreachable / sheet-
spanning loads, notes for sheet-spanning supplies."""
n_sup: dict = {}
has_load: set = set()
for t, labs in zip(terminals, per_term):
if t.role == "supply":
for l in labs:
n_sup[l] = n_sup.get(l, 0) + 1
else:
has_load |= labs
kept = {l for l, c in n_sup.items() if l in has_load or c >= 2}
if not kept:
raise ConnectivityError(
"No copper component connects a supply to a load (not even "
"through vias). Check the layer selection, the terminal "
"definitions and that the fills are up to date."
)
for t, labs in zip(terminals, per_term):
if t.role != "load":
continue
kl = labs & kept
if not kl:
raise ConnectivityError(
f"Load '{t.label}' sits on copper that is not connected "
f"to any supply (not even through vias)."
)
if len(kl) > 1:
if t.bonded:
# the external bond IS the connection: the split
# between the sheets is well-defined through the lug
print(f"note: bonded load '{t.label}' spans {len(kl)} "
f"disconnected copper sheets; the split between "
f"them is set by its external bond")
continue
raise ConnectivityError(
f"Load '{t.label}' spans {len(kl)} disconnected copper "
f"sheets - the current split between them is undefined "
f"with per-cell injection. Include the layers/vias that "
f"join them, mark the load as bonded, or split it into "
f"one terminal per sheet."
)
for t, labs in zip(terminals, per_term):
if t.role == "supply" and len(labs & kept) > 1:
print(f"note: supply '{t.label}' feeds {len(labs & kept)} "
f"disconnected copper sheets; the split between them "
f"is set by its output resistance (Thevenin)")
return kept
def connected_restrict_multi(stack: RasterStack, term_masks: list,
terminals: list, edges: Edges
) -> tuple[bool, int]:
"""PDN connectivity restriction: keep copper components holding
>= 1 supply AND (>= 1 load OR >= 2 supplies) - the second clause
keeps circulating-current paths between paralleled supplies with
unequal v_oc. A load on copper reachable from no supply is an
error; so is a load spanning several kept components (its uniform
injection cannot decide the split between disconnected sheets). A
load merely LOSING cells to dropped copper is fine: those cells
could not carry current anyway, the draw renormalizes over the
rest. Mutates stack.masks and the term_masks. Returns (changed,
n_kept_components)."""
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)
per_term = [set(np.unique(labels3[m]).tolist()) if m.any() else set()
for m in term_masks]
kept = _pdn_keep_components(terminals, per_term)
keep = np.isin(labels3, sorted(kept)) & stack.masks
changed = bool((stack.masks & ~keep).any())
stack.masks &= keep
for t, m in zip(terminals, term_masks):
had = bool(m.any())
m &= keep
if t.role == "supply" and had and not m.any():
print(f"warning: supply '{t.label}' only touches copper not "
f"connected to any load - it delivers 0 A")
return changed, len(kept)
def _assemble(state: np.ndarray, edges: Edges, rhs_extra: np.ndarray | None,
dirichlet_v: np.ndarray | None = 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."""
rhs_extra: per-flat-cell current injection [A] added for free cells.
dirichlet_v (PDN mode): per-node Dirichlet volts - any state >= 2
is then held at dirichlet_v[node] instead of the fixed 1 V / 0 V
pair, and the direct-connection short check is skipped (edges
between Dirichlet nodes simply conduct; their currents come out of
the post-solve edge fluxes). None keeps the classic behavior
bit-for-bit."""
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."
)
if dirichlet_v is None:
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 "
f"{int(short.sum())} conductance(s) ({n_via} via "
f"barrel(s)) without any free copper in between - move "
f"the contacts apart."
)
free = state == 1
n_free = int(free.sum())
@@ -318,10 +486,18 @@ def _assemble(state: np.ndarray, edges: Edges, rhs_extra: np.ndarray | None):
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 dirichlet_v is None:
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])
else:
r1a = fa & (sb >= 2)
r1b = fb & (sa >= 2)
np.add.at(rhs, idx[edges.a[r1a]],
edges.w[r1a] * dirichlet_v[edges.b[r1a]])
np.add.at(rhs, idx[edges.b[r1b]],
edges.w[r1b] * dirichlet_v[edges.a[r1b]])
if rhs_extra is not None:
rhs += rhs_extra[free]
@@ -605,6 +781,71 @@ def _part_currents(parts, Ie, edges, e_flat, scale,
return out
def _postprocess_fields(problem: Problem, stack: RasterStack, edges: Edges,
Vflat: np.ndarray, s: float, sigmas: list[float],
sigma_buildup: float):
"""Edge powers, per-layer dissipation, via reports and the V/J/P
display fields on the uniform grid - shared verbatim by the classic
and PDN solves. PDN appends virtual supply nodes after the grid
ids: everything here slices Vflat back to the grid (a no-op view
for classic) and selects in-plane edges by via_index == -1 / via
barrels by >= 0, so PDN_EDGE attachment edges stay out of the
copper fields and the via reports. Returns (Pe, Ie, P_layers,
P_vias, Parea, via_reports, V3, J3): Pe is s^2-scaled, Ie is at the
drive of Vflat (unit drive classic, absolute volts PDN); via report
currents are s-scaled here."""
L, ny, nx = stack.masks.shape
h_m = stack.h_nm * 1e-9
n_grid = stack.masks.size
# 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 == -1
Pflat = np.zeros(n_grid)
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[edges.via_index >= 0].sum())
# via reports: max segment current + total power per via
Ie = edges.w * (Vflat[edges.a] - Vflat[edges.b])
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)
# embedded potential + per-layer current density @ I_test; chain
# cells have no sheet faces in the model, so keep them out of the
# face computation and overlay their true 1D link density instead
V3 = np.full((L, ny, nx), np.nan)
V3[stack.masks] = Vflat[:n_grid].reshape(L, ny, nx)[stack.masks] * s
sheet = stack.masks if stack.chain is None \
else stack.masks & ~stack.chain
J3 = np.stack([
_face_current_density(
np.nan_to_num(V3[li]), sheet[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)
])
overlay_chain_density(stack, problem.rho_ohm_m, V3, J3)
return Pe, Ie, P_layers, P_vias, Parea, via_reports, V3, J3
def _conductance_params(problem: Problem, stack: RasterStack,
freq_hz: float):
"""Effective (possibly AC) sheet conductances per layer, Rs ratios,
@@ -711,18 +952,9 @@ def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
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())
Pe, Ie, P_layers, P_vias, Parea, via_reports, V3, J3 = \
_postprocess_fields(problem, stack, edges, Vflat, s, sigmas,
sigma_buildup)
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:
@@ -733,22 +965,6 @@ def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
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,
@@ -756,23 +972,6 @@ def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
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; chain
# cells have no sheet faces in the model, so keep them out of the
# face computation and overlay their true 1D link density instead
V3 = np.full((L, ny, nx), np.nan)
V3[stack.masks] = Vflat.reshape(L, ny, nx)[stack.masks] * s
sheet = stack.masks if stack.chain is None \
else stack.masks & ~stack.chain
J3 = np.stack([
_face_current_density(
np.nan_to_num(V3[li]), sheet[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)
])
overlay_chain_density(stack, problem.rho_ohm_m, V3, J3)
timings["postprocess_s"] = time.perf_counter() - t0
return Result(
@@ -790,3 +989,551 @@ def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
rs_ratios=rs_ratios,
timings=timings,
)
# --- PDN mode ---------------------------------------------------------------
#
# N supplies + M loads instead of one driven terminal pair, solved in
# ABSOLUTE volts (no unit-drive rescale). Each supply is a Thevenin
# source: a virtual node held Dirichlet at v_oc, attached to its
# contact cells through 1/(r_out * n_cells) each (sum = 1/r_out) -
# because Dirichlet nodes are eliminated, virtual nodes never enter the
# matrix, they only shift the diagonal/RHS of their contact cells and
# the system stays SPD. r_out <= PDN_R_OUT_EPS degrades to a direct
# Dirichlet contact (the exact limit). Each load draws its prescribed
# current with uniform orthogonal injection (-I/n per cell), the same
# semantics as the classic "uniform" contact model. Supply currents are
# OUTCOMES (the Thevenin split); KCL makes them sum to the load draws.
def _label_terminals(terminals: list) -> None:
"""Assign S1/L1-style display tags to unlabeled terminals (in
definition order, per role)."""
ns = nl = 0
for t in terminals:
if t.role == "supply":
ns += 1
if not t.label:
t.label = f"S{ns}"
else:
nl += 1
if not t.label:
t.label = f"L{nl}"
def _validate_terminals(terminals: list) -> None:
for t in terminals:
if t.role not in ("supply", "load"):
raise ElectrodeError(
f"Terminal '{t.label}': unknown role '{t.role}' "
f"(expected 'supply' or 'load')."
)
if t.role == "load" and t.i_draw_a < 0:
raise ElectrodeError(
f"Load '{t.label}': i_draw_a must be >= 0 "
f"(got {t.i_draw_a:g})."
)
if t.role == "supply" and t.r_out_ohm < 0:
raise ElectrodeError(
f"Supply '{t.label}': r_out_ohm must be >= 0 "
f"(got {t.r_out_ohm:g})."
)
if not any(t.role == "supply" for t in terminals):
raise ElectrodeError("PDN mode needs at least one supply terminal.")
if not any(t.role == "load" for t in terminals):
raise ElectrodeError("PDN mode needs at least one load terminal.")
@dataclass
class _Attach:
"""How one supply is wired into the extended graph."""
v_oc: float
r_out: float
nodes: np.ndarray # contact node ids (base space)
ideal: bool # r_out below eps: direct Dirichlet
e0: int = 0 # its attachment edges in edges_ext
e1: int = 0 # (empty slice for ideal supplies)
def _pdn_attach(terminals: list, term_nodes: list, state: np.ndarray,
edges: Edges, v_nominal: float):
"""Extend the copper graph with the PDN boundary conditions. state
is uint8 over the base node space (1 = copper); term_nodes carries
each terminal's contact node ids in that space (uniform grid: flat
cell ids; adaptive: leaf ids - contact cells are pinned fine there,
so nodes and cells are 1:1 and per-node injection equals per-cell).
A BONDED terminal shorts all its contact cells into one super-node:
`merge` maps every node id to its representative (identity outside
bonded terminals; None when no terminal is bonded). The caller
solves on merge-relabeled edges (member cells leave the system,
state 0) and afterwards scatters the potentials back with
Vflat = Vflat[merge], so all extraction runs on the ORIGINAL edge
endpoints where internal member-member edges carry exactly zero.
Returns (state_ext, dirichlet_v, inj, edges_ext, attaches, merge)
with virtual supply nodes appended after state.size."""
n_base = state.size
n_virt = sum(1 for t, nd in zip(terminals, term_nodes)
if t.role == "supply" and len(nd)
and t.r_out_ohm > config.PDN_R_OUT_EPS)
n_ext = n_base + n_virt
state_ext = np.zeros(n_ext, dtype=np.uint8)
state_ext[:n_base] = state
dirichlet_v = np.zeros(n_ext)
inj = np.zeros(n_ext)
merge = None
def bond(nodes):
"""Short the cells to nodes[0]; members leave the system."""
nonlocal merge
if merge is None:
merge = np.arange(n_ext, dtype=np.int64)
rep = int(nodes[0])
merge[nodes] = rep
state_ext[nodes] = 0
return rep
aa = [edges.a]
bb = [edges.b]
ww = [edges.w]
vv = [edges.via_index]
attaches: list = []
nv = 0
e_next = len(edges.a)
for t, nodes in zip(terminals, term_nodes):
if t.role != "supply":
attaches.append(None)
if len(nodes):
if t.bonded:
rep = bond(nodes)
state_ext[rep] = 1
inj[rep] -= t.i_draw_a # whole draw at the lug
else:
inj[nodes] -= t.i_draw_a / len(nodes)
continue
v = v_nominal if t.v_oc is None else t.v_oc
at = _Attach(v_oc=v, r_out=t.r_out_ohm, nodes=nodes,
ideal=t.r_out_ohm <= config.PDN_R_OUT_EPS)
attaches.append(at)
if len(nodes) == 0:
continue # restricted away: reports 0 A
if t.bonded:
rep = bond(nodes)
if at.ideal:
state_ext[rep] = 2
dirichlet_v[rep] = v
else:
state_ext[rep] = 1
vid = n_base + nv
nv += 1
state_ext[vid] = 2
dirichlet_v[vid] = v
# the whole r_out in series with the equipotential lug
aa.append(np.array([vid], dtype=np.int64))
bb.append(np.array([rep], dtype=np.int64))
ww.append(np.array([1.0 / t.r_out_ohm]))
vv.append(np.array([PDN_EDGE], dtype=np.int32))
at.e0, at.e1 = e_next, e_next + 1
e_next += 1
elif at.ideal:
state_ext[nodes] = 2
dirichlet_v[nodes] = v
else:
vid = n_base + nv
nv += 1
state_ext[vid] = 2
dirichlet_v[vid] = v
k = len(nodes)
# oriented virtual -> cell so Ie = w * (v_oc - V_cell) is
# the delivered current, positive out of the supply
aa.append(np.full(k, vid, dtype=np.int64))
bb.append(nodes.astype(np.int64))
ww.append(np.full(k, 1.0 / (t.r_out_ohm * k)))
vv.append(np.full(k, PDN_EDGE, dtype=np.int32))
at.e0, at.e1 = e_next, e_next + k
e_next += k
edges_ext = Edges(a=np.concatenate(aa), b=np.concatenate(bb),
w=np.concatenate(ww), via_index=np.concatenate(vv),
dead_barrels=edges.dead_barrels)
return state_ext, dirichlet_v, inj, edges_ext, attaches, merge
def _pdn_solve_edges(edges_ext: Edges, merge) -> Edges:
"""The edge set the linear system is assembled from: bonded
terminals' member cells relabeled to their representative (edge
ORDER and COUNT are preserved - only endpoints move; internal
edges become self-loops, which cancel exactly in the COO
assembly). Identity when nothing is bonded."""
if merge is None:
return edges_ext
return Edges(a=merge[edges_ext.a], b=merge[edges_ext.b],
w=edges_ext.w, via_index=edges_ext.via_index,
dead_barrels=edges_ext.dead_barrels)
def _pdn_extract(terminals: list, term_nodes: list, attaches: list,
Vflat: np.ndarray, Ie: np.ndarray, edges_ext: Edges,
term_part_nodes: list):
"""Per-supply delivered currents and per-load voltages/powers from
the solved potentials, shared by the uniform-grid and adaptive PDN
paths (Ie must satisfy KCL - the corrected currents on the adaptive
path; bonded terminals' Vflat already scattered back, so their
internal edges carry exactly zero). Returns (supplies, loads)."""
n_ext = Vflat.size
copper = edges_ext.via_index != PDN_EDGE
def part_flux_out(pn):
"""Oriented copper-edge flux out of a part's cells: the part's
boundary current (same-terminal internal edges carry zero for
Dirichlet and bonded contacts; attachment edges excluded so a
bonded supply's lug edge is not double-counted)."""
pm = np.zeros(n_ext, dtype=bool)
pm[pn] = True
return float(Ie[pm[edges_ext.a] & copper].sum()
- Ie[pm[edges_ext.b] & copper].sum())
supplies: list = []
loads: list = []
for t, nodes, at, pnodes in zip(terminals, term_nodes, attaches,
term_part_nodes):
if t.role == "supply":
if len(at.nodes) == 0:
supplies.append(SupplyReport(
label=t.label, v_oc=at.v_oc, r_out_ohm=at.r_out,
i_a=0.0, v_contact=at.v_oc, p_internal_w=0.0,
part_currents=[(pl, 0.0) for pl, _ in pnodes],
v_eff=at.v_oc, component=t.component,
comment=t.comment))
continue
if at.ideal:
# exact discrete flux out of the Dirichlet contact
# (edges inside the contact cancel a-side vs b-side)
member = np.zeros(n_ext, dtype=bool)
member[at.nodes] = True
i_a = float(Ie[member[edges_ext.a] & copper].sum()
- Ie[member[edges_ext.b] & copper].sum())
v_contact = at.v_oc
v_eff = at.v_oc
p_int = 0.0
pcs = [(pl, part_flux_out(pn)) for pl, pn in pnodes]
else:
sl = slice(at.e0, at.e1)
i_a = float(Ie[sl].sum())
v_contact = float(Vflat[at.nodes].mean())
dv = Vflat[edges_ext.a[sl]] - Vflat[edges_ext.b[sl]]
p_int = float((edges_ext.w[sl] * dv * dv).sum())
if t.bonded:
# one lug edge carries the total; the per-part
# split is the copper boundary flux (an outcome)
v_eff = v_contact # equipotential lug
pcs = [(pl, part_flux_out(pn)) for pl, pn in pnodes]
else:
cells = edges_ext.b[sl]
# the potential the delivered power actually sees:
# per-cell currents weight their cell potentials
v_eff = (float((Ie[sl] * Vflat[cells]).sum() / i_a)
if abs(i_a) > 1e-300 else v_contact)
pcs = []
for pl, pn in pnodes:
pm = np.zeros(n_ext, dtype=bool)
pm[pn] = True
pcs.append((pl, float(Ie[sl][pm[cells]].sum())))
supplies.append(SupplyReport(
label=t.label, v_oc=at.v_oc, r_out_ohm=at.r_out,
i_a=i_a, v_contact=v_contact, p_internal_w=p_int,
part_currents=pcs, v_eff=v_eff,
component=t.component, comment=t.comment))
else:
v = Vflat[nodes]
n_k = len(nodes)
if t.bonded:
# split by the network through the external bond
pcs = [(pl, -part_flux_out(pn)) for pl, pn in pnodes]
else:
pcs = [(pl, t.i_draw_a * len(pn) / max(n_k, 1))
for pl, pn in pnodes]
loads.append(LoadReport(
label=t.label, i_a=t.i_draw_a,
v_mean=float(v.mean()), v_min=float(v.min()),
p_w=t.i_draw_a * float(v.mean()), part_currents=pcs,
component=t.component, comment=t.comment))
return supplies, loads
def _pdn_balance(supplies: list, loads: list, P_layers: list,
P_vias: float) -> tuple[float, float, float, float, float]:
"""Generalized power balance (Tellegen): source power = copper
dissipation + R_out dissipation + load power, exactly, independent
of the voltage reference (supply and load currents sum equal).
Returns (balance_rel, mismatch_rel, i_sup, i_loads, p_loads_rout)
or raises SolverError; a zero-power probe run (no draws, equal
v_oc) has nothing to balance and reports 0."""
i_loads = sum(l.i_a for l in loads)
i_sup = sum(s_.i_a for s_ in supplies)
p_rout = sum(s_.p_internal_w for s_ in supplies)
p_loads = sum(l.p_w for l in loads)
p_src = sum(s_.v_oc * s_.i_a for s_ in supplies)
# gross scale, not |net|: with circulating currents between supplies
# the net source power nearly cancels while watts really flow - the
# residual must be judged against what flows, or the check trips on
# pure floating-point cancellation
p_gross = sum(abs(s_.v_oc * s_.i_a) for s_ in supplies)
p_sink = sum(P_layers) + P_vias + p_rout + p_loads
vocs = [s_.v_oc for s_ in supplies]
if i_loads == 0.0 and max(vocs) == min(vocs):
balance = 0.0
else:
balance = abs(p_src - p_sink) / max(p_gross, 1e-300)
if not np.isfinite(balance) or balance > 1e-3:
raise SolverError(
f"Inconsistent PDN solve: power-balance error "
f"{balance:.2e} (sources {p_src:.6g} W vs copper + "
f"R_out + loads {p_sink:.6g} W). The result is not "
f"trustworthy - try a different grid size."
)
i_scale = max(abs(i_loads),
max((abs(s_.i_a) for s_ in supplies), default=0.0),
1e-300)
mismatch = abs(i_sup - i_loads) / i_scale
return balance, mismatch, i_sup, i_loads, p_loads
def _pdn_pairs(terminals: list, term_nodes: list, attaches: list,
merge, state_base: np.ndarray, edges: Edges,
supplies: list, loads: list, make_solver) -> list:
"""Effective copper resistance between every supply and every load,
plus the proportional-sharing loss allocation (see PairReport).
The pair network is the COPPER alone - attachment resistances and
Thevenin sources stripped. Contact patterns mirror the solve's
models: loads and resistive supplies inject uniformly per cell,
bonded terminals and ideal supplies are equipotential super-nodes.
R_ij = (p_i - p_j)^T L_g^{-1} (p_i - p_j) costs ONE extra solve per
terminal (same factorization; one node per connected component is
grounded - the balanced quadratic form is ground-independent).
Pairs without a common copper component get r_ohm None and no
allocation; the allocation splits each component's copper loss by
f_ij = I_i * I_j / I_loads_of_that_component, which sums exactly
to the total copper dissipation.
make_solver(state_g, dirichlet_v, edges_pm, pmerge) -> solve(inj)
-> V lets the adaptive path plug in its deferred-correction loop
(pmerge is the pair system's own node-merge map, or None)."""
n_base = state_base.size
idx0 = np.arange(n_base, dtype=np.int64)
# pair-system merge: the solve's bonded lugs plus every ideal
# supply contact (a Dirichlet region is equipotential too)
pmerge = idx0.copy() if merge is None else merge[:n_base].copy()
for t, at, nodes in zip(terminals, attaches, term_nodes):
if (t.role == "supply" and at is not None and at.ideal
and not t.bonded and len(nodes)):
pmerge[nodes] = pmerge[int(nodes[0])]
if bool((pmerge == idx0).all()):
pmerge = None
state_g = state_base.copy()
if pmerge is not None:
state_g[pmerge != idx0] = 0
edges_pm = Edges(a=pmerge[edges.a], b=pmerge[edges.b],
w=edges.w, via_index=edges.via_index,
dead_barrels=edges.dead_barrels)
else:
edges_pm = edges
pats = []
for t, at, nodes in zip(terminals, attaches, term_nodes):
if len(nodes) == 0:
pats.append(None)
continue
p = np.zeros(n_base)
if (t.bonded or (t.role == "supply" and at is not None
and at.ideal)):
rep = (int(pmerge[nodes[0]]) if pmerge is not None
else int(nodes[0]))
p[rep] = 1.0
else:
p[nodes] = 1.0 / len(nodes)
pats.append(p)
graph = sparse.coo_matrix(
(np.ones(len(edges_pm.a)), (edges_pm.a, edges_pm.b)),
shape=(n_base, n_base))
_, labels = csgraph.connected_components(graph, directed=False)
tcomp = []
for p in pats:
if p is None:
tcomp.append(None)
continue
cs = set(labels[np.flatnonzero(p)].tolist())
# a terminal spread over several components has no single
# pair resistance - its pairs report "no path"
tcomp.append(cs.pop() if len(cs) == 1 else None)
dv = np.zeros(n_base)
grounds: set = set()
for c, p in zip(tcomp, pats):
if c is not None and c not in grounds:
state_g[int(np.flatnonzero(p)[0])] = 2
grounds.add(c)
if not grounds:
return []
progress.stage("source-sink pair resistances ...")
solve = make_solver(state_g, dv, edges_pm, pmerge)
us = [solve(p) if p is not None and c is not None else None
for p, c in zip(pats, tcomp)]
# per-component load current: proportional sharing never crosses a
# copper gap (nothing flows between disconnected sheets)
comp_load_i: dict = {}
lidx = -1
for j, tj in enumerate(terminals):
if tj.role != "load":
continue
lidx += 1
if tcomp[j] is not None:
comp_load_i[tcomp[j]] = (comp_load_i.get(tcomp[j], 0.0)
+ loads[lidx].i_a)
rows: list = []
sidx = -1
for i, ti in enumerate(terminals):
if ti.role != "supply":
continue
sidx += 1
s = supplies[sidx]
lidx = -1
for j, tj in enumerate(terminals):
if tj.role != "load":
continue
lidx += 1
ld = loads[lidx]
same = tcomp[i] is not None and tcomp[i] == tcomp[j]
r = None
f = 0.0
if same:
r = float(pats[i] @ us[i] + pats[j] @ us[j]
- 2.0 * (pats[i] @ us[j]))
i_tot = comp_load_i.get(tcomp[i], 0.0)
if i_tot > 0.0:
f = s.i_a * ld.i_a / i_tot
rows.append(PairReport(
supply=s.label, load=ld.label, r_ohm=r,
i_share_a=f, p_w=f * (s.v_eff - ld.v_mean)))
return rows
def run_solve_pdn(problem: Problem, stack: RasterStack, term_masks: list,
term_parts: list, freq_hz: float = 0.0,
v_nominal: float | None = None) -> Result:
"""PDN solve on the uniform grid (adaptive dispatch on top, like
run_solve). term_masks/term_parts come from raster.terminal_masks /
terminal_partition, aligned with problem.terminals. Contact models
are fixed: Thevenin supplies, uniform-injection loads."""
if v_nominal is None:
v_nominal = config.PDN_V_NOMINAL
terminals = problem.terminals
_label_terminals(terminals)
_validate_terminals(terminals)
if config.ADAPTIVE_CELLS:
from . import adaptive
return adaptive.run_solve_adaptive_pdn(problem, stack, term_masks,
term_parts, freq_hz,
v_nominal)
timings = {}
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_kept = connected_restrict_multi(stack, term_masks,
terminals, 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 stack.buildup is not None:
stack.buildup &= stack.masks
if stack.chain is not None:
stack.chain &= stack.masks
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
t0 = time.perf_counter()
state = np.zeros(stack.masks.size, dtype=np.uint8)
state[stack.masks.ravel()] = 1
term_nodes = [np.flatnonzero(m.ravel()) for m in term_masks]
state_base = state.copy() # copper-only state for _pdn_pairs
state, dirichlet_v, inj, edges_ext, attaches, merge = _pdn_attach(
terminals, term_nodes, state, edges, v_nominal)
A, rhs, _ = _assemble(state, _pdn_solve_edges(edges_ext, merge),
inj, dirichlet_v)
x, info = solve_system(A, rhs)
Vflat = np.where(state >= 2, dirichlet_v, 0.0)
Vflat[state == 1] = x
if merge is not None:
Vflat = Vflat[merge] # bonded members read their lug
timings["solve_s"] = time.perf_counter() - t0
t0 = time.perf_counter()
Pe, Ie, P_layers, P_vias, Parea, via_reports, V3, J3 = \
_postprocess_fields(problem, stack, edges_ext, Vflat, 1.0, sigmas,
sigma_buildup)
term_part_nodes = [
[(pl, np.flatnonzero(m.ravel())) for pl, m in parts]
for parts in term_parts]
supplies, loads = _pdn_extract(terminals, term_nodes, attaches, Vflat,
Ie, edges_ext, term_part_nodes)
balance, mismatch, i_sup, i_loads, p_loads = _pdn_balance(
supplies, loads, P_layers, P_vias)
timings["postprocess_s"] = time.perf_counter() - t0
t0 = time.perf_counter()
def _pair_solver(state_g, dv, edges_pm, pmerge):
A2, rhs0p, _ = _assemble(state_g, edges_pm, None, dv)
ps2 = PreparedSolver(A2)
freeg = state_g == 1
def slv(inj_p):
x2, _ = ps2.solve(rhs0p + inj_p[freeg])
V = np.where(state_g >= 2, dv, 0.0)
V[freeg] = x2
return V
return slv
pairs = _pdn_pairs(terminals, term_nodes, attaches, merge,
state_base, edges, supplies, loads, _pair_solver)
timings["pairs_s"] = time.perf_counter() - t0
return 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,
)
+48 -7
View File
@@ -16,19 +16,25 @@ from pathlib import Path
from . import config, pipeline, progress
from .errors import UserFacingError
from .geometry import load_problem
from .skin import parse_frequency
from .skin import parse_engineering, parse_frequency
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("dump", type=Path, help="geometry_dump.json from a plugin run")
ap.add_argument("--current", type=float, default=None,
help="test current [A] (default: config TEST_CURRENT_A)")
ap.add_argument("--freq", type=parse_frequency, default=0.0,
ap.add_argument("--current", type=parse_engineering, default=None,
help="test current [A], SI suffixes ok (500m = 0.5) "
"(default: config TEST_CURRENT_A)")
ap.add_argument("--config", type=Path, default=None, metavar="JSON",
help="fill_res_config.json whose run parameters act "
"as defaults under the explicit flags here. Only "
"re-solve parameters apply - the dump already "
"bakes the geometry and physics")
ap.add_argument("--freq", type=parse_frequency, default=None,
help="frequency, e.g. 142k or 1.5M (default: DC). "
"Skin resistance only, a lower bound - not AC "
"impedance (no proximity, no inductance)")
ap.add_argument("--cell-um", type=float, default=None,
ap.add_argument("--cell-um", type=parse_engineering, default=None,
help="force grid cell size [um]")
ap.add_argument("--layers", type=str, default=None,
help="comma-separated subset of layers to include")
@@ -37,7 +43,12 @@ def main(argv=None) -> int:
ap.add_argument("--no-show", action="store_true",
help="save PNGs only, no windows")
ap.add_argument("--contact-model", choices=["uniform", "equipotential"],
default=None, help="contact model (default: config)")
default=None, help="contact model (default: config); "
"ignored for PDN dumps (models fixed there)")
ap.add_argument("--v-nominal", type=parse_engineering, default=None,
help="PDN dumps: default supply open-circuit voltage "
"[V] (default: config PDN_V_NOMINAL); supplies "
"with their own v_oc keep it")
ap.add_argument("--strip-buildup", action="store_true",
help="ignore solder buildup stored in the dump")
ap.add_argument("--uncapped", action="store_true",
@@ -62,6 +73,32 @@ def main(argv=None) -> int:
"uniform reference grid")
args = ap.parse_args(argv)
if args.config is not None:
from .configfile import load_config
try:
cfg = load_config(args.config)
except UserFacingError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 1
# config file values fill in only where no explicit flag was
# given (CLI wins); geometry/physics stay as baked into the dump
if args.current is None and cfg.current_a is not None:
args.current = cfg.current_a
if args.freq is None and cfg.freq_hz is not None:
args.freq = cfg.freq_hz
if args.cell_um is None and cfg.cell_um_given:
args.cell_um = cfg.cell_um
if args.adaptive is None and cfg.adaptive is not None:
args.adaptive = cfg.adaptive
if args.contact_model is None and cfg.contact_model is not None:
args.contact_model = cfg.contact_model
if args.v_nominal is None and cfg.v_nominal is not None:
args.v_nominal = cfg.v_nominal
if args.layers is None and cfg.layers:
args.layers = ",".join(cfg.layers)
if args.freq is None:
args.freq = 0.0
if args.cell_um is not None:
config.CELL_UM_OVERRIDE = args.cell_um
if args.no_show:
@@ -93,9 +130,13 @@ def main(argv=None) -> int:
if args.progress:
progress.start()
try:
if problem.terminals:
print(f"PDN dump: {len(problem.terminals)} terminals "
f"(supplies/loads from the dump; --current is ignored)")
pipeline.run(problem, outdir, show=not args.no_show,
i_test=args.current, freq_hz=args.freq,
contact_model=args.contact_model)
contact_model=args.contact_model,
v_nominal=args.v_nominal)
except progress.Cancelled:
print("cancelled")
return 1