From 09c58ea6305d41953748f88acd14a568a5c38be1 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 3 Oct 2026 14:45:59 -0600 Subject: [PATCH] --negotiate: PathFinder rounds on the empty board before routing Every connection is routed N times on the empty board, where routes may share cells at a price that doubles each round for every other net on a cell, and a cell that stays shared gains history. The real routing then pays that history on free cells, so the first routes stop taking the space later routes (a fine-pitch part's escapes) have no way round. Off by default. The old history term never steered: it only accrued at rip-up fights, after the first routes had claimed the contested space, and routed connections are never revisited in later passes. --- src/freeroute/cli.py | 16 ++++- src/freeroute/route/exact_router.py | 2 + src/freeroute/route/grid_router.py | 91 ++++++++++++++++++++++++++++- src/freeroute/route/pipeline.py | 8 +++ tests/route/test_negotiate.py | 65 +++++++++++++++++++++ tests/test_cli.py | 1 + 6 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 tests/route/test_negotiate.py diff --git a/src/freeroute/cli.py b/src/freeroute/cli.py index 07163c6..0fc7290 100644 --- a/src/freeroute/cli.py +++ b/src/freeroute/cli.py @@ -30,8 +30,8 @@ from freeroute.ses import write_ses #: which optional flags each engine actually implements. Anything not listed for #: 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", "rollback"}), - "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}), + "grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "negotiate", "rollback"}), + "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "negotiate", "rollback"}), "room": frozenset({"shove", "pack"}), } @@ -46,6 +46,7 @@ _FLAG_NAMES = { "orders": "--orders", "promote": "--promote-failures", "access": "--access-cost", + "negotiate": "--negotiate", "rollback": "--no-rollback", } @@ -167,6 +168,14 @@ def build_parser() -> argparse.ArgumentParser: help="grid and exact engines: extra cost for a route to pass where it would close " "another pin's way out while that pin still has connections to route (default 0: off)", ) + parser.add_argument( + "--negotiate", + type=int, + metavar="N", + help="grid and exact engines: before routing, N rounds of negotiation on the empty " + "board, where routes may share space at a price that rises where they compete; " + "the contested cells then cost extra in the real routing (default 0: off)", + ) parser.add_argument( "--no-rollback", dest="rollback", @@ -212,6 +221,7 @@ def main(argv: list[str] | None = None) -> int: ("orders", args.orders is not None), ("promote", args.promote_failures is not None), ("access", args.access_cost is not None), + ("negotiate", args.negotiate is not None), ("rollback", not args.rollback), ) if asked @@ -247,6 +257,7 @@ def main(argv: list[str] | None = None) -> int: "resolution": 2 if args.grid_resolution is None else args.grid_resolution, "heuristic_weight": args.heuristic_weight, "access_cost": args.access_cost or 0.0, + "negotiate": args.negotiate or 0, "hole_to_hole_mm": args.hole_to_hole, "rollback": args.rollback, } @@ -258,6 +269,7 @@ def main(argv: list[str] | None = None) -> int: "edge_clearance_mm": args.edge_clearance, "resolution": 1 if args.grid_resolution is None else args.grid_resolution, "access_cost": args.access_cost or 0.0, + "negotiate": args.negotiate or 0, "hole_to_hole_mm": args.hole_to_hole, "rollback": args.rollback, } diff --git a/src/freeroute/route/exact_router.py b/src/freeroute/route/exact_router.py index c9dd55b..38e6fe5 100644 --- a/src/freeroute/route/exact_router.py +++ b/src/freeroute/route/exact_router.py @@ -99,6 +99,7 @@ def route_board_exact( seed: int | None = None, route_first: frozenset | None = None, access_cost: float = 0.0, + negotiate: int = 0, rollback: bool = True, net_widths: dict[int, int] | None = None, via_drill: int | None = None, @@ -134,6 +135,7 @@ def route_board_exact( seed=seed, route_first=route_first, access_cost=access_cost, + negotiate=negotiate, rollback=rollback, net_widths=net_widths, via_drill=via_drill, diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 597794c..c4bdf00 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -180,6 +180,7 @@ class GridRouter: seed: int | None = None, route_first: frozenset | None = None, access_cost: float = 0.0, + negotiate: int = 0, rollback: bool = True, net_widths: dict[int, int] | None = None, via_drill: int | None = None, @@ -191,6 +192,15 @@ class GridRouter: # and the clearance around both) cost other nets this much extra, so # they route round a fine-pitch pin's way out instead of sealing it. self.access_cost = max(float(access_cost), 0.0) + # Negotiated congestion (PathFinder): before the real routing, every + # connection is routed this many times on the empty board, where routes + # may overlap at a price that grows where they compete. The cells that + # stay contested keep a history cost the real routing pays, so the first + # routes leave room around the places later routes have no way round. + self.negotiate = max(int(negotiate), 0) + self._history: dict[Node, float] = {} + self._present: dict[Node, dict[int, int]] | None = None + self._present_cost = 0.0 self._doorways: dict[Node, list[tuple[int, int]]] = {} self._pending: dict[int, int] = {} # Rip-up as a transaction: the connections a rip-up route crosses are @@ -767,6 +777,8 @@ class GridRouter: continue if self._doorways: enter += self._access(nxt, net_no) + if self._history or self._present is not None: + enter += self._congestion(nxt, net_no) 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 @@ -779,9 +791,12 @@ class GridRouter: if layer == cl: continue nxt = (cx, cy, layer) - if g_via < g_score.get(nxt, math.inf): + g_layer = g_via + if self._history or self._present is not None: + g_layer += self._congestion(nxt, net_no) + if g_layer < g_score.get(nxt, math.inf): heapq.heappush( - open_heap, (g_via + self._h(nxt, goal_cell), nxt, g_via, current) + open_heap, (g_layer + self._h(nxt, goal_cell), nxt, g_layer, current) ) return None @@ -1359,6 +1374,74 @@ class GridRouter: return self.access_cost return 0.0 + # --- negotiated congestion -------------------------------------------- + + def _congestion(self, node: Node, net_no: int) -> float: + """History of a contested cell, plus while negotiating the price of + sharing it with the other nets on it now.""" + cost = self._history.get(node, 0.0) + if self._present is not None: + users = self._present.get(node) + if users: + others = len(users) - (1 if net_no in users else 0) + cost += self._present_cost * others + return cost + + def _footprint(self, path: list[Node]) -> set[Node]: + """The nodes a route claims: its cells and their clearance halo on + its layer, and every via's footprint on all layers.""" + wire_segs, via_cells = _split_path(path) + halo = [(0, 0), *self._halo_offsets] + nodes: set[Node] = set() + for layer, cells in wire_segs: + for cx, cy in cells: + for dx, dy in halo: + nodes.add((cx + dx, cy + dy, layer)) + for cell in via_cells: + for c in self._via_cells(cell): + for layer in self.layers: + nodes.add((*c, layer)) + return nodes + + def _negotiate(self, order: list[_Connection], conns: list[_Connection]) -> None: + """Route every connection on the empty board ``self.negotiate`` times, + letting routes share cells at a price: the number of other nets on a + cell times a factor that doubles every round, plus the cell's history, + which grows by one for every extra net each round it stays shared. + Only the history is kept; the board is left empty.""" + present: dict[Node, dict[int, int]] = {} + claimed: dict[int, set[Node]] = {} + ends = [(c, c.a_end, c.b_end) for c in conns] + self._present, self._present_cost = present, 0.5 + try: + for _ in range(self.negotiate): + for conn in order: + for node in claimed.pop(conn.index, ()): + users = present[node] + users[conn.net_no] -= 1 + if not users[conn.net_no]: + del users[conn.net_no] + path = self._search(conn, allow_ripup=False, conns=conns) + if path is None: + continue + nodes = self._footprint(path) + claimed[conn.index] = nodes + for node in nodes: + users = present.setdefault(node, {}) + users[conn.net_no] = users.get(conn.net_no, 0) + 1 + shared = 0 + for node, users in present.items(): + if len(users) > 1: + shared += 1 + self._history[node] = self._history.get(node, 0.0) + len(users) - 1 + if not shared: + break + self._present_cost *= 2 + finally: + self._present = None + for conn, a_end, b_end in ends: # the search picks ends; undo that + conn.a_end, conn.b_end = a_end, b_end + # --- passes ------------------------------------------------------------- def route(self) -> RouteResult: @@ -1382,6 +1465,8 @@ class GridRouter: if self.route_first: # stable: the promoted keep their order among themselves, as do the rest order.sort(key=lambda c: (c.net_no, *_ends_of(c)) not in self.route_first) + if self.negotiate: + self._negotiate(order, conns) for _ in range(passes): changed = False for conn in order: @@ -1778,6 +1863,7 @@ def route_board( seed: int | None = None, route_first: frozenset | None = None, access_cost: float = 0.0, + negotiate: int = 0, rollback: bool = True, net_widths: dict[int, int] | None = None, via_drill: int | None = None, @@ -1800,6 +1886,7 @@ def route_board( seed=seed, route_first=route_first, access_cost=access_cost, + negotiate=negotiate, rollback=rollback, net_widths=net_widths, via_drill=via_drill, diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index d5b770a..5d46d14 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -118,6 +118,7 @@ def route_dsn_board( seed: int | None = None, route_first: frozenset | None = None, access_cost: float = 0.0, + negotiate: int = 0, hole_to_hole_mm: float = 0.25, rollback: bool = True, ) -> tuple[RouteResult, int, list[str]]: @@ -143,6 +144,7 @@ def route_dsn_board( seed=seed, route_first=route_first, access_cost=access_cost, + negotiate=negotiate, rollback=rollback, net_widths=_net_widths_board(dsn, scale), via_drill=_via_drill_board(dsn, scale), @@ -196,6 +198,7 @@ def build_routing_result( seed: int | None = None, route_first: frozenset | None = None, access_cost: float = 0.0, + negotiate: int = 0, hole_to_hole_mm: float = 0.25, rollback: bool = True, ) -> RoutingResult: @@ -205,6 +208,7 @@ def build_routing_result( edge_clearance_mm=edge_clearance_mm, resolution=resolution, seed=seed, route_first=route_first, access_cost=access_cost, + negotiate=negotiate, hole_to_hole_mm=hole_to_hole_mm, rollback=rollback, ) @@ -225,6 +229,7 @@ def route_dsn_board_exact( seed: int | None = None, route_first: frozenset | None = None, access_cost: float = 0.0, + negotiate: int = 0, hole_to_hole_mm: float = 0.25, rollback: bool = True, ) -> tuple[ExactRouteResult, int, list[str]]: @@ -254,6 +259,7 @@ def route_dsn_board_exact( seed=seed, route_first=route_first, access_cost=access_cost, + negotiate=negotiate, rollback=rollback, net_widths=_net_widths_board(dsn, scale), via_drill=_via_drill_board(dsn, scale), @@ -276,6 +282,7 @@ def build_exact_routing_result( seed: int | None = None, route_first: frozenset | None = None, access_cost: float = 0.0, + negotiate: int = 0, hole_to_hole_mm: float = 0.25, rollback: bool = True, ) -> RoutingResult: @@ -293,6 +300,7 @@ def build_exact_routing_result( seed=seed, route_first=route_first, access_cost=access_cost, + negotiate=negotiate, hole_to_hole_mm=hole_to_hole_mm, rollback=rollback, ) diff --git a/tests/route/test_negotiate.py b/tests/route/test_negotiate.py new file mode 100644 index 0000000..e2d588a --- /dev/null +++ b/tests/route/test_negotiate.py @@ -0,0 +1,65 @@ +"""--negotiate: PathFinder-style rounds on the empty board before routing.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from freeroute.board import build_board +from freeroute.dsn import parse_dsn +from freeroute.route.grid_router import GridRouter +from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn, route_dsn_board + +FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures" + + +def _router(name, negotiate): + dsn = parse_dsn((FIXTURES / name).read_text()) + s = dsn.resolution.value + return GridRouter( + build_board(dsn), + trace_width=round(_rule_width_dsn(dsn) * s), + clearance=round(_rule_clearance_dsn(dsn) * s), + negotiate=negotiate, + ) + + +def test_negotiation_leaves_the_board_empty_and_the_ends_as_they_were(): + router = _router("true_density.dsn", 3) + conns = router._conns = router._build_connections() + ends = [(c.a_end, c.b_end) for c in conns] + router._negotiate(list(conns), conns) + assert router._history # a dense board has contested cells + assert not router._cell_owner and not router._halo and not router._via_claims + assert not any(c.routed for c in conns) + assert [(c.a_end, c.b_end) for c in conns] == ends + assert router._present is None + + +def test_sharing_is_priced_only_for_other_nets(): + router = _router("true_density.dsn", 1) + node = (3, 4, 0) + router._history[node] = 2.0 + router._present, router._present_cost = {node: {7: 1, 9: 2}}, 0.5 + assert router._congestion(node, 7) == pytest.approx(2.5) # one other net + assert router._congestion(node, 5) == pytest.approx(3.0) # two other nets + router._present = None + assert router._congestion(node, 5) == pytest.approx(2.0) # history only, after + + +@pytest.mark.parametrize("name", ["ripup_needed.dsn", "true_density.dsn", "kicad_routable.dsn"]) +def test_negotiated_routing_keeps_the_occupancy_honest(name): + """Only routed connections hold cells afterwards, as without negotiation.""" + router = _router(name, 3) + router.route() + conns = router._conns + assert all(conns[i].routed for i in router._cell_owner.values()) + assert all(conns[i].routed for claims in router._halo.values() for i in claims) + + +def test_option_reaches_the_router_and_is_off_by_default(): + text = (FIXTURES / "kicad_routable.dsn").read_text() + plain, _, _ = route_dsn_board(parse_dsn(text)) + again, _, _ = route_dsn_board(parse_dsn(text), negotiate=0) + assert plain.unrouted == again.unrouted diff --git a/tests/test_cli.py b/tests/test_cli.py index 5e56191..a866609 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -158,6 +158,7 @@ def test_cli_supported_combinations_are_accepted(tmp_path): "orders": ["--orders", "2"], "promote": ["--promote-failures", "2"], "access": ["--access-cost", "5"], + "negotiate": ["--negotiate", "2"], "rollback": ["--no-rollback"], } for engine, options in ENGINE_OPTIONS.items():