The router began as a port of FreeRouting and has grown its own engine (negotiated congestion, tree nets, exact checks, axis escapes), and 'freeroute' was one letter from the project it no longer follows. The package, module and command are now rpmroute; the freeroute command stays as an alias, and the RPMROUTE / RPMROUTE_ORACLE_JAVA variables fall back to the old FREEROUTE names. Version 2026.10.07. Routing is unchanged: a PCBench board routes to a byte-identical session either way.
166 lines
5.6 KiB
Python
166 lines
5.6 KiB
Python
"""FreeRouting JAR oracle for behaviour-level validation.
|
|
|
|
**Dev/test only — not part of the shipped package** (rpmroute is Java-free by
|
|
design). This runs the reference FreeRouting JAR to route a Specctra DSN so that
|
|
rpmroute'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:
|
|
- ``RPMROUTE_ORACLE_JAVA`` (or the older ``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 rpmroute.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("RPMROUTE_ORACLE_JAVA") or 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"]
|
|
}
|