diff --git a/src/oedisi/componentframework/basic_component.py b/src/oedisi/componentframework/basic_component.py index 8780ab1..c8ff522 100644 --- a/src/oedisi/componentframework/basic_component.py +++ b/src/oedisi/componentframework/basic_component.py @@ -3,11 +3,14 @@ import json import os from shutil import copytree -from . import system_configuration -from .system_configuration import AnnotatedType -from pydantic import BaseModel from typing import Any +from pydantic import BaseModel, Field + +from . import system_configuration +from .system_configuration import AnnotatedType, ComponentCapabilities +from oedisi.types.helics_config import HELICSFederateConfig + class ComponentDescription(BaseModel): """Component description for simple ComponentType. @@ -24,6 +27,8 @@ class ComponentDescription(BaseModel): List of input types. Typically subscriptions. dynamic_outputs : List of output types. Typically publications. + capabilities : + Component capability declarations for build-time validation. """ directory: str @@ -31,6 +36,7 @@ class ComponentDescription(BaseModel): static_inputs: list[AnnotatedType] dynamic_inputs: list[AnnotatedType] dynamic_outputs: list[AnnotatedType] + capabilities: ComponentCapabilities = Field(default_factory=ComponentCapabilities) def types_to_dict(types: list[AnnotatedType]): @@ -64,17 +70,18 @@ class BasicComponent(system_configuration.ComponentType): _dynamic_inputs = types_to_dict(comp_desc.dynamic_inputs) _dynamic_outputs = types_to_dict(comp_desc.dynamic_outputs) _static_inputs = types_to_dict(comp_desc.static_inputs) + _capabilities = comp_desc.capabilities def __init__( self, - name, + base_config: HELICSFederateConfig, parameters: dict[str, Any], directory: str, host: str, port: int, comp_type: str, ): - self._name = name + self._base_config = base_config self._directory = directory self._parameters = parameters self.check_parameters(parameters) @@ -95,9 +102,13 @@ def copy_code_into_directory(self): copytree(self._origin_directory, self._directory, dirs_exist_ok=True) def generate_parameter_config(self): - self._parameters["name"] = self._name + if self.broker_config_support: + config = self._base_config.to_dict().update(self._parameters) + else: # Backwards compatible behavior where we ignore extra information. + config = self._parameters + config["name"] = self._base_config.name with open(os.path.join(self._directory, "static_inputs.json"), "w") as f: - json.dump(self._parameters, f) + json.dump(config, f) def generate_input_mapping(self, links): with open(os.path.join(self._directory, "input_mapping.json"), "w") as f: @@ -115,4 +126,8 @@ def dynamic_outputs(self): def execute_function(self): return self._execute_function + @property + def broker_config_support(self): + return self._capabilities.broker_config + return BasicComponent diff --git a/src/oedisi/componentframework/mock_component.py b/src/oedisi/componentframework/mock_component.py index 6347b5d..0b9c293 100644 --- a/src/oedisi/componentframework/mock_component.py +++ b/src/oedisi/componentframework/mock_component.py @@ -13,8 +13,10 @@ import logging import json import os + from . import system_configuration from .system_configuration import AnnotatedType +from oedisi.types.helics_config import HELICSFederateConfig logger = logging.getLogger(__name__) @@ -25,14 +27,14 @@ class MockComponent(system_configuration.ComponentType): def __init__( self, - name, + base_config: HELICSFederateConfig, parameters: dict[str, dict[str, str]], directory: str, host: str | None = None, port: int | None = None, comp_type: str | None = None, ): - self._name = name + self._base_config = base_config self._directory = directory self._execute_function = os.path.join( os.path.dirname(os.path.abspath(__file__)), "mock_component.sh" @@ -51,7 +53,7 @@ def process_parameters(self, parameters): def generate_helics_config(self, outputs): helics_config = { - "name": self._name, + "name": self._base_config.name, "core_type": "zmq", "period": 1, "log_level": "warning", diff --git a/src/oedisi/componentframework/system_configuration.py b/src/oedisi/componentframework/system_configuration.py index 7c7ebe2..d58ea4d 100644 --- a/src/oedisi/componentframework/system_configuration.py +++ b/src/oedisi/componentframework/system_configuration.py @@ -23,6 +23,23 @@ from pydantic import field_validator, BaseModel, ValidationInfo from oedisi.types.common import DOCKER_HUB_USER, APP_NAME +from oedisi.types.helics_config import HELICSFederateConfig, SharedFederateConfig + + +class ComponentCapabilities(BaseModel): + """Component capability declarations for build-time validation. + + Parameters + ---------- + version : + Capabilities schema version. + broker_config : + Whether this component supports receiving federate_config in static_inputs.json. + If True, the component can be used with WiringDiagram.shared_helics_config. + """ + + version: str = "1.0" + broker_config: bool = False class AnnotatedType(BaseModel): @@ -67,10 +84,12 @@ class ComponentType(ABC): to run the component. """ + _capabilities: ComponentCapabilities = ComponentCapabilities() + @abstractmethod def __init__( self, - name: str, + base_config: HELICSFederateConfig, parameters: dict[str, dict[str, str]], directory: str, host: str | None = None, @@ -128,6 +147,7 @@ class Component(BaseModel): container_port: int | None = None image: str = "" parameters: dict[str, Any] + helics_config_override: SharedFederateConfig | None = None def port(self, port_name: str): return Port(name=self.name, port_name=port_name) @@ -147,11 +167,25 @@ class ComponentStruct(BaseModel): class WiringDiagram(BaseModel): - """Cosimulation configuration. This may end up wrapped in another interface.""" + """Cosimulation configuration. This may end up wrapped in another interface. + + Parameters + ---------- + name : + Name of the simulation. + components : + List of components in the simulation. + links : + List of links connecting component ports. + shared_helics_config : + Optional shared federate configuration applied to all components. + Per-component values (name, core_name) are derived automatically. + """ name: str components: list[Component] links: list[Link] + shared_helics_config: SharedFederateConfig | None = None def clean_model(self, target_directory="."): for component in self.components: @@ -201,7 +235,7 @@ def add_link(self, link: Link): @classmethod def empty(cls, name="unnamed"): - return cls(name=name, components=[], links=[]) + return cls(name=name, components=[], links=[], shared_helics_config=None) class Federate(BaseModel): @@ -234,8 +268,34 @@ def initialize_federates( if not os.path.exists(directory): os.makedirs(directory) component_type = component_types[component.type] + + # Generate per-component federate config + federate_config = None + if component.helics_config_override is not None: + logging.warning( + f"Component '{component.name}' uses federate_config_override. " + "Per-component overrides can cause subtle timing issues." + ) + federate_config = component.helics_config_override.to_federate_config( + name=component.name + ) + elif wiring_diagram.shared_helics_config is not None: + federate_config = wiring_diagram.shared_helics_config.to_federate_config( + name=component.name + ) + + # Validate broker config support + if federate_config is not None and not component_type._capabilities.broker_config: + raise ValueError( + f"Component '{component.name}' (type: {component.type}) does not support " + 'HELICS configuration. Add \'"capabilities": {"broker_config": true}\' ' + "to the component's component_definition.json file." + ) + elif federate_config is None: + federate_config = HELICSFederateConfig(name=component.name) + initialized_component = component_type( - component.name, + federate_config, component.parameters, directory, component.host, @@ -247,17 +307,17 @@ def initialize_federates( for link in wiring_diagram.links: source_types = components[link.source].dynamic_outputs target_types = components[link.target].dynamic_inputs - assert ( - link.source_port in source_types - ), f"{link.source} does not have {link.source_port}" - assert ( - link.target_port in target_types - ), f"{link.target} does not have dynamic input {link.target_port}" + assert link.source_port in source_types, ( + f"{link.source} does not have {link.source_port}" + ) + assert link.target_port in target_types, ( + f"{link.target} does not have dynamic input {link.target_port}" + ) source_type = source_types[link.source_port] target_type = target_types[link.target_port] - assert compatability_checker( - source_type, target_type - ), f"{source_type} is not compatible with {target_type}" + assert compatability_checker(source_type, target_type), ( + f"{source_type} is not compatible with {target_type}" + ) federates = [] for name, component in components.items(): diff --git a/src/oedisi/tools/cli_tools.py b/src/oedisi/tools/cli_tools.py index 5b6bb85..4dd48df 100644 --- a/src/oedisi/tools/cli_tools.py +++ b/src/oedisi/tools/cli_tools.py @@ -132,6 +132,21 @@ def build( click.echo(f"Building system in {target_directory}") if multi_container: + # Validate no broker overrides in multicontainer mode + if wiring_diagram.shared_helics_config is not None: + raise ValueError( + "Multicontainer builds do not support 'shared_helics_config'. " + "Broker configuration is controlled by the broker service at runtime." + ) + + for component in wiring_diagram.components: + if component.helics_config_override is not None: + raise ValueError( + f"Component '{component.name}' has 'helics_config_override'. " + "Multicontainer builds do not support per-component HELICS overrides. " + "Broker configuration is controlled by the broker service at runtime." + ) + if simulation_id is None: simulation_id = str(uuid4()) click.echo(f"Simulation ID: {simulation_id}") diff --git a/src/oedisi/types/__init__.py b/src/oedisi/types/__init__.py index 0552768..44b9cb1 100644 --- a/src/oedisi/types/__init__.py +++ b/src/oedisi/types/__init__.py @@ -1 +1,13 @@ __version__ = "3.0.1" + +from oedisi.types.helics_config import ( + HELICSBrokerConfig, + HELICSFederateConfig, + SharedFederateConfig, +) + +__all__ = [ + "HELICSBrokerConfig", + "HELICSFederateConfig", + "SharedFederateConfig", +] diff --git a/src/oedisi/types/helics_config.py b/src/oedisi/types/helics_config.py new file mode 100644 index 0000000..0907f4c --- /dev/null +++ b/src/oedisi/types/helics_config.py @@ -0,0 +1,231 @@ +"""HELICS federate configuration types for type-safe simulation setup.""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field +from oedisi.types.common import BrokerConfig + + +class HELICSBrokerConfig(BaseModel): + """HELICS broker connection parameters. + + Parameters + ---------- + host : + Broker hostname or IP address. + port : + Broker port number. + key : + Broker key for authentication. + auto : + Whether to automatically configure broker connection. + initstring : + Additional initialization string for broker connection. + """ + + model_config = ConfigDict(populate_by_name=True) + + host: str | None = None + port: int | None = None + key: str | None = None + auto: bool | None = None + initstring: str | None = Field(default=None, alias="initString") + + @classmethod + def from_rest_config(cls, rest: BrokerConfig) -> HELICSBrokerConfig: + """Convert REST API BrokerConfig to HELICS native broker config.""" + return cls(host=rest.broker_ip, port=rest.broker_port) + + +class HELICSFederateConfig(BaseModel): + """Full HELICS federate configuration. + + This is what federates receive in static_inputs.json under the `federate_config` key. + Subtype this in your applications for custom configuration. + + Parameters + ---------- + name : + Federate name (derived from Component.name). + core_type : + HELICS core type (e.g., "zmq", "tcp", "inproc"). + core_name : + Core name for this federate (derived per-component). + core_init_string : + Core initialization string. + broker : + Broker connection configuration. + + Examples + -------- + >>> config = HELICSFederateConfig( + ... name="state_estimator", + ... core_type="zmq", + ... broker=HELICSBrokerConfig(port=23404) + ... ) + >>> config.to_json() + '{"name": "state_estimator", "coreType": "zmq", ...}' + """ + + model_config = ConfigDict(populate_by_name=True) + + name: str + core_type: str | None = Field(default=None, alias="coreType") + core_name: str | None = Field(default=None, alias="coreName") + core_init_string: str | None = Field(default=None, alias="coreInitString") + broker: HELICSBrokerConfig | None = None + + def to_json(self) -> str: + """Serialize to JSON string using HELICS-style camelCase keys.""" + return self.model_dump_json(by_alias=True, exclude_none=True) + + def to_dict(self) -> dict: + """Convert to dictionary using HELICS-style camelCase keys.""" + return self.model_dump(by_alias=True, exclude_none=True) + + def apply_to_federate_info(self, info) -> None: + """Apply configuration to a helics.HelicsFederateInfo object. + + Parameters + ---------- + info : + A helics.HelicsFederateInfo object to configure. + + Notes + ----- + This method requires the helics Python package to be installed. + It modifies the info object in place. + """ + if self.core_type is not None: + info.core_type = self.core_type + if self.core_name is not None: + info.core_name = self.core_name + if self.core_init_string is not None: + info.core_init_string = self.core_init_string + if self.broker is not None: + if self.broker.host is not None: + info.broker = self.broker.host + if self.broker.port is not None: + info.broker_port = self.broker.port + if self.broker.key is not None: + info.broker_key = self.broker.key + + @classmethod + def from_multicontainer( + cls, + broker_config: BrokerConfig, + params: dict | None = None, + core_type: str = "zmq", + **kwargs, + ) -> HELICSFederateConfig: + """Create federate config for multicontainer deployments. + + This is a convenience method for components running in Docker/Kubernetes + that receive BrokerConfig from the broker service's /run endpoint. + Supports subclasses of HELICSFederateConfig with additional parameters. + + Parameters + ---------- + broker_config : + REST API broker configuration from /run endpoint. + params : + Component parameters from static_inputs.json. If provided, + must contain 'name' key. All other keys are passed to the config. + core_type : + HELICS core type, defaults to "zmq". + **kwargs : + Additional config options (overrides params dict if both provided). + + Returns + ------- + HELICSFederateConfig + Complete federate configuration ready to apply. If called on a + subclass, returns an instance of that subclass. + + Examples + -------- + >>> # Basic usage with params dict + >>> with open("static_inputs.json") as f: + ... params = json.load(f) + >>> config = HELICSFederateConfig.from_multicontainer( + ... broker_config=broker_config, + ... params=params + ... ) + >>> fedinfo = h.helicsCreateFederateInfo() + >>> config.apply_to_federate_info(fedinfo) + + >>> # With custom subclass + >>> class MyComponentConfig(HELICSFederateConfig): + ... my_param: str + >>> config = MyComponentConfig.from_multicontainer( + ... broker_config=broker_config, + ... params={"name": "comp1", "my_param": "value"} + ... ) + """ + if params is not None: + # Merge params with kwargs, kwargs take precedence + merged = {**params, **kwargs} + merged.setdefault("core_type", core_type) + else: + merged = {"core_type": core_type, **kwargs} + + merged["broker"] = HELICSBrokerConfig.from_rest_config(broker_config) + return cls(**merged) + + +class SharedFederateConfig(BaseModel): + """Shared federate settings at the WiringDiagram level. + + This contains settings that are shared across all federates in a simulation. + Does NOT include name/core_name (those are per-component). + + Parameters + ---------- + core_type : + HELICS core type (e.g., "zmq", "tcp", "inproc"). + core_init_string : + Core initialization string. + broker : + Broker connection configuration. + + Examples + -------- + >>> shared = SharedFederateConfig( + ... core_type="zmq", + ... broker=HELICSBrokerConfig(port=23404) + ... ) + >>> config = shared.to_federate_config("my_federate") + >>> config.name + 'my_federate' + """ + + model_config = ConfigDict(populate_by_name=True) + + core_type: str | None = Field(default=None, alias="coreType") + core_init_string: str | None = Field(default=None, alias="coreInitString") + broker: HELICSBrokerConfig | None = None + + def to_federate_config( + self, name: str, core_name: str | None = None + ) -> HELICSFederateConfig: + """Create a full HELICSFederateConfig for a specific component. + + Parameters + ---------- + name : + Federate name (typically Component.name). + core_name : + Optional core name for this federate. + + Returns + ------- + HELICSFederateConfig + Complete federate configuration with per-component values set. + """ + return HELICSFederateConfig( + name=name, + core_name=core_name, + core_type=self.core_type, + core_init_string=self.core_init_string, + broker=self.broker, + ) diff --git a/tests/test_mock_system/test_mock_component.py b/tests/test_mock_system/test_mock_component.py index 2bf2be0..8b7cb1a 100644 --- a/tests/test_mock_system/test_mock_component.py +++ b/tests/test_mock_system/test_mock_component.py @@ -1,11 +1,12 @@ from oedisi.componentframework.mock_component import MockComponent +from oedisi.types.helics_config import HELICSFederateConfig import os if not os.path.exists("testC"): os.mkdir("testC") c = MockComponent( - "federate_name", + HELICSFederateConfig(name="federate_name"), {"outputs": {"pi": "double"}, "inputs": {"possible_input": "double"}}, "testC", )