Experimental: push |J| heatmap overlays into KiCad (dialog opt-in)
Build PCM package / build (push) Successful in 6s
Build PCM package / build (push) Successful in 6s
After a solve, the per-layer current-density maps can be pushed into the open board as reference images on User.9..User.12 (stackup order, top first) - visible right in the editor, toggled like any layer, never plotted to gerbers. Dialog checkbox, default OFF; every push replaces all reference images on those layers. Rendering (fill_resistance/overlay.py, KiCad-free and tested headless): one pixel per grid cell, opaque over copper with the log scale lifted off the colormap's near-black bottom (dark canvas), transparent elsewhere, one pixel of half-alpha edge bleed so the overlay reaches the drawn outline instead of stopping half a cell short. Pushing lives in board_io (ReferenceImage via the IPC API, KiCad >= 10.0.1; scale = width / (pixels * 1 inch / 300 PPI), position = image center); kipy 0.7.1 swallows creation errors, so the per-item status is read from the raw CreateItemsResponse. pipeline.run takes an optional overlay callback; failures are reported, never fatal. tools/kicad_overlay_test.py pushes a fiducial alignment pattern (KiCad bbox readback verified placement to half a pixel); tools/ kicad_heatmap_overlay.py runs the whole thing headless against the open board, filtering marker rectangles of other nets' analyses via the exact copper test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -631,6 +631,77 @@ def gather_tht_pad_copper(board: Board, net_name: str
|
||||
return shapes
|
||||
|
||||
|
||||
# --- in-KiCad result overlays (EXPERIMENTAL) ---------------------------------
|
||||
|
||||
# KiCad sizes reference images as pixels * (1 inch / PPI) * image_scale
|
||||
# and assumes 300 PPI for PNGs without a density chunk (BITMAP_BASE)
|
||||
OVERLAY_PIX_NM = 25.4e6 / 300
|
||||
|
||||
|
||||
def _create_reference_image(board: Board, ref) -> None:
|
||||
"""create_items with the per-item status surfaced (kipy <= 0.7.1
|
||||
swallows it and returns an empty wrapper on failure)."""
|
||||
from kipy.proto.common.commands.editor_commands_pb2 import (
|
||||
CreateItems, CreateItemsResponse)
|
||||
from kipy.util import pack_any
|
||||
|
||||
cmd = CreateItems()
|
||||
cmd.header.document.CopyFrom(board._doc)
|
||||
cmd.items.append(pack_any(ref.proto))
|
||||
result = board._kicad.send(cmd, CreateItemsResponse).created_items[0]
|
||||
if result.status.code != 1: # 1 = ISC_OK
|
||||
raise RuntimeError(
|
||||
f"KiCad rejected the image (status {result.status.code}) "
|
||||
f"{result.status.error_message or ''} - is the layer enabled "
|
||||
f"in Board Setup? (KiCad >= 10.0.1 required)")
|
||||
|
||||
|
||||
def remove_overlays(board: Board, layer) -> int:
|
||||
"""Remove every reference image on the given layer; returns count."""
|
||||
ours = [r for r in board.get_reference_images() if r.layer == layer]
|
||||
if ours:
|
||||
board.remove_items(ours)
|
||||
return len(ours)
|
||||
|
||||
|
||||
def push_result_overlays(board: Board, stack, result,
|
||||
lock: bool = False) -> None:
|
||||
"""EXPERIMENTAL: the solved |J| of every included copper layer as an
|
||||
unlocked reference image on config.OVERLAY_LAYERS (stackup order,
|
||||
top first; existing images there are replaced). Editor-only -
|
||||
reference images never plot. Per-layer failures are reported and
|
||||
skipped, never fatal to the run."""
|
||||
from kipy.board_types import ReferenceImage
|
||||
from kipy.geometry import Vector2
|
||||
|
||||
from .overlay import heatmap_png
|
||||
|
||||
names = stack.layer_names
|
||||
pairs = list(zip(names, config.OVERLAY_LAYERS))
|
||||
if len(names) > len(config.OVERLAY_LAYERS):
|
||||
print(f"overlays: more copper layers than slots - "
|
||||
f"{', '.join(names[len(config.OVERLAY_LAYERS):])} skipped")
|
||||
ny, nx = stack.shape2d
|
||||
w_nm, h_nm = nx * stack.h_nm, ny * stack.h_nm
|
||||
for src, dest_name in pairs:
|
||||
try:
|
||||
dest = layer_from_canonical_name(dest_name)
|
||||
png = heatmap_png(result.Jmag * 1e-6, names.index(src))
|
||||
remove_overlays(board, dest)
|
||||
ref = ReferenceImage()
|
||||
ref.layer = dest
|
||||
ref.position = Vector2.from_xy(round(stack.x0_nm + w_nm / 2),
|
||||
round(stack.y0_nm + h_nm / 2))
|
||||
ref.image_scale = w_nm / (nx * OVERLAY_PIX_NM)
|
||||
ref.image_data = png
|
||||
ref.locked = lock
|
||||
_create_reference_image(board, ref)
|
||||
print(f"overlay: |J| of {src} -> {dest_name} "
|
||||
f"({len(png) / 1024:.0f} kB)")
|
||||
except Exception as e:
|
||||
print(f"overlay: {src} -> {dest_name} failed: {e}")
|
||||
|
||||
|
||||
# --- top level ----------------------------------------------------------------
|
||||
|
||||
def build_problem(board: Board, net: str, layer_names: list[str],
|
||||
|
||||
@@ -78,6 +78,20 @@ ELECTRODE_POS_LAYER = "User.1" # rectangles on this layer mark V+ contact parts
|
||||
ELECTRODE_NEG_LAYER = "User.2" # rectangles on this layer mark V- contact parts
|
||||
ALWAYS_REFILL = False # refill zones even if KiCad says they are filled
|
||||
|
||||
# --- In-KiCad result overlays (EXPERIMENTAL) ---
|
||||
PUSH_OVERLAYS = False # after solving, push the per-layer |J|
|
||||
# heatmaps into the open board as unlocked
|
||||
# reference images (editor-only, never
|
||||
# plotted); dialog-toggleable
|
||||
OVERLAY_LAYERS = ("User.9", "User.10", "User.11", "User.12")
|
||||
# copper layers map here in stackup order
|
||||
# (top first); existing reference images on
|
||||
# these layers are REPLACED on every push;
|
||||
# each must be enabled in Board Setup
|
||||
OVERLAY_ALPHA = 255 # overlay opacity over copper (0-255);
|
||||
# translucency washes out over bright
|
||||
# copper - toggle the User layer instead
|
||||
|
||||
# --- Adaptive grid ---
|
||||
ADAPTIVE_CELLS = True # solve on a 2:1-balanced quadtree: fine at
|
||||
# copper boundaries/electrodes/features,
|
||||
|
||||
@@ -39,6 +39,7 @@ class Selection:
|
||||
vias_capped: bool = True
|
||||
cap_max_drill_mm: float = 0.5
|
||||
adaptive: bool = True
|
||||
push_overlays: bool = False # EXPERIMENTAL in-KiCad |J| overlays
|
||||
|
||||
|
||||
class _Dialog(QDialog):
|
||||
@@ -121,6 +122,14 @@ class _Dialog(QDialog):
|
||||
self.extracu_edit.setEnabled(bool(buildup_layers))
|
||||
form.addRow("Extra Cu in openings [µm]:", self.extracu_edit)
|
||||
|
||||
first, last = config.OVERLAY_LAYERS[0], config.OVERLAY_LAYERS[-1]
|
||||
self.overlay_check = QCheckBox(
|
||||
f"experimental: push per-layer |J| heatmaps into the board as "
|
||||
f"reference images on {first}..{last} (replaces images there; "
|
||||
f"layers must be enabled in Board Setup)")
|
||||
self.overlay_check.setChecked(config.PUSH_OVERLAYS)
|
||||
form.addRow("Overlays:", self.overlay_check)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
buttons.accepted.connect(self._try_accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
@@ -237,7 +246,8 @@ class _Dialog(QDialog):
|
||||
include_tracks=self.tracks_check.isChecked(),
|
||||
vias_capped=self.capped_check.isChecked(),
|
||||
cap_max_drill_mm=cap_max_drill,
|
||||
adaptive=self.adaptive_check.isChecked())
|
||||
adaptive=self.adaptive_check.isChecked(),
|
||||
push_overlays=self.overlay_check.isChecked())
|
||||
|
||||
def _try_accept(self) -> None:
|
||||
try:
|
||||
|
||||
@@ -102,9 +102,14 @@ def main() -> None:
|
||||
raise UserFacingError(f"KiCad API error: {e}")
|
||||
|
||||
report.write_geometry_dump(outdir, problem)
|
||||
overlay_cb = None
|
||||
if selection.push_overlays:
|
||||
def overlay_cb(stack, result):
|
||||
board_io.push_result_overlays(board, stack, result)
|
||||
pipeline.run(problem, outdir, show=True, i_test=selection.current_a,
|
||||
freq_hz=selection.freq_hz,
|
||||
contact_model=selection.contact_model)
|
||||
contact_model=selection.contact_model,
|
||||
overlay=overlay_cb)
|
||||
except UserFacingError as e:
|
||||
_fail(str(e), outdir)
|
||||
except Exception:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Rendering for the experimental in-KiCad result overlays: a solved
|
||||
field (|J|) as an RGBA PNG, one pixel per grid cell, transparent where
|
||||
there is no copper. The pushing side (ReferenceImages via the IPC API)
|
||||
lives in board_io; this module stays KiCad-free so it is testable
|
||||
headless.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import config
|
||||
|
||||
# the colormap's near-black bottom must stay distinguishable from
|
||||
# KiCad's dark canvas (matplotlib figures sit on a light background
|
||||
# instead), so the log scale starts this far up the colormap
|
||||
FLOOR = 0.18
|
||||
|
||||
|
||||
def heatmap_png(data3: np.ndarray, li: int, alpha: int | None = None,
|
||||
bleed: bool = True) -> bytes:
|
||||
"""One layer of a field (e.g. |J|, NaN = no copper) as opaque-over-
|
||||
copper RGBA PNG bytes. Color scale matches the plugin's log figure
|
||||
(global vmax across layers). `bleed` extends the edge color one
|
||||
pixel outward at half opacity: the raster mask covers cells whose
|
||||
CENTER is inside the copper, so without it the overlay stops half a
|
||||
cell short of the outline KiCad draws."""
|
||||
import matplotlib
|
||||
from PIL import Image
|
||||
from scipy import ndimage
|
||||
|
||||
if alpha is None:
|
||||
alpha = config.OVERLAY_ALPHA
|
||||
if not np.isfinite(data3).any():
|
||||
raise ValueError("field is empty - nothing to overlay")
|
||||
vmax = float(np.nanmax(data3))
|
||||
if vmax <= 0:
|
||||
raise ValueError("field is empty - nothing to overlay")
|
||||
vmin = vmax / config.CURRENT_DYNAMIC_RANGE
|
||||
d = np.clip(data3[li], vmin, vmax)
|
||||
if config.LOG_CURRENT_SCALE:
|
||||
u = (np.log(d) - np.log(vmin)) / (np.log(vmax) - np.log(vmin))
|
||||
else:
|
||||
u = d / vmax
|
||||
u = FLOOR + (1.0 - FLOOR) * u
|
||||
cmap = matplotlib.colormaps[config.CMAP_CURRENT]
|
||||
rgba = (cmap(np.nan_to_num(u)) * 255).astype(np.uint8)
|
||||
copper = ~np.isnan(data3[li])
|
||||
rgba[..., 3] = np.where(copper, alpha, 0)
|
||||
|
||||
if bleed and copper.any() and not copper.all():
|
||||
ring = ndimage.binary_dilation(
|
||||
copper, structure=np.ones((3, 3), dtype=bool)) & ~copper
|
||||
iy, ix = ndimage.distance_transform_edt(
|
||||
~copper, return_distances=False, return_indices=True)
|
||||
rgba[ring, :3] = rgba[iy[ring], ix[ring], :3]
|
||||
rgba[ring, 3] = alpha // 2
|
||||
|
||||
buf = io.BytesIO()
|
||||
# no dpi metadata: KiCad assumes its 300 PPI default, which the
|
||||
# pusher's scale computation relies on
|
||||
Image.fromarray(rgba, "RGBA").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
@@ -12,7 +12,9 @@ from .solver import Result
|
||||
|
||||
def run(problem: Problem, outdir: Path | None, show: bool = True,
|
||||
i_test: float | None = None, freq_hz: float = 0.0,
|
||||
contact_model: str | None = None) -> Result:
|
||||
contact_model: str | None = None, overlay=None) -> Result:
|
||||
"""overlay: optional callback(stack, result) run after the solve
|
||||
(EXPERIMENTAL in-KiCad overlays); its failures are non-fatal."""
|
||||
if i_test is None:
|
||||
i_test = config.TEST_CURRENT_A
|
||||
if i_test <= 0:
|
||||
@@ -43,6 +45,12 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
|
||||
report.write_summary(outdir, problem, stack, result)
|
||||
print(report.result_line(result, problem, stack))
|
||||
|
||||
if overlay is not None:
|
||||
try:
|
||||
overlay(stack, result)
|
||||
except Exception as e:
|
||||
print(f"overlay push failed: {e}")
|
||||
|
||||
figs = [
|
||||
(plots.fig_raster(stack, e1, e2, problem, result), "1_raster_map"),
|
||||
(plots.fig_potential(result, stack, e1, e2, problem), "2_potential"),
|
||||
|
||||
Reference in New Issue
Block a user