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.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user