feat(autoroute): add best-of-N support (attempts, targetNets, passSchedule) (#190)
The existing single-shot autoroute leaves 1-7 nets unrouted on dense
boards in my testing. Best-of-N drives that to 0 most of the time by
running Freerouting a few times with varied --max-passes and keeping
the SES with the best routing score.
New optional parameters (all backward-compatible):
attempts: int, default 1 (unchanged behaviour). When > 1, run
Freerouting N times and pick the highest-scoring SES.
targetNets: list of critical net names. An attempt that routes all
of them earns a 50,000-point scoring bonus.
passSchedule: list of --max-passes values to cycle through across
attempts. Default: [50, 60, 65, 70, 75, 80, 85, 90, 55,
95] (wraps if attempts > len). Ignored when attempts=1
(legacy maxPasses still used).
Scoring contract (pinned by tests):
score = nets_routed * 1000 + segments
if targetNets and all routed: score += 50_000
- +1 net always beats any segment-count delta (1000 pt step).
- Segments break ties at equal net count.
- Target bonus dominates net-count gains from unrelated nets.
## Implementation notes
- When attempts > 1, each attempt runs with `-mt 1` (single-thread
optimisation). Freerouting 2.x's multi-threaded optimiser is
documented to introduce clearance violations, so forcing
single-thread during scoring keeps the comparison apples-to-apples.
- One failed attempt does not abort the whole best-of-N run. The
failure is recorded in the response under attempts[] with ok=False,
and the remaining attempts compete for best. If every attempt fails
the response surfaces a clear error.
- The winning SES is preserved as <stem>_best.ses next to the
canonical <stem>.ses so the caller can inspect it after the run.
- Response shape:
attempts == 1: unchanged (no attempts/best_attempt fields)
attempts > 1: adds attempts[], best_attempt, best_score,
best_ses_path
## Attribution
Scoring approach and default pass schedule ported from
morningfire-pcb-automation
(https://github.com/NiNjA-CodE/morningfire-pcb-automation,
scripts/routing/freeroute_runner.py). Credited in the function
docstring, the TypeScript wrapper comment, the tool description (visible
to MCP clients), and the CHANGELOG entry.
The MCP version adds: cleaner per-attempt result reporting, automatic
single-thread optimisation, graceful degradation on partial failure,
and explicit validation that surfaces clean error payloads for invalid
attempts values.
## Tests
tests/test_autoroute_score.py 8 cases, scoring contract
tests/test_autoroute_best_of_n.py 6 cases, orchestration logic
All 14 passing. Tests are pure-Python: subprocess is mocked so the
suite runs in any environment (no Java / Freerouting / KiCad required).
- Single-attempt response shape unchanged
- Best-of-three picks the highest-scoring SES
- One nonzero exit attempt doesn't abort the run
- passSchedule wraps when attempts exceeds len
- targetNets bonus wins over higher raw net count
- attempts=0 rejected with clean error before DSN export
- +1 net (1000 pts) dominates any segment delta
- Segments tiebreak at equal net count
- Quoted net names in SES are normalised vs unquoted targets
TypeScript builds clean.
This commit is contained in:
@@ -9,9 +9,17 @@ import { z } from "zod";
|
||||
|
||||
export function registerFreeroutingTools(server: McpServer, callKicadScript: Function) {
|
||||
// Full autoroute: export DSN -> run Freerouting -> import SES
|
||||
//
|
||||
// Best-of-N support (the `attempts` / `targetNets` / `passSchedule`
|
||||
// parameters) is ported from morningfire-pcb-automation:
|
||||
// https://github.com/NiNjA-CodE/morningfire-pcb-automation
|
||||
// (scripts/routing/freeroute_runner.py — `score_ses` + run loop)
|
||||
// On dense boards a single attempt regularly leaves 1–7 nets unrouted;
|
||||
// cycling through a few `--max-passes` values typically drives the
|
||||
// unrouted count to zero.
|
||||
server.tool(
|
||||
"autoroute",
|
||||
"Run Freerouting autorouter on the current PCB. Exports to Specctra DSN, runs Freerouting CLI, and imports the routed SES result. Requires Java 11+ and freerouting.jar (see check_freerouting).",
|
||||
"Run Freerouting autorouter on the current PCB. Exports to Specctra DSN, runs Freerouting CLI, and imports the routed SES result. Requires Java 11+ and freerouting.jar (see check_freerouting). Set `attempts` > 1 to run best-of-N: Freerouting is invoked multiple times with varied `--max-passes`, each result is scored by (nets_routed * 1000 + segments, +50000 bonus when every `targetNets` entry routed), and the winning SES is imported. Single-attempt behaviour is unchanged when `attempts` is omitted.",
|
||||
{
|
||||
boardPath: z.string().optional().describe("Path to .kicad_pcb file (default: current board)"),
|
||||
freeroutingJar: z
|
||||
@@ -20,8 +28,30 @@ export function registerFreeroutingTools(server: McpServer, callKicadScript: Fun
|
||||
.describe(
|
||||
"Path to freerouting.jar (default: ~/.kicad-mcp/freerouting.jar or FREEROUTING_JAR env)",
|
||||
),
|
||||
maxPasses: z.number().optional().describe("Maximum routing passes (default: 20)"),
|
||||
timeout: z.number().optional().describe("Timeout in seconds (default: 300)"),
|
||||
maxPasses: z.number().optional().describe(
|
||||
"Maximum routing passes for single-attempt mode (default: 20). Ignored when `attempts` > 1; use `passSchedule` instead.",
|
||||
),
|
||||
timeout: z.number().optional().describe("Per-attempt timeout in seconds (default: 300)"),
|
||||
attempts: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of Freerouting runs to try (default: 1 — backward-compatible). When > 1, runs best-of-N: scores each attempt by routing completeness and keeps the SES with the highest score. Recommended: 3–5 for dense boards.",
|
||||
),
|
||||
targetNets: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional list of critical net names. An attempt that routes all of them earns a 50,000-point scoring bonus, breaking ties in favour of designs that include the must-have nets.",
|
||||
),
|
||||
passSchedule: z
|
||||
.array(z.number())
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-attempt `--max-passes` values to cycle through (default: [50, 60, 65, 70, 75, 80, 85, 90, 55, 95]). The list wraps if `attempts` exceeds its length.",
|
||||
),
|
||||
},
|
||||
async (args: any) => {
|
||||
const result = await callKicadScript("autoroute", args);
|
||||
|
||||
Reference in New Issue
Block a user