Mark low-current copper as polygons on user layers (dialog opt-in)
Build PCM package / build (push) Successful in 7s
tests / ubuntu-latest · py3.11 (push) Successful in 53s
tests / ubuntu-latest · py3.13 (push) Successful in 1m43s
tests / archlinux:latest (push) Successful in 39s
tests / debian:12 (push) Successful in 49s
tests / fedora:latest (push) Successful in 1m1s
tests / ubuntu:24.04 (push) Successful in 1m26s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Has been skipped

New EXPERIMENTAL dialog option (default off): after the solve, copper
whose |J| is below a threshold (default 10% of the mean |J| over all
solved copper cells - mean, not max, since contact-corner spikes would
dwarf a max-relative threshold) is vectorized into filled graphic
polygons on TRIM_LAYERS (User.5..User.8, configurable), one polygon
per region so Edit > Convert can turn one into a rule area by hand.
Areas are printed and the polygons saved to low_current_copper.json.

The mask -> polygon step is the 0.5 contour of the binary field via
contourpy (already in every venv as matplotlib dependency), padded so
regions touching the raster edge close, simplified with
Douglas-Peucker at 0.4 cells: staircase bevels collapse, one-cell-wide
strips survive. Specks under TRIM_MIN_AREA_MM2 are dropped.

Explicitly a suggestion, not a safe cut list (docstring, dialog and
README all say so): copper carries little current BECAUSE the rest
carries it, so removal redistributes |J| - the constant-density
optimizer that iterates this to convergence is future work.

board_io: the create/delete-with-status-surfaced helpers are now
generic (_create_items_checked / _remove_items_checked) and shared
between reference-image overlays and trim polygons.

Tested on the dev stack (3.13) and the Python 3.9 mac-stack venv, 148
passed each; contourpy 1.3.x has identical API on both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-07-24 12:54:25 +07:00
co-authored by Claude Fable 5
parent 4f361d846a
commit 6d8634802d
8 changed files with 507 additions and 25 deletions
+18
View File
@@ -177,6 +177,24 @@ spelled out per step and in *Platform notes* below.
reference images on those layers**, so don't store unrelated images reference images on those layers**, so don't store unrelated images
there. Also available headless: there. Also available headless:
`python tools/kicad_heatmap_overlay.py --net X --amps 10`. `python tools/kicad_heatmap_overlay.py --net X --amps 10`.
6. **Experimental — low-current copper marking** (dialog checkbox,
default off): after the solve, the copper whose |J| is **below a
threshold** (dialog-settable, default 10 % of the mean |J| over all
solved copper) is outlined as **filled graphic polygons** on
`User.5`…`User.8` (`TRIM_LAYERS` in `fill_resistance/config.py`;
enable them in Board Setup), copper layers mapped in stackup order,
top first. Marked specks under `TRIM_MIN_AREA_MM2` (0.5 mm²) are
dropped. Each region is one selectable polygon — use KiCad's
**Edit → Convert** to turn one into a rule area or zone cutout by
hand. Per-layer areas are printed to the Messages panel and the
polygons also land in `low_current_copper.json` next to the PNGs.
Every push **replaces all graphic polygons on those layers** (one
undo step). **This is a suggestion, not a safe cut list**: copper
carries little current *because* the rest carries it — removing
copper redistributes the current and raises |J| everywhere else, so
re-run after any change. The pour may also serve thermal spreading,
EMI return paths, or plane capacitance, which this DC analysis does
not see.
## Model & limits ## Model & limits
+117 -20
View File
@@ -649,7 +649,8 @@ def gather_tht_pad_copper(board: Board, net_name: str
OVERLAY_PIX_NM = 25.4e6 / 300 OVERLAY_PIX_NM = 25.4e6 / 300
def _create_reference_image(board: Board, ref) -> None: def _create_items_checked(board: Board, items, what: str,
hint: str = "") -> None:
"""create_items with the per-item status surfaced (kipy <= 0.7.1 """create_items with the per-item status surfaced (kipy <= 0.7.1
swallows it and returns an empty wrapper on failure).""" swallows it and returns an empty wrapper on failure)."""
from kipy.proto.common.commands.editor_commands_pb2 import ( from kipy.proto.common.commands.editor_commands_pb2 import (
@@ -658,33 +659,33 @@ def _create_reference_image(board: Board, ref) -> None:
cmd = CreateItems() cmd = CreateItems()
cmd.header.document.CopyFrom(board._doc) cmd.header.document.CopyFrom(board._doc)
cmd.items.append(pack_any(ref.proto)) for item in items:
result = board._kicad.send(cmd, CreateItemsResponse).created_items[0] cmd.items.append(pack_any(item.proto))
if result.status.code != 1: # 1 = ISC_OK results = board._kicad.send(cmd, CreateItemsResponse).created_items
bad = [r for r in results if r.status.code != 1] # 1 = ISC_OK
if bad or len(results) != len(items):
detail = (f"status {bad[0].status.code} "
f"{bad[0].status.error_message or ''}" if bad
else f"{len(items) - len(results)} item(s) not created")
raise RuntimeError( raise RuntimeError(
f"KiCad rejected the image (status {result.status.code}) " f"KiCad rejected the {what} ({detail}) - is the layer "
f"{result.status.error_message or ''} - is the layer enabled " f"enabled in Board Setup?{hint}")
f"in Board Setup? (KiCad >= 10.0.1 required)")
def remove_overlays(board: Board, layer) -> int: def _remove_items_checked(board: Board, items, what: str) -> int:
"""Remove every reference image on the given layer; returns count. """remove_items with the per-item status surfaced: kipy discards the
remove_items with the per-item status surfaced: kipy discards the
DeleteItemsResponse, and its own proto warns the overall status "may DeleteItemsResponse, and its own proto warns the overall status "may
return IRS_OK even if no items were deleted" - a locked image comes return IRS_OK even if no items were deleted" - a locked item comes
back IDS_IMMUTABLE. Unchecked, the stale image survives and the new back IDS_IMMUTABLE. Unchecked, the stale item survives and the new
one is stacked on top of it instead of replacing it.""" one is stacked on top of it instead of replacing it."""
from kipy.proto.common.commands.editor_commands_pb2 import ( from kipy.proto.common.commands.editor_commands_pb2 import (
DeleteItems, DeleteItemsResponse, ItemDeletionStatus) DeleteItems, DeleteItemsResponse, ItemDeletionStatus)
ours = [r for r in board.get_reference_images() if r.layer == layer] if not items:
if not ours:
return 0 return 0
cmd = DeleteItems() cmd = DeleteItems()
cmd.header.document.CopyFrom(board._doc) cmd.header.document.CopyFrom(board._doc)
cmd.item_ids.extend([r.id for r in ours]) cmd.item_ids.extend([it.id for it in items])
results = board._kicad.send(cmd, DeleteItemsResponse).deleted_items results = board._kicad.send(cmd, DeleteItemsResponse).deleted_items
stuck = [r for r in results stuck = [r for r in results
@@ -694,13 +695,20 @@ def remove_overlays(board: Board, layer) -> int:
locked = sum(1 for r in stuck locked = sum(1 for r in stuck
if r.status == ItemDeletionStatus.IDS_IMMUTABLE) if r.status == ItemDeletionStatus.IDS_IMMUTABLE)
raise RuntimeError( raise RuntimeError(
f"{len(stuck)} existing overlay image(s) could not be removed" f"{len(stuck)} existing {what}(s) could not be removed"
+ (f" ({locked} locked)" if locked else "") + (f" ({locked} locked)" if locked else "")
+ " - unlock them in KiCad, or delete them by hand, then run " + " - unlock them in KiCad, or delete them by hand, then run "
"again (a new image would otherwise stack on top).") "again (the replacement would otherwise stack on top).")
return len(results) return len(results)
def remove_overlays(board: Board, layer) -> int:
"""Remove every reference image on the given layer; returns count."""
return _remove_items_checked(
board, [r for r in board.get_reference_images() if r.layer == layer],
"overlay image")
def push_result_overlays(board: Board, stack, result, def push_result_overlays(board: Board, stack, result,
lock: bool = False) -> None: lock: bool = False) -> None:
"""EXPERIMENTAL: the solved |J| of every included copper layer as an """EXPERIMENTAL: the solved |J| of every included copper layer as an
@@ -748,7 +756,8 @@ def push_result_overlays(board: Board, stack, result,
ref.image_scale = w_nm / (nx * OVERLAY_PIX_NM) ref.image_scale = w_nm / (nx * OVERLAY_PIX_NM)
ref.image_data = png ref.image_data = png
ref.locked = lock ref.locked = lock
_create_reference_image(board, ref) _create_items_checked(board, [ref], "image",
" (KiCad >= 10.0.1 required)")
print(f"overlay: |J| of {src} -> {dest_name} " print(f"overlay: |J| of {src} -> {dest_name} "
f"({len(png) / 1024:.0f} kB)") f"({len(png) / 1024:.0f} kB)")
except Exception as e: except Exception as e:
@@ -764,6 +773,94 @@ def push_result_overlays(board: Board, stack, result,
pass pass
# --- low-current copper polygons (EXPERIMENTAL) ------------------------------
def remove_trim_polygons(board: Board, layer) -> int:
"""Remove every graphic polygon on the given layer; returns count."""
from kipy.board_types import BoardPolygon
return _remove_items_checked(
board, [s for s in board.get_shapes()
if isinstance(s, BoardPolygon) and s.layer == layer],
"trim polygon")
def _trim_shape(tp, layer, lock: bool):
"""One filled BoardPolygon (outline + holes) on the given layer -
individually selectable, so Edit > Convert can turn it into a rule
area or a zone cutout by hand."""
from kipy.board_types import BoardPolygon
from kipy.geometry import PolygonWithHoles, PolyLine, PolyLineNode
def poly_line(ring) -> PolyLine:
line = PolyLine()
for x, y in ring.tolist():
line.append(PolyLineNode.from_xy(int(x), int(y)))
line.closed = True
return line
pwh = PolygonWithHoles()
pwh.outline = poly_line(tp.outline)
for hole in tp.holes:
pwh.add_hole(poly_line(hole))
shape = BoardPolygon()
shape.layer = layer
shape.locked = lock
shape.attributes.fill.filled = True
shape.polygons.append(pwh)
return shape
def push_trim_polygons(board: Board, trim, lock: bool = False) -> None:
"""EXPERIMENTAL: the below-threshold copper of every included layer
as filled graphic polygons on config.TRIM_LAYERS (stackup order, top
first; existing polygons on those layers are REPLACED, and slots
this run does not write are cleared so no stale suggestion is left
behind). The whole push is one commit, so a single undo reverts it.
Per-layer failures are reported and skipped, never fatal to the
run."""
pairs = list(zip(trim.layers, config.TRIM_LAYERS))
if len(trim.layers) > len(config.TRIM_LAYERS):
skipped = [lt.layer for lt in trim.layers[len(config.TRIM_LAYERS):]]
print(f"trim: more copper layers than slots - "
f"{', '.join(skipped)} skipped")
commit = board.begin_commit() if hasattr(board, "begin_commit") else None
done = False
try:
for dest_name in config.TRIM_LAYERS[len(pairs):]:
try:
if remove_trim_polygons(board,
layer_from_canonical_name(dest_name)):
print(f"trim: cleared stale {dest_name}")
except Exception as e:
print(f"trim: clearing stale {dest_name} failed: {e}")
for lt, dest_name in pairs:
try:
dest = layer_from_canonical_name(dest_name)
remove_trim_polygons(board, dest)
if lt.polygons:
_create_items_checked(
board,
[_trim_shape(tp, dest, lock) for tp in lt.polygons],
"trim polygon")
print(f"trim: {lt.layer} -> {dest_name} "
f"({len(lt.polygons)} polygon(s), "
f"{lt.marked_mm2:.1f} mm2)")
except Exception as e:
print(f"trim: {lt.layer} -> {dest_name} failed: {e}")
if commit is not None:
board.push_commit(commit, "Fill Resistance low-current copper")
done = True
finally:
if commit is not None and not done:
try:
board.drop_commit(commit)
except Exception:
pass
# --- top level ---------------------------------------------------------------- # --- top level ----------------------------------------------------------------
def build_problem(board: Board, net: str, layer_names: list[str], def build_problem(board: Board, net: str, layer_names: list[str],
+19
View File
@@ -95,6 +95,25 @@ OVERLAY_ALPHA = 255 # overlay opacity over copper (0-255);
# translucency washes out over bright # translucency washes out over bright
# copper - toggle the User layer instead # copper - toggle the User layer instead
# --- Low-current copper marking (EXPERIMENTAL) ---
TRIM_ENABLED = False # dialog default: mark the copper below
# TRIM_THRESHOLD_PCT as polygons on
# TRIM_LAYERS. A suggestion, not a safe
# cut list: copper carries little current
# BECAUSE the rest carries it - removal
# redistributes |J|, re-run after changes
TRIM_THRESHOLD_PCT = 10.0 # threshold as % of the mean |J| over all
# solved copper cells (mean, not max:
# contact-corner spikes would dwarf a
# max-relative threshold); dialog-settable
TRIM_LAYERS = ("User.5", "User.6", "User.7", "User.8")
# copper layers map here in stackup order
# (top first); existing polygons on these
# layers are REPLACED on every push; each
# must be enabled in Board Setup
TRIM_MIN_AREA_MM2 = 0.5 # marked specks smaller than this are
# dropped (nothing useful to reclaim)
# --- Adaptive grid --- # --- Adaptive grid ---
ADAPTIVE_CELLS = True # solve on a 2:1-balanced quadtree: fine at ADAPTIVE_CELLS = True # solve on a 2:1-balanced quadtree: fine at
# copper boundaries/electrodes/features, # copper boundaries/electrodes/features,
+24 -1
View File
@@ -40,6 +40,8 @@ class Selection:
cap_max_drill_mm: float = 0.5 cap_max_drill_mm: float = 0.5
adaptive: bool = True adaptive: bool = True
push_overlays: bool = False # EXPERIMENTAL in-KiCad |J| overlays push_overlays: bool = False # EXPERIMENTAL in-KiCad |J| overlays
trim_enabled: bool = False # EXPERIMENTAL low-current copper marking
trim_pct: float = 10.0 # threshold as % of the mean |J|
class _Dialog(QDialog): class _Dialog(QDialog):
@@ -130,6 +132,19 @@ class _Dialog(QDialog):
self.overlay_check.setChecked(config.PUSH_OVERLAYS) self.overlay_check.setChecked(config.PUSH_OVERLAYS)
form.addRow("Overlays:", self.overlay_check) form.addRow("Overlays:", self.overlay_check)
tfirst, tlast = config.TRIM_LAYERS[0], config.TRIM_LAYERS[-1]
self.trim_check = QCheckBox(
f"experimental: mark copper below the threshold as polygons "
f"on {tfirst}..{tlast} (replaces polygons there; a suggestion "
f"only - removing copper shifts current elsewhere)")
self.trim_check.setChecked(config.TRIM_ENABLED)
form.addRow("Low-current copper:", self.trim_check)
self.trim_edit = QLineEdit(f"{config.TRIM_THRESHOLD_PCT:g}")
self.trim_edit.setEnabled(config.TRIM_ENABLED)
self.trim_check.toggled.connect(self.trim_edit.setEnabled)
form.addRow("Threshold [% of mean |J|]:", self.trim_edit)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._try_accept) buttons.accepted.connect(self._try_accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
@@ -227,6 +242,12 @@ class _Dialog(QDialog):
extra_cu = number(self.extracu_edit, "Extra Cu") extra_cu = number(self.extracu_edit, "Extra Cu")
if extra_cu < 0: if extra_cu < 0:
raise ValueError("Extra Cu must be ≥ 0 µm.") raise ValueError("Extra Cu must be ≥ 0 µm.")
trim_pct = config.TRIM_THRESHOLD_PCT
if self.trim_check.isChecked():
trim_pct = number(self.trim_edit, "Trim threshold")
if not 0 < trim_pct < 100:
raise ValueError("Trim threshold must be between 0 and "
"100 (% of the mean |J|).")
cap_max_drill = config.CAP_MAX_DRILL_MM cap_max_drill = config.CAP_MAX_DRILL_MM
if self.capped_check.isChecked(): if self.capped_check.isChecked():
cap_max_drill = number(self.cap_drill_edit, "Capped up to drill") cap_max_drill = number(self.cap_drill_edit, "Capped up to drill")
@@ -251,7 +272,9 @@ class _Dialog(QDialog):
vias_capped=self.capped_check.isChecked(), vias_capped=self.capped_check.isChecked(),
cap_max_drill_mm=cap_max_drill, cap_max_drill_mm=cap_max_drill,
adaptive=self.adaptive_check.isChecked(), adaptive=self.adaptive_check.isChecked(),
push_overlays=self.overlay_check.isChecked()) push_overlays=self.overlay_check.isChecked(),
trim_enabled=self.trim_check.isChecked(),
trim_pct=trim_pct)
def _try_accept(self) -> None: def _try_accept(self) -> None:
try: try:
+8 -1
View File
@@ -136,10 +136,17 @@ def main() -> None:
if selection.push_overlays: if selection.push_overlays:
def overlay_cb(stack, result): def overlay_cb(stack, result):
board_io.push_result_overlays(board, stack, result) board_io.push_result_overlays(board, stack, result)
trim_cb = None
if selection.trim_enabled:
def trim_cb(tr):
board_io.push_trim_polygons(board, tr)
pipeline.run(problem, outdir, show=True, i_test=selection.current_a, pipeline.run(problem, outdir, show=True, i_test=selection.current_a,
freq_hz=selection.freq_hz, freq_hz=selection.freq_hz,
contact_model=selection.contact_model, contact_model=selection.contact_model,
overlay=overlay_cb) overlay=overlay_cb,
trim_pct=(selection.trim_pct if selection.trim_enabled
else None),
trim_push=trim_cb)
except progress.Cancelled: except progress.Cancelled:
print("cancelled") # user's own doing: no error figure print("cancelled") # user's own doing: no error figure
except UserFacingError as e: except UserFacingError as e:
+20 -3
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from . import config, plots, progress, raster, report, solver from . import config, plots, progress, raster, report, solver, trim
from .errors import UserFacingError from .errors import UserFacingError
from .geometry import Problem from .geometry import Problem
from .solver import Result from .solver import Result
@@ -12,9 +12,15 @@ from .solver import Result
def run(problem: Problem, outdir: Path | None, show: bool = True, def run(problem: Problem, outdir: Path | None, show: bool = True,
i_test: float | None = None, freq_hz: float = 0.0, i_test: float | None = None, freq_hz: float = 0.0,
contact_model: str | None = None, overlay=None) -> Result: contact_model: str | None = None, overlay=None,
trim_pct: float | None = None, trim_push=None) -> Result:
"""overlay: optional callback(stack, result) run after the solve """overlay: optional callback(stack, result) run after the solve
(EXPERIMENTAL in-KiCad overlays); its failures are non-fatal.""" (EXPERIMENTAL in-KiCad overlays); its failures are non-fatal.
trim_pct: mark copper below this % of the mean |J| (None = off):
per-layer areas are printed, polygons saved to
<outdir>/low_current_copper.json and handed to trim_push, an
optional callback(trim_result) that pushes them into the board
(failures non-fatal)."""
if i_test is None: if i_test is None:
i_test = config.TEST_CURRENT_A i_test = config.TEST_CURRENT_A
if i_test <= 0: if i_test <= 0:
@@ -51,6 +57,17 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
except Exception as e: except Exception as e:
print(f"overlay push failed: {e}") print(f"overlay push failed: {e}")
if trim_pct is not None:
tr = trim.compute(result, stack, trim_pct)
print(trim.summary_line(tr))
if outdir is not None:
trim.write_json(outdir, tr)
if trim_push is not None:
try:
trim_push(tr)
except Exception as e:
print(f"trim push failed: {e}")
progress.stage("rendering figures ...") progress.stage("rendering figures ...")
figs = [ figs = [
(plots.fig_raster(stack, e1, e2, problem, result), "1_raster_map"), (plots.fig_raster(stack, e1, e2, problem, result), "1_raster_map"),
+183
View File
@@ -0,0 +1,183 @@
"""Low-current copper marking (EXPERIMENTAL): polygons around the copper
that carries almost no current at the solved operating point.
The mask is |J| < threshold, the threshold given as a percentage of the
MEAN |J| over the copper cells of every solved layer (mean, not max:
|J| spikes at contact corners would dwarf a max-relative threshold).
Cell mask -> polygons via the 0.5 contour of the binary field
(contourpy, matplotlib's own contour engine - already installed in
every plugin venv), simplified with Douglas-Peucker so the staircase
bevels collapse but one-cell-wide strips survive.
The marked copper is a SUGGESTION, not a safe cut list: it carries
little current BECAUSE the rest carries it - removing copper
redistributes the current and raises |J| everywhere else. Re-run after
any change.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from . import config
JSON_NAME = "low_current_copper.json"
@dataclass
class TrimPolygon:
outline: np.ndarray # (N, 2) int64 board nm, unclosed ring
holes: list[np.ndarray] # same format
@dataclass
class LayerTrim:
layer: str # copper layer name
polygons: list[TrimPolygon]
marked_mm2: float # below-threshold copper area
copper_mm2: float # total copper area of the layer
@dataclass
class TrimResult:
threshold_pct: float
threshold_a_mm2: float # the absolute threshold this run used
layers: list[LayerTrim] # stackup order, top first
def low_current_mask(Jmag: np.ndarray,
threshold_pct: float) -> tuple[np.ndarray, float]:
"""(L, ny, nx) |J| in A/m2 with NaN outside copper -> boolean mask of
the copper cells below threshold_pct % of the mean |J|, plus the
absolute threshold (A/m2). The mean is global over all layers: a
layer that carries little current overall is exactly the copper the
mask should show, not a reason to lower its own threshold."""
copper = np.isfinite(Jmag)
if not copper.any():
raise ValueError("no copper cells in the solved field")
thr = float(np.nanmean(Jmag)) * threshold_pct / 100.0
below = np.zeros(Jmag.shape, dtype=bool)
below[copper] = Jmag[copper] < thr
return below, thr
def _rdp(pts: np.ndarray, tol: float) -> np.ndarray:
"""Iterative Douglas-Peucker; the first and last point always stay."""
n = len(pts)
if n < 3:
return pts
keep = np.zeros(n, dtype=bool)
keep[0] = keep[-1] = True
stack = [(0, n - 1)]
while stack:
i0, i1 = stack.pop()
if i1 <= i0 + 1:
continue
seg = pts[i1] - pts[i0]
rel = pts[i0 + 1:i1] - pts[i0]
length = float(np.hypot(seg[0], seg[1]))
if length == 0.0:
d = np.hypot(rel[:, 0], rel[:, 1])
else:
d = np.abs(rel[:, 0] * seg[1] - rel[:, 1] * seg[0]) / length
k = int(np.argmax(d))
if d[k] > tol:
j = i0 + 1 + k
keep[j] = True
stack.append((i0, j))
stack.append((j, i1))
return pts[keep]
def _ring_area_nm2(ring: np.ndarray) -> float:
x = ring[:, 0].astype(np.float64)
y = ring[:, 1].astype(np.float64)
return abs(float(np.dot(x, np.roll(y, -1))
- np.dot(y, np.roll(x, -1)))) / 2.0
def mask_to_polygons(mask2: np.ndarray, x0_nm: float, y0_nm: float,
h_nm: float, min_area_mm2: float) -> list[TrimPolygon]:
"""Boolean cell mask -> TrimPolygons in board nm. The boundary runs
along cell edges, corners cut at 45 degrees by the marching-squares
interpolation - half a cell, below the model's own resolution."""
if not mask2.any():
return []
import contourpy
# a ring of 0-cells so regions touching the grid edge close exactly
# on the raster boundary
z = np.pad(mask2.astype(np.float32), 1)
xs = x0_nm + (np.arange(z.shape[1], dtype=np.float64) - 0.5) * h_nm
ys = y0_nm + (np.arange(z.shape[0], dtype=np.float64) - 0.5) * h_nm
gen = contourpy.contour_generator(
x=xs, y=ys, z=z, fill_type=contourpy.FillType.OuterOffset)
points_list, offsets_list = gen.filled(0.5, 1.5)
tol = 0.4 * h_nm # > 0.354h kills the staircase bevels, < 0.5h
# keeps the half-width of a one-cell-wide strip
out: list[TrimPolygon] = []
for pts, offs in zip(points_list, offsets_list):
rings = []
for i in range(len(offs) - 1):
ring = pts[offs[i]:offs[i + 1] - 1] # drop closing duplicate
rings.append(np.rint(_rdp(ring, tol)).astype(np.int64))
if _ring_area_nm2(rings[0]) < min_area_mm2 * 1e12:
continue # speck: nothing to reclaim
out.append(TrimPolygon(outline=rings[0], holes=rings[1:]))
return out
def compute(result, stack, threshold_pct: float) -> TrimResult:
"""Threshold the solved |J| and vectorize the below-threshold copper
of every layer; areas are cell counts (exact for the model)."""
below, thr = low_current_mask(result.Jmag, threshold_pct)
cell_mm2 = (stack.h_nm * 1e-6) ** 2
layers = []
for li, name in enumerate(stack.layer_names):
polys = mask_to_polygons(below[li], stack.x0_nm, stack.y0_nm,
stack.h_nm, config.TRIM_MIN_AREA_MM2)
layers.append(LayerTrim(
layer=name, polygons=polys,
marked_mm2=float(below[li].sum()) * cell_mm2,
copper_mm2=float(np.isfinite(result.Jmag[li]).sum()) * cell_mm2))
return TrimResult(threshold_pct=threshold_pct,
threshold_a_mm2=thr * 1e-6, layers=layers)
def summary_line(trim: TrimResult) -> str:
parts = []
for lt in trim.layers:
pct = (f" ({100.0 * lt.marked_mm2 / lt.copper_mm2:.0f}%)"
if lt.copper_mm2 else "")
parts.append(f"{lt.layer} {lt.marked_mm2:.1f} mm2{pct}")
return (f"low-current copper (|J| < {trim.threshold_pct:g}% of mean "
f"= {trim.threshold_a_mm2:.3g} A/mm2): " + "; ".join(parts))
def write_json(outdir: Path, trim: TrimResult) -> Path:
def ring_mm(ring: np.ndarray) -> list:
return [[round(x * 1e-6, 4), round(y * 1e-6, 4)]
for x, y in ring.tolist()]
p = Path(outdir) / JSON_NAME
doc = {
"threshold_pct_of_mean_J": trim.threshold_pct,
"threshold_a_per_mm2": trim.threshold_a_mm2,
"note": ("marked = copper below the threshold at the solved "
"operating point; removing copper redistributes the "
"current and raises |J| elsewhere - re-run after changes"),
"layers": [{
"layer": lt.layer,
"marked_mm2": round(lt.marked_mm2, 3),
"copper_mm2": round(lt.copper_mm2, 3),
"polygons": [{"outline_mm": ring_mm(tp.outline),
"holes_mm": [ring_mm(h) for h in tp.holes]}
for tp in lt.polygons],
} for lt in trim.layers],
}
p.write_text(json.dumps(doc, indent=1), encoding="utf-8")
return p
+118
View File
@@ -0,0 +1,118 @@
"""Low-current copper marking: threshold mask -> polygons in board nm.
The kipy pushing side is exercised only against a live KiCad (as for
the overlays); the proto assembly of a single polygon is testable
offline and covered here.
"""
import json
from types import SimpleNamespace
import numpy as np
import pytest
from fill_resistance import trim
def _stack(names=("F.Cu",), h_nm=100_000, x0=0, y0=0):
return SimpleNamespace(layer_names=list(names), h_nm=h_nm,
x0_nm=x0, y0_nm=y0)
def test_low_current_mask_threshold():
J = np.full((1, 4, 4), np.nan)
J[0, :2, :] = 1.0 # 8 cells carrying little
J[0, 2, :2] = 100.0 # 2 hot cells; mean = 20.8
mask, thr = trim.low_current_mask(J, 10.0)
assert thr == pytest.approx(2.08)
assert mask[0, :2, :].all()
assert not mask[0, 2, :2].any()
assert not mask[0, 3, :].any() # NaN = no copper, never marked
def test_mask_rectangle_polygon():
m = np.zeros((20, 30), dtype=bool)
m[5:15, 4:9] = True
polys = trim.mask_to_polygons(m, x0_nm=0, y0_nm=0, h_nm=1000,
min_area_mm2=0.0)
assert len(polys) == 1
p = polys[0]
assert p.holes == []
xs, ys = p.outline[:, 0], p.outline[:, 1]
# the boundary runs on the cell edges of the marked block
assert xs.min() == 4000 and xs.max() == 9000
assert ys.min() == 5000 and ys.max() == 15000
# RDP collapsed the straight runs: 2 bevel points per corner plus at
# most one leftover at the ring seam (first/last are fixed anchors)
assert len(p.outline) <= 9
def test_mask_with_hole():
m = np.zeros((20, 20), dtype=bool)
m[2:18, 2:18] = True
m[8:12, 8:12] = False
polys = trim.mask_to_polygons(m, 0, 0, 1000, min_area_mm2=0.0)
assert len(polys) == 1
assert len(polys[0].holes) == 1
def test_mask_touching_grid_edge_closes():
# the padding ring must close regions that touch the raster edge
# exactly on the raster boundary
m = np.ones((5, 8), dtype=bool)
polys = trim.mask_to_polygons(m, 0, 0, 1000, min_area_mm2=0.0)
assert len(polys) == 1
xs, ys = polys[0].outline[:, 0], polys[0].outline[:, 1]
assert xs.min() == 0 and xs.max() == 8000
assert ys.min() == 0 and ys.max() == 5000
def test_min_area_drops_specks():
m = np.zeros((10, 10), dtype=bool)
m[5, 5] = True # one 100 um cell = 0.01 mm2
assert trim.mask_to_polygons(m, 0, 0, 100_000, min_area_mm2=0.5) == []
assert len(trim.mask_to_polygons(m, 0, 0, 100_000,
min_area_mm2=0.0)) == 1
def test_compute_and_json(tmp_path):
J = np.full((2, 10, 10), np.nan)
J[0, :, :] = 10.0
J[0, :, :5] = 0.01 # half of the top layer nearly dead
J[1, :, :] = 10.0
stack = _stack(names=["F.Cu", "B.Cu"], h_nm=1_000_000)
tr = trim.compute(SimpleNamespace(Jmag=J), stack, 10.0)
assert [lt.layer for lt in tr.layers] == ["F.Cu", "B.Cu"]
assert tr.layers[0].polygons and not tr.layers[1].polygons
assert tr.layers[0].marked_mm2 == pytest.approx(50.0)
assert tr.layers[0].copper_mm2 == pytest.approx(100.0)
# mean = (50*0.01 + 150*10) / 200 = 7.5025 A/m2, threshold 10% of it
assert tr.threshold_a_mm2 == pytest.approx(0.75025e-6)
p = trim.write_json(tmp_path, tr)
doc = json.loads(p.read_text(encoding="utf-8"))
assert doc["layers"][0]["marked_mm2"] == pytest.approx(50.0)
ring = doc["layers"][0]["polygons"][0]["outline_mm"]
assert all(0 <= x <= 5.5 and 0 <= y <= 10.0 for x, y in ring)
assert "F.Cu" in trim.summary_line(tr)
def test_trim_shape_proto():
from kipy.util.board_layer import layer_from_canonical_name
from fill_resistance import board_io
tp = trim.TrimPolygon(
outline=np.array([[0, 0], [10000, 0], [10000, 5000], [0, 5000]],
dtype=np.int64),
holes=[np.array([[2000, 1000], [3000, 1000], [3000, 2000]],
dtype=np.int64)])
layer = layer_from_canonical_name("User.5")
proto = board_io._trim_shape(tp, layer, lock=False).proto
poly = proto.shape.polygon.polygons[0]
assert len(poly.outline.nodes) == 4 and poly.outline.closed
assert len(poly.holes) == 1 and len(poly.holes[0].nodes) == 3
assert poly.holes[0].closed
assert proto.layer == layer
from kipy.proto.common.types.base_types_pb2 import GraphicFillType
assert (proto.shape.attributes.fill.fill_type
== GraphicFillType.GFT_FILLED)