diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 587d083..9263c10 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -1,20 +1,27 @@ -"""A grid-based maze router (MVP), now multi-layer with vias. +"""A grid-based maze router (MVP): multi-layer, vias, and rip-up-and-retry. -Still an *MVP* — not a port of FreeRouting's expansion-room maze — but it routes -across the board's signal layers and changes layers through vias when a net -cannot get through on one layer: +Still an *MVP* — not a port of FreeRouting's expansion-room maze / ripper — but +it routes across the board's signal layers, changes layers through vias, and +recovers from bad greedy net orderings with rip-up-and-retry: * the occupancy grid has a layer axis; A* nodes are ``(col, row, layer)``; -* in-plane moves (8-connected, 45-degree) cost distance and stay on a layer; -* a **via move** transitions between layers at a cell for a configurable - ``via_cost`` (so the router prefers one layer but will change to get through); -* a cell is blocked per-layer by other-net pads/traces/vias on that layer plus - keepouts; a via cell must be clear on **all** signal layers (a through via); -* each net's ratsnest is connected pin-to-pin; routed traces and vias then block - other nets. Trace endpoints land exactly on the connected pads. +* in-plane moves (8-connected, 45-degree) cost distance; a **via move** changes + layer at a cell for a configurable ``via_cost``; a through via must be clear on + every signal layer; +* occupancy is tracked *per connection* so a specific routed connection can be + removed. When a connection cannot reach its target through free space, the + router runs a **rip-up** search that may pass through other nets' traces (at a + penalty), rips up the connections it crosses, routes the failing connection, + and re-queues the ripped connections for a later pass; +* passes iterate up to ``max_passes``, keeping the best (fewest-unrouted) result + and stopping on full success or when a pass changes nothing; +* **thrash prevention:** each connection may be ripped at most ``rip_cap`` times, + and the penalty to rip a connection escalates with its rip count, so + repeatedly-ripped connections harden into walls. Everything is deterministic + (no RNG). -Deferred (still): FreeRouting's free-space expansion rooms, rip-up-and-retry, -blind/buried via spans, and shove. Those raise coverage/quality on dense boards. +Deferred (still): FreeRouting's free-space expansion rooms, exact shove / +DRC-clean geometry, and blind/buried via spans. """ from __future__ import annotations @@ -52,12 +59,14 @@ class RouteResult: ``routed_net_numbers`` are the nets that got any wire or via. """ - __slots__ = ("wires", "vias", "half_width") + __slots__ = ("wires", "vias", "half_width", "unrouted") def __init__(self, half_width: int) -> None: self.wires: dict[int, list[tuple[int, list[IntPoint]]]] = {} self.vias: dict[int, list[IntPoint]] = {} self.half_width = half_width + #: number of ratsnest connections left unrouted + self.unrouted = 0 def add_wire(self, net_no: int, layer: int, path: list[IntPoint]) -> None: self.wires.setdefault(net_no, []).append((layer, path)) @@ -75,8 +84,37 @@ class RouteResult: return sum(len(v) for v in self.vias.values()) +class _Connection: + """One ratsnest connection (a pin pair) and its routed state.""" + + __slots__ = ( + "index", + "net_no", + "a", + "b", + "routed", + "rip_count", + "wires", + "vias", + "trace_cells", + "via_cells", + ) + + def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None: + self.index = index + self.net_no = net_no + self.a = a + self.b = b + self.routed = False + self.rip_count = 0 + self.wires: list[tuple[int, list[IntPoint]]] = [] + self.vias: list[IntPoint] = [] + self.trace_cells: list[tuple[Cell, int]] = [] + self.via_cells: list[Cell] = [] + + class GridRouter: - """Routes a :class:`BasicBoard` across signal layers with a grid A* + vias.""" + """Routes a :class:`BasicBoard` with a grid A*, vias, and rip-up-and-retry.""" def __init__( self, @@ -86,12 +124,20 @@ class GridRouter: clearance: int, layers: list[int] | None = None, via_cost: float = 10.0, + rip_up: bool = True, + max_passes: int = 10, + rip_cap: int = 4, + rip_penalty: float = 30.0, ) -> None: self.board = board self.trace_width = max(trace_width, 1) self.clearance = max(clearance, 0) self.half_width = self.trace_width // 2 self.via_cost = via_cost + self.rip_up = rip_up + self.max_passes = max(max_passes, 1) + self.rip_cap = max(rip_cap, 0) + self.rip_penalty = rip_penalty # cell size must fit a trace plus its clearance to a neighbouring trace self.step = max(self.trace_width + self.clearance, 1) @@ -105,11 +151,12 @@ class GridRouter: 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) - # per-(cell, layer) occupancy + # static obstacles self._pad_block: dict[Node, set[int]] = {} self._keepout: set[Node] = set() - self._trace_block: dict[Node, int] = {} - self._via_block: dict[Cell, int] = {} + # per-connection occupancy (owner = connection index) + self._cell_owner: dict[Node, int] = {} + self._via_owner: dict[Cell, int] = {} self._rasterize_obstacles() # --- grid helpers ------------------------------------------------------- @@ -154,34 +201,68 @@ class GridRouter: for cell in self._cells_in_box(box, self.clearance): self._keepout.add((*cell, item.layer)) - def _blocked(self, cell: Cell, layer: int, net_no: int) -> bool: + # --- occupancy cost ----------------------------------------------------- + + def _enter_cost( + self, cell: Cell, layer: int, net_no: int, allow_ripup: bool, conns: list[_Connection] + ) -> float | None: + """Cost to enter ``(cell, layer)`` for ``net_no``; ``None`` if blocked. + + Static pads/keepouts always block. A cell occupied by another net's trace + or via is impassable in free-space mode; in rip-up mode it is passable at + a penalty that escalates with the owner's rip count, unless the owner has + already hit ``rip_cap`` (then it hardens into a wall). + """ node = (*cell, layer) if node in self._keepout: - return True + return None pads = self._pad_block.get(node) if pads and any(n != net_no for n in pads): - return True - trace = self._trace_block.get(node) - if trace is not None and trace != net_no: - return True - via = self._via_block.get(cell) - return via is not None and via != net_no + return None + blocking = None + owner = self._cell_owner.get(node) + if owner is not None and conns[owner].net_no != net_no: + blocking = owner + else: + via_owner = self._via_owner.get(cell) + if via_owner is not None and conns[via_owner].net_no != net_no: + blocking = via_owner + if blocking is None: + return 0.0 + if not allow_ripup: + return None + rip_count = conns[blocking].rip_count + if rip_count >= self.rip_cap: + return None + return self.rip_penalty * (1 + rip_count) - def _via_placeable(self, cell: Cell, net_no: int) -> bool: - """A through via at ``cell`` needs every signal layer clear of other nets.""" - return all(not self._blocked(cell, layer, net_no) for layer in self.layers) + def _via_placeable(self, cell: Cell, net_no: int, conns: list[_Connection]) -> bool: + """A through via needs every signal layer strictly free of other nets.""" + return all( + self._enter_cost(cell, layer, net_no, False, conns) == 0.0 for layer in self.layers + ) # --- A* search ---------------------------------------------------------- def _search( - self, starts: set[Node], goal_cell: Cell, goals: set[Node], net_no: int + self, conn: _Connection, allow_ripup: bool, conns: list[_Connection] ) -> list[Node] | None: + start_cell = self._cell_of(conn.a.location) + goal_cell = self._cell_of(conn.b.location) + a_layers = [layer for layer in conn.a.layers if layer in self.layers] + b_layers = [layer for layer in conn.b.layers if layer in self.layers] + if not a_layers or not b_layers: + return None + starts = {(*start_cell, layer) for layer in a_layers} + goals = {(*goal_cell, layer) for layer in b_layers} if starts & goals: - return [next(iter(starts & goals))] + return [min(starts & goals)] + net_no = conn.net_no + open_heap: list[tuple[float, Node]] = [] g_score: dict[Node, float] = {} came_from: dict[Node, Node] = {} - for s in starts: + for s in sorted(starts): g_score[s] = 0.0 heapq.heappush(open_heap, (self._h(s, goal_cell), s)) @@ -191,30 +272,28 @@ class GridRouter: return _reconstruct(came_from, current) cx, cy, cl = current base = g_score[current] - # in-plane moves for dx, dy in _NEIGHBOURS: cell = (cx + dx, cy + dy) if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows): continue nxt = (*cell, cl) - if nxt not in goals and self._blocked(cell, cl, net_no): - continue - cost = 1.0 if dx == 0 or dy == 0 else math.sqrt(2) - self._relax(current, nxt, base + cost, goal_cell, g_score, came_from, open_heap) - # via moves (change layer at the same cell) - if len(self.layers) > 1 and self._via_placeable((cx, cy), net_no): + if nxt in goals: + enter = 0.0 + else: + enter = self._enter_cost(cell, cl, net_no, allow_ripup, conns) + if enter is None: + continue + step = 1.0 if dx == 0 or dy == 0 else math.sqrt(2) + self._relax( + current, nxt, base + step + enter, goal_cell, g_score, came_from, open_heap + ) + if len(self.layers) > 1 and self._via_placeable((cx, cy), net_no, conns): for layer in self.layers: if layer == cl: continue nxt = (cx, cy, layer) self._relax( - current, - nxt, - base + self.via_cost, - goal_cell, - g_score, - came_from, - open_heap, + current, nxt, base + self.via_cost, goal_cell, g_score, came_from, open_heap ) return None @@ -227,28 +306,106 @@ class GridRouter: def _h(self, node: Node, goal_cell: Cell) -> float: return math.hypot(node[0] - goal_cell[0], node[1] - goal_cell[1]) - # --- commit / marking --------------------------------------------------- + # --- commit / rip ------------------------------------------------------- - def _mark(self, wires, vias, net_no: int) -> None: - for layer, cells in wires: - for cell in cells: - self._trace_block[(*cell, layer)] = net_no - for cell in vias: - self._via_block[cell] = net_no + def _commit(self, conn: _Connection, path: list[Node]) -> None: + wire_segs, via_cells = _split_path(path) + conn.trace_cells = [(cell, layer) for layer, cells in wire_segs for cell in cells] + conn.via_cells = list(via_cells) + for cell, layer in conn.trace_cells: + self._cell_owner[(*cell, layer)] = conn.index + for cell in conn.via_cells: + self._via_owner[cell] = conn.index + conn.wires = [] + for idx, (layer, cells) in enumerate(wire_segs): + points = [self._cell_center(c) for c in cells] + if idx == 0: + points.insert(0, conn.a.location) + if idx == len(wire_segs) - 1: + points.append(conn.b.location) + points = _simplify(points) + if len(points) >= 2: + conn.wires.append((layer, points)) + conn.vias = [self._cell_center(c) for c in conn.via_cells] + conn.routed = True - # --- public routing ----------------------------------------------------- + def _rip(self, conn: _Connection) -> None: + for cell, layer in conn.trace_cells: + if self._cell_owner.get((*cell, layer)) == conn.index: + del self._cell_owner[(*cell, layer)] + for cell in conn.via_cells: + if self._via_owner.get(cell) == conn.index: + del self._via_owner[cell] + conn.trace_cells = [] + conn.via_cells = [] + conn.wires = [] + conn.vias = [] + conn.routed = False + conn.rip_count += 1 + + def _crossed_owners(self, path: list[Node], net_no: int, conns: list[_Connection]) -> set[int]: + owners: set[int] = set() + for cx, cy, cl in path: + owner = self._cell_owner.get((cx, cy, cl)) + if owner is not None and conns[owner].net_no != net_no: + owners.add(owner) + via_owner = self._via_owner.get((cx, cy)) + if via_owner is not None and conns[via_owner].net_no != net_no: + owners.add(via_owner) + return owners + + # --- passes ------------------------------------------------------------- def route(self) -> RouteResult: - """Route every net with >= 2 pins across the router's signal layers.""" - result = RouteResult(self.half_width) + """Route all ratsnest connections, using rip-up-and-retry across passes.""" + conns = self._build_connections() + if not conns: + return RouteResult(self.half_width) + + passes = self.max_passes if self.rip_up else 1 + best_count = -1 + best_snapshot: list[tuple[int, list, list]] | None = None + best_unrouted = len(conns) + for _ in range(passes): + changed = False + for conn in conns: # deterministic: connection creation order + if conn.routed: + continue + if self._try_route(conn, conns): + changed = True + routed_count = sum(1 for c in conns if c.routed) + if routed_count > best_count: + best_count = routed_count + best_unrouted = len(conns) - routed_count + best_snapshot = self._snapshot(conns) + if routed_count == len(conns): + break + if not changed: + break + return self._result_from(best_snapshot or [], best_unrouted) + + def _try_route(self, conn: _Connection, conns: list[_Connection]) -> bool: + path = self._search(conn, allow_ripup=False, conns=conns) + if path is None: + if not self.rip_up: + return False + path = self._search(conn, allow_ripup=True, conns=conns) + if path is None: + return False + for idx in sorted(self._crossed_owners(path, conn.net_no, conns)): + self._rip(conns[idx]) + self._commit(conn, path) + return True + + def _build_connections(self) -> list[_Connection]: pins_by_net = self._pins_by_net() - for net_no in sorted(pins_by_net, key=lambda n: len(pins_by_net[n])): + conns: list[_Connection] = [] + # deterministic: fewer-pin nets first, then by net number + for net_no in sorted(pins_by_net, key=lambda n: (len(pins_by_net[n]), n)): pins = pins_by_net[net_no] - if len(pins) < 2: - continue for a, b in zip(pins, pins[1:], strict=False): - self._route_connection(a, b, net_no, result) - return result + conns.append(_Connection(len(conns), net_no, a, b)) + return conns def _pins_by_net(self) -> dict[int, list[Pin]]: by_net: dict[int, list[Pin]] = {} @@ -259,32 +416,22 @@ class GridRouter: by_net.setdefault(net_no, []).append(pin) return by_net - def _route_connection(self, a: Pin, b: Pin, net_no: int, result: RouteResult) -> None: - start_cell = self._cell_of(a.location) - goal_cell = self._cell_of(b.location) - a_layers = [layer for layer in a.layers if layer in self.layers] - b_layers = [layer for layer in b.layers if layer in self.layers] - if not a_layers or not b_layers: - return - starts = {(*start_cell, layer) for layer in a_layers} - goals = {(*goal_cell, layer) for layer in b_layers} - path = self._search(starts, goal_cell, goals, net_no) - if path is None: - return - wires, vias = _split_path(path) - self._mark(wires, vias, net_no) - # emit wires with exact pad endpoints; emit vias at their cell centres - for idx, (layer, cells) in enumerate(wires): - points = [self._cell_center(c) for c in cells] - if idx == 0: - points.insert(0, a.location) - if idx == len(wires) - 1: - points.append(b.location) - points = _simplify(points) - if len(points) >= 2: + def _snapshot(self, conns: list[_Connection]) -> list[tuple[int, list, list]]: + return [ + (c.net_no, [(layer, list(pts)) for layer, pts in c.wires], list(c.vias)) + for c in conns + if c.routed + ] + + def _result_from(self, snapshot, unrouted: int) -> RouteResult: + result = RouteResult(self.half_width) + result.unrouted = unrouted + for net_no, wires, vias in snapshot: + for layer, points in wires: result.add_wire(net_no, layer, points) - for cell in vias: - result.add_via(net_no, self._cell_center(cell)) + for location in vias: + result.add_via(net_no, location) + return result def _split_path(path: list[Node]) -> tuple[list[tuple[int, list[Cell]]], list[Cell]]: @@ -336,6 +483,8 @@ def route_board( clearance: int, layers: list[int] | None = None, via_cost: float = 10.0, + rip_up: bool = True, + max_passes: int = 10, ) -> RouteResult: """Convenience wrapper: build a :class:`GridRouter` and route the board.""" router = GridRouter( @@ -344,5 +493,7 @@ def route_board( clearance=clearance, layers=layers, via_cost=via_cost, + rip_up=rip_up, + max_passes=max_passes, ) return router.route() diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index 9ec9933..959eb8f 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -44,23 +44,37 @@ def _via_padstack(dsn: DsnBoard) -> str: def route_dsn_board( - dsn: DsnBoard, *, layers: list[int] | None = None + dsn: DsnBoard, + *, + layers: list[int] | None = None, + rip_up: bool = True, + max_passes: int = 10, ) -> tuple[RouteResult, int, list[str]]: """Route a parsed :class:`DsnBoard`; return the board-unit result, the scale, and the layer names. ``layers`` restricts routing to those signal-layer - indices (default: all signal layers, i.e. multi-layer with vias).""" + indices (default: all signal layers, i.e. multi-layer with vias); ``rip_up`` + toggles rip-up-and-retry (``False`` = greedy single pass).""" 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, layers=layers) + result = route_board( + board, + trace_width=width_board, + clearance=clearance_board, + layers=layers, + rip_up=rip_up, + max_passes=max_passes, + ) return result, scale, [layer.name for layer in dsn.layers] -def build_routing_result(dsn: DsnBoard, *, layers: list[int] | None = None) -> RoutingResult: +def build_routing_result( + dsn: DsnBoard, *, layers: list[int] | None = None, rip_up: bool = True +) -> RoutingResult: """Route ``dsn`` and convert the board-unit paths + vias to a DSN-unit :class:`~freeroute.ses.RoutingResult` for SES emission.""" - route, scale, layer_names = route_dsn_board(dsn, layers=layers) + route, scale, layer_names = route_dsn_board(dsn, layers=layers, rip_up=rip_up) width_dsn = _rule_width_dsn(dsn) via_name = _via_padstack(dsn) # net_number is assigned in DSN order by build_board, so index i -> number i+1