Expose the exact and room engines in the CLI
The grid MVP was the only track reachable from the command line, so the
continuous room engine, channel packing, shove, and 45-degree routing were
unusable by anything shelling out to freeroute.
Add --engine {grid,exact,room} (default grid, unchanged behaviour) plus
--pack / --shove / --diagonal, and honour -mp on the tracks that implement
rip-up passes. Not every engine supports every pass; ENGINE_OPTIONS is the
matrix and an unsupported combination exits 2 with a message naming the
engines that do support the flag, rather than silently no-opping.
This commit is contained in:
parent
accec686f8
commit
758ba1fbe3
@ -1,7 +1,16 @@
|
||||
"""freeroute command-line interface: Specctra DSN in, routed SES out.
|
||||
|
||||
Flag-compatible with the FreeRouting JAR (``-de`` / ``-do``) so it can be
|
||||
dropped in wherever ``java -jar freerouting.jar`` was invoked.
|
||||
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
|
||||
@ -10,11 +19,48 @@ import argparse
|
||||
import sys
|
||||
|
||||
from freeroute.dsn.reader import parse_dsn
|
||||
from freeroute.route.pipeline import build_routing_result
|
||||
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"}),
|
||||
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes"}),
|
||||
"room": frozenset({"shove", "pack"}),
|
||||
}
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
#: 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",
|
||||
}
|
||||
|
||||
|
||||
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.",
|
||||
@ -31,17 +77,67 @@ def main(argv: list[str] | None = None) -> int:
|
||||
"--max-passes",
|
||||
type=int,
|
||||
default=None,
|
||||
help="accepted for FreeRouting CLI compatibility (advisory; freeroute self-bounds passes)",
|
||||
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("--no-rip-up", action="store_true", help="disable rip-up-and-retry")
|
||||
parser.add_argument("--layers", help="comma-separated signal layer indices to route on")
|
||||
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),
|
||||
)
|
||||
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:
|
||||
@ -51,7 +147,23 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return 2
|
||||
|
||||
dsn = parse_dsn(dsn_text)
|
||||
result = build_routing_result(dsn, layers=layers, rip_up=not args.no_rip_up)
|
||||
rip_up = not args.no_rip_up
|
||||
max_passes = args.max_passes if args.max_passes is not None else 10
|
||||
|
||||
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,
|
||||
)
|
||||
elif args.engine == "room":
|
||||
result = build_rooms_routing_result(dsn, layers=layers, shove=args.shove, pack=args.pack)
|
||||
else:
|
||||
result = build_routing_result(dsn, layers=layers, rip_up=rip_up, max_passes=max_passes)
|
||||
|
||||
ses_text = write_ses(dsn, result)
|
||||
|
||||
if args.output:
|
||||
|
||||
@ -97,10 +97,16 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str])
|
||||
|
||||
|
||||
def build_routing_result(
|
||||
dsn: DsnBoard, *, layers: list[int] | None = None, rip_up: bool = True
|
||||
dsn: DsnBoard,
|
||||
*,
|
||||
layers: list[int] | None = None,
|
||||
rip_up: bool = True,
|
||||
max_passes: int = 10,
|
||||
) -> 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)
|
||||
route, scale, layer_names = route_dsn_board(
|
||||
dsn, layers=layers, rip_up=rip_up, max_passes=max_passes
|
||||
)
|
||||
return _to_routing_result(route, dsn, scale, layer_names)
|
||||
|
||||
|
||||
@ -136,11 +142,22 @@ def route_dsn_board_exact(
|
||||
|
||||
|
||||
def build_exact_routing_result(
|
||||
dsn: DsnBoard, *, layers: list[int] | None = None, shove: bool = False, diagonal: bool = False
|
||||
dsn: DsnBoard,
|
||||
*,
|
||||
layers: list[int] | None = None,
|
||||
shove: bool = False,
|
||||
diagonal: bool = False,
|
||||
rip_up: bool = True,
|
||||
max_passes: int = 10,
|
||||
) -> RoutingResult:
|
||||
"""Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult."""
|
||||
exact, scale, layer_names = route_dsn_board_exact(
|
||||
dsn, layers=layers, shove=shove, diagonal=diagonal
|
||||
dsn,
|
||||
layers=layers,
|
||||
shove=shove,
|
||||
diagonal=diagonal,
|
||||
rip_up=rip_up,
|
||||
max_passes=max_passes,
|
||||
)
|
||||
return _to_routing_result(exact.result, dsn, scale, layer_names)
|
||||
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
"""Tests for the freeroute CLI (Specctra DSN in, SES out)."""
|
||||
"""Tests for the freeroute CLI (Specctra DSN in, SES out).
|
||||
|
||||
Covers the engine selection contract: every engine must route a fixture from the
|
||||
command line, and every option the selected engine does not implement must fail
|
||||
loudly rather than being silently dropped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -6,9 +11,13 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from freeroute.cli import main
|
||||
from freeroute.cli import ENGINE_OPTIONS, main
|
||||
|
||||
FIXTURE = Path(__file__).parent / "dsn" / "fixtures" / "simple_2net.dsn"
|
||||
FIXTURES = Path(__file__).parent / "dsn" / "fixtures"
|
||||
FIXTURE = FIXTURES / "simple_2net.dsn"
|
||||
CHANNEL_PACK = FIXTURES / "channel_pack.dsn"
|
||||
SHOVE_NEEDED = FIXTURES / "shove_needed.dsn"
|
||||
DIAGONAL_WIN = FIXTURES / "diagonal_win.dsn"
|
||||
|
||||
|
||||
def test_cli_jar_compatible_flags_write_ses(tmp_path):
|
||||
@ -35,3 +44,114 @@ def test_cli_unreadable_input_returns_2(capsys):
|
||||
rc = main(["/nonexistent/board.dsn"])
|
||||
assert rc == 2
|
||||
assert "cannot read" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_cli_bad_layers_errors(capsys):
|
||||
with pytest.raises(SystemExit):
|
||||
main([str(FIXTURE), "--layers", "top,bottom"])
|
||||
assert "--layers must be comma-separated integers" in capsys.readouterr().err
|
||||
|
||||
|
||||
# --- engine selection -------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("engine", sorted(ENGINE_OPTIONS))
|
||||
def test_cli_every_engine_routes_a_fixture(engine, tmp_path):
|
||||
out = tmp_path / f"{engine}.ses"
|
||||
rc = main([str(FIXTURE), "--engine", engine, "-do", str(out)])
|
||||
assert rc == 0
|
||||
text = out.read_text()
|
||||
assert text.startswith("(session")
|
||||
assert "(wire" in text
|
||||
|
||||
|
||||
def test_cli_default_engine_is_grid(tmp_path):
|
||||
"""The default must stay grid — the drop-in JAR replacement behaviour."""
|
||||
default = tmp_path / "default.ses"
|
||||
grid = tmp_path / "grid.ses"
|
||||
assert main([str(FIXTURE), "-do", str(default)]) == 0
|
||||
assert main([str(FIXTURE), "--engine", "grid", "-do", str(grid)]) == 0
|
||||
assert default.read_text() == grid.read_text()
|
||||
|
||||
|
||||
def test_cli_room_pack_routes(tmp_path):
|
||||
out = tmp_path / "pack.ses"
|
||||
rc = main([str(CHANNEL_PACK), "--engine", "room", "--pack", "-do", str(out)])
|
||||
assert rc == 0
|
||||
assert "(wire" in out.read_text()
|
||||
|
||||
|
||||
def test_cli_room_shove_routes(tmp_path):
|
||||
out = tmp_path / "shove.ses"
|
||||
rc = main([str(SHOVE_NEEDED), "--engine", "room", "--shove", "-do", str(out)])
|
||||
assert rc == 0
|
||||
assert "(wire" in out.read_text()
|
||||
|
||||
|
||||
def test_cli_exact_shove_routes(tmp_path):
|
||||
out = tmp_path / "shove.ses"
|
||||
rc = main([str(SHOVE_NEEDED), "--engine", "exact", "--shove", "-do", str(out)])
|
||||
assert rc == 0
|
||||
assert "(wire" in out.read_text()
|
||||
|
||||
|
||||
def test_cli_exact_diagonal_routes(tmp_path):
|
||||
out = tmp_path / "diag.ses"
|
||||
rc = main([str(DIAGONAL_WIN), "--engine", "exact", "--diagonal", "-do", str(out)])
|
||||
assert rc == 0
|
||||
assert "(wire" in out.read_text()
|
||||
|
||||
|
||||
def test_cli_max_passes_is_honoured_not_advisory(tmp_path):
|
||||
out = tmp_path / "mp.ses"
|
||||
rc = main([str(FIXTURE), "--engine", "exact", "-mp", "3", "-do", str(out)])
|
||||
assert rc == 0
|
||||
assert "(wire" in out.read_text()
|
||||
|
||||
|
||||
# --- unsupported combinations must error, never be silently ignored ---------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("argv", "flag"),
|
||||
[
|
||||
(["--engine", "grid", "--pack"], "--pack"),
|
||||
(["--engine", "grid", "--shove"], "--shove"),
|
||||
(["--engine", "grid", "--diagonal"], "--diagonal"),
|
||||
(["--engine", "exact", "--pack"], "--pack"),
|
||||
(["--engine", "room", "--diagonal"], "--diagonal"),
|
||||
(["--engine", "room", "--no-rip-up"], "--no-rip-up"),
|
||||
(["--engine", "room", "-mp", "5"], "-mp/--max-passes"),
|
||||
],
|
||||
)
|
||||
def test_cli_unsupported_option_errors(argv, flag, capsys):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main([str(FIXTURE), *argv])
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert flag in err
|
||||
assert "not supported by --engine" in err
|
||||
assert "supported by:" in err
|
||||
|
||||
|
||||
def test_cli_unknown_engine_errors(capsys):
|
||||
with pytest.raises(SystemExit):
|
||||
main([str(FIXTURE), "--engine", "quantum"])
|
||||
assert "invalid choice" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_cli_supported_combinations_are_accepted(tmp_path):
|
||||
"""Every (engine, flag) pair the matrix claims to support must actually run."""
|
||||
argv_for = {
|
||||
"pack": ["--pack"],
|
||||
"shove": ["--shove"],
|
||||
"diagonal": ["--diagonal"],
|
||||
"rip_up": ["--no-rip-up"],
|
||||
"max_passes": ["-mp", "4"],
|
||||
}
|
||||
for engine, options in ENGINE_OPTIONS.items():
|
||||
for option in options:
|
||||
out = tmp_path / f"{engine}-{option}.ses"
|
||||
rc = main([str(FIXTURE), "--engine", engine, *argv_for[option], "-do", str(out)])
|
||||
assert rc == 0, f"{engine} + {option} failed"
|
||||
assert out.read_text().startswith("(session")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user