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
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:
+209
-57
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user