diff --git a/tests/dsn/fixtures/crossing_2net.dsn b/tests/dsn/fixtures/crossing_2net.dsn new file mode 100644 index 0000000..febb45d --- /dev/null +++ b/tests/dsn/fixtures/crossing_2net.dsn @@ -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 + ) +) diff --git a/tests/route/test_router.py b/tests/route/test_router.py index 24c2139..adbeb18 100644 --- a/tests/route/test_router.py +++ b/tests/route/test_router.py @@ -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] -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 +# --- multi-layer crossing case ---------------------------------------------- -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())) +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, 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] + 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] -# --- robustness ------------------------------------------------------------- +# --- 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): + 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_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