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>
119 lines
4.4 KiB
Python
119 lines
4.4 KiB
Python
"""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)
|