--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
#: 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)
+2
View File
@@ -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
+12 -1
View File
@@ -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()
+8 -1
View File
@@ -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)
+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
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 ----------------------------------------------------------
+1
View File
@@ -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: