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
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:
@@ -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
@@ -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],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user