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.
123 lines
4.5 KiB
Python
123 lines
4.5 KiB
Python
"""Tests for the best-of-N scoring in autoroute (`_score_ses`).
|
|
|
|
Best-of-N support is ported from morningfire-pcb-automation
|
|
(https://github.com/NiNjA-CodE/morningfire-pcb-automation,
|
|
scripts/routing/freeroute_runner.py::score_ses). These tests pin the
|
|
scoring contract so future changes don't silently shift the ranking.
|
|
"""
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
PYTHON_DIR = Path(__file__).parent.parent / "python"
|
|
sys.path.insert(0, str(PYTHON_DIR))
|
|
|
|
from commands.freerouting import _score_ses # noqa: E402
|
|
|
|
|
|
def _ses(nets, segments, vias=0):
|
|
"""Build a minimal SES text with the given (net, segment_count) shape.
|
|
|
|
The parser only looks at `(net NAME\n (wire` occurrences and `(wire`
|
|
/ `(via ` substrings, so a synthetic fixture is enough.
|
|
"""
|
|
chunks = []
|
|
for net, seg_count in nets:
|
|
chunks.append(f'(net "{net}"\n')
|
|
for _ in range(seg_count):
|
|
chunks.append(" (wire (path F.Cu 200 0 0 1 1))\n")
|
|
chunks.append(")\n")
|
|
# Tack on extra wires not tied to a (net ...) header — counted as
|
|
# segments but not as nets.
|
|
for _ in range(segments - sum(s for _, s in nets)):
|
|
chunks.append(" (wire (path F.Cu 200 0 0 1 1))\n")
|
|
for _ in range(vias):
|
|
chunks.append(' (via "Via[0-1]_600:300_um" 100 200)\n')
|
|
return "".join(chunks)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_empty_ses_scores_zero():
|
|
r = _score_ses("", [])
|
|
assert r["score"] == 0
|
|
assert r["nets"] == 0
|
|
assert r["segments"] == 0
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_more_nets_always_beats_more_segments():
|
|
"""A single extra net (1000 pts) must beat any reasonable seg count delta."""
|
|
a = _score_ses(_ses([("N1", 50), ("N2", 50), ("N3", 50)], segments=150), [])
|
|
b = _score_ses(_ses([("N1", 50), ("N2", 50)], segments=999), [])
|
|
assert a["nets"] == 3 and b["nets"] == 2
|
|
assert a["score"] > b["score"], "+1 net (1000 pts) must dominate segment delta"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_segments_break_ties_when_net_counts_equal():
|
|
a = _score_ses(_ses([("N1", 10), ("N2", 10)], segments=20), [])
|
|
b = _score_ses(_ses([("N1", 30), ("N2", 30)], segments=60), [])
|
|
assert a["nets"] == b["nets"] == 2
|
|
assert b["score"] > a["score"], "more segments breaks tie when net counts equal"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_target_bonus_dominates_when_all_targets_routed():
|
|
"""The 50,000-point bonus must outweigh marginal nets/segments differences."""
|
|
# `with_targets` has the targets but fewer overall nets.
|
|
with_targets = _score_ses(
|
|
_ses([("CRITICAL_A", 10), ("CRITICAL_B", 10)], segments=20),
|
|
target_nets=["CRITICAL_A", "CRITICAL_B"],
|
|
)
|
|
# `without_targets` has more nets but is missing one target.
|
|
without_targets = _score_ses(
|
|
_ses([
|
|
("CRITICAL_A", 10),
|
|
("OTHER1", 10), ("OTHER2", 10), ("OTHER3", 10),
|
|
("OTHER4", 10), ("OTHER5", 10), ("OTHER6", 10),
|
|
], segments=70),
|
|
target_nets=["CRITICAL_A", "CRITICAL_B"],
|
|
)
|
|
assert without_targets["targets_missing"] == ["CRITICAL_B"]
|
|
assert with_targets["targets_missing"] == []
|
|
assert with_targets["score"] > without_targets["score"], (
|
|
"all-targets bonus must beat marginal net-count gain"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_target_bonus_inactive_when_targets_unspecified():
|
|
"""No targets configured -> score == nets*1000 + segments (no bonus)."""
|
|
r = _score_ses(_ses([("X", 5)], segments=5), [])
|
|
assert r["score"] == 1000 + 5
|
|
assert r["targets_found"] == [] and r["targets_missing"] == []
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_quoted_net_names_in_ses_are_normalised():
|
|
"""Freerouting writes nets as `(net "X"` — surrounding quotes must be stripped."""
|
|
text = '(net "MY_NET"\n (wire (path F.Cu 200 0 0 1 1))\n)\n'
|
|
r = _score_ses(text, target_nets=["MY_NET"])
|
|
assert r["targets_missing"] == [], "quoted net name should match unquoted target"
|
|
assert r["targets_found"] == ["MY_NET"]
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_targets_missing_and_found_are_sorted_for_stable_output():
|
|
text = _ses(
|
|
[("Z_NET", 1), ("A_NET", 1), ("M_NET", 1)],
|
|
segments=3,
|
|
)
|
|
r = _score_ses(text, target_nets=["Z_NET", "A_NET", "MISSING_X", "MISSING_A"])
|
|
assert r["targets_found"] == ["A_NET", "Z_NET"]
|
|
assert r["targets_missing"] == ["MISSING_A", "MISSING_X"]
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_via_count_is_reported_independently_of_score():
|
|
r = _score_ses(_ses([("X", 2)], segments=2, vias=7), [])
|
|
assert r["vias"] == 7
|
|
# Score formula does NOT include vias by design.
|
|
assert r["score"] == 1 * 1000 + 2
|