Skip to content

Commit

Permalink
feat(local): implement thermal_ts_generation (#112)
Browse files Browse the repository at this point in the history
  • Loading branch information
MartinBelthle authored Mar 6, 2025
1 parent 92ca409 commit 8b616b4
Show file tree
Hide file tree
Showing 7 changed files with 174 additions and 5 deletions.
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
antares-timeseries-generation==0.1.7

absl-py~=1.4.0
click~=8.1.7
configparser~=5.0.2
Expand Down
3 changes: 2 additions & 1 deletion src/antares/craft/model/study.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,8 @@ def move(self, parent_path: Path) -> None:
self.path = self._study_service.move_study(parent_path)

def generate_thermal_timeseries(self, nb_years: int) -> None:
self._study_service.generate_thermal_timeseries(nb_years)
seed = self._settings.seed_parameters.seed_tsgen_thermal
self._study_service.generate_thermal_timeseries(nb_years, self._areas, seed)
# Copies objects to bypass the fact that the class is frozen
self._settings.general_parameters = replace(self._settings.general_parameters, nb_timeseries_thermal=nb_years)

Expand Down
3 changes: 2 additions & 1 deletion src/antares/craft/service/api_services/services/study.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
TaskTimeOutError,
ThermalTimeseriesGenerationError,
)
from antares.craft.model.area import Area
from antares.craft.model.binding_constraint import (
BindingConstraint,
)
Expand Down Expand Up @@ -136,7 +137,7 @@ def move_study(self, new_parent_path: Path) -> PurePath:
raise StudyMoveError(self.study_id, new_parent_path.as_posix(), e.message) from e

@override
def generate_thermal_timeseries(self, nb_years: int) -> None:
def generate_thermal_timeseries(self, nb_years: int, areas: dict[str, Area], seed: int) -> None:
url = f"{self._base_url}/studies/{self.study_id}/timeseries/generate"
url_config = f"{self._base_url}/studies/{self.study_id}/timeseries/config"
json_thermal_timeseries = {"thermal": {"number": nb_years}}
Expand Down
2 changes: 1 addition & 1 deletion src/antares/craft/service/base_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,7 @@ def delete_output(self, output_name: str) -> None:
pass

@abstractmethod
def generate_thermal_timeseries(self, number_of_years: int) -> None:
def generate_thermal_timeseries(self, number_of_years: int, areas: dict[str, "Area"], seed: int) -> None:
pass


Expand Down
102 changes: 100 additions & 2 deletions src/antares/craft/service/local_services/services/study.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,109 @@
# SPDX-License-Identifier: MPL-2.0
#
# This file is part of the Antares project.
import logging
import shutil
import tempfile

from pathlib import Path, PurePath
from typing import TYPE_CHECKING

import numpy as np
import pandas as pd

from antares.craft.config.local_configuration import LocalConfiguration
from antares.craft.model.area import Area
from antares.craft.model.binding_constraint import (
BindingConstraint,
)
from antares.craft.model.output import Output
from antares.craft.model.thermal import LocalTSGenerationBehavior
from antares.craft.service.base_services import BaseOutputService, BaseStudyService
from antares.tsgen.duration_generator import ProbabilityLaw
from antares.tsgen.random_generator import MersenneTwisterRNG
from antares.tsgen.ts_generator import OutageGenerationParameters, ThermalCluster, TimeseriesGenerator
from typing_extensions import override

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
from antares.craft.model.study import Study


def _replace_safely_original_files(study_path: Path, tmp_path: Path) -> None:
original_path = study_path / "input" / "thermal" / "series"
shutil.rmtree(original_path)
tmp_path.rename(original_path)


def _build_timeseries(number_of_years: int, areas_dict: dict[str, Area], seed: int, tmp_path: Path) -> None:
# 1- Get the seed and nb_years to generate
# 2 - Build the generator
rng = MersenneTwisterRNG(seed=seed)
generator = TimeseriesGenerator(rng=rng, days=365)
# 3- Do a first loop to know how many operations will be performed
total_generations = sum(len(area.get_thermals()) for area in areas_dict.values())
# 4- Loop through areas in alphabetical order
generation_performed = 0
areas = list(areas_dict.values())
areas.sort(key=lambda area: area.id)
for area in areas:
area_id = area.id
# 5- Loop through thermal clusters in alphabetical order
thermals = list(area.get_thermals().values())
thermals.sort(key=lambda thermal: thermal.id)
for thermal in thermals:
try:
# 6 - Filters out clusters with no generation
if thermal.properties.gen_ts == LocalTSGenerationBehavior.FORCE_NO_GENERATION:
generation_performed += 1
continue
# 7- Build the cluster
modulation_matrix = thermal.get_prepro_modulation_matrix()
modulation_capacity = modulation_matrix[2].to_numpy()
data_matrix = thermal.get_prepro_data_matrix()
fo_duration = np.array(data_matrix[0], dtype=int)
po_duration = np.array(data_matrix[1], dtype=int)
fo_rate = np.array(data_matrix[2], dtype=float)
po_rate = np.array(data_matrix[3], dtype=float)
npo_min = np.array(data_matrix[4], dtype=int)
npo_max = np.array(data_matrix[5], dtype=int)
generation_params = OutageGenerationParameters(
unit_count=thermal.properties.unit_count,
fo_law=ProbabilityLaw(thermal.properties.law_forced.value.upper()),
fo_volatility=thermal.properties.volatility_forced,
po_law=ProbabilityLaw(thermal.properties.law_planned.value.upper()),
po_volatility=thermal.properties.volatility_planned,
fo_duration=fo_duration,
fo_rate=fo_rate,
po_duration=po_duration,
po_rate=po_rate,
npo_min=npo_min,
npo_max=npo_max,
)
cluster = ThermalCluster(
outage_gen_params=generation_params,
nominal_power=thermal.properties.nominal_capacity,
modulation=modulation_capacity,
)
# 8- Generate the time-series
results = generator.generate_time_series_for_clusters(cluster, number_of_years)
generated_matrix = results.available_power
# 9- Write the matrix inside the input folder.
df = pd.DataFrame(data=generated_matrix)
df = df[list(df.columns)].astype(int)
target_path = tmp_path / area_id / thermal.id / "series.txt"
df.to_csv(target_path, sep="\t", header=False, index=False, float_format="%.6f")
# 10- Notify the progress
generation_performed += 1
logger.info(
f"Thermal cluster ts-generation advancement {round(generation_performed * 100 / total_generations, 1)} %"
)
except Exception as e:
e.args = tuple([f"Area {area_id}, cluster {thermal.id}: {e.args[0]}"])
raise


class StudyLocalService(BaseStudyService):
def __init__(self, config: LocalConfiguration, study_name: str, output_service: BaseOutputService) -> None:
self._config = config
Expand Down Expand Up @@ -73,5 +161,15 @@ def move_study(self, new_parent_path: Path) -> PurePath:
raise NotImplementedError

@override
def generate_thermal_timeseries(self, number_of_years: int) -> None:
raise NotImplementedError
def generate_thermal_timeseries(self, number_of_years: int, areas: dict[str, Area], seed: int) -> None:
study_path = self.config.study_path
with tempfile.TemporaryDirectory(suffix=".thermal_ts_gen.tmp", prefix="~", dir=study_path.parent) as path:
tmp_dir = Path(path)
try:
shutil.copytree(study_path / "input" / "thermal" / "series", tmp_dir, dirs_exist_ok=True)
_build_timeseries(number_of_years, areas, seed, tmp_dir)
except Exception as e:
logger.error(f"Unhandled exception when trying to generate thermal timeseries: {e}", exc_info=True)
raise
else:
_replace_safely_original_files(study_path, tmp_dir)
7 changes: 7 additions & 0 deletions tests/antares/services/local_services/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ def local_study_w_thermal(tmp_path, local_study_w_links) -> Study:
return local_study_w_links


@pytest.fixture
def local_study_w_thermals(tmp_path, local_study_w_thermal) -> Study:
local_study_w_thermal.get_areas()["fr"].create_thermal_cluster("thermal_fr_2")
local_study_w_thermal.get_areas()["it"].create_thermal_cluster("thermal_it")
return local_study_w_thermal


@pytest.fixture
def local_study_w_storage(tmp_path, local_study_w_areas) -> Study:
st_properties = STStorageProperties(efficiency=0.4, initial_level_optim=True)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Copyright (c) 2024, RTE (https://www.rte-france.com)
#
# See AUTHORS.txt
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
#
# SPDX-License-Identifier: MPL-2.0
#
# This file is part of the Antares project.
import pytest

import re

import numpy as np
import pandas as pd

from antares.craft.model.thermal import ThermalClusterPropertiesUpdate


class TestThermalTsGeneration:
def set_up_matrices(self, local_study_w_thermals):
prepro_data_matrix = np.zeros((365, 6))
prepro_data_matrix[:, :2] = 1

prepro_modulation_matrix = np.ones((8760, 4))
prepro_modulation_matrix[:, 3] = 0

cluster_1 = local_study_w_thermals.get_areas()["fr"].get_thermals()["test thermal cluster"]
cluster_2 = local_study_w_thermals.get_areas()["fr"].get_thermals()["thermal_fr_2"]
cluster_3 = local_study_w_thermals.get_areas()["it"].get_thermals()["thermal_it"]
for cluster in [cluster_1, cluster_2, cluster_3]:
cluster.update_prepro_data_matrix(pd.DataFrame(prepro_data_matrix))
cluster.update_prepro_modulation_matrix(pd.DataFrame(prepro_modulation_matrix))

def test_nominal_case(self, local_study_w_thermals):
self.set_up_matrices(local_study_w_thermals)
# Change nominal capacity
cluster_1 = local_study_w_thermals.get_areas()["fr"].get_thermals()["test thermal cluster"]
cluster_2 = local_study_w_thermals.get_areas()["fr"].get_thermals()["thermal_fr_2"]
cluster_3 = local_study_w_thermals.get_areas()["it"].get_thermals()["thermal_it"]
for k, cluster in enumerate([cluster_1, cluster_2, cluster_3]):
new_properties = ThermalClusterPropertiesUpdate(nominal_capacity=100 * (k + 1))
cluster.update_properties(new_properties)
# Generate new TS
local_study_w_thermals.generate_thermal_timeseries(4)
# Checks TS generated
for k, cluster in enumerate([cluster_1, cluster_2, cluster_3]):
series = cluster.get_series_matrix()
expected_series = pd.DataFrame(np.full((8760, 4), (k + 1) * 100))
assert series.equals(expected_series)

def test_error_case(self, local_study_w_thermals):
self.set_up_matrices(local_study_w_thermals)
with pytest.raises(
ValueError,
match=re.escape("Area fr, cluster test thermal cluster: Nominal power must be strictly positive, got 0."),
):
local_study_w_thermals.generate_thermal_timeseries(4)

0 comments on commit 8b616b4

Please sign in to comment.