Custom callback functions in Sinergym

First let’s import the 5zone environment as an example for custom callbacks. We need add new variables to be used by the callback function.

[1]:
import gymnasium as gym
import numpy as np

import sinergym

new_variables = {
    'outdoor_temperature': ('Site Outdoor Air Drybulb Temperature', 'Environment'),
    'outdoor_humidity': ('Site Outdoor Air Relative Humidity', 'Environment'),
    'wind_speed': ('Site Wind Speed', 'Environment'),
    'wind_direction': ('Site Wind Direction', 'Environment'),
    'diffuse_solar_radiation': (
        'Site Diffuse Solar Radiation Rate per Area',
        'Environment',
    ),
    'direct_solar_radiation': (
        'Site Direct Solar Radiation Rate per Area',
        'Environment',
    ),
    'air_temperature_SPACE1-1': ('Zone Air Temperature', 'SPACE1-1'),
    'air_temperature_SPACE2-1': ('Zone Air Temperature', 'SPACE2-1'),
    'air_temperature_SPACE3-1': ('Zone Air Temperature', 'SPACE3-1'),
    'air_temperature_SPACE4-1': ('Zone Air Temperature', 'SPACE4-1'),
    'air_temperature': ('Zone Air Temperature', 'SPACE5-1'),
    'air_relative_humidity_SPACE1-1': ('Zone Air Relative Humidity', 'SPACE1-1'),
    'air_relative_humidity_SPACE2-1': ('Zone Air Relative Humidity', 'SPACE2-1'),
    'air_relative_humidity_SPACE3-1': ('Zone Air Relative Humidity', 'SPACE3-1'),
    'air_relative_humidity_SPACE4-1': ('Zone Air Relative Humidity', 'SPACE4-1'),
    'air_relative_humidity_SPACE5-1': ('Zone Air Relative Humidity', 'SPACE5-1'),
    'HVAC_electricity_demand_rate': (
        'Facility Total HVAC Electricity Demand Rate',
        'Whole Building',
    ),
}


env = gym.make("Eplus-5zone-hot-continuous-stochastic-v1", variables=new_variables)

print(
    'The current observation variables are:: {}'.format(
        env.get_wrapper_attr('observation_variables')
    )
)
#==============================================================================================#
[ENVIRONMENT] (INFO) : Creating Gymnasium environment.
[ENVIRONMENT] (INFO) : Name: Eplus-5zone-hot-continuous-stochastic-v1
#==============================================================================================#
[MODEL] (INFO) : Working directory created: /workspaces/sinergym/examples/Eplus-5zone-hot-continuous-stochastic-v1-res16
[MODEL] (INFO) : Model Config is correct.
[MODEL] (INFO) : Building model Output:Variable updated with defined variable names.
[MODEL] (INFO) : Updated building model Output:Meter with meter names.
[MODEL] (INFO) : Runperiod established.
[MODEL] (INFO) : Episode length (seconds): 31536000.0
[MODEL] (INFO) : timestep size (seconds): 900.0
[MODEL] (INFO) : timesteps per episode: 35040
[REWARD] (INFO) : Reward function initialized.
[ENVIRONMENT] (INFO) : Environment created successfully.
The current observation variables are:: ['month', 'day_of_month', 'hour', 'outdoor_temperature', 'outdoor_humidity', 'wind_speed', 'wind_direction', 'diffuse_solar_radiation', 'direct_solar_radiation', 'air_temperature_SPACE1-1', 'air_temperature_SPACE2-1', 'air_temperature_SPACE3-1', 'air_temperature_SPACE4-1', 'air_temperature', 'air_relative_humidity_SPACE1-1', 'air_relative_humidity_SPACE2-1', 'air_relative_humidity_SPACE3-1', 'air_relative_humidity_SPACE4-1', 'air_relative_humidity_SPACE5-1', 'HVAC_electricity_demand_rate', 'total_electricity_HVAC']

Define custom callbacks

The callback _avg_zone_timestep computes a volume-weighted average air temperature across all five thermal zones of the building (SPACE1-1 through SPACE5-1) at every zone timestep and stores the result in _avg["value"].

How it works:

  1. State dict ``_avg`` β€” A module-level dictionary holds EnergyPlus variable handles (T1–T5) and zone air volumes (vol1–vol5), plus a need_handles flag and the running value. This avoids re-fetching handles every timestep.

  2. Handle initialisation (first call only) β€” On the first timestep where the EnergyPlus API data is fully ready, the callback retrieves:

    • Variable handles for Zone Air Temperature in each zone via get_variable_handle.

    • Internal variable values for Zone Air Volume in each zone via get_internal_variable_handle / get_internal_variable_value. Zone volumes are static for a given building model, so they are read once and cached.

  3. Volume-weighted mean temperature β€” Each subsequent timestep the callback reads the current zone temperatures and computes:

\[\bar{T} = \frac{\sum_{i=1}^{5} T_i \cdot V_i}{\sum_{i=1}^{5} V_i}\]

The result is written to _avg["value"], making it available to the logger wrapper.

  1. Callback hook β€” The function is registered on the callback_end_zone_timestep_before_zone_reporting hook, so it runs at the end of each zone timestep before zone-level reporting occurs, ensuring the temperature values are fresh for that timestep.

We first define the _avg dict to store handles and the computed value, then define the callback function that uses the EnergyPlus runtime API.

[ ]:
_avg: dict = {
    "need_handles": True,
    "T1": None,
    "T2": None,
    "T3": None,
    "T4": None,
    "T5": None,
    "vol1": None,
    "vol2": None,
    "vol3": None,
    "vol4": None,
    "vol5": None,
    "value": 0.0,
}

# define a callback function to compute the average zone air temperature at each timestep
def _avg_zone_timestep(state):

    # get the energyplus simulator wrapper to access the exchange API
    simulator = env.get_wrapper_attr("energyplus_simulator")

    # check if the API data is fully ready before trying to access it
    if not simulator.exchange.api_data_fully_ready(state):
        return

    # if we haven't already, get the variable handles and zone volumes to compute the average temperature
    if _avg["need_handles"]:
        _avg["T1"] = simulator.exchange.get_variable_handle(
            state, "Zone Air Temperature", "SPACE1-1"
        )
        _avg["T2"] = simulator.exchange.get_variable_handle(
            state, "Zone Air Temperature", "SPACE2-1"
        )
        _avg["T3"] = simulator.exchange.get_variable_handle(
            state, "Zone Air Temperature", "SPACE3-1"
        )
        _avg["T4"] = simulator.exchange.get_variable_handle(
            state, "Zone Air Temperature", "SPACE4-1"
        )
        _avg["T5"] = simulator.exchange.get_variable_handle(
            state, "Zone Air Temperature", "SPACE5-1"
        )

        _avg["vol1"] = simulator.exchange.get_internal_variable_value(
            state,
            simulator.exchange.get_internal_variable_handle(
                state, "Zone Air Volume", "SPACE1-1"
            ),
        )
        _avg["vol2"] = simulator.exchange.get_internal_variable_value(
            state,
            simulator.exchange.get_internal_variable_handle(
                state, "Zone Air Volume", "SPACE2-1"
            ),
        )
        _avg["vol3"] = simulator.exchange.get_internal_variable_value(
            state,
            simulator.exchange.get_internal_variable_handle(
                state, "Zone Air Volume", "SPACE3-1"
            ),
        )
        _avg["vol4"] = simulator.exchange.get_internal_variable_value(
            state,
            simulator.exchange.get_internal_variable_handle(
                state, "Zone Air Volume", "SPACE4-1"
            ),
        )
        _avg["vol5"] = simulator.exchange.get_internal_variable_value(
            state,
            simulator.exchange.get_internal_variable_handle(
                state, "Zone Air Volume", "SPACE5-1"
            ),
        )

        # we only need to get the variable handles and zone volumes once, so we can set this flag to False
        _avg["need_handles"] = False

    # now we can compute the average zone air temperature using the variable handles and zone volumes
    T1 = simulator.exchange.get_variable_value(state, _avg["T1"])
    T2 = simulator.exchange.get_variable_value(state, _avg["T2"])
    T3 = simulator.exchange.get_variable_value(state, _avg["T3"])
    T4 = simulator.exchange.get_variable_value(state, _avg["T4"])
    T5 = simulator.exchange.get_variable_value(state, _avg["T5"])

    # compute the weighted average of the zone air temperatures using the zone volumes as weights
    num = (
        T1 * _avg["vol1"]
        + T2 * _avg["vol2"]
        + T3 * _avg["vol3"]
        + T4 * _avg["vol4"]
        + T5 * _avg["vol5"]
    )
    den = _avg["vol1"] + _avg["vol2"] + _avg["vol3"] + _avg["vol4"] + _avg["vol5"]
    _avg["value"] = num / den

Log avg_zone_temperature with CSVLogger

To record _avg["value"] into a CSV file, wrap the environment with a custom LoggerWrapper subclass that overrides calculate_custom_metrics, then apply CSVLogger. The value will be saved per-timestep to custom_metrics.csv inside the episode folder.

[ ]:
from sinergym.utils.wrappers import CSVLogger, LoggerWrapper


# define a custom LoggerWrapper to log the average zone temperature computed by the _avg_zone_timestep callback into CSVLogger's custom_metrics.csv
class AvgTempLoggerWrapper(LoggerWrapper):
    """Custom LoggerWrapper that records the volume-weighted average zone temperature
    computed by the _avg_zone_timestep callback into CSVLogger's custom_metrics.csv."""

    def __init__(self, env):
        super().__init__(env)
        self.custom_variables = ['avg_zone_temperature']

    def calculate_custom_metrics(self, obs, action, reward, info, terminated, truncated):
        return [_avg['value']]

# Now we can wrap our environment with the AvgTempLoggerWrapper and CSVLogger to log the average zone temperature at each timestep into a custom_metrics.csv file
env = AvgTempLoggerWrapper(env)
env = CSVLogger(env)
print('Environment wrapped with AvgTempLoggerWrapper and CSVLogger.')

[WRAPPER LoggerWrapper] (INFO) : Wrapper initialized.
[WRAPPER CSVLogger] (INFO) : Wrapper initialized.
Environment wrapped with AvgTempLoggerWrapper and CSVLogger.

Register callbacks to environment

We provide an API in the env called env.register_callback. The callback name should match the runtime method. Otherwise, the simulator will throw a ValueError. You can clear the callbacks using clear_callbacks.

[4]:
env.get_wrapper_attr('register_callback')(
    'callback_end_zone_timestep_before_zone_reporting', _avg_zone_timestep
)

# print the current callbacks
print('The current callbacks are:: {}'.format(env.get_wrapper_attr('callbacks')))
[SIMULATOR] (INFO) : Registered custom callback 'callback_end_zone_timestep_before_zone_reporting' successfully.
The current callbacks are:: {'callback_end_zone_timestep_before_zone_reporting': ['_avg_zone_timestep']}
[5]:
for i in range(1):
    obs, info = env.reset()
    rewards = []
    truncated = terminated = False
    current_month = 0
    while not (terminated or truncated):
        a = env.action_space.sample()
        obs, reward, terminated, truncated, info = env.step(a)
        rewards.append(reward)
        if info['month'] != current_month:  # display results every month
            current_month = info['month']
            print('Reward: ', sum(rewards), info)

# close() flushes all CSVLogger files (including custom_metrics.csv) for the last episode
env.close()

#----------------------------------------------------------------------------------------------#
[ENVIRONMENT] (INFO) : Starting a new episode.
[ENVIRONMENT] (INFO) : Episode 1: Eplus-5zone-hot-continuous-stochastic-v1
#----------------------------------------------------------------------------------------------#
[MODEL] (INFO) : Episode directory created.
[MODEL] (INFO) : Weather file USA_AZ_Davis-Monthan.AFB.722745_TMY3.epw used.
[MODEL] (INFO) : Adapting weather to building model.
[MODEL] (INFO) : Weather noise applied to columns: ['Dry Bulb Temperature', 'Relative Humidity']
[ENVIRONMENT] (INFO) : Saving episode output path in /workspaces/sinergym/examples/Eplus-5zone-hot-continuous-stochastic-v1-res16/episode-1/output.
[SIMULATOR] (INFO) : handlers initialized.
[SIMULATOR] (INFO) : handlers are ready.
[SIMULATOR] (INFO) : System is ready.
[ENVIRONMENT] (INFO) : Episode 1 started.
Reward:  -0.04287902203346494 {'time_elapsed(hours)': 0.5, 'month': 1, 'day': 1, 'hour': 0, 'is_raining': False, 'action': [16.848060607910156, 26.069730758666992], 'timestep': 1, 'energy_term': -0.005897377870609513, 'comfort_term': -0.03698164416285543, 'energy_penalty': -117.94755741219025, 'comfort_penalty': -0.07396328832571086, 'total_power_demand': 117.94755741219025, 'total_temperature_violation': 0.07396328832571086, 'reward_weight': 0.5}
Simulation Progress [Episode 1]:  10%|β–ˆ         | 10/100 [00:02<00:26,  3.41%/s, 10% completed] Reward:  -1480.2231924196765 {'time_elapsed(hours)': 744.25, 'month': 2, 'day': 1, 'hour': 0, 'is_raining': False, 'action': [20.97361946105957, 24.608291625976562], 'timestep': 2976, 'energy_term': -0.09904933431744359, 'comfort_term': 0.0, 'energy_penalty': -1980.9866863488717, 'comfort_penalty': 0, 'total_power_demand': 1980.9866863488717, 'total_temperature_violation': 0, 'reward_weight': 0.5}
Simulation Progress [Episode 1]:  17%|β–ˆβ–‹        | 17/100 [00:04<00:20,  4.05%/s, 17% completed]Reward:  -2646.2258305344885 {'time_elapsed(hours)': 1416.375, 'month': 3, 'day': 1, 'hour': 0, 'is_raining': False, 'action': [14.753031730651855, 29.141185760498047], 'timestep': 5664, 'energy_term': -0.005897377870609513, 'comfort_term': 0.0, 'energy_penalty': -117.94755741219025, 'comfort_penalty': 0, 'total_power_demand': 117.94755741219025, 'total_temperature_violation': 0, 'reward_weight': 0.5}
Simulation Progress [Episode 1]:  26%|β–ˆβ–ˆβ–Œ       | 26/100 [00:06<00:20,  3.66%/s, 26% completed]Reward:  -4017.263983592177 {'time_elapsed(hours)': 2160.25, 'month': 4, 'day': 1, 'hour': 0, 'is_raining': False, 'action': [13.849004745483398, 27.945350646972656], 'timestep': 8640, 'energy_term': -0.005897377870609513, 'comfort_term': 0.0, 'energy_penalty': -117.94755741219025, 'comfort_penalty': 0, 'total_power_demand': 117.94755741219025, 'total_temperature_violation': 0, 'reward_weight': 0.5}
Simulation Progress [Episode 1]:  34%|β–ˆβ–ˆβ–ˆβ–      | 34/100 [00:09<00:21,  3.12%/s, 34% completed]Reward:  -5631.547684178791 {'time_elapsed(hours)': 2880.25, 'month': 5, 'day': 1, 'hour': 0, 'is_raining': False, 'action': [22.14965057373047, 26.190317153930664], 'timestep': 11520, 'energy_term': -0.1083457587514961, 'comfort_term': 0.0, 'energy_penalty': -2166.915175029922, 'comfort_penalty': 0, 'total_power_demand': 2166.915175029922, 'total_temperature_violation': 0, 'reward_weight': 0.5}
Simulation Progress [Episode 1]:  42%|β–ˆβ–ˆβ–ˆβ–ˆβ–     | 42/100 [00:12<00:17,  3.32%/s, 42% completed]Reward:  -7449.764901747518 {'time_elapsed(hours)': 3624.25, 'month': 6, 'day': 1, 'hour': 0, 'is_raining': False, 'action': [21.538137435913086, 29.186525344848633], 'timestep': 14496, 'energy_term': -0.03644927670930085, 'comfort_term': -0.21133938304344824, 'energy_penalty': -728.9855341860169, 'comfort_penalty': -0.4226787660868965, 'total_power_demand': 728.9855341860169, 'total_temperature_violation': 0.4226787660868965, 'reward_weight': 0.5}
Simulation Progress [Episode 1]:  51%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ     | 51/100 [00:14<00:18,  2.60%/s, 51% completed]Reward:  -8466.010635863455 {'time_elapsed(hours)': 4344.25, 'month': 7, 'day': 1, 'hour': 0, 'is_raining': False, 'action': [18.533937454223633, 24.87399673461914], 'timestep': 17376, 'energy_term': -0.0360937122820931, 'comfort_term': 0.0, 'energy_penalty': -721.874245641862, 'comfort_penalty': 0, 'total_power_demand': 721.874245641862, 'total_temperature_violation': 0, 'reward_weight': 0.5}
Simulation Progress [Episode 1]:  59%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‰    | 59/100 [00:17<00:14,  2.85%/s, 59% completed]Reward:  -9575.308902929375 {'time_elapsed(hours)': 5088.25, 'month': 8, 'day': 1, 'hour': 0, 'is_raining': False, 'action': [18.66809844970703, 27.539033889770508], 'timestep': 20352, 'energy_term': -0.04008009651105924, 'comfort_term': -0.09550297102558858, 'energy_penalty': -801.6019302211847, 'comfort_penalty': -0.19100594205117716, 'total_power_demand': 801.6019302211847, 'total_temperature_violation': 0.19100594205117716, 'reward_weight': 0.5}
Simulation Progress [Episode 1]:  67%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‹   | 67/100 [00:20<00:10,  3.03%/s, 67% completed]Reward:  -10673.964106430458 {'time_elapsed(hours)': 5832.25, 'month': 9, 'day': 1, 'hour': 0, 'is_raining': False, 'action': [20.86484146118164, 28.4190673828125], 'timestep': 23328, 'energy_term': -0.030015891611737705, 'comfort_term': -0.18425733973388425, 'energy_penalty': -600.3178322347541, 'comfort_penalty': -0.3685146794677685, 'total_power_demand': 600.3178322347541, 'total_temperature_violation': 0.3685146794677685, 'reward_weight': 0.5}
Simulation Progress [Episode 1]:  76%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–Œ  | 76/100 [00:22<00:06,  3.74%/s, 76% completed]Reward:  -11840.48791261703 {'time_elapsed(hours)': 6552.25, 'month': 10, 'day': 1, 'hour': 0, 'is_raining': False, 'action': [21.76607322692871, 29.912328720092773], 'timestep': 26208, 'energy_term': -0.03488829934324132, 'comfort_term': 0.0, 'energy_penalty': -697.7659868648265, 'comfort_penalty': 0, 'total_power_demand': 697.7659868648265, 'total_temperature_violation': 0, 'reward_weight': 0.5}
Simulation Progress [Episode 1]:  84%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ– | 84/100 [00:25<00:05,  2.75%/s, 84% completed]Reward:  -13412.57832706892 {'time_elapsed(hours)': 7296.25, 'month': 11, 'day': 1, 'hour': 0, 'is_raining': False, 'action': [15.970210075378418, 26.904888153076172], 'timestep': 29184, 'energy_term': -0.010556678414792702, 'comfort_term': 0.0, 'energy_penalty': -211.13356829585402, 'comfort_penalty': 0, 'total_power_demand': 211.13356829585402, 'total_temperature_violation': 0, 'reward_weight': 0.5}
Simulation Progress [Episode 1]:  92%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–| 92/100 [00:27<00:02,  3.95%/s, 92% completed]Reward:  -14757.808355656342 {'time_elapsed(hours)': 8016.25, 'month': 12, 'day': 1, 'hour': 0, 'is_raining': False, 'action': [12.337260246276855, 24.085851669311523], 'timestep': 32064, 'energy_term': -0.005897377870609513, 'comfort_term': 0.0, 'energy_penalty': -117.94755741219025, 'comfort_penalty': 0, 'total_power_demand': 117.94755741219025, 'total_temperature_violation': 0, 'reward_weight': 0.5}
[WRAPPER CSVLogger] (INFO) : Environment closed, data updated in monitor and progress.csv.
Simulation Progress [Episode 1]: 100%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ| 100/100 [00:33<00:00,  2.99%/s, 100% completed]
[ENVIRONMENT] (INFO) : Environment closed. [Eplus-5zone-hot-continuous-stochastic-v1]
[ ]: