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.
146 lines
5.0 KiB
Python
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
|