Custom Models & Catalog
CosimGym treats simulated environments as plug-and-play modules. To achieve this, it enforces a single interface standard (BaseModel) and resolves models at runtime through a dynamic Model Catalog.
1. Extending BaseModel
Every physical subsystem, controller, or data feed must extend BaseModel, defined in src/models/base_model.py. You implement three lifecycle methods. You do not override __init__ — the base class builds the model's state from the catalog metadata and your scenario config for you.
All data lives on self.state (a State dataclass):
| Attribute | Meaning |
|---|---|
self.state.parameters |
Static parameters (from catalog defaults, overridden by the scenario parameters block) |
self.state.inputs |
Latest values received from subscriptions (filled by the federate before each step()) |
self.state.outputs |
Values your model produces — published to HELICS after step() |
self.state.time |
Current simulation datetime (maintained by the base class) |
self.state.ts |
Current integer time step |
initialize(self)
Called once before the simulation clock starts. Use it to set up internal arrays, matrices, or solver state. Parameters are already available in self.state.parameters.
step(self)
Called every HELICS time step. The base class has already updated the time state and copied incoming values into self.state.inputs. Your job:
- Read inputs from
self.state.inputs. - Advance your internal state.
- Write results into
self.state.outputs.
finalize(self)
Called once at simulation end for cleanup (close files, free resources).
Minimal example
# src/models/model_catalog/physical_models/gain.py
from models.base_model import BaseModel
class Gain(BaseModel):
"""Multiplies its input by a constant gain parameter."""
def initialize(self) -> None:
self.k = self.state.parameters['gain']
def step(self) -> None:
x = self.state.inputs.get('x', 0.0)
self.state.outputs['y'] = self.k * x
def finalize(self) -> None:
pass
2. Registering in the Catalog
For ScenarioManager to instantiate your model from a string name in the YAML, register it in src/models/model_catalog/catalog.yaml. Every entry lives under the top-level models: key. Use model_template.yaml in the same folder as a starting point.
models:
# ... existing entries ...
gain: # ← model_name referenced in scenario YAML
class_name: Gain # Python class name
module_path: models.model_catalog.physical_models.gain # import path (NB: no "src." prefix)
version: 1.0.0
description: Multiplies its input by a constant gain.
author: Your Name
domain: testing
category: physical_model
time_step: 60 # nominal step (seconds)
max_time_step: 3600
min_time_step: 1
user_defined: {}
parameters:
gain:
type: float
default_value: 2.0 # NB: default_value, and unit (singular)
unit: '-'
description: Multiplication factor
required: false
inputs:
x:
type: float
default_value: 0.0
unit: '-'
description: Input signal
required: true
outputs:
y:
type: float
default_value: 0.0
unit: '-'
description: Scaled output signal
required: true
Catalog vs. connections. The
parameters/inputs/outputsblocks in the catalog declare the model's interface schema and default values. The actual HELICS wiring (which variable subscribes to which publisher) is defined separately in the scenario'sconnections.publishes/connections.subscribes. Thekeyof each publish/subscribe must match anoutputs/inputsname here. See Federate Configuration.
3. How the Catalog is distributed
- At setup,
catalog_loader.pyreadscatalog.yamland loads every entry into Redis. - When
ScenarioManagerspawns each federate in its own OS process, that federate queries Redis (viaRedisCatalog) for the model'smodule_path,class_name, and interface defaults. - The federate dynamically imports the class and instantiates it — no fragile local path dependencies between processes.
Reload the catalog after editing catalog.yaml:
python src/models/model_catalog/catalog_loader.py
(The Docker / Makefile setups run this step for you.)
4. Interface Adapters (transport, not physics)
An interface federate (type: interface, see Federate Configuration) instantiates a transport adapter instead of a BaseModel — but through the identical catalog mechanism: category: interface_adapter entries in catalog.yaml (mapped to their own Redis key prefix in catalog_loader.py's CATEGORY_MAP), dynamic-imported by class name/module path.
models:
mqtt_adapter:
class_name: MqttAdapter
module_path: adapters.mqtt_adapter
category: interface_adapter
parameters:
client_id: { type: str, default_value: cosim_dt }
host: { type: str, default_value: localhost }
port: { type: int, default_value: 11883 }
qos: { type: int, default_value: 0 }
To add a new transport (e.g. Kafka, Modbus, OPC-UA), implement InterfaceAdapter (src/adapters/base_adapter.py: connect, publish, subscribe, latest, close) and register it the same way. See Digital-Twin Interfaces & Live Streaming for the adapter's role in both the streaming mirror and the interface federate.