Add crossing fixture and multi-layer/via routing tests

Adds crossing_2net.dsn: NET_A is a full-width horizontal wall and NET_B's
pins sit above and below it, so single-layer routing cannot connect NET_B
but two-layer routing succeeds by dipping to the other layer through vias.

Tests assert the multi-layer invariants: single-layer routing leaves a net
unrouted with no vias; multi-layer connects both nets with >= 1 via; the
SES contains a via scope; every via sits at a real layer transition of its
net's path; trace/via endpoints still land on the pads; traces stay on
valid layers and within the outline; and no two different nets cross on the
same layer. Oracle-gated parity tests cover the simple and crossing boards.
This commit is contained in:
Ryan Malloy 2026-07-12 13:21:00 -06:00
parent 992bba82a0
commit 49c258e81d
2 changed files with 218 additions and 70 deletions

View File

@ -0,0 +1,73 @@
(pcb "crossing_2net.dsn"
(parser
(string_quote ")
(space_in_quoted_tokens on)
(host_cad "freeroute-test")
(host_version "1.0")
)
(resolution um 10)
(unit um)
(structure
(layer Top
(type signal)
(property
(index 0)
)
)
(layer Bottom
(type signal)
(property
(index 1)
)
)
(boundary
(path pcb 0 0 0 200000 0 200000 -80000 0 -80000 0 0)
)
(via "Via[0-1]_600:300_um")
(rule
(width 2000)
(clearance 2000)
)
)
(library
(padstack Rect_Pad
(shape (rect Top -1000 -1000 1000 1000))
(attach off)
)
(padstack "Via[0-1]_600:300_um"
(shape (circle Top 600))
(shape (circle Bottom 600))
(attach off)
)
(image PAD
(pin Rect_Pad 1 0 0)
)
)
(placement
(component PAD
(place A1 2000 -40000 front 0)
(place A2 198000 -40000 front 0)
(place B1 100000 -15000 front 0)
(place B2 100000 -65000 front 0)
)
)
(network
(net NET_A
(pins A1-1 A2-1)
)
(net NET_B
(pins B1-1 B2-1)
)
(class default
(circuit
(use_via "Via[0-1]_600:300_um")
)
(rule
(width 2000)
(clearance 2000)
)
)
)
(wiring
)
)

View File

@ -1,10 +1,12 @@
"""Tests for the MVP grid maze router and the DSN -> SES pipeline.
"""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 a
valid layer, stay in bounds). An oracle-gated test checks connectivity parity
against the reference FreeRouting JAR on the simple board.
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: ``tests/dsn/fixtures/simple_2net.dsn`` (guaranteed routable).
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
@ -23,106 +25,170 @@ 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"
def _net_out(ses_text: str):
def _network_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")}
network = root.child("routes").child("network_out")
return network.children("net")
def _wire_paths(wire):
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()]
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
return vals[0], coords
# --- connectivity -----------------------------------------------------------
def _orient(a, b, c):
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
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 _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())
top = parse(ses) # must be a single balanced S-expression
assert top.head == "session"
nets = _net_out(ses)
assert parse(ses).head == "session"
nets = _net_scopes(ses)
assert set(nets) == {"NET_A", "NET_B"}
assert all(wires for wires in nets.values()) # each net has >= 1 wire
assert all(n["wires"] for n in nets.values())
# --- 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)
def test_simple_trace_endpoints_sit_on_the_net_pads():
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():
for net_name, scope in _net_scopes(route(SIMPLE.read_text())).items():
endpoints = set()
for wire in wires:
_, coords = _wire_paths(wire)
assert len(coords) >= 2, "a trace needs at least two points"
for wire in scope["wires"]:
_, coords = _wire_layer_and_coords(wire)
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]
# --- multi-layer crossing case ----------------------------------------------
def test_single_layer_cannot_route_the_crossing_board():
result, _, _ = route_dsn_board(parse_dsn(CROSSING.read_text()), layers=[0])
# NET_A is a full-width wall; on one layer NET_B cannot cross it
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]
# --- 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():
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 path in (SIMPLE, CROSSING):
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
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_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_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)
@ -131,15 +197,24 @@ def test_router_does_not_crash_on_larger_board():
@pytest.mark.oracle
def test_connectivity_parity_with_oracle_on_simple_board():
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_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)
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"
# connectivity parity: freeroute connects every net the reference connects
assert oracle_nets <= our_nets