Files
rpmroute/tests/route/test_real_board_geometry.py
T
rsp2k 8b3ebc53cf Leave fine-pitch pins straight and let rip-up reach a pin's way out
A 1.0 mm trace from a 1.27 mm pitch pin (the RN2483 row on the TTN
Enschede mote) has to stay within 0.06 mm of the pin's centreline until
it is past the neighbouring pads. No grid column is that close, and the
router could not get there:

- A stub ran as one slanted segment to its cell centre. The exact check
  sees that as its bounding box, which lands on the next pad. Orthogonal
  stubs now leave straight along the pin's axis and jog to the cell
  only at the end.
- End nodes were chosen as in free space even in the rip-up search, so
  once another net crossed a pin's only exit, the pin had no end left
  and rip-up could never take the exit back. In rip-up mode those cells
  and stubs are now enterable at the rip penalty, and routes on a stub
  are ripped along with the ones the path crosses.
- Two neighbouring pins' stubs each claim the cells a centred trace
  would crowd, so their claims overlapped well before their copper
  did. Stub against stub is now checked on the copper.
- Escapes also look one column either side, so two pins leaving the
  same way can jog apart.

TTN Enschede mote, exact engine with --shove: 12 unrouted before, 0
after; KiCad DRC on the result has no clearance violations and no
unconnected items. The 0.5 mm pitch row test at resolution 1 now
routes all 5 pins (it pinned 2).
2026-09-30 09:49:05 -06:00

255 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", [1, 4])
def test_every_pin_of_a_half_millimetre_pitch_row_escapes(resolution):
# 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 4 the 0.1 mm columns cannot miss it. At resolution
# 1 the 0.4 mm grid misses most bands, but a stub leaves its pin straight
# along the band and jogs to the column only past the pads, so every pin
# gets out there too, as the JAR manages (only 2 did before).
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) == 5
assert result.drc_clean
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"