Skip to content

osdi ¤

Provision OpenVAF modules and compile an elaborated library topology.

Functions:

Name Description
build_resolved

Build canonical topology and shared descriptors without creating a solver.

compile_resolved

Compile original cards using the same topology builder as registrations.

compile_va

Compile into a content-addressed cache, including preprocessor dependencies.

module_metadata

Read canonical names and declared aliases from the OSDI 0.4 ABI.

build_resolved ¤

build_resolved(
    resolved: ResolvedCircuit,
    *,
    module_paths: tuple[Path, ...] = (),
    osdi_modules: tuple[Path, ...] = (),
    compiler: str | None = None,
    cache_dir: Path | None = None,
    analysis: str = "dc",
    state_policy: str = "reject",
    simparams: Mapping[str, float] | None = None,
    modules: dict[str, tuple[Path, dict[str, str]]] | None = None,
    expose_internal_nodes: bool = True
) -> tuple[Netlist, dict[str, Any], dict[str, tuple[Path, dict[str, str]]]]

Build canonical topology and shared descriptors without creating a solver.

@tags circulax-simulation

Source code in circulax/netlist_io/osdi.py
def build_resolved(  # noqa: C901, PLR0912 -- topology and terminal validation
    resolved: ResolvedCircuit,
    *,
    module_paths: tuple[Path, ...] = (),
    osdi_modules: tuple[Path, ...] = (),
    compiler: str | None = None,
    cache_dir: Path | None = None,
    analysis: str = "dc",
    state_policy: str = "reject",
    simparams: Mapping[str, float] | None = None,
    modules: dict[str, tuple[Path, dict[str, str]]] | None = None,
    expose_internal_nodes: bool = True,
) -> tuple[kfnl.Netlist, dict[str, Any], dict[str, tuple[Path, dict[str, str]]]]:
    """Build canonical topology and shared descriptors without creating a solver.

    @tags circulax-simulation
    """
    if modules is None:
        modules = _provision_modules(resolved, module_paths, osdi_modules, compiler, cache_dir)

    netlist = kfnl.Netlist()
    netlist.create_inst(name="GND", kcl="", component="ground")
    models = {}
    nodes: dict[str, list[kfnl.PortRef | kfnl.NetlistPort]] = {"0": [kfnl.PortRef(instance="GND", port="p1")]}
    for index, instance in enumerate(resolved.instances):
        key = f"device{index}"
        module = instance.module.lower()
        settings = instance.parameters
        if module in {"vsource", "isource"}:
            component, model, settings = _native_source(instance)
            models[component] = model
            names = ("p1", "p2")
        else:
            component = _BUILTIN_SPICE_MODULES.get(module, module)
            if component not in modules:
                msg = f"{instance.name}: no loaded OSDI module named {instance.module!r}"
                raise NetlistError(msg)
            path, aliases = modules[component]
            names = tuple(f"p{i}" for i in range(len(instance.nodes)))
            if component not in models:
                models[component] = _shared_descriptor(
                    str(path),
                    ports=names,
                    temperature=resolved.temperature_c + 273.15,
                    analysis=analysis,
                    state_policy=state_policy,
                    simparams=tuple(sorted(simparams.items())) if simparams is not None else None,
                )
            elif models[component].ports != names:
                msg = f"{instance.name}: inconsistent terminal count for {module}"
                raise NetlistError(msg)
            canonical = {}
            for name, value in settings.items():
                # ngspice's N-device `m` is simulator multiplicity when the
                # module does not declare its own parameter of that name.
                if name.lower() == "m" and "m" not in aliases and "$mfactor" in aliases:
                    name = "$mfactor"
                if name.lower() not in aliases:
                    msg = f"{instance.name}: unknown parameter {name!r} for {module}"
                    raise NetlistError(msg)
                canonical[aliases[name.lower()]] = value
            settings = canonical
        if len(names) != len(instance.nodes):
            msg = f"{instance.name}: expected {len(names)} terminals"
            raise NetlistError(msg)
        netlist.create_inst(name=key, kcl="", component=component, settings=settings or None)
        for name, node in zip(names, instance.nodes, strict=True):
            nodes.setdefault(node, []).append(kfnl.PortRef(instance=key, port=name))
    # Expose all flattened nodes, plus the public wrapper's formal port aliases.
    ports = {node: node for node in nodes} if expose_internal_nodes else {}
    for port, node in resolved.ports.items():
        if node not in nodes:
            msg = f"unconnected public terminal {port!r}"
            raise NetlistError(msg)
        ports[port] = node
    for port, node in ports.items():
        netlist.create_port(port)
        nodes[node].append(kfnl.NetlistPort(port))
    for members in nodes.values():
        netlist.create_net(*members)
    netlist.sort()
    return netlist, models, modules

compile_resolved ¤

compile_resolved(
    resolved: ResolvedCircuit,
    *,
    module_paths: tuple[Path, ...] = (),
    osdi_modules: tuple[Path, ...] = (),
    compiler: str | None = None,
    cache_dir: Path | None = None,
    backend: str = "dense",
    analysis: str = "dc",
    state_policy: str = "reject",
    simparams: Mapping[str, float] | None = None
) -> Circuit

Compile original cards using the same topology builder as registrations.

@tags circulax-simulation

Source code in circulax/netlist_io/osdi.py
def compile_resolved(
    resolved: ResolvedCircuit,
    *,
    module_paths: tuple[Path, ...] = (),
    osdi_modules: tuple[Path, ...] = (),
    compiler: str | None = None,
    cache_dir: Path | None = None,
    backend: str = "dense",
    analysis: str = "dc",
    state_policy: str = "reject",
    simparams: Mapping[str, float] | None = None,
) -> Circuit:
    """Compile original cards using the same topology builder as registrations.

    @tags circulax-simulation
    """
    netlist, models, _ = build_resolved(
        resolved,
        module_paths=module_paths,
        osdi_modules=osdi_modules,
        compiler=compiler,
        cache_dir=cache_dir,
        analysis=analysis,
        state_policy=state_policy,
        simparams=simparams,
    )
    return compile_circuit(
        netlist,
        models,
        backend=backend,
        is_complex=False,
        g_leak=0.0,
        rtol=1e-8,
        atol=1e-12,
    )

compile_va ¤

compile_va(
    source: Path, *, compiler: str | None = None, cache_dir: Path | None = None
) -> Path

Compile into a content-addressed cache, including preprocessor dependencies.

Source code in circulax/netlist_io/osdi.py
def compile_va(source: Path, *, compiler: str | None = None, cache_dir: Path | None = None) -> Path:
    """Compile into a content-addressed cache, including preprocessor dependencies."""
    executable = shutil.which(compiler or "openvaf-r")
    if executable is None:
        msg = "OpenVAF compiler not found; supply compiler= or put openvaf-r on PATH"
        raise FileNotFoundError(msg)
    source = source.resolve()
    digest = hashlib.sha256()
    version = subprocess.check_output([executable, "--version"])  # noqa: S603 -- explicit compiler, no shell
    digest.update(version)
    digest.update(Path(executable).read_bytes())
    digest.update(b"target_cpu=generic")
    digest.update(f"{platform.system()}/{platform.machine()}".encode())
    visited: set[Path] = set()

    def include(path: Path) -> None:
        path = path.resolve()
        if path in visited:
            return
        visited.add(path)
        data = path.read_bytes()
        digest.update(str(path).encode())
        digest.update(data)
        for reference in re.findall(rb'`include\s+"([^"]+)"', data):
            dependency = path.parent / os.fsdecode(reference)
            # OpenVAF supplies constants.vams/disciplines.vams itself.
            if dependency.is_file():
                include(dependency)
            elif reference not in {b"constants.vams", b"disciplines.vams", b"constants.h", b"discipline.h"}:
                msg = f"{path}: unresolved Verilog-A include {reference!r}"
                raise FileNotFoundError(msg)

    include(source)
    cache = Path(cache_dir or Path.home() / ".cache/circulax/osdi")
    cache.mkdir(parents=True, exist_ok=True)
    target = cache / f"{source.stem}-{digest.hexdigest()}.osdi"
    if not target.is_file():
        with tempfile.TemporaryDirectory(dir=cache) as temporary:
            output = Path(temporary) / "model.osdi"
            run = subprocess.run(  # noqa: S603 -- explicit compiler, no shell
                [executable, "--target_cpu", "generic", str(source), "-o", str(output)], capture_output=True, text=True, check=False
            )
            if run.returncode or not output.is_file():
                msg = f"OpenVAF failed for {source}:\n{run.stdout}\n{run.stderr}"
                raise NetlistError(msg)
            output.replace(target)
    return target

module_metadata ¤

module_metadata(path: Path) -> tuple[str, dict[str, str]]

Read canonical names and declared aliases from the OSDI 0.4 ABI.

Source code in circulax/netlist_io/osdi.py
def module_metadata(path: Path) -> tuple[str, dict[str, str]]:
    """Read canonical names and declared aliases from the OSDI 0.4 ABI."""
    if ctypes.sizeof(ctypes.c_void_p) != 8:
        msg = "OSDI metadata inspection currently requires a 64-bit platform"
        raise NetlistError(msg)
    library = ctypes.CDLL(str(path))
    version = tuple(ctypes.c_uint32.in_dll(library, f"OSDI_VERSION_{part}").value for part in ("MAJOR", "MINOR"))
    if version != (0, 4):
        msg = f"{path}: unsupported OSDI ABI {version}; bosdi requires 0.4"
        raise NetlistError(msg)
    if ctypes.c_uint32.in_dll(library, "OSDI_NUM_DESCRIPTORS").value != 1:
        msg = f"{path}: expected one OSDI module per binary"
        raise NetlistError(msg)
    name = ctypes.c_char_p.in_dll(library, "OSDI_DESCRIPTORS").value.decode()
    base = ctypes.addressof(ctypes.c_char_p.in_dll(library, "OSDI_DESCRIPTORS"))
    count = ctypes.c_uint32.from_address(base + 76).value
    address = ctypes.c_void_p.from_address(base + 88).value
    table = ctypes.cast(address, ctypes.POINTER(_Parameter))
    aliases = {}
    for index in range(count):
        parameter = table[index]
        canonical = parameter.names[0].decode()
        for alias in range(parameter.aliases + 1):
            aliases[parameter.names[alias].decode().lower()] = canonical
    return name, aliases