Files
rpmroute/bench/diagnose.py
T
rsp2k dc11b4aa14 Route past round pads by their outline, not their bounding square
A circle pad was its bounding box in both the grid and the exact check, so a
4 mm mounting hole's square corners refused legal routes 0.8 mm clear of the
real copper. It is now a 16-sided polygon whose sides touch the circle, so
the router is never closer than KiCad measures. diagnose.py takes --seed.
2026-10-02 04:14:39 -06:00

306 lines
12 KiB
Python

"""Why did each connection fail? Sort the failures of one exact-engine run.
uv run python bench/diagnose.py board.dsn --edge-clearance 0.5 [--json out.json]
Every connection the exact engine leaves unrouted lands in one bucket:
rejected the grid routed it, the exact geometry check refused its copper
alone it cannot be routed even on a board with no other routes: the
pads, keepouts or the grid itself are the limit, not congestion
walled-in in the routed board one of its pins has no way out: a flood from
the pin's end cells through free cells stops short (pin access)
congestion both pins can get out, but no free path joins them
The board state is the one the result came from (the best pass), rebuilt by
replaying that pass's paths. Diagnostic only: it reaches into the router.
"""
from __future__ import annotations
import argparse
from collections import Counter
import json
from pathlib import Path
import sys
from freeroute.board import build_board
from freeroute.board.search_tree import ShapeSearchTree
from freeroute.dsn import parse_dsn
from freeroute.geometry.octagon import overlaps_2d
from freeroute.route import grid_router
from freeroute.route.exact_router import route_board_exact
from freeroute.route.grid_router import GridRouter, _ends_of
from freeroute.route.pipeline import (
_mm_to_board,
_net_widths_board,
_rule_clearance_dsn,
_rule_width_dsn,
_via_drill_board,
_via_half_board,
)
# a pin whose free neighbourhood holds fewer nodes than this is walled in
FLOOD_LIMIT = 200
def _capture_router():
"""Patch GridRouter so the instance route_board_exact builds, and the paths
of its best pass, can be read back afterwards."""
seen: dict = {}
original_commit = GridRouter._commit
original_snapshot = GridRouter._snapshot
def commit(self, conn, path):
original_commit(self, conn, path)
self._diag_paths[conn.index] = (list(path), conn.a_end, conn.b_end)
def snapshot(self, conns):
self._diag_best = {c.index: self._diag_paths[c.index] for c in conns if c.routed}
seen["router"] = self
return original_snapshot(self, conns)
def init(self, *args, **kwargs):
original_init(self, *args, **kwargs)
self._diag_paths = {}
self._diag_best = {}
seen["kwargs"] = kwargs
seen["args"] = args
original_init = GridRouter.__init__
GridRouter.__init__ = init
GridRouter._commit = commit
GridRouter._snapshot = snapshot
return seen
def _capture_conflicts() -> list:
"""Record each exact-check refusal: the copper box, its kind, and the
shape it came too close to. The check stops at a connection's first
conflict, so refusals and rejected connections line up in order."""
log: list = []
original = ShapeSearchTree.clearance_conflict
def conflict(self, box, net_no, layer, clearance, kind="wire"):
hit = original(self, box, net_no, layer, clearance, kind)
if hit:
# the shape that tripped the check, found the way the check does
other = None
expanded = box.offset(clearance)
for s in self.overlapping(expanded):
if s.net_no == net_no or not s.on_layer(layer):
continue
if s.blocks != "all" and s.blocks != kind + "s":
continue
if not s.clearance:
if s.tile.overlaps(box):
other = s
break
continue
if (s.exact is not None and overlaps_2d(s.exact, expanded)) or (
s.exact is None and s.tile.overlaps(expanded)
):
other = s
break
log.append((net_no, kind, layer, box, clearance, other))
return hit
ShapeSearchTree.clearance_conflict = conflict
return log
def _wall_name(wall, names) -> str:
if isinstance(wall, str):
if wall.startswith("fixed copper of net "):
net = int(wall.rsplit(" ", 1)[1])
return f"fixed copper of {names.get(net, net)}"
return wall
return names.get(wall, "pads/keepouts")
def _gap(a, b) -> int:
dx = max(b.ll.x - a.ur.x, a.ll.x - b.ur.x, 0)
dy = max(b.ll.y - a.ur.y, a.ll.y - b.ur.y, 0)
return max(dx, dy)
def _fresh(seen) -> GridRouter:
router = grid_router.GridRouter(*seen["args"], **seen["kwargs"])
router._conns = router._build_connections()
return router
def _static_wall(router: GridRouter, node) -> str:
"""What static copper closes a node: a keepout, copper already on the
board (a fixed via or track, by net), or a pad."""
if node in router._keepout:
return "keepout"
fixed = getattr(router, "_diag_fixed", None)
if fixed is None:
fixed = router._diag_fixed = {}
margin = router.clearance + router.half_width
for net, layers, box in router.board.get_fixed_copper():
for cell in router._cells_in_box(box, margin):
for layer in layers:
fixed[(*cell, layer)] = net
net = fixed.get(node)
if net is not None:
return f"fixed copper of net {net}"
return "pads"
def _flood(router: GridRouter, pin, net_no: int, conns) -> tuple[int, Counter]:
"""How many nodes a pin can reach through free cells (up to FLOOD_LIMIT),
and which other nets' copper bounds the region."""
walls: Counter = Counter()
# the search starts on every end node unconditionally (it may leave one
# inside a wire keepout by a via), so the flood does too
start = list(router._end_nodes(pin, net_no, conns))
seen = set(start)
frontier = list(start)
while frontier and len(seen) < FLOOD_LIMIT:
x, y, layer = frontier.pop()
steps = [(x + dx, y + dy, layer) for dx, dy in router._neighbours]
if router._via_placeable((x, y), net_no, conns):
steps += [(x, y, other) for other in router.layers if other != layer]
elif len(seen) == 1:
# the pin's own exit is a via that other copper leaves no room for
via_nodes = [(*c, lay) for c in router._via_cells((x, y)) for lay in router.layers]
for owner in router._crossed_owners(via_nodes, net_no, conns):
walls[conns[owner].net_no] += 1
for n in steps:
if n in seen or not (0 <= n[0] < router.cols and 0 <= n[1] < router.rows):
continue
if router._enter_cost(n[:2], n[2], net_no, False, conns) is None:
# trace, via footprint or clearance halo, as rip-up sees them
owners = router._crossed_owners([n], net_no, conns)
for owner in owners:
walls[conns[owner].net_no] += 1
if not owners:
walls[_static_wall(router, n)] += 1
continue
seen.add(n)
frontier.append(n)
return len(seen), walls
def diagnose(
dsn_text: str, edge_clearance_mm: float | None, resolution: int = 2, shove: bool = False,
access_cost: float = 0.0, hole_to_hole_mm: float = 0.25, seed: int | None = None,
) -> dict:
dsn = parse_dsn(dsn_text)
names = {i + 1: n.name for i, n in enumerate(dsn.nets)}
scale = max(dsn.resolution.value, 1)
seen = _capture_router()
conflicts = _capture_conflicts()
exact = route_board_exact(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * scale),
clearance=round(_rule_clearance_dsn(dsn) * scale),
via_half=_via_half_board(dsn, scale),
edge_clearance=_mm_to_board(dsn, scale, edge_clearance_mm),
resolution=resolution,
shove=shove,
access_cost=access_cost,
seed=seed,
# the product's configuration: net-class widths and hole spacing
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,
)
routed = seen["router"]
best = routed._diag_best
grid_failed = {(c.net_no, *_ends_of(c)) for c in routed._conns if c.index not in best}
# the board as the best pass left it
state = _fresh(seen)
conns = state._conns
for idx, (path, a_end, b_end) in best.items():
conns[idx].a_end, conns[idx].b_end = a_end, b_end
state._commit(conns[idx], path)
empty = _fresh(seen)
# refusals in the order the check made them, one per rejected connection
per_net: dict[int, list] = {}
for net_no, kind, layer, box, clearance, other in conflicts:
if other is None:
what = "?"
elif other.net_no == -1:
what = "keepout" if other.clearance else "rule area"
else:
what = ("trace/via of " if other.routed else "pad of ") + names.get(other.net_no, "?")
need = clearance if other is None or other.clearance else 0
short = (need - _gap(box, other.tile)) / scale / 1000 if other is not None else 0
per_net.setdefault(net_no, []).append(
{"copper": kind, "layer": layer, "against": what, "short_mm": round(short, 4)}
)
by_key = {(c.net_no, *_ends_of(c)): c for c in conns}
rows = []
for failure in exact.result.failed:
net_no, a, b = failure[0], failure[1], failure[2]
zone = failure[3] if len(failure) > 3 else ""
if zone:
continue # left to a zone fill, not a failure
key = (net_no, a, b)
row = {"net": names.get(net_no, str(net_no)), "a": a, "b": b}
conn = by_key.get(key)
if key not in grid_failed:
row["kind"] = "rejected"
if per_net.get(net_no):
row["conflict"] = per_net[net_no].pop(0)
elif conn is None:
row["kind"] = "unknown"
elif empty._search(empty._conns[conn.index], allow_ripup=False, conns=empty._conns) is None:
row["kind"] = "alone"
else:
walled = []
for end, group in ((a, conn.a_group), (b, conn.b_group)):
for pin in group[0][:1]: # a group's first pin is its route end
reach, walls = _flood(state, pin, net_no, conns)
if reach < FLOOD_LIMIT:
walled.append({
"pin": end, "reach": reach,
"walls": [
_wall_name(n, names) for n, _ in walls.most_common(4)
],
})
row["kind"] = "walled-in" if walled else "congestion"
if walled:
row["walled"] = walled
rows.append(row)
return {"failures": rows, "counts": dict(Counter(r["kind"] for r in rows))}
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("dsn", type=Path)
ap.add_argument("--edge-clearance", type=float, metavar="MM")
ap.add_argument("--grid-resolution", type=int, default=2, metavar="K")
ap.add_argument("--shove", action="store_true", help="as mckicad routes")
ap.add_argument("--access-cost", type=float, default=0.0)
ap.add_argument("--seed", type=int, help="route in this shuffled order, as bench.py does")
ap.add_argument("--hole-to-hole", type=float, default=0.25, metavar="MM")
ap.add_argument("--json", type=Path, help="also write the full table here")
args = ap.parse_args(argv)
report = diagnose(args.dsn.read_text(), args.edge_clearance, args.grid_resolution, args.shove,
args.access_cost, args.hole_to_hole, args.seed)
for row in report["failures"]:
extra = ""
for w in row.get("walled", []):
extra += f" [{w['pin']} reaches {w['reach']} cells, walled by {', '.join(w['walls'])}]"
if "conflict" in row:
c = row["conflict"]
extra += (f" [{c['copper']} on layer {c['layer']} {c['short_mm']} mm too close"
f" to {c['against']}]")
print(f"{row['kind']:11} {row['net']}: {row['a']} to {row['b']}{extra}")
total = sum(report["counts"].values())
print(f"\n{total} failed: " + ", ".join(f"{k} {v}" for k, v in sorted(report["counts"].items())),
file=sys.stderr)
if args.json:
args.json.write_text(json.dumps(report, indent=1))
return 0
if __name__ == "__main__":
raise SystemExit(main())