diff --git a/emiproc/profiles/temporal_profiles.py b/emiproc/profiles/temporal_profiles.py index 3b3deee..d420d3d 100644 --- a/emiproc/profiles/temporal_profiles.py +++ b/emiproc/profiles/temporal_profiles.py @@ -15,6 +15,7 @@ import emiproc from emiproc.profiles.utils import ( + get_objects_of_same_type_from_list, get_profiles_indexes, merge_indexes, read_profile_csv, @@ -59,6 +60,35 @@ def _generate_next_value_(name: str, start, count, last_values): # One of the 2 last days WEEKEND = auto() + @classmethod + def from_day_number(cls, day_number: int) -> SpecificDay: + """Return the day corresponding to the day number. + + Day start at 0 for Monday and ends at 6 for Sunday. + """ + if not isinstance(day_number, int): + raise TypeError(f"{day_number=} must be an int.") + + dict_day = { + 0: cls.MONDAY, + 1: cls.TUESDAY, + 2: cls.WEDNESDAY, + 3: cls.THURSDAY, + 4: cls.FRIDAY, + 5: cls.SATURDAY, + 6: cls.SUNDAY, + } + + if day_number not in dict_day: + raise ValueError(f"{day_number=} is not a valid day number.") + + return dict_day[day_number] + + +def days_of_specific_day(specific_day: SpecificDay) -> list[SpecificDay]: + int_days = get_days_as_ints(specific_day) + return [SpecificDay.from_day_number(i) for i in int_days] + def get_days_as_ints(specific_day: SpecificDay) -> list[int]: """Return the days corresponding for a specific day.""" @@ -551,6 +581,70 @@ def make_composite_profiles( return CompositeTemporalProfiles(extracted_profiles), out_indexes +def ensure_specific_days_consistency( + profiles: list[AnyTimeProfile], +) -> list[AnyTimeProfile]: + """Make sure that there is not confilct between specific days profiles and normal daily profiles. + + In case there is any conflict, this return a profile for each day of the week. + """ + + if not any(isinstance(p, SpecificDayProfile) for p in profiles): + return profiles + + # Get the specific days profiles + + daily_profiles = [p for p in profiles if isinstance(p, DailyProfile)] + non_daily_profiles = [p for p in profiles if not isinstance(p, DailyProfile)] + + weekdays_profiles = { + SpecificDay.MONDAY: None, + SpecificDay.TUESDAY: None, + SpecificDay.WEDNESDAY: None, + SpecificDay.THURSDAY: None, + SpecificDay.FRIDAY: None, + SpecificDay.SATURDAY: None, + SpecificDay.SUNDAY: None, + } + # First assign what we have sepcific + for p in daily_profiles: + if isinstance(p, SpecificDayProfile): + days = days_of_specific_day(p.specific_day) + if len(days) == 1: + # This day was concerned specifically + weekdays_profiles[p.specific_day] = p + else: + # This was a weekend or weekday profile + for day in days: + # Only override if not already defined + if weekdays_profiles[day] is None: + weekdays_profiles[day] = SpecificDayProfile( + specific_day=day, ratios=p.ratios + ) + + general_daily_profiles = [p for p in daily_profiles if type(p) == DailyProfile] + + # Add a constant profile for missing day + for day, profile in weekdays_profiles.items(): + if profile is not None: + continue + if len(general_daily_profiles) == 0: + p = SpecificDayProfile(specific_day=day) + + elif len(general_daily_profiles) == 1: + p = SpecificDayProfile( + specific_day=day, ratios=general_daily_profiles[0].ratios + ) + else: + raise ValueError( + f"Cannot assign {general_daily_profiles=} to {day=}, more than one" + f" general {type(DailyProfile)} was given." + ) + weekdays_profiles[day] = p + + return non_daily_profiles + list(weekdays_profiles.values()) + + def create_scaling_factors_time_serie( start_time: datetime, end_time: datetime, @@ -584,6 +678,9 @@ def create_scaling_factors_time_serie( # Create the scaling factors scaling_factors = np.ones(len(time_serie)) + # Correct profiles list with specific day profiles + profiles = ensure_specific_days_consistency(profiles) + # Apply the profiles for profile in profiles: scaling_factors *= profile_to_scaling_factors( @@ -622,7 +719,19 @@ def profile_to_scaling_factors( scaling_factors = np.ones(len(time_serie)) # Get the profile - if isinstance(profile, DailyProfile): + if isinstance(profile, SpecificDayProfile): + # Find the days corresponding to this factor + days_allowed = get_days_as_ints(profile.specific_day) + if len(days_allowed) != 1: + raise ValueError( + f"Cannot apply {profile=} to a time serie, it must have only one day." + "convert the time profiles with `ensure_specific_days_consistency`." + ) + mask_matching_day = np.isin(time_serie.day_of_week, days_allowed) + for hour, factor in enumerate(factors): + # Other days will not have a scaling factor + scaling_factors[(time_serie.hour == hour) & mask_matching_day] *= factor + elif isinstance(profile, DailyProfile): # Get the mask for each hour of day and apply the scaling factor for hour, factor in enumerate(factors): scaling_factors[time_serie.hour == hour] *= factor @@ -657,13 +766,6 @@ def profile_to_scaling_factors( # Months start with 1 month += 1 scaling_factors[time_serie.month == month] *= factor - elif isinstance(profile, SpecificDayProfile): - # Find the days corresponding to this factor - days_allowed = get_days_as_ints(profile.specific_day) - mask_matching_day = np.isin(time_serie.day_of_week, days_allowed) - for hour, factor in enumerate(factors): - # Other days will not have a scaling factor - scaling_factors[(time_serie.hour == hour) & mask_matching_day] *= factor else: raise NotImplementedError( f"Cannot apply {profile=}, {type(profile)=} is not implemented." diff --git a/emiproc/profiles/utils.py b/emiproc/profiles/utils.py index a1daea3..dd90f94 100644 --- a/emiproc/profiles/utils.py +++ b/emiproc/profiles/utils.py @@ -3,7 +3,7 @@ import logging from os import PathLike from pathlib import Path -from typing import Any, Type +from typing import TYPE_CHECKING, Any, Type import pandas as pd import xarray as xr @@ -12,6 +12,10 @@ import emiproc from emiproc.profiles import naming +if TYPE_CHECKING: + from emiproc.profiles.vertical_profiles import VerticalProfiles + from emiproc.profiles.temporal_profiles import CompositeTemporalProfiles + logger = logging.getLogger(__name__) @@ -38,12 +42,46 @@ def remove_objects_of_type_from_list(object: Any, objects_list: list[Any]) -> li def get_objects_of_same_type_from_list( - object: Any, objects_list: list[Any] + object: Any, objects_list: list[Any], exact_type: bool = False ) -> list[Any]: """Return the object of the same type from the list.""" + func = isinstance if exact_type else lambda x, y: type(x) == y return [o for o in objects_list if isinstance(object, type(o))] +def check_valid_indexes( + indexes: xr.DataArray, profiles: VerticalProfiles | CompositeTemporalProfiles = None +) -> None: + """Check that the given indexes are valid. + + :raises ValueError: if the indexes are not valid + """ + + # check all the dims names are valid + dims_not_allowed = set(indexes.dims) - set(naming.type_of_dim.keys()) + if len(dims_not_allowed) > 0: + raise ValueError( + f"Indexes are not allowed to contain {dims_not_allowed=}, " + f"allowed dims are {naming.type_of_dim.keys()}" + ) + # Make sure no coords has duplicated values + for coord in indexes.coords: + if len(indexes.coords[coord]) != len(np.unique(indexes.coords[coord])): + raise ValueError( + f"Indexes are not valid, they contain duplicated values for {coord=}:" + f" {indexes.coords[coord]}" + ) + + if profiles is not None: + # Check that the max value of the index is given in the profiles + if indexes.max().values >= len(profiles): + raise ValueError( + "Indexes are not valid, they contain values that are not in the" + f" profiles.Got {indexes.max().values=} but profiles has" + f" {len(profiles)=}" + ) + + def get_desired_profile_index( profiles_indexes: xr.DataArray, cell: int | None = None, diff --git a/tests/profiles/test_create_timeserie.py b/tests/profiles/test_create_timeserie.py new file mode 100644 index 0000000..65047d1 --- /dev/null +++ b/tests/profiles/test_create_timeserie.py @@ -0,0 +1,75 @@ +import pytest +from emiproc import FILES_DIR +from datetime import datetime +import numpy as np +from emiproc.tests_utils.temporal_profiles import oem_const_profile +from emiproc.profiles.temporal_profiles import ( + DailyProfile, + SpecificDay, + SpecificDayProfile, + days_of_specific_day, + ensure_specific_days_consistency, + from_yaml, + create_scaling_factors_time_serie, + profile_to_scaling_factors, +) + + +def test_ensure_specific_days_consistency_non_specific(): + out = ensure_specific_days_consistency(oem_const_profile) + assert len(out) == len(oem_const_profile) + + out = ensure_specific_days_consistency( + [SpecificDayProfile(specific_day=SpecificDay("monday"))] + ) + assert len(out) == 7 # Contains the full week + + half_double_ratios = np.concatenate((np.full(12, 0.5), np.full(12, 1.5))) / 24 + thirds_triple_ratios = np.concatenate((np.full(12, 1 / 3), np.full(12, 5 / 3))) / 24 + quaters_ratios = np.concatenate((np.full(12, 1 / 4), np.full(12, 7 / 4))) / 24 + out = ensure_specific_days_consistency( + [ + SpecificDayProfile( + ratios=half_double_ratios, specific_day=SpecificDay.MONDAY + ), + SpecificDayProfile( + ratios=thirds_triple_ratios, specific_day=SpecificDay.WEEKEND + ), + SpecificDayProfile(ratios=quaters_ratios, specific_day=SpecificDay.SUNDAY), + DailyProfile(), + ], + ) + for p in out: + assert isinstance(p, SpecificDayProfile) + assert len(days_of_specific_day(p.specific_day)) == 1 + if p.specific_day == SpecificDay.MONDAY: + equalt_to = half_double_ratios + elif p.specific_day == SpecificDay.SATURDAY: + equalt_to = thirds_triple_ratios + elif p.specific_day == SpecificDay.SUNDAY: + equalt_to = quaters_ratios + else: + equalt_to = np.full(24, 1.0) / 24 + np.testing.assert_array_equal( + p.ratios.reshape(-1), equalt_to, err_msg=f"Failed for {p.specific_day=}" + ) + + +@pytest.mark.parametrize( + "profiles", + [ + oem_const_profile, + from_yaml(FILES_DIR / "profiles" / "yamls" / "heat.yaml"), + from_yaml(FILES_DIR / "profiles" / "yamls" / "human_home.yaml"), + from_yaml(FILES_DIR / "profiles" / "yamls" / "human.yaml"), + from_yaml(FILES_DIR / "profiles" / "yamls" / "ship.yaml"), + from_yaml(FILES_DIR / "profiles" / "yamls" / "heavy.yaml"), + from_yaml(FILES_DIR / "profiles" / "yamls" / "light.yaml"), + ], +) +def test_create_scaling_factors_time_serie(profiles): + start = datetime(2018, 1, 1) + end = datetime(2019, 1, 1) + ts = create_scaling_factors_time_serie(start, end, profiles) + + assert np.isclose(ts.mean(), 1.0, atol=0.1) diff --git a/tests/profiles/test_profiles_utils.py b/tests/profiles/test_profiles_utils.py index 9e92285..c23b585 100644 --- a/tests/profiles/test_profiles_utils.py +++ b/tests/profiles/test_profiles_utils.py @@ -5,6 +5,34 @@ ) from emiproc.profiles.utils import get_desired_profile_index +from emiproc.profiles.temporal_profiles import SpecificDay, days_of_specific_day + + +@pytest.mark.parametrize( + "specific_days, expected", + ( + (SpecificDay.MONDAY, [SpecificDay.MONDAY]), + (SpecificDay.TUESDAY, [SpecificDay.TUESDAY]), + (SpecificDay.WEDNESDAY, [SpecificDay.WEDNESDAY]), + (SpecificDay.THURSDAY, [SpecificDay.THURSDAY]), + (SpecificDay.FRIDAY, [SpecificDay.FRIDAY]), + (SpecificDay.SATURDAY, [SpecificDay.SATURDAY]), + (SpecificDay.SUNDAY, [SpecificDay.SUNDAY]), + (SpecificDay.WEEKEND, [SpecificDay.SATURDAY, SpecificDay.SUNDAY]), + ( + SpecificDay.WEEKDAY, + [ + SpecificDay.MONDAY, + SpecificDay.TUESDAY, + SpecificDay.WEDNESDAY, + SpecificDay.THURSDAY, + SpecificDay.FRIDAY, + ], + ), + ), +) +def test_days_of_specific_day(specific_days, expected): + assert days_of_specific_day(specific_days) == expected def test_get_desired_profile_index_working_cases(): @@ -30,6 +58,7 @@ def test_get_desired_profile_index_errors(): ValueError, get_desired_profile_index, da_profiles_indexes_catsub, cat="a" ) + def test_get_desired_profile_index_wrong_catsub(): # Category does not exist pytest.raises(