"""Drive the original 1999 ASITIC binary headlessly and parse its
output, so reASITIC's numerical results can be compared to the
ground truth.
The binary lives at ``../run/asitic.linux.2.2`` (relative to the
reASITIC source tree). It is a 32-bit ELF that depends on the
bundled libstdc++/Mesa/X11/readline shipped under ``../run/libs/``;
running it requires a working X display (the binary ``--ngr``
flag does not fully bypass the X init). We use ``xvfb-run`` to
provide a virtual display when one isn't already available.
Notes on the legacy binary's quirks:
* The ``Ind`` / inductance commands segfault in headless mode on
modern Linux (uninitialized table read at ``-4`` offset, traced
to ``cmd_inductance_compute`` reading ``g_metal_layer_table``
before it has been fully populated). We therefore validate
numerical results against published Greenhouse / Grover formulas
rather than against the binary's ``Ind`` output. Geometry-only
commands (``Geom``, ``MetArea``, ``ListSegs``, etc.) work and
are the basis of binary-driven testing here.
* When stdin closes the binary loops forever printing
``Unknown or Mistyped Commnd``; therefore every script must end
with a quit command (``Q`` / ``QUIT`` / ``EXIT``).
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import textwrap
from dataclasses import dataclass
from pathlib import Path
[docs]
class BinaryNotFoundError(RuntimeError):
"""Raised when the legacy ``asitic`` binary cannot be located."""
def _default_binary_path() -> Path:
"""Locate the legacy binary relative to the package tree."""
# reasitic/validation/binary_runner.py → repo root is 4 levels up
here = Path(__file__).resolve()
for parent in here.parents:
candidate = parent / "run" / "asitic"
if candidate.exists():
return candidate
# Allow override
env = os.environ.get("REASITIC_BINARY")
if env and Path(env).exists():
return Path(env)
raise BinaryNotFoundError("could not locate run/asitic launcher")
[docs]
@dataclass
class GeomResult:
"""Parsed output of the ``Geom <name>`` command."""
name: str
kind: str # "Wire", "Square spiral", "Spiral", ...
length_um: float | None = None # for Wire, the length; for spirals, L1
width_um: float | None = None
metal: str | None = None
total_length_um: float | None = None
total_area_um2: float | None = None
location: tuple[float, float] | None = None
n_segments: int | None = None
spiral_l1_um: float | None = None
spiral_l2_um: float | None = None
spiral_spacing_um: float | None = None
spiral_turns: float | None = None
raw: str = ""
_GEOM_HEADER_RE = re.compile(r"^(.+?)\s+<([^>]+)>\s+has the following geometry")
_LW_RE = re.compile(r"L\s*=\s*([0-9.]+)\s*,\s*W\s*=\s*([0-9.]+)\s*,\s*Metal\s*=\s*(\S+)")
_TOTAL_RE = re.compile(
r"Total length\s*=\s*([0-9.]+)\s*\(um\),\s*Total Area\s*=\s*([0-9.]+)"
)
_LOC_RE = re.compile(
r"Located at\s*\(\s*(-?[0-9.]+)\s*,\s*(-?[0-9.]+)\s*\) with\s+(\d+)\s+segments"
)
_SPIRAL_PARAMS_RE = re.compile(
r"L1\s*=\s*([0-9.]+)\s*,\s*L2\s*=\s*([0-9.]+)\s*,\s*W\s*=\s*([0-9.]+)"
r"\s*,\s*S\s*=\s*([0-9.]+)\s*,\s*N\s*=\s*([0-9.]+)"
)
[docs]
def parse_geom_output(text: str) -> GeomResult:
"""Parse the textual block emitted by ``Geom <name>``."""
result = GeomResult(name="", kind="", raw=text)
for line in text.splitlines():
m = _GEOM_HEADER_RE.search(line)
if m:
result.kind = m.group(1)
result.name = m.group(2)
continue
m = _LW_RE.search(line)
if m:
result.length_um = float(m.group(1))
result.width_um = float(m.group(2))
result.metal = m.group(3)
continue
m = _TOTAL_RE.search(line)
if m:
result.total_length_um = float(m.group(1))
result.total_area_um2 = float(m.group(2))
continue
m = _LOC_RE.search(line)
if m:
result.location = (float(m.group(1)), float(m.group(2)))
result.n_segments = int(m.group(3))
continue
m = _SPIRAL_PARAMS_RE.search(line)
if m:
result.spiral_l1_um = float(m.group(1))
result.spiral_l2_um = float(m.group(2))
result.width_um = float(m.group(3))
result.spiral_spacing_um = float(m.group(4))
result.spiral_turns = float(m.group(5))
return result
_DEFAULT_QEMU_BINARY = "qemu-i386-static"
[docs]
@dataclass
class BinaryRunner:
"""Runs the legacy ``asitic`` binary under user-mode QEMU.
The 1999 ASITIC binary is a 32-bit i386 ELF linked against
1999-era libstdc++ / libreadline / Mesa libraries (bundled in
``run/libs/``). Native execution on modern Linux works for
geometry-only commands but segfaults inside
``compute_mutual_inductance`` (decomp ``0x0804efb0``) for any
AC-frequency analysis (``Res <freq>``, ``Pi <freq>``, ``2Port``,
``Eddy on``). The crash is a 1999-era kernel/FPU-state ABI
mismatch.
To make results reproducible across hosts, **reASITIC's
validation harness only supports QEMU user-mode execution**.
Install ``qemu-user-static`` and the harness picks up
``qemu-i386-static`` automatically; set ``REASITIC_QEMU_USER``
to override the QEMU binary. If QEMU is not available, the
runner construction raises :class:`BinaryNotFoundError` and
every test that depends on it auto-skips.
The parent ``asitic-re`` repo's ``BINARY_VALIDATION.md``
documents how to install QEMU on each major distro and the
legacy-Linux containerised path for full reproduction.
"""
binary: Path
tech_file: Path
cwd: Path
qemu_user: str
timeout_s: float = 30.0
use_xvfb: bool = True
[docs]
@classmethod
def auto(
cls,
tech_file: str | Path = "tek/BiCMOS.tek",
timeout_s: float = 30.0,
qemu_user: str | None = None,
) -> BinaryRunner:
"""Construct a runner using the legacy binary at ``run/asitic``.
``tech_file`` is resolved relative to the binary's directory if
not absolute. ``xvfb-run`` is auto-enabled when no DISPLAY is
set on the host.
``qemu_user`` is the QEMU binary used for translation
(defaults to ``$REASITIC_QEMU_USER`` or
``"qemu-i386-static"``). Raises :class:`BinaryNotFoundError`
when the QEMU binary isn't on the PATH.
"""
binary = _default_binary_path()
cwd = binary.parent # the run/ directory
tech = Path(tech_file)
if not tech.is_absolute():
tech = cwd / tech
if not tech.exists():
raise FileNotFoundError(tech)
qemu = qemu_user or os.environ.get(
"REASITIC_QEMU_USER", _DEFAULT_QEMU_BINARY
)
if shutil.which(qemu) is None:
raise BinaryNotFoundError(
f"QEMU user-mode binary {qemu!r} not found on PATH; "
"install ``qemu-user-static`` or set REASITIC_QEMU_USER"
)
use_xvfb = (
shutil.which("xvfb-run") is not None
and "DISPLAY" not in os.environ
)
return cls(
binary=binary, tech_file=tech, cwd=cwd,
timeout_s=timeout_s, use_xvfb=use_xvfb, qemu_user=qemu,
)
[docs]
def run_script(self, script: str) -> str:
"""Run ``script`` (newline-separated commands) and return stdout."""
if not script.endswith("\n"):
script += "\n"
# `Q` is the Q-factor command, not quit. Always end with EXIT
# so the binary leaves the prompt loop cleanly.
if "QUIT\n" not in script.upper() and "EXIT\n" not in script.upper():
script += "EXIT\n"
argv: list[str] = []
if self.use_xvfb:
argv.extend(["xvfb-run", "-a"])
argv.append(self.qemu_user)
argv.extend([str(self.binary), "-t", str(self.tech_file)])
# start_new_session=True isolates us from the SIGQUIT that
# xvfb-run sends to its process group when the legacy
# libstdc++ runtime tears down the X connection on exit.
proc = subprocess.run(
argv,
input=script,
text=True,
capture_output=True,
cwd=str(self.cwd),
timeout=self.timeout_s,
check=False,
start_new_session=True,
)
# The binary prints results inline on stdout. Note: Ind crashes,
# but Geom and other geometry commands work.
return proc.stdout
[docs]
def geom(self, build_command: str, name: str) -> GeomResult:
"""Build ``name`` via ``build_command`` then run ``Geom <name>``.
Example::
r = runner.geom(
"W NAME=W1:LEN=100:WID=10:METAL=m3:XORG=0:YORG=0",
"W1",
)
assert r.length_um == 100.0
"""
script = textwrap.dedent(
f"""\
{build_command}
Geom {name}
"""
)
out = self.run_script(script)
# Carve out just the lines after "Geom <name>"
marker = f"Geom {name}"
idx = out.find(marker)
if idx < 0:
return parse_geom_output(out)
return parse_geom_output(out[idx:])