Source code for pySimBlocks.project.load_project_config

# ******************************************************************************
#                                  pySimBlocks
#                     Copyright (c) 2026 Université de Lille & INRIA
# ******************************************************************************
#  This program is free software: you can redistribute it and/or modify it
#  under the terms of the GNU Lesser General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or (at your
#  option) any later version.
#
#  This program is distributed in the hope that it will be useful, but WITHOUT
#  ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
#  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License
#  for more details.
#
#  You should have received a copy of the GNU Lesser General Public License
#  along with this program.  If not, see <https://www.gnu.org/licenses/>.
# ******************************************************************************
#  Authors: see Authors.txt
# ******************************************************************************

from __future__ import annotations

from pathlib import Path
from typing import Any, Dict, Tuple

from pySimBlocks.core.config import PlotConfig, SimulationConfig
from pySimBlocks.project.load_simulation_config import (
    _check_no_external_refs,
    _load_external_module,
    _load_yaml,
    _resolve_external_refs,
    eval_recursive,
)


def _validate_schema_version(raw: Dict[str, Any]) -> None:
    """Raise if ``schema_version`` is missing or not equal to 1."""
    schema_version = raw.get("schema_version", None)
    if schema_version != 1:
        raise ValueError(
            f"Unsupported or missing 'schema_version': {schema_version!r}. "
            "Expected: 1"
        )


def _load_scope(raw: Dict[str, Any], project_yaml: Path) -> Tuple[Any, Dict[str, Any]]:
    """Load the external parameters module and return (module, scope dict)."""
    simulation = raw.get("simulation", {})
    if not isinstance(simulation, dict):
        raise ValueError("'simulation' section must be a mapping")

    external_module_path = simulation.get("external_module", None)
    if external_module_path is None:
        _check_no_external_refs(raw)
        return None, {}

    if not isinstance(external_module_path, str):
        raise ValueError("'simulation.external_module' must be a path to a Python file")

    external_path = Path(external_module_path).expanduser()
    if not external_path.is_absolute():
        external_path = project_yaml.parent / external_path
    external_module, scope = _load_external_module(external_path)
    return external_module, scope


def _build_plot_config(sim_data: Dict[str, Any]) -> PlotConfig | None:
    """Build and validate a :class:`PlotConfig` from the simulation section, or return None."""
    plots_data = sim_data.get("plots", None)
    if plots_data is None:
        return None

    if not isinstance(plots_data, list):
        raise ValueError("'simulation.plots' section must be a list")

    plot_cfg = PlotConfig(plots=plots_data)
    plot_cfg.validate()
    return plot_cfg


def _adapt_diagram_to_model_dict(
    diagram_data: Dict[str, Any],
    scope: Dict[str, Any],
) -> Dict[str, Any]:
    """Convert a diagram section dict into the model dict format expected by build_model."""
    blocks = diagram_data.get("blocks", [])
    if not isinstance(blocks, list):
        raise ValueError("'diagram.blocks' section must be a list")

    model_blocks: list[dict[str, Any]] = []
    for desc in blocks:
        if not isinstance(desc, dict):
            raise ValueError("Each block in 'diagram.blocks' must be a mapping")

        name = desc.get("name")
        category = desc.get("category")
        block_type = desc.get("type")
        if not isinstance(name, str) or not isinstance(category, str) or not isinstance(block_type, str):
            raise ValueError(
                "Each block in 'diagram.blocks' must define string fields: "
                "'name', 'category', and 'type'"
            )

        params_raw = desc.get("parameters", {})
        if not isinstance(params_raw, dict):
            raise ValueError(
                f"'diagram.blocks[{name}].parameters' must be a mapping"
            )

        model_blocks.append(
            {
                "name": name,
                "category": category,
                "type": block_type,
                "parameters": eval_recursive(params_raw, scope),
            }
        )

    connections_raw = diagram_data.get("connections", [])
    if not isinstance(connections_raw, list):
        raise ValueError("'diagram.connections' section must be a list")

    model_connections: list[list[str]] = []
    for conn in connections_raw:
        if not isinstance(conn, dict):
            raise ValueError("Each connection in 'diagram.connections' must be a mapping")

        ports = conn.get("ports", None)
        if not isinstance(ports, list) or len(ports) != 2:
            raise ValueError(
                "Each connection in 'diagram.connections' must define "
                "'ports: [src, dst]'"
            )

        src, dst = ports
        if not isinstance(src, str) or not isinstance(dst, str):
            raise ValueError(
                "Connection ports in 'diagram.connections' must be strings"
            )
        model_connections.append([src, dst])

    return {
        "blocks": model_blocks,
        "connections": model_connections,
    }


[docs] def load_project_config( project_yaml: str | Path, ) -> Tuple[SimulationConfig, Dict[str, Any], PlotConfig | None, str, Path]: """Load a full pySimBlocks unified project.yaml configuration. Parses and validates all sections of the project file, evaluates parameter expressions against the optional external parameters module, and returns all configuration objects needed to build and run the simulation. Args: project_yaml: Path to the unified ``project.yaml`` file. Returns: A tuple ``(sim_cfg, model_dict, plot_cfg, project_name, params_dir)`` where: - ``sim_cfg``: validated :class:`SimulationConfig`. - ``model_dict``: model dict with ``'blocks'`` and ``'connections'`` sections ready to pass to :func:`build_model_from_dict`. - ``plot_cfg``: :class:`PlotConfig` or None if no plots are defined. - ``project_name``: project name string from ``project.name``. - ``params_dir``: resolved directory of the project file. Raises: FileNotFoundError: If the project file does not exist. ValueError: If the file is malformed, the schema version is wrong, or required fields are missing. """ project_yaml = Path(project_yaml) raw = _load_yaml(project_yaml) _validate_schema_version(raw) external_module, scope = _load_scope(raw, project_yaml) resolved = _resolve_external_refs(raw, external_module) if external_module else raw project_data = resolved.get("project", {}) if not isinstance(project_data, dict): raise ValueError("'project' section must be a mapping") project_name = project_data.get("name", "model") if not isinstance(project_name, str) or not project_name.strip(): raise ValueError("'project.name' must be a non-empty string") sim_data = resolved.get("simulation", {}) if not isinstance(sim_data, dict): raise ValueError("'simulation' section must be a mapping") required = {"dt", "T"} missing = required - sim_data.keys() if missing: raise ValueError( f"Missing required simulation parameters in 'simulation': {sorted(missing)}" ) sim_eval_data = eval_recursive(sim_data, scope) sim_cfg = SimulationConfig( dt=sim_eval_data["dt"], T=sim_eval_data["T"], t0=sim_eval_data.get("t0", 0.0), solver=sim_eval_data.get("solver", "fixed"), logging=sim_data.get("logging", []), clock=sim_eval_data.get("clock", "internal"), ) sim_cfg.validate() plot_cfg = _build_plot_config(sim_data) diagram_data = resolved.get("diagram", {}) if not isinstance(diagram_data, dict): raise ValueError("'diagram' section must be a mapping") model_dict = _adapt_diagram_to_model_dict(diagram_data, scope) return sim_cfg, model_dict, plot_cfg, project_name, project_yaml.parent.resolve()