252 lines
12 KiB
Python
252 lines
12 KiB
Python
"""Routing behaviours found on the TerraHawk rc4 boards (2026-09-27)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import pytest
|
|
|
|
from freeroute.board import build_board
|
|
from freeroute.dsn import parse_dsn
|
|
from freeroute.route.pipeline import route_dsn_board_exact
|
|
|
|
# 20 x 20 mm board whose corners are rounded with a 3 mm radius, as polylines
|
|
_R = 3000.0
|
|
_arc = [(_R - _R * math.cos(math.radians(a)), _R - _R * math.sin(math.radians(a))) for a in range(0, 91, 15)]
|
|
|
|
|
|
def _outline_coords():
|
|
pts = []
|
|
for cx, cy, sx, sy in ((0, 0, 1, 1), (20000, 0, -1, 1), (20000, -20000, -1, -1), (0, -20000, 1, -1)):
|
|
corner = [(cx + sx * x, cy - sy * y) for x, y in _arc]
|
|
pts.extend(corner if sx * sy > 0 else corner[::-1])
|
|
return " ".join(f"{x:.1f} {y:.1f}" for x, y in pts)
|
|
|
|
|
|
def _dsn(placement, network, extra=""):
|
|
return f"""(pcb t (parser) (resolution um 10) (unit um)
|
|
(structure (layer F.Cu (type signal)) (layer B.Cu (type signal))
|
|
(boundary (path pcb 0 {_outline_coords()}))
|
|
(via "Via[0-1]_600:300_um") (rule (width 250) (clearance 150)) {extra})
|
|
(placement {placement})
|
|
(library (image P (pin pad 1 0 0))
|
|
(padstack pad (shape (rect F.Cu -150 -500 150 500)) (attach off))
|
|
(padstack "Via[0-1]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
|
|
(network {network}))"""
|
|
|
|
|
|
def test_routes_keep_edge_clearance_at_a_rounded_corner():
|
|
# two pads near the top-left corner: the direct path cuts the corner arc
|
|
text = _dsn("(component P (place A 1800 -6000 front 0) (place B 6000 -1800 front 90))",
|
|
"(net N (pins A-1 B-1))")
|
|
dsn = parse_dsn(text)
|
|
result, scale, _ = route_dsn_board_exact(dsn, edge_clearance_mm=0.5)
|
|
assert result.routed_net_numbers
|
|
outline = build_board(dsn).outline
|
|
half = 125 * scale
|
|
for segs in result.result.wires.values():
|
|
for _layer, pts in segs:
|
|
for p in pts:
|
|
# every trace point, plus half the trace, keeps 0.5 mm to the edge
|
|
assert outline.edge_distance(p) >= half + 500 * scale - 1
|
|
|
|
|
|
@pytest.mark.parametrize(("resolution", "escape"), [(1, 2), (4, 5)])
|
|
def test_every_pin_of_a_half_millimetre_pitch_row_escapes(resolution, escape):
|
|
# Five 0.3 mm pads at 0.5 mm pitch, each wired to its own pad 8 mm away.
|
|
# Between two neighbours a trace's centre has a legal band only 0.15 mm
|
|
# wide. At resolution 1 the 0.4 mm grid misses most bands; at 4 its
|
|
# 0.1 mm columns cannot, and every pin gets out, as the JAR manages.
|
|
places = " ".join(f"(place R{i} {6000 + 500 * i} -6000 front 0)" for i in range(5))
|
|
places += " " + " ".join(f"(place F{i} {6000 + 500 * i} -14000 front 0)" for i in range(5))
|
|
nets = " ".join(f"(net N{i} (pins R{i}-1 F{i}-1))" for i in range(5))
|
|
result, _, _ = route_dsn_board_exact(
|
|
parse_dsn(_dsn(f"(component P {places})", nets)), resolution=resolution
|
|
)
|
|
assert len(result.routed_net_numbers) == escape
|
|
|
|
|
|
def test_resolution_one_has_no_halo_so_the_grid_is_unchanged():
|
|
from freeroute.route.grid_router import GridRouter
|
|
|
|
board = build_board(parse_dsn(_dsn("", "")))
|
|
assert GridRouter(board, trace_width=2500, clearance=1500)._halo_offsets == []
|
|
fine = GridRouter(board, trace_width=2500, clearance=1500, resolution=2)
|
|
assert fine.step == 2000 and (1, 0) in fine._halo_offsets and (2, 0) not in fine._halo_offsets
|
|
|
|
|
|
def test_heuristic_weight_bounds_route_length():
|
|
# weighted A* may take a longer route, but never more than W times the shortest
|
|
places = "(place A 3000 -3000 front 0) (place B 17000 -17000 front 0)"
|
|
|
|
def length(weight):
|
|
result, _, _ = route_dsn_board_exact(
|
|
parse_dsn(_dsn(f"(component P {places})", "(net N (pins A-1 B-1))")),
|
|
heuristic_weight=weight,
|
|
)
|
|
return sum(
|
|
math.hypot(q.x - p.x, q.y - p.y)
|
|
for segs in result.result.wires.values()
|
|
for _layer, pts in segs
|
|
for p, q in zip(pts, pts[1:], strict=False)
|
|
)
|
|
|
|
shortest = length(1.0)
|
|
assert shortest > 0
|
|
assert length(2.0) <= 2.0 * shortest
|
|
|
|
|
|
# --- copper zones (DSN planes) -----------------------------------------------
|
|
# rc4 power stage: pads inside "keep router off" pours connect through the pour,
|
|
# and freeroute, not knowing zones, dropped every such connection.
|
|
|
|
_ZONE = "(plane N (polygon F.Cu 0 4000 -4000 10000 -4000 10000 -10000 4000 -10000 4000 -4000))"
|
|
_NO_TRACKS = "(wire_keepout \"pour\" (polygon F.Cu 0 4000 -4000 10000 -4000 10000 -10000 4000 -10000))"
|
|
|
|
|
|
def _zone_board():
|
|
places = "(place P1 5500 -7000 front 0) (place P2 8500 -7000 front 0) (place P3 16000 -16000 front 0)"
|
|
return parse_dsn(_dsn(f"(component P {places})", "(net N (pins P1-1 P2-1 P3-1))", _ZONE + " " + _NO_TRACKS))
|
|
|
|
|
|
def test_planes_are_read_and_are_not_obstacles():
|
|
dsn = _zone_board()
|
|
assert [(p.net, p.shape.layer) for p in dsn.planes] == [("N", "F.Cu")]
|
|
board = build_board(dsn)
|
|
assert len(board.get_conduction_areas()) == 1
|
|
assert all(not a.is_obstacle() or a.name == "pour" for a in board.get_obstacle_areas())
|
|
|
|
|
|
def test_pads_in_one_zone_need_no_route_between_them():
|
|
from freeroute.route.grid_router import _connected_groups
|
|
|
|
board = build_board(_zone_board())
|
|
pins = sorted(board.get_pins(), key=lambda p: p.component_ref)
|
|
groups = _connected_groups(pins, board.get_conduction_areas())
|
|
assert [sorted(p.component_ref for p in g[0]) for g in groups] == [["P1", "P2"], ["P3"]]
|
|
|
|
|
|
def test_a_pad_reaches_a_zone_under_a_no_tracks_area_by_a_via():
|
|
dsn = _zone_board()
|
|
result, scale, names = route_dsn_board_exact(dsn)
|
|
net = next(iter(result.routed_net_numbers))
|
|
assert result.result.vias.get(net) # it had to drop into the zone
|
|
board = build_board(dsn)
|
|
pour = next(a for a in board.get_obstacle_areas() if a.name == "pour")
|
|
f_cu = names.index("F.Cu")
|
|
for layer, pts in result.result.wires[net]:
|
|
if layer == f_cu: # no track runs inside the no-tracks area
|
|
assert not any(t.contains(p) and not t.bounding_box().is_empty()
|
|
for t in pour.tiles for p in pts[1:-1])
|
|
|
|
|
|
def test_a_pour_other_nets_can_cross_is_not_trusted_to_connect():
|
|
# the same zone with no no-tracks area over it: its fill could be split by
|
|
# other nets' tracks, so its pads are joined by routing, not assumed
|
|
places = "(place P1 5500 -7000 front 0) (place P2 8500 -7000 front 0)"
|
|
dsn = parse_dsn(_dsn(f"(component P {places})", "(net N (pins P1-1 P2-1))", _ZONE))
|
|
result, _, _ = route_dsn_board_exact(dsn)
|
|
net = next(iter(result.routed_net_numbers))
|
|
assert result.result.wires.get(net) # P1 and P2 got a track between them
|
|
|
|
|
|
def test_cli_names_the_connections_it_could_not_route(tmp_path, capsys):
|
|
# a keepout wall across the board on both layers: A and B cannot be joined
|
|
from freeroute.cli import main
|
|
|
|
wall = " ".join(
|
|
f"(keepout \"wall\" (polygon {layer} 0 9000 1000 11000 1000 11000 -21000 9000 -21000))"
|
|
for layer in ("F.Cu", "B.Cu")
|
|
)
|
|
dsn = tmp_path / "wall.dsn"
|
|
dsn.write_text(_dsn("(component P (place A 4000 -10000 front 0) (place B 16000 -10000 front 0))",
|
|
"(net N (pins A-1 B-1))", wall))
|
|
assert main([str(dsn), "--engine", "exact", "-do", str(tmp_path / "out.ses")]) == 0
|
|
err = capsys.readouterr().err
|
|
assert "1 connection(s) not routed" in err and "N: A-1 to B-1" in err
|
|
|
|
|
|
# --- inner plane layers (a 4-layer board with a GND plane on In1.Cu) --------
|
|
|
|
def _four_layer_dsn():
|
|
return """(pcb four (parser) (resolution um 10) (unit um)
|
|
(structure
|
|
(layer F.Cu (type signal)) (layer In1.Cu (type power))
|
|
(layer In2.Cu (type power)) (layer B.Cu (type signal))
|
|
(boundary (path pcb 0 0 0 30000 0 30000 -30000 0 -30000 0 0))
|
|
(plane GND (polygon In1.Cu 0 500 -500 29500 -500 29500 -29500 500 -29500 500 -500))
|
|
(via "Via[0-3]_600:300_um") (rule (width 250) (clearance 150)))
|
|
(placement
|
|
(component S (place G1 5000 -5000 front 0) (place G2 25000 -5000 front 0)
|
|
(place G3 15000 -25000 front 0) (place A 5000 -15000 front 0) (place B 25000 -15000 front 0))
|
|
(component T (place GT 15000 -15000 front 0)))
|
|
(library
|
|
(image S (pin smd 1 0 0))
|
|
(image T (pin tht 1 0 0))
|
|
(padstack smd (shape (rect F.Cu -300 -300 300 300)) (attach off))
|
|
(padstack tht (shape (circle F.Cu 1000)) (shape (circle B.Cu 1000)) (attach off))
|
|
(padstack "Via[0-3]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
|
|
(network (net GND (pins G1-1 G2-1 G3-1 GT-1)) (net SIG (pins A-1 B-1))))"""
|
|
|
|
|
|
def test_smd_pads_reach_an_inner_plane_by_their_own_via():
|
|
dsn = parse_dsn(_four_layer_dsn())
|
|
result, scale, names = route_dsn_board_exact(dsn)
|
|
gnd = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "GND")
|
|
sig = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "SIG")
|
|
assert len(result.result.vias.get(gnd, [])) == 3 # one per SMD pad, none for the THT pad
|
|
for _layer, pts in result.result.wires.get(gnd, []): # stubs, not tracks across the board
|
|
length = sum(abs(q.x - p.x) + abs(q.y - p.y) for p, q in zip(pts, pts[1:], strict=False))
|
|
assert length < 3000 * scale # under 3 mm: a stub to its via
|
|
assert sig in result.result.routed_net_numbers # signals still route on the outer layers
|
|
assert all(names[layer] in ("F.Cu", "B.Cu") for segs in result.result.wires.values() for layer, _ in segs)
|
|
|
|
|
|
def test_a_pad_outside_the_plane_routes_to_it():
|
|
# G1 sits left of a plane that covers only the right half, so its route
|
|
# has to reach the plane's cells: those ends are the plane, not a pin
|
|
dsn_text = _four_layer_dsn().replace(
|
|
"(plane GND (polygon In1.Cu 0 500 -500 29500 -500 29500 -29500 500 -29500 500 -500))",
|
|
"(plane GND (polygon In1.Cu 0 15000 -500 29500 -500 29500 -29500 15000 -29500 15000 -500))",
|
|
)
|
|
result, _scale, _names = route_dsn_board_exact(parse_dsn(dsn_text))
|
|
gnd = next(i + 1 for i, n in enumerate(parse_dsn(dsn_text).nets) if n.name == "GND")
|
|
assert gnd in result.result.routed_net_numbers
|
|
|
|
|
|
def test_a_through_hole_pad_inside_a_plane_needs_no_route():
|
|
from freeroute.route.grid_router import _connected_groups
|
|
|
|
board = build_board(parse_dsn(_four_layer_dsn()))
|
|
plane = [z for z in board.get_conduction_areas() if z.name == "GND"]
|
|
gnd_pins = [p for p in board.get_pins() if p.component_ref in ("G1", "G2", "G3", "GT")]
|
|
groups = _connected_groups(gnd_pins, plane, frozenset(id(z) for z in plane))
|
|
hub = next(g for g in groups if g[1])
|
|
assert [p.component_ref for p in hub[0]] == ["GT"]
|
|
|
|
|
|
def test_a_pad_inside_a_via_keepout_reaches_the_plane_by_a_via_outside_it():
|
|
# a sensor's GND pad: vias are forbidden under the body, so the route
|
|
# has to leave on the top layer and drop to the plane past the keepout
|
|
dsn = parse_dsn("""(pcb repro (parser) (resolution um 10) (unit um)
|
|
(structure
|
|
(layer F.Cu (type signal)) (layer In1.Cu (type power)) (layer In2.Cu (type power))
|
|
(layer B.Cu (type signal))
|
|
(boundary (path pcb 0 0 0 20000 0 20000 -20000 0 -20000 0 0))
|
|
(plane GND (polygon In1.Cu 0 500 -500 19500 -500 19500 -19500 500 -19500 500 -500))
|
|
(via_keepout "" (polygon signal 0 8500 -8500 11500 -8500 11500 -11500 8500 -11500 8500 -8500))
|
|
(via "Via[0-3]_600:300_um") (rule (width 150) (clearance 150)))
|
|
(placement (component S (place G1 10000 -10000 front 0)))
|
|
(library
|
|
(image S (pin smd 1 0 0))
|
|
(padstack smd (shape (rect F.Cu -175 -300 175 300)) (attach off))
|
|
(padstack "Via[0-3]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
|
|
(network (net GND (pins G1-1))))""")
|
|
exact, scale, _names = route_dsn_board_exact(dsn, resolution=2)
|
|
assert exact.result.failed == []
|
|
(via,) = exact.result.vias[1]
|
|
half = 300 * scale # the via's radius
|
|
outside = (via.x + half <= 8500 * scale or via.x - half >= 11500 * scale
|
|
or via.y - half >= -8500 * scale or via.y + half <= -11500 * scale)
|
|
assert outside, f"via at {via} is inside the via keepout"
|