freeroute/tests/route/test_router.py
Ryan Malloy 89c0481ccc Add MVP grid maze router and DSN->SES routing pipeline
Implements a working autorouter and the Java-free replacement for the
freerouting.jar step: dsn_text -> parse_dsn -> build_board -> route ->
write_ses.

GridRouter is a single-layer A* maze search over a uniform occupancy
grid: a cell is blocked by another net's pad (inflated by clearance +
half trace width) or a keepout; each net's ratsnest is connected pin to
pin; a routed trace then blocks other nets. The cell path becomes a
trace polyline whose endpoints are the exact pin locations. Board-unit
paths are converted back to DSN units for the SES (wire (path ...))
scopes.

This is an MVP, not a port of FreeRouting's expansion-room maze:
free-space rooms, rip-up-and-retry, multi-layer via search, and shove
are deferred (they raise quality/coverage, not the connectivity
milestone). It reaches connectivity on boards whose nets route on one
layer without crossing.

Adds tests/dsn/fixtures/simple_2net.dsn (a guaranteed-routable 2-net
board). Invariant tests: both nets route, the emitted SES parses, every
trace stays on a valid layer and within the board outline, and every
routed net's trace endpoints sit exactly on its two pads. An oracle-gated
test asserts connectivity parity with the reference FreeRouting JAR on
the simple board.
2026-07-12 11:25:03 -06:00

146 lines
5.0 KiB
Python

"""Tests for the MVP grid maze router and the DSN -> SES pipeline.
Fast tests assert the routing invariants (traces connect their pads, stay on a
valid layer, stay in bounds). An oracle-gated test checks connectivity parity
against the reference FreeRouting JAR on the simple board.
Fixtures: ``tests/dsn/fixtures/simple_2net.dsn`` (guaranteed routable).
"""
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
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
SIMPLE = FIXTURES / "simple_2net.dsn"
def _net_out(ses_text: str):
root = parse(ses_text)
routes = root.child("routes")
network = routes.child("network_out")
return {n.values()[0].text: n.children("wire") for n in network.children("net")}
def _wire_paths(wire):
path = wire.child("path")
vals = [v.text for v in path.values()]
layer = vals[0]
nums = [float(v) for v in vals[2:]]
coords = list(zip(nums[0::2], nums[1::2], strict=False))
return layer, coords
# --- connectivity -----------------------------------------------------------
def test_simple_board_routes_both_nets():
result, scale, layers = route_dsn_board(parse_dsn(SIMPLE.read_text()))
assert result.routed_net_numbers == {1, 2} # NET_A, NET_B
def test_pipeline_emits_valid_ses_with_both_nets():
ses = route(SIMPLE.read_text())
top = parse(ses) # must be a single balanced S-expression
assert top.head == "session"
nets = _net_out(ses)
assert set(nets) == {"NET_A", "NET_B"}
assert all(wires for wires in nets.values()) # each net has >= 1 wire
# --- invariants -------------------------------------------------------------
def test_traces_stay_on_a_valid_layer():
ses = route(SIMPLE.read_text())
layer_names = {"Top", "Bottom"}
for wires in _net_out(ses).values():
for wire in wires:
layer, _ = _wire_paths(wire)
assert layer in layer_names
def test_trace_endpoints_sit_on_the_net_pads():
# DSN pad locations per net (component place coords; pin offset is 0,0)
pads = {
"NET_A": {(20000.0, -20000.0), (180000.0, -20000.0)},
"NET_B": {(20000.0, -60000.0), (180000.0, -60000.0)},
}
ses = route(SIMPLE.read_text())
for net_name, wires in _net_out(ses).items():
endpoints = set()
for wire in wires:
_, coords = _wire_paths(wire)
assert len(coords) >= 2, "a trace needs at least two points"
endpoints.add(coords[0])
endpoints.add(coords[-1])
# every trace endpoint is one of the net's pads
assert endpoints <= pads[net_name]
# both pads of the net are touched
assert endpoints == pads[net_name]
def test_traces_stay_within_the_board_outline():
ses = route(SIMPLE.read_text())
# simple_2net boundary: x in [0, 200000], y in [-80000, 0]
for wires in _net_out(ses).values():
for wire in wires:
_, coords = _wire_paths(wire)
for x, y in coords:
assert 0 <= x <= 200000
assert -80000 <= y <= 0
def test_every_routed_net_joins_its_pin_pair():
# a routed net's trace path must actually connect its two pins in board units
result, scale, _ = route_dsn_board(parse_dsn(SIMPLE.read_text()))
board = build_board(parse_dsn(SIMPLE.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, paths in result.wires.items():
touched = set()
for path in paths:
touched.add((path[0].x, path[0].y))
touched.add((path[-1].x, path[-1].y))
assert touched == pins_by_net[net_no]
# --- robustness -------------------------------------------------------------
def test_router_does_not_crash_on_larger_board():
# the real KiCad board is dense; the router must complete without error and
# route at least some nets (full coverage is a later, multi-layer concern).
result, _, _ = route_dsn_board(parse_dsn((FIXTURES / "kicad_routable.dsn").read_text()))
assert isinstance(result.routed_net_numbers, set)
# --- oracle parity ----------------------------------------------------------
@pytest.mark.oracle
def test_connectivity_parity_with_oracle_on_simple_board():
from oracle import HAS_ORACLE, route_dsn, routed_net_set
if not HAS_ORACLE:
pytest.skip("FreeRouting oracle unavailable")
our_ses = route(SIMPLE.read_text())
our_nets = routed_net_set(our_ses)
oracle_ses = route_dsn(SIMPLE, max_passes=3, timeout=300)
oracle_nets = routed_net_set(oracle_ses)
assert oracle_nets, "oracle routed nothing on a routable board"
# connectivity parity: freeroute connects every net the reference connects
assert oracle_nets <= our_nets