From e28f3ae647ac9d634bef8fab5bc372ba9ddc3aad Mon Sep 17 00:00:00 2001 From: Joseph McKinsey Date: Fri, 30 Jan 2026 14:49:35 -0700 Subject: [PATCH 1/7] Add broker configuration --- .../componentframework/basic_component.py | 35 +++- .../componentframework/mock_component.py | 4 + .../system_configuration.py | 48 ++++- src/oedisi/tools/cli_tools.py | 47 +++++ src/oedisi/types/__init__.py | 12 ++ src/oedisi/types/helics_config.py | 173 ++++++++++++++++++ 6 files changed, 314 insertions(+), 5 deletions(-) create mode 100644 src/oedisi/types/helics_config.py diff --git a/src/oedisi/componentframework/basic_component.py b/src/oedisi/componentframework/basic_component.py index 8780ab1..06f1c20 100644 --- a/src/oedisi/componentframework/basic_component.py +++ b/src/oedisi/componentframework/basic_component.py @@ -3,10 +3,29 @@ import json import os from shutil import copytree +from typing import Any + +from pydantic import BaseModel, Field + from . import system_configuration from .system_configuration import AnnotatedType -from pydantic import BaseModel -from typing import Any +from oedisi.types.helics_config import HELICSFederateConfig + + +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.federate_config. + """ + + version: str = "1.0" + broker_config: bool = False class ComponentDescription(BaseModel): @@ -24,6 +43,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 +52,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,6 +86,7 @@ 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, @@ -73,10 +96,12 @@ def __init__( host: str, port: int, comp_type: str, + federate_config: HELICSFederateConfig | None = None, ): self._name = name self._directory = directory self._parameters = parameters + self._federate_config = federate_config self.check_parameters(parameters) self.copy_code_into_directory() self.generate_parameter_config() @@ -96,6 +121,8 @@ def copy_code_into_directory(self): def generate_parameter_config(self): self._parameters["name"] = self._name + if self._federate_config is not None: + self._parameters["federate_config"] = self._federate_config.to_dict() with open(os.path.join(self._directory, "static_inputs.json"), "w") as f: json.dump(self._parameters, f) @@ -115,4 +142,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..275b4c4 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__) @@ -31,9 +33,11 @@ def __init__( host: str | None = None, port: int | None = None, comp_type: str | None = None, + federate_config: HELICSFederateConfig | None = None, ): self._name = name self._directory = directory + self._federate_config = federate_config self._execute_function = os.path.join( os.path.dirname(os.path.abspath(__file__)), "mock_component.sh" ) diff --git a/src/oedisi/componentframework/system_configuration.py b/src/oedisi/componentframework/system_configuration.py index 7c7ebe2..c09d5aa 100644 --- a/src/oedisi/componentframework/system_configuration.py +++ b/src/oedisi/componentframework/system_configuration.py @@ -21,8 +21,9 @@ import psutil from abc import ABC, abstractmethod -from pydantic import field_validator, BaseModel, ValidationInfo +from pydantic import field_validator, BaseModel, ConfigDict, ValidationInfo, Field from oedisi.types.common import DOCKER_HUB_USER, APP_NAME +from oedisi.types.helics_config import HELICSFederateConfig, SharedFederateConfig class AnnotatedType(BaseModel): @@ -76,6 +77,7 @@ def __init__( host: str | None = None, port: int | None = None, comp_type: str | None = None, + federate_config: HELICSFederateConfig | None = None, ): pass @@ -122,12 +124,17 @@ def connect(self, port: "Port"): class Component(BaseModel): """A component type in WiringDiagram, includes name, type, and initial parameters.""" + model_config = ConfigDict(populate_by_name=True) + name: str type: str host: str | None = None container_port: int | None = None image: str = "" parameters: dict[str, Any] + federate_config_override: SharedFederateConfig | None = Field( + default=None, alias="federateConfigOverride" + ) def port(self, port_name: str): return Port(name=self.name, port_name=port_name) @@ -147,11 +154,29 @@ 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. + federate_config : + Optional shared federate configuration applied to all components. + Per-component values (name, core_name) are derived automatically. + """ + + model_config = ConfigDict(populate_by_name=True) name: str components: list[Component] links: list[Link] + federate_config: SharedFederateConfig | None = Field( + default=None, alias="federateConfig" + ) def clean_model(self, target_directory="."): for component in self.components: @@ -201,7 +226,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=[], federate_config=None) class Federate(BaseModel): @@ -234,6 +259,22 @@ 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.federate_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.federate_config_override.to_federate_config( + name=component.name + ) + elif wiring_diagram.federate_config is not None: + federate_config = wiring_diagram.federate_config.to_federate_config( + name=component.name + ) + initialized_component = component_type( component.name, component.parameters, @@ -241,6 +282,7 @@ def initialize_federates( component.host, component.container_port, component.type, + federate_config, ) components[component.name] = initialized_component diff --git a/src/oedisi/tools/cli_tools.py b/src/oedisi/tools/cli_tools.py index 5b6bb85..5033749 100644 --- a/src/oedisi/tools/cli_tools.py +++ b/src/oedisi/tools/cli_tools.py @@ -36,6 +36,50 @@ ) +def validate_broker_config_support( + wiring_diagram: WiringDiagram, + component_dict_of_files: dict[str, str], +) -> None: + """Validate that all components support broker config if wiring diagram uses it. + + Parameters + ---------- + wiring_diagram : + The wiring diagram being built. + component_dict_of_files : + Dictionary mapping component type names to their definition file paths. + + Raises + ------ + click.ClickException + If federate_config is specified but some components don't support it. + """ + if wiring_diagram.federate_config is None: + return + + unsupported_components = [] + for component in wiring_diagram.components: + filepath = component_dict_of_files.get(component.type) + if filepath is None: + continue + + with open(filepath) as f: + comp_desc = ComponentDescription.model_validate(json.load(f)) + + if not comp_desc.capabilities.broker_config: + unsupported_components.append(f" - {component.name} (type: {component.type})") + + if unsupported_components: + msg = ( + "WiringDiagram specifies federate_config but the following components " + "do not support broker configuration:\n" + + "\n".join(unsupported_components) + + '\n\nTo fix this, add \'"capabilities": {"broker_config": true}\' to each ' + "component's component_definition.json file." + ) + raise click.ClickException(msg) + + @click.group() def cli(): pass @@ -129,6 +173,9 @@ def build( with open(system) as f: wiring_diagram = WiringDiagram.model_validate(json.load(f)) + # Validate broker config support if federate_config is specified + validate_broker_config_support(wiring_diagram, component_dict_of_files) + click.echo(f"Building system in {target_directory}") if multi_container: 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..7ec8b73 --- /dev/null +++ b/src/oedisi/types/helics_config.py @@ -0,0 +1,173 @@ +"""HELICS federate configuration types for type-safe simulation setup.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import BaseModel, ConfigDict, Field + +if TYPE_CHECKING: + 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 + + +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, + ) From 6b0b88251738394c56893ae62eb52ed07fde2b6e Mon Sep 17 00:00:00 2001 From: Joseph McKinsey Date: Fri, 30 Jan 2026 15:45:06 -0700 Subject: [PATCH 2/7] Refactor some names and __init__ options --- .../componentframework/basic_component.py | 16 +++--- .../system_configuration.py | 50 ++++++++----------- src/oedisi/types/helics_config.py | 6 +-- 3 files changed, 29 insertions(+), 43 deletions(-) diff --git a/src/oedisi/componentframework/basic_component.py b/src/oedisi/componentframework/basic_component.py index 06f1c20..10ced32 100644 --- a/src/oedisi/componentframework/basic_component.py +++ b/src/oedisi/componentframework/basic_component.py @@ -90,18 +90,16 @@ class BasicComponent(system_configuration.ComponentType): def __init__( self, - name, + base_config: HELICSFederateConfig, parameters: dict[str, Any], directory: str, host: str, port: int, comp_type: str, - federate_config: HELICSFederateConfig | None = None, ): - self._name = name + self._base_config = base_config self._directory = directory self._parameters = parameters - self._federate_config = federate_config self.check_parameters(parameters) self.copy_code_into_directory() self.generate_parameter_config() @@ -120,11 +118,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._federate_config is not None: - self._parameters["federate_config"] = self._federate_config.to_dict() + 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: diff --git a/src/oedisi/componentframework/system_configuration.py b/src/oedisi/componentframework/system_configuration.py index c09d5aa..fba4cc5 100644 --- a/src/oedisi/componentframework/system_configuration.py +++ b/src/oedisi/componentframework/system_configuration.py @@ -21,7 +21,7 @@ import psutil from abc import ABC, abstractmethod -from pydantic import field_validator, BaseModel, ConfigDict, ValidationInfo, Field +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 @@ -71,13 +71,12 @@ class ComponentType(ABC): @abstractmethod def __init__( self, - name: str, + base_config: HELICSFederateConfig, parameters: dict[str, dict[str, str]], directory: str, host: str | None = None, port: int | None = None, comp_type: str | None = None, - federate_config: HELICSFederateConfig | None = None, ): pass @@ -124,17 +123,13 @@ def connect(self, port: "Port"): class Component(BaseModel): """A component type in WiringDiagram, includes name, type, and initial parameters.""" - model_config = ConfigDict(populate_by_name=True) - name: str type: str host: str | None = None container_port: int | None = None image: str = "" parameters: dict[str, Any] - federate_config_override: SharedFederateConfig | None = Field( - default=None, alias="federateConfigOverride" - ) + helics_config_override: SharedFederateConfig | None = None def port(self, port_name: str): return Port(name=self.name, port_name=port_name) @@ -164,19 +159,15 @@ class WiringDiagram(BaseModel): List of components in the simulation. links : List of links connecting component ports. - federate_config : + shared_helics_config : Optional shared federate configuration applied to all components. Per-component values (name, core_name) are derived automatically. """ - model_config = ConfigDict(populate_by_name=True) - name: str components: list[Component] links: list[Link] - federate_config: SharedFederateConfig | None = Field( - default=None, alias="federateConfig" - ) + shared_helics_config: SharedFederateConfig | None = None def clean_model(self, target_directory="."): for component in self.components: @@ -226,7 +217,7 @@ def add_link(self, link: Link): @classmethod def empty(cls, name="unnamed"): - return cls(name=name, components=[], links=[], federate_config=None) + return cls(name=name, components=[], links=[], shared_helics_config=None) class Federate(BaseModel): @@ -262,44 +253,43 @@ def initialize_federates( # Generate per-component federate config federate_config = None - if component.federate_config_override is not 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.federate_config_override.to_federate_config( + federate_config = component.helics_config_override.to_federate_config( name=component.name ) - elif wiring_diagram.federate_config is not None: - federate_config = wiring_diagram.federate_config.to_federate_config( + elif wiring_diagram.shared_helics_config is not None: + federate_config = wiring_diagram.shared_helics_config.to_federate_config( name=component.name ) initialized_component = component_type( - component.name, + federate_config, component.parameters, directory, component.host, component.container_port, component.type, - federate_config, ) components[component.name] = initialized_component 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/types/helics_config.py b/src/oedisi/types/helics_config.py index 7ec8b73..d5f0819 100644 --- a/src/oedisi/types/helics_config.py +++ b/src/oedisi/types/helics_config.py @@ -2,12 +2,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from pydantic import BaseModel, ConfigDict, Field - -if TYPE_CHECKING: - from oedisi.types.common import BrokerConfig +from oedisi.types.common import BrokerConfig class HELICSBrokerConfig(BaseModel): From 101667ffbab76c64e8755718f493ab9c343abdb5 Mon Sep 17 00:00:00 2001 From: Joseph McKinsey Date: Fri, 30 Jan 2026 15:53:40 -0700 Subject: [PATCH 3/7] Refactor the broker validation --- .../componentframework/basic_component.py | 18 +------ .../system_configuration.py | 28 +++++++++++ src/oedisi/tools/cli_tools.py | 47 ------------------- 3 files changed, 29 insertions(+), 64 deletions(-) diff --git a/src/oedisi/componentframework/basic_component.py b/src/oedisi/componentframework/basic_component.py index 10ced32..c8ff522 100644 --- a/src/oedisi/componentframework/basic_component.py +++ b/src/oedisi/componentframework/basic_component.py @@ -8,26 +8,10 @@ from pydantic import BaseModel, Field from . import system_configuration -from .system_configuration import AnnotatedType +from .system_configuration import AnnotatedType, ComponentCapabilities from oedisi.types.helics_config import HELICSFederateConfig -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.federate_config. - """ - - version: str = "1.0" - broker_config: bool = False - - class ComponentDescription(BaseModel): """Component description for simple ComponentType. diff --git a/src/oedisi/componentframework/system_configuration.py b/src/oedisi/componentframework/system_configuration.py index fba4cc5..d58ea4d 100644 --- a/src/oedisi/componentframework/system_configuration.py +++ b/src/oedisi/componentframework/system_configuration.py @@ -26,6 +26,22 @@ 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): """Represent the types of components and their interfaces.""" @@ -68,6 +84,8 @@ class ComponentType(ABC): to run the component. """ + _capabilities: ComponentCapabilities = ComponentCapabilities() + @abstractmethod def __init__( self, @@ -266,6 +284,16 @@ def initialize_federates( 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( federate_config, component.parameters, diff --git a/src/oedisi/tools/cli_tools.py b/src/oedisi/tools/cli_tools.py index 5033749..5b6bb85 100644 --- a/src/oedisi/tools/cli_tools.py +++ b/src/oedisi/tools/cli_tools.py @@ -36,50 +36,6 @@ ) -def validate_broker_config_support( - wiring_diagram: WiringDiagram, - component_dict_of_files: dict[str, str], -) -> None: - """Validate that all components support broker config if wiring diagram uses it. - - Parameters - ---------- - wiring_diagram : - The wiring diagram being built. - component_dict_of_files : - Dictionary mapping component type names to their definition file paths. - - Raises - ------ - click.ClickException - If federate_config is specified but some components don't support it. - """ - if wiring_diagram.federate_config is None: - return - - unsupported_components = [] - for component in wiring_diagram.components: - filepath = component_dict_of_files.get(component.type) - if filepath is None: - continue - - with open(filepath) as f: - comp_desc = ComponentDescription.model_validate(json.load(f)) - - if not comp_desc.capabilities.broker_config: - unsupported_components.append(f" - {component.name} (type: {component.type})") - - if unsupported_components: - msg = ( - "WiringDiagram specifies federate_config but the following components " - "do not support broker configuration:\n" - + "\n".join(unsupported_components) - + '\n\nTo fix this, add \'"capabilities": {"broker_config": true}\' to each ' - "component's component_definition.json file." - ) - raise click.ClickException(msg) - - @click.group() def cli(): pass @@ -173,9 +129,6 @@ def build( with open(system) as f: wiring_diagram = WiringDiagram.model_validate(json.load(f)) - # Validate broker config support if federate_config is specified - validate_broker_config_support(wiring_diagram, component_dict_of_files) - click.echo(f"Building system in {target_directory}") if multi_container: From cfdecade67f855f17b0877f7a349aaca6bef6477 Mon Sep 17 00:00:00 2001 From: Joseph McKinsey Date: Fri, 30 Jan 2026 15:58:17 -0700 Subject: [PATCH 4/7] Fix mock component to align with new refactor --- src/oedisi/componentframework/mock_component.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/oedisi/componentframework/mock_component.py b/src/oedisi/componentframework/mock_component.py index 275b4c4..0b9c293 100644 --- a/src/oedisi/componentframework/mock_component.py +++ b/src/oedisi/componentframework/mock_component.py @@ -27,17 +27,15 @@ 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, - federate_config: HELICSFederateConfig | None = None, ): - self._name = name + self._base_config = base_config self._directory = directory - self._federate_config = federate_config self._execute_function = os.path.join( os.path.dirname(os.path.abspath(__file__)), "mock_component.sh" ) @@ -55,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", From dbccd0715353de2b34a0c805166cd53f13d9baf8 Mon Sep 17 00:00:00 2001 From: Joseph McKinsey Date: Fri, 30 Jan 2026 16:11:01 -0700 Subject: [PATCH 5/7] Fix mock test again --- tests/test_mock_system/test_mock_component.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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", ) From a9bb785d2d5829ff9e6fc2c243bd4e10e92f8590 Mon Sep 17 00:00:00 2001 From: Joseph McKinsey Date: Mon, 2 Feb 2026 10:32:08 -0700 Subject: [PATCH 6/7] Add warnings and small API change for the multicontainer side --- src/oedisi/tools/cli_tools.py | 15 ++++++++++ src/oedisi/types/helics_config.py | 48 +++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) 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/helics_config.py b/src/oedisi/types/helics_config.py index d5f0819..2c58ba6 100644 --- a/src/oedisi/types/helics_config.py +++ b/src/oedisi/types/helics_config.py @@ -110,6 +110,54 @@ def apply_to_federate_info(self, info) -> None: if self.broker.key is not None: info.broker_key = self.broker.key + @classmethod + def from_multicontainer( + cls, + broker_config: BrokerConfig, + name: str, + 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. + + Parameters + ---------- + broker_config : + REST API broker configuration from /run endpoint. + name : + Federate name (typically from static_inputs.json). + core_type : + HELICS core type, defaults to "zmq". + **kwargs : + Additional federate config options (core_name, core_init_string, etc). + + Returns + ------- + HELICSFederateConfig + Complete federate configuration ready to apply. + + Examples + -------- + >>> # In component server.py /run endpoint + >>> with open("static_inputs.json") as f: + ... params = json.load(f) + >>> config = HELICSFederateConfig.from_multicontainer( + ... broker_config=broker_config, + ... name=params["name"] + ... ) + >>> fedinfo = h.helicsCreateFederateInfo() + >>> config.apply_to_federate_info(fedinfo) + """ + return cls( + name=name, + core_type=core_type, + broker=HELICSBrokerConfig.from_rest_config(broker_config), + **kwargs, + ) + class SharedFederateConfig(BaseModel): """Shared federate settings at the WiringDiagram level. From 6bf5f744a8ca6dfdd523a378e3a74c52b1f2f4bd Mon Sep 17 00:00:00 2001 From: Joseph McKinsey Date: Mon, 2 Feb 2026 10:43:53 -0700 Subject: [PATCH 7/7] Make HELICSFederatConfig.from_multicontainer behave better with subclasses --- src/oedisi/types/helics_config.py | 40 +++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src/oedisi/types/helics_config.py b/src/oedisi/types/helics_config.py index 2c58ba6..0907f4c 100644 --- a/src/oedisi/types/helics_config.py +++ b/src/oedisi/types/helics_config.py @@ -114,7 +114,7 @@ def apply_to_federate_info(self, info) -> None: def from_multicontainer( cls, broker_config: BrokerConfig, - name: str, + params: dict | None = None, core_type: str = "zmq", **kwargs, ) -> HELICSFederateConfig: @@ -122,41 +122,55 @@ def from_multicontainer( 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. - name : - Federate name (typically from static_inputs.json). + 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 federate config options (core_name, core_init_string, etc). + Additional config options (overrides params dict if both provided). Returns ------- HELICSFederateConfig - Complete federate configuration ready to apply. + Complete federate configuration ready to apply. If called on a + subclass, returns an instance of that subclass. Examples -------- - >>> # In component server.py /run endpoint + >>> # Basic usage with params dict >>> with open("static_inputs.json") as f: ... params = json.load(f) >>> config = HELICSFederateConfig.from_multicontainer( ... broker_config=broker_config, - ... name=params["name"] + ... 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"} + ... ) """ - return cls( - name=name, - core_type=core_type, - broker=HELICSBrokerConfig.from_rest_config(broker_config), - **kwargs, - ) + 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):