From 27f7202d2fb6e7f9ea7745ceb94659805e43fc8c Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 6 Oct 2026 15:04:02 -0600 Subject: [PATCH 1/3] --window-reroute N: route a finer grid around each connection still stuck After the exact engine, every net routed wholly inside a window around a stuck connection's pins is ripped, the rest of the board is laid down as fixed copper, and the window alone is routed again at --window-resolution (default 4) with the stuck net and the ripped nets only. The result is kept only if fewer connections are left unrouted; each round doubles the margin. The grid and exact routers gain only_nets and bounds for it. Off by default. --- src/freeroute/cli.py | 33 +++++- src/freeroute/route/exact_router.py | 11 +- src/freeroute/route/grid_router.py | 17 +++ src/freeroute/route/pipeline.py | 55 ++++++--- src/freeroute/route/window.py | 176 ++++++++++++++++++++++++++++ tests/route/test_window_reroute.py | 95 +++++++++++++++ tests/test_cli.py | 1 + 7 files changed, 370 insertions(+), 18 deletions(-) create mode 100644 src/freeroute/route/window.py create mode 100644 tests/route/test_window_reroute.py diff --git a/src/freeroute/cli.py b/src/freeroute/cli.py index 6dfa7ac..7b3bafb 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,29 @@ 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-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 +261,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 +300,9 @@ 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, } 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..5079cf9 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -203,8 +203,14 @@ 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 # 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 +330,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) @@ -1837,6 +1848,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( @@ -2196,6 +2209,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 +2235,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..3b477a6 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -21,6 +21,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 +238,9 @@ 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, ) -> 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 +252,44 @@ 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, + } 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), + ) return exact, scale, [layer.name for layer in dsn.layers] @@ -292,6 +311,9 @@ 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, ) -> RoutingResult: """Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult.""" exact, scale, layer_names = route_dsn_board_exact( @@ -311,6 +333,9 @@ 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, ) 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..4de530d --- /dev/null +++ b/src/freeroute/route/window.py @@ -0,0 +1,176 @@ +"""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. + +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 + +from typing import Any + +from freeroute.board import build_board +from freeroute.board.items import 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 _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) + for net in keep: + wires, vias = copper.get(net, ([], [])) + width = half.get(net, default_half) + for layer, path in wires: + if len(path) >= 2: + 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), + )) + 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, +) -> 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.""" + 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) + layers = route_args.get("layers") or [ + i for i, layer in enumerate(build_board(dsn).layer_structure.arr) if layer.is_signal + ] + labels = _pin_labels(build_board(dsn)) + + changed = False + for rnd in range(rounds): + reach = margin * (2 ** rnd) + progress = False + for entry in _open(list(failed)): + 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: + 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) + 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 + # 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 + ] + progress = changed = True + if not progress and 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..4908f1a --- /dev/null +++ b/tests/route/test_window_reroute.py @@ -0,0 +1,95 @@ +"""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): + exact, _scale, _layers = route_dsn_board_exact( + parse_dsn(text), resolution=1, window_reroute=rounds, window_resolution=4, + ) + 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 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: From 28523bcbacc64036a7ff49fef421276bce0ea01b Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 6 Oct 2026 22:51:51 -0600 Subject: [PATCH 2/3] Window re-route: time budget, window cap, smallest first, window boards A window's board now holds every pin and piece of copper of the nets it reroutes and, of the rest, only items near the window, instead of the whole board rebuilt per window. The smallest windows go first, at most --window-cap per round (40), and no window starts once --window-budget of the routing's own time is spent (0.25). On th-eda's flight controller the stage had taken 12x the routing time for no gain. --- src/freeroute/cli.py | 17 ++++ src/freeroute/route/pipeline.py | 11 +++ src/freeroute/route/window.py | 129 ++++++++++++++++++++++------- tests/route/test_window_reroute.py | 21 ++++- 4 files changed, 145 insertions(+), 33 deletions(-) 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 From d1def2c4b968206feeb7d559f1b42571e2e2b665 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 6 Oct 2026 23:07:09 -0600 Subject: [PATCH 3/3] Window boards: never search a connection ending outside the window With items away from the window left off its board, a stub from the window edge out to a rerouted net's far pin crossed copper the exact check never saw: 38 shorts on th-eda's flight controller. A connection with an end outside the bounds is now left unrouted, and a window whose new copper leaves the window is refused. --- src/freeroute/route/grid_router.py | 24 ++++++++++++++++++++++++ src/freeroute/route/window.py | 5 +++++ tests/route/test_window_reroute.py | 16 ++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 5079cf9..8c27dbc 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -211,6 +211,10 @@ class GridRouter: # 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. @@ -717,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: @@ -1872,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]: @@ -2017,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) diff --git a/src/freeroute/route/window.py b/src/freeroute/route/window.py index e61050d..7fb0b47 100644 --- a/src/freeroute/route/window.py +++ b/src/freeroute/route/window.py @@ -206,6 +206,11 @@ def window_reroute( 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( diff --git a/tests/route/test_window_reroute.py b/tests/route/test_window_reroute.py index 0a18f2d..9584535 100644 --- a/tests/route/test_window_reroute.py +++ b/tests/route/test_window_reroute.py @@ -110,3 +110,19 @@ def test_window_board_keeps_far_items_of_the_rerouted_nets_only(): 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)