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>
317 lines
13 KiB
Python
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
|