diff --git a/src/freeroute/cli.py b/src/freeroute/cli.py index 6dfa7ac..d21c8fa 100644 --- a/src/freeroute/cli.py +++ b/src/freeroute/cli.py @@ -31,7 +31,10 @@ from freeroute.ses import write_ses #: an engine is rejected by :func:`_validate` rather than quietly ignored. ENGINE_OPTIONS: dict[str, frozenset[str]] = { "grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "negotiate", "rollback", "tree"}), - "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "negotiate", "rollback", "tree"}), + "exact": frozenset({ + "shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", + "negotiate", "rollback", "tree", "window", + }), "room": frozenset({"shove", "pack"}), } @@ -49,6 +52,7 @@ _FLAG_NAMES = { "negotiate": "--negotiate", "rollback": "--no-rollback", "tree": "--no-tree", + "window": "--window-reroute", } @@ -194,6 +198,44 @@ def build_parser() -> argparse.ArgumentParser: "paired by a minimum spanning tree and a connection may end on copper its net " "has already routed", ) + parser.add_argument( + "--window-reroute", + type=int, + metavar="N", + help="exact engine: after routing, N rounds of window re-routes: around each " + "connection still unrouted, the nets routed wholly inside a window are ripped and " + "the window routed again on a finer grid, kept only if fewer connections are left " + "unrouted; each round doubles the window's margin (default 0: off)", + ) + parser.add_argument( + "--window-resolution", + type=int, + default=4, + metavar="K", + help="the window re-route's grid resolution (default 4)", + ) + parser.add_argument( + "--window-budget", + type=float, + default=0.25, + metavar="F", + help="the window re-route stops starting windows once it has spent this share " + "of the routing's own time (default 0.25)", + ) + parser.add_argument( + "--window-cap", + type=int, + default=40, + metavar="W", + help="at most this many windows per round, smallest first (default 40)", + ) + parser.add_argument( + "--window-margin", + type=float, + default=1.5, + metavar="MM", + help="the first round's window margin around the stuck connection's pins (default 1.5)", + ) parser.add_argument( "--hole-to-hole", type=float, @@ -234,6 +276,7 @@ def main(argv: list[str] | None = None) -> int: ("negotiate", args.negotiate is not None), ("rollback", not args.rollback), ("tree", not args.tree_nets), + ("window", args.window_reroute is not None), ) if asked ] @@ -272,6 +315,11 @@ def main(argv: list[str] | None = None) -> int: "hole_to_hole_mm": args.hole_to_hole, "rollback": args.rollback, "tree_nets": args.tree_nets, + "window_reroute": args.window_reroute or 0, + "window_resolution": args.window_resolution, + "window_margin_mm": args.window_margin, + "window_budget": args.window_budget, + "window_cap": args.window_cap, } elif args.engine == "grid": options = { diff --git a/src/freeroute/route/exact_router.py b/src/freeroute/route/exact_router.py index bc80ec8..f842b71 100644 --- a/src/freeroute/route/exact_router.py +++ b/src/freeroute/route/exact_router.py @@ -106,6 +106,8 @@ def route_board_exact( net_widths: dict[int, int] | None = None, via_drill: int | None = None, hole_to_hole: int = 0, + only_nets: frozenset | None = None, + bounds: IntBox | None = None, ) -> ExactRouteResult: """Route ``board`` orthogonally and verify the result with exact geometry. @@ -143,6 +145,8 @@ def route_board_exact( net_widths=net_widths, via_drill=via_drill, hole_to_hole=hole_to_hole, + only_nets=only_nets, + bounds=bounds, ) edge = 0 if edge_clearance is None else edge_clearance outline = board.outline @@ -259,7 +263,10 @@ def route_board_exact( # a net is dropped if it has a real ratsnest (>= 2 pins on a signal layer) # but was not accepted — whether the grid failed to route it or its exact # copper conflicted. - dropped = sorted(_routable_nets(board, signal_layers) - set(accepted)) + routable = _routable_nets(board, signal_layers) + if only_nets is not None: # a window re-route: the other nets are not ours to route + routable &= set(only_nets) + dropped = sorted(routable - set(accepted)) shoves = 0 if shove and dropped: @@ -284,7 +291,7 @@ def route_board_exact( if diagonal: outline = board.bounding_box - for net_no in sorted(_routable_nets(board, signal_layers)): + for net_no in sorted(routable): _diagonalize( net_no, board, tree, accepted, owner, signal_layers, half_width, clearance, outline ) diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index a227a4f..8c27dbc 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -203,8 +203,18 @@ class GridRouter: via_drill: int | None = None, hole_to_hole: int = 0, tree_nets: bool = True, + only_nets: frozenset | None = None, + bounds: IntBox | None = None, ) -> None: self.board = board + # A window re-route routes some nets inside part of the board: only + # these nets get connections, and the grid covers only ``bounds`` + # (cells outside it do not exist, so nothing is routed there). + self.only_nets = frozenset(only_nets) if only_nets is not None else None + self._bounds = bounds + # connections with an end outside ``bounds``: never searched, since + # the grid has no cells to reach that end by + self._outside: set[int] = set() # Multi-pin nets: pair a net's pin groups by a minimum spanning tree, # and let a connection start or end on copper its net already routed # (a T into the tree), not only on one chosen pin. @@ -324,6 +334,11 @@ class GridRouter: self.layers = list(layers) box = board.bounding_box + if bounds is not None: + box = IntBox( + max(box.ll.x, bounds.ll.x), max(box.ll.y, bounds.ll.y), + min(box.ur.x, bounds.ur.x), min(box.ur.y, bounds.ur.y), + ) 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) @@ -706,6 +721,8 @@ class GridRouter: def _search( self, conn: _Connection, allow_ripup: bool, conns: list[_Connection] ) -> list[Node] | None: + if conn.index in self._outside: + return None half = self._conn_half(conn) self._cur_half, self._spread = half, self._spread_for(half) try: @@ -1837,6 +1854,8 @@ class GridRouter: self._plane_nets: set[int] = set() nets = set(pins_by_net) | set(zones_by_net) | set(planes_by_net) # deterministic: fewer-pin nets first, then by net number + if self.only_nets is not None: + nets &= self.only_nets for net_no in sorted(nets, key=lambda n: (len(pins_by_net.get(n, [])), n)): zones = zones_by_net.get(net_no, []) + planes_by_net.get(net_no, []) groups = _connected_groups( @@ -1859,6 +1878,11 @@ class GridRouter: self._net_conns: dict[int, list[_Connection]] = {} for conn in conns: self._net_conns.setdefault(conn.net_no, []).append(conn) + if self._bounds is not None: + self._outside = { + c.index for c in conns + if not (_group_inside(c.a_group, self._bounds) and _group_inside(c.b_group, self._bounds)) + } return conns def _fixed_pieces(self, net_no: int) -> list[_FixedPiece]: @@ -2004,6 +2028,19 @@ def _group_label(group) -> str: return "/".join(parts) or "?" +def _group_inside(group, bounds: IntBox) -> bool: + """A group has a pin inside ``bounds``, or a zone reaching into it.""" + pins, zones = group + if any(bounds.ll.x <= p.location.x <= bounds.ur.x and bounds.ll.y <= p.location.y <= bounds.ur.y + for p in pins): + return True + for zone in zones: + box = zone.bounding_box() + if box.ll.x <= bounds.ur.x and bounds.ll.x <= box.ur.x and box.ll.y <= bounds.ur.y and bounds.ll.y <= box.ur.y: + return True + return False + + def _ends_of(conn) -> tuple[str, str]: """A readable description of what a connection joins.""" return _group_label(conn.a_group), _group_label(conn.b_group) @@ -2196,6 +2233,8 @@ def route_board( net_widths: dict[int, int] | None = None, via_drill: int | None = None, hole_to_hole: int = 0, + only_nets: frozenset | None = None, + bounds: IntBox | None = None, ) -> RouteResult: """Convenience wrapper: build a :class:`GridRouter` and route the board.""" router = GridRouter( @@ -2220,5 +2259,7 @@ def route_board( net_widths=net_widths, via_drill=via_drill, hole_to_hole=hole_to_hole, + only_nets=only_nets, + bounds=bounds, ) return router.route() diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index 5195431..83f9068 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -13,6 +13,7 @@ to DSN units (dividing by the resolution) for the SES ``(wire (path ...))`` and from __future__ import annotations import re +import time from freeroute.board import build_board from freeroute.dsn import DsnBoard, parse_dsn @@ -21,6 +22,7 @@ from freeroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses from .exact_router import ExactRouteResult, route_board_exact from .grid_router import RouteResult, route_board from .room_router import route_board_rooms +from .window import window_reroute as window_reroute_stage __all__ = ["route", "route_dsn_board", "build_routing_result"] @@ -237,6 +239,11 @@ def route_dsn_board_exact( hole_to_hole_mm: float = 0.25, rollback: bool = True, tree_nets: bool = True, + window_reroute: int = 0, + window_resolution: int = 4, + window_margin_mm: float = 1.5, + window_budget: float = 0.25, + window_cap: int = 40, ) -> tuple[ExactRouteResult, int, list[str]]: """Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal, DRC-verified) track; return the exact result, the scale, and layer names. @@ -248,29 +255,48 @@ def route_dsn_board_exact( edge_clearance_board = _mm_to_board(dsn, scale, edge_clearance_mm) width_board = round(_rule_width_dsn(dsn) * scale) clearance_board = round(_rule_clearance_dsn(dsn) * scale) + # the settings a window re-route repeats, at its own resolution + common = { + "heuristic_weight": heuristic_weight, + "via_half": _via_half_board(dsn, scale), + "edge_clearance": edge_clearance_board, + "trace_width": width_board, + "clearance": clearance_board, + "layers": layers, + "max_passes": max_passes, + "rip_up": rip_up, + "shove": shove, + "diagonal": diagonal, + "rollback": rollback, + "tree_nets": tree_nets, + "net_widths": _net_widths_board(dsn, scale), + "via_drill": _via_drill_board(dsn, scale), + "hole_to_hole": _mm_to_board(dsn, scale, hole_to_hole_mm) or 0, + } + started = time.monotonic() exact = route_board_exact( board, resolution=resolution, - heuristic_weight=heuristic_weight, - via_half=_via_half_board(dsn, scale), - edge_clearance=edge_clearance_board, - trace_width=width_board, - clearance=clearance_board, - layers=layers, - max_passes=max_passes, - rip_up=rip_up, - shove=shove, - diagonal=diagonal, seed=seed, route_first=route_first, access_cost=access_cost, negotiate=negotiate, - rollback=rollback, - tree_nets=tree_nets, - net_widths=_net_widths_board(dsn, scale), - via_drill=_via_drill_board(dsn, scale), - hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0, + **common, ) + if window_reroute > 0: + via_half = common["via_half"] or max(width_board // 2, 1) + exact.result = window_reroute_stage( + dsn, exact.result, common, + rounds=window_reroute, + resolution=max(int(window_resolution), resolution), + margin=_mm_to_board(dsn, scale, window_margin_mm) or 0, + via_half=via_half, + via_drill=common["via_drill"] or 2 * via_half, + via_name=_via_padstack(dsn), + # a share of what the routing itself took + budget_s=max(float(window_budget), 0.0) * (time.monotonic() - started), + max_windows=window_cap, + ) return exact, scale, [layer.name for layer in dsn.layers] @@ -292,6 +318,11 @@ def build_exact_routing_result( hole_to_hole_mm: float = 0.25, rollback: bool = True, tree_nets: bool = True, + window_reroute: int = 0, + window_resolution: int = 4, + window_margin_mm: float = 1.5, + window_budget: float = 0.25, + window_cap: int = 40, ) -> RoutingResult: """Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult.""" exact, scale, layer_names = route_dsn_board_exact( @@ -311,6 +342,11 @@ def build_exact_routing_result( hole_to_hole_mm=hole_to_hole_mm, rollback=rollback, tree_nets=tree_nets, + window_reroute=window_reroute, + window_resolution=window_resolution, + window_margin_mm=window_margin_mm, + window_budget=window_budget, + window_cap=window_cap, ) return _to_routing_result(exact.result, dsn, scale, layer_names) diff --git a/src/freeroute/route/window.py b/src/freeroute/route/window.py new file mode 100644 index 0000000..7fb0b47 --- /dev/null +++ b/src/freeroute/route/window.py @@ -0,0 +1,248 @@ +"""Window re-route: a last stage for the connections still unrouted. + +Around each stuck connection a window is cut: the box of its two end pins +plus a margin. Every net whose routed copper lies wholly inside it is +ripped; everything else stays, as fixed copper. The window is then routed +again on its own, on a finer grid than the whole board can afford, the +stuck connection's net and the ripped nets only. The result is kept only +if fewer connections are left unrouted than before; otherwise the board is +as it was. Each round doubles the margin for the connections still stuck. + +It is a last stage, so it is kept cheap: a window's board holds only what +the window needs (every pin and piece of copper of the nets it reroutes, +so their connectivity is unchanged, and of the other items only those near +the window), the smallest windows go first, at most ``max_windows`` run per +round, and no new window starts once ``budget_s`` seconds are spent. + +What makes it differ from re-running the same search near the failure: the +finer grid finds gaps the board's grid steps over, and the ripped nets go in +again in a new order around the stuck one. A finer grid on the whole board +costs routes elsewhere (its cells are slower and its order changes); inside +a small window it only ever replaces what it can improve. +""" + +from __future__ import annotations + +import time +from typing import Any + +from freeroute.board import BasicBoard, build_board +from freeroute.board.items import ObstacleArea, Pin, Trace, Via +from freeroute.dsn import DsnBoard +from freeroute.geometry import IntBox, IntPoint + +from .exact_router import route_board_exact +from .grid_router import RouteResult + +Copper = tuple[list[tuple[int, list[IntPoint]]], list[IntPoint]] + + +def _pin_labels(board) -> dict[tuple[int, str], IntPoint]: + """Each pin's location by (net, label), the label a failure names it by.""" + found: dict[tuple[int, str], IntPoint] = {} + for pin in board.get_pins(): + for net in pin.net_nos: + found.setdefault((net, f"{pin.component_ref}-{pin.name}"), pin.location) + return found + + +def _end_point(labels, net: int, label: str) -> IntPoint | None: + """Where a failure's end is: the first pin its label names.""" + return labels.get((net, label.split("/")[0])) + + +def _inside(copper: Copper, window: IntBox) -> bool: + wires, vias = copper + points = [p for _layer, path in wires for p in path] + list(vias) + return bool(points) and all( + window.ll.x <= p.x <= window.ur.x and window.ll.y <= p.y <= window.ur.y for p in points + ) + + +def _item_box(item) -> IntBox | None: + if isinstance(item, (Pin, Via)): + return item.shape.bounding_box() if item.shape is not None else None + if isinstance(item, ObstacleArea): + return item.bounding_box() + if isinstance(item, Trace) and item.corners: + h = item.half_width + xs, ys = [p.x for p in item.corners], [p.y for p in item.corners] + return IntBox(min(xs) - h, min(ys) - h, max(xs) + h, max(ys) + h) + return None + + +def _meets(box: IntBox | None, view: IntBox) -> bool: + return box is not None and box.ll.x <= view.ur.x and view.ll.x <= box.ur.x and ( + box.ll.y <= view.ur.y and view.ll.y <= box.ur.y + ) + + +def _path_box(path: list[IntPoint], grow: int) -> IntBox: + xs, ys = [p.x for p in path], [p.y for p in path] + return IntBox(min(xs) - grow, min(ys) - grow, max(xs) + grow, max(ys) + grow) + + +def _window_board( + base: BasicBoard, base_items: list, view: IntBox, affected: set[int], + copper: dict[int, Copper], keep: set[int], half: dict[int, int], default_half: int, + via_half: int, via_drill: int, layers: list[int], via_name: str, +) -> BasicBoard: + """The board a window is routed on: the base board's items of the nets + being rerouted and those near ``view``, plus the routed copper being kept + (all of an affected net's, so its connectivity is the whole board's, and + of other nets only what is near ``view``), as fixed copper.""" + board = BasicBoard(base.layer_structure, base.nets, base.clearance_matrix, base.bounding_box) + board.outline = base.outline + for item, box in base_items: + if (item.net_nos and item.net_nos[0] in affected) or _meets(box, view): + board.insert(item) + for net in keep: + wires, vias = copper.get(net, ([], [])) + width = half.get(net, default_half) + mine = net in affected + for layer, path in wires: + if len(path) >= 2 and (mine or _meets(_path_box(path, width), view)): + board.insert(Trace(net_nos=[net], fixed=True, layer=layer, half_width=width, corners=list(path))) + for location in dict.fromkeys(vias): + shape = IntBox(location.x - via_half, location.y - via_half, + location.x + via_half, location.y + via_half) + if mine or _meets(shape, view): + board.insert(Via( + net_nos=[net], fixed=True, padstack_name=via_name, drill=via_drill, + location=location, layers=list(layers), shape=shape, + )) + return board + + +def _open(failed) -> list: + """Failures that are real opens, not connections left to a zone fill.""" + return [f for f in failed if not (len(f) > 3 and f[3])] + + +def window_reroute( + dsn: DsnBoard, + result: RouteResult, + route_args: dict[str, Any], + *, + rounds: int, + resolution: int, + margin: int, + via_half: int, + via_drill: int, + via_name: str, + budget_s: float | None = None, + max_windows: int = 40, +) -> RouteResult: + """Re-route a window around each still-failing connection; keep only gains. + + ``route_args`` are the exact engine's settings for the whole board (width, + clearance, layers, shove, ...); the window runs them at ``resolution``. + ``margin`` is the first round's window margin in board units. No new + window starts once ``budget_s`` seconds have gone by (None: no limit), + and at most ``max_windows`` run per round.""" + started = time.monotonic() + if rounds < 1 or not _open(result.failed): + return result + default_half = result.half_width + copper: dict[int, Copper] = { + net: (list(result.wires.get(net, [])), list(result.vias.get(net, []))) + for net in set(result.wires) | set(result.vias) + } + half = dict(result.net_half) + failed = list(result.failed) + base = build_board(dsn) + base_items = [(item, _item_box(item)) for item in base.get_items()] + layers = route_args.get("layers") or [ + i for i, layer in enumerate(base.layer_structure.arr) if layer.is_signal + ] + labels = _pin_labels(base) + # the copper a window's routing can feel from outside it + context = 2 * (route_args["trace_width"] + route_args["clearance"]) + 2 * via_half + ( + route_args.get("edge_clearance") or 0 + ) + + def window_of(entry, reach: int) -> IntBox | None: + ends = [_end_point(labels, entry[0], label) for label in entry[1:3]] + ends = [p for p in ends if p is not None] + if not ends: + return None + return IntBox( + min(p.x for p in ends) - reach, min(p.y for p in ends) - reach, + max(p.x for p in ends) + reach, max(p.y for p in ends) + reach, + ) + + def area(box: IntBox | None) -> float: + return float("inf") if box is None else (box.ur.x - box.ll.x) * (box.ur.y - box.ll.y) + + changed = out_of_time = False + for rnd in range(rounds): + reach = margin * (2 ** rnd) + + # the cheap wins first: the smallest windows + todo = sorted(_open(list(failed)), key=lambda f: area(window_of(f, reach))) + for entry in todo[:max(int(max_windows), 1)]: + if budget_s is not None and time.monotonic() - started >= budget_s: + out_of_time = True + break + if entry not in failed: + continue # an earlier window routed it + net = entry[0] + window = window_of(entry, reach) + if window is None: + continue + victims = {n for n, c in copper.items() if _inside(c, window)} + affected = victims | {net} + before = _open(f for f in failed if f[0] in affected) + keep = set(copper) - victims + view = IntBox(window.ll.x - context, window.ll.y - context, + window.ur.x + context, window.ur.y + context) + board = _window_board(base, base_items, view, affected, copper, keep, half, + default_half, via_half, via_drill, layers, via_name) + sub = route_board_exact( + board, **route_args, resolution=resolution, + only_nets=frozenset(affected), bounds=window, + route_first=frozenset((f[0], f[1], f[2]) for f in before), + ).result + after = _open(f for f in sub.failed if f[0] in affected) + if len(after) >= len(before): + continue + # the window board left out what lies away from it, so copper + # reaching out of the window was never checked against it + if any(not _inside((sub.wires.get(n, []), sub.vias.get(n, [])), window) + for n in affected if sub.wires.get(n) or sub.vias.get(n)): + continue + # a net keeping some of its copper is written at one width: the + # window's new copper has to have gone in at that width too + if any( + n not in victims and copper.get(n) and (sub.wires.get(n) or sub.vias.get(n)) + and sub.net_half.get(n, default_half) != half.get(n, default_half) + for n in affected + ): + continue + for n in affected: + kept_wires, kept_vias = copper.get(n, ([], [])) if n not in victims else ([], []) + copper[n] = (kept_wires + sub.wires.get(n, []), kept_vias + sub.vias.get(n, [])) + if n in victims: + half.pop(n, None) + if n in sub.net_half: + half[n] = sub.net_half[n] + failed = [f for f in failed if f[0] not in affected] + [ + f for f in sub.failed if f[0] in affected + ] + changed = True + if out_of_time or not _open(failed): + break + + if not changed: + return result + out = RouteResult(default_half) + for net, (wires, vias) in copper.items(): + for layer, path in wires: + out.add_wire(net, layer, path) + for location in dict.fromkeys(vias): + out.add_via(net, location) + out.failed = failed + out.unrouted = len(_open(failed)) + out.net_half = half + out.narrowed = list(result.narrowed) + return out diff --git a/tests/route/test_window_reroute.py b/tests/route/test_window_reroute.py new file mode 100644 index 0000000..9584535 --- /dev/null +++ b/tests/route/test_window_reroute.py @@ -0,0 +1,128 @@ +"""Window re-route: a finer grid around a stuck connection, kept only when +it leaves fewer connections unrouted.""" + +from __future__ import annotations + +from freeroute.board import build_board +from freeroute.dsn import parse_dsn +from freeroute.geometry import IntBox +from freeroute.route.grid_router import GridRouter +from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn, route_dsn_board_exact + + +def _slot(gap: int = 640, far: bool = False) -> str: + """A wall across the board with a slot in it. A trace fits the slot + only along a narrow band of heights that the board's 1x grid has no row + in and a 4x grid does. ``far`` adds a second net nowhere near the wall.""" + yc = -5000 + lo, hi = yc - gap // 2, yc + gap // 2 + extra_place = "(place Q1 2000 -1000 front 0) (place Q2 6000 -1000 front 0)" if far else "" + extra_net = "(net M (pins Q1-1 Q2-1))" if far else "" + return f"""(pcb "slot.dsn" + (parser (string_quote ") (space_in_quoted_tokens on) (host_cad "t") (host_version "1")) + (resolution um 10) + (unit um) + (structure + (layer F.Cu (type signal) (property (index 0))) + (boundary (path pcb 0 0 0 20000 0 20000 -10000 0 -10000 0 0)) + (keepout "" (rect F.Cu 9000 -10000 11000 {lo})) + (keepout "" (rect F.Cu 9000 {hi} 11000 0)) + (rule (width 200) (clearance 200)) + ) + (library + (padstack Rect_Pad (shape (rect F.Cu -100 -100 100 100)) (attach off)) + (image PAD (pin Rect_Pad 1 0 0)) + ) + (placement + (component PAD + (place P1 3000 {yc} front 0) + (place P2 17000 {yc} front 0) + {extra_place} + ) + ) + (network + (net N (pins P1-1 P2-1)) + {extra_net} + (class default (rule (width 200) (clearance 200))) + ) + (wiring) +) +""" + + +def _route(text: str, rounds: int, **window): + exact, _scale, _layers = route_dsn_board_exact( + parse_dsn(text), resolution=1, window_reroute=rounds, window_resolution=4, **window, + ) + return exact.result + + +def test_the_board_grid_misses_the_slot_and_a_finer_window_finds_it(): + plain = _route(_slot(), 0) + assert len(plain.failed) == 1 + windowed = _route(_slot(), 1) + assert windowed.failed == [] + assert windowed.wires # and the copper it found is in the result + + +def test_a_window_that_cannot_help_leaves_the_board_as_it_was(): + plain = _route(_slot(gap=500, far=True), 0) # too narrow for any grid + windowed = _route(_slot(gap=500, far=True), 2) + assert len(windowed.failed) == len(plain.failed) == 1 + assert windowed.wires == plain.wires + assert windowed.vias == plain.vias + + +def test_copper_outside_the_window_is_kept_as_it_was(): + plain = _route(_slot(far=True), 0) + windowed = _route(_slot(far=True), 1) + assert windowed.failed == [] + far_net = 2 # M, the second net in the DSN + assert windowed.wires[far_net] == plain.wires[far_net] + + +def test_a_router_limited_to_some_nets_and_a_box_builds_only_those(): + dsn = parse_dsn(_slot(far=True)) + s = dsn.resolution.value + board = build_board(dsn) + whole = GridRouter(board, trace_width=round(_rule_width_dsn(dsn) * s), + clearance=round(_rule_clearance_dsn(dsn) * s), orthogonal=True) + part = GridRouter(board, trace_width=round(_rule_width_dsn(dsn) * s), + clearance=round(_rule_clearance_dsn(dsn) * s), orthogonal=True, + only_nets=frozenset({2}), bounds=IntBox(0, -20000, 80000, 0)) + assert {c.net_no for c in whole._build_connections()} == {1, 2} + assert {c.net_no for c in part._build_connections()} == {2} + assert part.cols * part.rows < whole.cols * whole.rows + + +def test_a_spent_budget_starts_no_window(): + assert len(_route(_slot(), 1, window_budget=0.0).failed) == 1 + assert _route(_slot(), 1, window_budget=1000.0).failed == [] + + +def test_window_board_keeps_far_items_of_the_rerouted_nets_only(): + from freeroute.route.window import _item_box, _window_board + + board = build_board(parse_dsn(_slot(far=True))) + items = [(item, _item_box(item)) for item in board.get_items()] + view = IntBox(80000, -60000, 120000, -40000) # around the slot, far from Q1/Q2 + sub = _window_board(board, items, view, {1}, {}, set(), {}, 100, 300, 300, [0], "via") + refs = {p.component_ref for p in sub.get_pins()} + assert {"P1", "P2"} <= refs # net N's pins, though outside the view + assert not refs & {"Q1", "Q2"} # net M's, far away, left out + + +def test_a_connection_ending_outside_the_window_gets_no_copper(): + """Its far end has no grid cells; a stub out to it would cross items the + window board never saw.""" + from freeroute.route.exact_router import route_board_exact + + dsn = parse_dsn(_slot(far=True)) + s = dsn.resolution.value + window = IntBox(70000, -70000, 130000, -30000) # the slot only; net M's pins are far off + result = route_board_exact( + build_board(dsn), trace_width=round(_rule_width_dsn(dsn) * s), + clearance=round(_rule_clearance_dsn(dsn) * s), only_nets=frozenset({2}), bounds=window, + ).result + assert [f[0] for f in result.failed] == [2] + assert not result.wires.get(2) and not result.vias.get(2) diff --git a/tests/test_cli.py b/tests/test_cli.py index 601f7bd..a01e587 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -161,6 +161,7 @@ def test_cli_supported_combinations_are_accepted(tmp_path): "negotiate": ["--negotiate", "2"], "rollback": ["--no-rollback"], "tree": ["--no-tree"], + "window": ["--window-reroute", "1"], } for engine, options in ENGINE_OPTIONS.items(): for option in options: