--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:
+52
-21
@@ -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]]
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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")
|
||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user