diff --git a/.github/workflows/create_pr.yml b/.github/workflows/create_pr.yml index d309f2326b..d9731a07be 100644 --- a/.github/workflows/create_pr.yml +++ b/.github/workflows/create_pr.yml @@ -4,7 +4,7 @@ on: pull_request # pwd: /home/runner/work/sinergym/sinergym jobs: - autopep8-check: + autopep8-isort-check: runs-on: ubuntu-24.04 steps: @@ -16,17 +16,34 @@ jobs: with: python-version: ${{ vars.PYTHON_VERSION }} + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: latest + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Install autopep8 and isort + run: poetry install --no-interaction --only format + - name: autopep8 check id: autopep8 - uses: peter-evans/autopep8@v2 - with: - args: --exit-code --recursive --diff --aggressive --aggressive . + run: poetry run autopep8 --exit-code --recursive --diff --aggressive --aggressive . + continue-on-error: true - - name: Fail if autopep8 made changes - if: steps.autopep8.outputs.exit-code == 2 - run: exit 1 + - name: isort check + id: isort + run: poetry run isort --check-only --diff . + continue-on-error: true + + - name: Fail if autopep8/isort found diffs + if: steps.autopep8.outcome != 'success' || steps.isort.outcome != 'success' + run: | + echo "Error detected in code formatting (autopep8 and/or isort)." + exit 1 - isort-check: + type-check: runs-on: ubuntu-24.04 steps: @@ -38,19 +55,6 @@ jobs: with: python-version: ${{ vars.PYTHON_VERSION }} - - name: isort check - id: isort-step - # default configuration use --check-only and --diff instead of --in-place options. - uses: isort/isort-action@master - continue-on-error: false - - type-check: - runs-on: ubuntu-24.04 - steps: - - - name: Checkout code - uses: actions/checkout@v4 - - name: Install Poetry uses: snok/install-poetry@v1 with: @@ -58,11 +62,6 @@ jobs: virtualenvs-create: true virtualenvs-in-project: true installer-parallel: true - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ vars.PYTHON_VERSION }} - name: Install pytype run: poetry install --no-interaction --only typing @@ -77,6 +76,11 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ vars.PYTHON_VERSION }} + - name: Install Poetry uses: snok/install-poetry@v1 with: @@ -84,11 +88,6 @@ jobs: virtualenvs-create: true virtualenvs-in-project: true installer-parallel: true - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ vars.PYTHON_VERSION }} - name: Verify documentation update uses: dorny/paths-filter@v3 diff --git a/.github/workflows/create_release.yml b/.github/workflows/create_release.yml index c7f450497c..ada953a05f 100644 --- a/.github/workflows/create_release.yml +++ b/.github/workflows/create_release.yml @@ -67,6 +67,11 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ vars.PYTHON_VERSION }} + - name: Install Poetry uses: snok/install-poetry@v1 with: @@ -75,11 +80,6 @@ jobs: virtualenvs-in-project: true installer-parallel: true - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ vars.PYTHON_VERSION }} - - name: Build the distribution files run: poetry build diff --git a/.github/workflows/merge_pr.yml b/.github/workflows/merge_pr.yml index a4cda9cb3a..79d12de217 100644 --- a/.github/workflows/merge_pr.yml +++ b/.github/workflows/merge_pr.yml @@ -18,31 +18,29 @@ jobs: with: python-version: ${{ vars.PYTHON_VERSION }} - - name: Apply isort - id: isort-step - # default configuration use --check-only and --diff instead of --in-place options. - uses: isort/isort-action@master + - name: Install Poetry + uses: snok/install-poetry@v1 with: - configuration: --only-modified + version: latest + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true - - name: autopep8 check and fix - id: autopep8 - uses: peter-evans/autopep8@v2 - with: - args: --exit-code --recursive --in-place --aggressive --aggressive . + - name: Install autopep8 and isort + run: poetry install --no-interaction --only format - - name: Detect changes by isort - uses: tj-actions/verify-changed-files@v18 - id: verify-isort-update - with: - files: | - tests/ - sinergym/ - examples/ - *.py + - name: Apply isort + id: isort + run: poetry run isort . + continue-on-error: true + + - name: apply autopep8 + id: autopep8 + run: poetry run autopep8 --exit-code --recursive --in-place --aggressive --aggressive . + continue-on-error: true - name: Commit format changes - if: steps.autopep8.outputs.exit-code == 2 || steps.verify-isort-update.outputs.files_changed == 'true' + if: steps.autopep8.outcome != 'success' || steps.isort.outcome != 'success' uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: Automatic format fixes (autopep8 + isort) @@ -57,6 +55,11 @@ jobs: with: fetch-depth: 0 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ vars.PYTHON_VERSION }} + - name: Install Poetry uses: snok/install-poetry@v1 with: @@ -64,11 +67,6 @@ jobs: virtualenvs-create: true virtualenvs-in-project: true installer-parallel: true - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ vars.PYTHON_VERSION }} - name: Verify documentation update uses: dorny/paths-filter@v3 diff --git a/.gitignore b/.gitignore index 4cf3b177ae..94b0eecb6c 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ dist/ .coverage codecov coverage.xml +cov.xml #wandb wandb/ diff --git a/docs/source/conf.py b/docs/source/conf.py index 195d7e8d20..62f01af9b5 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -20,7 +20,7 @@ # -- Project information ----------------------------------------------------- project = 'Sinergym' -copyright = '2024, J. Jiménez, J. Gómez, M. Molina, A. Manjavacas, A. Campoy' +copyright = '2025, J. Jiménez, J. Gómez, M. Molina, A. Manjavacas, A. Campoy' author = 'J. Jiménez, J. Gómez, M.l Molina, A. Manjavacas, A. Campoy' diff --git a/docs/source/pages/environments.rst b/docs/source/pages/environments.rst index 9586f83d3c..b2853fb6ef 100644 --- a/docs/source/pages/environments.rst +++ b/docs/source/pages/environments.rst @@ -99,6 +99,8 @@ Finally, :math:`\tau` is the time constant (in hours), controlling how quickly t In climate systems, a large :math:`\tau` could model scenarios where extreme events, such as heatwaves or cold spells, last longer before the system reverts to its average state. The next figure illustrates the effect of different hyperparameters on the Ornstein-Uhlenbeck process noise in a mixed weather. +Starting from version 3.7.2 of Sinergym, these hyperparameters (:math:\sigma, :math:\mu, and :math:\tau) can also be defined as ranges of values rather than fixed constants. To configure this, a tuple of two values (minimum and maximum) is used instead of a single float. For each episode, a random value is sampled from the specified range, enabling more dynamic and varied simulations that better capture the inherent unpredictability of climate systems. A JSON file is saved with the chosen parameters in each episode subfolder. + .. note:: Starting from Sinergym v3.7.1, :math:`\tau` is represented in hours instead of as a percentage of the climate file, making its use more intuitive. .. image:: /_static/ornstein_noise_v2.png diff --git a/docs/source/pages/tests.rst b/docs/source/pages/tests.rst index 5ac9463569..9fe22b7559 100644 --- a/docs/source/pages/tests.rst +++ b/docs/source/pages/tests.rst @@ -43,6 +43,16 @@ To run tests for a specific module, such as ``test_common.py``, use the followin $ pytest tests/test_common.py -vv +************* +Test coverage +************* + +To check the test coverage, you can use the following command: + +.. code:: sh + + $ pytest --cov=. tests/ --cov-report xml:cov.xml + *************** Available tests *************** diff --git a/pyproject.toml b/pyproject.toml index 94b5eae3a9..3c3ecba18c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ package-mode = true name = "sinergym" -version = "3.7.1" +version = "3.7.2" description = "Sinergym provides a Gymnasium-based interface to interact with building simulations. This allows control in simulation time through custom controllers, including reinforcement learning agents" license = "MIT" diff --git a/scripts/train/train_agent.py b/scripts/train/train_agent.py index 9fa096ecfe..1b40ff6350 100644 --- a/scripts/train/train_agent.py +++ b/scripts/train/train_agent.py @@ -44,8 +44,9 @@ def process_environment_parameters(env_params: dict) -> dict: env_params['actuators'][actuator_name] = tuple(components) if env_params.get('weather_variability'): - env_params['weather_variability'] = tuple( - env_params['weather_variability']) + for var_name, var_params in env_params['weather_variability'].items(): + env_params['weather_variability'][var_name] = tuple( + var_params) if env_params.get('reward'): env_params['reward'] = eval(env_params['reward']) diff --git a/sinergym/config/modeling.py b/sinergym/config/modeling.py index 4ebf1042ac..b216b3559c 100644 --- a/sinergym/config/modeling.py +++ b/sinergym/config/modeling.py @@ -318,11 +318,15 @@ def update_weather_path(self) -> None: def apply_weather_variability( self, - weather_variability: Optional[Dict[str, Tuple[float, float, float]]] = None) -> str: + weather_variability: Optional[Dict[str, Tuple[ + Union[float, Tuple[float, float]], + Union[float, Tuple[float, float]], + Union[float, Tuple[float, float]] + ]]] = None) -> str: """Modify weather data using Ornstein-Uhlenbeck process according to the variation specified in the weather_variability dictionary. Args: - weather_variability (Optional[Dict[str, Tuple[float, float, float]]], optional): Dictionary with the variation for each column in the weather data. Defaults to None. The key is the column name and the value is a tuple with the sigma, mean and tau for OU process. + weather_variability (Optional[Dict[str,Tuple[Union[float,Tuple[float,float]],Union[float,Tuple[float,float]],Union[float,Tuple[float,float]]]]]): Dictionary with the variation for each column in the weather data. Defaults to None. The key is the column name and the value is a tuple with the sigma, mean and tau for OU process. Returns: str: New EPW file path generated in simulator working path in that episode or current EPW path if variation is not defined. @@ -334,18 +338,34 @@ def apply_weather_variability( # Apply variation to EPW if exists if weather_variability is not None: + # Check if there are ranges specified in params and get a random + # value + variability_config = { + weather_var: tuple( + np.random.uniform(param[0], param[1]) if isinstance(param, tuple) else param + for param in params + ) + for weather_var, params in weather_variability.items() + } + + # Write variability_config to a JSON file for episode + config_path = f"{ + self.episode_path}/weather_variability_config.json" + with open(config_path, 'w') as f: + json.dump(variability_config, f) + + # Apply Ornstein-Uhlenbeck process to weather data weather_data_mod.dataframe = ornstein_uhlenbeck_process( data=self.weather_data.dataframe, - variability_config=weather_variability) + variability_config=variability_config) self.logger.info( 'Weather noise applied in columns: {}'.format( list( - weather_variability.keys()))) + variability_config.keys()))) - # Change name filename to specify variation nature in name - filename = filename.split('.epw')[0] - filename += '_OU_Noise.epw' + # Modify filename to reflect noise addition + filename = f"{filename.split('.epw')[0]}_OU_Noise.epw" episode_weather_path = self.episode_path + '/' + filename weather_data_mod.write(episode_weather_path) diff --git a/sinergym/envs/eplus_env.py b/sinergym/envs/eplus_env.py index ec0a585be5..abb8bc5a6b 100644 --- a/sinergym/envs/eplus_env.py +++ b/sinergym/envs/eplus_env.py @@ -42,7 +42,11 @@ def __init__( variables: Dict[str, Tuple[str, str]] = {}, meters: Dict[str, str] = {}, actuators: Dict[str, Tuple[str, str, str]] = {}, - weather_variability: Optional[Dict[str, Tuple[float, float, float]]] = None, + weather_variability: Optional[Dict[str, Tuple[ + Union[float, Tuple[float, float]], + Union[float, Tuple[float, float]], + Union[float, Tuple[float, float]] + ]]] = None, reward: Any = LinearReward, reward_kwargs: Optional[Dict[str, Any]] = {}, max_ep_data_store_num: int = 10, @@ -59,7 +63,7 @@ def __init__( variables (Dict[str, Tuple[str, str]]): Specification for EnergyPlus Output:Variable. The key name is custom, then tuple must be the original variable name and the output variable key. Defaults to empty dict. meters (Dict[str, str]): Specification for EnergyPlus Output:Meter. The key name is custom, then value is the original EnergyPlus Meters name. actuators (Dict[str, Tuple[str, str, str]]): Specification for EnergyPlus Input Actuators. The key name is custom, then value is a tuple with actuator type, value type and original actuator name. Defaults to empty dict. - weather_variability Optional[Dict[str, Tuple[float, float, float]]]: Tuple with sigma, mu and tau of the Ornstein-Uhlenbeck process for each desired variable to be applied to weather data. Defaults to None. + weather_variability (Optional[Dict[str,Tuple[Union[float,Tuple[float,float]],Union[float,Tuple[float,float]],Union[float,Tuple[float,float]]]]]): Tuple with sigma, mu and tau of the Ornstein-Uhlenbeck process for each desired variable to be applied to weather data. Ranges can be specified to and a value will be select randomly for each episode. Defaults to None. reward (Any, optional): Reward function instance used for agent feedback. Defaults to LinearReward. reward_kwargs (Optional[Dict[str, Any]], optional): Parameters to be passed to the reward function. Defaults to empty dict. max_ep_data_store_num (int, optional): Number of last sub-folders (one for each episode) generated during execution on the simulation. @@ -434,6 +438,52 @@ def _check_eplus_env(self) -> None: 'Action space shape must match with number of action variables specified.') raise err + # WEATHER VARIABILITY + if 'weather_variability' in self.default_options: + def validate_params(params): + """Validate weather variability parameters.""" + if not (isinstance(params, tuple) or isinstance(params, list)): + raise ValueError( + f"Invalid parameter for Ornstein-Uhlenbeck process: {params}. " + "It must be a tuple of 3 elements." + ) + if len(params) != 3: + raise ValueError( + f"Invalid parameter for Ornstein-Uhlenbeck process: {params}. " + "It must have exactly 3 values." + ) + + for param in params: + if not ( + isinstance( + param, + tuple) or isinstance( + param, + list) or isinstance( + param, + float) + ): + raise ValueError( + f"Invalid parameter for Ornstein-Uhlenbeck process: {param}. " + "It must be a tuple of two values (range), or a float." + ) + if (isinstance( + param, tuple) or isinstance( + param, list)) and len(param) != 2: + raise ValueError( + f"Invalid parameter for Ornstein-Uhlenbeck process: {param}. " + "Tuples must have exactly two values (range)." + ) + + try: + # Validate each weather variability parameter + for _, params in self.default_options['weather_variability'].items( + ): + validate_params(params) + except ValueError as err: + self.logger.critical(str(err)) # Convert the error to a string + raise err + # ---------------------------------------------------------------------------- # # Properties # # ---------------------------------------------------------------------------- # diff --git a/sinergym/version.txt b/sinergym/version.txt index 5cdb444f3d..47b6be3faf 100644 --- a/sinergym/version.txt +++ b/sinergym/version.txt @@ -1 +1 @@ -3.7.1 \ No newline at end of file +3.7.2 \ No newline at end of file diff --git a/tests/test_common.py b/tests/test_common.py index 1d861f102d..8ca1c12959 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -113,8 +113,8 @@ def test_ornstein_uhlenbeck_process(weather_data): df = weather_data.dataframe # Specify variability configuration for each desired column variability_conf = { - 'Dry Bulb Temperature': (1.0, 0.0, 0.001), - 'Wind Speed': (3.0, 0.0, 0.01) + 'Dry Bulb Temperature': (1.0, 0.0, 24.0), + 'Wind Speed': (3.0, 0.0, 48.0) } # Calculate dataframe with noise noise = common.ornstein_uhlenbeck_process( diff --git a/tests/test_env.py b/tests/test_env.py index 6b4c3971ab..b03ab94176 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -185,6 +185,50 @@ def test_action_contradiction(env_demo): env_demo._check_eplus_env() +def test_wrong_weather_variability_conf(env_5zone_stochastic): + + env_5zone_stochastic.get_wrapper_attr( + 'default_options')['weather_variability'] = { + 'Dry Bulb Temperature': ((1.0, 2.0), (-0.5, 0.5), 24.0), + 'Wind Speed': (3.0, 0.0, (30.0, 35.0)) + } + # It should accept ranges + env_5zone_stochastic._check_eplus_env() + + # It should raise an exception if is not a tuple or with wrong length (3) + env_5zone_stochastic.get_wrapper_attr( + 'default_options')['weather_variability'] = { + 'Dry Bulb Temperature': ((1.0, 2.0), (-0.5, 0.5), 24.0), + 'Wind Speed': (3.0, (30.0, 35.0)) + } + with pytest.raises(ValueError): + env_5zone_stochastic._check_eplus_env() + # It should raise an exception if is not a tuple or with wrong length (3) + env_5zone_stochastic.get_wrapper_attr( + 'default_options')['weather_variability'] = { + 'Dry Bulb Temperature': 25.0, + 'Wind Speed': (3.0, 0.0, (30.0, 35.0)) + } + with pytest.raises(ValueError): + env_5zone_stochastic._check_eplus_env() + # It should raise an exception if the param is not a tuple or float + env_5zone_stochastic.get_wrapper_attr( + 'default_options')['weather_variability'] = { + 'Dry Bulb Temperature': ('a', (-0.5, 0.5), 24.0), + 'Wind Speed': (3.0, 0.0, (30.0, 35.0)) + } + with pytest.raises(ValueError): + env_5zone_stochastic._check_eplus_env() + # It should raise an exception if the range has not 2 values + env_5zone_stochastic.get_wrapper_attr( + 'default_options')['weather_variability'] = { + 'Dry Bulb Temperature': ((1.0, 2.0, 3.0), (-0.5, 0.5), 24.0), + 'Wind Speed': (3.0, 0.0, (30.0, 35.0)) + } + with pytest.raises(ValueError): + env_5zone_stochastic._check_eplus_env() + + def test_is_discrete_property(env_5zone): assert isinstance(env_5zone.action_space, gym.spaces.Box) assert env_5zone.is_discrete == False diff --git a/tests/test_modeling.py b/tests/test_modeling.py index fcb3c2eb83..2b1fc040ce 100644 --- a/tests/test_modeling.py +++ b/tests/test_modeling.py @@ -153,7 +153,7 @@ def test_save_building_model(model_5zone): def test_check_model_wrong_weather(model_5zone_several_weathers): model_5zone_several_weathers._check_eplus_config() # update weather paths with one which does not exist - model_5zone_several_weathers.weather_files.append('unkown_weather.epw') + model_5zone_several_weathers.weather_files.append('unknown_weather.epw') with pytest.raises(AssertionError): model_5zone_several_weathers._check_eplus_config() @@ -200,6 +200,12 @@ def test_apply_weather_variability(model_5zone): original_filename = model_5zone._weather_path.split('/')[-1] path_filename = path_result.split('/')[-1] assert original_filename == path_filename + # It shouldn't generate variability config + # It should generate a json file + assert not os.path.exists( + model_5zone.episode_path + + '/weather_variability_config.json') + # Check with a variation weather_variability = { 'Dry Bulb Temperature': (1.0, 0.0, 24.0), @@ -207,6 +213,10 @@ def test_apply_weather_variability(model_5zone): } path_result = model_5zone.apply_weather_variability( weather_variability=weather_variability) + # It should generate weather variability config file + assert os.path.exists( + model_5zone.episode_path + + '/weather_variability_config.json') filename = model_5zone._weather_path.split('/')[-1] filename = filename.split('.epw')[0] filename += '_OU_Noise.epw' diff --git a/tests/test_wrapper.py b/tests/test_wrapper.py index 6f7b7421cc..da92c6f71f 100644 --- a/tests/test_wrapper.py +++ b/tests/test_wrapper.py @@ -291,8 +291,8 @@ def test_weatherforecasting_wrapper_forecastdata(env_demo): env = WeatherForecastingWrapper( env_demo, n=3, delta=1, forecast_variability={ 'Dry Bulb Temperature': ( - 1.0, 0.0, 0.001), 'Wind Speed': ( - 3.0, 0.0, 0.01)}) + 1.0, 0.0, 24.0), 'Wind Speed': ( + 3.0, 0.0, 48.0)}) # Get original weather_data original_weather_data = Weather() @@ -350,15 +350,15 @@ def test_weatherforecasting_wrapper_exceptions(env_demo): 'Dry Bulb Temperature': ( 1.0, 0.0, - 0.001), + 24.0), 'Wind Speed': ( 3.0, 0.0, - 0.01), + 48.0), 'Not in columns': ( 3.0, 0.0, - 0.01)}) + 48.0)}) def test_energycost_wrapper(env_demo): @@ -396,7 +396,7 @@ def test_energycost_wrapper_energycostdata(env_demo): energy_cost_variability=( 1.0, 0.0, - 0.001)) + 24.0)) # Get and preprocess manually original cost data original_energy_cost_data = pd.read_csv(env.energy_cost_data_path, sep=';')