diff --git a/src/freeroute/cli.py b/src/freeroute/cli.py index 7b3bafb..d21c8fa 100644 --- a/src/freeroute/cli.py +++ b/src/freeroute/cli.py @@ -214,6 +214,21 @@ def build_parser() -> argparse.ArgumentParser: 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, @@ -303,6 +318,8 @@ def main(argv: list[str] | None = None) -> int: "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/pipeline.py b/src/freeroute/route/pipeline.py index 3b477a6..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 @@ -241,6 +242,8 @@ def route_dsn_board_exact( 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. @@ -270,6 +273,7 @@ def route_dsn_board_exact( "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, @@ -289,6 +293,9 @@ def route_dsn_board_exact( 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] @@ -314,6 +321,8 @@ def build_exact_routing_result( 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( @@ -336,6 +345,8 @@ def build_exact_routing_result( 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 index 4de530d..e61050d 100644 --- a/src/freeroute/route/window.py +++ b/src/freeroute/route/window.py @@ -8,6 +8,12 @@ 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 @@ -17,10 +23,11 @@ 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 build_board -from freeroute.board.items import Trace, Via +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 @@ -52,25 +59,58 @@ def _inside(copper: Copper, window: IntBox) -> bool: ) -def _board_with( - dsn: DsnBoard, 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, -): - """The board with the routed copper of ``keep`` laid down as fixed copper.""" - board = build_board(dsn) +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: + 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): - board.insert(Via( - net_nos=[net], fixed=True, padstack_name=via_name, drill=via_drill, - location=location, layers=list(layers), - shape=IntBox(location.x - via_half, location.y - via_half, - location.x + via_half, location.y + via_half), - )) + 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 @@ -90,12 +130,17 @@ def window_reroute( 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.""" + ``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 @@ -105,32 +150,54 @@ def window_reroute( } 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(build_board(dsn).layer_structure.arr) if layer.is_signal + i for i, layer in enumerate(base.layer_structure.arr) if layer.is_signal ] - labels = _pin_labels(build_board(dsn)) + 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 + ) - changed = False + 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) - progress = False - for entry in _open(list(failed)): + + # 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] - ends = [_end_point(labels, net, label) for label in entry[1:3]] - ends = [p for p in ends if p is not None] - if not ends: + window = window_of(entry, reach) + if window is None: continue - window = 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, - ) 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 - board = _board_with(dsn, copper, keep, half, default_half, via_half, via_drill, layers, via_name) + 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, @@ -157,8 +224,8 @@ def window_reroute( failed = [f for f in failed if f[0] not in affected] + [ f for f in sub.failed if f[0] in affected ] - progress = changed = True - if not progress and not _open(failed): + changed = True + if out_of_time or not _open(failed): break if not changed: diff --git a/tests/route/test_window_reroute.py b/tests/route/test_window_reroute.py index 4908f1a..0a18f2d 100644 --- a/tests/route/test_window_reroute.py +++ b/tests/route/test_window_reroute.py @@ -50,9 +50,9 @@ def _slot(gap: int = 640, far: bool = False) -> str: """ -def _route(text: str, rounds: int): +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, + parse_dsn(text), resolution=1, window_reroute=rounds, window_resolution=4, **window, ) return exact.result @@ -93,3 +93,20 @@ def test_a_router_limited_to_some_nets_and_a_box_builds_only_those(): 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