From 0f5dacbd8817b7f27392e3d1483dad9b304b12ec Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 27 Sep 2026 22:14:59 -0600 Subject: [PATCH 1/2] --orders: route several connection orders in parallel, keep the best The default order is always one of the candidates, so the result is never worse than a plain run; ties go to the earlier candidate. --- src/freeroute/cli.py | 73 +++++++++++++++++++++++++---------- src/freeroute/route/orders.py | 67 ++++++++++++++++++++++++++++++++ tests/route/test_orders.py | 44 +++++++++++++++++++++ tests/test_cli.py | 1 + 4 files changed, 164 insertions(+), 21 deletions(-) create mode 100644 src/freeroute/route/orders.py create mode 100644 tests/route/test_orders.py diff --git a/src/freeroute/cli.py b/src/freeroute/cli.py index 24f7773..7321f72 100644 --- a/src/freeroute/cli.py +++ b/src/freeroute/cli.py @@ -19,6 +19,7 @@ import argparse import sys from freeroute.dsn.reader import parse_dsn +from freeroute.route.orders import best_of_orders from freeroute.route.pipeline import ( build_exact_routing_result, build_rooms_routing_result, @@ -29,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"}), - "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed"}), + "grid": frozenset({"rip_up", "max_passes", "seed", "orders"}), + "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders"}), "room": frozenset({"shove", "pack"}), } @@ -42,6 +43,7 @@ _FLAG_NAMES = { "rip_up": "--no-rip-up", "max_passes": "-mp/--max-passes", "seed": "--seed", + "orders": "--orders", } @@ -141,6 +143,19 @@ def build_parser() -> argparse.ArgumentParser: help="grid and exact engines: shuffle the order connections are routed in; " "the same seed gives the same result (default: fewer-pin nets first)", ) + parser.add_argument( + "--orders", + type=int, + metavar="N", + help="grid and exact engines: route N connection orders in parallel and keep the " + "one leaving the fewest connections unrouted (the default order is one of them)", + ) + parser.add_argument( + "--jobs", + type=int, + metavar="J", + help="processes for --orders (default: one per order, up to the CPU count)", + ) return parser @@ -161,6 +176,7 @@ def main(argv: list[str] | None = None) -> int: ("rip_up", args.no_rip_up), ("max_passes", args.max_passes is not None), ("seed", args.seed is not None), + ("orders", args.orders is not None), ) if asked ] @@ -182,31 +198,46 @@ def main(argv: list[str] | None = None) -> int: rip_up = not args.no_rip_up max_passes = args.max_passes if args.max_passes is not None else 10 + if args.orders is not None and args.orders < 1: + parser.error("--orders must be at least 1") if args.engine == "exact": - result = build_exact_routing_result( - dsn, - layers=layers, - shove=args.shove, - diagonal=args.diagonal, - rip_up=rip_up, - max_passes=max_passes, - 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": + options = { + "layers": layers, + "shove": args.shove, + "diagonal": args.diagonal, + "rip_up": rip_up, + "max_passes": max_passes, + "edge_clearance_mm": args.edge_clearance, + "resolution": 2 if args.grid_resolution is None else args.grid_resolution, + "heuristic_weight": args.heuristic_weight, + } + elif args.engine == "grid": + options = { + "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, + } + if args.engine == "room": result = build_rooms_routing_result( dsn, layers=layers, shove=args.shove, pack=args.pack, edge_clearance_mm=args.edge_clearance, ) - else: - result = build_routing_result( - 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, + elif args.orders and args.orders > 1: + result, kept, tried = best_of_orders( + dsn_text, args.engine, options, orders=args.orders, seed=args.seed, jobs=args.jobs ) + counts = ", ".join(f"{'default' if s is None else s}: {m}" for s, (m, _z) in tried) + print( + f"freeroute: tried {len(tried)} orders, kept " + f"{'the default order' if kept is None else f'--seed {kept}'} (unrouted {counts})", + file=sys.stderr, + ) + elif args.engine == "exact": + result = build_exact_routing_result(dsn, seed=args.seed, **options) + else: + result = build_routing_result(dsn, seed=args.seed, **options) ses_text = write_ses(dsn, result) missing = [u for u in result.unrouted if not u[3]] diff --git a/src/freeroute/route/orders.py b/src/freeroute/route/orders.py new file mode 100644 index 0000000..aa51052 --- /dev/null +++ b/src/freeroute/route/orders.py @@ -0,0 +1,67 @@ +"""Route one board in several connection orders at once and keep the best. + +Which connection routes first moves the result on a crowded board by several +connections either way, and no single order wins everywhere. Routing a few +orders in parallel processes and keeping the one that leaves the fewest +connections unrouted costs cores, not wall time. + +Without a seed the candidates are the default order and seeds 0 to N-2, so the +result is never worse than a plain run. With seed S they are seeds S to S+N-1. +Ties go to the earlier candidate, so the choice is deterministic. +""" + +from __future__ import annotations + +from concurrent.futures import ProcessPoolExecutor +import os + +from freeroute.dsn import parse_dsn +from freeroute.ses import RoutingResult + +from .pipeline import build_exact_routing_result, build_routing_result + + +def candidate_seeds(orders: int, seed: int | None = None) -> list[int | None]: + orders = max(int(orders), 1) + if seed is None: + return [None, *range(orders - 1)] + return list(range(seed, seed + orders)) + + +def score(result: RoutingResult) -> tuple[int, int]: + """Connections left open, then those left to a zone fill: lower is better.""" + missing = sum(1 for u in result.unrouted if not u[3]) + return missing, len(result.unrouted) - missing + + +def route_once(dsn_text: str, engine: str, options: dict, seed: int | None) -> RoutingResult: + dsn = parse_dsn(dsn_text) + if engine == "exact": + return build_exact_routing_result(dsn, seed=seed, **options) + if engine == "grid": + return build_routing_result(dsn, seed=seed, **options) + raise ValueError(f"routing orders are not supported by the {engine} engine") + + +def best_of_orders( + dsn_text: str, + engine: str, + options: dict, + *, + orders: int, + seed: int | None = None, + jobs: int | None = None, +) -> tuple[RoutingResult, int | None, list[tuple[int | None, tuple[int, int]]]]: + """Route ``orders`` orders and return the best result, the seed that made + it (None for the default order), and every candidate's seed and score.""" + seeds = candidate_seeds(orders, seed) + if len(seeds) == 1: + result = route_once(dsn_text, engine, options, seeds[0]) + return result, seeds[0], [(seeds[0], score(result))] + jobs = max(1, min(jobs or os.cpu_count() or 1, len(seeds))) + with ProcessPoolExecutor(jobs) as pool: + futures = [pool.submit(route_once, dsn_text, engine, options, s) for s in seeds] + results = [f.result() for f in futures] + scores = [score(r) for r in results] + best = min(range(len(seeds)), key=lambda i: (scores[i], i)) + return results[best], seeds[best], list(zip(seeds, scores, strict=True)) diff --git a/tests/route/test_orders.py b/tests/route/test_orders.py new file mode 100644 index 0000000..53f6d23 --- /dev/null +++ b/tests/route/test_orders.py @@ -0,0 +1,44 @@ +"""--orders: route several connection orders and keep the best.""" + +from __future__ import annotations + +from pathlib import Path + +from freeroute.cli import main +from freeroute.route.orders import best_of_orders, candidate_seeds, route_once, score + +FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures" +# Greedy (no rip-up), the default order strands a net here and seeds 1 to 3 do not. +RIPUP = (FIXTURES / "ripup_needed.dsn").read_text() +GREEDY = {"rip_up": False} + + +def test_the_default_order_is_always_a_candidate_without_a_seed(): + assert candidate_seeds(1) == [None] + assert candidate_seeds(4) == [None, 0, 1, 2] + assert candidate_seeds(3, seed=7) == [7, 8, 9] + + +def test_keeps_the_order_that_leaves_the_fewest_unrouted(): + assert score(route_once(RIPUP, "grid", GREEDY, None)) == (1, 0) + result, kept, tried = best_of_orders(RIPUP, "grid", GREEDY, orders=3, jobs=2) + assert kept == 1 + assert score(result) == (0, 0) + assert tried == [(None, (1, 0)), (0, (1, 0)), (1, (0, 0))] + + +def test_ties_go_to_the_earlier_candidate(): + # every order routes this board fully, so the default order is kept + _, kept, tried = best_of_orders(RIPUP, "grid", {}, orders=3, jobs=3) + assert kept is None + assert all(s == (0, 0) for _, s in tried) + + +def test_cli_orders_writes_the_best_session(tmp_path, capsys): + out = tmp_path / "best.ses" + rc = main([str(FIXTURES / "ripup_needed.dsn"), "--no-rip-up", "--orders", "3", "-do", str(out)]) + assert rc == 0 + err = capsys.readouterr().err + assert "tried 3 orders, kept --seed 1" in err + assert "not routed" not in err + assert out.read_text().startswith("(session") diff --git a/tests/test_cli.py b/tests/test_cli.py index b880e55..87a6179 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -155,6 +155,7 @@ def test_cli_supported_combinations_are_accepted(tmp_path): "rip_up": ["--no-rip-up"], "max_passes": ["-mp", "4"], "seed": ["--seed", "3"], + "orders": ["--orders", "2"], } for engine, options in ENGINE_OPTIONS.items(): for option in options: From 01ca10883131fb3ef444e4d289f7aa492c9b712f Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 27 Sep 2026 22:34:06 -0600 Subject: [PATCH 2/2] --promote-failures: reroute with the connections that failed moved first Each round routes every connection that has failed so far ahead of the rest, which keep their order. The promoted set only grows, so two groups cannot take turns failing; the best round is kept. --- src/freeroute/cli.py | 23 +++++++++++++++--- src/freeroute/route/exact_router.py | 2 ++ src/freeroute/route/grid_router.py | 8 +++++++ src/freeroute/route/orders.py | 31 +++++++++++++++++++++++++ src/freeroute/route/pipeline.py | 8 +++++++ tests/route/test_orders.py | 36 ++++++++++++++++++++++++++++- tests/test_cli.py | 1 + 7 files changed, 105 insertions(+), 4 deletions(-) diff --git a/src/freeroute/cli.py b/src/freeroute/cli.py index 7321f72..36ee052 100644 --- a/src/freeroute/cli.py +++ b/src/freeroute/cli.py @@ -19,7 +19,7 @@ import argparse import sys from freeroute.dsn.reader import parse_dsn -from freeroute.route.orders import best_of_orders +from freeroute.route.orders import best_of_orders, promote_failures from freeroute.route.pipeline import ( build_exact_routing_result, build_rooms_routing_result, @@ -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"}), - "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders"}), + "grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote"}), + "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote"}), "room": frozenset({"shove", "pack"}), } @@ -44,6 +44,7 @@ _FLAG_NAMES = { "max_passes": "-mp/--max-passes", "seed": "--seed", "orders": "--orders", + "promote": "--promote-failures", } @@ -150,6 +151,13 @@ def build_parser() -> argparse.ArgumentParser: help="grid and exact engines: route N connection orders in parallel and keep the " "one leaving the fewest connections unrouted (the default order is one of them)", ) + parser.add_argument( + "--promote-failures", + type=int, + metavar="R", + help="grid and exact engines: up to R rounds, each routing the connections that " + "failed so far first; keeps the best round", + ) parser.add_argument( "--jobs", type=int, @@ -177,6 +185,7 @@ def main(argv: list[str] | None = None) -> int: ("max_passes", args.max_passes is not None), ("seed", args.seed is not None), ("orders", args.orders is not None), + ("promote", args.promote_failures is not None), ) if asked ] @@ -224,6 +233,14 @@ def main(argv: list[str] | None = None) -> int: dsn, layers=layers, shove=args.shove, pack=args.pack, edge_clearance_mm=args.edge_clearance, ) + elif args.promote_failures and args.promote_failures > 1: + if args.orders: + parser.error("--orders and --promote-failures cannot be combined") + result, rounds = promote_failures( + dsn_text, args.engine, options, rounds=args.promote_failures, seed=args.seed + ) + counts = ", ".join(str(m) for m, _z in rounds) + print(f"freeroute: {len(rounds)} rounds, unrouted {counts}; kept the best", file=sys.stderr) elif args.orders and args.orders > 1: result, kept, tried = best_of_orders( dsn_text, args.engine, options, orders=args.orders, seed=args.seed, jobs=args.jobs diff --git a/src/freeroute/route/exact_router.py b/src/freeroute/route/exact_router.py index d20ce3f..277f526 100644 --- a/src/freeroute/route/exact_router.py +++ b/src/freeroute/route/exact_router.py @@ -95,6 +95,7 @@ def route_board_exact( resolution: int = 1, heuristic_weight: float = 1.0, seed: int | None = None, + route_first: frozenset | None = None, ) -> ExactRouteResult: """Route ``board`` orthogonally and verify the result with exact geometry. @@ -124,6 +125,7 @@ def route_board_exact( resolution=resolution, heuristic_weight=heuristic_weight, seed=seed, + route_first=route_first, ) 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 5b71f6f..666dde2 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -156,11 +156,14 @@ class GridRouter: resolution: int = 1, heuristic_weight: float = 1.0, seed: int | None = None, + route_first: frozenset | 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 + # connections, as (net_no, one end, other end), to route before the rest + self.route_first = frozenset(route_first or ()) # how far (in cells) an escape stub may run to leave a walled-in pin self._escape_cells = 12 self._crowded_cache: dict = {} @@ -828,6 +831,9 @@ class GridRouter: order = list(conns) if self.seed is not None: random.Random(self.seed).shuffle(order) + 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) for _ in range(passes): changed = False for conn in order: @@ -1060,6 +1066,7 @@ def route_board( resolution: int = 1, heuristic_weight: float = 1.0, seed: int | None = None, + route_first: frozenset | None = None, ) -> RouteResult: """Convenience wrapper: build a :class:`GridRouter` and route the board.""" router = GridRouter( @@ -1076,5 +1083,6 @@ def route_board( resolution=resolution, heuristic_weight=heuristic_weight, seed=seed, + route_first=route_first, ) return router.route() diff --git a/src/freeroute/route/orders.py b/src/freeroute/route/orders.py index aa51052..54a31e4 100644 --- a/src/freeroute/route/orders.py +++ b/src/freeroute/route/orders.py @@ -65,3 +65,34 @@ def best_of_orders( scores = [score(r) for r in results] best = min(range(len(seeds)), key=lambda i: (scores[i], i)) return results[best], seeds[best], list(zip(seeds, scores, strict=True)) + + +def promote_failures( + dsn_text: str, + engine: str, + options: dict, + *, + rounds: int, + seed: int | None = None, +) -> tuple[RoutingResult, list[tuple[int, int]]]: + """Route, then route again with every connection that has failed so far + moved to the front, keeping the rest in their order. Returns the best + round's result and every round's score. + + The promoted set only grows, so a connection that failed once keeps its + early place and two groups cannot take turns failing. Stops early when a + round routes everything or fails nothing new.""" + numbers = {n.name: i + 1 for i, n in enumerate(parse_dsn(dsn_text).nets)} + first: frozenset = frozenset() + best: RoutingResult | None = None + scores: list[tuple[int, int]] = [] + for _ in range(max(int(rounds), 1)): + result = route_once(dsn_text, engine, {**options, "route_first": first}, seed) + scores.append(score(result)) + if best is None or scores[-1] < score(best): + best = result + failed = {(numbers.get(name), a, b) for name, a, b, zone in result.unrouted if not zone} + if not failed or failed <= first: + break + first = first | failed + return best, scores diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index d8f2559..e1f5908 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -81,6 +81,7 @@ def route_dsn_board( rip_up: bool = True, max_passes: int = 10, seed: int | None = None, + route_first: frozenset | 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 @@ -102,6 +103,7 @@ def route_dsn_board( rip_up=rip_up, max_passes=max_passes, seed=seed, + route_first=route_first, ) return result, scale, [layer.name for layer in dsn.layers] @@ -143,11 +145,13 @@ def build_routing_result( max_passes: int = 10, edge_clearance_mm: float | None = None, seed: int | None = None, + route_first: frozenset | 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, seed=seed, + route_first=route_first, ) return _to_routing_result(route, dsn, scale, layer_names) @@ -164,6 +168,7 @@ def route_dsn_board_exact( shove: bool = False, diagonal: bool = False, seed: int | None = None, + route_first: frozenset | 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. @@ -189,6 +194,7 @@ def route_dsn_board_exact( shove=shove, diagonal=diagonal, seed=seed, + route_first=route_first, ) return exact, scale, [layer.name for layer in dsn.layers] @@ -205,6 +211,7 @@ def build_exact_routing_result( max_passes: int = 10, edge_clearance_mm: float | None = None, seed: int | None = None, + route_first: frozenset | None = None, ) -> RoutingResult: """Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult.""" exact, scale, layer_names = route_dsn_board_exact( @@ -218,6 +225,7 @@ def build_exact_routing_result( resolution=resolution, heuristic_weight=heuristic_weight, seed=seed, + route_first=route_first, ) return _to_routing_result(exact.result, dsn, scale, layer_names) diff --git a/tests/route/test_orders.py b/tests/route/test_orders.py index 53f6d23..38c285d 100644 --- a/tests/route/test_orders.py +++ b/tests/route/test_orders.py @@ -5,7 +5,13 @@ from __future__ import annotations from pathlib import Path from freeroute.cli import main -from freeroute.route.orders import best_of_orders, candidate_seeds, route_once, score +from freeroute.route.orders import ( + best_of_orders, + candidate_seeds, + promote_failures, + route_once, + score, +) FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures" # Greedy (no rip-up), the default order strands a net here and seeds 1 to 3 do not. @@ -42,3 +48,31 @@ def test_cli_orders_writes_the_best_session(tmp_path, capsys): assert "tried 3 orders, kept --seed 1" in err assert "not routed" not in err assert out.read_text().startswith("(session") + + +def test_promoting_the_failed_connection_routes_it(): + best, rounds = promote_failures(RIPUP, "grid", GREEDY, rounds=3) + assert rounds == [(1, 0), (0, 0)] # stops once nothing fails + assert score(best) == (0, 0) + + +def test_nothing_promoted_is_the_plain_order(): + plain = route_once(RIPUP, "grid", {}, None) + empty = route_once(RIPUP, "grid", {"route_first": frozenset()}, None) + assert plain == empty + + +def test_cli_promote_failures(tmp_path, capsys): + out = tmp_path / "p.ses" + rc = main( + [ + str(FIXTURES / "ripup_needed.dsn"), + "--no-rip-up", + "--promote-failures", + "3", + "-do", + str(out), + ] + ) + assert rc == 0 + assert "2 rounds, unrouted 1, 0" in capsys.readouterr().err diff --git a/tests/test_cli.py b/tests/test_cli.py index 87a6179..9a88393 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -156,6 +156,7 @@ def test_cli_supported_combinations_are_accepted(tmp_path): "max_passes": ["-mp", "4"], "seed": ["--seed", "3"], "orders": ["--orders", "2"], + "promote": ["--promote-failures", "2"], } for engine, options in ENGINE_OPTIONS.items(): for option in options: