Skip to content

netlist_io ¤

File-input adapter: parse and evaluate model cards, then build kfnetlist topology.

NetlistParse supplies syntax; kfnetlist owns circuit connectivity. Library resolution retains card scopes and OSDI metadata until compilation provisions models and passes a kfnetlist.Netlist to compile_circuit.

Modules:

Name Description
expressions

Evaluate NetlistParse expression nodes with explicit, scoped name lookup.

library

Scoped model-card loading and hierarchical circuit elaboration.

model

Reusable model-library registrations for schematic instance elaboration.

osdi

Provision OpenVAF modules and compile an elaborated library topology.

sources

Parse SPICE independent-source specifications into waveform source settings.

syntax

Direct NetlistParse syntax access, preserving symbolic expressions.

units

Explicit scalar unit conversion at the schematic/library boundary.

Classes:

Name Description
Library

Load existing model libraries and elaborate their public subcircuits.

LibraryModel

Register one library wrapper, binding settings separately per instance.

NetlistDefinition

Canonical topology and shared leaf models, before solver compilation.

NetlistError

A netlist could not be parsed or resolved without changing its meaning.

ResolvedCircuit

Evaluated model cards awaiting conversion to kfnetlist and native models.

ResolvedInstance

A leaf compact model with evaluated model and instance parameters.

Functions:

Name Description
parse_source

Parse the value part of a SPICE V/I source card: [DC] value and/or a waveform.

parse_waveform

Parse SIN(...), PULSE(...) or PWL(...) into waveform source settings.

Library ¤

Library(
    *,
    temperature_c: float = 27.0,
    include_paths: tuple[Path, ...] = (),
    dialect: str = "ngspice",
    statistical_mode: str = "reject"
)

Load existing model libraries and elaborate their public subcircuits.

Parameters and model cards are evaluated separately for each instance. Libraries retain their lexical scopes, local nodes and conditional branches.

Methods:

Name Description
from_file

Read a library and select a named corner section when requested.

include

Add a library to the same root scope, preserving declaration order.

inspection_sources

Fingerprint actual parsed source dependencies without native loading.

instantiate

Expand a library subcircuit without loading executable OSDI modules.

refreshed

Reuse unchanged parsed cards or reparse edited, removed or shadowed source dependencies.

resolve

Expand instances declared at the top level of a loaded circuit file.

with_temperature

Reparse source declarations at a new temperature without mutation.

Source code in circulax/netlist_io/library.py
def __init__(
    self,
    *,
    temperature_c: float = 27.0,
    include_paths: tuple[Path, ...] = (),
    dialect: str = "ngspice",
    statistical_mode: str = "reject",
) -> None:
    """Create an empty model library at the requested card temperature."""
    if not math.isfinite(temperature_c) or temperature_c <= -273.15:
        msg = "temperature_c must be finite and above absolute zero"
        raise NetlistError(msg)
    if statistical_mode not in {"reject", "nominal"}:
        msg = "statistical_mode must be 'reject' or 'nominal'"
        raise NetlistError(msg)
    self.temperature_c = temperature_c
    self.include_paths = tuple(Path(p) for p in include_paths)
    self.dialect = dialect
    self.loads: list[tuple[str, Path]] = []
    self.globals = {"0"}
    self.grounds = {"0"}
    self.scope = Scope()
    self.scope.dialect = dialect
    self.scope.statistical_mode = statistical_mode
    self.scope.bindings["$temp"] = temperature_c
    self.frame = _Frame(self.scope)
    self._parsed: dict[Path, list[Statement]] = {}
    self._parsed_fingerprints: dict[Path, str] = {}
    self._digests = FileDigests()
    # Include search results: candidate paths in order, and how many precede the one found.
    self._lookups: dict[tuple[Path, ...], int] = {}
    self._sources: list[tuple[Path, str | None]] = []
    self.revision = next(_REVISIONS)

from_file classmethod ¤

from_file(
    path: str | Path,
    *,
    section: str | None = None,
    temperature_c: float = 27.0,
    include_paths: tuple[Path, ...] = (),
    dialect: str = "ngspice",
    statistical_mode: str = "reject"
) -> Library

Read a library and select a named corner section when requested.

statistical_mode="nominal" explicitly evaluates agauss at its nominal value. The default rejects random functions; sampling is unsupported.

Source code in circulax/netlist_io/library.py
@classmethod
def from_file(
    cls,
    path: str | Path,
    *,
    section: str | None = None,
    temperature_c: float = 27.0,
    include_paths: tuple[Path, ...] = (),
    dialect: str = "ngspice",
    statistical_mode: str = "reject",
) -> Library:
    """Read a library and select a named corner section when requested.

    statistical_mode="nominal" explicitly evaluates agauss at its nominal
    value. The default rejects random functions; sampling is unsupported.
    """
    library = cls(temperature_c=temperature_c, include_paths=include_paths, dialect=dialect, statistical_mode=statistical_mode)
    library.include(path, section=section)
    return library

include ¤

include(path: str | Path, *, section: str | None = None) -> None

Add a library to the same root scope, preserving declaration order.

Source code in circulax/netlist_io/library.py
def include(self, path: str | Path, *, section: str | None = None) -> None:
    """Add a library to the same root scope, preserving declaration order."""
    source = Path(path).resolve()
    statements = self._read(source, section, (), set())
    self._populate(self.frame, statements)
    self._sources.append((source, section))

inspection_sources ¤

inspection_sources() -> list[dict[str, str]]

Fingerprint actual parsed source dependencies without native loading.

Source code in circulax/netlist_io/library.py
def inspection_sources(self) -> list[dict[str, str]]:
    """Fingerprint actual parsed source dependencies without native loading."""
    records = []
    for path in sorted(self._parsed, key=str):
        digest = self._digests.digest(path)
        if digest is None:
            msg = f"{path}: library source not found"
            raise NetlistError(msg)
        records.append({"path": str(path), "sha256": digest})
    return records

instantiate ¤

instantiate(
    subcircuit: str,
    nodes: tuple[str, ...] | None = None,
    settings: dict[str, float] | None = None,
    *,
    name: str = "X1"
) -> ResolvedCircuit

Expand a library subcircuit without loading executable OSDI modules.

Source code in circulax/netlist_io/library.py
def instantiate(
    self, subcircuit: str, nodes: tuple[str, ...] | None = None, settings: dict[str, float] | None = None, *, name: str = "X1"
) -> ResolvedCircuit:
    """Expand a library subcircuit without loading executable OSDI modules."""
    found = self.frame.find(subcircuit.lower(), "subcircuits")
    if found is None:
        msg = f"unknown subcircuit {subcircuit!r}"
        raise NetlistError(msg)
    _, definition = found
    ports = tuple(_nodes(definition[0].node))
    actual_nodes = nodes if nodes is not None else ports
    instances: list[ResolvedInstance] = []
    self._instantiate(definition, actual_nodes, settings or {}, name, instances, ())
    return ResolvedCircuit(instances, list(self.loads), dict(zip(ports, actual_nodes, strict=True)), self.temperature_c)

refreshed ¤

refreshed() -> Library

Reuse unchanged parsed cards or reparse edited, removed or shadowed source dependencies.

Source code in circulax/netlist_io/library.py
def refreshed(self) -> Library:
    """Reuse unchanged parsed cards or reparse edited, removed or shadowed source dependencies."""
    unchanged = all(self._digests.digest(path) == fingerprint for path, fingerprint in self._parsed_fingerprints.items())
    if unchanged and not any(c.is_file() for candidates, found in self._lookups.items() for c in candidates[:found]):
        return self
    return self.with_temperature(self.temperature_c)

resolve ¤

resolve() -> ResolvedCircuit

Expand instances declared at the top level of a loaded circuit file.

Source code in circulax/netlist_io/library.py
def resolve(self) -> ResolvedCircuit:
    """Expand instances declared at the top level of a loaded circuit file."""
    instances: list[ResolvedInstance] = []
    self._calls(self.frame, {}, "", instances, ())
    return ResolvedCircuit(instances, list(self.loads), temperature_c=self.temperature_c)

with_temperature ¤

with_temperature(temperature_c: float) -> Library

Reparse source declarations at a new temperature without mutation.

Source code in circulax/netlist_io/library.py
def with_temperature(self, temperature_c: float) -> Library:
    """Reparse source declarations at a new temperature without mutation."""
    if not self._sources:
        msg = "Temperature override requires reparsable library source declarations"
        raise ValueError(msg)
    library = type(self)(
        temperature_c=temperature_c,
        include_paths=self.include_paths,
        dialect=self.dialect,
        statistical_mode=self.scope.statistical_mode,
    )
    for path, section in self._sources:
        library.include(path, section=section)
    return library

LibraryModel ¤

LibraryModel(
    library: Library | None,
    subcircuit: str,
    *,
    defaults: Mapping[str, Any] | None = None,
    params_map: Mapping[str, str] | None = None,
    port_map: Mapping[str, str] | None = None,
    parameter_units: Mapping[str, tuple[str, str]] | None = None,
    parameter_values: Mapping[str, Mapping[Any, float]] | None = None,
    expressions: Mapping[str, str] | None = None,
    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
)

Register one library wrapper, binding settings separately per instance.

This object is deliberately non-callable. compile_circuit elaborates wrapper instances to kfnetlist and lets kfnetlist flatten their topology. OSDI descriptor definitions are shared; card and instance values populate individual leaf settings and therefore rows in the native device batch.

params_map maps schematic setting names to library parameter names. parameter_units explicitly declares (source, target) units for those schematic settings. Defaults use the same source units. expressions maps library parameter names to safe expressions over converted settings. Unspecified defaults remain owned by the original library cards.

A temperature_c setting (degrees Celsius) selects the card temperature. It stays available to expressions and parameter_units/parameter_values, but is forwarded to the library as a parameter only when params_map names it.

@tags circulax-simulation

Methods:

Name Description
from_file

Register lazily; parse the selected corner only when instantiated.

inspection_identity

Return deterministic read-only provenance and resolved topology.

instantiate

Build the resolved topology and retain reusable native models.

resolve

Evaluate settings and cards without building or provisioning models.

Source code in circulax/netlist_io/model.py
def __init__(
    self,
    library: Library | None,
    subcircuit: str,
    *,
    defaults: Mapping[str, Any] | None = None,
    params_map: Mapping[str, str] | None = None,
    port_map: Mapping[str, str] | None = None,
    parameter_units: Mapping[str, tuple[str, str]] | None = None,
    parameter_values: Mapping[str, Mapping[Any, float]] | None = None,
    expressions: Mapping[str, str] | None = None,
    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,
) -> None:
    """Declare schematic-to-library mappings and native provisioning.

    @tags circulax-simulation
    """
    self.library = library
    self.subcircuit = subcircuit
    self.defaults = dict(defaults or {})
    self.params_map = dict(params_map or {})
    self.port_map = dict(port_map or {})
    self.parameter_units = dict(parameter_units or {})
    self.parameter_values = {name: dict(mapping) for name, mapping in (parameter_values or {}).items()}
    self.expressions = dict(expressions or {})
    self.options = {
        "module_paths": module_paths,
        "osdi_modules": osdi_modules,
        "compiler": compiler,
        "cache_dir": cache_dir,
        "analysis": analysis,
        "state_policy": state_policy,
        "simparams": simparams,
    }
    # Provisioned modules per Library.revision, with the artifact signature they were built from.
    self._modules = {}
    self._module_signatures = {}
    self._digests = FileDigests()
    self._source = None
    self._libraries = {}

from_file classmethod ¤

from_file(
    path: str | Path,
    subcircuit: str,
    *,
    section: str | None = None,
    sections: tuple[str, ...] = (),
    dialect: str = "ngspice",
    temperature_c: float = 27.0,
    include_paths: tuple[Path, ...] = (),
    **options: Any
) -> LibraryModel

Register lazily; parse the selected corner only when instantiated.

@tags circulax-simulation

Source code in circulax/netlist_io/model.py
@classmethod
def from_file(
    cls,
    path: str | Path,
    subcircuit: str,
    *,
    section: str | None = None,
    sections: tuple[str, ...] = (),
    dialect: str = "ngspice",
    temperature_c: float = 27.0,
    include_paths: tuple[Path, ...] = (),
    **options: Any,
) -> LibraryModel:
    """Register lazily; parse the selected corner only when instantiated.

    @tags circulax-simulation
    """
    model = cls(None, subcircuit, **options)
    model._source = (Path(path), section, tuple(sections), dialect, temperature_c, include_paths)
    return model

inspection_identity ¤

inspection_identity(settings: Mapping[str, Any] | None = None) -> dict[str, Any]

Return deterministic read-only provenance and resolved topology.

Source/include contents and available native artifact bytes contribute to the fingerprint. Native artifacts are not loaded or ABI-validated; this identity does not establish backend readiness.

Source code in circulax/netlist_io/model.py
def inspection_identity(self, settings: Mapping[str, Any] | None = None) -> dict[str, Any]:
    """Return deterministic read-only provenance and resolved topology.

    Source/include contents and available native artifact bytes contribute
    to the fingerprint. Native artifacts are not loaded or ABI-validated;
    this identity does not establish backend readiness.
    """
    resolved = self.resolve(settings)
    library = self._select_library({**self.defaults, **(settings or {})})
    artifacts = {Path(path).resolve() for path in self.options["osdi_modules"]}
    artifacts.update(Path(path).resolve() for _, path in resolved.loads)
    artifacts.update(Path(path).resolve() for path, _ in self._modules.get(library.revision, {}).values())
    native: list[dict[str, Any]] = []
    for path in sorted(artifacts, key=str):
        record: dict[str, Any] = {"path": str(path)}
        if path.is_file():
            record["sha256"] = hashlib.sha256(path.read_bytes()).hexdigest()
        else:
            record["missing"] = True
        native.append(record)
    identity = {
        "sources": library.inspection_sources(),
        "native_artifacts": native,
        "resolved": {
            "ports": dict(resolved.ports),
            "temperature_c": resolved.temperature_c,
            "corner": {**self.defaults, **(settings or {})}.get(
                "corner", self._source[1] if self._source is not None else None
            ),
            "instances": [
                {
                    "name": instance.name,
                    "module": instance.module,
                    "nodes": list(instance.nodes),
                    "parameters": dict(instance.parameters),
                }
                for instance in resolved.instances
            ],
            "loads": [{"name": name, "path": str(path)} for name, path in resolved.loads],
        },
    }
    identity["fingerprint"] = hashlib.sha256(json.dumps(identity, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
    return identity

instantiate ¤

instantiate(settings: Mapping[str, Any] | None = None) -> NetlistDefinition

Build the resolved topology and retain reusable native models.

Source code in circulax/netlist_io/model.py
def instantiate(self, settings: Mapping[str, Any] | None = None) -> NetlistDefinition:
    """Build the resolved topology and retain reusable native models."""
    resolved = self.resolve(settings)
    library = self._select_library({**self.defaults, **(settings or {})})
    signature = self._artifact_signature(resolved)
    cached = self._modules.get(library.revision) if self._module_signatures.get(library.revision) == signature else None
    netlist, models, modules = build_resolved(resolved, **self.options, modules=cached, expose_internal_nodes=False)
    self._modules[library.revision] = modules
    self._module_signatures[library.revision] = signature
    return NetlistDefinition(netlist, models)

resolve ¤

resolve(settings: Mapping[str, Any] | None = None) -> ResolvedCircuit

Evaluate settings and cards without building or provisioning models.

Returns ordered electrical leaf topology and public terminal aliases using the same corner, unit and expression bindings as execution. Source files may be parsed and cached; no native model, compiler or simulator is loaded or executed by this operation.

@tags circulax-simulation

Source code in circulax/netlist_io/model.py
def resolve(self, settings: Mapping[str, Any] | None = None) -> ResolvedCircuit:
    """Evaluate settings and cards without building or provisioning models.

    Returns ordered electrical leaf topology and public terminal aliases
    using the same corner, unit and expression bindings as execution.
    Source files may be parsed and cached; no native model, compiler or
    simulator is loaded or executed by this operation.

    @tags circulax-simulation
    """
    values = {**self.defaults, **(settings or {})}
    library = self._select_library(values)
    for name, translation in self.parameter_values.items():
        if name in values:
            if values[name] not in translation:
                msg = f"unsupported value {values[name]!r} for {name!r}; expected one of {tuple(translation)!r}"
                raise ValueError(msg)
            values[name] = translation[values[name]]
    for name, (source, target) in self.parameter_units.items():
        if name in values:
            values[name] = convert_unit(values[name], source, target)
    mapped = {
        self.params_map.get(name, name): value
        for name, value in values.items()
        if name in self.params_map or (not self.expressions and name != "temperature_c")
    }
    mapped.update({name: evaluate_source(expression, values) for name, expression in self.expressions.items()})
    resolved = library.instantiate(self.subcircuit, settings=mapped)
    if self.port_map:
        unknown = set(self.port_map.values()) - resolved.ports.keys()
        if unknown:
            msg = f"unknown library terminals {sorted(unknown)}"
            raise ValueError(msg)
        ports = {name: node for name, node in resolved.ports.items() if name not in self.port_map.values()}
        ports.update({alias: resolved.ports[terminal] for alias, terminal in self.port_map.items()})
        resolved.ports = ports
    return resolved

NetlistDefinition dataclass ¤

NetlistDefinition(source_netlist: Netlist, source_models: dict[str, Any])

Canonical topology and shared leaf models, before solver compilation.

NetlistError ¤

Bases: ValueError

A netlist could not be parsed or resolved without changing its meaning.

ResolvedCircuit dataclass ¤

ResolvedCircuit(
    instances: list[ResolvedInstance],
    loads: list[tuple[str, Path]],
    ports: dict[str, str] = dict(),
    temperature_c: float = 27.0,
)

Evaluated model cards awaiting conversion to kfnetlist and native models.

This intermediate retains ordered terminals and load declarations needed for OSDI provisioning. Compilation builds the canonical kfnetlist.Netlist.

Methods:

Name Description
compile

Compile with immutable native analysis mode: dc (default), ac, or tran.

compile ¤

compile(
    *,
    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
) -> Any

Compile with immutable native analysis mode: dc (default), ac, or tran.

simparams supplies numeric $simparam settings to all native devices. Separate registrations are required to change mode. For AC, retain DC conductance and obtain capacitance from an AC registration.

osdi_modules registers compiled .osdi/.va files by their own descriptor name directly, for libraries (e.g. plain ngspice model cards) that reference a module by name without a load statement.

Source code in circulax/netlist_io/library.py
def compile(
    self,
    *,
    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,
) -> Any:
    """Compile with immutable native analysis mode: dc (default), ac, or tran.

    simparams supplies numeric $simparam settings to all native devices.
    Separate registrations are required to change mode. For AC, retain
    DC conductance and obtain capacitance from an AC registration.

    osdi_modules registers compiled `.osdi`/`.va` files by their own
    descriptor name directly, for libraries (e.g. plain ngspice model
    cards) that reference a module by name without a `load` statement.
    """
    from circulax.netlist_io.osdi import compile_resolved

    return compile_resolved(
        self,
        module_paths=module_paths,
        osdi_modules=osdi_modules,
        compiler=compiler,
        cache_dir=cache_dir,
        backend=backend,
        analysis=analysis,
        state_policy=state_policy,
        simparams=simparams,
    )

ResolvedInstance dataclass ¤

ResolvedInstance(
    name: str, module: str, nodes: tuple[str, ...], parameters: dict[str, float]
)

A leaf compact model with evaluated model and instance parameters.

parse_source ¤

parse_source(
    spec: str,
    *,
    dialect: str = "spice",
    tstep: float | None = None,
    tstop: float | None = None,
    pwl_points: int | None = None
) -> dict[str, float | tuple[float, ...]]

Parse the value part of a SPICE V/I source card: [DC] value and/or a waveform.

Accepts "1.2", "DC 1.2", "SIN(...)" and "DC 0.5 SIN(...)" (in either order). dc_given preserves whether an operating-point override was supplied. Without one, DC analysis uses the waveform's time-zero value. Transients always initialize from the waveform, independently of an explicit DC override. AC clauses are rejected (source-driven AC is out of scope).

Returns:

Type Description
dict[str, float | tuple[float, ...]]

Keyword settings with kind, dc and dc_given plus waveform fields, ready

dict[str, float | tuple[float, ...]]

for WaveformVoltageSource(**settings) / WaveformCurrentSource(**settings).

@tags circulax-simulation

Source code in circulax/netlist_io/sources.py
def parse_source(
    spec: str,
    *,
    dialect: str = "spice",
    tstep: float | None = None,
    tstop: float | None = None,
    pwl_points: int | None = None,
) -> dict[str, float | tuple[float, ...]]:
    """Parse the value part of a SPICE V/I source card: ``[DC] value`` and/or a waveform.

    Accepts ``"1.2"``, ``"DC 1.2"``, ``"SIN(...)"`` and ``"DC 0.5 SIN(...)"`` (in
    either order). ``dc_given`` preserves whether an operating-point override
    was supplied. Without one, DC analysis uses the waveform's time-zero value.
    Transients always initialize from the waveform, independently of an explicit
    DC override. ``AC`` clauses are rejected (source-driven AC is out of scope).

    Returns:
        Keyword settings with ``kind``, ``dc`` and ``dc_given`` plus waveform fields, ready
        for ``WaveformVoltageSource(**settings)`` / ``WaveformCurrentSource(**settings)``.

    @tags circulax-simulation

    """
    calls = list(_CALL.finditer(spec))
    if len(calls) > 1:
        msg = f"only one transient waveform is allowed per source; got {spec!r}"
        raise NetlistError(msg)
    call = calls[0] if calls else None
    settings: dict[str, float | tuple[float, ...]] = {"kind": WAVE_DC}
    rest = spec
    if call is not None:
        settings, end = _parse_call(spec, call, dialect)
        rest = f"{spec[: call.start()]} {spec[end:]}"
    tokens = _tokens(rest)
    if tokens and tokens[0].lower() == "dc":
        tokens = tokens[1:]
        if not tokens:
            msg = "DC requires a value"
            raise NetlistError(msg)
    if tokens and tokens[0].lower() == "ac":
        msg = "AC source magnitudes are not supported"
        raise NetlistError(msg)
    if len(tokens) > 1:
        msg = f"unsupported source clauses {' '.join(tokens[1:])!r}"
        raise NetlistError(msg)
    # An empty specification is SPICE's zero-valued source (e.g. an ammeter).
    dc_given = float(bool(tokens))
    dc = _number(tokens[0], dialect, "DC") if tokens else 0.0
    settings = _finish_settings(settings, tstep, tstop, pwl_points)
    return {**settings, "dc": dc, "dc_given": dc_given}

parse_waveform ¤

parse_waveform(
    waveform: str,
    *,
    dialect: str = "spice",
    tstep: float | None = None,
    tstop: float | None = None,
    pwl_points: int | None = None
) -> dict[str, float | tuple[float, ...]]

Parse SIN(...), PULSE(...) or PWL(...) into waveform source settings.

Parameters:

Name Type Description Default
waveform str

SPICE waveform text, e.g. "PULSE(0 1 1n 1n 1n 10n 20n)".

required
dialect str

Number-suffix dialect; "spice" reads M as milli.

'spice'
tstep float | None

SPICE TSTEP for zero/omitted PULSE rise and fall times. When absent, the transient solver supplies its initial time step.

None
tstop float | None

SPICE TSTOP for zero/omitted PULSE width and period and SIN frequency 1 / tstop. When absent, the analysis supplies it.

None
pwl_points int | None

When set, emit pwl_t/pwl_v of exactly this length for every kind (PWL padded by repeating its last point, others zeros) so instances of different kinds stack into one batched group. PWL instances with more points raise.

None

Returns:

Type Description
dict[str, float | tuple[float, ...]]

Keyword settings for the waveform source components, including kind.

dict[str, float | tuple[float, ...]]

Fields not needed by the waveform keep their component defaults.

@tags circulax-simulation

Source code in circulax/netlist_io/sources.py
def parse_waveform(
    waveform: str,
    *,
    dialect: str = "spice",
    tstep: float | None = None,
    tstop: float | None = None,
    pwl_points: int | None = None,
) -> dict[str, float | tuple[float, ...]]:
    """Parse ``SIN(...)``, ``PULSE(...)`` or ``PWL(...)`` into waveform source settings.

    Args:
        waveform: SPICE waveform text, e.g. ``"PULSE(0 1 1n 1n 1n 10n 20n)"``.
        dialect: Number-suffix dialect; ``"spice"`` reads ``M`` as milli.
        tstep: SPICE ``TSTEP`` for zero/omitted PULSE rise and fall times.
            When absent, the transient solver supplies its initial time step.
        tstop: SPICE ``TSTOP`` for zero/omitted PULSE width and period and
            SIN frequency ``1 / tstop``. When absent, the analysis supplies it.
        pwl_points: When set, emit ``pwl_t``/``pwl_v`` of exactly this length for
            *every* kind (PWL padded by repeating its last point, others zeros) so
            instances of different kinds stack into one batched group. PWL
            instances with more points raise.

    Returns:
        Keyword settings for the waveform source components, including ``kind``.
        Fields not needed by the waveform keep their component defaults.

    @tags circulax-simulation

    """
    text = waveform.strip()
    match = _CALL.match(text)
    if match is None:
        msg = f"unsupported transient source waveform {waveform!r}"
        raise NetlistError(msg)
    result, end = _parse_call(text, match, dialect)
    tail = text[end:].strip()
    if tail:
        msg = f"unsupported source clauses {tail!r}"
        raise NetlistError(msg)
    return _finish_settings(result, tstep, tstop, pwl_points)