Digital-Twin Interfaces & Live Streaming ⚠️ enabled but still under testing & refactoring
CosimGym federates normally exchange data only over HELICS and write results once, at the end of a run. This page covers two opt-in, MQTT-backed mechanisms that externalize simulation data while it runs, and the BK4 "config-only sim-to-real" pattern they enable.
Both mechanisms share one transport foundation: a background-thread MQTT client
(src/adapters/mqtt_adapter.py) talking to a Mosquitto broker (docker-compose.yaml, port
11883 on the host). Publishing only enqueues onto a bounded, drop-oldest queue — the sim thread
never blocks on network I/O. Everything here is off by default; a scenario with no
streaming/interface_config block behaves exactly as it did before this feature existed.
(A) stream — outbound telemetry mirror
Add streaming: { stream: true } to any base or rl federate's config to mirror its
inputs/outputs to MQTT each step, alongside normal HELICS traffic:
federate_configs:
spring_federate:
type: base
streaming:
stream: true
# stream_topic_prefix: cosim/${sim_id}/spring # default: cosim/<sim_id>/<federate_name>
# every_n_ticks: 1
Each message is published to <prefix>/<inputs|outputs>/<entity_id>/<var_name> as JSON:
{sim_id, key, value, sim_time, wall_time}. Use this for a live dashboard or any external
observer — it changes nothing about the co-simulation itself.
(B) Interface federate — bidirectional external bridge
An interface federate (type: interface) has no physics model. Instead of stepping a model, it
relays its wired HELICS connections to and from the external world via an adapter
(interface_config.adapter, resolved from the same model catalog used for physics models).
federate_configs:
dt_bridge:
type: interface
timing_configs: { real_period: 1, rt_lag: 1.0, rt_lead: 1.0 } # wall-clock pacing
flags: { realtime: true }
interface_config:
adapter:
name: mqtt_adapter
params: { host: localhost, port: 11883, qos: 0, client_id: cosim_dt }
streams: # co-sim -> external: subscribe in HELICS, publish to MQTT
- helics_key: plant.spring_federate.0/position
topic: cosim/${sim_id}/spring/position
every_n_ticks: 1
bridges: # external -> co-sim, or co-sim -> registry override
- helics_key: plant.spring_federate.0/force
topic: cosim/${sim_id}/sensor/force
bounds: [-10, 10]
scope: input # input | output | param
mode: replace # replace external value | passthrough (real source + override)
scope picks how a bridge attaches to the target:
input— the interface federate registers a normal HELICS global publication athelics_key.mode: replacepublishes only once an external value has arrived;mode: passthrough(requiressource_key) relays a real HELICS source until an external value shows up, then follows it. This is real-sensor-in-the-loop.output/param— the target already computes this value itself, so there is no HELICS representation to register. Instead the bridge writes the bounds-clipped external value into a Redis-backedOverrideRegistry(src/core/override_registry.py), keyed by(scope, sim_id, federation, federate, entity, var). Anybase/rlfederate opts in withoverride_enabled: true; it substitutes the override in_publish_outputs()(output) or viaBaseModel.set_parameter()(param, bounds-clipped against the catalog'smin/max). Clearing the external value (no message on the bridge's topic) restores the federate's own computed behavior on the next step — no separate "disable" mechanism needed.
The BK4 pattern: config-only sim-to-real
Because HELICS treats a physics-model federate and an interface federate identically — both just register global publications/subscriptions under the same key names — swapping simulated hardware for a real one is a change to one federate's block only: everything that subscribes to it is untouched.
src/scenarios/m5_bk4_demo_a_full_sim.yaml and m5_bk4_demo_b_digital_twin.yaml are an identical
pair except for input_federate:
- (a) full sim:
input_federateistype: base, running theinputs4springmodel (constant force + randomized disturbance). - (b) digital twin:
input_federateistype: interfacewith twoscope: inputbridges registered at the same global publication keys (input_federate.0/force,input_federate.0/disturbance) the model federate would have used.spring_federate's YAML — the "consumer" — is byte-identical between the two files.
Run (b), then feed it external "sensor" values with the demo actuator script:
python src/scenarios/bk4_demo_external_sensor.py # publishes sinusoidal force + disturbance over MQTT
Watch both runs live with the live dashboard (below) to see (a)'s internally-generated values vs. (b)'s externally-driven ones land on the same HELICS keys.
Live dashboard
src/dashboard/live_dashboard.py is the "Live" page of the Streamlit dashboard (dashboard_app.py
is the "Results" page, the post-run historical explorer — both are served by the same app via
st.navigation). The Live page subscribes to cosim/# and shows the latest value per topic plus a
rolling chart, refreshed on a timer:
./src/dashboard/run_dashboard.sh # http://localhost:8052, then switch to the "Live" page
It works with both mechanisms above — a stream: true federate's telemetry and an interface
federate's streams/bridges topics all show up as soon as they're published, no run needs to
finish first.