Source code for syssimx.system.loader

"""Declarative system descriptions: build a ``System`` from YAML/JSON.

A system description is a mapping with the following shape (YAML shown;
JSON with the same structure is equally supported)::

    system:
      name: Quickstart
    components:
      - name: Source
        class: "my_package.components:LinearSource"   # "module:ClassName"
        args: {a: 1.0, b: 0.0}                        # constructor kwargs
        parameters: {gain: 2.0}                       # set_parameters(...) after construction
    connections:
      - src: Source.y            # "Component.port" (split at the first dot) ...
        dst: Integrator.u
      - src: {component: A, port: out}                # ... or explicit mapping form
        dst: {component: B, port: in}
    event_connections:                                # optional
      - src: Plant.wall_hit
        dst: Controller.reset
    algorithm:                                        # optional (default gauss_seidel)
      type: gauss_seidel        # jacobi | gauss_seidel | hybrid | ijcsa
    run:                                              # optional defaults for run_from_config/CLI
      t0: 0.0
      tf: 5.0
      dt: 0.1

Component classes are resolved by import path, so any installed
``CoSimComponent`` subclass — including user-defined ones — can be used
without registration. Use :func:`build_system` to assemble a ``System`` and
:func:`run_from_config` to assemble, initialize, and run it in one call.
"""

from __future__ import annotations

import importlib
import json
from pathlib import Path
from typing import Any

import yaml

from ..core.base import CoSimComponent
from .algorithms import (
    Algorithm,
    GaussSeidelAlgorithm,
    HybridAlgorithm,
    IJCSAAlgorithm,
    JacobiAlgorithm,
)
from .connection import Connection, EventConnection
from .results import SimulationResult
from .system import System

ALGORITHMS: dict[str, type[Algorithm]] = {
    "jacobi": JacobiAlgorithm,
    "gauss_seidel": GaussSeidelAlgorithm,
    "hybrid": HybridAlgorithm,
    "ijcsa": IJCSAAlgorithm,
}


[docs] class ConfigError(ValueError): """Raised when a system description is invalid."""
# ---------------------------------------------------------------------------- # Config loading # ----------------------------------------------------------------------------
[docs] def load_config(path: str | Path) -> dict[str, Any]: """Load a system description from a YAML or JSON file. The format is chosen by file extension: ``.json`` is parsed as JSON, everything else (``.yaml``/``.yml``) as YAML. Args: path: Path to the description file. Returns: The parsed configuration mapping. Raises: ConfigError: If the file does not contain a mapping at top level. """ path = Path(path) text = path.read_text() if path.suffix.lower() == ".json": config = json.loads(text) else: config = yaml.safe_load(text) if not isinstance(config, dict): raise ConfigError(f"{path}: top level of a system description must be a mapping") return config
# ---------------------------------------------------------------------------- # System assembly # ----------------------------------------------------------------------------
[docs] def build_system(config: dict[str, Any] | str | Path) -> System: """Assemble a :class:`System` from a description. Args: config: A parsed configuration mapping, or a path to a YAML/JSON file (which is loaded via :func:`load_config`). Returns: The assembled (not yet initialized) system. Raises: ConfigError: On any invalid or missing configuration entry. """ if isinstance(config, (str, Path)): config = load_config(config) name = config.get("system", {}).get("name", "System") system = System(name=name) components = config.get("components") if not components: raise ConfigError("System description must define at least one component") for entry in components: system.add_component(_build_component(entry)) for entry in config.get("connections", []) or []: src_comp, src_port = _parse_endpoint(entry, "src") dst_comp, dst_port = _parse_endpoint(entry, "dst") _require_component(system, src_comp, entry) _require_component(system, dst_comp, entry) system.add_connection( Connection(src_comp=src_comp, src_port=src_port, dst_comp=dst_comp, dst_port=dst_port) ) for entry in config.get("event_connections", []) or []: src_comp, src_event = _parse_endpoint(entry, "src") dst_comp, dst_port = _parse_endpoint(entry, "dst") _require_component(system, src_comp, entry) _require_component(system, dst_comp, entry) system.add_event_connection( EventConnection( src_comp=src_comp, src_port=src_event, dst_comp=dst_comp, dst_port=dst_port ) ) algo_cfg = config.get("algorithm") if algo_cfg: if not isinstance(algo_cfg, dict): raise ConfigError( f"'algorithm' must be a mapping with a 'type' key, " f"got {type(algo_cfg).__name__}" ) algo_type = algo_cfg.get("type") if algo_type not in ALGORITHMS: raise ConfigError( f"Unknown algorithm type '{algo_type}'. " f"Available: {', '.join(sorted(ALGORITHMS))}" ) system.set_algorithm(ALGORITHMS[algo_type]()) return system
[docs] def run_from_config( config: dict[str, Any] | str | Path, t0: float | None = None, tf: float | None = None, dt: float | None = None, progress: Any = None, ) -> SimulationResult: """Assemble, initialize, and run a system from a description. Run settings are taken from the description's ``run`` section; the ``t0``/``tf``/``dt`` arguments override individual entries. Args: config: Configuration mapping or path to a YAML/JSON file. t0: Override for the start time. tf: Override for the end time. dt: Override for the macro step size. progress: Optional progress callback forwarded to ``System.run``. Returns: The :class:`SimulationResult` of the run. Raises: ConfigError: If any of ``t0``/``tf``/``dt`` is neither configured nor provided as an argument. """ if isinstance(config, (str, Path)): config = load_config(config) run_cfg = dict(config.get("run", {}) or {}) if t0 is not None: run_cfg["t0"] = t0 if tf is not None: run_cfg["tf"] = tf if dt is not None: run_cfg["dt"] = dt missing = [key for key in ("t0", "tf", "dt") if key not in run_cfg] if missing: raise ConfigError( f"Run settings missing: {', '.join(missing)} " "(add a 'run:' section to the description or pass them as arguments)" ) system = build_system(config) system.initialize(t0=float(run_cfg["t0"])) return system.run( t0=float(run_cfg["t0"]), tf=float(run_cfg["tf"]), dt=float(run_cfg["dt"]), progress=progress, )
# ---------------------------------------------------------------------------- # Helpers # ----------------------------------------------------------------------------
[docs] def _build_component(entry: dict[str, Any]) -> CoSimComponent: """Instantiate one component from its description entry.""" if "name" not in entry: raise ConfigError(f"Component entry missing 'name': {entry}") if "class" not in entry: raise ConfigError(f"Component '{entry['name']}' missing 'class' (module:ClassName)") cls = _resolve_class(entry["class"]) args = entry.get("args", {}) or {} try: component = cls(name=entry["name"], **args) except TypeError as exc: raise ConfigError( f"Component '{entry['name']}': cannot construct {entry['class']} " f"with args {args}: {exc}" ) from exc if not isinstance(component, CoSimComponent): raise ConfigError( f"Component '{entry['name']}': {entry['class']} is not a CoSimComponent" ) parameters = entry.get("parameters") if parameters: if not isinstance(parameters, dict): raise ConfigError( f"Component '{entry['name']}': 'parameters' must be a mapping, " f"got {type(parameters).__name__}" ) try: component.set_parameters(**parameters) except (KeyError, TypeError, ValueError) as exc: raise ConfigError( f"Component '{entry['name']}': invalid parameters {parameters}: {exc}" ) from exc return component
[docs] def _resolve_class(spec: str) -> type: """Resolve a ``"module.path:ClassName"`` import spec to a class.""" if ":" not in spec: raise ConfigError( f"Invalid class spec '{spec}': expected 'module.path:ClassName'" ) module_path, class_name = spec.rsplit(":", 1) try: module = importlib.import_module(module_path) except ImportError as exc: raise ConfigError(f"Cannot import module '{module_path}': {exc}") from exc try: cls = getattr(module, class_name) except AttributeError as exc: raise ConfigError( f"Module '{module_path}' has no attribute '{class_name}'" ) from exc if not isinstance(cls, type): raise ConfigError(f"'{spec}' does not resolve to a class") return cls
[docs] def _parse_endpoint(entry: dict[str, Any], key: str) -> tuple[str, str]: """Parse a connection endpoint: ``"Comp.port"`` or ``{component, port}``.""" if key not in entry: raise ConfigError(f"Connection entry missing '{key}': {entry}") value = entry[key] if isinstance(value, dict): try: return str(value["component"]), str(value["port"]) except KeyError as exc: raise ConfigError( f"Connection endpoint mapping needs 'component' and 'port': {value}" ) from exc if isinstance(value, str) and "." in value: comp, port = value.split(".", 1) return comp, port raise ConfigError( f"Invalid connection endpoint '{value}': " "use 'Component.port' or {component: ..., port: ...}" )
[docs] def _require_component(system: System, comp_name: str, entry: dict[str, Any]) -> None: """Raise a helpful error when a connection references an unknown component.""" if comp_name not in system.components: raise ConfigError( f"Connection {entry} references unknown component '{comp_name}'. " f"Defined components: {', '.join(system.components)}" )