Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Sphinx documentation configuration for OEDISI."""

# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
Expand Down
4 changes: 3 additions & 1 deletion src/oedisi/componentframework/basic_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,19 @@ class ComponentDescription(BaseModel):


def types_to_dict(types: list[AnnotatedType]):
"""Convert list of annotated types to dictionary keyed by port name."""
return {t.port_name: t for t in types}


def component_from_json(filepath, type_checker):
"""Load component description from JSON file and create component type."""
with open(filepath) as f:
comp_desc = ComponentDescription.model_validate(json.load(f))
return basic_component(comp_desc, type_checker)


def basic_component(comp_desc: ComponentDescription, type_checker):
"""Uses data in component_definition to create a new component type.
"""Create a new component type from component definition data.

Parameters
----------
Expand Down
58 changes: 57 additions & 1 deletion src/oedisi/componentframework/mock_component.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""
"""Mock component and federate for testing HELICS simulations.

MockComponent and MockFederate allow you to instantiate a mock component
with a specified set of inputs and outputs. The parameters dictionary
should contain a list under "inputs" and "outputs". During implementation,
Expand All @@ -23,6 +24,27 @@


class MockComponent(system_configuration.ComponentType):
"""Mock component for testing HELICS-based simulations.

Provides a configurable mock component with dynamic inputs and outputs
for use in testing and validation scenarios.

Parameters
----------
name : str
Name of the mock component.
parameters : dict[str, dict[str, str]]
Configuration parameters containing "inputs" and "outputs" keys.
directory : str
Working directory for component configuration files.
host : str, optional
Host address (not used in mock implementation).
port : int, optional
Port number (not used in mock implementation).
comp_type : str, optional
Component type identifier (not used in mock implementation).
"""

def __init__(
self,
name,
Expand All @@ -40,6 +62,13 @@ def __init__(
self.process_parameters(parameters)

def process_parameters(self, parameters):
"""Process and configure component parameters.

Parameters
----------
parameters : dict[str, dict[str, str]]
Configuration dictionary with "inputs" and "outputs" keys.
"""
self._dynamic_inputs = {
name: AnnotatedType(type="", port_id=name) for name in parameters["inputs"]
}
Expand All @@ -50,6 +79,13 @@ def process_parameters(self, parameters):
self.generate_helics_config(parameters["outputs"])

def generate_helics_config(self, outputs):
"""Generate HELICS configuration file for the mock component.

Parameters
----------
outputs : dict[str, str]
Mapping of output port names to HELICS data types.
"""
helics_config = {
"name": self._name,
"core_type": "zmq",
Expand All @@ -63,35 +99,54 @@ def generate_helics_config(self, outputs):
json.dump(helics_config, f)

def generate_input_mapping(self, links):
"""Generate input mapping file for subscriptions.

Parameters
----------
links : dict
Mapping of local input port names to HELICS subscription keys.
"""
with open(os.path.join(self._directory, "input_mapping.json"), "w") as f:
json.dump(links, f)

@property
def dynamic_inputs(self):
"""Dynamic input ports."""
return self._dynamic_inputs

@property
def dynamic_outputs(self):
"""Dynamic output ports."""
return self._dynamic_outputs

@property
def execute_function(self):
"""Path to mock component execution script."""
return self._execute_function


def get_default_value(date_type: h.HelicsDataType):
"""Return pi value for mock publications."""
return 3.1415926536


def destroy_federate(fed):
"""Disconnect and free a HELICS federate."""
_ = h.helicsFederateDisconnect(fed)
h.helicsFederateFree(fed)
h.helicsCloseLibrary()
logger.info("Federate finalized")


class MockFederate:
"""Mock HELICS federate for testing simulations.

Loads configuration and subscriptions from files, then publishes
test values during simulation.
"""

def __init__(self):
"""Initialize mock federate from HELICS and input mapping configs."""
logger.info(f"Current Working Directory: {os.path.abspath(os.curdir)}")
self.fed = h.helicsCreateValueFederateFromConfig("helics_config.json")
logger.info(f"Created federate {self.fed.name}")
Expand All @@ -105,6 +160,7 @@ def __init__(self):
logging.info("Loaded all subscriptions from file")

def run(self):
"""Execute simulation, publishing values for 100 seconds."""
self.fed.enter_executing_mode()
logger.info("Entered HELICS execution mode")

Expand Down
25 changes: 21 additions & 4 deletions src/oedisi/componentframework/system_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,36 +81,45 @@ def __init__(

@abstractmethod
def generate_input_mapping(self, links: dict[str, str]):
"""Generate input mapping from link target ports to HELICS subscription keys."""
pass

@property
@abstractmethod
def execute_function(self):
"""Command to execute the component federate."""
pass

@property
@abstractmethod
def dynamic_inputs(self):
"""Dictionary of dynamic input port names to types."""
pass

@property
@abstractmethod
def dynamic_outputs(self):
"""Dictionary of dynamic output port names to types."""
pass


class Link(BaseModel):
"""Connection between component ports in wiring diagram."""

source: str
source_port: str
target: str
target_port: str


class Port(BaseModel):
"""Port identifier for creating links between components."""

name: str
port_name: str

def connect(self, port: "Port"):
"""Create link from this port to target port."""
return Link(
source=self.name,
source_port=self.port_name,
Expand All @@ -130,6 +139,7 @@ class Component(BaseModel):
parameters: dict[str, Any]

def port(self, port_name: str):
"""Create Port object for connecting this component."""
return Port(name=self.name, port_name=port_name)

@field_validator("image", mode="before")
Expand All @@ -142,6 +152,8 @@ def validate_image(cls, v, info: ValidationInfo):


class ComponentStruct(BaseModel):
"""Component with its associated links for multi-container configuration."""

component: Component
links: list[Link]

Expand All @@ -154,6 +166,7 @@ class WiringDiagram(BaseModel):
links: list[Link]

def clean_model(self, target_directory="."):
"""Remove component directories, log files, and stray broker processes."""
for component in self.components:
to_delete = os.path.join(target_directory, component.name)
log_file = os.path.join(target_directory, component.name + ".log")
Expand All @@ -170,7 +183,6 @@ def clean_model(self, target_directory="."):
else:
os.remove(log_file)

# TODO: Check for any processes using the HELICS port and kill them too
for proc in psutil.process_iter():
if proc.name() == "helics_broker":
proc.kill()
Expand All @@ -186,6 +198,7 @@ def check_component_names(cls, components):
@field_validator("links")
@classmethod
def check_link_names(cls, links, info: ValidationInfo):
"""Validate that link source and target components exist."""
if "components" in info.data:
components = info.data["components"]
names = set(map(lambda c: c.name, components))
Expand All @@ -194,13 +207,16 @@ def check_link_names(cls, links, info: ValidationInfo):
return links

def add_component(self, c: Component):
"""Add component to wiring diagram."""
self.components.append(c)

def add_link(self, link: Link):
"""Add link to wiring diagram."""
self.links.append(link)

@classmethod
def empty(cls, name="unnamed"):
"""Create empty wiring diagram with no components or links."""
return cls(name=name, components=[], links=[])


Expand All @@ -214,6 +230,7 @@ class Federate(BaseModel):


def get_federates_conn_info(wiring_diagram: WiringDiagram):
"""Get connection information string for all federates."""
data = ""
for component in wiring_diagram.components:
data += f" {component.host} {component.port}"
Expand Down Expand Up @@ -274,6 +291,7 @@ def initialize_federates(


def get_link_map(wiring_diagram: WiringDiagram):
"""Create mapping from component names to their incoming links."""
link_map = defaultdict(list)
for link in wiring_diagram.links:
link_map[link.target].append(link)
Expand All @@ -300,7 +318,7 @@ class RunnerConfig(BaseModel):


def bad_compatability_checker(type1, type2):
"""Basic compatability checker that says all types are compatible."""
"""Return True for all type pairs (no type checking)."""
return True


Expand All @@ -310,8 +328,7 @@ def generate_runner_config(
compatibility_checker=bad_compatability_checker,
target_directory=".",
):
"""Brings together a `WiringDiagram` and a dictionary of `ComponentTypes`
to create a helics run configuration.
"""Create HELICS run configuration from wiring diagram and component types.

Parameters
----------
Expand Down
3 changes: 3 additions & 0 deletions src/oedisi/componentframework/wiring_diagram_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def get_graph(wiring_diagram: WiringDiagram):


def plot_graph_matplotlib(wiring_diagram: WiringDiagram):
"""Plot wiring diagram using matplotlib with spring layout."""
import matplotlib.pyplot as plt
import networkx as nx

Expand Down Expand Up @@ -53,6 +54,7 @@ def plot_graph_matplotlib(wiring_diagram: WiringDiagram):


def get_graph_renderer(G): # noqa: N803
"""Create bokeh graph renderer with styling for interactive visualization."""
import networkx as nx
from bokeh.plotting import from_networkx
from bokeh.models import Circle, EdgesOnly, MultiLine
Expand Down Expand Up @@ -80,6 +82,7 @@ def get_graph_renderer(G): # noqa: N803


def plot_graph_bokeh(wiring_diagram: WiringDiagram):
"""Plot wiring diagram using bokeh for interactive visualization."""
from bokeh.models import (
BoxSelectTool,
HoverTool,
Expand Down
1 change: 1 addition & 0 deletions src/oedisi/tools/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""API utilities for OEDISI."""
6 changes: 5 additions & 1 deletion src/oedisi/tools/broker_utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Utilities for HELICS broker time data management."""

from pydantic import BaseModel


Expand All @@ -10,7 +12,7 @@ class TimeData(BaseModel):


def pprint_time_data(time_data):
"""A table would be better somehow, but which should be the columns."""
"""Pretty print time data for a federate."""
print(
f"""
Name : {time_data.name}
Expand All @@ -21,6 +23,7 @@ def pprint_time_data(time_data):


def parse_time_data(response):
"""Parse broker response into list of TimeData objects."""
time_data = []
for core in response["cores"]:
for fed in core["federates"]:
Expand All @@ -36,5 +39,6 @@ def parse_time_data(response):


def get_time_data(broker):
"""Query broker for global time data and parse into TimeData objects."""
# Use global time debugging?
return parse_time_data(broker.query("broker", "global_time"))
Loading