From 0f5dacbd8817b7f27392e3d1483dad9b304b12ec Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 27 Sep 2026 22:14:59 -0600 Subject: [PATCH] --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: