Files
kicad-zone-resistance/fill_resistance/report.py
T
janikandClaude Fable 5 26b1cfaa45
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
Release 1.4.0: PDN mode, the config-file workflow, and the dialog editor
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>
2026-08-27 17:01:24 +07:00

317 lines
13 KiB
Python

"""Output directory, summary.txt, geometry dump, stdout one-liner."""
from __future__ import annotations
import tempfile
from datetime import datetime
from pathlib import Path
import numpy as np
from . import __version__, config
from .geometry import Problem, save_problem
from .raster import RasterStack
from .solver import Result
def make_output_dir(board_dir: Path) -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
board_dir = Path(board_dir)
out = board_dir / config.OUTPUT_DIRNAME / stamp
try:
out.mkdir(parents=True, exist_ok=True)
except OSError as e:
# The board can live somewhere unwritable - e.g. the demos
# folder on the mounted KiCad installer image (read-only, and
# how the first macOS field test was run). Results still have
# to land somewhere the figures/summary can be written.
out = (Path(tempfile.gettempdir()) / config.OUTPUT_DIRNAME
/ f"{board_dir.name}-{stamp}")
print(f"board directory not writable ({e}); saving results to "
f"{out}")
out.mkdir(parents=True, exist_ok=True)
return out
def write_geometry_dump(outdir: Path, problem: Problem) -> Path:
p = outdir / "geometry_dump.json"
save_problem(problem, p)
return p
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)}, "
f"grid {nx}x{ny}x{stack.nlayers}, cell {stack.h_nm / 1000:.0f} um)")
def _electrode_line(e) -> str:
r = e.rect
return (f"{e.label:12s} contact={e.contact:8s} "
f"x [{r.x0 / 1e6:.2f}, {r.x1 / 1e6:.2f}] "
f"y [{r.y0 / 1e6:.2f}, {r.y1 / 1e6:.2f}] mm")
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
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),
f"board: {problem.board_path}",
f"net: {problem.net_name}",
f"test current: {result.i_test:g} A",
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"RESISTANCE: {result.R_ohm * 1000:.6g} mOhm"
+ (" (SKIN-ONLY LOWER BOUND: no proximity/inductance - "
"not AC impedance)"
if result.freq_hz > 0 else ""),
f"VOLTAGE DROP: {result.R_ohm * result.i_test * 1000:.4g} mV "
f"@ {result.i_test:g} A",
f"TOTAL POWER: {result.P_total:.6g} W @ {result.i_test:g} A",
f" in vias: {result.P_vias:.4g} W",
f" power balance: {result.power_balance_rel:.2e} (consistency)",
"",
]
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)"
if result.contact_model == "uniform" else " (ideal bonded lug)"),
"terminals:",
f" V+ ({len(problem.electrodes1)} injection area(s)):",
*(f" {_electrode_line(e)}" for e in problem.electrodes1),
f" V- ({len(problem.electrodes2)} injection area(s)):",
*(f" {_electrode_line(e)}" for e in problem.electrodes2),
]
if result.part_currents1 or result.part_currents2:
how = ("prescribed by area share (uniform model)"
if result.contact_model == "uniform"
else "computed flux (equipotential model)")
lines += ["", f"current per injection area @ {result.i_test:g} A "
f"({how}):"]
for sign, pcs in (("+", result.part_currents1),
("-", result.part_currents2)):
for i, (label, amps) in enumerate(pcs):
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}%)")
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