Files
rpmroute/tests/oracle.py
T
rsp2k 3ad5c983f5 Honour KiCad's keepouts: keyhole rings, wire_keepout, per-tile rasterizing
On the TerraHawk rc4 power stage every engine misread the board's rule areas:

- A no-track ring along the board edge arrives as one keyhole polygon, and
  keepout polygons were wrapped in a single convex tile, so the ring became
  the whole board. Split keepout polygons into convex pieces instead.
- The grid engine rasterized an area by its overall bounding box, which for
  that ring is again the whole board; it routed nothing. Rasterize per tile.
- "Keep router off" rule areas arrive as wire_keepout, which the reader did
  not know, so tracks ran through them (172 DRC items_not_allowed). Read it
  at board and image level. For now it blocks vias too, which is stricter
  than KiCad and cannot create a violation.
- place_keepout only restricts part placement; stop treating it as copper.

The oracle now runs the JAR headless; it was opening FreeRouting's GUI on
the desktop for every oracle test.
2026-09-27 06:30:43 -06:00

166 lines
5.5 KiB
Python

"""FreeRouting JAR oracle for behaviour-level validation.
**Dev/test only — not part of the shipped package** (freeroute is Java-free by
design). This runs the reference FreeRouting JAR to route a Specctra DSN so that
freeroute's own output can be diffed against the reference implementation. There
are no unit-test oracles for FreeRouting's geometry/router, so the oracle is how
we validate at the behaviour level: same DSN in, compare routed connectivity.
Tests that use this skip automatically when a Java 21+ runtime or the JAR is not
available (e.g. CI without a JVM), so the normal suite stays Java-free.
Overrides:
- ``FREEROUTE_ORACLE_JAVA`` — explicit path to a ``java`` binary.
- ``FREEROUTING_JAR`` — explicit path to a freerouting JAR.
"""
from __future__ import annotations
import contextlib
import glob
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import pytest
from freeroute.dsn.sexp import parse
_MIN_JAVA_MAJOR = 21
_REPO_ROOT = Path(__file__).resolve().parents[1]
# JARs bundled in the (gitignored) reference clone, newest first.
_JAR_CANDIDATES = (
"reference/freerouting/scripts/benchmark/binaries/freerouting-current.jar",
"reference/freerouting/scripts/benchmark/binaries/freerouting-2.2.4.jar",
"reference/freerouting/integrations/KiCad/kicad-freerouting/plugins/jar/freerouting-2.2.4.jar",
"reference/freerouting/scripts/benchmark/binaries/freerouting-1.9.0.jar",
)
def _java_major(java: str) -> int:
"""Parse the major version from ``java -version`` (handles the old 1.8 form)."""
try:
out = subprocess.run(
[java, "-version"], capture_output=True, text=True, timeout=10
).stderr
except (OSError, subprocess.SubprocessError):
return 0
m = re.search(r'version "(\d+)(?:\.(\d+))?', out)
if not m:
return 0
major = int(m.group(1))
# "1.8.0" -> 8; "26.0.1" -> 26
return int(m.group(2)) if major == 1 and m.group(2) else major
def find_java() -> str | None:
"""Return a path to a Java >= 21 runtime, or None."""
candidates: list[str] = []
override = os.environ.get("FREEROUTE_ORACLE_JAVA")
if override:
candidates.append(override)
java_home = os.environ.get("JAVA_HOME")
if java_home:
candidates.append(os.path.join(java_home, "bin", "java"))
# Arch-style versioned JVMs, newest major first.
candidates += sorted(
glob.glob("/usr/lib/jvm/java-*-openjdk/bin/java"), reverse=True
)
on_path = shutil.which("java")
if on_path:
candidates.append(on_path)
for java in candidates:
if java and os.path.isfile(java) and _java_major(java) >= _MIN_JAVA_MAJOR:
return java
return None
def find_freerouting_jar() -> str | None:
"""Return a path to a runnable freerouting JAR, or None."""
override = os.environ.get("FREEROUTING_JAR")
if override and os.path.isfile(override):
return override
for rel in _JAR_CANDIDATES:
p = _REPO_ROOT / rel
if p.is_file():
return str(p)
return None
JAVA = find_java()
JAR = find_freerouting_jar()
HAS_ORACLE = JAVA is not None and JAR is not None
requires_oracle = pytest.mark.skipif(
not HAS_ORACLE, reason="FreeRouting oracle unavailable (needs Java 21+ and a freerouting JAR)"
)
def route_dsn(dsn_path: str | Path, *, max_passes: int = 6, timeout: int = 180) -> str:
"""Route ``dsn_path`` with the FreeRouting JAR and return the SES text.
Raises RuntimeError if the oracle is unavailable or produced no SES.
"""
if not HAS_ORACLE:
raise RuntimeError("FreeRouting oracle unavailable")
with tempfile.NamedTemporaryFile(suffix=".ses", delete=False) as tmp:
out_path = tmp.name
try:
# Headless: without these the JAR opens its GUI on the developer's
# desktop for every oracle run.
env = {k: v for k, v in os.environ.items() if k not in ("DISPLAY", "WAYLAND_DISPLAY")}
subprocess.run(
[JAVA, "-Djava.awt.headless=true", "-jar", JAR, "-de", str(dsn_path),
"-do", out_path, "-mp", str(max_passes), "--gui.enabled=false"],
capture_output=True,
text=True,
timeout=timeout,
check=False,
env=env,
)
text = Path(out_path).read_text()
finally:
with contextlib.suppress(OSError):
os.unlink(out_path)
if not text.strip():
raise RuntimeError("FreeRouting produced an empty SES")
return text
def routed_nets(ses_text: str) -> dict[str, dict[str, int]]:
"""Map each net in an SES ``network_out`` to its wire/via counts.
This is the diff primitive the router phase compares against: two SES files
route the "same" board when they cover the same set of nets with wires/vias.
"""
root = parse(ses_text)
routes = root.child("routes")
if routes is None:
return {}
network_out = routes.child("network_out")
if network_out is None:
return {}
result: dict[str, dict[str, int]] = {}
for net in network_out.children("net"):
vals = net.values()
name = vals[0].text if vals else "?"
result[name] = {
"wires": len(net.children("wire")),
"vias": len(net.children("via")),
}
return result
def routed_net_set(ses_text: str) -> set[str]:
"""Return the set of nets that actually carry routing (>=1 wire or via)."""
return {
name
for name, counts in routed_nets(ses_text).items()
if counts["wires"] or counts["vias"]
}