Files
rpmroute/tests/route/test_router.py
T
rsp2k b26fdd96d5 Write SES coordinates in resolution units, as FreeRouting does
SES lengths and coordinates were written in the DSN's unit while the
session declared (resolution um 10), so readers that follow FreeRouting's
SesWriter (KiCad's importer, mckicad's ses_apply) placed every track at a
tenth of its size. The JAR, run on the same DSN, writes F1 at 65000 where
freeroute wrote 6500.

Scale every length by the resolution factor: placements, via padstack
shapes, wire widths and points, and vias. Rotations stay as they are. The
router tests compared SES output against DSN pad positions and so had the
same mistake built in; they now derive the expected values through
ses_scale.
2026-09-26 14:26:18 -06:00

309 lines
12 KiB
Python

"""Tests for the multi-layer grid maze router and the DSN -> SES pipeline.
Fast tests assert the routing invariants (traces connect their pads, stay on
valid layers, stay in bounds, vias sit at real layer transitions, no same-layer
crossing between nets). Oracle-gated tests check connectivity parity against the
reference FreeRouting JAR.
Fixtures: ``simple_2net.dsn`` (single-layer routable) and ``crossing_2net.dsn``
(a full-width NET_A wall that NET_B must cross, forcing a via).
"""
from __future__ import annotations
from pathlib import Path
import sys
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.dsn.sexp import parse
from freeroute.route import route, route_dsn_board
from freeroute.ses.writer import ses_scale
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
SIMPLE = FIXTURES / "simple_2net.dsn"
CROSSING = FIXTURES / "crossing_2net.dsn"
RIPUP = FIXTURES / "ripup_needed.dsn"
def _wire_key(result):
"""A hashable snapshot of a routing result for determinism checks."""
return {
net: [(layer, [(p.x, p.y) for p in pts]) for layer, pts in segs]
for net, segs in result.wires.items()
}
def _network_out(ses_text: str):
root = parse(ses_text)
network = root.child("routes").child("network_out")
return network.children("net")
def _net_scopes(ses_text: str):
result = {}
for net in _network_out(ses_text):
name = net.values()[0].text
result[name] = {"wires": net.children("wire"), "vias": net.children("via")}
return result
def _wire_layer_and_coords(wire):
path = wire.child("path")
vals = [v.text for v in path.values()]
nums = [float(v) for v in vals[2:]]
coords = list(zip(nums[0::2], nums[1::2], strict=False))
return vals[0], coords
def _orient(a, b, c):
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
def _segments_properly_cross(p1, p2, p3, p4) -> bool:
d1 = _orient(p3, p4, p1)
d2 = _orient(p3, p4, p2)
d3 = _orient(p1, p2, p3)
d4 = _orient(p1, p2, p4)
return ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (d4 > 0))
# --- single-layer / no-crossing case ----------------------------------------
def test_simple_board_routes_both_nets_without_vias():
result, _, _ = route_dsn_board(parse_dsn(SIMPLE.read_text()))
assert result.routed_net_numbers == {1, 2}
assert result.via_count() == 0 # no crossing -> no layer change needed
def test_pipeline_emits_valid_ses_with_both_nets():
ses = route(SIMPLE.read_text())
assert parse(ses).head == "session"
nets = _net_scopes(ses)
assert set(nets) == {"NET_A", "NET_B"}
assert all(n["wires"] for n in nets.values())
def test_simple_trace_endpoints_sit_on_the_net_pads():
# Pad centres in DSN units, scaled to the SES's resolution units.
k = ses_scale(parse_dsn(SIMPLE.read_text()))
pads = {
net: {(x * k, y * k) for x, y in centres}
for net, centres in {
"NET_A": {(20000.0, -20000.0), (180000.0, -20000.0)},
"NET_B": {(20000.0, -60000.0), (180000.0, -60000.0)},
}.items()
}
for net_name, scope in _net_scopes(route(SIMPLE.read_text())).items():
endpoints = set()
for wire in scope["wires"]:
_, coords = _wire_layer_and_coords(wire)
endpoints.add(coords[0])
endpoints.add(coords[-1])
assert endpoints == pads[net_name]
# --- multi-layer crossing case ----------------------------------------------
def test_greedy_single_layer_strands_a_net_on_the_crossing_board():
# greedy (no rip-up) on one layer: NET_A's straight wall strands NET_B
result, _, _ = route_dsn_board(parse_dsn(CROSSING.read_text()), layers=[0], rip_up=False)
assert len(result.routed_net_numbers) < 2
assert result.via_count() == 0
def test_multilayer_routes_the_crossing_board_with_a_via():
result, _, _ = route_dsn_board(parse_dsn(CROSSING.read_text()))
assert result.routed_net_numbers == {1, 2} # both nets connect
assert result.via_count() >= 1 # the crossing net changes layers
def test_crossing_ses_contains_a_via():
ses = route(CROSSING.read_text())
nets = _net_scopes(ses)
assert set(nets) == {"NET_A", "NET_B"}
total_vias = sum(len(n["vias"]) for n in nets.values())
assert total_vias >= 1
def test_vias_sit_at_real_layer_transitions():
# every via location must be shared by two wire segments of the same net on
# different layers (i.e. a genuine layer transition of that net's path).
result, _, _ = route_dsn_board(parse_dsn(CROSSING.read_text()))
for net_no, locations in result.vias.items():
segments = result.wires.get(net_no, [])
for via in locations:
point = (via.x, via.y)
layers_touching = {
layer for layer, pts in segments if any((p.x, p.y) == point for p in pts)
}
assert len(layers_touching) >= 2, "via not at a layer transition"
def test_via_endpoints_come_from_the_net_pads_across_layers():
# the crossing net's overall path still starts and ends on its two pads
result, _, _ = route_dsn_board(parse_dsn(CROSSING.read_text()))
board = build_board(parse_dsn(CROSSING.read_text()))
pins_by_net: dict[int, set[tuple[int, int]]] = {}
for pin in board.get_pins():
for net_no in pin.net_nos:
pins_by_net.setdefault(net_no, set()).add((pin.location.x, pin.location.y))
for net_no, segments in result.wires.items():
all_points = [(p.x, p.y) for _, pts in segments for p in pts]
endpoints = {all_points[0], all_points[-1]}
assert endpoints == pins_by_net[net_no]
# --- rip-up-and-retry (single-layer congestion) -----------------------------
def test_greedy_strands_a_net_on_the_congested_board():
# ripup_needed has one signal layer (no vias); greedy net order strands NET_B
result, _, _ = route_dsn_board(parse_dsn(RIPUP.read_text()), rip_up=False)
assert result.unrouted >= 1
assert len(result.routed_net_numbers) < 2
assert result.via_count() == 0 # single signal layer -> vias impossible
def test_ripup_connects_all_on_the_congested_board():
result, _, _ = route_dsn_board(parse_dsn(RIPUP.read_text()), rip_up=True)
assert result.unrouted == 0
assert result.routed_net_numbers == {1, 2} # rip-up reroutes to connect both
assert result.via_count() == 0
def test_ripup_result_is_deterministic():
a, _, _ = route_dsn_board(parse_dsn(RIPUP.read_text()), rip_up=True)
b, _, _ = route_dsn_board(parse_dsn(RIPUP.read_text()), rip_up=True)
assert _wire_key(a) == _wire_key(b)
def test_ripup_endpoints_still_on_pads_and_no_same_layer_crossing():
result, _, _ = route_dsn_board(parse_dsn(RIPUP.read_text()), rip_up=True)
board = build_board(parse_dsn(RIPUP.read_text()))
pins_by_net: dict[int, set[tuple[int, int]]] = {}
for pin in board.get_pins():
for net_no in pin.net_nos:
pins_by_net.setdefault(net_no, set()).add((pin.location.x, pin.location.y))
per_layer: dict[int, list[tuple[int, list]]] = {}
for net_no, segments in result.wires.items():
pts = [(p.x, p.y) for _, seg in segments for p in seg]
assert {pts[0], pts[-1]} == pins_by_net[net_no] # endpoints on pads
for layer, seg in segments:
per_layer.setdefault(layer, []).append((net_no, [(p.x, p.y) for p in seg]))
for entries in per_layer.values():
for i in range(len(entries)):
net_i, poly_i = entries[i]
for j in range(i + 1, len(entries)):
net_j, poly_j = entries[j]
if net_i == net_j:
continue
for a in range(len(poly_i) - 1):
for b in range(len(poly_j) - 1):
assert not _segments_properly_cross(
poly_i[a], poly_i[a + 1], poly_j[b], poly_j[b + 1]
)
# --- cross-net invariants ---------------------------------------------------
def test_no_two_nets_cross_on_the_same_layer():
result, _, _ = route_dsn_board(parse_dsn(CROSSING.read_text()))
per_layer: dict[int, list[tuple[int, list]]] = {}
for net_no, segments in result.wires.items():
for layer, pts in segments:
coords = [(p.x, p.y) for p in pts]
per_layer.setdefault(layer, []).append((net_no, coords))
for entries in per_layer.values():
for i in range(len(entries)):
net_i, poly_i = entries[i]
for j in range(i + 1, len(entries)):
net_j, poly_j = entries[j]
if net_i == net_j:
continue
for a in range(len(poly_i) - 1):
for b in range(len(poly_j) - 1):
assert not _segments_properly_cross(
poly_i[a], poly_i[a + 1], poly_j[b], poly_j[b + 1]
), "different nets cross on the same layer"
def test_traces_stay_within_the_board_outline():
for path in (SIMPLE, CROSSING):
k = ses_scale(parse_dsn(path.read_text()))
ses = route(path.read_text())
for scope in _net_scopes(ses).values():
for wire in scope["wires"]:
_, coords = _wire_layer_and_coords(wire)
for x, y in coords:
assert 0 <= x <= 200000 * k
assert -80000 * k <= y <= 0
def test_traces_stay_on_valid_layers():
ses = route(CROSSING.read_text())
for scope in _net_scopes(ses).values():
for wire in scope["wires"]:
layer, _ = _wire_layer_and_coords(wire)
assert layer in {"Top", "Bottom"}
def test_larger_board_still_connects_its_multipin_nets():
# regression: rip-up must not lose coverage on the real board. It has 4
# multi-pin nets, all of which the router connects.
dsn = parse_dsn((FIXTURES / "kicad_routable.dsn").read_text())
result, _, _ = route_dsn_board(dsn)
multi_pin = sum(1 for n in dsn.nets if len(n.pins) >= 2)
assert len(result.routed_net_numbers) == multi_pin == 4
# --- oracle parity ----------------------------------------------------------
@pytest.mark.oracle
def test_connectivity_parity_on_congested_board():
from oracle import HAS_ORACLE, route_dsn, routed_net_set
if not HAS_ORACLE:
pytest.skip("FreeRouting oracle unavailable")
our_nets = routed_net_set(route(RIPUP.read_text()))
assert our_nets == {"NET_A", "NET_B"} # freeroute connects both via rip-up
oracle_nets = routed_net_set(route_dsn(RIPUP, max_passes=3, timeout=300))
if not oracle_nets:
# the reference JAR does not route this synthetic single-signal-layer
# board (it expects a routing/via layer); freeroute's rip-up connecting
# both nets is already asserted above and by the fast tests.
pytest.skip("reference JAR routed nothing on the synthetic single-layer board")
assert oracle_nets <= our_nets # rip-up matches the JAR's connectivity
@pytest.mark.oracle
def test_connectivity_parity_on_crossing_board():
from oracle import HAS_ORACLE, route_dsn, routed_net_set
if not HAS_ORACLE:
pytest.skip("FreeRouting oracle unavailable")
our_nets = routed_net_set(route(CROSSING.read_text()))
oracle_nets = routed_net_set(route_dsn(CROSSING, max_passes=3, timeout=300))
assert oracle_nets, "oracle routed nothing on a routable board"
assert oracle_nets <= our_nets # freeroute connects every net the JAR does
@pytest.mark.oracle
def test_connectivity_parity_on_simple_board():
from oracle import HAS_ORACLE, route_dsn, routed_net_set
if not HAS_ORACLE:
pytest.skip("FreeRouting oracle unavailable")
our_nets = routed_net_set(route(SIMPLE.read_text()))
oracle_nets = routed_net_set(route_dsn(SIMPLE, max_passes=3, timeout=300))
assert oracle_nets, "oracle routed nothing on a routable board"
assert oracle_nets <= our_nets