Files
janik bc95b444b4
Build PCM package / build (push) Successful in 6s
Model SMD pad copper: exact net pad shapes stamped on their layers
Pads are the junctions where traces and thermal-relief spokes actually
meet; without their copper a multi-track junction necks down to the
accidental overlap of the rounded track ends, or is severed outright.
gather_smd_pad_copper fetches the exact shape of every undrilled pad
on the net (one API call per pad, layer from the padstack) and
build_problem stamps them onto their own layer (INCLUDE_SMD_PADS).
Selected SMD-pad contacts get their real copper as a side effect
(previously electrode-shape intersected whatever lay underneath).
Dead-end pads become floating islands that the existing connectivity
restriction drops.

Closes the documented SMD-pad-copper limitation (prompted by the
padne comparison).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:37:06 +07:00

284 lines
12 KiB
Python

"""Track (trace) conductor tests: capsule / arc-band outline generation,
solves on rasterized traces, and the 1D resistor-chain model for traces
narrower than TRACK_1D_FACTOR grid cells."""
import math
import numpy as np
import pytest
from fill_resistance import raster, solver
from fill_resistance.geometry import (Electrode, LayerFill, Polygon, Problem,
TrackSeg, arc_band_ring, capsule_ring,
load_problem, save_problem)
from tests.util import NM, rect_mm, sigma_s
TOL_NM = 10_000
RHO = 1.68e-8
T_M = 70e-6
def _track_problem(rings, rect1, rect2, t_um=70.0):
return Problem(
board_path="synthetic", net_name="TEST", rho_ohm_m=1.68e-8,
plating_nm=18_000,
layers=[LayerFill(layer_name="F.Cu", thickness_nm=int(t_um * 1000),
z_nm=0,
polygons=[Polygon(outline=r) for r in rings])],
vias=[],
electrodes1=[Electrode(rect=rect_mm(rect1))],
electrodes2=[Electrode(rect=rect_mm(rect2))],
)
def _seg(points_mm, w_mm, layer="F.Cu") -> TrackSeg:
pts = (np.asarray(points_mm, dtype=float) * NM).astype(np.int64)
return TrackSeg(layer_name=layer, points=pts, width_nm=int(w_mm * NM))
def _seg_problem(segs, rect1, rect2, fills_mm=(), t_um=70.0):
polys = [Polygon(outline=(np.asarray(o, dtype=float) * NM
).astype(np.int64)) for o in fills_mm]
return Problem(
board_path="synthetic", net_name="TEST", rho_ohm_m=RHO,
plating_nm=18_000,
layers=[LayerFill(layer_name="F.Cu", thickness_nm=int(t_um * 1000),
z_nm=0, polygons=polys)],
vias=[],
electrodes1=[Electrode(rect=rect_mm(rect1))],
electrodes2=[Electrode(rect=rect_mm(rect2))],
tracks=segs,
)
def _solve(problem, h_mm):
stack = raster.rasterize_stack(problem, h_mm * NM)
e1, e2 = raster.electrode_masks(stack, problem)
return solver.run_solve(problem, stack, e1, e2, 1.0,
contact_model="equipotential"), stack
def test_straight_track_exact_chain():
"""A 1.2 mm wide capsule at h = 1 mm rasterizes to a single-cell-high
row (the grid origin floats with the polygon bbox, so the count is
taken from the mask): an N-cell chain solves to exactly (N-1) faces."""
ring = capsule_ring(1 * NM, NM // 2, 9 * NM, NM // 2,
int(1.2 * NM), TOL_NM)
p = _track_problem([ring], (0, 0, 1.5, 1), (8.5, 0, 10, 1))
res, stack = _solve(p, 1.0)
m = stack.masks[0]
rows = np.flatnonzero(m.any(axis=1))
assert len(rows) == 1 # one 1-cell-high chain
n = int(m.sum())
assert n >= 8
assert res.R_ohm == pytest.approx((n - 1) / sigma_s(), rel=1e-9)
def test_capsule_zero_length_is_circle():
ring = capsule_ring(5 * NM, 5 * NM, 5 * NM, 5 * NM, 2 * NM, TOL_NM)
d = np.hypot(ring[:, 0] - 5 * NM, ring[:, 1] - 5 * NM)
assert np.allclose(d, NM, atol=TOL_NM + 2)
assert len(ring) >= 8
def test_capsule_ring_geometry():
"""Every outline point lies on the capsule boundary: at half-width
from the centerline segment."""
ring = capsule_ring(2 * NM, 3 * NM, 17 * NM, 11 * NM,
int(1.5 * NM), TOL_NM)
a = np.array([2 * NM, 3 * NM], dtype=float)
b = np.array([17 * NM, 11 * NM], dtype=float)
ab = b - a
t = np.clip(((ring - a) @ ab) / (ab @ ab), 0.0, 1.0)
d = np.hypot(*(ring - (a + t[:, None] * ab)).T)
assert np.allclose(d, 0.75 * NM, atol=TOL_NM + 2)
def test_arc_band_ring_geometry():
"""Arc-band points lie on the annulus walls or on the end caps."""
start, mid, end = ((10 * NM, 0), (int(10 * NM / math.sqrt(2)),
int(10 * NM / math.sqrt(2))),
(0, 10 * NM))
ring = arc_band_ring(start, mid, end, 1 * NM, TOL_NM).astype(float)
r = np.hypot(ring[:, 0], ring[:, 1])
on_annulus = (np.abs(r - 10.5 * NM) < TOL_NM + 2) \
| (np.abs(r - 9.5 * NM) < TOL_NM + 2)
d_start = np.hypot(ring[:, 0] - start[0], ring[:, 1] - start[1])
d_end = np.hypot(ring[:, 0] - end[0], ring[:, 1] - end[1])
on_caps = (d_start < 0.5 * NM + TOL_NM + 2) | (d_end < 0.5 * NM + TOL_NM + 2)
assert (on_annulus | on_caps).all()
def test_collinear_arc_degrades_to_capsule():
cap = capsule_ring(0, 0, 10 * NM, 0, NM, TOL_NM)
band = arc_band_ring((0, 0), (5 * NM, 0), (10 * NM, 0), NM, TOL_NM)
assert np.array_equal(cap, band)
def test_arc_track_matches_annular_sector():
"""90 deg arc trace, r = 10 mm, w = 1 mm: R = theta / (sigma *
ln(r_out/r_in)) between the radial end faces (electrodes cover the
end caps). The staircase on the curved walls narrows the band, so R
converges to the analytic value from above as h shrinks."""
start = (10 * NM, 0)
mid = (int(round(10 * NM / math.sqrt(2))),
int(round(10 * NM / math.sqrt(2))))
end = (0, 10 * NM)
ring = arc_band_ring(start, mid, end, 1 * NM, TOL_NM)
def solve_at(h_mm):
p = _track_problem([ring], rect1=(9.3, -0.8, 10.7, 0.05),
rect2=(-0.8, 9.3, 0.05, 10.7))
res, _ = _solve(p, h_mm)
return res.R_ohm
r_exact = (math.pi / 2) / (sigma_s() * math.log(10.5 / 9.5))
err_coarse = abs(solve_at(0.1) / r_exact - 1)
err_fine = abs(solve_at(0.05) / r_exact - 1)
assert err_fine < err_coarse # converges toward analytic
assert err_fine < 0.04
def test_narrow_trace_1d_matches_fine_raster():
"""A 0.2 mm trace at h = 1 mm (1D chain) must agree with the same
trace finely rasterized at h = 0.05 mm (4 cells wide) and with the
analytic R between the electrode inner edges."""
seg = _seg([(1, 0.5), (41, 0.5)], 0.2)
p = _seg_problem([seg], (0, 0, 2, 1), (40, 0, 42, 1))
res_1d, stack = _solve(p, 1.0)
assert stack.chain is not None and stack.chain.any()
res_fine, stack_f = _solve(_seg_problem([seg], (0, 0, 2, 1),
(40, 0, 42, 1)), 0.05)
assert stack_f.chain is None or not stack_f.chain.any()
r_analytic = RHO * 0.038 / (0.2e-3 * T_M) # between x=2 and x=40
assert res_fine.R_ohm == pytest.approx(r_analytic, rel=0.03)
assert res_1d.R_ohm == pytest.approx(res_fine.R_ohm, rel=0.06)
def test_diagonal_narrow_trace_no_staircase():
"""1D links carry the TRUE arc length: a diagonal trace must not be
inflated by the 4-connected staircase (which would be up to +41%)."""
seg = _seg([(1, 1), (25, 19)], 0.2)
p = _seg_problem([seg], (0, 0, 2, 2), (24, 18, 26, 20))
res, _ = _solve(p, 1.0)
L = math.hypot(24, 18) * 1e-3 # 30 mm
r_full = RHO * L / (0.2e-3 * T_M)
assert res.R_ohm < 1.05 * r_full # no staircase inflation
assert res.R_ohm == pytest.approx(r_full, rel=0.08)
def test_narrow_arc_trace_uses_arc_length():
"""Quarter-circle 0.2 mm trace, r = 10 mm, as a 1D chain: R follows
the arc length (a chord-based length would read ~10% low)."""
seg = TrackSeg(layer_name="F.Cu", points=np.array(
[[10 * NM, 0],
[int(round(10 * NM / math.sqrt(2))),
int(round(10 * NM / math.sqrt(2)))],
[0, 10 * NM]], dtype=np.int64), width_nm=int(0.2 * NM))
p = _seg_problem([seg], (9, -1, 11, 1), (-1, 9, 1, 11))
res, _ = _solve(p, 0.5)
# the electrode rects cover the arc where y < 1 (resp. x < 1), so the
# free span is theta in [asin(0.1), pi/2 - asin(0.1)]
th = math.asin(0.1)
r_arc = RHO * ((math.pi / 2 - 2 * th) * 10e-3) / (0.2e-3 * T_M)
assert res.R_ohm == pytest.approx(r_arc, rel=0.06)
def test_narrow_trace_bridges_pours():
"""A sub-resolution trace joins two pours: without it they are
disconnected; with it R is dominated by the trace's gap length."""
from fill_resistance.errors import ConnectivityError
pour1 = [(0, 0), (10, 0), (10, 10), (0, 10)]
pour2 = [(30, 0), (40, 0), (40, 10), (30, 10)]
rects = ((0, 0, 2, 10), (38, 0, 40, 10))
bare = _seg_problem([], *rects, fills_mm=(pour1, pour2))
stack = raster.rasterize_stack(bare, 1.0 * NM)
e1, e2 = raster.electrode_masks(stack, bare)
with pytest.raises(ConnectivityError):
solver.run_solve(bare, stack, e1, e2, 1.0,
contact_model="equipotential")
seg = _seg([(5, 5), (35, 5)], 0.2)
bridged = _seg_problem([seg], *rects, fills_mm=(pour1, pour2))
res, _ = _solve(bridged, 1.0)
r_gap = RHO * 0.020 / (0.2e-3 * T_M) # 20 mm between pours
assert res.R_ohm == pytest.approx(r_gap, rel=0.10)
assert res.power_balance_rel < 1e-9
def test_wide_track_still_rasterized():
"""At or above the width threshold the trace is rasterized normally
and no chain cells appear."""
seg = _seg([(1, 2), (19, 2)], 2.0)
p = _seg_problem([seg], (0, 1, 2, 3), (18, 1, 20, 3))
res, stack = _solve(p, 0.25)
assert stack.chain is None or not stack.chain.any()
assert int(stack.masks.sum()) > 300 # a real 2D band
assert np.isfinite(res.R_ohm) and res.R_ohm > 0
def test_json_v5_roundtrip_with_tracks(tmp_path):
seg = _seg([(1, 0.5), (41, 0.5)], 0.2)
p = _seg_problem([seg], (0, 0, 2, 1), (40, 0, 42, 1))
f = tmp_path / "d.json"
save_problem(p, f)
q = load_problem(f)
assert len(q.tracks) == 1
assert q.tracks[0].layer_name == "F.Cu"
assert q.tracks[0].width_nm == int(0.2 * NM)
assert np.array_equal(q.tracks[0].points, p.tracks[0].points)
r_p, _ = _solve(p, 1.0)
r_q, _ = _solve(q, 1.0)
assert r_q.R_ohm == pytest.approx(r_p.R_ohm, rel=1e-12)
def test_track_unions_with_fill():
"""A trace overlapping a plate merges into one conductor: the mask is
the union, and R drops when the trace bridges a slot."""
plate = [(0, 0), (20, 0), (20, 10), (0, 10)]
slot = [(9, 2), (11, 2), (11, 10), (9, 10)] # slot open to the top
plate_poly = Polygon(
outline=np.array([(x * NM, y * NM) for x, y in plate]),
holes=[np.array([(x * NM, y * NM) for x, y in slot])])
bridge = capsule_ring(6 * NM, 6 * NM, 14 * NM, 6 * NM,
int(1.2 * NM), TOL_NM)
def problem(polys):
return Problem(
board_path="synthetic", net_name="TEST", rho_ohm_m=1.68e-8,
plating_nm=18_000,
layers=[LayerFill(layer_name="F.Cu", thickness_nm=70_000,
z_nm=0, polygons=polys)],
vias=[],
electrodes1=[Electrode(rect=rect_mm((0, 0, 1, 10)))],
electrodes2=[Electrode(rect=rect_mm((19, 0, 20, 10)))],
)
r_plate, s_plate = _solve(problem([plate_poly]), 0.25)
r_both, s_both = _solve(problem([plate_poly,
Polygon(outline=bridge)]), 0.25)
assert int(s_both.masks.sum()) > int(s_plate.masks.sum())
assert r_both.R_ohm < 0.75 * r_plate.R_ohm # bridge shortens the detour
assert r_both.power_balance_rel < 1e-9
def test_pad_copper_bridges_track_junction():
"""Two traces meet ON an SMD pad, their rounded ends 0.5 mm apart:
the junction only exists through the pad copper (board_io stamps
the net's pad shapes onto their layers). Without the pad the net
is severed - at both track models (rasterized and 1D chain)."""
from fill_resistance.errors import ConnectivityError
tabs = [[(0, 4.5), (1, 4.5), (1, 5.5), (0, 5.5)],
[(19, 4.5), (20, 4.5), (20, 5.5), (19, 5.5)]]
pad = [(9.25, 4.4), (10.75, 4.4), (10.75, 5.6), (9.25, 5.6)]
segs = [_seg([(0.5, 5), (9.5, 5)], 0.5),
_seg([(10.5, 5), (19.5, 5)], 0.5)]
r1, r2 = (0, 4.5, 1, 5.5), (19, 4.5, 20, 5.5)
for h in (0.1, 0.25): # 5 cells: outlines; 2 cells: 1D chains
res, _ = _solve(_seg_problem(segs, r1, r2, fills_mm=tabs + [pad]), h)
# ~36 squares of 0.5 mm trace + tabs/pad: sanity-band the value
assert 0.007 < res.R_ohm < 0.011
with pytest.raises(ConnectivityError):
_solve(_seg_problem(segs, r1, r2, fills_mm=tabs), h)