--seed: shuffle the order connections are routed in

The router is deterministic, but its result depends heavily on which
connection routes first. A seed shuffles that order reproducibly, so a
change can be judged over several orders instead of the one it happened
to get.
This commit is contained in:
2026-09-27 15:04:35 -06:00
parent cf8b7ece60
commit 76c3af94b8
6 changed files with 49 additions and 4 deletions
+13 -2
View File
@@ -29,8 +29,8 @@ from freeroute.ses import write_ses
#: which optional flags each engine actually implements. Anything not listed for #: which optional flags each engine actually implements. Anything not listed for
#: an engine is rejected by :func:`_validate` rather than quietly ignored. #: an engine is rejected by :func:`_validate` rather than quietly ignored.
ENGINE_OPTIONS: dict[str, frozenset[str]] = { ENGINE_OPTIONS: dict[str, frozenset[str]] = {
"grid": frozenset({"rip_up", "max_passes"}), "grid": frozenset({"rip_up", "max_passes", "seed"}),
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes"}), "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed"}),
"room": frozenset({"shove", "pack"}), "room": frozenset({"shove", "pack"}),
} }
@@ -41,6 +41,7 @@ _FLAG_NAMES = {
"diagonal": "--diagonal", "diagonal": "--diagonal",
"rip_up": "--no-rip-up", "rip_up": "--no-rip-up",
"max_passes": "-mp/--max-passes", "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 " 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", "(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 return parser
@@ -152,6 +160,7 @@ def main(argv: list[str] | None = None) -> int:
("diagonal", args.diagonal), ("diagonal", args.diagonal),
("rip_up", args.no_rip_up), ("rip_up", args.no_rip_up),
("max_passes", args.max_passes is not None), ("max_passes", args.max_passes is not None),
("seed", args.seed is not None),
) )
if asked if asked
] ]
@@ -184,6 +193,7 @@ def main(argv: list[str] | None = None) -> int:
edge_clearance_mm=args.edge_clearance, edge_clearance_mm=args.edge_clearance,
resolution=2 if args.grid_resolution is None else args.grid_resolution, resolution=2 if args.grid_resolution is None else args.grid_resolution,
heuristic_weight=args.heuristic_weight, heuristic_weight=args.heuristic_weight,
seed=args.seed,
) )
elif args.engine == "room": elif args.engine == "room":
result = build_rooms_routing_result( 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, dsn, layers=layers, rip_up=rip_up, max_passes=max_passes,
edge_clearance_mm=args.edge_clearance, edge_clearance_mm=args.edge_clearance,
resolution=1 if args.grid_resolution is None else args.grid_resolution, resolution=1 if args.grid_resolution is None else args.grid_resolution,
seed=args.seed,
) )
ses_text = write_ses(dsn, result) ses_text = write_ses(dsn, result)
+2
View File
@@ -94,6 +94,7 @@ def route_board_exact(
edge_clearance: int | None = None, edge_clearance: int | None = None,
resolution: int = 1, resolution: int = 1,
heuristic_weight: float = 1.0, heuristic_weight: float = 1.0,
seed: int | None = None,
) -> ExactRouteResult: ) -> ExactRouteResult:
"""Route ``board`` orthogonally and verify the result with exact geometry. """Route ``board`` orthogonally and verify the result with exact geometry.
@@ -122,6 +123,7 @@ def route_board_exact(
edge_clearance=edge_clearance, edge_clearance=edge_clearance,
resolution=resolution, resolution=resolution,
heuristic_weight=heuristic_weight, heuristic_weight=heuristic_weight,
seed=seed,
) )
edge = 0 if edge_clearance is None else edge_clearance edge = 0 if edge_clearance is None else edge_clearance
outline = board.outline outline = board.outline
+12 -1
View File
@@ -28,6 +28,7 @@ from __future__ import annotations
import heapq import heapq
import math import math
import random
from freeroute.board.board import BasicBoard from freeroute.board.board import BasicBoard
from freeroute.board.items import ConductionArea, ObstacleArea, Pin from freeroute.board.items import ConductionArea, ObstacleArea, Pin
@@ -154,8 +155,12 @@ class GridRouter:
edge_clearance: int | None = None, edge_clearance: int | None = None,
resolution: int = 1, resolution: int = 1,
heuristic_weight: float = 1.0, heuristic_weight: float = 1.0,
seed: int | None = None,
) -> None: ) -> None:
self.board = board 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 # how far (in cells) an escape stub may run to leave a walled-in pin
self._escape_cells = 12 self._escape_cells = 12
self._crowded_cache: dict = {} self._crowded_cache: dict = {}
@@ -819,9 +824,13 @@ class GridRouter:
best_count = -1 best_count = -1
best_snapshot: list[tuple[int, list, list]] | None = None best_snapshot: list[tuple[int, list, list]] | None = None
best_unrouted = len(conns) 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): for _ in range(passes):
changed = False changed = False
for conn in conns: # deterministic: connection creation order for conn in order:
if conn.routed: if conn.routed:
continue continue
if self._try_route(conn, conns): if self._try_route(conn, conns):
@@ -1050,6 +1059,7 @@ def route_board(
edge_clearance: int | None = None, edge_clearance: int | None = None,
resolution: int = 1, resolution: int = 1,
heuristic_weight: float = 1.0, heuristic_weight: float = 1.0,
seed: int | None = None,
) -> RouteResult: ) -> RouteResult:
"""Convenience wrapper: build a :class:`GridRouter` and route the board.""" """Convenience wrapper: build a :class:`GridRouter` and route the board."""
router = GridRouter( router = GridRouter(
@@ -1065,5 +1075,6 @@ def route_board(
edge_clearance=edge_clearance, edge_clearance=edge_clearance,
resolution=resolution, resolution=resolution,
heuristic_weight=heuristic_weight, heuristic_weight=heuristic_weight,
seed=seed,
) )
return router.route() return router.route()
+8 -1
View File
@@ -80,6 +80,7 @@ def route_dsn_board(
layers: list[int] | None = None, layers: list[int] | None = None,
rip_up: bool = True, rip_up: bool = True,
max_passes: int = 10, max_passes: int = 10,
seed: int | None = None,
) -> tuple[RouteResult, int, list[str]]: ) -> tuple[RouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard`; return the board-unit result, the scale, """Route a parsed :class:`DsnBoard`; return the board-unit result, the scale,
and the layer names. ``layers`` restricts routing to those signal-layer and the layer names. ``layers`` restricts routing to those signal-layer
@@ -100,6 +101,7 @@ def route_dsn_board(
layers=layers, layers=layers,
rip_up=rip_up, rip_up=rip_up,
max_passes=max_passes, max_passes=max_passes,
seed=seed,
) )
return result, scale, [layer.name for layer in dsn.layers] return result, scale, [layer.name for layer in dsn.layers]
@@ -140,11 +142,12 @@ def build_routing_result(
rip_up: bool = True, rip_up: bool = True,
max_passes: int = 10, max_passes: int = 10,
edge_clearance_mm: float | None = None, edge_clearance_mm: float | None = None,
seed: int | None = None,
) -> RoutingResult: ) -> RoutingResult:
"""Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult.""" """Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult."""
route, scale, layer_names = route_dsn_board( route, scale, layer_names = route_dsn_board(
dsn, layers=layers, rip_up=rip_up, max_passes=max_passes, 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) return _to_routing_result(route, dsn, scale, layer_names)
@@ -160,6 +163,7 @@ def route_dsn_board_exact(
rip_up: bool = True, rip_up: bool = True,
shove: bool = False, shove: bool = False,
diagonal: bool = False, diagonal: bool = False,
seed: int | None = None,
) -> tuple[ExactRouteResult, int, list[str]]: ) -> tuple[ExactRouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal, """Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal,
DRC-verified) track; return the exact result, the scale, and layer names. 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, rip_up=rip_up,
shove=shove, shove=shove,
diagonal=diagonal, diagonal=diagonal,
seed=seed,
) )
return exact, scale, [layer.name for layer in dsn.layers] return exact, scale, [layer.name for layer in dsn.layers]
@@ -199,6 +204,7 @@ def build_exact_routing_result(
rip_up: bool = True, rip_up: bool = True,
max_passes: int = 10, max_passes: int = 10,
edge_clearance_mm: float | None = None, edge_clearance_mm: float | None = None,
seed: int | None = None,
) -> RoutingResult: ) -> RoutingResult:
"""Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult.""" """Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult."""
exact, scale, layer_names = route_dsn_board_exact( exact, scale, layer_names = route_dsn_board_exact(
@@ -211,6 +217,7 @@ def build_exact_routing_result(
edge_clearance_mm=edge_clearance_mm, edge_clearance_mm=edge_clearance_mm,
resolution=resolution, resolution=resolution,
heuristic_weight=heuristic_weight, heuristic_weight=heuristic_weight,
seed=seed,
) )
return _to_routing_result(exact.result, dsn, scale, layer_names) return _to_routing_result(exact.result, dsn, scale, layer_names)
+13
View File
@@ -264,6 +264,19 @@ def test_larger_board_still_connects_its_multipin_nets():
assert len(result.routed_net_numbers) == multi_pin == 4 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 ---------------------------------------------------------- # --- oracle parity ----------------------------------------------------------
+1
View File
@@ -154,6 +154,7 @@ def test_cli_supported_combinations_are_accepted(tmp_path):
"diagonal": ["--diagonal"], "diagonal": ["--diagonal"],
"rip_up": ["--no-rip-up"], "rip_up": ["--no-rip-up"],
"max_passes": ["-mp", "4"], "max_passes": ["-mp", "4"],
"seed": ["--seed", "3"],
} }
for engine, options in ENGINE_OPTIONS.items(): for engine, options in ENGINE_OPTIONS.items():
for option in options: for option in options: