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.
308 lines
11 KiB
Python
308 lines
11 KiB
Python
"""freeroute command-line interface: Specctra DSN in, routed SES out.
|
|
|
|
Flag-compatible with the FreeRouting JAR (``-de`` / ``-do`` / ``-mp``) so it can
|
|
be dropped in wherever ``java -jar freerouting.jar`` was invoked, plus the
|
|
freeroute-specific engine selection (``--engine``) and its opt-in passes.
|
|
|
|
Not every engine supports every pass. The matrix below is the single source of
|
|
truth; an unsupported combination is an error, never a silent no-op::
|
|
|
|
engine --pack --shove --diagonal --no-rip-up -mp/--max-passes
|
|
grid no no no yes yes
|
|
exact no yes yes yes yes
|
|
room yes yes no no no
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
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,
|
|
build_routing_result,
|
|
)
|
|
from freeroute.ses import write_ses
|
|
|
|
#: which optional flags each engine actually implements. Anything not listed for
|
|
#: an engine is rejected by :func:`_validate` rather than quietly ignored.
|
|
ENGINE_OPTIONS: dict[str, frozenset[str]] = {
|
|
"grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}),
|
|
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}),
|
|
"room": frozenset({"shove", "pack"}),
|
|
}
|
|
|
|
#: option key -> the CLI spelling to name in an error message
|
|
_FLAG_NAMES = {
|
|
"pack": "--pack",
|
|
"shove": "--shove",
|
|
"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",
|
|
}
|
|
|
|
|
|
def _supported_by(option: str) -> str:
|
|
engines = [e for e, opts in ENGINE_OPTIONS.items() if option in opts]
|
|
return " or ".join(f"--engine {e}" for e in engines)
|
|
|
|
|
|
def _validate(parser: argparse.ArgumentParser, engine: str, requested: list[str]) -> None:
|
|
"""Reject any requested option the selected engine does not implement."""
|
|
supported = ENGINE_OPTIONS[engine]
|
|
for option in requested:
|
|
if option not in supported:
|
|
parser.error(
|
|
f"{_FLAG_NAMES[option]} is not supported by --engine {engine} "
|
|
f"(supported by: {_supported_by(option)})"
|
|
)
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
prog="freeroute",
|
|
description="Native PCB autorouter — route a Specctra DSN and write an SES session file.",
|
|
)
|
|
parser.add_argument("dsn", nargs="?", help="input Specctra .dsn file")
|
|
parser.add_argument(
|
|
"-de", "--design", dest="design", help="input .dsn (FreeRouting-compatible alias)"
|
|
)
|
|
parser.add_argument(
|
|
"-do", "-o", "--output", dest="output", help="output .ses file (default: stdout)"
|
|
)
|
|
parser.add_argument(
|
|
"-mp",
|
|
"--max-passes",
|
|
type=int,
|
|
default=None,
|
|
help="rip-up-and-retry pass bound (grid and exact engines; default 10)",
|
|
)
|
|
parser.add_argument(
|
|
"--engine",
|
|
choices=sorted(ENGINE_OPTIONS),
|
|
default="grid",
|
|
help=(
|
|
"routing track: grid (default; multi-layer rip-up maze, highest coverage), "
|
|
"exact (orthogonal, exact-geometry DRC-verified output), "
|
|
"room (continuous expansion rooms, routes off-grid channels)"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--pack",
|
|
action="store_true",
|
|
help="coordinated multi-trace channel packing (room engine only)",
|
|
)
|
|
parser.add_argument(
|
|
"--shove",
|
|
action="store_true",
|
|
help="shove committed traces aside to recover dropped nets (exact and room engines)",
|
|
)
|
|
parser.add_argument(
|
|
"--diagonal",
|
|
action="store_true",
|
|
help="45-degree recovery pass for dropped 2-pin nets (exact engine only)",
|
|
)
|
|
parser.add_argument(
|
|
"--no-rip-up",
|
|
action="store_true",
|
|
help="disable rip-up-and-retry (grid and exact engines)",
|
|
)
|
|
parser.add_argument("--layers", help="comma-separated signal layer indices to route on")
|
|
parser.add_argument(
|
|
"--grid-resolution",
|
|
type=int,
|
|
default=None,
|
|
metavar="K",
|
|
help="grid and exact engines: cells K times finer than trace + clearance; "
|
|
"default 2 for exact (reaches 0.5 mm pitch pins), 1 for grid",
|
|
)
|
|
parser.add_argument(
|
|
"--heuristic-weight",
|
|
type=float,
|
|
default=1.0,
|
|
metavar="W",
|
|
help="exact engine: weighted A* (W >= 1); larger is faster, routes at most "
|
|
"W times the shortest length (default 1)",
|
|
)
|
|
parser.add_argument(
|
|
"--edge-clearance",
|
|
type=float,
|
|
metavar="MM",
|
|
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
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv)
|
|
|
|
dsn_path = args.design or args.dsn
|
|
if not dsn_path:
|
|
parser.error("no input DSN (pass a path, or -de <file>)")
|
|
|
|
requested = [
|
|
option
|
|
for option, asked in (
|
|
("pack", args.pack),
|
|
("shove", args.shove),
|
|
("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
|
|
]
|
|
_validate(parser, args.engine, requested)
|
|
|
|
try:
|
|
layers = [int(x) for x in args.layers.split(",")] if args.layers else None
|
|
except ValueError:
|
|
parser.error(f"--layers must be comma-separated integers (got: {args.layers!r})")
|
|
|
|
try:
|
|
with open(dsn_path, encoding="utf-8") as f:
|
|
dsn_text = f.read()
|
|
except OSError as e:
|
|
print(f"freeroute: cannot read {dsn_path}: {e}", file=sys.stderr)
|
|
return 2
|
|
|
|
dsn = parse_dsn(dsn_text)
|
|
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":
|
|
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,
|
|
)
|
|
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]]
|
|
in_zone = [u for u in result.unrouted if u[3]]
|
|
if missing:
|
|
# the SES is still written; say plainly what it is missing
|
|
print(f"freeroute: {len(missing)} connection(s) not routed:", file=sys.stderr)
|
|
for net, a, b, _zone in missing:
|
|
print(f" {net}: {a} to {b}", file=sys.stderr)
|
|
if in_zone:
|
|
print(
|
|
f"freeroute: {len(in_zone)} more connection(s) left to a zone fill that may join them:",
|
|
file=sys.stderr,
|
|
)
|
|
for net, a, b, zone in in_zone:
|
|
print(f" {net}: {a} to {b} (both in zone {zone})", file=sys.stderr)
|
|
|
|
if args.output:
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
f.write(ses_text)
|
|
else:
|
|
sys.stdout.write(ses_text)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|