Files
kicad-zone-resistance/fill_resistance/plots.py
T
janik 06c62e04f8 Initial import: KiCad zone resistance plugin
DC/AC resistance, power dissipation, and via/injection-area currents of
copper zone fills. KiCad 10 IPC-API plugin (kicad-python/kipy):
multi-layer via-coupled FDM solver, multi-part terminals via User.1/User.2
marker layers, pads as contacts, uniform-injection and equipotential
contact models, per-foil skin effect, optional solder/copper buildup on
mask openings. 54-case test suite incl. exact analytic references.

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

392 lines
15 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 (tkinter, else Qt), else Agg
with os.startfile on the saved PNGs so results are never silent.
"""
from __future__ import annotations
import os
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 Python has no tkinter), so probe the
toolkits explicitly."""
try:
import tkinter # noqa: F401
return "TkAgg"
except Exception:
pass
for qt in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
try:
__import__(qt)
return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg"
except Exception:
continue
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.patches import Patch # noqa: E402
from . import config # noqa: E402
_BG = "#f5f3f0"
_COPPER = "#c98b4e"
_E1_COLOR = "#c8385a"
_E2_COLOR = "#2f6fb0"
_VIA_COLOR = "#2d6b45"
_SOLDER = "#9aa3ad" # tin-gray: solder buildup areas
_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_fig(stack, window_title: str):
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, axes = plt.subplots(L, 1, figsize=(w, row_h * L + 1.4),
sharex=True, sharey=True, squeeze=False)
axes = axes[:, 0]
if INTERACTIVE_BACKEND:
fig.canvas.manager.set_window_title(window_title)
for ax, name in zip(axes, stack.layer_names):
ax.set_ylabel(f"{name}\ny [mm]", fontsize=8)
ax.tick_params(colors=_INK, labelsize=8)
for s in ax.spines.values():
s.set_color(_GRID_INK)
axes[-1].set_xlabel("x [mm]")
return fig, axes
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):
xs = [v.x * 1e-6 for v in problem.vias if v.spans(layer.z_nm)]
ys = [v.y * 1e-6 for v in problem.vias if v.spans(layer.z_nm)]
if xs:
ax.plot(xs, ys, ".", ms=2.5, color=_VIA_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):
fig, axes = _layer_fig(stack, "Fill Resistance - rasterized map")
cmap = ListedColormap([_BG, _COPPER, _E1_COLOR, _E2_COLOR, _SOLDER])
has_buildup = stack.buildup is not None and stack.buildup.any()
for li, ax in enumerate(axes):
codes = np.zeros(stack.shape2d, dtype=np.uint8)
codes[stack.masks[li]] = 1
if has_buildup:
codes[stack.buildup[li]] = 4
codes[e1[li]] = 2
codes[e2[li]] = 3
ax.imshow(codes, cmap=cmap, vmin=0, vmax=4, 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])
handles = [Patch(fc=_COPPER, label="copper"),
Patch(fc=_VIA_COLOR, label="vias")]
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 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")]
axes[0].legend(handles=handles, loc="upper right", fontsize=7,
framealpha=0.9)
fig.suptitle("Rasterized fill + electrodes | "
+ _suptitle(problem, stack, result), fontsize=10, color=_INK)
fig.tight_layout()
return fig
def fig_potential(result, stack, e1, e2, problem):
fig, axes = _layer_fig(stack, "Fill Resistance - potential")
vmax = float(np.nanmax(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)
im = None
for li, ax in enumerate(axes):
vs = result.V[li] * scale
im = ax.imshow(vs, cmap=cmap, vmin=0, 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])
cb = fig.colorbar(im, ax=axes, 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 fig
def _field_fig(result, stack, e1, e2, problem, data3, cmap_name, dyn_range,
label, title, window):
"""Shared per-layer LogNorm field figure (current, power)."""
fig, axes = _layer_fig(stack, window)
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
im = None
for li, ax in enumerate(axes):
d = data3[li]
shown = np.clip(d, vmax / dyn_range, None) if norm is not None else d
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:
li, i, j = np.unravel_index(np.nanargmax(data3), data3.shape)
mx = (stack.x0_nm + (j + 0.5) * stack.h_nm) * 1e-6
my = (stack.y0_nm + (i + 0.5) * stack.h_nm) * 1e-6
axes[li].plot(mx, my, "o", ms=9, mfc="none", mec="white", mew=1.4)
axes[li].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"))
cb = fig.colorbar(im, ax=axes, shrink=0.85)
cb.set_label(label, fontsize=9)
fig.suptitle(title + " | " + _suptitle(problem, stack, result),
fontsize=10, color=_INK)
return fig, axes
def fig_current(result, stack, e1, e2, problem):
fig, axes = _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")
# mark the hottest via
if result.via_reports:
v = result.via_reports[0]
for ax in axes:
ax.plot(v.x_mm, v.y_mm, "s", ms=7, mfc="none", mec="#7fe0a8",
mew=1.2)
axes[0].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 fig
def fig_power(result, stack, e1, e2, problem):
# W/m^2 -> W/mm^2
fig, axes = _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")
for li, ax in enumerate(axes):
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)
return fig
def fig_error(message: str):
fig, ax = plt.subplots(figsize=(9, 4.5))
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)
fig.tight_layout()
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 _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 = []
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:
p = outdir / f"{name}.png"
fig.savefig(p, dpi=config.DPI, facecolor="white",
bbox_inches="tight")
saved.append(p)
print(f"saved {p}")
if show and config.INTERACTIVE:
if INTERACTIVE_BACKEND:
_raise_windows()
plt.show()
else:
for p in saved:
try:
os.startfile(p) # windows: open in default viewer
except Exception:
pass
plt.close("all")
return saved