Files
kicad-mcp-server/python/commands/freerouting.py
NiNjA-CodE e2941631c1 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.
2026-05-18 23:03:38 -04:00

759 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Freerouting autoroute integration for KiCAD MCP Server.
Exports the board to Specctra DSN format, runs Freerouting CLI,
and imports the routed SES file back into the board.
Supports two execution modes:
- Direct: java -jar freerouting.jar (requires Java 21+)
- Docker: docker run eclipse-temurin:21-jre (requires Docker)
"""
import logging
import os
import re
import shutil
import subprocess
import time
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
logger = logging.getLogger("kicad_interface")
# Default Freerouting JAR location
DEFAULT_FREEROUTING_JAR = os.environ.get(
"FREEROUTING_JAR",
os.path.join(os.path.expanduser("~"), ".kicad-mcp", "freerouting.jar"),
)
DOCKER_IMAGE = "eclipse-temurin:21-jre"
# Default schedule of `-mp` (max passes) values used when ``attempts`` > 1.
# Cycles through a range that empirically produces enough variation between
# runs to surface a better result than any single fixed value. Ported from
# morningfire-pcb-automation/scripts/routing/freeroute_runner.py.
DEFAULT_PASS_SCHEDULE = [50, 60, 65, 70, 75, 80, 85, 90, 55, 95]
def _find_java() -> Optional[str]:
"""Find java executable on the system."""
java = shutil.which("java")
if java:
return java
for candidate in [
"/usr/bin/java",
"/usr/local/bin/java",
os.path.expandvars("$JAVA_HOME/bin/java"),
]:
if os.path.isfile(candidate):
return candidate
return None
def _find_docker() -> Optional[str]:
"""Find docker executable on the system."""
return shutil.which("docker") or shutil.which("podman")
def _docker_available() -> bool:
"""Check if Docker/Podman is available and running."""
docker = _find_docker()
if not docker:
return False
try:
proc = subprocess.run(
[docker, "info"],
capture_output=True,
timeout=10,
)
return proc.returncode == 0
except Exception:
return False
def _java_version_ok(java_exe: str) -> bool:
"""Check if local Java is version 21+."""
try:
proc = subprocess.run(
[java_exe, "-version"],
capture_output=True,
text=True,
timeout=10,
)
output = proc.stderr or proc.stdout
# Parse version like: openjdk version "17.0.18"
for line in output.split("\n"):
if "version" in line:
ver = line.split('"')[1] if '"' in line else ""
major = int(ver.split(".")[0])
return major >= 21
except Exception:
pass
return False
def _build_freerouting_cmd(
jar_path: str,
dsn_path: str,
ses_path: str,
passes: int,
use_docker: bool,
single_thread: bool = False,
) -> List[str]:
"""Build the command to run Freerouting.
``single_thread`` forces ``-mt 1`` (single-threaded optimisation).
Freerouting 2.x's multi-threaded optimiser is documented to produce
clearance violations in some cases (the runtime even prints a warning);
best-of-N callers should pass this so each attempt's score reflects a
valid routed board, not an artefact of MT optimisation.
"""
extra = ["-mt", "1"] if single_thread else []
if use_docker:
docker_exe = _find_docker()
if docker_exe is None:
raise RuntimeError("Docker/Podman executable not found")
board_dir = os.path.dirname(dsn_path)
dsn_name = os.path.basename(dsn_path)
ses_name = os.path.basename(ses_path)
jar_name = os.path.basename(jar_path)
return [
docker_exe,
"run",
"--rm",
"-v",
f"{jar_path}:/app/{jar_name}:ro",
"-v",
f"{board_dir}:/work",
DOCKER_IMAGE,
"java",
"-jar",
f"/app/{jar_name}",
"-de",
f"/work/{dsn_name}",
"-do",
f"/work/{ses_name}",
"-mp",
str(passes),
*extra,
]
else:
java_exe = _find_java()
if java_exe is None:
raise RuntimeError("Java executable not found")
return [
java_exe,
"-jar",
jar_path,
"-de",
dsn_path,
"-do",
ses_path,
"-mp",
str(passes),
*extra,
]
# ---------------------------------------------------------------------------
# Best-of-N scoring helpers (ported from morningfire-pcb-automation)
# ---------------------------------------------------------------------------
#
# Approach lifted from
# https://github.com/NiNjA-CodE/morningfire-pcb-automation
# scripts/routing/freeroute_runner.py::score_ses
#
# Single-shot Freerouting on dense boards routinely leaves 17 nets
# unrouted. Re-running with varied --max-passes values surfaces a better
# solution most of the time; the scoring function below picks the best
# SES across attempts.
# ---------------------------------------------------------------------------
_SES_NET_RE = re.compile(r'\(net\s+(\S+)\s*\n\s*\(wire')
def _score_ses(ses_text: str, target_nets: Iterable[str]) -> Dict[str, Any]:
"""Score a Specctra SES file by routing completeness.
Score = (nets_routed * 1000) + segments + 50000_if_all_targets_routed
The ``nets_routed * 1000`` term dominates segment count so an attempt
that routes one more net always beats an attempt with marginally more
segments. The target-net bonus is huge so any attempt that routes all
critical nets wins, regardless of segment count.
Returns: ``{"score": int, "nets": int, "segments": int, "vias": int,
"targets_found": [...], "targets_missing": [...]}``
"""
nets = set(_SES_NET_RE.findall(ses_text))
# Strip wrapping quotes if Freerouting emits them.
clean_nets = {n.strip('"') for n in nets}
segments = len(re.findall(r'\(wire', ses_text))
vias = len(re.findall(r'\(via ', ses_text))
targets = set(target_nets) if target_nets else set()
found = sorted(targets & clean_nets)
missing = sorted(targets - clean_nets)
score = len(clean_nets) * 1000 + segments
if targets and not missing:
score += 50_000
return {
"score": score,
"nets": len(clean_nets),
"segments": segments,
"vias": vias,
"targets_found": found,
"targets_missing": missing,
}
class FreeroutingCommands:
"""Handles Freerouting autoroute operations."""
def __init__(self, board: Any = None) -> None:
self.board = board
def _resolve_execution_mode(self, jar_path: str) -> Dict[str, Any]:
"""Determine how to run Freerouting: direct or docker.
Returns dict with 'mode', 'use_docker', or 'error'.
"""
java_exe = _find_java()
if java_exe and _java_version_ok(java_exe):
return {"mode": "direct", "use_docker": False}
if _docker_available():
return {"mode": "docker", "use_docker": True}
if java_exe:
return {
"mode": "error",
"error": (
f"Java found at {java_exe} but version < 21. "
"Freerouting 2.x requires Java 21+. "
"Install Java 21+ or Docker."
),
}
return {
"mode": "error",
"error": (
"Neither Java 21+ nor Docker found. " "Install one of them to use Freerouting."
),
}
def autoroute(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Run Freerouting autorouter on the current board.
Single-attempt flow (default):
1. Export board to Specctra DSN
2. Run Freerouting CLI on DSN -> SES (one pass with ``maxPasses``)
3. Import SES back into the board
4. Save the board
Best-of-N flow (``attempts > 1``):
1. Export DSN once
2. Run Freerouting ``attempts`` times, varying ``--max-passes``
per the ``passSchedule`` (defaults to a built-in schedule
of 10 spread-out values).
3. Score each SES by (nets_routed * 1000) + segments, plus a
50,000-point bonus when every ``targetNets`` entry routed.
4. Keep the highest-scoring SES; import that one into the board.
Single-attempt behaviour is unchanged when ``attempts`` is omitted
or set to 1, so existing callers do not need updates.
The best-of-N scoring approach is ported from
morningfire-pcb-automation
(https://github.com/NiNjA-CodE/morningfire-pcb-automation,
scripts/routing/freeroute_runner.py). On dense boards a single
run regularly leaves 17 nets unrouted; cycling through a few
``-mp`` values typically gets the count to zero.
"""
try:
import pcbnew
except ImportError:
return {
"success": False,
"message": "pcbnew not available",
"errorDetails": "KiCAD Python API is required",
}
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
board_path = params.get("boardPath")
if not board_path:
board_path = self.board.GetFileName()
if not board_path:
return {
"success": False,
"message": "No board file path available",
"errorDetails": ("Provide boardPath or open a project first"),
}
jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR)
timeout = params.get("timeout", 300)
passes = params.get("maxPasses", 20)
# Best-of-N parameters
attempts_raw = params.get("attempts", 1)
try:
attempts = int(attempts_raw) if attempts_raw is not None else 1
except (TypeError, ValueError):
return {
"success": False,
"message": "Invalid attempts value",
"errorDetails": f"attempts must be a positive integer; got {attempts_raw!r}",
}
if attempts < 1:
return {
"success": False,
"message": "Invalid attempts value",
"errorDetails": "attempts must be >= 1",
}
target_nets = list(params.get("targetNets") or [])
pass_schedule = list(params.get("passSchedule") or DEFAULT_PASS_SCHEDULE)
if not pass_schedule:
pass_schedule = [passes]
# Validate Freerouting JAR
if not os.path.isfile(jar_path):
return {
"success": False,
"message": "Freerouting JAR not found",
"errorDetails": (
f"Expected at: {jar_path}. Download from "
"https://github.com/freerouting/freerouting/"
"releases or set FREEROUTING_JAR env var."
),
}
# Determine execution mode
exec_mode = self._resolve_execution_mode(jar_path)
if exec_mode["mode"] == "error":
return {
"success": False,
"message": "No suitable Java runtime",
"errorDetails": exec_mode["error"],
}
use_docker = exec_mode["use_docker"]
# Set up file paths
board_dir = os.path.dirname(board_path)
board_stem = Path(board_path).stem
dsn_path = os.path.join(board_dir, f"{board_stem}.dsn")
ses_path = os.path.join(board_dir, f"{board_stem}.ses")
best_ses_path = os.path.join(board_dir, f"{board_stem}_best.ses")
# Step 1: Export DSN (once, regardless of attempt count)
logger.info(f"Exporting DSN to {dsn_path}")
try:
result = pcbnew.ExportSpecctraDSN(self.board, dsn_path)
if result is not True and result != 0:
return {
"success": False,
"message": "DSN export failed",
"errorDetails": (f"ExportSpecctraDSN returned: {result}"),
}
except Exception as e:
return {
"success": False,
"message": "DSN export failed",
"errorDetails": str(e),
}
if not os.path.isfile(dsn_path):
return {
"success": False,
"message": "DSN file was not created",
"errorDetails": f"Expected at: {dsn_path}",
}
dsn_size = os.path.getsize(dsn_path)
logger.info(f"DSN exported: {dsn_size} bytes")
# Step 2: Run Freerouting (single or multiple attempts)
mode_label = "docker" if use_docker else "direct"
total_start = time.time()
attempt_results: List[Dict[str, Any]] = []
best_score = -1
best_attempt_idx = -1
best_proc_stdout = ""
# If only one attempt, use the legacy maxPasses value (preserves
# exact backward-compatible behaviour). Otherwise cycle through
# passSchedule. Always run single-threaded when scoring multiple
# attempts so the optimiser doesn't introduce clearance violations
# that would distort the comparison.
for idx in range(attempts):
if attempts == 1:
attempt_passes = passes
single_thread = False
else:
attempt_passes = pass_schedule[idx % len(pass_schedule)]
single_thread = True
cmd = _build_freerouting_cmd(
jar_path, dsn_path, ses_path, attempt_passes, use_docker,
single_thread=single_thread,
)
logger.info(
f"Freerouting attempt {idx + 1}/{attempts} "
f"(mp={attempt_passes}, mode={mode_label})"
)
attempt_start = time.time()
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
cwd=board_dir,
)
attempt_elapsed = round(time.time() - attempt_start, 1)
except subprocess.TimeoutExpired:
return {
"success": False,
"message": f"Freerouting timed out after {timeout}s",
"errorDetails": "Increase timeout or reduce board complexity",
"attempts_completed": idx,
}
except Exception as e:
return {
"success": False,
"message": "Failed to run Freerouting",
"errorDetails": str(e),
"attempts_completed": idx,
}
if proc.returncode != 0:
# Don't abort the whole best-of-N just because one attempt
# exits nonzero — record it and move on.
attempt_results.append({
"attempt": idx + 1,
"max_passes": attempt_passes,
"elapsed_seconds": attempt_elapsed,
"ok": False,
"exit_code": proc.returncode,
"stderr": (proc.stderr or "")[:200],
})
if attempts == 1:
return {
"success": False,
"message": f"Freerouting exited with code {proc.returncode}",
"errorDetails": proc.stderr or proc.stdout,
"elapsed_seconds": attempt_elapsed,
"mode": mode_label,
}
continue
if not os.path.isfile(ses_path):
attempt_results.append({
"attempt": idx + 1,
"max_passes": attempt_passes,
"elapsed_seconds": attempt_elapsed,
"ok": False,
"error": "no SES produced",
})
if attempts == 1:
return {
"success": False,
"message": "Freerouting did not produce SES output",
"errorDetails": (
f"Expected at: {ses_path}. Stdout: {proc.stdout[:500]}"
),
"elapsed_seconds": attempt_elapsed,
}
continue
# Score this attempt
with open(ses_path, "r", encoding="utf-8", errors="replace") as fh:
ses_text = fh.read()
score_info = _score_ses(ses_text, target_nets)
score = score_info["score"]
attempt_results.append({
"attempt": idx + 1,
"max_passes": attempt_passes,
"elapsed_seconds": attempt_elapsed,
"ok": True,
**score_info,
})
logger.info(
f" attempt {idx + 1}: score={score} "
f"({score_info['nets']} nets, {score_info['segments']} segs, "
f"{score_info['vias']} vias)"
)
if score > best_score:
best_score = score
best_attempt_idx = idx
best_proc_stdout = proc.stdout or ""
# Snapshot the SES that produced this score so later
# attempts (which overwrite ses_path) don't clobber it.
shutil.copy2(ses_path, best_ses_path)
elapsed = round(time.time() - total_start, 1)
if best_attempt_idx == -1:
return {
"success": False,
"message": "All Freerouting attempts failed",
"errorDetails": "No attempt produced a usable SES file",
"elapsed_seconds": elapsed,
"attempts": attempt_results,
}
# Restore the winning SES as the canonical output file
if attempts > 1:
shutil.copy2(best_ses_path, ses_path)
ses_size = os.path.getsize(ses_path)
logger.info(
f"Best SES: attempt {best_attempt_idx + 1}, score={best_score}, "
f"{ses_size} bytes (total {elapsed}s)"
)
# Step 3: Import the winning SES
logger.info(f"Importing SES from {ses_path}")
try:
result = pcbnew.ImportSpecctraSES(self.board, ses_path)
if result is not True and result != 0:
return {
"success": False,
"message": "SES import failed",
"errorDetails": f"ImportSpecctraSES returned: {result}",
"elapsed_seconds": elapsed,
"attempts": attempt_results,
}
except Exception as e:
return {
"success": False,
"message": "SES import failed",
"errorDetails": str(e),
"elapsed_seconds": elapsed,
"attempts": attempt_results,
}
# Step 4: Save board
try:
self.board.Save(board_path)
except Exception as e:
logger.warning(f"Board save after autoroute failed: {e}")
# Collect stats
tracks = self.board.GetTracks()
track_count = 0
via_count = 0
for t in tracks:
if t.GetClass() == "PCB_VIA":
via_count += 1
else:
track_count += 1
response: Dict[str, Any] = {
"success": True,
"message": f"Autoroute completed in {elapsed}s",
"mode": mode_label,
"dsn_path": dsn_path,
"ses_path": ses_path,
"elapsed_seconds": elapsed,
"board_stats": {
"tracks": track_count,
"vias": via_count,
},
"freerouting_stdout": best_proc_stdout[:1000],
}
if attempts > 1:
response["attempts"] = attempt_results
response["best_attempt"] = best_attempt_idx + 1
response["best_score"] = best_score
response["best_ses_path"] = best_ses_path
return response
def export_dsn(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Export the board to Specctra DSN format only."""
try:
import pcbnew
except ImportError:
return {
"success": False,
"message": "pcbnew not available",
"errorDetails": "KiCAD Python API is required",
}
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
board_path = params.get("boardPath") or self.board.GetFileName()
output_path = params.get("outputPath")
if not output_path:
if board_path:
output_path = os.path.splitext(board_path)[0] + ".dsn"
else:
return {
"success": False,
"message": "No output path",
"errorDetails": ("Provide outputPath or have a board open"),
}
try:
result = pcbnew.ExportSpecctraDSN(self.board, output_path)
if result is not True and result != 0:
return {
"success": False,
"message": "DSN export failed",
"errorDetails": (f"ExportSpecctraDSN returned: {result}"),
}
except Exception as e:
return {
"success": False,
"message": "DSN export failed",
"errorDetails": str(e),
}
file_size = os.path.getsize(output_path) if os.path.isfile(output_path) else 0
return {
"success": True,
"message": f"Exported DSN to {output_path}",
"path": output_path,
"size_bytes": file_size,
}
def import_ses(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Import a Specctra SES file into the board."""
try:
import pcbnew
except ImportError:
return {
"success": False,
"message": "pcbnew not available",
"errorDetails": "KiCAD Python API is required",
}
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
ses_path = params.get("sesPath")
if not ses_path:
return {
"success": False,
"message": "Missing sesPath parameter",
"errorDetails": ("Provide the path to the .ses file"),
}
if not os.path.isfile(ses_path):
return {
"success": False,
"message": "SES file not found",
"errorDetails": f"File not found: {ses_path}",
}
try:
result = pcbnew.ImportSpecctraSES(self.board, ses_path)
if result is not True and result != 0:
return {
"success": False,
"message": "SES import failed",
"errorDetails": (f"ImportSpecctraSES returned: {result}"),
}
except Exception as e:
return {
"success": False,
"message": "SES import failed",
"errorDetails": str(e),
}
board_path = params.get("boardPath") or self.board.GetFileName()
if board_path:
try:
self.board.Save(board_path)
except Exception as e:
logger.warning(f"Board save after SES import failed: {e}")
tracks = self.board.GetTracks()
track_count = sum(1 for t in tracks if t.GetClass() != "PCB_VIA")
via_count = sum(1 for t in tracks if t.GetClass() == "PCB_VIA")
return {
"success": True,
"message": f"Imported SES from {ses_path}",
"board_stats": {
"tracks": track_count,
"vias": via_count,
},
}
def check_freerouting(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Check if Freerouting and Java/Docker are available."""
jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR)
# Check local Java
java_exe = _find_java()
java_version = None
java_21_ok = False
if java_exe:
try:
proc = subprocess.run(
[java_exe, "-version"],
capture_output=True,
text=True,
timeout=10,
)
java_version = (proc.stderr or proc.stdout).strip().split("\n")[0]
java_21_ok = _java_version_ok(java_exe)
except Exception:
pass
# Check Docker/Podman
docker_exe = _find_docker()
has_docker = _docker_available()
jar_exists = os.path.isfile(jar_path)
ready = jar_exists and (java_21_ok or has_docker)
mode = "none"
if java_21_ok:
mode = "direct"
elif has_docker:
mode = "docker"
return {
"success": True,
"message": "Freerouting dependency check",
"java": {
"found": java_exe is not None,
"path": java_exe,
"version": java_version,
"java_21_ok": java_21_ok,
},
"docker": {
"available": has_docker,
"path": docker_exe,
"image": DOCKER_IMAGE,
},
"freerouting": {
"jar_found": jar_exists,
"jar_path": jar_path,
},
"execution_mode": mode,
"ready": ready,
}