Files
kicad-zone-resistance/fill_resistance/plots.py
T
grabowski de7a4f8641 Probe QtWidgets, not QtCore: the qt backend needs the full GUI stack
QtCore's libraries (glib, icu) can load while QtWidgets/QtGui still
miss libGL - matplotlib's qt backend imports all three, so the old
probe could promise QtAgg and lose even the error figure at
switch_backend, one library layer deeper than the NixOS fix. The
regression test's mock now mirrors the real failure (bare package and
QtCore import fine, GUI modules raise): both the bare-import and the
QtCore-probe variants now fail it, verified by mutation. Also drop two
redundant f-prefixes on placeholder-less strings in report.py.
2026-07-23 17:25:19 +07:00

581 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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:
parts.append(f"R = {result.R_ohm * 1000:.4g}")
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.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))
# uniform model: <V-> = 0 is the reference, individual V- cells can
# sit slightly below it - keep them in range instead of clipping
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 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