SES lengths and coordinates were written in the DSN's unit while the session declared (resolution um 10), so readers that follow FreeRouting's SesWriter (KiCad's importer, mckicad's ses_apply) placed every track at a tenth of its size. The JAR, run on the same DSN, writes F1 at 65000 where freeroute wrote 6500. Scale every length by the resolution factor: placements, via padstack shapes, wire widths and points, and vias. Rotations stay as they are. The router tests compared SES output against DSN pad positions and so had the same mistake built in; they now derive the expected values through ses_scale.
309 lines
12 KiB
Python
309 lines
12 KiB
Python
"""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
|
|
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: ``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
|
|
|
|
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
|
|
from freeroute.ses.writer import ses_scale
|
|
|
|
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"
|
|
RIPUP = FIXTURES / "ripup_needed.dsn"
|
|
|
|
|
|
def _wire_key(result):
|
|
"""A hashable snapshot of a routing result for determinism checks."""
|
|
return {
|
|
net: [(layer, [(p.x, p.y) for p in pts]) for layer, pts in segs]
|
|
for net, segs in result.wires.items()
|
|
}
|
|
|
|
|
|
def _network_out(ses_text: str):
|
|
root = parse(ses_text)
|
|
network = root.child("routes").child("network_out")
|
|
return network.children("net")
|
|
|
|
|
|
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()]
|
|
nums = [float(v) for v in vals[2:]]
|
|
coords = list(zip(nums[0::2], nums[1::2], strict=False))
|
|
return vals[0], coords
|
|
|
|
|
|
def _orient(a, b, c):
|
|
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
|
|
|
|
|
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())
|
|
assert parse(ses).head == "session"
|
|
nets = _net_scopes(ses)
|
|
assert set(nets) == {"NET_A", "NET_B"}
|
|
assert all(n["wires"] for n in nets.values())
|
|
|
|
|
|
def test_simple_trace_endpoints_sit_on_the_net_pads():
|
|
# Pad centres in DSN units, scaled to the SES's resolution units.
|
|
k = ses_scale(parse_dsn(SIMPLE.read_text()))
|
|
pads = {
|
|
net: {(x * k, y * k) for x, y in centres}
|
|
for net, centres in {
|
|
"NET_A": {(20000.0, -20000.0), (180000.0, -20000.0)},
|
|
"NET_B": {(20000.0, -60000.0), (180000.0, -60000.0)},
|
|
}.items()
|
|
}
|
|
for net_name, scope in _net_scopes(route(SIMPLE.read_text())).items():
|
|
endpoints = set()
|
|
for wire in scope["wires"]:
|
|
_, coords = _wire_layer_and_coords(wire)
|
|
endpoints.add(coords[0])
|
|
endpoints.add(coords[-1])
|
|
assert endpoints == pads[net_name]
|
|
|
|
|
|
# --- multi-layer crossing case ----------------------------------------------
|
|
|
|
|
|
def test_greedy_single_layer_strands_a_net_on_the_crossing_board():
|
|
# greedy (no rip-up) on one layer: NET_A's straight wall strands NET_B
|
|
result, _, _ = route_dsn_board(parse_dsn(CROSSING.read_text()), layers=[0], rip_up=False)
|
|
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]
|
|
|
|
|
|
# --- rip-up-and-retry (single-layer congestion) -----------------------------
|
|
|
|
|
|
def test_greedy_strands_a_net_on_the_congested_board():
|
|
# ripup_needed has one signal layer (no vias); greedy net order strands NET_B
|
|
result, _, _ = route_dsn_board(parse_dsn(RIPUP.read_text()), rip_up=False)
|
|
assert result.unrouted >= 1
|
|
assert len(result.routed_net_numbers) < 2
|
|
assert result.via_count() == 0 # single signal layer -> vias impossible
|
|
|
|
|
|
def test_ripup_connects_all_on_the_congested_board():
|
|
result, _, _ = route_dsn_board(parse_dsn(RIPUP.read_text()), rip_up=True)
|
|
assert result.unrouted == 0
|
|
assert result.routed_net_numbers == {1, 2} # rip-up reroutes to connect both
|
|
assert result.via_count() == 0
|
|
|
|
|
|
def test_ripup_result_is_deterministic():
|
|
a, _, _ = route_dsn_board(parse_dsn(RIPUP.read_text()), rip_up=True)
|
|
b, _, _ = route_dsn_board(parse_dsn(RIPUP.read_text()), rip_up=True)
|
|
assert _wire_key(a) == _wire_key(b)
|
|
|
|
|
|
def test_ripup_endpoints_still_on_pads_and_no_same_layer_crossing():
|
|
result, _, _ = route_dsn_board(parse_dsn(RIPUP.read_text()), rip_up=True)
|
|
board = build_board(parse_dsn(RIPUP.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))
|
|
per_layer: dict[int, list[tuple[int, list]]] = {}
|
|
for net_no, segments in result.wires.items():
|
|
pts = [(p.x, p.y) for _, seg in segments for p in seg]
|
|
assert {pts[0], pts[-1]} == pins_by_net[net_no] # endpoints on pads
|
|
for layer, seg in segments:
|
|
per_layer.setdefault(layer, []).append((net_no, [(p.x, p.y) for p in seg]))
|
|
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]
|
|
)
|
|
|
|
|
|
# --- 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):
|
|
k = ses_scale(parse_dsn(path.read_text()))
|
|
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 * k
|
|
assert -80000 * k <= 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_larger_board_still_connects_its_multipin_nets():
|
|
# regression: rip-up must not lose coverage on the real board. It has 4
|
|
# multi-pin nets, all of which the router connects.
|
|
dsn = parse_dsn((FIXTURES / "kicad_routable.dsn").read_text())
|
|
result, _, _ = route_dsn_board(dsn)
|
|
multi_pin = sum(1 for n in dsn.nets if len(n.pins) >= 2)
|
|
assert len(result.routed_net_numbers) == multi_pin == 4
|
|
|
|
|
|
# --- oracle parity ----------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.oracle
|
|
def test_connectivity_parity_on_congested_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(RIPUP.read_text()))
|
|
assert our_nets == {"NET_A", "NET_B"} # freeroute connects both via rip-up
|
|
oracle_nets = routed_net_set(route_dsn(RIPUP, max_passes=3, timeout=300))
|
|
if not oracle_nets:
|
|
# the reference JAR does not route this synthetic single-signal-layer
|
|
# board (it expects a routing/via layer); freeroute's rip-up connecting
|
|
# both nets is already asserted above and by the fast tests.
|
|
pytest.skip("reference JAR routed nothing on the synthetic single-layer board")
|
|
assert oracle_nets <= our_nets # rip-up matches the JAR's connectivity
|
|
|
|
|
|
@pytest.mark.oracle
|
|
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_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"
|
|
assert oracle_nets <= our_nets
|