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
+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")