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>
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
"""Rasterization of the fill polygons onto a shared multi-layer grid, and
|
||||
electrode mask construction.
|
||||
|
||||
Grid convention: layer l, row i, col j maps to the cell center
|
||||
x = x0_nm + (j + 0.5) * h_nm
|
||||
y = y0_nm + (i + 0.5) * h_nm
|
||||
in KiCad board coordinates (y grows down). Row 0 is the minimum-y row,
|
||||
the TOP of the board as drawn in the editor; plots use origin='upper'.
|
||||
All layers share the same frame, so cell (i, j) is vertically aligned
|
||||
across layers (via links connect equal (i, j) on different layers).
|
||||
|
||||
Connectivity restriction lives in solver.py: it needs the via edges.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
from matplotlib.path import Path as MplPath
|
||||
from scipy import ndimage
|
||||
|
||||
from . import config
|
||||
from .errors import ElectrodeError, GridSizeError
|
||||
from .geometry import Electrode, Problem, Rect
|
||||
|
||||
# 4-connectivity: matches the in-plane 5-point stencil of the solver
|
||||
_STRUCT4 = ndimage.generate_binary_structure(2, 1)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RasterStack:
|
||||
masks: np.ndarray # bool (L, ny, nx), True = copper
|
||||
x0_nm: float # grid origin (outer corner of cell [., 0, 0])
|
||||
y0_nm: float
|
||||
h_nm: float
|
||||
layer_names: list[str]
|
||||
buildup: np.ndarray | None = None # bool (L, ny, nx): solder buildup
|
||||
# (mask opening ∩ copper)
|
||||
|
||||
@property
|
||||
def nlayers(self) -> int:
|
||||
return self.masks.shape[0]
|
||||
|
||||
@property
|
||||
def shape2d(self) -> tuple[int, int]:
|
||||
return self.masks.shape[1:]
|
||||
|
||||
def cell_centers(self, i0: int, i1: int, j0: int, j1: int):
|
||||
xs = self.x0_nm + (np.arange(j0, j1) + 0.5) * self.h_nm
|
||||
ys = self.y0_nm + (np.arange(i0, i1) + 0.5) * self.h_nm
|
||||
return np.meshgrid(xs, ys)
|
||||
|
||||
def cell_of(self, x_nm: float, y_nm: float) -> tuple[int, int] | None:
|
||||
"""(i, j) of the cell containing the point, or None if outside."""
|
||||
ny, nx = self.shape2d
|
||||
j = int((x_nm - self.x0_nm) / self.h_nm)
|
||||
i = int((y_nm - self.y0_nm) / self.h_nm)
|
||||
if 0 <= i < ny and 0 <= j < nx:
|
||||
return i, j
|
||||
return None
|
||||
|
||||
def extent_mm(self) -> tuple[float, float, float, float]:
|
||||
"""imshow extent (left, right, bottom, top) for origin='upper',
|
||||
y axis in board orientation (increasing downward)."""
|
||||
ny, nx = self.shape2d
|
||||
return (
|
||||
self.x0_nm * 1e-6,
|
||||
(self.x0_nm + nx * self.h_nm) * 1e-6,
|
||||
(self.y0_nm + ny * self.h_nm) * 1e-6,
|
||||
self.y0_nm * 1e-6,
|
||||
)
|
||||
|
||||
|
||||
def choose_cell_size(bbox_nm: tuple[int, int, int, int], nlayers: int) -> float:
|
||||
"""Pick the cell size h [nm]; TARGET_CELLS counts TOTAL cells across
|
||||
all layers. Raise if the grid would exceed HARD_MAX_CELLS."""
|
||||
x0, y0, x1, y1 = bbox_nm
|
||||
w, ht = float(x1 - x0), float(y1 - y0)
|
||||
if w <= 0 or ht <= 0:
|
||||
raise GridSizeError("Copper geometry has a degenerate bounding box.")
|
||||
|
||||
if config.CELL_UM_OVERRIDE is not None:
|
||||
h = config.CELL_UM_OVERRIDE * 1000.0
|
||||
else:
|
||||
h = math.sqrt(w * ht * nlayers / config.TARGET_CELLS)
|
||||
h = min(max(h, config.MIN_CELL_UM * 1000.0), config.MAX_CELL_UM * 1000.0)
|
||||
|
||||
ncells = math.ceil(w / h) * math.ceil(ht / h) * nlayers
|
||||
if ncells > config.HARD_MAX_CELLS:
|
||||
raise GridSizeError(
|
||||
f"Grid would need ~{ncells / 1e6:.1f} M cells over {nlayers} "
|
||||
f"layer(s) at cell size {h / 1000:.0f} um (limit "
|
||||
f"{config.HARD_MAX_CELLS / 1e6:.0f} M). Raise MAX_CELL_UM / "
|
||||
f"CELL_UM_OVERRIDE in config.py, deselect layers, or measure a "
|
||||
f"smaller region."
|
||||
)
|
||||
return h
|
||||
|
||||
|
||||
def _paint_ring(stack: RasterStack, ring: np.ndarray, value: bool,
|
||||
target: np.ndarray) -> None:
|
||||
"""Set target (2D) cells whose center lies inside ring to `value`,
|
||||
testing only cells within the ring's bbox (cheap for small holes)."""
|
||||
ny, nx = stack.shape2d
|
||||
h = stack.h_nm
|
||||
j0 = max(0, int((ring[:, 0].min() - stack.x0_nm) / h) - 1)
|
||||
j1 = min(nx, int((ring[:, 0].max() - stack.x0_nm) / h) + 2)
|
||||
i0 = max(0, int((ring[:, 1].min() - stack.y0_nm) / h) - 1)
|
||||
i1 = min(ny, int((ring[:, 1].max() - stack.y0_nm) / h) + 2)
|
||||
if i0 >= i1 or j0 >= j1:
|
||||
return
|
||||
xg, yg = stack.cell_centers(i0, i1, j0, j1)
|
||||
pts = np.column_stack([xg.ravel(), yg.ravel()])
|
||||
# Path(closed=True) treats the LAST vertex as the CLOSEPOLY dummy, so
|
||||
# the first vertex must be appended or the ring loses its last corner
|
||||
verts = np.vstack([ring, ring[:1]])
|
||||
inside = MplPath(verts, closed=True).contains_points(pts)
|
||||
inside = inside.reshape(i1 - i0, j1 - j0)
|
||||
sub = target[i0:i1, j0:j1]
|
||||
sub[inside] = value
|
||||
|
||||
|
||||
def rasterize_stack(problem: Problem, h_nm: float) -> RasterStack:
|
||||
"""Rasterize every included layer onto one shared frame."""
|
||||
x0, y0, x1, y1 = problem.copper_bbox()
|
||||
m = config.MARGIN_CELLS
|
||||
nx = math.ceil((x1 - x0) / h_nm) + 2 * m
|
||||
ny = math.ceil((y1 - y0) / h_nm) + 2 * m
|
||||
stack = RasterStack(
|
||||
masks=np.zeros((len(problem.layers), ny, nx), dtype=bool),
|
||||
x0_nm=x0 - m * h_nm,
|
||||
y0_nm=y0 - m * h_nm,
|
||||
h_nm=h_nm,
|
||||
layer_names=problem.layer_names,
|
||||
)
|
||||
for li, layer in enumerate(problem.layers):
|
||||
for poly in layer.polygons:
|
||||
pmask = np.zeros((ny, nx), dtype=bool)
|
||||
_paint_ring(stack, poly.outline, True, pmask)
|
||||
for hole in poly.holes:
|
||||
_paint_ring(stack, hole, False, pmask)
|
||||
stack.masks[li] |= pmask
|
||||
|
||||
if problem.buildups:
|
||||
stack.buildup = np.zeros_like(stack.masks)
|
||||
index = {name: li for li, name in enumerate(stack.layer_names)}
|
||||
for b in problem.buildups:
|
||||
li = index.get(b.layer_name)
|
||||
if li is None:
|
||||
continue
|
||||
for poly in b.polygons:
|
||||
pmask = np.zeros((ny, nx), dtype=bool)
|
||||
_paint_ring(stack, poly.outline, True, pmask)
|
||||
for hole in poly.holes:
|
||||
_paint_ring(stack, hole, False, pmask)
|
||||
stack.buildup[li] |= pmask
|
||||
stack.buildup &= stack.masks # solder wets exposed copper only
|
||||
return stack
|
||||
|
||||
|
||||
def _rect_cells(stack: RasterStack, rect: Rect) -> np.ndarray:
|
||||
"""Bool (ny, nx) mask of cells whose center lies inside the rectangle."""
|
||||
ny, nx = stack.shape2d
|
||||
h = stack.h_nm
|
||||
out = np.zeros((ny, nx), dtype=bool)
|
||||
j0 = max(0, int(math.ceil((rect.x0 - stack.x0_nm) / h - 0.5)))
|
||||
j1 = min(nx, int(math.floor((rect.x1 - stack.x0_nm) / h - 0.5)) + 1)
|
||||
i0 = max(0, int(math.ceil((rect.y0 - stack.y0_nm) / h - 0.5)))
|
||||
i1 = min(ny, int(math.floor((rect.y1 - stack.y0_nm) / h - 0.5)) + 1)
|
||||
if i0 < i1 and j0 < j1:
|
||||
out[i0:i1, j0:j1] = True
|
||||
return out
|
||||
|
||||
|
||||
def _electrode_cells2d(stack: RasterStack, e: Electrode) -> np.ndarray:
|
||||
"""2D footprint of the electrode shape (pad polygons or rectangle)."""
|
||||
if e.polygons:
|
||||
cells = np.zeros(stack.shape2d, dtype=bool)
|
||||
for poly in e.polygons:
|
||||
pm = np.zeros(stack.shape2d, dtype=bool)
|
||||
_paint_ring(stack, poly.outline, True, pm)
|
||||
for hole in poly.holes:
|
||||
_paint_ring(stack, hole, False, pm)
|
||||
cells |= pm
|
||||
if not cells.any():
|
||||
# shape smaller than one grid cell (small pad): use the cell
|
||||
# containing its center
|
||||
r = e.rect
|
||||
c = stack.cell_of((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2)
|
||||
if c is not None:
|
||||
cells[c] = True
|
||||
return cells
|
||||
return _rect_cells(stack, e.rect)
|
||||
|
||||
|
||||
def electrode_masks(stack: RasterStack, problem: Problem
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Terminal mask = OR over its parts; part = shape ∩ copper on the
|
||||
part's contact layer(s). contact 'all' = every included layer (bolted
|
||||
lug / through pad); a layer name = that layer only. Every part must
|
||||
individually land on copper (clear feedback). V+/V- must not overlap;
|
||||
touching is checked later, only for the equipotential contact model."""
|
||||
def build(parts: list[Electrode], which: str) -> np.ndarray:
|
||||
e = np.zeros_like(stack.masks)
|
||||
for el in parts:
|
||||
cells2d = _electrode_cells2d(stack, el)
|
||||
part = np.zeros_like(stack.masks)
|
||||
for li, name in enumerate(stack.layer_names):
|
||||
if el.contact == "all" or el.contact == name:
|
||||
part[li] = cells2d & stack.masks[li]
|
||||
if not part.any():
|
||||
raise ElectrodeError(
|
||||
f"A {which} contact part ({el.label}) does not overlap "
|
||||
f"any copper of the selected fill on contact layer(s) "
|
||||
f"'{el.contact}' (or is smaller than one grid cell)."
|
||||
)
|
||||
e |= part
|
||||
if not e.any():
|
||||
raise ElectrodeError(f"The {which} terminal has no contact parts.")
|
||||
return e
|
||||
|
||||
e1 = build(problem.electrodes1, "V+")
|
||||
e2 = build(problem.electrodes2, "V-")
|
||||
|
||||
if (e1 & e2).any():
|
||||
raise ElectrodeError(
|
||||
"The V+ and V- contact areas overlap on the copper grid. "
|
||||
"Move them apart."
|
||||
)
|
||||
return e1, e2
|
||||
|
||||
|
||||
def electrode_partition(stack: RasterStack, problem: Problem
|
||||
) -> tuple[list, list]:
|
||||
"""Per-part cell masks for both terminals, as [(label, mask3d), ...].
|
||||
Cells covered by several overlapping parts are attributed to the
|
||||
FIRST part (first-wins partition), so part currents sum exactly to
|
||||
the terminal current."""
|
||||
def build(parts: list[Electrode]) -> list:
|
||||
out = []
|
||||
claimed = np.zeros_like(stack.masks)
|
||||
for el in parts:
|
||||
cells2d = _electrode_cells2d(stack, el)
|
||||
m = np.zeros_like(stack.masks)
|
||||
for li, name in enumerate(stack.layer_names):
|
||||
if el.contact == "all" or el.contact == name:
|
||||
m[li] = cells2d & stack.masks[li]
|
||||
m &= ~claimed
|
||||
claimed |= m
|
||||
out.append((el.label, m))
|
||||
return out
|
||||
|
||||
return build(problem.electrodes1), build(problem.electrodes2)
|
||||
|
||||
|
||||
def electrodes_touch(stack: RasterStack, e1: np.ndarray,
|
||||
e2: np.ndarray) -> str | None:
|
||||
"""Layer name where the terminals are 4-adjacent, or None."""
|
||||
for li in range(stack.nlayers):
|
||||
if (ndimage.binary_dilation(e1[li], structure=_STRUCT4) & e2[li]).any():
|
||||
return stack.layer_names[li]
|
||||
return None
|
||||
Reference in New Issue
Block a user