A zone outline is where copper may go, not where it ends up: on the rc4 sense board the board-wide GND pours break into islands around other nets' tracks, and treating every pad inside them as connected left 28 GND islands unjoined (63 unconnected against 42 without zones). Trust a zone only when a no-tracks rule area covers it on its layer, as the power stage's pours are covered; route the rest with tracks as before. rc4, exact engine, resolution 2: power stage 5 unconnected (JAR 12), sense board 41 (JAR 32), no DRC errors on either.
151 lines
6.6 KiB
Python
151 lines
6.6 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
|