Add MVP grid maze router and DSN->SES routing pipeline
Implements a working autorouter and the Java-free replacement for the freerouting.jar step: dsn_text -> parse_dsn -> build_board -> route -> write_ses. GridRouter is a single-layer A* maze search over a uniform occupancy grid: a cell is blocked by another net's pad (inflated by clearance + half trace width) or a keepout; each net's ratsnest is connected pin to pin; a routed trace then blocks other nets. The cell path becomes a trace polyline whose endpoints are the exact pin locations. Board-unit paths are converted back to DSN units for the SES (wire (path ...)) scopes. This is an MVP, not a port of FreeRouting's expansion-room maze: free-space rooms, rip-up-and-retry, multi-layer via search, and shove are deferred (they raise quality/coverage, not the connectivity milestone). It reaches connectivity on boards whose nets route on one layer without crossing. Adds tests/dsn/fixtures/simple_2net.dsn (a guaranteed-routable 2-net board). Invariant tests: both nets route, the emitted SES parses, every trace stays on a valid layer and within the board outline, and every routed net's trace endpoints sit exactly on its two pads. An oracle-gated test asserts connectivity parity with the reference FreeRouting JAR on the simple board.
This commit is contained in:
parent
4220fe0cd6
commit
89c0481ccc
27
src/freeroute/route/__init__.py
Normal file
27
src/freeroute/route/__init__.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
"""Autorouting for freeroute.
|
||||||
|
|
||||||
|
An MVP grid-based maze router and the DSN-in / SES-out pipeline that makes
|
||||||
|
freeroute a Java-free replacement for the ``freerouting.jar`` step::
|
||||||
|
|
||||||
|
from freeroute.route import route
|
||||||
|
ses_text = route(dsn_text)
|
||||||
|
|
||||||
|
The router (:class:`GridRouter`) is intentionally simple — single-layer A* over
|
||||||
|
a uniform occupancy grid, enough to reach connectivity on boards whose nets
|
||||||
|
route without crossing. FreeRouting's free-space expansion-room maze, rip-up,
|
||||||
|
multi-layer via search, and shove are deferred.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .grid_router import GridRouter, RouteResult, route_board
|
||||||
|
from .pipeline import build_routing_result, route, route_dsn_board
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GridRouter",
|
||||||
|
"RouteResult",
|
||||||
|
"route_board",
|
||||||
|
"route",
|
||||||
|
"route_dsn_board",
|
||||||
|
"build_routing_result",
|
||||||
|
]
|
||||||
259
src/freeroute/route/grid_router.py
Normal file
259
src/freeroute/route/grid_router.py
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
"""A grid-based maze router (MVP).
|
||||||
|
|
||||||
|
This is an *MVP* autorouter, not a port of FreeRouting's expansion-room maze.
|
||||||
|
Per the phase brief the milestone is connectivity parity with the reference JAR
|
||||||
|
on a simple board, not FreeRouting-quality optimization. It routes on a single
|
||||||
|
signal layer with an A* search over a uniform occupancy grid:
|
||||||
|
|
||||||
|
* a cell is blocked if it overlaps another net's pad or a keepout, inflated by
|
||||||
|
``clearance + half trace width``;
|
||||||
|
* each net's ratsnest is connected pin-to-pin; a routed trace's cells then block
|
||||||
|
other nets (so routes do not overlap);
|
||||||
|
* the resulting cell path is turned into a trace polyline whose endpoints are the
|
||||||
|
exact pin locations.
|
||||||
|
|
||||||
|
Deferred (router phase, exact-geometry track): FreeRouting's free-space
|
||||||
|
expansion rooms, rip-up-and-retry, multi-layer via search, and shove. Those
|
||||||
|
raise quality/completeness; this reaches connectivity on boards whose nets route
|
||||||
|
on one layer without crossing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import heapq
|
||||||
|
import math
|
||||||
|
|
||||||
|
from freeroute.board.board import BasicBoard
|
||||||
|
from freeroute.board.items import ObstacleArea, Pin
|
||||||
|
from freeroute.geometry import IntPoint
|
||||||
|
|
||||||
|
__all__ = ["GridRouter", "RouteResult", "route_board"]
|
||||||
|
|
||||||
|
# 8-connected neighbourhood (orthogonal + diagonal) for 45-degree routing.
|
||||||
|
_NEIGHBOURS = [
|
||||||
|
(1, 0),
|
||||||
|
(-1, 0),
|
||||||
|
(0, 1),
|
||||||
|
(0, -1),
|
||||||
|
(1, 1),
|
||||||
|
(1, -1),
|
||||||
|
(-1, 1),
|
||||||
|
(-1, -1),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class RouteResult:
|
||||||
|
"""Per-net routing outcome in *board* units.
|
||||||
|
|
||||||
|
``wires`` maps a net number to a list of trace paths (each a list of
|
||||||
|
:class:`IntPoint`). ``routed_net_numbers`` are the nets that got >= 1 trace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("wires", "half_width", "layer")
|
||||||
|
|
||||||
|
def __init__(self, half_width: int, layer: int) -> None:
|
||||||
|
self.wires: dict[int, list[list[IntPoint]]] = {}
|
||||||
|
self.half_width = half_width
|
||||||
|
self.layer = layer
|
||||||
|
|
||||||
|
def add(self, net_no: int, path: list[IntPoint]) -> None:
|
||||||
|
self.wires.setdefault(net_no, []).append(path)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def routed_net_numbers(self) -> set[int]:
|
||||||
|
return {n for n, paths in self.wires.items() if paths}
|
||||||
|
|
||||||
|
|
||||||
|
class GridRouter:
|
||||||
|
"""Routes a :class:`BasicBoard` on one layer with a uniform-grid A* search."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
board: BasicBoard,
|
||||||
|
*,
|
||||||
|
trace_width: int,
|
||||||
|
clearance: int,
|
||||||
|
layer: int = 0,
|
||||||
|
) -> None:
|
||||||
|
self.board = board
|
||||||
|
self.trace_width = max(trace_width, 1)
|
||||||
|
self.clearance = max(clearance, 0)
|
||||||
|
self.layer = layer
|
||||||
|
self.half_width = self.trace_width // 2
|
||||||
|
# cell size must fit a trace plus its clearance to a neighbouring trace
|
||||||
|
self.step = max(self.trace_width + self.clearance, 1)
|
||||||
|
|
||||||
|
box = board.bounding_box
|
||||||
|
self.origin_x = box.ll.x
|
||||||
|
self.origin_y = box.ll.y
|
||||||
|
self.cols = max(1, math.ceil((box.ur.x - box.ll.x) / self.step) + 1)
|
||||||
|
self.rows = max(1, math.ceil((box.ur.y - box.ll.y) / self.step) + 1)
|
||||||
|
|
||||||
|
# cell -> set of net numbers whose pad blocks it; keepout cells; trace cells
|
||||||
|
self._pad_block: dict[tuple[int, int], set[int]] = {}
|
||||||
|
self._keepout: set[tuple[int, int]] = set()
|
||||||
|
self._trace_block: dict[tuple[int, int], int] = {}
|
||||||
|
self._rasterize_obstacles()
|
||||||
|
|
||||||
|
# --- grid helpers -------------------------------------------------------
|
||||||
|
|
||||||
|
def _cell_of(self, point: IntPoint) -> tuple[int, int]:
|
||||||
|
gx = round((point.x - self.origin_x) / self.step)
|
||||||
|
gy = round((point.y - self.origin_y) / self.step)
|
||||||
|
gx = min(max(gx, 0), self.cols - 1)
|
||||||
|
gy = min(max(gy, 0), self.rows - 1)
|
||||||
|
return gx, gy
|
||||||
|
|
||||||
|
def _cell_center(self, gx: int, gy: int) -> IntPoint:
|
||||||
|
return IntPoint(self.origin_x + gx * self.step, self.origin_y + gy * self.step)
|
||||||
|
|
||||||
|
def _cells_in_box(self, box, margin: int):
|
||||||
|
lo_x = round((box.ll.x - margin - self.origin_x) / self.step)
|
||||||
|
hi_x = round((box.ur.x + margin - self.origin_x) / self.step)
|
||||||
|
lo_y = round((box.ll.y - margin - self.origin_y) / self.step)
|
||||||
|
hi_y = round((box.ur.y + margin - self.origin_y) / self.step)
|
||||||
|
for gx in range(max(lo_x, 0), min(hi_x, self.cols - 1) + 1):
|
||||||
|
for gy in range(max(lo_y, 0), min(hi_y, self.rows - 1) + 1):
|
||||||
|
yield gx, gy
|
||||||
|
|
||||||
|
def _rasterize_obstacles(self) -> None:
|
||||||
|
margin = self.clearance + self.half_width
|
||||||
|
for item in self.board.get_items():
|
||||||
|
if isinstance(item, Pin):
|
||||||
|
if self.layer not in item.layers:
|
||||||
|
continue
|
||||||
|
box = item.shape.bounding_box() if item.shape is not None else None
|
||||||
|
if box is None or box.is_empty():
|
||||||
|
continue
|
||||||
|
net = item.net_nos[0] if item.net_nos else 0
|
||||||
|
for cell in self._cells_in_box(box, margin):
|
||||||
|
self._pad_block.setdefault(cell, set()).add(net)
|
||||||
|
elif isinstance(item, ObstacleArea):
|
||||||
|
if item.layer != self.layer:
|
||||||
|
continue
|
||||||
|
box = item.bounding_box()
|
||||||
|
if box.is_empty():
|
||||||
|
continue
|
||||||
|
for cell in self._cells_in_box(box, self.clearance):
|
||||||
|
self._keepout.add(cell)
|
||||||
|
|
||||||
|
def _blocked(self, cell: tuple[int, int], net_no: int) -> bool:
|
||||||
|
if cell in self._keepout:
|
||||||
|
return True
|
||||||
|
pads = self._pad_block.get(cell)
|
||||||
|
if pads and any(n != net_no for n in pads):
|
||||||
|
return True
|
||||||
|
trace = self._trace_block.get(cell)
|
||||||
|
return trace is not None and trace != net_no
|
||||||
|
|
||||||
|
# --- A* search ----------------------------------------------------------
|
||||||
|
|
||||||
|
def _search(
|
||||||
|
self, start: tuple[int, int], goal: tuple[int, int], net_no: int
|
||||||
|
) -> list[tuple[int, int]] | None:
|
||||||
|
if start == goal:
|
||||||
|
return [start]
|
||||||
|
open_heap: list[tuple[float, tuple[int, int]]] = []
|
||||||
|
heapq.heappush(open_heap, (0.0, start))
|
||||||
|
came_from: dict[tuple[int, int], tuple[int, int]] = {}
|
||||||
|
g_score: dict[tuple[int, int], float] = {start: 0.0}
|
||||||
|
|
||||||
|
def h(cell):
|
||||||
|
return math.hypot(cell[0] - goal[0], cell[1] - goal[1])
|
||||||
|
|
||||||
|
while open_heap:
|
||||||
|
_, current = heapq.heappop(open_heap)
|
||||||
|
if current == goal:
|
||||||
|
return _reconstruct(came_from, current)
|
||||||
|
cx, cy = current
|
||||||
|
for dx, dy in _NEIGHBOURS:
|
||||||
|
nxt = (cx + dx, cy + dy)
|
||||||
|
if not (0 <= nxt[0] < self.cols and 0 <= nxt[1] < self.rows):
|
||||||
|
continue
|
||||||
|
# the goal cell may be blocked by its own pad's net-agnostic
|
||||||
|
# rasterization; always allow stepping onto the goal
|
||||||
|
if nxt != goal and self._blocked(nxt, net_no):
|
||||||
|
continue
|
||||||
|
step_cost = 1.0 if dx == 0 or dy == 0 else math.sqrt(2)
|
||||||
|
tentative = g_score[current] + step_cost
|
||||||
|
if tentative < g_score.get(nxt, math.inf):
|
||||||
|
came_from[nxt] = current
|
||||||
|
g_score[nxt] = tentative
|
||||||
|
heapq.heappush(open_heap, (tentative + h(nxt), nxt))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _mark_trace(self, cells: list[tuple[int, int]], net_no: int) -> None:
|
||||||
|
for cell in cells:
|
||||||
|
self._trace_block[cell] = net_no
|
||||||
|
|
||||||
|
# --- public routing -----------------------------------------------------
|
||||||
|
|
||||||
|
def route(self) -> RouteResult:
|
||||||
|
"""Route every net with >= 2 pins on this router's layer."""
|
||||||
|
result = RouteResult(self.half_width, self.layer)
|
||||||
|
pins_by_net = self._pins_by_net()
|
||||||
|
# route nets with fewer pins first (usually easier / shorter)
|
||||||
|
for net_no in sorted(pins_by_net, key=lambda n: len(pins_by_net[n])):
|
||||||
|
pins = pins_by_net[net_no]
|
||||||
|
if len(pins) < 2:
|
||||||
|
continue
|
||||||
|
for a, b in zip(pins, pins[1:], strict=False):
|
||||||
|
path = self._route_connection(a, b, net_no)
|
||||||
|
if path is not None:
|
||||||
|
result.add(net_no, path)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _pins_by_net(self) -> dict[int, list[Pin]]:
|
||||||
|
by_net: dict[int, list[Pin]] = {}
|
||||||
|
for pin in self.board.get_pins():
|
||||||
|
if self.layer not in pin.layers:
|
||||||
|
continue
|
||||||
|
for net_no in pin.net_nos:
|
||||||
|
by_net.setdefault(net_no, []).append(pin)
|
||||||
|
return by_net
|
||||||
|
|
||||||
|
def _route_connection(self, a: Pin, b: Pin, net_no: int) -> list[IntPoint] | None:
|
||||||
|
start = self._cell_of(a.location)
|
||||||
|
goal = self._cell_of(b.location)
|
||||||
|
cells = self._search(start, goal, net_no)
|
||||||
|
if cells is None:
|
||||||
|
return None
|
||||||
|
self._mark_trace(cells, net_no)
|
||||||
|
# cell centres, with exact pad locations as the true endpoints
|
||||||
|
points = [a.location]
|
||||||
|
points.extend(self._cell_center(gx, gy) for gx, gy in cells)
|
||||||
|
points.append(b.location)
|
||||||
|
return _simplify(points)
|
||||||
|
|
||||||
|
|
||||||
|
def _reconstruct(came_from, current):
|
||||||
|
path = [current]
|
||||||
|
while current in came_from:
|
||||||
|
current = came_from[current]
|
||||||
|
path.append(current)
|
||||||
|
path.reverse()
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _simplify(points: list[IntPoint]) -> list[IntPoint]:
|
||||||
|
"""Drop duplicate and collinear points from a polyline."""
|
||||||
|
out: list[IntPoint] = []
|
||||||
|
for p in points:
|
||||||
|
if out and out[-1].x == p.x and out[-1].y == p.y:
|
||||||
|
continue
|
||||||
|
if len(out) >= 2:
|
||||||
|
a, b = out[-2], out[-1]
|
||||||
|
# collinear if cross product of (b-a) and (p-a) is zero
|
||||||
|
if (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x) == 0:
|
||||||
|
out[-1] = p
|
||||||
|
continue
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def route_board(
|
||||||
|
board: BasicBoard, *, trace_width: int, clearance: int, layer: int = 0
|
||||||
|
) -> RouteResult:
|
||||||
|
"""Convenience wrapper: build a :class:`GridRouter` and route the board."""
|
||||||
|
router = GridRouter(board, trace_width=trace_width, clearance=clearance, layer=layer)
|
||||||
|
return router.route()
|
||||||
76
src/freeroute/route/pipeline.py
Normal file
76
src/freeroute/route/pipeline.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
"""End-to-end routing pipeline: DSN text in, SES text out.
|
||||||
|
|
||||||
|
Ties together the parser, board construction, the grid router, and the SES
|
||||||
|
writer — the Java-free replacement for the ``freerouting.jar`` step:
|
||||||
|
|
||||||
|
dsn_text -> parse_dsn -> build_board -> route_board -> write_ses -> ses_text
|
||||||
|
|
||||||
|
Router output is in board units; it is converted back to DSN units (dividing by
|
||||||
|
the resolution) for the SES ``(wire (path ...))`` scopes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from freeroute.board import build_board
|
||||||
|
from freeroute.dsn import DsnBoard, parse_dsn
|
||||||
|
from freeroute.ses import RoutedWire, RoutingResult, write_ses
|
||||||
|
|
||||||
|
from .grid_router import RouteResult, route_board
|
||||||
|
|
||||||
|
__all__ = ["route", "route_dsn_board", "build_routing_result"]
|
||||||
|
|
||||||
|
#: fallback trace width / clearance in DSN units when the DSN has no rules
|
||||||
|
_DEFAULT_WIDTH_DSN = 2000
|
||||||
|
_DEFAULT_CLEARANCE_DSN = 2000
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_width_dsn(dsn: DsnBoard) -> float:
|
||||||
|
rules = dsn.structure_rules.width_rules
|
||||||
|
return rules[0].value if rules else _DEFAULT_WIDTH_DSN
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_clearance_dsn(dsn: DsnBoard) -> float:
|
||||||
|
for rule in dsn.structure_rules.clearance_rules:
|
||||||
|
if not rule.class_pairs: # the layer-wide default clearance
|
||||||
|
return rule.value
|
||||||
|
return _DEFAULT_CLEARANCE_DSN
|
||||||
|
|
||||||
|
|
||||||
|
def route_dsn_board(dsn: DsnBoard) -> tuple[RouteResult, int, list[str]]:
|
||||||
|
"""Route a parsed :class:`DsnBoard`; return the board-unit result, the scale,
|
||||||
|
and the layer names."""
|
||||||
|
board = build_board(dsn)
|
||||||
|
scale = max(dsn.resolution.value, 1)
|
||||||
|
width_board = round(_rule_width_dsn(dsn) * scale)
|
||||||
|
clearance_board = round(_rule_clearance_dsn(dsn) * scale)
|
||||||
|
result = route_board(board, trace_width=width_board, clearance=clearance_board, layer=0)
|
||||||
|
return result, scale, [layer.name for layer in dsn.layers]
|
||||||
|
|
||||||
|
|
||||||
|
def build_routing_result(dsn: DsnBoard) -> RoutingResult:
|
||||||
|
"""Route ``dsn`` and convert the board-unit paths to a DSN-unit
|
||||||
|
:class:`~freeroute.ses.RoutingResult` for SES emission."""
|
||||||
|
route, scale, layer_names = route_dsn_board(dsn)
|
||||||
|
layer_name = layer_names[route.layer] if layer_names else "F.Cu"
|
||||||
|
width_dsn = _rule_width_dsn(dsn)
|
||||||
|
# net_number is assigned in DSN order by build_board, so index i -> number i+1
|
||||||
|
numbers_to_names = {i + 1: n.name for i, n in enumerate(dsn.nets)}
|
||||||
|
|
||||||
|
result = RoutingResult()
|
||||||
|
for net_no, paths in route.wires.items():
|
||||||
|
name = numbers_to_names.get(net_no, str(net_no))
|
||||||
|
for path in paths:
|
||||||
|
coords: list[float] = []
|
||||||
|
for point in path:
|
||||||
|
coords.append(point.x / scale)
|
||||||
|
coords.append(point.y / scale)
|
||||||
|
if len(coords) >= 4:
|
||||||
|
result.add_wire(name, RoutedWire(layer=layer_name, width=width_dsn, coords=coords))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def route(dsn_text: str) -> str:
|
||||||
|
"""Route a Specctra DSN string and return the routed SES string."""
|
||||||
|
dsn = parse_dsn(dsn_text)
|
||||||
|
result = build_routing_result(dsn)
|
||||||
|
return write_ses(dsn, result)
|
||||||
73
tests/dsn/fixtures/simple_2net.dsn
Normal file
73
tests/dsn/fixtures/simple_2net.dsn
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
(pcb "simple_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 20000 -20000 front 0)
|
||||||
|
(place A2 180000 -20000 front 0)
|
||||||
|
(place B1 20000 -60000 front 0)
|
||||||
|
(place B2 180000 -60000 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
|
||||||
|
)
|
||||||
|
)
|
||||||
145
tests/route/test_router.py
Normal file
145
tests/route/test_router.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
"""Tests for the MVP 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.
|
||||||
|
|
||||||
|
Fixtures: ``tests/dsn/fixtures/simple_2net.dsn`` (guaranteed routable).
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
|
||||||
|
SIMPLE = FIXTURES / "simple_2net.dsn"
|
||||||
|
|
||||||
|
|
||||||
|
def _net_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")}
|
||||||
|
|
||||||
|
|
||||||
|
def _wire_paths(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
|
||||||
|
|
||||||
|
|
||||||
|
# --- connectivity -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
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 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 set(nets) == {"NET_A", "NET_B"}
|
||||||
|
assert all(wires for wires in nets.values()) # each net has >= 1 wire
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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)
|
||||||
|
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():
|
||||||
|
endpoints = set()
|
||||||
|
for wire in wires:
|
||||||
|
_, coords = _wire_paths(wire)
|
||||||
|
assert len(coords) >= 2, "a trace needs at least two points"
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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_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)
|
||||||
|
|
||||||
|
|
||||||
|
# --- oracle parity ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.oracle
|
||||||
|
def test_connectivity_parity_with_oracle_on_simple_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)
|
||||||
|
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