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:
parent
992bba82a0
commit
49c258e81d
73
tests/dsn/fixtures/crossing_2net.dsn
Normal file
73
tests/dsn/fixtures/crossing_2net.dsn
Normal 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
|
||||
)
|
||||
)
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user