Compare commits

..
Author SHA1 Message Date
rsp2k a2e108bc51 Rollback on by default; --no-rollback keeps every rip-up
Over 8 net orders it left 25 to 30 fewer connections unrouted on the
dense sense boards and 16 to 18 fewer on two PCBench boards, and was
never clearly worse. KiCad DRC on the sense harness: 41 opens to 28.
2026-09-28 16:17:28 -06:00
rsp2k 3462089e09 --rollback: undo a rip-up that leaves fewer connections routed
The connections a rip-up route crosses are rerouted at once in free
space. If that leaves fewer connections routed than before, the new
route comes out and every displaced connection goes back on its old
path, with its rip count as it was. Even trades are kept.
2026-09-28 14:54:29 -06:00
rsp2k 35891c02ff Merge feat/rule-area-keepouts: keepout exits, via-only walled pins, rule-area support 2026-09-28 10:32:44 -06:00
rsp2k 3e4f53a469 Keepout exits and rule areas
A route may not leave a cell inside a wire keepout as a wire, nor reach
a pin there by one: it changes layer instead, and the via goes in the
pad. A pin whose every stub enters a keepout is reached by a via only.

A structure keepout named rule_area... (mckicad names KiCad rule areas
so) is checked for overlap only, as KiCad's DRC does, with the free ends
of each wire tested for their round caps.
2026-09-28 10:08:02 -06:00
rsp2k bb75dc5067 DSN reader: only the declared string_quote character opens a string
KiCad declares " and writes a keycap part number as a bare ', which the
tokenizer took for an unterminated string (PCBench f.60_keyboard).
2026-09-28 09:41:27 -06:00
rsp2k 9de8329027 Merge feat/pin-access: vias in pads inside keepouts, exact stub checks, --access-cost 2026-09-28 09:38:18 -06:00
rsp2k f1d0f8df70 Pin access: --access-cost, vias in pads inside wire keepouts, exact stub checks
--access-cost C makes a route pay C to pass where it would close another
pin's way out (its end cells, the via it may need there, and the
clearance around both) while that pin still has connections to route.

A pin inside a wire keepout can only leave by a via; its stub to the
cell centre was track inside the keepout, which the exact check refused.
The via now goes on the pin itself. Pin stubs are also checked exactly
against wire keepouts, as the exact engine will check them.

bench/diagnose.py reports what each refused connection collided with.
2026-09-28 09:15:11 -06:00
rsp2k c44ff272ae bench/diagnose.py: sort an exact-engine run's failures by cause
rejected by the exact check, unroutable even alone, walled in (a pin's
free region is closed off by other copper, with the nets that close it),
or congestion. The board state is the best pass's, rebuilt by replaying
its paths.
2026-09-28 00:30:05 -06:00
rsp2k 05f31fc828 Merge feat/promote-failures: reroute with failed connections first; --orders 2026-09-27 22:53:47 -06:00
rsp2k 01ca108831 --promote-failures: reroute with the connections that failed moved first
Each round routes every connection that has failed so far ahead of the
rest, which keep their order. The promoted set only grows, so two groups
cannot take turns failing; the best round is kept.
2026-09-27 22:34:06 -06:00
rsp2k 0f5dacbd88 --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.
2026-09-27 22:14:59 -06:00
rsp2k e67ccc04a0 Keep benchmark board lists and results out of git 2026-09-27 15:07:13 -06:00
rsp2k ed2e4c3fac bench: compare routing variants over several connection orders
Runs every board with every variant under the same seeds and compares
them seed by seed, so a change is judged against the routing-order
noise rather than one lucky or unlucky run.
2026-09-27 15:06:30 -06:00
rsp2k 76c3af94b8 --seed: shuffle the order connections are routed in
The router is deterministic, but its result depends heavily on which
connection routes first. A seed shuffles that order reproducibly, so a
change can be judged over several orders instead of the one it happened
to get.
2026-09-27 15:04:35 -06:00
rsp2k cf8b7ece60 uv.lock: record the CalVer version from pyproject 2026-09-27 15:04:35 -06:00
rsp2k f8804512ac Merge feat/plane-layers: route to inner plane layers with a via 2026-09-27 15:01:38 -06:00
20 changed files with 1214 additions and 30 deletions
+4
View File
@@ -20,3 +20,7 @@ htmlcov/
# Editor / OS
.DS_Store
*.swp
# Benchmark inputs and results: board lists name local, often private, designs
/bench/boards*.txt
/bench/results/
+206
View File
@@ -0,0 +1,206 @@
"""Route boards under several connection orders and compare variants fairly.
One routing run says little: which connection routes first moves a result by
ten connections either way on a crowded board. This runs every board with
every variant under the same seeds (``--seed``), so variant A and variant B
face the same order on seed 3, and compares them seed by seed.
uv run python bench/bench.py BOARDS -v base= -v hist1="--history-cost 1" \\
--seeds 6 --jobs 4 --out bench/results/hist.jsonl
BOARDS is a text file, one board per line: a name, a DSN path and any flags
that board always needs, e.g.
sense /path/to/charge-sense.dsn --edge-clearance 0.5
# comments and blank lines are ignored
A variant is ``NAME=FLAGS``, or ``NAME@SRC=FLAGS`` to run the code under SRC
(a worktree's ``src`` directory) rather than the installed package. The first
variant is the baseline. Results are appended to --out as JSON lines, and runs
already in it are skipped, so an interrupted benchmark picks up where it left
off. Exits non-zero if any run crashed or timed out.
"""
from __future__ import annotations
import argparse
from concurrent.futures import ThreadPoolExecutor, as_completed
import json
import math
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
import tempfile
import threading
import time
NOT_ROUTED = re.compile(r"freeroute: (\d+) connection\(s\) not routed")
TO_ZONE = re.compile(r"freeroute: (\d+) more connection\(s\) left to a zone fill")
def parse_boards(path: Path) -> list[tuple[str, Path, list[str]]]:
boards = []
for line in path.read_text().splitlines():
words = shlex.split(line, comments=True)
if not words:
continue
if len(words) < 2:
raise SystemExit(f"{path}: want 'name path [flags]', got {line!r}")
dsn = Path(words[1]).expanduser()
if not dsn.is_file():
raise SystemExit(f"{path}: no such DSN {dsn}")
boards.append((words[0], dsn, words[2:]))
return boards
def parse_variant(spec: str) -> tuple[str, str | None, list[str]]:
head, sep, flags = spec.partition("=")
if not sep or not head:
raise SystemExit(f"variant {spec!r}: want NAME=FLAGS or NAME@SRC=FLAGS")
name, _, src = head.partition("@")
if src and not (Path(src) / "freeroute").is_dir():
raise SystemExit(f"variant {name}: {src} has no freeroute package")
return name, src or None, shlex.split(flags)
def run_one(board, variant, seed: int, timeout: float) -> dict:
name, dsn, board_flags = board
vname, src, flags = variant
env = dict(os.environ)
if src:
env["PYTHONPATH"] = src + os.pathsep + env.get("PYTHONPATH", "")
with tempfile.TemporaryDirectory() as tmp:
cmd = [
sys.executable, "-m", "freeroute.cli", str(dsn), "-do", f"{tmp}/out.ses",
*board_flags, *flags, "--seed", str(seed),
]
start = time.monotonic()
proc = subprocess.Popen(
cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True
)
expired = threading.Event()
def expire() -> None:
expired.set()
proc.kill()
timer = threading.Timer(timeout, expire)
timer.start()
err = proc.stderr.read()
# reap it here rather than through Popen, to keep its peak memory
_, wstatus, usage = os.wait4(proc.pid, 0)
timer.cancel()
proc.returncode = os.waitstatus_to_exitcode(wstatus)
proc.stderr.close()
status = "timeout" if expired.is_set() else (
"ok" if proc.returncode == 0 else f"exit {proc.returncode}"
)
rss_mb = round(usage.ru_maxrss / 1024)
secs = round(time.monotonic() - start, 1)
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,
"tail": err.strip().splitlines()[-1] if status != "ok" and err.strip() else "",
}
def mean_sd(xs: list[float]) -> tuple[float, float]:
m = sum(xs) / len(xs)
sd = math.sqrt(sum((x - m) ** 2 for x in xs) / (len(xs) - 1)) if len(xs) > 1 else 0.0
return m, sd
def report(rows: list[dict], boards, variants) -> None:
ok = {(r["board"], r["variant"], r["seed"]): r for r in rows if r["status"] == "ok"}
base = variants[0][0]
print(f"\nunrouted per board (lower is better), baseline {base!r}\n")
print(f"{'board':14} {'variant':14} {'n':>3} {'mean':>6} {'sd':>5} {'min':>4} {'max':>4}"
f" {'secs':>6} vs baseline, seed by seed")
for bname, _, _ in boards:
for vname, _, _ in variants:
runs = sorted((k[2], r) for k, r in ok.items() if k[:2] == (bname, vname))
if not runs:
print(f"{bname:14} {vname:14} no finished runs")
continue
xs = [r["unrouted"] for _, r in runs]
m, sd = mean_sd(xs)
t, _ = mean_sd([r["secs"] for _, r in runs])
line = (f"{bname:14} {vname:14} {len(xs):>3} {m:>6.1f} {sd:>5.1f} {min(xs):>4}"
f" {max(xs):>4} {t:>6.0f}")
if vname != base:
diffs = [r["unrouted"] - ok[(bname, base, s)]["unrouted"]
for s, r in runs if (bname, base, s) in ok]
if diffs:
dm, dsd = mean_sd(diffs)
se = dsd / math.sqrt(len(diffs)) if len(diffs) > 1 else float("inf")
better = sum(d < 0 for d in diffs)
worse = sum(d > 0 for d in diffs)
# a difference inside two standard errors is not a finding
verdict = ("better" if dm < 0 else "worse") if abs(dm) > 2 * se else "no clear change"
line += (f" {dm:+.1f} ± {se:.1f} ({better} better, {worse} worse,"
f" {len(diffs) - better - worse} same) {verdict}")
print(line)
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']}")
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("boards", type=Path, help="board list: name, DSN path, flags")
ap.add_argument("-v", "--variant", action="append", required=True,
help="NAME=FLAGS or NAME@SRC=FLAGS; the first is the baseline")
ap.add_argument("--seeds", type=int, default=6, help="orders per board and variant")
ap.add_argument("--first-seed", type=int, default=0)
ap.add_argument("--jobs", type=int, default=4, help="runs at once")
ap.add_argument("--timeout", type=float, default=1800, help="seconds per run")
ap.add_argument("--out", type=Path, required=True, help="JSON lines, appended")
ap.add_argument("--report-only", action="store_true", help="summarise --out, run nothing")
args = ap.parse_args(argv)
boards = parse_boards(args.boards)
variants = [parse_variant(v) for v in args.variant]
if len({v[0] for v in variants}) != len(variants):
raise SystemExit("variant names must differ")
seeds = range(args.first_seed, args.first_seed + args.seeds)
rows: list[dict] = []
if args.out.exists():
rows = [json.loads(line) for line in args.out.read_text().splitlines() if line.strip()]
done = {(r["board"], r["variant"], r["seed"]) for r in rows if r["status"] == "ok"}
todo = [(b, v, s) for b in boards for v in variants for s in seeds
if (b[0], v[0], s) not in done]
if not args.report_only and todo:
args.out.parent.mkdir(parents=True, exist_ok=True)
print(f"{len(todo)} runs, {args.jobs} at a time", file=sys.stderr)
# retried runs replace their earlier failure
rows = [r for r in rows if (r["board"], r["variant"], r["seed"]) in done]
with ThreadPoolExecutor(args.jobs) as pool, args.out.open("w") as out:
for r in rows:
out.write(json.dumps(r) + "\n")
futures = [pool.submit(run_one, b, v, s, args.timeout) for b, v, s in todo]
for n, fut in enumerate(as_completed(futures), 1):
r = fut.result()
rows.append(r)
out.write(json.dumps(r) + "\n")
out.flush()
print(f"[{n}/{len(todo)}] {r['board']} {r['variant']} seed {r['seed']}:"
f" {r['status']} unrouted {r['unrouted']} ({r['secs']}s,"
f" {r['rss_mb']} MB)", file=sys.stderr)
wanted = {(b[0], v[0], s) for b in boards for v in variants for s in seeds}
rows = [r for r in rows if (r["board"], r["variant"], r["seed"]) in wanted]
report(rows, boards, variants)
return 1 if any(r["status"] != "ok" for r in rows) or len(rows) < len(wanted) else 0
if __name__ == "__main__":
raise SystemExit(main())
+266
View File
@@ -0,0 +1,266 @@
"""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,
_rule_clearance_dsn,
_rule_width_dsn,
_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 _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 _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[0] += 1 # a pad or keepout
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,
) -> 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,
)
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": [names.get(n, "pads/keepouts") 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("--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)
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())
+10
View File
@@ -333,10 +333,20 @@ def _insert_keepouts(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTran
tiles=tiles,
blocks_wires=keepout.kind != "via_keepout",
blocks_vias=keepout.kind != "wire_keepout",
clearance=not is_rule_area(keepout.area.name),
)
)
#: a structure keepout named with this prefix is a KiCad rule area (mckicad
#: names them on export); it is checked for overlap only
RULE_AREA_PREFIX = "rule_area"
def is_rule_area(name: str | None) -> bool:
return bool(name) and name.startswith(RULE_AREA_PREFIX)
def _area_border_tiles(shape: Shape, transform: CoordinateTransform) -> list:
"""Convex board tiles covering a keepout border shape.
+5
View File
@@ -76,6 +76,11 @@ class ObstacleArea(Item):
#: tracks" rule area, where vias are allowed), via_keepout only vias
blocks_wires: bool = True
blocks_vias: bool = True
#: False for a KiCad rule area: copper may not overlap it but may touch
#: it, since KiCad's DRC applies no clearance to a rule area. Every other
#: keepout may stand for copper (a net tie, a track kept as it is), so it
#: keeps the clearance unless named as a rule area.
clearance: bool = True
def is_obstacle(self) -> bool:
return True
+26
View File
@@ -50,6 +50,8 @@ class TreeShape:
#: which copper a keepout tile stops: "all", "wires" (a wire_keepout,
#: vias allowed) or "vias" (a via_keepout)
blocks: str = "all"
#: False for a rule area: copper may touch it, only overlap counts
clearance: bool = True
def on_layer(self, layer: int) -> bool:
return layer in self.layers
@@ -129,6 +131,8 @@ class ShapeSearchTree:
Uses a strict 2-D overlap of the ``clearance``-expanded box, so tiles
exactly ``clearance`` apart (touching after expansion) are allowed.
A rule area (``clearance=False``) only has to be clear of the copper
itself.
"""
expanded = box.offset(clearance)
for shape in self.overlapping(expanded):
@@ -136,6 +140,10 @@ class ShapeSearchTree:
continue
if shape.blocks != "all" and shape.blocks != kind + "s":
continue # a wire-only keepout does not stop a via, and so on
if not shape.clearance:
if shape.tile.overlaps(box):
return True
continue
if shape.exact is not None:
if overlaps_2d(shape.exact, expanded):
return True
@@ -143,6 +151,17 @@ class ShapeSearchTree:
return True
return False
def rule_area_overlap(self, box: IntBox, layer: int, kind: str = "wire") -> bool:
"""True if ``box`` overlaps a rule area on ``layer`` that stops ``kind``."""
for shape in self.overlapping(box):
if shape.clearance or not shape.on_layer(layer):
continue
if shape.blocks != "all" and shape.blocks != kind + "s":
continue
if shape.tile.overlaps(box):
return True
return False
def clearance_conflict_shape(
self, region, net_no: int, layer: int
) -> bool:
@@ -178,6 +197,13 @@ class ShapeSearchTree:
continue # static input pair (e.g. pad vs pad) — not a routing DRC
if not (shape.layers & other.layers):
continue
if not (shape.clearance and other.clearance):
# a rule area: only an overlap is a violation
mine = shape.exact if shape.exact is not None else shape.tile
theirs = other.exact if other.exact is not None else other.tile
if overlaps_2d(mine, theirs):
return shape
continue
if shape.exact is None and other.exact is None:
if other.tile.overlaps(expanded): # byte-identical box path
return shape
+101 -19
View File
@@ -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"}),
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes"}),
"grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}),
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}),
"room": frozenset({"shove", "pack"}),
}
@@ -41,6 +42,11 @@ _FLAG_NAMES = {
"diagonal": "--diagonal",
"rip_up": "--no-rip-up",
"max_passes": "-mp/--max-passes",
"seed": "--seed",
"orders": "--orders",
"promote": "--promote-failures",
"access": "--access-cost",
"rollback": "--no-rollback",
}
@@ -133,6 +139,48 @@ def build_parser() -> argparse.ArgumentParser:
help="copper to board-edge clearance in mm, e.g. KiCad's min_copper_edge_clearance "
"(the DSN does not carry it); default 0: copper only has to stay inside the outline",
)
parser.add_argument(
"--seed",
type=int,
metavar="N",
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(
"--access-cost",
type=float,
metavar="C",
help="grid and exact engines: extra cost for a route to pass where it would close "
"another pin's way out while that pin still has connections to route (default 0: off)",
)
parser.add_argument(
"--no-rollback",
dest="rollback",
action="store_false",
help="grid and exact engines: keep every rip-up, as before. By default the "
"connections a rip-up displaces are rerouted at once and the rip-up is undone "
"if fewer connections end up routed",
)
parser.add_argument(
"--jobs",
type=int,
metavar="J",
help="processes for --orders (default: one per order, up to the CPU count)",
)
return parser
@@ -152,6 +200,11 @@ def main(argv: list[str] | None = None) -> int:
("diagonal", args.diagonal),
("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),
("access", args.access_cost is not None),
("rollback", not args.rollback),
)
if asked
]
@@ -173,29 +226,58 @@ 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,
)
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,
"access_cost": args.access_cost or 0.0,
"rollback": args.rollback,
}
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,
"access_cost": args.access_cost or 0.0,
"rollback": args.rollback,
}
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,
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]]
+17 -1
View File
@@ -9,6 +9,8 @@ tolerant ``skip_scope`` behaviour.
from __future__ import annotations
import re
from .model import (
ClearanceRule,
ComponentPlace,
@@ -481,12 +483,26 @@ def _read_network(node: SExp, board: DsnBoard) -> None:
# --- top level ---------------------------------------------------------------
_STRING_QUOTE = re.compile(r"\(\s*string_quote\s+(\S)\s*\)")
def _declared_quote(text: str) -> str:
"""The quote character the file declares in ``(string_quote X)``.
Only that character opens a string. Without a declaration both kinds are
accepted, as before: KiCad declares ``"`` and then writes a bare ``'``
where a value is a single quote mark, e.g. a keycap part number.
"""
found = _STRING_QUOTE.search(text[:4096])
return found.group(1) if found else "\"'"
def parse_dsn(text: str) -> DsnBoard:
"""Parse DSN ``text`` into a :class:`DsnBoard`.
Raises :class:`DsnParseError` if the top-level scope is not ``(pcb ...)``.
"""
top = parse(text)
top = parse(text, _declared_quote(text))
if top.head != "pcb":
raise DsnParseError(
f"not a Specctra DSN file: expected top-level '(pcb ...)', got '({top.head} ...)'"
+27 -2
View File
@@ -42,6 +42,7 @@ from freeroute.geometry import (
segment_octagon,
shove_segment,
)
from freeroute.geometry.polyline import corner_box
from .grid_router import RouteResult, route_board
@@ -94,6 +95,10 @@ def route_board_exact(
edge_clearance: int | None = None,
resolution: int = 1,
heuristic_weight: float = 1.0,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
rollback: bool = True,
) -> ExactRouteResult:
"""Route ``board`` orthogonally and verify the result with exact geometry.
@@ -122,6 +127,10 @@ def route_board_exact(
edge_clearance=edge_clearance,
resolution=resolution,
heuristic_weight=heuristic_weight,
seed=seed,
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
)
edge = 0 if edge_clearance is None else edge_clearance
outline = board.outline
@@ -151,7 +160,10 @@ def route_board_exact(
box = tile.bounding_box()
if not box.is_empty():
tree.insert(
TreeShape(next(owner), -1, frozenset({obstacle.layer}), box, blocks=blocks)
TreeShape(
next(owner), -1, frozenset({obstacle.layer}), box,
blocks=blocks, clearance=obstacle.clearance,
)
)
via_layers = frozenset(signal_layers)
@@ -172,7 +184,9 @@ def route_board_exact(
)
for i, (box, box_layers) in enumerate(boxes)
for layer in box_layers
) or (outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes)):
) or _end_in_rule_area(tree, wires, half_width) or (
outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes)
):
rejected.append((net_no, *ends))
continue # exact-clearance conflict -> drop (may be recovered by shove)
acc = accepted.get(net_no)
@@ -586,6 +600,17 @@ def _net_boxes(result, net_no, half_width, via_layers, via_half=None):
)
def _end_in_rule_area(tree, wires, half_width) -> bool:
"""A wire's tiles stop at its end points, but a track ends in a round cap
half its width beyond them. Against other copper the pad or via there
covers it; a rule area has no clearance to absorb it, so check it."""
for layer, points in wires:
for end in (points[0], points[-1]):
if tree.rule_area_overlap(corner_box(end, half_width), layer):
return True
return False
def _copper_boxes(wires, vias, half_width, via_layers, via_half=None):
via_half = via_half or half_width
boxes: list[tuple[IntBox, frozenset[int]]] = []
+191 -6
View File
@@ -28,10 +28,12 @@ from __future__ import annotations
import heapq
import math
import random
from freeroute.board.board import BasicBoard
from freeroute.board.items import ConductionArea, ObstacleArea, Pin
from freeroute.geometry import IntBox, IntPoint
from freeroute.geometry.polyline import corner_box
__all__ = ["GridRouter", "RouteResult", "route_board"]
@@ -111,6 +113,7 @@ class _Connection:
"b_group",
"a_end",
"b_end",
"path",
)
def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None:
@@ -132,6 +135,8 @@ class _Connection:
self.b_group: tuple[list[Pin], list] = ([b], [])
self.a_end: Pin | None = a
self.b_end: Pin | None = b
# the node path last committed, so a rip-up can be undone
self.path: list[Node] | None = None
class GridRouter:
@@ -154,8 +159,30 @@ class GridRouter:
edge_clearance: int | None = None,
resolution: int = 1,
heuristic_weight: float = 1.0,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
rollback: bool = True,
) -> None:
self.board = board
# Pin access: while a pin still has connections to route, the cells
# its route would leave by (its end cells, the via it may need there,
# and the clearance around both) cost other nets this much extra, so
# they route round a fine-pitch pin's way out instead of sealing it.
self.access_cost = max(float(access_cost), 0.0)
self._doorways: dict[Node, list[tuple[int, int]]] = {}
self._pending: dict[int, int] = {}
# Rip-up as a transaction: the connections a rip-up route crosses are
# rerouted at once, and if fewer connections are routed afterwards
# than before, everything goes back as it was. On by default: over
# 8 net orders it left 25 to 30 fewer connections unrouted on dense
# boards and was never clearly worse.
self.rollback = bool(rollback)
# 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 = {}
@@ -281,6 +308,10 @@ class GridRouter:
margin = self.clearance + self.half_width
outline = self.board.outline
self._via_edge_block: set[Cell] = set()
# wire keepouts as boxes, to check a pin stub the way the exact engine will
self._keepout_boxes: dict[int, list[tuple[IntBox, bool]]] = {}
# pin end nodes no wire can reach without entering a keepout
self._via_only: set[Node] = set()
if outline is not None:
# cells too close to the real outline (rounded corners included):
# for a trace on every layer, and separately for a via's wider copper
@@ -317,7 +348,13 @@ class GridRouter:
if box.is_empty():
continue
# the same distance the exact engine verifies against
for cell in self._cells_in_box(box, margin):
if item.blocks_wires:
self._keepout_boxes.setdefault(item.layer, []).append(
(box, item.clearance)
)
# a rule area takes no clearance: a trace may touch it
reach = margin if item.clearance else self.half_width
for cell in self._cells_in_box(box, reach):
if item.blocks_wires:
self._keepout.add((*cell, item.layer))
if item.blocks_vias:
@@ -455,23 +492,34 @@ class GridRouter:
return path
cx, cy, cl = current
base = g_score[current]
for dx, dy in self._neighbours:
# A cell inside a wire keepout (a pin there, or where a via
# landed) may only be left by another via: a wire from it would
# start inside the keepout.
blocked = current in self._keepout or current in self._via_only
neighbours = () if blocked else self._neighbours
for dx, dy in neighbours:
cell = (cx + dx, cy + dy)
if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows):
continue
nxt = (*cell, cl)
if nxt in goal_stub: # a pin end; a zone end is entered normally
if nxt in self._keepout or nxt in self._via_only:
continue # a pin inside a keepout is reached by a via only
enter = goal_stub[nxt]
else:
enter = self._enter_cost(cell, cl, net_no, allow_ripup, conns)
if enter is None:
continue
if self._doorways:
enter += self._access(nxt, net_no)
step = 1.0 if dx == 0 or dy == 0 else math.sqrt(2)
self._relax(
current, nxt, base + step + enter, goal_cell, g_score, came_from, open_heap
)
if len(self.layers) > 1:
g_via = base + self.via_cost
if self._doorways:
g_via += self._access(current, net_no)
for layer in self.layers:
if layer == cl:
continue
@@ -504,7 +552,27 @@ class GridRouter:
if _point_segment(self._cell_center(c), pin_location, end) < reach
]
def _stub_clear(self, pin: Pin, cell: Cell, layer: int, net_no: int, conns) -> bool:
def _stub_clear(
self, pin: Pin, cell: Cell, layer: int, net_no: int, conns, keepouts_only: bool = False
) -> bool:
keepouts = self._keepout_boxes.get(layer)
if keepouts:
# the stub is off the grid, so check it exactly, as the exact
# engine will: its copper grown by clearance must miss every keepout
from freeroute.geometry.polyline import Polyline, PolylineShape
end = self._cell_center(cell)
if end != pin.location:
for tile in PolylineShape(Polyline([pin.location, end]), self.half_width).tiles():
grown = tile.offset(self.clearance)
if any(k.overlaps(grown if needs else tile) for k, needs in keepouts):
return False
# the track's round end at the pin, for a rule area
cap = corner_box(pin.location, self.half_width)
if any(not needs and k.overlaps(cap) for k, needs in keepouts):
return False
if keepouts_only:
return True
for c in self._stub_cells(pin.location, cell):
owner = self._cell_owner.get((*c, layer))
if owner is not None and conns[owner].net_no != net_no:
@@ -610,6 +678,13 @@ class GridRouter:
nodes |= self._escape_nodes(pin, net_no, conns, layers, (cx, cy))
if not nodes: # nothing clear: fall back to the nearest cell, as before
nodes = {(cx, cy, layer) for layer in layers}
if self._keepout_boxes and not any(
self._stub_clear(pin, (cx, cy), layer, net_no, conns, keepouts_only=True)
for layer in layers
):
# no wire can reach the pin without entering a keepout: the
# route has to arrive by a via, which goes in the pad
self._via_only.update(nodes)
return nodes
def _crowded(self, pin: Pin, net_no: int) -> bool:
@@ -723,6 +798,7 @@ class GridRouter:
# --- commit / rip -------------------------------------------------------
def _commit(self, conn: _Connection, path: list[Node]) -> None:
conn.path = list(path)
wire_segs, via_cells = _split_path(path)
via_cells = list(via_cells)
if isinstance(conn.a_end, ConductionArea):
@@ -757,8 +833,21 @@ class GridRouter:
if node not in self._cell_owner:
self._cell_owner[node] = conn.index
conn.trace_cells.append((c, layer))
# A pin inside a wire keepout can only leave by a via. Its stub to the
# cell centre would be track inside the keepout, so the via goes on
# the pin itself (a via in its own pad) and the stub is left out.
in_pad: dict[int, IntPoint] = {}
if len(wire_segs) > 1:
for idx, pin in ((0, conn.a_end), (len(wire_segs) - 1, conn.b_end)):
layer, cells = wire_segs[idx]
node = (*cells[0], layer)
walled = node in self._keepout or node in self._via_only
if isinstance(pin, Pin) and len(cells) == 1 and walled:
in_pad[idx] = pin.location
conn.wires = []
for idx, (layer, cells) in enumerate(wire_segs):
if idx in in_pad:
continue
points = [self._cell_center(c) for c in cells]
if idx == 0 and isinstance(conn.a_end, Pin):
points.insert(0, conn.a_end.location)
@@ -768,7 +857,11 @@ class GridRouter:
if len(points) >= 2:
conn.wires.append((layer, points))
conn.vias = [self._cell_center(c) for c in conn.via_cells]
for idx, location in in_pad.items():
# via k joins wire segments k and k + 1
conn.vias[0 if idx == 0 else idx - 1] = location
conn.routed = True
self._settle(conn, -1)
def _rip(self, conn: _Connection) -> None:
for cell, layer in conn.trace_cells:
@@ -791,6 +884,7 @@ class GridRouter:
conn.vias = []
conn.routed = False
conn.rip_count += 1
self._settle(conn, +1)
def _crossed_owners(self, path: list[Node], net_no: int, conns: list[_Connection]) -> set[int]:
owners: set[int] = set()
@@ -806,6 +900,50 @@ class GridRouter:
owners.add(i)
return owners
# --- pin access ---------------------------------------------------------
@staticmethod
def _group_pins(conn: _Connection):
return [*conn.a_group[0], *conn.b_group[0]]
def _build_doorways(self, conns: list[_Connection]) -> None:
"""Map every cell that would close a pin's way out to that pin."""
spread = [(0, 0), *self._halo_offsets]
done: set[int] = set()
for conn in conns:
for pin in self._group_pins(conn):
self._pending[id(pin)] = self._pending.get(id(pin), 0) + 1
if id(pin) in done:
continue
done.add(id(pin))
claim = (id(pin), conn.net_no)
exits: set[Node] = set()
for x, y, layer in self._end_nodes(pin, conn.net_no, conns):
exits.add((x, y, layer))
for c in self._via_cells((x, y)):
exits.update((*c, other) for other in self.layers)
cells: set[Node] = set()
for x, y, layer in exits:
for dx, dy in spread:
if 0 <= x + dx < self.cols and 0 <= y + dy < self.rows:
cells.add((x + dx, y + dy, layer))
for node in cells:
self._doorways.setdefault(node, []).append(claim)
def _settle(self, conn: _Connection, delta: int) -> None:
if not self._pending:
return
for pin in self._group_pins(conn):
if id(pin) in self._pending:
self._pending[id(pin)] += delta
def _access(self, node: Node, net_no: int) -> float:
"""Extra cost for ``net_no`` to use a cell another pin still needs."""
for pin_id, owner_net in self._doorways.get(node, ()):
if owner_net != net_no and self._pending.get(pin_id, 0) > 0:
return self.access_cost
return 0.0
# --- passes -------------------------------------------------------------
def route(self) -> RouteResult:
@@ -814,14 +952,23 @@ class GridRouter:
self._conns = conns
if not conns:
return RouteResult(self.half_width)
if self.access_cost:
self._build_doorways(conns)
passes = self.max_passes if self.rip_up else 1
best_count = -1
best_snapshot: list[tuple[int, list, list]] | None = None
best_unrouted = len(conns)
# conns[i].index == i is what rip-up relies on, so shuffle a view
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 conns: # deterministic: connection creation order
for conn in order:
if conn.routed:
continue
if self._try_route(conn, conns):
@@ -845,11 +992,41 @@ class GridRouter:
path = self._search(conn, allow_ripup=True, conns=conns)
if path is None:
return False
for idx in sorted(self._crossed_owners(path, conn.net_no, conns)):
self._rip(conns[idx])
victims = [conns[i] for i in sorted(self._crossed_owners(path, conn.net_no, conns))]
if self.rollback:
return self._ripup_transaction(conn, path, victims, conns)
for victim in victims:
self._rip(victim)
self._commit(conn, path)
return True
def _ripup_transaction(self, conn, path, victims, conns) -> bool:
"""Route ``conn`` through ``victims``, reroute them at once in free
space, and undo it all if fewer connections end up routed."""
saved = [(v, v.path, v.a_end, v.b_end, v.rip_count) for v in victims]
own_rips = conn.rip_count
for victim in victims:
self._rip(victim)
self._commit(conn, path)
rerouted = 0
for victim in victims:
again = self._search(victim, allow_ripup=False, conns=conns)
if again is not None:
self._commit(victim, again)
rerouted += 1
if 1 + rerouted >= len(victims):
return True # a gain, or an even trade that may pay off later
for victim in victims:
if victim.routed:
self._rip(victim)
self._rip(conn)
conn.rip_count = own_rips
for victim, old_path, a_end, b_end, rips in saved:
victim.a_end, victim.b_end = a_end, b_end
self._commit(victim, old_path)
victim.rip_count = rips
return False
def _build_connections(self) -> list[_Connection]:
pins_by_net = self._pins_by_net()
zones_by_net: dict[int, list] = {}
@@ -1050,6 +1227,10 @@ def route_board(
edge_clearance: int | None = None,
resolution: int = 1,
heuristic_weight: float = 1.0,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
rollback: bool = True,
) -> RouteResult:
"""Convenience wrapper: build a :class:`GridRouter` and route the board."""
router = GridRouter(
@@ -1065,5 +1246,9 @@ def route_board(
edge_clearance=edge_clearance,
resolution=resolution,
heuristic_weight=heuristic_weight,
seed=seed,
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
)
return router.route()
+98
View File
@@ -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
+32 -1
View File
@@ -80,6 +80,10 @@ def route_dsn_board(
layers: list[int] | None = None,
rip_up: bool = True,
max_passes: int = 10,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
rollback: bool = True,
) -> 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
@@ -100,6 +104,10 @@ def route_dsn_board(
layers=layers,
rip_up=rip_up,
max_passes=max_passes,
seed=seed,
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
)
return result, scale, [layer.name for layer in dsn.layers]
@@ -140,11 +148,18 @@ def build_routing_result(
rip_up: bool = True,
max_passes: int = 10,
edge_clearance_mm: float | None = None,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
rollback: bool = True,
) -> 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,
edge_clearance_mm=edge_clearance_mm, resolution=resolution, seed=seed,
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
)
return _to_routing_result(route, dsn, scale, layer_names)
@@ -160,6 +175,10 @@ def route_dsn_board_exact(
rip_up: bool = True,
shove: bool = False,
diagonal: bool = False,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
rollback: bool = True,
) -> 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.
@@ -184,6 +203,10 @@ def route_dsn_board_exact(
rip_up=rip_up,
shove=shove,
diagonal=diagonal,
seed=seed,
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
)
return exact, scale, [layer.name for layer in dsn.layers]
@@ -199,6 +222,10 @@ def build_exact_routing_result(
rip_up: bool = True,
max_passes: int = 10,
edge_clearance_mm: float | None = None,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
rollback: bool = True,
) -> RoutingResult:
"""Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult."""
exact, scale, layer_names = route_dsn_board_exact(
@@ -211,6 +238,10 @@ def build_exact_routing_result(
edge_clearance_mm=edge_clearance_mm,
resolution=resolution,
heuristic_weight=heuristic_weight,
seed=seed,
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
)
return _to_routing_result(exact.result, dsn, scale, layer_names)
+34
View File
@@ -254,3 +254,37 @@ def test_via_size_comes_from_the_via_padstack():
from freeroute.route.pipeline import _via_half_board
assert _via_half_board(parse_dsn(_PAD_DSN), 10) == 3000 # 0.6 mm via, board units
# --- rule areas: overlap only ------------------------------------------------
_CHANNEL_DSN = """(pcb channel.dsn
(parser (string_quote ")) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 0 0 40000 0 40000 -30000 0 -30000 0 0))
(keepout "{name}top" (polygon signal 0 18000 0 22000 0 22000 -14750 18000 -14750))
(keepout "{name}bottom" (polygon signal 0 18000 -15250 22000 -15250 22000 -30000 18000 -30000))
(rule (width 250) (clearance 150)))
(placement (component P (place P1 5000 -15000 front 0) (place P2 35000 -15000 front 0)))
(library (image P (pin pad 1 0 0)) (padstack pad (shape (circle F.Cu 600)) (attach off)))
(network (net N (pins P1-1 P2-1))))"""
def test_a_keepout_named_rule_area_takes_no_clearance():
named = _areas(_CHANNEL_DSN.format(name="rule_area_"))
plain = _areas(_CHANNEL_DSN.format(name=""))
assert named and not any(a.clearance for a in named)
assert plain and all(a.clearance for a in plain)
def _route_channel(name):
from freeroute.route.pipeline import build_exact_routing_result
return build_exact_routing_result(parse_dsn(_CHANNEL_DSN.format(name=name)), resolution=2)
def test_a_trace_may_run_beside_a_rule_area_closer_than_clearance():
# the 0.5 mm channel fits a 0.25 mm trace, but not with 0.15 mm each side
assert _route_channel("rule_area_").unrouted == []
assert _route_channel("").unrouted != []
+34
View File
@@ -62,3 +62,37 @@ def test_has_violation_ignores_static_pad_pairs():
# a routed trace of net 3 within clearance of pad net 1 -> violation
tree.insert(shape(2, 3, 0, IntBox(11, 0, 20, 10), routed=True))
assert tree.has_violation(clearance=20) is not None
def test_a_rule_area_conflicts_only_on_overlap():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100), clearance=False))
near = IntBox(0, 0, 95, 100) # 5 short of the area, well inside clearance 50
over = IntBox(0, 0, 105, 100)
assert not tree.clearance_conflict(near, 7, 0, 50)
assert tree.clearance_conflict(over, 7, 0, 50)
def test_an_ordinary_keepout_still_needs_clearance():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100)))
assert tree.clearance_conflict(IntBox(0, 0, 95, 100), 7, 0, 50)
def test_the_routed_board_check_treats_a_rule_area_the_same_way():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100), clearance=False))
tree.insert(TreeShape(2, 7, frozenset({0}), IntBox(0, 0, 95, 100), routed=True))
assert tree.has_violation(50) is None
tree.insert(TreeShape(3, 7, frozenset({0}), IntBox(150, 0, 160, 100), routed=True))
assert tree.has_violation(50) is not None
def test_rule_area_overlap_sees_only_rule_areas():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100), clearance=False))
tree.insert(TreeShape(2, -1, frozenset({0}), IntBox(300, 0, 400, 100)))
assert tree.rule_area_overlap(IntBox(90, 40, 110, 60), 0)
assert not tree.rule_area_overlap(IntBox(290, 40, 310, 60), 0)
assert not tree.rule_area_overlap(IntBox(90, 40, 110, 60), 1)
+23
View File
@@ -156,3 +156,26 @@ def test_unplaced_component_has_no_coords():
place = board.placements[0].places[0]
assert place.name == "U9"
assert place.x is None and place.y is None
# --- string_quote ----------------------------------------------------------
_QUOTED = """(pcb q.dsn
(parser (string_quote {q}) (space_in_quoted_tokens on))
(resolution um 10) (unit um)
(structure (layer F.Cu (type signal)) (boundary (path pcb 0 0 0 1000 0 1000 1000 0 1000 0 0)))
(placement (component "SW1" (place SW1 100 100 front 0 (PN {pn}))))
(library)
(network)
)"""
def test_a_bare_apostrophe_is_an_atom_when_the_file_quotes_with_double_quotes():
# KiCad declares " and writes a keycap's part number as a bare '
board = parse_dsn(_QUOTED.format(q='"', pn="'"))
assert board.parser.string_quote == '"'
def test_the_declared_quote_character_is_the_one_that_opens_strings():
board = parse_dsn(_QUOTED.format(q="'", pn="'10k 1%'").replace('"SW1"', "'SW1'"))
assert board.parser.string_quote == "'"
+78
View File
@@ -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
+43
View File
@@ -0,0 +1,43 @@
"""--rollback: a rip-up that loses connections is undone, leaving no trace."""
from __future__ import annotations
from pathlib import Path
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn, route_dsn_board
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
def _routed_router(name):
dsn = parse_dsn((FIXTURES / name).read_text())
s = dsn.resolution.value
router = GridRouter(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s),
rollback=True,
)
return router, router.route()
@pytest.mark.parametrize("name", ["ripup_needed.dsn", "true_density.dsn", "kicad_routable.dsn"])
def test_occupancy_only_holds_routed_connections(name):
router, _ = _routed_router(name)
conns = router._conns
assert all(conns[i].routed for i in router._cell_owner.values())
assert all(conns[i].routed for i in router._via_owner.values())
assert all(conns[i].routed for claims in router._halo.values() for i in claims)
@pytest.mark.parametrize("name", ["ripup_needed.dsn", "true_density.dsn", "kicad_routable.dsn"])
def test_rollback_never_routes_fewer_than_plain_rip_up(name):
text = (FIXTURES / name).read_text()
plain, _, _ = route_dsn_board(parse_dsn(text), rollback=False)
undo, _, _ = route_dsn_board(parse_dsn(text))
assert undo.unrouted <= plain.unrouted
+13
View File
@@ -264,6 +264,19 @@ def test_larger_board_still_connects_its_multipin_nets():
assert len(result.routed_net_numbers) == multi_pin == 4
def test_a_seed_is_repeatable_and_does_change_the_order():
# The benchmark samples seeds to measure how much a result owes to the
# routing order. A seed that changed nothing would make every sample the
# same run and report no spread at all.
text = (FIXTURES / "kicad_routable.dsn").read_text()
default = _wire_key(route_dsn_board(parse_dsn(text))[0])
runs = {s: route_dsn_board(parse_dsn(text), seed=s)[0] for s in range(4)}
again = route_dsn_board(parse_dsn(text), seed=1)[0]
assert _wire_key(again) == _wire_key(runs[1])
assert any(_wire_key(r) != default for r in runs.values())
assert all(r.unrouted == 0 for r in runs.values())
# --- oracle parity ----------------------------------------------------------
+5
View File
@@ -154,6 +154,11 @@ def test_cli_supported_combinations_are_accepted(tmp_path):
"diagonal": ["--diagonal"],
"rip_up": ["--no-rip-up"],
"max_passes": ["-mp", "4"],
"seed": ["--seed", "3"],
"orders": ["--orders", "2"],
"promote": ["--promote-failures", "2"],
"access": ["--access-cost", "5"],
"rollback": ["--no-rollback"],
}
for engine, options in ENGINE_OPTIONS.items():
for option in options:
Generated
+1 -1
View File
@@ -128,7 +128,7 @@ wheels = [
[[package]]
name = "freeroute"
version = "0.1.0"
version = "2026.7.14"
source = { editable = "." }
[package.dev-dependencies]