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