diff --git a/bench/bench.py b/bench/bench.py index 6f70354..6d1eaf5 100644 --- a/bench/bench.py +++ b/bench/bench.py @@ -100,13 +100,18 @@ def run_one(board, variant, seed: int, timeout: float) -> dict: ) rss_mb = round(usage.ru_maxrss / 1024) secs = round(time.monotonic() - start, 1) + try: + with open(f"{tmp}/out.ses", encoding="utf-8") as f: + vias = f.read().count("(via ") + except OSError: + vias = None missing = NOT_ROUTED.search(err) zone = TO_ZONE.search(err) return { "board": name, "variant": vname, "seed": seed, "status": status, "unrouted": int(missing.group(1)) if missing else 0, "to_zone": int(zone.group(1)) if zone else 0, - "secs": secs, "rss_mb": rss_mb, + "secs": secs, "rss_mb": rss_mb, "vias": vias, "tail": err.strip().splitlines()[-1] if status != "ok" and err.strip() else "", } @@ -147,6 +152,16 @@ def report(rows: list[dict], boards, variants) -> None: line += (f" {dm:+.1f} ± {se:.1f} ({better} better, {worse} worse," f" {len(diffs) - better - worse} same) {verdict}") print(line) + # vias, where the runs recorded them (older result files did not) + print("\nvias per variant, summed over boards (mean over seeds per board)\n") + for vname, _, _ in variants: + total, boards_counted = 0.0, 0 + for bname, _, _ in boards: + vs = [r["vias"] for k, r in ok.items() if k[:2] == (bname, vname) and r.get("vias") is not None] + if vs: + total += sum(vs) / len(vs) + boards_counted += 1 + print(f"{vname:14} {total:8.1f} vias over {boards_counted} boards") failed = [r for r in rows if r["status"] != "ok"] for r in failed: print(f"FAILED {r['board']} {r['variant']} seed {r['seed']}: {r['status']} {r['tail']}") diff --git a/src/rpmroute/cli.py b/src/rpmroute/cli.py index 2879603..0dbdacf 100644 --- a/src/rpmroute/cli.py +++ b/src/rpmroute/cli.py @@ -30,10 +30,13 @@ from rpmroute.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", "promote", "access", "negotiate", "rollback", "tree"}), + "grid": frozenset({ + "rip_up", "max_passes", "seed", "orders", "promote", "access", "negotiate", "rollback", + "tree", "via_cost", + }), "exact": frozenset({ "shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", - "negotiate", "rollback", "tree", "window", + "negotiate", "rollback", "tree", "window", "vias", "via_cost", }), "room": frozenset({"shove", "pack"}), } @@ -53,6 +56,8 @@ _FLAG_NAMES = { "rollback": "--no-rollback", "tree": "--no-tree", "window": "--window-reroute", + "vias": "--reduce-vias", + "via_cost": "--via-cost", } @@ -237,6 +242,37 @@ def build_parser() -> argparse.ArgumentParser: metavar="MM", help="the first round's window margin around the stuck connection's pins (default 1.5)", ) + parser.add_argument( + "--via-cost", + type=float, + default=None, + metavar="C", + help="grid and exact engines: what a layer change costs the search, in grid " + "steps of track (default 10)", + ) + parser.add_argument( + "--reduce-vias", + action="store_true", + default=None, + help="exact engine: after routing, route each net that has vias again on its own " + "with vias made dearer, everything else held fixed; its new copper is kept only " + "if it fails nothing new and has fewer vias", + ) + parser.add_argument( + "--reduce-vias-budget", + type=float, + default=0.5, + metavar="F", + help="via reduction stops starting nets once it has spent this share of the " + "routing's own time (default 0.5)", + ) + parser.add_argument( + "--reduce-vias-margin", + type=float, + default=2.0, + metavar="MM", + help="how far past its pins and copper a net may go when routed again (default 2.0)", + ) parser.add_argument( "--hole-to-hole", type=float, @@ -278,6 +314,8 @@ def main(argv: list[str] | None = None) -> int: ("rollback", not args.rollback), ("tree", not args.tree_nets), ("window", args.window_reroute is not None), + ("vias", args.reduce_vias is not None), + ("via_cost", args.via_cost is not None), ) if asked ] @@ -321,7 +359,12 @@ def main(argv: list[str] | None = None) -> int: "window_margin_mm": args.window_margin, "window_budget": args.window_budget, "window_cap": args.window_cap, + "reduce_vias": bool(args.reduce_vias), + "reduce_vias_budget": args.reduce_vias_budget, + "reduce_vias_margin_mm": args.reduce_vias_margin, } + if args.via_cost is not None: + options["via_cost"] = args.via_cost elif args.engine == "grid": options = { "layers": layers, @@ -335,6 +378,8 @@ def main(argv: list[str] | None = None) -> int: "rollback": args.rollback, "tree_nets": args.tree_nets, } + if args.via_cost is not None: + options["via_cost"] = args.via_cost if args.engine == "room": result = build_rooms_routing_result( dsn, layers=layers, shove=args.shove, pack=args.pack, diff --git a/src/rpmroute/route/pipeline.py b/src/rpmroute/route/pipeline.py index 6f376b4..9b2f55f 100644 --- a/src/rpmroute/route/pipeline.py +++ b/src/rpmroute/route/pipeline.py @@ -22,6 +22,7 @@ from rpmroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses from .exact_router import ExactRouteResult, route_board_exact from .grid_router import RouteResult, route_board from .room_router import route_board_rooms +from .vias import reduce_vias as reduce_vias_stage from .window import window_reroute as window_reroute_stage __all__ = ["route", "route_dsn_board", "build_routing_result"] @@ -124,6 +125,7 @@ def route_dsn_board( hole_to_hole_mm: float = 0.25, rollback: bool = True, tree_nets: bool = True, + via_cost: float = 10.0, ) -> 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 @@ -153,6 +155,7 @@ def route_dsn_board( net_widths=_net_widths_board(dsn, scale), via_drill=_via_drill_board(dsn, scale), hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0, + via_cost=via_cost, ) return result, scale, [layer.name for layer in dsn.layers] @@ -206,6 +209,7 @@ def build_routing_result( hole_to_hole_mm: float = 0.25, rollback: bool = True, tree_nets: bool = True, + via_cost: float = 10.0, ) -> RoutingResult: """Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult.""" route, scale, layer_names = route_dsn_board( @@ -217,6 +221,7 @@ def build_routing_result( hole_to_hole_mm=hole_to_hole_mm, rollback=rollback, tree_nets=tree_nets, + via_cost=via_cost, ) return _to_routing_result(route, dsn, scale, layer_names) @@ -244,6 +249,10 @@ def route_dsn_board_exact( window_margin_mm: float = 1.5, window_budget: float = 0.25, window_cap: int = 40, + reduce_vias: bool = False, + reduce_vias_budget: float = 0.5, + reduce_vias_margin_mm: float = 2.0, + via_cost: float = 10.0, ) -> 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. @@ -272,6 +281,7 @@ def route_dsn_board_exact( "net_widths": _net_widths_board(dsn, scale), "via_drill": _via_drill_board(dsn, scale), "hole_to_hole": _mm_to_board(dsn, scale, hole_to_hole_mm) or 0, + "via_cost": via_cost, } started = time.monotonic() exact = route_board_exact( @@ -297,6 +307,18 @@ def route_dsn_board_exact( budget_s=max(float(window_budget), 0.0) * (time.monotonic() - started), max_windows=window_cap, ) + if reduce_vias: + via_half = common["via_half"] or max(width_board // 2, 1) + exact.result = reduce_vias_stage( + dsn, exact.result, common, + resolution=resolution, + margin=_mm_to_board(dsn, scale, reduce_vias_margin_mm) or 0, + via_half=via_half, + via_drill=common["via_drill"] or 2 * via_half, + via_name=_via_padstack(dsn), + # a share of what the routing itself took + budget_s=max(float(reduce_vias_budget), 0.0) * (time.monotonic() - started), + ) return exact, scale, [layer.name for layer in dsn.layers] @@ -323,6 +345,10 @@ def build_exact_routing_result( window_margin_mm: float = 1.5, window_budget: float = 0.25, window_cap: int = 40, + reduce_vias: bool = False, + reduce_vias_budget: float = 0.5, + reduce_vias_margin_mm: float = 2.0, + via_cost: float = 10.0, ) -> RoutingResult: """Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult.""" exact, scale, layer_names = route_dsn_board_exact( @@ -347,6 +373,10 @@ def build_exact_routing_result( window_margin_mm=window_margin_mm, window_budget=window_budget, window_cap=window_cap, + reduce_vias=reduce_vias, + reduce_vias_budget=reduce_vias_budget, + reduce_vias_margin_mm=reduce_vias_margin_mm, + via_cost=via_cost, ) return _to_routing_result(exact.result, dsn, scale, layer_names) diff --git a/src/rpmroute/route/vias.py b/src/rpmroute/route/vias.py new file mode 100644 index 0000000..160b205 --- /dev/null +++ b/src/rpmroute/route/vias.py @@ -0,0 +1,128 @@ +"""Via reduction: a last stage that routes each net again with vias made dear. + +The main search prices a layer change at a few millimetres of track, which +is right while the board is crowded and wrong once it is routed: a net that +hopped layers to slip past a route can often stay on one layer around it. +So each net that has vias is ripped and routed again on its own, everything +else held as fixed copper, with the via cost raised; the new copper is kept +only if the net loses no connection and ends with fewer vias. + +It reuses the window stage's board: the net's own items and copper, and of +everything else only what lies near the box the net is routed in. The nets +with the most vias go first, and no new net starts once ``budget_s`` is +spent. +""" + +from __future__ import annotations + +import time +from typing import Any + +from rpmroute.board import build_board +from rpmroute.dsn import DsnBoard +from rpmroute.geometry import IntBox + +from .exact_router import route_board_exact +from .grid_router import RouteResult +from .window import Copper, _inside, _item_box, _open, _window_board + + +def _net_box(copper: Copper, pins: list, grow: int) -> IntBox | None: + wires, vias = copper + points = [p for _layer, path in wires for p in path] + list(vias) + pins + if not points: + return None + xs, ys = [p.x for p in points], [p.y for p in points] + return IntBox(min(xs) - grow, min(ys) - grow, max(xs) + grow, max(ys) + grow) + + +def reduce_vias( + dsn: DsnBoard, + result: RouteResult, + route_args: dict[str, Any], + *, + resolution: int, + margin: int, + via_half: int, + via_drill: int, + via_name: str, + via_cost_factor: float = 8.0, + budget_s: float | None = None, +) -> RouteResult: + """Route each net with vias again with vias ``via_cost_factor`` times dearer; + keep a net's new copper only when it fails nothing new and has fewer vias. + + ``route_args`` are the exact engine's settings for the whole board. + ``margin`` grows the box around the net's pins and copper that it is + routed in, so it may go round what it used to hop over.""" + started = time.monotonic() + copper: dict[int, Copper] = { + net: (list(result.wires.get(net, [])), list(result.vias.get(net, []))) + for net in set(result.wires) | set(result.vias) + } + todo = sorted((n for n, c in copper.items() if c[1]), key=lambda n: -len(set(copper[n][1]))) + if not todo: + return result + default_half = result.half_width + half = dict(result.net_half) + failed = list(result.failed) + base = build_board(dsn) + base_items = [(item, _item_box(item)) for item in base.get_items()] + layers = route_args.get("layers") or [ + i for i, layer in enumerate(base.layer_structure.arr) if layer.is_signal + ] + pins_of: dict[int, list] = {} + for pin in base.get_pins(): + for net in pin.net_nos: + pins_of.setdefault(net, []).append(pin.location) + context = 2 * (route_args["trace_width"] + route_args["clearance"]) + 2 * via_half + ( + route_args.get("edge_clearance") or 0 + ) + args = dict(route_args) + args["via_cost"] = float(route_args.get("via_cost", 10.0)) * via_cost_factor + + changed = False + for net in todo: + if budget_s is not None and time.monotonic() - started >= budget_s: + break + old_vias = len(set(copper[net][1])) + window = _net_box(copper[net], pins_of.get(net, []), margin) + if window is None: + continue + before = _open(f for f in failed if f[0] == net) + view = IntBox(window.ll.x - context, window.ll.y - context, + window.ur.x + context, window.ur.y + context) + keep = set(copper) - {net} + board = _window_board(base, base_items, view, {net}, copper, keep, half, + default_half, via_half, via_drill, layers, via_name) + sub = route_board_exact( + board, **args, resolution=resolution, only_nets=frozenset({net}), bounds=window, + ).result + after = _open(f for f in sub.failed if f[0] == net) + new = (sub.wires.get(net, []), sub.vias.get(net, [])) + if len(after) > len(before) or len(set(new[1])) >= old_vias: + continue + # the board left out what lies away from the box, so copper reaching + # out of it was never checked against that + if (new[0] or new[1]) and not _inside(new, window): + continue + copper[net] = (list(new[0]), list(new[1])) + half.pop(net, None) + if net in sub.net_half: + half[net] = sub.net_half[net] + failed = [f for f in failed if f[0] != net] + [f for f in sub.failed if f[0] == net] + changed = True + + if not changed: + return result + out = RouteResult(default_half) + for net, (wires, vias) in copper.items(): + for layer, path in wires: + out.add_wire(net, layer, path) + for location in dict.fromkeys(vias): + out.add_via(net, location) + out.failed = failed + out.unrouted = len(_open(failed)) + out.net_half = half + out.narrowed = list(result.narrowed) + return out diff --git a/tests/route/test_reduce_vias.py b/tests/route/test_reduce_vias.py new file mode 100644 index 0000000..937a18d --- /dev/null +++ b/tests/route/test_reduce_vias.py @@ -0,0 +1,123 @@ +"""Via reduction: each net with vias is routed again with vias made dear, +kept only when it fails nothing new and has fewer vias.""" + +from __future__ import annotations + +from rpmroute.board import build_board +from rpmroute.dsn import parse_dsn +from rpmroute.geometry import IntPoint +from rpmroute.route.grid_router import RouteResult +from rpmroute.route.pipeline import ( + _rule_clearance_dsn, + _rule_width_dsn, + _via_half_board, + route_dsn_board_exact, +) +from rpmroute.route.vias import reduce_vias + +VIA = "Via[0-1]_600:300_um" + + +def _board(second_on_bottom: bool = False) -> str: + """Two pads 14 mm apart on an empty two-layer board; the second on the + bottom layer when ``second_on_bottom``.""" + p2_stack = "Bot_Pad" if second_on_bottom else "Top_Pad" + return f"""(pcb "vias.dsn" + (parser (string_quote ") (space_in_quoted_tokens on) (host_cad "t") (host_version "1")) + (resolution um 10) + (unit um) + (structure + (layer F.Cu (type signal) (property (index 0))) + (layer B.Cu (type signal) (property (index 1))) + (boundary (path pcb 0 0 0 20000 0 20000 -10000 0 -10000 0 0)) + (via "{VIA}") + (rule (width 200) (clearance 200)) + ) + (library + (padstack Top_Pad (shape (rect F.Cu -300 -300 300 300)) (attach off)) + (padstack Bot_Pad (shape (rect B.Cu -300 -300 300 300)) (attach off)) + (padstack "{VIA}" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)) + (image TOP (pin Top_Pad 1 0 0)) + (image SECOND (pin {p2_stack} 1 0 0)) + ) + (placement + (component TOP (place P1 3000 -5000 front 0)) + (component SECOND (place P2 17000 -5000 front 0)) + ) + (network + (net N (pins P1-1 P2-1)) + (class default (rule (width 200) (clearance 200))) + ) + (wiring) +) +""" + + +def _route_args(dsn) -> dict: + scale = max(dsn.resolution.value, 1) + return { + "heuristic_weight": 1.0, + "via_half": _via_half_board(dsn, scale), + "edge_clearance": None, + "trace_width": round(_rule_width_dsn(dsn) * scale), + "clearance": round(_rule_clearance_dsn(dsn) * scale), + "layers": None, + "max_passes": 10, + "rip_up": True, + "shove": False, + "diagonal": False, + "rollback": True, + "tree_nets": True, + "net_widths": {}, + "via_drill": None, + "hole_to_hole": 0, + "via_cost": 10.0, + } + + +def _reduce(text: str, result: RouteResult) -> RouteResult: + dsn = parse_dsn(text) + args = _route_args(dsn) + return reduce_vias( + dsn, result, args, resolution=1, margin=20000, + via_half=args["via_half"], via_drill=2 * args["via_half"], via_name=VIA, + ) + + +def _pins(text: str) -> tuple[IntPoint, IntPoint]: + pins = {p.component_ref: p.location for p in build_board(parse_dsn(text)).get_pins()} + return pins["P1"], pins["P2"] + + +def test_a_needless_via_pair_is_routed_out(): + text = _board() + a, b = _pins(text) + # a detour: down to the bottom layer and back up, for no reason + v1, v2 = IntPoint(a.x + 40000, a.y), IntPoint(b.x - 40000, b.y) + detour = RouteResult(1000) + detour.add_wire(1, 0, [a, v1]) + detour.add_via(1, v1) + detour.add_wire(1, 1, [v1, v2]) + detour.add_via(1, v2) + detour.add_wire(1, 0, [v2, b]) + out = _reduce(text, detour) + assert out.failed == [] + assert sum(len(v) for v in out.vias.values()) == 0 + assert out.wires.get(1) + + +def test_a_needed_via_stays(): + text = _board(second_on_bottom=True) + exact, _scale, _layers = route_dsn_board_exact(parse_dsn(text), resolution=1) + routed = exact.result + assert routed.failed == [] + vias_before = sum(len(set(v)) for v in routed.vias.values()) + assert vias_before >= 1 + out = _reduce(text, routed) + assert out.failed == [] + assert sum(len(set(v)) for v in out.vias.values()) == vias_before + + +def test_the_flag_runs_the_stage_end_to_end(): + exact, _scale, _layers = route_dsn_board_exact(parse_dsn(_board()), resolution=1, reduce_vias=True) + assert exact.result.failed == [] diff --git a/tests/test_cli.py b/tests/test_cli.py index f0d129e..1fb55d0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -174,6 +174,8 @@ def test_cli_supported_combinations_are_accepted(tmp_path): "rollback": ["--no-rollback"], "tree": ["--no-tree"], "window": ["--window-reroute", "1"], + "vias": ["--reduce-vias"], + "via_cost": ["--via-cost", "40"], } for engine, options in ENGINE_OPTIONS.items(): for option in options: