A route may not leave a cell inside a wire keepout as a wire, nor reach a pin there by one: it changes layer instead, and the via goes in the pad. A pin whose every stub enters a keepout is reached by a via only. A structure keepout named rule_area... (mckicad names KiCad rule areas so) is checked for overlap only, as KiCad's DRC does, with the free ends of each wire tested for their round caps.
291 lines
11 KiB
Python
291 lines
11 KiB
Python
"""DSN -> board construction invariants, validated on a real KiCad board.
|
|
|
|
There is no unit-test oracle for the board model, so these assert structural
|
|
invariants against the parsed DSN (the source of truth) and — when a JVM + JAR
|
|
are available — cross-check the board's net set against the reference router's
|
|
routed net set.
|
|
|
|
Fixture: ``tests/dsn/fixtures/kicad_routable.dsn`` (a KiCad pcbnew export).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
from freeroute.board import BasicBoard, ObstacleArea, Pin, build_board
|
|
from freeroute.dsn import parse_dsn
|
|
from freeroute.geometry import IntPoint
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
|
|
ROUTABLE = FIXTURES / "kicad_routable.dsn"
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def dsn():
|
|
return parse_dsn(ROUTABLE.read_text())
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def board(dsn) -> BasicBoard:
|
|
return build_board(dsn)
|
|
|
|
|
|
# --- structural counts match the DSN ----------------------------------------
|
|
|
|
|
|
def test_layer_count_matches_dsn(dsn, board):
|
|
assert board.get_layer_count() == len(dsn.layers)
|
|
assert [layer.name for layer in board.layer_structure.arr] == [
|
|
layer.name for layer in dsn.layers
|
|
]
|
|
|
|
|
|
def test_net_count_matches_dsn(dsn, board):
|
|
assert len(board.nets) == len(dsn.nets)
|
|
assert board.nets.names() == {n.name for n in dsn.nets}
|
|
|
|
|
|
def test_pin_count_matches_placement(dsn, board):
|
|
images = {img.name: img for img in dsn.images}
|
|
expected = sum(
|
|
len(images[pl.lib_name].pins)
|
|
for pl in dsn.placements
|
|
for place in pl.places
|
|
if place.x is not None and pl.lib_name in images
|
|
)
|
|
assert len(board.get_pins()) == expected
|
|
assert expected > 0
|
|
|
|
|
|
def test_obstacle_count_matches_keepouts(dsn, board):
|
|
# Board-level keepouts plus every placed instance of a footprint-level one.
|
|
# This fixture has none of the first and six plated mounting holes, whose
|
|
# copper KiCad exports as a keepout on each layer.
|
|
images = {img.name: img for img in dsn.images}
|
|
placed = 0
|
|
for placement in dsn.placements:
|
|
image = images.get(placement.lib_name)
|
|
if image is None:
|
|
continue
|
|
per_part = len(image.keepouts) + len(image.via_keepouts) + len(image.wire_keepouts)
|
|
placed += per_part * sum(p.x is not None for p in placement.places)
|
|
assert len(dsn.keepouts) == 0
|
|
assert placed == 12
|
|
assert len(board.get_obstacle_areas()) == placed
|
|
|
|
|
|
# --- per-item invariants ----------------------------------------------------
|
|
|
|
|
|
def test_every_pin_shape_contains_its_origin(board):
|
|
pins = board.get_pins()
|
|
assert pins
|
|
for pin in pins:
|
|
assert pin.shape_contains_origin(), f"pad shape missed origin for pin {pin.name}"
|
|
|
|
|
|
def test_every_pin_reports_a_net(dsn, board):
|
|
# this board's netlist connects every pad, so every pin has a net number
|
|
for pin in board.get_pins():
|
|
assert pin.net_nos, f"pin {pin.name} has no net"
|
|
for net_no in pin.net_nos:
|
|
assert board.nets.get_by_number(net_no) is not None
|
|
|
|
|
|
def test_items_report_correct_net_membership(board):
|
|
for pin in board.get_pins():
|
|
for net_no in pin.net_nos:
|
|
assert pin in board.get_connectable_items(net_no)
|
|
|
|
|
|
def test_pin_layers_are_valid(board):
|
|
n_layers = board.get_layer_count()
|
|
for pin in board.get_pins():
|
|
assert pin.layers
|
|
assert all(0 <= layer < n_layers for layer in pin.layers)
|
|
|
|
|
|
def test_bounding_box_is_non_degenerate(board):
|
|
assert not board.bounding_box.is_empty()
|
|
assert board.bounding_box.dimension() == 2
|
|
# every pin location lies within the board bounding box
|
|
for pin in board.get_pins():
|
|
assert board.bounding_box.contains(pin.location)
|
|
|
|
|
|
def test_item_types(board):
|
|
for item in board.get_items():
|
|
assert isinstance(item, (Pin, ObstacleArea))
|
|
|
|
|
|
# --- oracle cross-check ------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.oracle
|
|
def test_board_net_set_covers_oracle_routed_nets(board):
|
|
from oracle import HAS_ORACLE, route_dsn, routed_net_set
|
|
|
|
if not HAS_ORACLE:
|
|
pytest.skip("FreeRouting oracle unavailable")
|
|
# Routing the full board is slow; a couple of passes is enough to route most
|
|
# nets, and a generous timeout absorbs JVM start-up variance.
|
|
ses = route_dsn(ROUTABLE, max_passes=2, timeout=420)
|
|
routed = routed_net_set(ses)
|
|
assert routed, "oracle produced no routed nets"
|
|
# every net the reference router routed exists on our constructed board
|
|
assert routed <= board.nets.names()
|
|
|
|
|
|
# --- keepouts as KiCad exports them ------------------------------------------
|
|
# Found on the TerraHawk charge-node boards (2026-09-27): a no-track ring along
|
|
# the board edge arrives as one keyhole polygon, "keep router off" rule areas
|
|
# arrive as wire_keepout, and neither may block the board's interior.
|
|
|
|
_RING_DSN = """(pcb ring (parser) (resolution um 10) (unit um)
|
|
(structure
|
|
(layer F.Cu (type signal)) (layer B.Cu (type signal))
|
|
(boundary (path pcb 0 0 0 40000 0 40000 -30000 0 -30000 0 0))
|
|
(keepout "" (polygon F.Cu 0 0 0 40000 0 40000 -30000 0 -30000 0 0
|
|
500 -500 500 -29500 39500 -29500 39500 -500 500 -500 0 0))
|
|
(wire_keepout "pour" (polygon F.Cu 0 10000 -10000 20000 -10000 20000 -20000 10000 -20000))
|
|
(place_keepout "courtyard" (polygon F.Cu 0 30000 -10000 35000 -10000 35000 -20000 30000 -20000))
|
|
(rule (width 250) (clearance 150)))
|
|
(placement) (library) (network))"""
|
|
|
|
|
|
def _areas(dsn_text):
|
|
return build_board(parse_dsn(dsn_text)).get_obstacle_areas()
|
|
|
|
|
|
def _blocked(areas, x_um, y_um):
|
|
p = IntPoint(x_um * 10, y_um * 10)
|
|
return any(t.contains(p) for a in areas for t in a.tiles)
|
|
|
|
|
|
def test_keyhole_ring_keepout_blocks_only_the_border():
|
|
areas = _areas(_RING_DSN)
|
|
assert _blocked(areas, 200, -15000) # inside the 0.5 mm edge strip
|
|
assert not _blocked(areas, 5000, -5000) # open board interior
|
|
|
|
|
|
def test_wire_keepout_is_read_and_blocks_its_area():
|
|
dsn = parse_dsn(_RING_DSN)
|
|
assert [k.kind for k in dsn.keepouts].count("wire_keepout") == 1
|
|
assert _blocked(_areas(_RING_DSN), 15000, -15000)
|
|
|
|
|
|
def test_place_keepout_does_not_block_routing():
|
|
assert not _blocked(_areas(_RING_DSN), 32500, -15000)
|
|
|
|
|
|
def test_grid_router_leaves_the_interior_of_a_ring_keepout_open():
|
|
from freeroute.route.pipeline import route_dsn_board
|
|
|
|
ring_only = "\n".join(
|
|
line for line in _RING_DSN.splitlines() if "wire_keepout" not in line
|
|
).replace(
|
|
"(placement) (library) (network)",
|
|
"(placement (component P (place P1 5000 -15000 front 0) (place P2 35000 -15000 front 0)))\n"
|
|
" (library (image P (pin pad 1 0 0)) (padstack pad (shape (circle F.Cu 600)) (attach off)))\n"
|
|
" (network (net N (pins P1-1 P2-1)))",
|
|
)
|
|
result, _, _ = route_dsn_board(parse_dsn(ring_only), max_passes=2)
|
|
assert result.routed_net_numbers
|
|
|
|
|
|
# --- pads placed as KiCad places them (2026-09-27, TerraHawk rc4) ------------
|
|
|
|
_PAD_DSN = """(pcb pads (parser) (resolution um 10) (unit um)
|
|
(structure
|
|
(layer F.Cu (type signal)) (layer B.Cu (type signal))
|
|
(boundary (path pcb 0 0 0 40000 0 40000 -30000 0 -30000 0 0))
|
|
(via "Via[0-1]_600:300_um")
|
|
(rule (width 250) (clearance 150)))
|
|
(placement
|
|
(component R (place TOP 10000 -10000 front 0) (place BOT 20000 -10000 back 0)
|
|
(place ROT 30000 -10000 front 90)))
|
|
(library
|
|
(image R (pin rect 1 -1000 0))
|
|
(padstack rect (shape (rect F.Cu -400 -250 400 250)) (attach off))
|
|
(padstack "Via[0-1]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
|
|
(network))"""
|
|
|
|
|
|
def _pins_by_ref(text):
|
|
board = build_board(parse_dsn(text))
|
|
return {p.component_ref: p for p in board.get_pins()}, board
|
|
|
|
|
|
def test_back_side_pad_moves_to_the_bottom_layer():
|
|
pins, board = _pins_by_ref(_PAD_DSN)
|
|
names = [layer.name for layer in board.layer_structure.arr]
|
|
assert [names[i] for i in pins["TOP"].layers] == ["F.Cu"]
|
|
assert [names[i] for i in pins["BOT"].layers] == ["B.Cu"]
|
|
|
|
|
|
def test_back_side_pin_offset_is_mirrored():
|
|
pins, _ = _pins_by_ref(_PAD_DSN)
|
|
assert pins["TOP"].location.x == (10000 - 1000) * 10
|
|
assert pins["BOT"].location.x == (20000 + 1000) * 10
|
|
|
|
|
|
def test_rotated_part_rotates_its_pad_outline():
|
|
pins, _ = _pins_by_ref(_PAD_DSN)
|
|
flat = pins["TOP"].shape.bounding_box()
|
|
turned = pins["ROT"].shape.bounding_box()
|
|
assert (flat.ur.x - flat.ll.x, flat.ur.y - flat.ll.y) == (8000, 5000)
|
|
assert (turned.ur.x - turned.ll.x, turned.ur.y - turned.ll.y) == (5000, 8000)
|
|
|
|
|
|
def test_oval_pad_keeps_its_width():
|
|
oval = _PAD_DSN.replace("(shape (rect F.Cu -400 -250 400 250))", "(shape (path F.Cu 500 -300 0 300 0))")
|
|
pins, _ = _pins_by_ref(oval)
|
|
box = pins["TOP"].shape.bounding_box()
|
|
assert box.ur.y - box.ll.y == 5000 # the aperture, not a zero-height line
|
|
assert box.ur.x - box.ll.x == 11000 # centreline 600 um plus the aperture
|
|
|
|
|
|
def test_via_size_comes_from_the_via_padstack():
|
|
from freeroute.route.pipeline import _via_half_board
|
|
|
|
assert _via_half_board(parse_dsn(_PAD_DSN), 10) == 3000 # 0.6 mm via, board units
|
|
|
|
|
|
# --- rule areas: overlap only ------------------------------------------------
|
|
|
|
_CHANNEL_DSN = """(pcb channel.dsn
|
|
(parser (string_quote ")) (resolution um 10) (unit um)
|
|
(structure
|
|
(layer F.Cu (type signal)) (layer B.Cu (type signal))
|
|
(boundary (path pcb 0 0 0 40000 0 40000 -30000 0 -30000 0 0))
|
|
(keepout "{name}top" (polygon signal 0 18000 0 22000 0 22000 -14750 18000 -14750))
|
|
(keepout "{name}bottom" (polygon signal 0 18000 -15250 22000 -15250 22000 -30000 18000 -30000))
|
|
(rule (width 250) (clearance 150)))
|
|
(placement (component P (place P1 5000 -15000 front 0) (place P2 35000 -15000 front 0)))
|
|
(library (image P (pin pad 1 0 0)) (padstack pad (shape (circle F.Cu 600)) (attach off)))
|
|
(network (net N (pins P1-1 P2-1))))"""
|
|
|
|
|
|
def test_a_keepout_named_rule_area_takes_no_clearance():
|
|
named = _areas(_CHANNEL_DSN.format(name="rule_area_"))
|
|
plain = _areas(_CHANNEL_DSN.format(name=""))
|
|
assert named and not any(a.clearance for a in named)
|
|
assert plain and all(a.clearance for a in plain)
|
|
|
|
|
|
def _route_channel(name):
|
|
from freeroute.route.pipeline import build_exact_routing_result
|
|
|
|
return build_exact_routing_result(parse_dsn(_CHANNEL_DSN.format(name=name)), resolution=2)
|
|
|
|
|
|
def test_a_trace_may_run_beside_a_rule_area_closer_than_clearance():
|
|
# the 0.5 mm channel fits a 0.25 mm trace, but not with 0.15 mm each side
|
|
assert _route_channel("rule_area_").unrouted == []
|
|
assert _route_channel("").unrouted != []
|