diff --git a/src/freeroute/cli.py b/src/freeroute/cli.py index 7f80a2a..24f7773 100644 --- a/src/freeroute/cli.py +++ b/src/freeroute/cli.py @@ -29,8 +29,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"}), - "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes"}), + "grid": frozenset({"rip_up", "max_passes", "seed"}), + "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed"}), "room": frozenset({"shove", "pack"}), } @@ -41,6 +41,7 @@ _FLAG_NAMES = { "diagonal": "--diagonal", "rip_up": "--no-rip-up", "max_passes": "-mp/--max-passes", + "seed": "--seed", } @@ -133,6 +134,13 @@ def build_parser() -> argparse.ArgumentParser: help="copper to board-edge clearance in mm, e.g. KiCad's min_copper_edge_clearance " "(the DSN does not carry it); default 0: copper only has to stay inside the outline", ) + parser.add_argument( + "--seed", + type=int, + metavar="N", + help="grid and exact engines: shuffle the order connections are routed in; " + "the same seed gives the same result (default: fewer-pin nets first)", + ) return parser @@ -152,6 +160,7 @@ def main(argv: list[str] | None = None) -> int: ("diagonal", args.diagonal), ("rip_up", args.no_rip_up), ("max_passes", args.max_passes is not None), + ("seed", args.seed is not None), ) if asked ] @@ -184,6 +193,7 @@ def main(argv: list[str] | None = None) -> int: edge_clearance_mm=args.edge_clearance, resolution=2 if args.grid_resolution is None else args.grid_resolution, heuristic_weight=args.heuristic_weight, + seed=args.seed, ) elif args.engine == "room": result = build_rooms_routing_result( @@ -195,6 +205,7 @@ def main(argv: list[str] | None = None) -> int: dsn, layers=layers, rip_up=rip_up, max_passes=max_passes, edge_clearance_mm=args.edge_clearance, resolution=1 if args.grid_resolution is None else args.grid_resolution, + seed=args.seed, ) ses_text = write_ses(dsn, result) diff --git a/src/freeroute/route/exact_router.py b/src/freeroute/route/exact_router.py index e377a2b..d20ce3f 100644 --- a/src/freeroute/route/exact_router.py +++ b/src/freeroute/route/exact_router.py @@ -94,6 +94,7 @@ def route_board_exact( edge_clearance: int | None = None, resolution: int = 1, heuristic_weight: float = 1.0, + seed: int | None = None, ) -> ExactRouteResult: """Route ``board`` orthogonally and verify the result with exact geometry. @@ -122,6 +123,7 @@ def route_board_exact( edge_clearance=edge_clearance, resolution=resolution, heuristic_weight=heuristic_weight, + seed=seed, ) edge = 0 if edge_clearance is None else edge_clearance outline = board.outline diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index a98c679..5b71f6f 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -28,6 +28,7 @@ from __future__ import annotations import heapq import math +import random from freeroute.board.board import BasicBoard from freeroute.board.items import ConductionArea, ObstacleArea, Pin @@ -154,8 +155,12 @@ class GridRouter: edge_clearance: int | None = None, resolution: int = 1, heuristic_weight: float = 1.0, + seed: int | None = None, ) -> None: self.board = board + # None keeps the deterministic order; a seed shuffles which connection + # routes first, to measure how much a result owes to the order + self.seed = seed # how far (in cells) an escape stub may run to leave a walled-in pin self._escape_cells = 12 self._crowded_cache: dict = {} @@ -819,9 +824,13 @@ class GridRouter: best_count = -1 best_snapshot: list[tuple[int, list, list]] | None = None best_unrouted = len(conns) + # conns[i].index == i is what rip-up relies on, so shuffle a view + order = list(conns) + if self.seed is not None: + random.Random(self.seed).shuffle(order) for _ in range(passes): changed = False - for conn in conns: # deterministic: connection creation order + for conn in order: if conn.routed: continue if self._try_route(conn, conns): @@ -1050,6 +1059,7 @@ def route_board( edge_clearance: int | None = None, resolution: int = 1, heuristic_weight: float = 1.0, + seed: int | None = None, ) -> RouteResult: """Convenience wrapper: build a :class:`GridRouter` and route the board.""" router = GridRouter( @@ -1065,5 +1075,6 @@ def route_board( edge_clearance=edge_clearance, resolution=resolution, heuristic_weight=heuristic_weight, + seed=seed, ) return router.route() diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index 1b24a66..d8f2559 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -80,6 +80,7 @@ def route_dsn_board( layers: list[int] | None = None, rip_up: bool = True, max_passes: int = 10, + seed: int | None = None, ) -> tuple[RouteResult, int, list[str]]: """Route a parsed :class:`DsnBoard`; return the board-unit result, the scale, and the layer names. ``layers`` restricts routing to those signal-layer @@ -100,6 +101,7 @@ def route_dsn_board( layers=layers, rip_up=rip_up, max_passes=max_passes, + seed=seed, ) return result, scale, [layer.name for layer in dsn.layers] @@ -140,11 +142,12 @@ def build_routing_result( rip_up: bool = True, max_passes: int = 10, edge_clearance_mm: float | None = None, + seed: int | None = None, ) -> RoutingResult: """Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult.""" route, scale, layer_names = route_dsn_board( dsn, layers=layers, rip_up=rip_up, max_passes=max_passes, - edge_clearance_mm=edge_clearance_mm, resolution=resolution, + edge_clearance_mm=edge_clearance_mm, resolution=resolution, seed=seed, ) return _to_routing_result(route, dsn, scale, layer_names) @@ -160,6 +163,7 @@ def route_dsn_board_exact( rip_up: bool = True, shove: bool = False, diagonal: bool = False, + seed: int | None = None, ) -> 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. @@ -184,6 +188,7 @@ def route_dsn_board_exact( rip_up=rip_up, shove=shove, diagonal=diagonal, + seed=seed, ) return exact, scale, [layer.name for layer in dsn.layers] @@ -199,6 +204,7 @@ def build_exact_routing_result( rip_up: bool = True, max_passes: int = 10, edge_clearance_mm: float | None = None, + seed: int | None = None, ) -> RoutingResult: """Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult.""" exact, scale, layer_names = route_dsn_board_exact( @@ -211,6 +217,7 @@ def build_exact_routing_result( edge_clearance_mm=edge_clearance_mm, resolution=resolution, heuristic_weight=heuristic_weight, + seed=seed, ) return _to_routing_result(exact.result, dsn, scale, layer_names) diff --git a/tests/route/test_router.py b/tests/route/test_router.py index 90803a8..6f67806 100644 --- a/tests/route/test_router.py +++ b/tests/route/test_router.py @@ -264,6 +264,19 @@ def test_larger_board_still_connects_its_multipin_nets(): assert len(result.routed_net_numbers) == multi_pin == 4 +def test_a_seed_is_repeatable_and_does_change_the_order(): + # The benchmark samples seeds to measure how much a result owes to the + # routing order. A seed that changed nothing would make every sample the + # same run and report no spread at all. + text = (FIXTURES / "kicad_routable.dsn").read_text() + default = _wire_key(route_dsn_board(parse_dsn(text))[0]) + runs = {s: route_dsn_board(parse_dsn(text), seed=s)[0] for s in range(4)} + again = route_dsn_board(parse_dsn(text), seed=1)[0] + assert _wire_key(again) == _wire_key(runs[1]) + assert any(_wire_key(r) != default for r in runs.values()) + assert all(r.unrouted == 0 for r in runs.values()) + + # --- oracle parity ---------------------------------------------------------- diff --git a/tests/test_cli.py b/tests/test_cli.py index d3edc6c..b880e55 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -154,6 +154,7 @@ def test_cli_supported_combinations_are_accepted(tmp_path): "diagonal": ["--diagonal"], "rip_up": ["--no-rip-up"], "max_passes": ["-mp", "4"], + "seed": ["--seed", "3"], } for engine, options in ENGINE_OPTIONS.items(): for option in options: