Files
janik 23edb39f52
Build PCM package / build (push) Successful in 5s
Fix the two macOS field-test failures: backend order, RO board dir
Second Mac run (KiCad demo board opened from the mounted installer
image) got past the dialog and died twice:

1. make_output_dir crashed with OSError 30: the demos volume is a
   read-only filesystem. Now falls back to a temp directory named
   after the board, with the path printed (the Messages panel showed
   it is actually read there).

2. The error figure - and every other figure - could never render:
   matplotlib refused with Cannot load backend TkAgg ... as qt is
   currently running. macOS bundled Python ships tkinter, so the
   old tk-first probe picked TkAgg while the PySide6 dialog and
   progress window had already made this a Qt process. Windows never
   saw it because KiCad Python there has no tkinter. Qt now comes
   first - PySide6 is a hard dependency, so it is always there.

Both covered by tests that fail on the old code: the dev env has both
toolkits installed, so the backend-order assertion is exercised for
real, and the RO fallback is simulated by denying mkdir under the
board dir. 140 passed on the dev stack and on the Python 3.9 +
numpy 2.0 / scipy 1.13 / matplotlib 3.9 / PySide6 6.10 stack that a
Mac venv resolves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:29:22 +07:00

164 lines
7.1 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 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 "")
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 write_summary(outdir: Path, problem: Problem, stack: RasterStack,
result: Result) -> Path:
ny, nx = stack.shape2d
info = result.solve_info
lines = [
"fill_resistance summary",
"=======================",
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",
"",
(f"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)",
"",
"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())}",
"",
f"contact model: {result.contact_model}"
+ (" (uniform orthogonal injection; R is the upper contact bound)"
if result.contact_model == "uniform" else " (ideal bonded lug)"),
f"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}%)")
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}"
)
p = outdir / "summary.txt"
p.write_text("\n".join(lines), encoding="utf-8")
return p