Merge feat/reduce-vias: opt-in --reduce-vias pass and --via-cost

This commit is contained in:
2026-10-10 09:39:07 -06:00
6 changed files with 346 additions and 3 deletions
+16 -1
View File
@@ -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']}")
+47 -2
View File
@@ -31,10 +31,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"}),
}
@@ -54,6 +57,8 @@ _FLAG_NAMES = {
"rollback": "--no-rollback",
"tree": "--no-tree",
"window": "--window-reroute",
"vias": "--reduce-vias",
"via_cost": "--via-cost",
}
@@ -238,6 +243,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,
@@ -288,6 +324,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
]
@@ -331,7 +369,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,
@@ -345,6 +388,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,
+30
View File
@@ -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)
+128
View File
@@ -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
+123
View File
@@ -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 == []
+2
View File
@@ -188,6 +188,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: