Source code for qpdk.simulation.vacask.runner

"""Run the VACASK circuit simulator as a subprocess."""

from __future__ import annotations

import hashlib
import json
import os
import re
import shutil
import subprocess
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Self

from qpdk.logger import logger

from .errors import VacaskError
from .netlist import Netlist
from .raw import RawFile, RawPlot

INSTALL_HINT = (
    "Install VACASK from https://codeberg.org/arpadbuermen/VACASK/releases "
    "(the Linux .tar.gz/.deb and Windows .zip bundle the openvaf-r compiler; on macOS "
    "build both from source), then put 'vacask' on PATH or set the VACASK environment "
    "variable to the binary. On Linux, OpenVAF also needs a system linker "
    "(the binutils package)."
)


def _find_binary(env_vars: tuple[str, ...], name: str) -> Path | None:
    """Return the first path set in ``env_vars``, else ``name`` on ``PATH``."""
    for var in env_vars:
        if value := os.environ.get(var):
            return Path(value)
    found = shutil.which(name)
    return Path(found) if found else None


[docs] @dataclass(frozen=True, slots=True, kw_only=True) class SimulationResult: """Plots and diagnostics from one simulator invocation. With an explicit workdir, the deck, model files, raw results, ``vacask.log`` and ``run.json`` remain available after success or failure. A successful process exit does not establish harmonic convergence or pump stability. """ plots: dict[str, RawPlot] output: str elapsed: float binary_sha256: str
[docs] @dataclass(frozen=True) class Vacask: """The VACASK simulator, run as a separate process. Attributes: binary: Path to the ``vacask`` binary. openvaf: Path to the Verilog-A compiler, passed to VACASK as ``SIM_OPENVAF`` unless that is already set. """ binary: Path openvaf: Path | None = None
[docs] @classmethod def find(cls) -> Self: """Locate VACASK and OpenVAF. Checks the ``VACASK`` and ``QPDK_VACASK`` environment variables, then ``PATH``, for ``vacask``, and ``OPENVAF`` then ``PATH`` for ``openvaf-r``. Returns: A runner for the binaries found. Raises: VacaskError: If no ``vacask`` binary is found. """ binary = _find_binary(("VACASK", "QPDK_VACASK"), "vacask") if binary is None: raise VacaskError(f"VACASK binary not found. {INSTALL_HINT}") return cls(binary, _find_binary(("OPENVAF",), "openvaf-r"))
[docs] def run( self, netlist: Netlist, *, workdir: str | Path | None = None, timeout: float | None = None, ) -> dict[str, RawPlot]: """Run a netlist and return the results of every analysis. The netlist and its files are written to a scratch directory, which is also where VACASK compiles Verilog-A sources. Args: netlist: The circuit and analyses. workdir: Directory to run in. Defaults to a temporary directory that is removed afterwards. timeout: Timeout in seconds. Returns: ``{analysis name: plot}``, read from ``<analysis name>.raw``. Swept analyses hold all sweep points; see :meth:`RawPlot.split`. See :meth:`run_record` for diagnostics and failure conditions. """ return self.run_record(netlist, workdir=workdir, timeout=timeout).plots
[docs] def run_record( self, netlist: Netlist, *, workdir: str | Path | None = None, timeout: float | None = None, ) -> SimulationResult: """Run a netlist with captured diagnostics and optional retained artifacts. Expected raw outputs from a previous invocation are removed before the process starts, so a reused directory cannot supply stale results. ``run.json`` records input and binary hashes, elapsed seconds and exit status. Compiler overrides and module paths are recorded from the effective environment; they can refer to external, unbundled files. Returns: Plots, captured output, elapsed seconds and the simulator hash. Raises: VacaskError: If analyses have duplicate or invalid names, the simulator cannot start, fails or times out, or an output is missing. """ env = dict(os.environ) if "SIM_OPENVAF" not in env and self.openvaf is not None: env["SIM_OPENVAF"] = str(self.openvaf) binary = self.binary.resolve() try: binary_sha256 = hashlib.sha256(binary.read_bytes()).hexdigest() except OSError as error: raise VacaskError( f"Cannot read VACASK binary {binary}. {INSTALL_HINT}" ) from error with tempfile.TemporaryDirectory(prefix="qpdk_vacask_") as tmp: cwd = Path(workdir) if workdir is not None else Path(tmp) cwd.mkdir(parents=True, exist_ok=True) names = netlist.analyses if len(names) != len(set(names)) or any( re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name) is None for name in names ): raise VacaskError("Analysis names must be unique simple identifiers") for name in names: (cwd / f"{name}.raw").unlink(missing_ok=True) inputs = {} for name, content in netlist.all_files().items(): if isinstance(content, Path): shutil.copyfile(content, cwd / name) else: (cwd / name).write_text(content) inputs[name] = hashlib.sha256((cwd / name).read_bytes()).hexdigest() (cwd / "netlist.sim").write_text(netlist.render()) inputs["netlist.sim"] = hashlib.sha256( (cwd / "netlist.sim").read_bytes() ).hexdigest() metadata: dict[str, Any] = { "binary": str(binary), "binary_sha256": binary_sha256, "inputs_sha256": inputs, "environment": { key: env[key] for key in ( "SIM_OPENVAF", "SIM_MODULE_PATH", "SIM_INCLUDE_PATH", "SIM_CONFIG", ) if key in env }, } if compiler := shutil.which(env.get("SIM_OPENVAF", "openvaf-r")): metadata["compiler"] = str(Path(compiler).resolve()) metadata["compiler_sha256"] = hashlib.sha256( Path(compiler).read_bytes() ).hexdigest() started = time.perf_counter() logger.debug("Running {} in {}", self.binary, cwd) log_path = cwd / "vacask.log" try: with log_path.open("w") as stream: proc = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] [str(binary), "netlist.sim"], cwd=cwd, env=env, stdout=stream, stderr=subprocess.STDOUT, text=True, timeout=timeout, check=False, ) except subprocess.TimeoutExpired as error: metadata.update(elapsed=time.perf_counter() - started, status="timeout") (cwd / "run.json").write_text(json.dumps(metadata, indent=2) + "\n") raise VacaskError(f"VACASK timed out after {timeout} s") from error except OSError as error: metadata.update( elapsed=time.perf_counter() - started, status="launch-error", error=str(error), ) (cwd / "run.json").write_text(json.dumps(metadata, indent=2) + "\n") raise VacaskError( f"Cannot launch VACASK binary {binary}: {error}" ) from error elapsed = time.perf_counter() - started output = log_path.read_text(errors="replace").strip() metadata.update(elapsed=elapsed, returncode=proc.returncode) (cwd / "run.json").write_text(json.dumps(metadata, indent=2) + "\n") if proc.returncode != 0: raise VacaskError( f"VACASK exited with code {proc.returncode}:\n{output}" ) results: dict[str, RawPlot] = {} for name in netlist.analyses: raw_file = cwd / f"{name}.raw" if not raw_file.exists(): raise VacaskError( f"Analysis {name!r} wrote no {raw_file.name}:\n{output}" ) results[name] = RawFile(raw_file)[0] return SimulationResult( plots=results, output=output, elapsed=elapsed, binary_sha256=binary_sha256 )