diff --git a/src/freeroute/cli.py b/src/freeroute/cli.py index 24f7773..36ee052 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, promote_failures 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", "promote"}), + "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote"}), "room": frozenset({"shove", "pack"}), } @@ -42,6 +43,8 @@ _FLAG_NAMES = { "rip_up": "--no-rip-up", "max_passes": "-mp/--max-passes", "seed": "--seed", + "orders": "--orders", + "promote": "--promote-failures", } @@ -141,6 +144,26 @@ 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( + "--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, + metavar="J", + help="processes for --orders (default: one per order, up to the CPU count)", + ) return parser @@ -161,6 +184,8 @@ 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), + ("promote", args.promote_failures is not None), ) if asked ] @@ -182,31 +207,54 @@ 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.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 + ) + 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/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 new file mode 100644 index 0000000..54a31e4 --- /dev/null +++ b/src/freeroute/route/orders.py @@ -0,0 +1,98 @@ +"""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)) + + +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 new file mode 100644 index 0000000..38c285d --- /dev/null +++ b/tests/route/test_orders.py @@ -0,0 +1,78 @@ +"""--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, + 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. +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") + + +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 b880e55..9a88393 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -155,6 +155,8 @@ def test_cli_supported_combinations_are_accepted(tmp_path): "rip_up": ["--no-rip-up"], "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: