56e2f9c81a
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>
146 lines
5.9 KiB
Python
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
|