Build PCM package / build (push) Successful in 6s
tests / ubuntu-latest · py3.11 (push) Successful in 54s
tests / fedora:latest (push) Successful in 1m11s
tests / ubuntu:24.04 (push) Successful in 1m22s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Has been skipped
tests / ubuntu-latest · py3.13 (push) Successful in 2m42s
tests / archlinux:latest (push) Successful in 55s
tests / debian:12 (push) Successful in 3m5s
The dialog threshold field gets a unit selector: % of mean |J| (default, as before) or absolute A/mm2. The absolute variant applies at the solved operating point - |J| scales with the test current, so it is meant to be used with the real operating current entered as the test current (config comment, README and dialog say so). Switching units swaps in the other unit config default (TRIM_THRESHOLD_A_MM2 = 1.0 for absolute) but never clobbers a number the user typed. Validation stays per unit: 0..100 for %, > 0 for A/mm2. pipeline.run takes trim_pct/trim_abs (at most one), compute() mirrors that, and the JSON records threshold_mode + threshold_value next to the resolved threshold_a_per_mm2. Suite on dev 3.13 and the Python 3.9 mac-stack venv: 151 passed each; dialog wiring exercised offscreen (defaults, unit swap, validation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
156 lines
5.9 KiB
Python
156 lines
5.9 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, pct=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_low_current_mask_absolute():
|
|
J = np.full((1, 3, 3), np.nan)
|
|
J[0, 0, :] = 0.5e6 # 0.5 A/mm2 in A/m2
|
|
J[0, 1, :] = 2.0e6 # 2 A/mm2
|
|
mask, thr = trim.low_current_mask(J, abs_a_mm2=1.0)
|
|
assert thr == pytest.approx(1.0e6)
|
|
assert mask[0, 0, :].all()
|
|
assert not mask[0, 1, :].any()
|
|
|
|
|
|
def test_low_current_mask_needs_exactly_one_threshold():
|
|
J = np.ones((1, 2, 2))
|
|
with pytest.raises(ValueError):
|
|
trim.low_current_mask(J)
|
|
with pytest.raises(ValueError):
|
|
trim.low_current_mask(J, pct=10.0, abs_a_mm2=1.0)
|
|
|
|
|
|
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, pct=10.0)
|
|
assert tr.mode == "pct" and tr.value == 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)
|
|
assert "% of mean" in trim.summary_line(tr)
|
|
|
|
|
|
def test_compute_absolute_mode(tmp_path):
|
|
J = np.full((1, 10, 10), np.nan)
|
|
J[0, :, :] = 10.0e6 # 10 A/mm2
|
|
J[0, :, :5] = 0.1e6 # 0.1 A/mm2: below 1 A/mm2
|
|
stack = _stack(names=["F.Cu"], h_nm=1_000_000)
|
|
tr = trim.compute(SimpleNamespace(Jmag=J), stack, abs_a_mm2=1.0)
|
|
assert tr.mode == "abs" and tr.value == 1.0
|
|
assert tr.threshold_a_mm2 == pytest.approx(1.0)
|
|
assert tr.layers[0].marked_mm2 == pytest.approx(50.0)
|
|
line = trim.summary_line(tr)
|
|
assert "|J| < 1 A/mm2" in line and "% of mean" not in line
|
|
doc = json.loads(trim.write_json(tmp_path, tr)
|
|
.read_text(encoding="utf-8"))
|
|
assert doc["threshold_mode"] == "absolute"
|
|
assert doc["threshold_value"] == 1.0
|
|
|
|
|
|
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)
|