--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.
This commit is contained in:
2026-09-27 22:14:59 -06:00
parent e67ccc04a0
commit 0f5dacbd88
4 changed files with 164 additions and 21 deletions
+52 -21
View File
@@ -19,6 +19,7 @@ import argparse
import sys import sys
from freeroute.dsn.reader import parse_dsn from freeroute.dsn.reader import parse_dsn
from freeroute.route.orders import best_of_orders
from freeroute.route.pipeline import ( from freeroute.route.pipeline import (
build_exact_routing_result, build_exact_routing_result,
build_rooms_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 #: 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", "seed"}), "grid": frozenset({"rip_up", "max_passes", "seed", "orders"}),
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed"}), "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders"}),
"room": frozenset({"shove", "pack"}), "room": frozenset({"shove", "pack"}),
} }
@@ -42,6 +43,7 @@ _FLAG_NAMES = {
"rip_up": "--no-rip-up", "rip_up": "--no-rip-up",
"max_passes": "-mp/--max-passes", "max_passes": "-mp/--max-passes",
"seed": "--seed", "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; " help="grid and exact engines: shuffle the order connections are routed in; "
"the same seed gives the same result (default: fewer-pin nets first)", "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 return parser
@@ -161,6 +176,7 @@ def main(argv: list[str] | None = None) -> int:
("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), ("seed", args.seed is not None),
("orders", args.orders is not None),
) )
if asked if asked
] ]
@@ -182,31 +198,46 @@ def main(argv: list[str] | None = None) -> int:
rip_up = not args.no_rip_up rip_up = not args.no_rip_up
max_passes = args.max_passes if args.max_passes is not None else 10 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": if args.engine == "exact":
result = build_exact_routing_result( options = {
dsn, "layers": layers,
layers=layers, "shove": args.shove,
shove=args.shove, "diagonal": args.diagonal,
diagonal=args.diagonal, "rip_up": rip_up,
rip_up=rip_up, "max_passes": max_passes,
max_passes=max_passes, "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 == "grid":
) options = {
elif args.engine == "room": "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( result = build_rooms_routing_result(
dsn, layers=layers, shove=args.shove, pack=args.pack, dsn, layers=layers, shove=args.shove, pack=args.pack,
edge_clearance_mm=args.edge_clearance, edge_clearance_mm=args.edge_clearance,
) )
else: elif args.orders and args.orders > 1:
result = build_routing_result( result, kept, tried = best_of_orders(
dsn, layers=layers, rip_up=rip_up, max_passes=max_passes, dsn_text, args.engine, options, orders=args.orders, seed=args.seed, jobs=args.jobs
edge_clearance_mm=args.edge_clearance,
resolution=1 if args.grid_resolution is None else args.grid_resolution,
seed=args.seed,
) )
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) ses_text = write_ses(dsn, result)
missing = [u for u in result.unrouted if not u[3]] missing = [u for u in result.unrouted if not u[3]]
+67
View File
@@ -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))
+44
View File
@@ -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")
+1
View File
@@ -155,6 +155,7 @@ def test_cli_supported_combinations_are_accepted(tmp_path):
"rip_up": ["--no-rip-up"], "rip_up": ["--no-rip-up"],
"max_passes": ["-mp", "4"], "max_passes": ["-mp", "4"],
"seed": ["--seed", "3"], "seed": ["--seed", "3"],
"orders": ["--orders", "2"],
} }
for engine, options in ENGINE_OPTIONS.items(): for engine, options in ENGINE_OPTIONS.items():
for option in options: for option in options: