Files
kicad-zone-resistance/tests/test_tracks.py
T
janik 56e2f9c81a Include the net's traces as conductors alongside zone fills
Straight tracks become capsule outline polygons (rectangle +
semicircular caps), arc tracks annular bands with end caps, both
tessellated to the same sagitta tolerance as zone-fill arcs; they merge
into the per-layer copper next to the fills, so rasterization, via
stitching, the solver and the plots handle them unchanged. Trace-only
layers and trace-only nets now qualify as candidates. Dialog checkbox
(on by default, INCLUDE_TRACKS) toggles them per run.

Hole-less polygons (every track outline) now paint the layer mask
directly instead of allocating a full-frame temporary each.

Tests: exact N-cell chain on a rasterized capsule, analytic annular-
sector convergence for an arc trace, capsule/arc-band outline geometry
invariants, collinear-arc degradation, and fill+trace union solve.

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

146 lines
5.9 KiB
Python

"""Track (trace) conductor tests: capsule / arc-band outline generation
and solves on rasterized traces. The 1-cell-wide capsule chain is exact;
the arc band is checked against the analytic annular-sector resistance."""
import math
import numpy as np
import pytest
from fill_resistance import raster, solver
from fill_resistance.geometry import (Electrode, LayerFill, Polygon, Problem,
arc_band_ring, capsule_ring)
from tests.util import NM, rect_mm, sigma_s
TOL_NM = 10_000
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 _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_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