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>
677 lines
27 KiB
Python
677 lines
27 KiB
Python
"""Figures: per-layer rasterized maps, potential, current density, power
|
||
density, and the error figure. PNGs are saved BEFORE any window opens.
|
||
|
||
Backend: interactive if a GUI toolkit exists (Qt first, tkinter as a
|
||
fallback), else Agg with the OS default viewer on the saved PNGs so
|
||
results are never silent.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import textwrap
|
||
from pathlib import Path
|
||
|
||
import matplotlib
|
||
import numpy as np
|
||
|
||
|
||
def _pick_backend():
|
||
"""matplotlib.use() is lazy and 'succeeds' for backends whose GUI
|
||
toolkit is missing (KiCad's Windows Python has no tkinter), so probe
|
||
the toolkits explicitly. Qt MUST come first: PySide6 is a hard
|
||
dependency and the selection dialog / progress window put a Qt event
|
||
loop in this process, after which matplotlib refuses TkAgg
|
||
("Cannot load backend 'TkAgg' ... as 'qt' is currently running") -
|
||
exactly what happened on macOS, whose bundled Python ships tkinter.
|
||
|
||
The probe must import QtWidgets, not just the package or QtCore:
|
||
on NixOS `import PySide6` succeeds (pure __init__) while QtCore's
|
||
.so cannot find the system libraries pip wheels expect
|
||
("libgthread-2.0.so.0: cannot open shared object file"), and on a
|
||
partially provisioned system QtCore's deps (glib, icu) can be
|
||
present while QtWidgets/QtGui still miss libGL/libEGL. matplotlib's
|
||
qt backend imports QtCore, QtGui and QtWidgets, so probe the widest
|
||
one - promising QtAgg then kills even the error figure at
|
||
switch_backend time."""
|
||
for qt in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
|
||
try:
|
||
__import__(qt + ".QtWidgets")
|
||
return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg"
|
||
except Exception:
|
||
continue
|
||
try:
|
||
import tkinter # noqa: F401
|
||
return "TkAgg"
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
INTERACTIVE_BACKEND = _pick_backend()
|
||
matplotlib.use(INTERACTIVE_BACKEND or "Agg")
|
||
|
||
import matplotlib.pyplot as plt # noqa: E402 (after backend selection)
|
||
from matplotlib.colors import ListedColormap, LogNorm # noqa: E402
|
||
from matplotlib.gridspec import GridSpec # noqa: E402
|
||
from matplotlib.patches import Patch # noqa: E402
|
||
from matplotlib.widgets import CheckButtons # noqa: E402
|
||
|
||
from . import config, progress # noqa: E402
|
||
|
||
_BG = "#f5f3f0"
|
||
_COPPER = "#c98b4e"
|
||
_E1_COLOR = "#c8385a"
|
||
_E2_COLOR = "#2f6fb0"
|
||
_VIA_COLOR = "#2d6b45"
|
||
_PAD_COLOR = "#5b4a8a" # THT pad barrels (kind='pad'), violet-ink
|
||
_SOLDER = "#9aa3ad" # tin-gray: solder buildup areas
|
||
_PLUG = "#6e7885" # darker tin: solder-filled THT holes (lead + plug)
|
||
_MESH = "#a56c33" # darker copper: adaptive leaf boundaries
|
||
_INK = "#3a3a3a"
|
||
_GRID_INK = "#b8b4ae"
|
||
|
||
|
||
def _fmt_si(value: float, unit: str) -> str:
|
||
for scale, prefix in ((1.0, ""), (1e-3, "m"), (1e-6, "µ")):
|
||
if abs(value) >= scale:
|
||
return f"{value / scale:.4g} {prefix}{unit}"
|
||
return f"{value:.3g} {unit}"
|
||
|
||
|
||
def _suptitle(problem, stack, result=None) -> str:
|
||
ny, nx = stack.shape2d
|
||
parts = []
|
||
if result is not None and result.mode == "pdn":
|
||
# no single two-terminal R in PDN mode (R_ohm is NaN)
|
||
parts.append(f"PDN {len(result.supplies)}S/{len(result.loads)}L, "
|
||
f"ΣI = {result.i_test:g} A")
|
||
parts.append(f"P_Cu = {_fmt_si(result.P_total, 'W')}")
|
||
if result.freq_hz > 0:
|
||
parts.append(f"f = {result.freq_hz / 1e3:g} kHz "
|
||
f"(δ={result.skin_depth_um:.0f} µm, lower bound)")
|
||
elif result is not None:
|
||
parts.append(f"R = {result.R_ohm * 1000:.4g} mΩ")
|
||
parts.append(f"P = {_fmt_si(result.P_total, 'W')} @ "
|
||
f"{result.i_test:g} A")
|
||
if result.freq_hz > 0:
|
||
parts.append(f"f = {result.freq_hz / 1e3:g} kHz "
|
||
f"(δ={result.skin_depth_um:.0f} µm, lower bound)")
|
||
parts.append(problem.net_name)
|
||
parts.append(f"{nx}×{ny}×{stack.nlayers} @ {stack.h_nm / 1000:.0f} µm")
|
||
return " | ".join(parts)
|
||
|
||
|
||
def _layer_windows(stack, window_title: str, paint, finalize):
|
||
"""Figure with one row per VISIBLE layer. Multi-layer figures on an
|
||
interactive backend get a layer checkbox panel: unticking a layer
|
||
removes its row and the remaining rows grow to fill the window (the
|
||
constrained layout reflows on every draw). paint(ax, li) draws one
|
||
layer; finalize(fig, rows) with rows = [(li, ax), ...] adds the
|
||
legend/colorbar/suptitle and returns artists to drop on the next
|
||
redraw (colorbars). Saved PNGs always contain every layer - they
|
||
are written before the window shows, with the panel hidden."""
|
||
L = stack.nlayers
|
||
ny, nx = stack.shape2d
|
||
aspect = ny / nx
|
||
w = 9.5
|
||
row_h = min(max(w * aspect * 0.9 + 0.6, 1.8), 8.5 / L)
|
||
fig = plt.figure(figsize=(w, row_h * L + 1.4), layout="constrained")
|
||
if INTERACTIVE_BACKEND:
|
||
fig.canvas.manager.set_window_title(window_title)
|
||
|
||
checks = None
|
||
if L > 1 and INTERACTIVE_BACKEND:
|
||
# reserve a strip on the right for the layer checkboxes
|
||
fig.get_layout_engine().set(rect=(0, 0, 0.86, 1))
|
||
h_panel = min(0.05 + 0.045 * L, 0.35)
|
||
panel = fig.add_axes([0.865, 0.96 - h_panel, 0.13, h_panel])
|
||
panel.set_in_layout(False)
|
||
checks = CheckButtons(panel, stack.layer_names, [True] * L)
|
||
for t in checks.labels:
|
||
t.set_fontsize(7)
|
||
fig._layer_panel = panel # hidden while saving PNGs
|
||
|
||
state = {"visible": [True] * L, "axes": [], "extras": [],
|
||
"guard": False}
|
||
|
||
def redraw():
|
||
for ax in state["axes"]:
|
||
ax.remove()
|
||
for art in state["extras"]:
|
||
try:
|
||
art.remove()
|
||
except Exception:
|
||
pass
|
||
state["axes"], state["extras"] = [], []
|
||
shown = [li for li in range(L) if state["visible"][li]]
|
||
if not shown:
|
||
fig.canvas.draw_idle()
|
||
return
|
||
gs = GridSpec(len(shown), 1, figure=fig)
|
||
rows = []
|
||
share = None
|
||
for k, li in enumerate(shown):
|
||
ax = fig.add_subplot(gs[k], sharex=share, sharey=share)
|
||
if share is None:
|
||
share = ax
|
||
ax.set_ylabel(f"{stack.layer_names[li]}\ny [mm]", fontsize=8)
|
||
ax.tick_params(colors=_INK, labelsize=8)
|
||
for sp in ax.spines.values():
|
||
sp.set_color(_GRID_INK)
|
||
paint(ax, li)
|
||
rows.append((li, ax))
|
||
for _, ax in rows:
|
||
ax.label_outer()
|
||
rows[-1][1].set_xlabel("x [mm]")
|
||
state["axes"] = [ax for _, ax in rows]
|
||
state["extras"] = list(finalize(fig, rows) or [])
|
||
fig.canvas.draw_idle()
|
||
|
||
def on_check(label):
|
||
if state["guard"]:
|
||
return
|
||
li = stack.layer_names.index(label)
|
||
if state["visible"][li] and sum(state["visible"]) == 1:
|
||
state["guard"] = True # keep at least one layer visible
|
||
checks.set_active(li)
|
||
state["guard"] = False
|
||
return
|
||
state["visible"][li] = not state["visible"][li]
|
||
redraw()
|
||
|
||
if checks is not None:
|
||
checks.on_clicked(on_check)
|
||
fig._layer_checks = checks # keep the widget alive / testable
|
||
redraw()
|
||
return fig
|
||
|
||
|
||
def _electrode_labels(ax, stack, e1_l, e2_l):
|
||
"""Label each connected contact part (multi-part terminals get one
|
||
label per island, largest first, up to 4)."""
|
||
from scipy import ndimage
|
||
for e, label, color in ((e1_l, "V+", _E1_COLOR), (e2_l, "V−", _E2_COLOR)):
|
||
if not e.any():
|
||
continue
|
||
labels, n = ndimage.label(e)
|
||
sizes = ndimage.sum_labels(np.ones_like(labels), labels,
|
||
range(1, n + 1))
|
||
order = np.argsort(sizes)[::-1][:4] + 1
|
||
for comp in order:
|
||
ii, jj = np.nonzero(labels == comp)
|
||
cx = (stack.x0_nm + (jj.mean() + 0.5) * stack.h_nm) * 1e-6
|
||
cy = (stack.y0_nm + (ii.mean() + 0.5) * stack.h_nm) * 1e-6
|
||
ax.annotate(label, (cx, cy), xytext=(0, 0),
|
||
textcoords="offset points", color="white",
|
||
fontsize=9, fontweight="bold", ha="center",
|
||
va="center",
|
||
bbox=dict(boxstyle="round,pad=0.2", fc=color,
|
||
ec="none", alpha=0.9))
|
||
|
||
|
||
def _via_markers(ax, problem, layer):
|
||
"""One dot per barrel spanning the layer: vias green, THT pad
|
||
barrels violet (same joint markers, different physics)."""
|
||
for kind, color in (("via", _VIA_COLOR), ("pad", _PAD_COLOR)):
|
||
pts = [(v.x * 1e-6, v.y * 1e-6) for v in problem.vias
|
||
if v.kind == kind and v.spans(layer.z_nm)]
|
||
if pts:
|
||
xs, ys = zip(*pts)
|
||
ax.plot(xs, ys, ".", ms=2.5, color=color, alpha=0.7)
|
||
|
||
|
||
def area_tag(sign: str, index: int) -> str:
|
||
"""Short injection-area tag: P1, P2, ... for V+; N1, N2, ... for V-."""
|
||
return f"{'P' if sign == '+' else 'N'}{index + 1}"
|
||
|
||
|
||
def _injection_area_labels(ax, li, layer_name, problem, result):
|
||
"""Mark every injection area with its short tag (currents live in
|
||
the legend)."""
|
||
groups = ((problem.electrodes1, "+", _E1_COLOR),
|
||
(problem.electrodes2, "-", _E2_COLOR))
|
||
for parts, sign, color in groups:
|
||
for i, el in enumerate(parts):
|
||
if el.contact != "all" and el.contact != layer_name:
|
||
continue
|
||
cx = (el.rect.x0 + el.rect.x1) / 2e6
|
||
cy = (el.rect.y0 + el.rect.y1) / 2e6
|
||
ax.annotate(area_tag(sign, i), (cx, cy), xytext=(0, 0),
|
||
textcoords="offset points", color="white",
|
||
fontsize=8, fontweight="bold", ha="center",
|
||
va="center",
|
||
bbox=dict(boxstyle="round,pad=0.15", fc=color,
|
||
ec="none", alpha=0.9))
|
||
|
||
|
||
def fig_raster(stack, e1, e2, problem, result=None):
|
||
cmap = ListedColormap([_BG, _COPPER, _E1_COLOR, _E2_COLOR, _SOLDER,
|
||
_MESH, _PLUG])
|
||
has_buildup = stack.buildup is not None and stack.buildup.any()
|
||
has_plug = stack.plug is not None and stack.plug.any()
|
||
has_mesh = stack.mesh is not None and stack.mesh.any()
|
||
|
||
def paint(ax, li):
|
||
codes = np.zeros(stack.shape2d, dtype=np.uint8)
|
||
codes[stack.masks[li]] = 1
|
||
if has_buildup:
|
||
codes[stack.buildup[li]] = 4
|
||
if has_plug:
|
||
codes[stack.plug[li]] = 6
|
||
if has_mesh:
|
||
codes[stack.mesh[li]] = 5
|
||
codes[e1[li]] = 2
|
||
codes[e2[li]] = 3
|
||
ax.imshow(codes, cmap=cmap, vmin=0, vmax=6, origin="upper",
|
||
extent=stack.extent_mm(), interpolation="nearest")
|
||
_via_markers(ax, problem, problem.layers[li])
|
||
if result is not None and (result.part_currents1
|
||
or result.part_currents2):
|
||
_injection_area_labels(ax, li, stack.layer_names[li], problem,
|
||
result)
|
||
else:
|
||
_electrode_labels(ax, stack, e1[li], e2[li])
|
||
|
||
def finalize(fig, rows):
|
||
kinds = {v.kind for v in problem.vias}
|
||
handles = [Patch(fc=_COPPER, label="copper")]
|
||
if "via" in kinds or not kinds:
|
||
handles.append(Patch(fc=_VIA_COLOR, label="vias"))
|
||
if "pad" in kinds:
|
||
handles.append(Patch(fc=_PAD_COLOR, label="THT pad barrels"))
|
||
if has_mesh:
|
||
handles.append(Patch(fc=_MESH,
|
||
label="adaptive mesh (coarse leaves)"))
|
||
if has_buildup:
|
||
handles.append(Patch(
|
||
fc=_SOLDER,
|
||
label=f"solder buildup "
|
||
f"({problem.solder_thickness_nm / 1000:.0f} µm"
|
||
+ (f" + {problem.extra_cu_nm / 1000:.0f} µm Cu"
|
||
if problem.extra_cu_nm else "") + ")"))
|
||
if has_plug:
|
||
handles.append(Patch(
|
||
fc=_PLUG, label="solder-filled THT hole (lead + solder)"))
|
||
if result is not None and result.mode == "pdn":
|
||
entries = ([(f"S{i + 1}", _E1_COLOR, s_.label, s_.i_a)
|
||
for i, s_ in enumerate(result.supplies)]
|
||
+ [(f"L{i + 1}", _E2_COLOR, l_.label, l_.i_a)
|
||
for i, l_ in enumerate(result.loads)])
|
||
shown = entries[:14]
|
||
for tag, color, label, amps in shown:
|
||
handles.append(Patch(
|
||
fc=color, label=f"{tag} {label}: {amps:.3g} A"))
|
||
if len(entries) > len(shown):
|
||
handles.append(Patch(
|
||
fc="#00000000",
|
||
label=f"... +{len(entries) - len(shown)} "
|
||
f"more in summary.txt"))
|
||
elif result is not None and (result.part_currents1
|
||
or result.part_currents2):
|
||
entries = ([("+", _E1_COLOR, i, amps)
|
||
for i, (_, amps) in
|
||
enumerate(result.part_currents1)]
|
||
+ [("-", _E2_COLOR, i, amps)
|
||
for i, (_, amps) in
|
||
enumerate(result.part_currents2)])
|
||
shown = entries[:14]
|
||
for sign, color, i, amps in shown:
|
||
handles.append(Patch(
|
||
fc=color,
|
||
label=f"{area_tag(sign, i)}: {amps:.3g} A "
|
||
f"({100 * amps / result.i_test:.0f}%)"))
|
||
if len(entries) > len(shown):
|
||
handles.append(Patch(
|
||
fc="#00000000",
|
||
label=f"... +{len(entries) - len(shown)} "
|
||
f"more in summary.txt"))
|
||
else:
|
||
handles += [Patch(fc=_E1_COLOR, label="V+"),
|
||
Patch(fc=_E2_COLOR, label="V−")]
|
||
rows[0][1].legend(handles=handles, loc="upper right", fontsize=7,
|
||
framealpha=0.9)
|
||
fig.suptitle("Rasterized fill + electrodes | "
|
||
+ _suptitle(problem, stack, result), fontsize=10,
|
||
color=_INK)
|
||
return []
|
||
|
||
return _layer_windows(stack, "Fill Resistance - rasterized map",
|
||
paint, finalize)
|
||
|
||
|
||
def fig_potential(result, stack, e1, e2, problem):
|
||
vmax = float(np.nanmax(result.V))
|
||
if result.mode == "pdn":
|
||
# absolute volts (e.g. 3.3 V nominal): anchoring the scale at
|
||
# 0 V would flatten the map into one color - auto-range instead
|
||
vmin = float(np.nanmin(result.V))
|
||
else:
|
||
# uniform model: <V-> = 0 is the reference, individual V- cells
|
||
# can sit slightly below it - keep them in range, don't clip
|
||
vmin = min(0.0, float(np.nanmin(result.V)))
|
||
unit, scale = ("mV", 1e3) if vmax < 0.1 else ("V", 1.0)
|
||
cmap = matplotlib.colormaps[config.CMAP_POTENTIAL].copy()
|
||
cmap.set_bad(_BG)
|
||
|
||
def paint(ax, li):
|
||
vs = result.V[li] * scale
|
||
ax._im = ax.imshow(vs, cmap=cmap, vmin=vmin * scale,
|
||
vmax=vmax * scale, origin="upper",
|
||
extent=stack.extent_mm(),
|
||
interpolation="nearest")
|
||
if np.isfinite(vs).sum() > 4:
|
||
ext = stack.extent_mm()
|
||
ny, nx = stack.shape2d
|
||
xs = np.linspace(ext[0], ext[1], nx, endpoint=False)
|
||
xs += (xs[1] - xs[0]) / 2
|
||
ys = np.linspace(ext[3], ext[2], ny, endpoint=False)
|
||
ys += (ys[1] - ys[0]) / 2
|
||
with np.errstate(invalid="ignore"):
|
||
ax.contour(xs, ys, vs, levels=15, colors="white",
|
||
linewidths=0.4, alpha=0.5)
|
||
_electrode_labels(ax, stack, e1[li], e2[li])
|
||
|
||
def finalize(fig, rows):
|
||
cb = fig.colorbar(rows[0][1]._im, ax=[ax for _, ax in rows],
|
||
shrink=0.85)
|
||
cb.set_label(f"potential [{unit}] @ {result.i_test:g} A",
|
||
fontsize=9)
|
||
fig.suptitle("Potential | " + _suptitle(problem, stack, result),
|
||
fontsize=10, color=_INK)
|
||
return [cb]
|
||
|
||
return _layer_windows(stack, "Fill Resistance - potential", paint,
|
||
finalize)
|
||
|
||
|
||
def _field_fig(result, stack, e1, e2, problem, data3, cmap_name, dyn_range,
|
||
label, title, window, paint_extra=None, finalize_extra=None):
|
||
"""Shared per-layer LogNorm field figure (current, power)."""
|
||
vmax = float(np.nanmax(data3))
|
||
cmap = matplotlib.colormaps[cmap_name].copy()
|
||
cmap.set_bad(_BG)
|
||
if config.LOG_CURRENT_SCALE and vmax > 0:
|
||
norm = LogNorm(vmin=vmax / dyn_range, vmax=vmax)
|
||
else:
|
||
norm = None
|
||
if vmax > 0:
|
||
mli, mi, mj = np.unravel_index(np.nanargmax(data3), data3.shape)
|
||
mx = (stack.x0_nm + (mj + 0.5) * stack.h_nm) * 1e-6
|
||
my = (stack.y0_nm + (mi + 0.5) * stack.h_nm) * 1e-6
|
||
|
||
def paint(ax, li):
|
||
d = data3[li]
|
||
shown = np.clip(d, vmax / dyn_range, None) if norm is not None else d
|
||
ax._im = ax.imshow(shown, cmap=cmap, norm=norm, origin="upper",
|
||
extent=stack.extent_mm(),
|
||
interpolation="nearest")
|
||
_electrode_labels(ax, stack, e1[li], e2[li])
|
||
if vmax > 0 and li == mli:
|
||
ax.plot(mx, my, "o", ms=9, mfc="none", mec="white", mew=1.4)
|
||
ax.annotate(f"max {vmax:.3g}", (mx, my), xytext=(10, -10),
|
||
textcoords="offset points", color="white",
|
||
fontsize=8,
|
||
bbox=dict(boxstyle="round,pad=0.2", fc="#00000088",
|
||
ec="none"))
|
||
if paint_extra is not None:
|
||
paint_extra(ax, li)
|
||
|
||
def finalize(fig, rows):
|
||
cb = fig.colorbar(rows[0][1]._im, ax=[ax for _, ax in rows],
|
||
shrink=0.85)
|
||
cb.set_label(label, fontsize=9)
|
||
fig.suptitle(title + " | " + _suptitle(problem, stack, result),
|
||
fontsize=10, color=_INK)
|
||
if finalize_extra is not None:
|
||
finalize_extra(fig, rows)
|
||
return [cb]
|
||
|
||
return _layer_windows(stack, window, paint, finalize)
|
||
|
||
|
||
def fig_current(result, stack, e1, e2, problem):
|
||
v = result.via_reports[0] if result.via_reports else None
|
||
|
||
def paint_extra(ax, li):
|
||
if v is not None:
|
||
ax.plot(v.x_mm, v.y_mm, "s", ms=7, mfc="none", mec="#7fe0a8",
|
||
mew=1.2)
|
||
|
||
def finalize_extra(fig, rows):
|
||
if v is not None:
|
||
rows[0][1].annotate(
|
||
f"hottest via {v.current_a:.3g} A", (v.x_mm, v.y_mm),
|
||
xytext=(10, 10), textcoords="offset points",
|
||
color="white", fontsize=8,
|
||
bbox=dict(boxstyle="round,pad=0.2", fc="#2d6b45",
|
||
ec="none"))
|
||
|
||
return _field_fig(
|
||
result, stack, e1, e2, problem, result.Jmag * 1e-6,
|
||
config.CMAP_CURRENT, config.CURRENT_DYNAMIC_RANGE,
|
||
f"|J| [A/mm²] @ {result.i_test:g} A",
|
||
"Current density (log)", "Fill Resistance - current density",
|
||
paint_extra=paint_extra, finalize_extra=finalize_extra)
|
||
|
||
|
||
def fig_power(result, stack, e1, e2, problem):
|
||
def paint_extra(ax, li):
|
||
ax.set_title(f"P({stack.layer_names[li]}) = "
|
||
f"{_fmt_si(result.P_layers[li], 'W')}",
|
||
fontsize=8, color=_INK, loc="right", pad=2)
|
||
|
||
# W/m^2 -> W/mm^2
|
||
return _field_fig(
|
||
result, stack, e1, e2, problem, result.Parea * 1e-6,
|
||
config.CMAP_POWER, config.POWER_DYNAMIC_RANGE,
|
||
f"p [W/mm²] @ {result.i_test:g} A",
|
||
"Power density (log)", "Fill Resistance - power density",
|
||
paint_extra=paint_extra)
|
||
|
||
|
||
def _style_table(tbl):
|
||
tbl.auto_set_font_size(False)
|
||
tbl.set_fontsize(9)
|
||
tbl.scale(1.0, 1.5)
|
||
tbl.auto_set_column_width(col=sorted({c for _r, c
|
||
in tbl.get_celld()}))
|
||
for (r, _c), cell in tbl.get_celld().items():
|
||
cell.set_edgecolor("#cccccc")
|
||
if r == 0:
|
||
cell.set_text_props(fontweight="bold", color=_INK)
|
||
cell.set_facecolor("#eeeeee")
|
||
elif r % 2 == 0:
|
||
cell.set_facecolor("#f7f7f7")
|
||
|
||
|
||
def fig_pdn_pairs(result):
|
||
"""The PDN source-sink pair table as a figure: effective copper
|
||
resistance between every supply and every load plus the
|
||
proportional-sharing loss attribution - the same numbers and
|
||
conventions as the summary.txt table. Terminals are keyed by their
|
||
(unique) labels alone; a legend table underneath notes each
|
||
terminal's component hint and comment when there are any."""
|
||
header = ["supply", "load", "R (copper)", "I attributed",
|
||
"P attributed"]
|
||
rows = [[pr.supply, pr.load,
|
||
(_fmt_si(pr.r_ohm, "Ω") if pr.r_ohm is not None
|
||
else "no path"),
|
||
_fmt_si(pr.i_share_a, "A"),
|
||
_fmt_si(pr.p_w, "W")] for pr in result.pairs]
|
||
legend = [[t.label, role, t.component, t.comment]
|
||
for role, terms in (("supply", result.supplies),
|
||
("load", result.loads))
|
||
for t in terms if t.component or t.comment]
|
||
h1 = 1.8 + 0.32 * len(rows)
|
||
h2 = 0.9 + 0.30 * len(legend)
|
||
if legend:
|
||
fig, (ax, ax2) = plt.subplots(
|
||
2, 1, figsize=(9.0, h1 + h2), layout="constrained",
|
||
gridspec_kw={"height_ratios": [h1, h2]})
|
||
else:
|
||
fig, ax = plt.subplots(figsize=(9.0, h1), layout="constrained")
|
||
ax2 = None
|
||
ax.axis("off")
|
||
ax.set_title("Fill Resistance - source→sink pairs", fontsize=13,
|
||
color=_INK, loc="left")
|
||
_style_table(ax.table(cellText=rows, colLabels=header,
|
||
loc="upper center", cellLoc="left",
|
||
colLoc="left"))
|
||
p_attr = sum(pr.p_w for pr in result.pairs)
|
||
ax.text(0.0, 0.02,
|
||
f"attributed copper loss total: {_fmt_si(p_attr, 'W')} "
|
||
f"(copper loss {_fmt_si(result.P_total, 'W')})\n"
|
||
"R: effective copper resistance between the two contacts - "
|
||
"operating-point independent, source R_out excluded.\n"
|
||
"I/P attributed by proportional sharing per copper island: "
|
||
"a convention (the pair split is not unique physics), but "
|
||
"exact in total.",
|
||
transform=ax.transAxes, fontsize=8, color="#666666",
|
||
va="bottom", ha="left")
|
||
if ax2 is not None:
|
||
ax2.axis("off")
|
||
ax2.set_title("terminals", fontsize=10, color=_INK, loc="left")
|
||
_style_table(ax2.table(
|
||
cellText=legend,
|
||
colLabels=["terminal", "role", "component", "comment"],
|
||
loc="upper center", cellLoc="left", colLoc="left"))
|
||
return fig
|
||
|
||
|
||
def fig_error(message: str):
|
||
fig, ax = plt.subplots(figsize=(9, 4.5), layout="constrained")
|
||
ax.axis("off")
|
||
ax.set_title("Fill Resistance - ERROR", color="#b02a2a",
|
||
fontsize=14, fontweight="bold", loc="left")
|
||
wrapped = "\n".join(
|
||
textwrap.fill(line, width=90) for line in message.splitlines()
|
||
)
|
||
ax.text(0.0, 0.95, wrapped, family="monospace", fontsize=9,
|
||
va="top", ha="left", color=_INK, transform=ax.transAxes)
|
||
return fig
|
||
|
||
|
||
def _resolve_label_overlaps(fig):
|
||
"""Measure every annotation's rendered box and greedily push
|
||
overlapping labels upward until nothing collides. Runs on the real
|
||
renderer, so it handles any font/DPI."""
|
||
from matplotlib.text import Annotation
|
||
try:
|
||
fig.canvas.draw()
|
||
renderer = fig.canvas.get_renderer()
|
||
except Exception:
|
||
return
|
||
for ax in fig.axes:
|
||
anns = [c for c in ax.get_children() if isinstance(c, Annotation)]
|
||
placed = []
|
||
for a in sorted(anns, key=lambda t: t.get_window_extent(renderer).x0):
|
||
try:
|
||
bb = a.get_window_extent(renderer)
|
||
except Exception:
|
||
continue
|
||
guard = 50
|
||
while guard > 0:
|
||
hit = next((p for p in placed if bb.overlaps(p)), None)
|
||
if hit is None:
|
||
break
|
||
push_px = (hit.y1 - bb.y0) + 3.0
|
||
dx, dy = a.xyann
|
||
a.xyann = (dx, dy + push_px * 72.0 / fig.dpi)
|
||
bb = a.get_window_extent(renderer)
|
||
guard -= 1
|
||
placed.append(bb)
|
||
|
||
|
||
def _fit_to_screen(fig) -> None:
|
||
"""Best effort: shrink the window so it fits the screen. Runs AFTER
|
||
the PNGs are saved (so their size is unaffected); the constrained
|
||
layout reflows the content on every subsequent resize."""
|
||
try:
|
||
win = fig.canvas.manager.window
|
||
if hasattr(win, "screen"): # Qt
|
||
avail = win.screen().availableGeometry()
|
||
sw, sh = avail.width(), avail.height()
|
||
elif hasattr(win, "winfo_screenwidth"): # Tk
|
||
sw, sh = win.winfo_screenwidth(), win.winfo_screenheight()
|
||
else:
|
||
return
|
||
w, h = fig.get_size_inches()
|
||
scale = min(0.9 * sw / (w * fig.dpi), 0.85 * sh / (h * fig.dpi), 1.0)
|
||
if scale < 1.0:
|
||
fig.set_size_inches(w * scale, h * scale, forward=True)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _raise_windows():
|
||
"""Best effort: bring plot windows in front of KiCad (windows spawned
|
||
by a background process tend to open behind)."""
|
||
for num in plt.get_fignums():
|
||
try:
|
||
win = plt.figure(num).canvas.manager.window
|
||
if hasattr(win, "attributes"): # Tk
|
||
win.attributes("-topmost", True)
|
||
win.after(300, lambda w=win: w.attributes("-topmost", False))
|
||
else: # Qt
|
||
win.raise_()
|
||
win.activateWindow()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def save_and_show(figs_named: list[tuple], outdir: Path | None,
|
||
show: bool = True) -> list[Path]:
|
||
"""figs_named: [(figure, basename), ...]. Saves first, then shows."""
|
||
saved = []
|
||
progress.stage("laying out figures ...", echo=False)
|
||
for fig, _ in figs_named:
|
||
_resolve_label_overlaps(fig)
|
||
if outdir is not None:
|
||
outdir.mkdir(parents=True, exist_ok=True)
|
||
for fig, name in figs_named:
|
||
# full-DPI savefig with tight bounding boxes is seconds per
|
||
# figure - the progress window has to stay up for it
|
||
progress.stage(f"saving {name}.png ...", echo=False)
|
||
panel = getattr(fig, "_layer_panel", None)
|
||
if panel is not None:
|
||
panel.set_visible(False) # PNGs carry no checkboxes
|
||
p = outdir / f"{name}.png"
|
||
fig.savefig(p, dpi=config.DPI, facecolor="white",
|
||
bbox_inches="tight")
|
||
if panel is not None:
|
||
panel.set_visible(True)
|
||
saved.append(p)
|
||
print(f"saved {p}")
|
||
if show and config.INTERACTIVE:
|
||
if INTERACTIVE_BACKEND:
|
||
progress.stage("opening the figure windows ...", echo=False)
|
||
for fig, _ in figs_named:
|
||
_fit_to_screen(fig)
|
||
progress.done() # last thing before the figures are up
|
||
_raise_windows()
|
||
plt.show()
|
||
else:
|
||
progress.done()
|
||
for p in saved:
|
||
_open_in_viewer(p)
|
||
else:
|
||
progress.done()
|
||
plt.close("all")
|
||
return saved
|
||
|
||
|
||
def _open_in_viewer(path: Path) -> None:
|
||
"""Open a saved PNG in the OS default viewer (no-GUI-backend
|
||
fallback so results are never silent)."""
|
||
try:
|
||
if sys.platform == "win32":
|
||
os.startfile(path)
|
||
elif sys.platform == "darwin":
|
||
subprocess.Popen(["open", str(path)])
|
||
else:
|
||
subprocess.Popen(["xdg-open", str(path)])
|
||
except Exception:
|
||
pass
|