Model sub-resolution traces as exact 1D resistor chains
Tracks are now first-class Problem objects (TrackSeg: centerline + width, dump schema v5), so the wide/narrow decision replays at raster time: traces at least TRACK_1D_FACTOR (3) cells wide rasterize from their outline as before; narrower ones mark the cells their centerline crosses as copper and connect them with explicit conductance links carrying the trace's TRUE arc length per link - no staircase inflation for diagonals or arcs, and no discretization error in the trace R, at any grid size. Links across cells already joined by pour faces are skipped (union, not sum); chain-only cells get no sheet faces (their copper is narrower than a cell). Electrodes, via barrels, connectivity restriction and the skin-effect scaling all work on chain cells unchanged. This removes the need to shrink the cell size for thin traces: a 0.2 mm bridge at 500 um cells now matches its finely-rasterized ground truth within a few percent (tested), including diagonal and arc traces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+120
-4
@@ -1,6 +1,6 @@
|
||||
"""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."""
|
||||
"""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
|
||||
@@ -8,10 +8,13 @@ import pytest
|
||||
|
||||
from fill_resistance import raster, solver
|
||||
from fill_resistance.geometry import (Electrode, LayerFill, Polygon, Problem,
|
||||
arc_band_ring, capsule_ring)
|
||||
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):
|
||||
@@ -27,6 +30,26 @@ def _track_problem(rings, rect1, rect2, t_um=70.0):
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -115,6 +138,99 @@ def test_arc_track_matches_annular_sector():
|
||||
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."""
|
||||
|
||||
Reference in New Issue
Block a user