From fa8b80b7d3ca2c4e88334a023ae51aa4817bd0da Mon Sep 17 00:00:00 2001 From: shubh Date: Sat, 19 Oct 2024 15:57:23 +0200 Subject: [PATCH 01/10] added caching and support for prediction storage --- .gitignore | 6 +- codegreen_core/models/predict.py | 9 +- codegreen_core/tools/loadshift_time.py | 149 +++++-------------------- codegreen_core/utilities/caching.py | 86 ++++++++++++++ codegreen_core/utilities/config.py | 13 ++- codegreen_core/utilities/metadata.py | 13 ++- 6 files changed, 142 insertions(+), 134 deletions(-) create mode 100644 codegreen_core/utilities/caching.py diff --git a/.gitignore b/.gitignore index 56573ae..671e9cd 100644 --- a/.gitignore +++ b/.gitignore @@ -171,6 +171,6 @@ tests/data1 # temp, will remove later codegreen_core/models/files -codegreen_core/utilities/log.py - -.vscode \ No newline at end of file +Dockerfile +.vscode +poetry.lock \ No newline at end of file diff --git a/codegreen_core/models/predict.py b/codegreen_core/models/predict.py index 5bfe973..9d443c2 100644 --- a/codegreen_core/models/predict.py +++ b/codegreen_core/models/predict.py @@ -11,10 +11,13 @@ # Path to the models directory models_dir = Path(__file__).parent / "files" - + +def predicted_energy(country): + # do the forecast from now , same return format as data.energy + return {"data":None} # Function to load a specific model by name -def load_prediction_model(country,version=None): +def _load_prediction_model(country,version=None): """Load a model by name""" model_details = get_prediction_model_details(country,version) model_path = models_dir / model_details["name"] @@ -25,7 +28,7 @@ def load_prediction_model(country,version=None): return load_model(model_path,compile=False) -def run(country,input,model_version=None): +def _run(country,input,model_version=None): """Returns the prediction values""" seq_length = len(input) diff --git a/codegreen_core/tools/loadshift_time.py b/codegreen_core/tools/loadshift_time.py index 1cc2736..b1d0639 100644 --- a/codegreen_core/tools/loadshift_time.py +++ b/codegreen_core/tools/loadshift_time.py @@ -4,114 +4,53 @@ import pandas as pd # from greenerai.api.data.utils import Message from ..utilities.message import Message -from ..utilities.log import time_prediction as log_time_prediction -from ..utilities.metadata import get_country_energy_source -from ..data import entsoe as e +from ..utilities.metadata import check_prediction_model_exists +from ..utilities.caching import get_cache_or_update from ..data import energy +from ..models.predict import predicted_energy from ..utilities.config import Config import redis import json import traceback - -# ======= Caching energy data in redis ============ -def _get_country_key(country_code): - return "codegreen_optimal_"+country_code - -def _get_cache_or_update(country, start, deadline): - """ - The cache contains an entry for every country. It holds the country code, - the last update time, the timestamp of the last entry and the data time series. - - The function first checks if the requested final time stamp is available, if not - it attempts to pull the data from ENTSOE, if the last update time is at least one hour earlier. - """ - print("_get_cache_or_update started") - cache = redis.from_url(Config.get("energy_redis_path")) - if cache.exists(_get_country_key(country)): - print("cache has country") - json_string = cache.get(_get_country_key(country)).decode("utf-8") - data_object = json.loads(json_string) - last_prediction_time = datetime.fromtimestamp(data_object["last_prediction"], tz=timezone.utc) - deadline_time = deadline.astimezone(timezone.utc) # datetime.strptime("202308201230", "%Y%m%d%H%M").replace(tzinfo=timezone.utc) - last_cache_update_time = datetime.fromtimestamp(data_object["last_updated"], tz=timezone.utc) - current_time_plus_one = datetime.now(timezone.utc)+timedelta(hours=-1) - # utc_dt = utc_dt.astimezone(timezone.utc) - # print(data_object) - if data_object["data_available"] and last_prediction_time > deadline_time: - return data_object - else: - # check if the last update has been at least one hour earlier, - if last_cache_update_time < current_time_plus_one: - print("cache must be updated") - return _pull_data(country, start, deadline) - else: - return data_object - else: - print("caches has no country, calling _pull_data(country, start, deadline)") - return _pull_data(country, start, deadline) - - -def _pull_data(country, start, end): - """Fetches the data from ENTSOE and updated the cache""" - print("_pull_data function started") - try: - cache = redis.from_url(Config.get("energy_redis_path")) - forecast_data = energy(country,start,end,"forecast") - # print(forecast_data) - last_update = datetime.now().timestamp() - if forecast_data["data_available"]: - last_prediction = forecast_data["data"].iloc[-1]["posix_timestamp"] - else: - last_prediction = pd.Timestamp(datetime.now(), tz="UTC") - # print(last_prediction) - # forecast_data["data"]["startTimeUTC"] = forecast_data["data"]['startTimeUTC'].dt.strftime('%Y%m%d%H%M').astype("str") - df = forecast_data["data"] - df['startTimeUTC'] = pd.to_datetime(df['startTimeUTC']) - df['startTimeUTC'] = df['startTimeUTC'].dt.strftime('%Y%m%d%H%M').astype("str") - cached_object = { - "data": df.to_dict(), - "time_interval": forecast_data["time_interval"], - "data_available": forecast_data["data_available"], - "last_updated": int(last_update), - "last_prediction": int(last_prediction), - } - cache.set(_get_country_key(country), json.dumps(cached_object)) - # print( - # "caching object with updated last_update key , result is %s", - # str(cached_object), - # ) - return cached_object - - except Exception as e: - print(traceback.format_exc()) - print(e) - return None - - # ========= the main methods ============ def _get_energy_data(country,start,end): """ Get energy data and check if it must be cached based on the options set + + Check the country data file if models exists """ + energy_mode = Config.get("default_energy_mode") + if Config.get("enable_energy_caching")==True: + # check prediction is enabled : get cache or update prediction try : - forecast = _get_cache_or_update(country, start, end) + # what if this fails ? + forecast = get_cache_or_update(country, start, end,energy_mode) forecast_data = pd.DataFrame(forecast["data"]) return forecast_data except Exception as e : print(traceback.format_exc()) else: - forecast = energy(country,start,end,"forecast") + if energy_mode =="local_prediction": + if check_prediction_model_exists(country): + forecast = predicted_energy(country) + else: + # prediction models do not exists , fallback to energy forecasts from public_data + forecast = energy(country,start,end,"forecast") + elif energy_mode == "public_data": + forecast = energy(country,start,end,"forecast") + else : + return None return forecast["data"] def predict_now( - country: str, - estimated_runtime_hours: int, - estimated_runtime_minutes:int, - hard_finish_date:datetime, - criteria:str = "percent_renewable", - percent_renewable: int = 50)->tuple: + country: str, + estimated_runtime_hours: int, + estimated_runtime_minutes:int, + hard_finish_date:datetime, + criteria:str = "percent_renewable", + percent_renewable: int = 50)->tuple: """ Predicts optimal computation time in the given location starting now @@ -149,42 +88,6 @@ def predict_now( except Exception as e: print(traceback.format_exc()) return _default_response(Message.ENERGY_DATA_FETCHING_ERROR) - if criteria == "optimal_percent_renewable": - try: - start_time = datetime.now() - # print(start_time,hard_finish_date) - energy_data = _get_energy_data(country,start_time,hard_finish_date) - if energy_data is not None : - print(energy_data) - col = energy_data['percent_renewable'] - pers = [] - pers.append(col.mean()) - pers.append(col.max()) - pers.append(col.nlargest(2).iloc[-1]) - pers.append(col.nlargest(3).iloc[-1]) - pers.append(col.nlargest(4).iloc[-1]) - print(pers) - results = [] - for p in pers : - q = predict_optimal_time( - energy_data, - estimated_runtime_hours, - estimated_runtime_minutes, - p, - hard_finish_date - ) - results.append(q) - print(results) - max_index, max_tuple = max(enumerate(results), key=lambda x: x[1][0]) - print(max_index) - print(max_tuple) - optimal = max_tuple + (round(pers[max_index],2),) - return optimal - else: - return _default_response(Message.ENERGY_DATA_FETCHING_ERROR) - except Exception as e: - print(traceback.format_exc()) - return _default_response(Message.ENERGY_DATA_FETCHING_ERROR) else: return _default_response(Message.INVALID_PREDICTION_CRITERIA) diff --git a/codegreen_core/utilities/caching.py b/codegreen_core/utilities/caching.py new file mode 100644 index 0000000..20ae36e --- /dev/null +++ b/codegreen_core/utilities/caching.py @@ -0,0 +1,86 @@ +from datetime import datetime, timedelta, timezone +from dateutil import tz +import pandas as pd +from ..data import energy +from ..models.predict import predicted_energy +from .config import Config +from .metadata import check_prediction_model_exists +import redis +import json +import traceback +import warnings + +def _get_country_key(country_code,energy_mode="pubic_data"): + return "codegreen_optimal_"+energy_mode+"_"+country_code + +def get_cache_or_update(country, start, deadline,energy_mode="public_data"): + """ + The cache contains an entry for every country. It holds the country code, + the last update time, the timestamp of the last entry and the data time series. + + The function first checks if the requested final time stamp is available, if not + it attempts to pull the data from ENTSOE, if the last update time is at least one hour earlier. + """ + cache = redis.from_url(Config.get("energy_redis_path")) + if cache.exists(_get_country_key(country,energy_mode)): + print("cache has country") + json_string = cache.get(_get_country_key(country,energy_mode)).decode("utf-8") + data_object = json.loads(json_string) + last_prediction_time = datetime.fromtimestamp(data_object["last_prediction"], tz=timezone.utc) + deadline_time = deadline.astimezone(timezone.utc) # datetime.strptime("202308201230", "%Y%m%d%H%M").replace(tzinfo=timezone.utc) + last_cache_update_time = datetime.fromtimestamp(data_object["last_updated"], tz=timezone.utc) + current_time_plus_one = datetime.now(timezone.utc)+timedelta(hours=-1) + # utc_dt = utc_dt.astimezone(timezone.utc) + # print(data_object) + if data_object["data_available"] and last_prediction_time > deadline_time: + return data_object + else: + # check if the last update has been at least one hour earlier, + if last_cache_update_time < current_time_plus_one: + print("cache must be updated") + return _pull_data(country, start, deadline,energy_mode) + else: + return data_object + else: + print("caches has no country, calling _pull_data(country, start, deadline)") + return _pull_data(country, start, deadline,energy_mode) + + +def _pull_data(country, start, end,energy_mode="public_data"): + """Fetches the data and updates the cache""" + print("_pull_data function started") + try: + cache = redis.from_url(Config.get("energy_redis_path")) + if energy_mode == "public_data": + forecast_data = energy(country,start,end,"forecast") + elif energy_mode == "local_prediction": + if check_prediction_model_exists(country): + forecast_data = predicted_energy(country) + else: + warnings.warn("Predication model for "+country+" do not exist in the system.") + return None + else : + return None + last_update = datetime.now().timestamp() + if forecast_data["data_available"]: + last_prediction = forecast_data["data"].iloc[-1]["posix_timestamp"] + else: + last_prediction = pd.Timestamp(datetime.now(), tz="UTC") + + df = forecast_data["data"] + df['startTimeUTC'] = pd.to_datetime(df['startTimeUTC']) + df['startTimeUTC'] = df['startTimeUTC'].dt.strftime('%Y%m%d%H%M').astype("str") + cached_object = { + "data": df.to_dict(), + "time_interval": forecast_data["time_interval"], + "data_available": forecast_data["data_available"], + "last_updated": int(last_update), + "last_prediction": int(last_prediction), + } + cache.set(_get_country_key(country,energy_mode), json.dumps(cached_object)) + return cached_object + + except Exception as e: + print(traceback.format_exc()) + print(e) + return None diff --git a/codegreen_core/utilities/config.py b/codegreen_core/utilities/config.py index a5189e8..6ffb881 100644 --- a/codegreen_core/utilities/config.py +++ b/codegreen_core/utilities/config.py @@ -8,7 +8,8 @@ class ConfigError(Exception): class Config: config_data = None section_name="codegreen" - boolean_keys = {"enable_energy_caching","enable_prediction_models","enable_time_prediction_logging"} + boolean_keys = {"enable_energy_caching","enable_time_prediction_logging"} + defaults = {"default_energy_mode":"public_data","enable_energy_caching":False} @classmethod def load_config(self,file_path=None): """ to load configurations from the user config file @@ -35,7 +36,6 @@ def load_config(self,file_path=None): else: r = redis.from_url(self.get("energy_redis_path")) r.ping() - # print("Redis pinged") @classmethod def get(self,key): @@ -43,8 +43,13 @@ def get(self,key): raise ConfigError("Configuration not loaded. Please call 'load_config' first.") try: value = self.config_data.get(self.section_name,key) - if key in self.boolean_keys: - value = value.lower() == "true" + if value is None: + #if key not in self.defaults: + # raise KeyError(f"No default value provided for key: {key}") + value = self.defaults.get(key,None) + else: + if key in self.boolean_keys: + value = value.lower() == "true" return value except (configparser.NoSectionError, configparser.NoOptionError): return None diff --git a/codegreen_core/utilities/metadata.py b/codegreen_core/utilities/metadata.py index fec2fcc..13e011e 100644 --- a/codegreen_core/utilities/metadata.py +++ b/codegreen_core/utilities/metadata.py @@ -49,6 +49,8 @@ def get_prediction_model_details(country,version=None): metadata = get_country_metadata() if country in metadata.keys(): if version is None : + if len(metadata[country]["models"])==0: + raise("No models exists") return metadata[country]["models"][len(metadata[country]["models"])-1] else: filter = next([d for d in metadata[country]["models"]],None) @@ -56,4 +58,13 @@ def get_prediction_model_details(country,version=None): raise "Version does not exists" return filter else: - raise "No models exists for this country" \ No newline at end of file + raise "Country not defined" + + +def check_prediction_model_exists(country): + """Checks if predication models exists for the give country""" + try: + m = get_prediction_model_details(country) + return m is not None + except Exception as e: + return False \ No newline at end of file From c514b16fdde57d5443a4c7599b1eeb7a05f2705a Mon Sep 17 00:00:00 2001 From: shubh Date: Wed, 23 Oct 2024 10:37:30 +0200 Subject: [PATCH 02/10] fixed energy format and doc strings --- codegreen_core/data/entsoe.py | 109 +++++++++++++---------- codegreen_core/data/main.py | 10 ++- codegreen_core/tools/carbon_intensity.py | 3 +- tests/get_data.py | 4 +- tests/test1_predictions.py | 2 +- tests/test_data.py | 3 +- 6 files changed, 76 insertions(+), 55 deletions(-) diff --git a/codegreen_core/data/entsoe.py b/codegreen_core/data/entsoe.py index 3711c74..2004dc8 100644 --- a/codegreen_core/data/entsoe.py +++ b/codegreen_core/data/entsoe.py @@ -197,7 +197,7 @@ def _convert_date_to_entsoe_format(dt:datetime): # the main methods -def get_actual_production_percentage(country, start, end, interval60=False) -> pd.DataFrame: +def get_actual_production_percentage(country, start, end, interval60=False) -> dict: """Returns time series data containing the percentage of energy generated from various sources for the specified country within the selected time period. It also includes the percentage of energy from renewable and non renewable sources. The data is fetched from the APIs is subsequently refined. To obtain data in 60-minute intervals (if not already available), set 'interval60' to True @@ -206,65 +206,80 @@ def get_actual_production_percentage(country, start, end, interval60=False) -> p :param datetime start: The start date for data retrieval. A Datetime object. Note that this date will be rounded to the nearest hour. :param datetime end: The end date for data retrieval. A datetime object. This date is also rounded to the nearest hour. :return: A DataFrame containing the hourly energy production mix and percentage of energy generated from renewable and non renewable sources. - :rtype: pd.DataFrame + :return: A dictionary containing: + - `error`: A string with an error message, empty if no errors. + - `data_available`: A boolean indicating if data was successfully retrieved. + - `data`: A pandas DataFrame containing the energy data if available, empty DataFrame if not. + - `time_interval` : the time interval of the DataFrame + :rtype: dict """ - options = {"country": country, "start": start,"end": end, "interval60": interval60} - # get actual generation data per production type and convert it into 60 min interval if required - totalRaw = _entsoe_get_actual_generation(options) - total = totalRaw["data"] - duration = totalRaw["duration"] - if options["interval60"] == True and totalRaw["duration"] != 60.0: - table = _convert_to_60min_interval(totalRaw) - duration = 60 - else: - table = total - # finding the percent renewable - allCols = table.columns.tolist() - # find out which columns are present in the data out of all the possible columns in both the categories - renPresent = list(set(allCols).intersection(renewableSources)) - renPresentWS = list(set(allCols).intersection(windSolarOnly)) - nonRenPresent = list(set(allCols).intersection(nonRenewableSources)) - # find total renewable, total non renewable and total energy values - table["renewableTotal"] = table[renPresent].sum(axis=1) - table["renewableTotalWS"] = table[renPresentWS].sum(axis=1) - table["nonRenewableTotal"] = table[nonRenPresent].sum(axis=1) - table["total"] = table["nonRenewableTotal"] + table["renewableTotal"] - # calculate percent renewable - table["percentRenewable"] = (table["renewableTotal"] / table["total"]) * 100 - # refine percentage values : replacing missing values with 0 and converting to integer - table['percentRenewable'] = table['percentRenewable'].fillna(0) - table["percentRenewable"] = table["percentRenewable"].round().astype(int) - table["percentRenewableWS"] = (table["renewableTotalWS"] / table["total"]) * 100 - table['percentRenewableWS']= table['percentRenewableWS'].fillna(0) - table["percentRenewableWS"] = table["percentRenewableWS"].round().astype(int) + try : + options = {"country": country, "start": start,"end": end, "interval60": interval60} + # get actual generation data per production type and convert it into 60 min interval if required + totalRaw = _entsoe_get_actual_generation(options) + total = totalRaw["data"] + duration = totalRaw["duration"] + if options["interval60"] == True and totalRaw["duration"] != 60.0: + table = _convert_to_60min_interval(totalRaw) + duration = 60 + else: + table = total + # finding the percent renewable + allCols = table.columns.tolist() + # find out which columns are present in the data out of all the possible columns in both the categories + renPresent = list(set(allCols).intersection(renewableSources)) + renPresentWS = list(set(allCols).intersection(windSolarOnly)) + nonRenPresent = list(set(allCols).intersection(nonRenewableSources)) + # find total renewable, total non renewable and total energy values + table["renewableTotal"] = table[renPresent].sum(axis=1) + table["renewableTotalWS"] = table[renPresentWS].sum(axis=1) + table["nonRenewableTotal"] = table[nonRenPresent].sum(axis=1) + table["total"] = table["nonRenewableTotal"] + table["renewableTotal"] + # calculate percent renewable + table["percentRenewable"] = (table["renewableTotal"] / table["total"]) * 100 + # refine percentage values : replacing missing values with 0 and converting to integer + table['percentRenewable'] = table['percentRenewable'].fillna(0) + table["percentRenewable"] = table["percentRenewable"].round().astype(int) + table["percentRenewableWS"] = (table["renewableTotalWS"] / table["total"]) * 100 + table['percentRenewableWS']= table['percentRenewableWS'].fillna(0) + table["percentRenewableWS"] = table["percentRenewableWS"].round().astype(int) - # individual energy source percentage calculation - allAddkeys = ["Wind","Solar","Nuclear","Hydroelectricity","Geothermal","Natural Gas","Petroleum","Coal","Biomass"] - for ky in allAddkeys: - keys_available = list(set(allCols).intersection(energy_type[ky])) - #print(keys_available) - fieldName = ky+"_per" - # print(fieldName) - table[fieldName] = table[keys_available].sum(axis=1) - table[fieldName] = (table[fieldName]/table["total"])*100 - table[fieldName] = table[fieldName].fillna(0) - table[fieldName] = table[fieldName].astype(int) - - return table + # individual energy source percentage calculation + allAddkeys = ["Wind","Solar","Nuclear","Hydroelectricity","Geothermal","Natural Gas","Petroleum","Coal","Biomass"] + for ky in allAddkeys: + keys_available = list(set(allCols).intersection(energy_type[ky])) + #print(keys_available) + fieldName = ky+"_per" + # print(fieldName) + table[fieldName] = table[keys_available].sum(axis=1) + table[fieldName] = (table[fieldName]/table["total"])*100 + table[fieldName] = table[fieldName].fillna(0) + table[fieldName] = table[fieldName].astype(int) + + return {"data":table,"data_available":True,"time_interval": totalRaw["duration"]} + except Exception as e: + print(e) + print(traceback.format_exc()) + return {"data": None,"data_available":False,"error":Exception,"time_interval": totalRaw["duration"]} -def get_forecast_percent_renewable(country:str, start:datetime, end:datetime) -> pd.DataFrame: +def get_forecast_percent_renewable(country:str, start:datetime, end:datetime) -> dict: """Returns time series data comprising the forecast of the percentage of energy generated from renewable sources (specifically, wind and solar) for the specified country within the selected time period. - The data source is the ENTSOE APIs and involves combining data from 2 APIs : total forecast, wind and solar forecast. - The time interval is 60 min - - the data frame includes : startTimeUTC, totalRenewable,total,percent_renewable,posix_timestamp + - the data frame includes : `startTimeUTC`, `totalRenewable`,`total`,`percent_renewable`,`posix_timestamp` :param str country: The 2 alphabet country code. :param datetime start: The start date for data retrieval. A Datetime object. Note that this date will be rounded to the nearest hour. :param datetime end: The end date for data retrieval. A datetime object. This date is also rounded to the nearest hour. - :return: A DataFrame containing startTimeUTC, totalRenewable,total,percent_renewable,posix_timestamp. + :return: A dictionary containing: + - `error`: A string with an error message, empty if no errors. + - `data_available`: A boolean indicating if data was successfully retrieved. + - `data`: A DataFrame containing `startTimeUTC`, `totalRenewable`,`total`,`percent_renewable`,`posix_timestamp`. + - `time_interval` : the time interval of the DataFrame + :rtype: dict """ try: # print(country,start,end) diff --git a/codegreen_core/data/main.py b/codegreen_core/data/main.py index cdc9ec4..c059a9b 100644 --- a/codegreen_core/data/main.py +++ b/codegreen_core/data/main.py @@ -5,7 +5,7 @@ from ..utilities import metadata as meta from . import entsoe as et -def energy(country,start_time,end_time,type="generation",interval60=True)-> pd.DataFrame: +def energy(country,start_time,end_time,type="generation",interval60=True)-> dict: """ Returns hourly time series of energy production mix for a specified country and time range. @@ -46,8 +46,12 @@ def energy(country,start_time,end_time,type="generation",interval60=True)-> pd.D :param datetime start_time: The start date for data retrieval. A Datetime object. Note that this date will be rounded to the nearest hour. :param datetime end_time: The end date for data retrieval. A datetime object. This date is also rounded to the nearest hour. :param str type: The type of data to retrieve; either 'historical' or 'forecasted'. Defaults to 'historical'. - :return: A DataFrame containing the hourly energy production mix. - :rtype: pd.DataFrame + :return: A dictionary containing: + - `error`: A string with an error message, empty if no errors. + - `data_available`: A boolean indicating if data was successfully retrieved. + - `data`: A pandas DataFrame containing the energy data if available, empty DataFrame if not. + - `time_interval` : the time interval of the DataFrame + :rtype: dict """ if not isinstance(country, str): raise ValueError("Invalid country") diff --git a/codegreen_core/tools/carbon_intensity.py b/codegreen_core/tools/carbon_intensity.py index 57549f9..0ddfee3 100644 --- a/codegreen_core/tools/carbon_intensity.py +++ b/codegreen_core/tools/carbon_intensity.py @@ -109,7 +109,8 @@ def compute_ci(country:str,start_time:datetime,end_time:datetime)-> pd.DataFrame """ e_source = get_country_energy_source(country) if e_source=="ENTSOE" : - energy_data = energy(country,start_time,end_time) + data = energy(country,start_time,end_time) + energy_data = data["data"] ci_values = compute_ci_from_energy(energy_data) return ci_values else: diff --git a/tests/get_data.py b/tests/get_data.py index 15e53a5..30b5a06 100644 --- a/tests/get_data.py +++ b/tests/get_data.py @@ -22,7 +22,7 @@ def gen_test_case(start,end,label): return cases def fetch_data(case): - data = energy(case["country"],case["start_time"],case["end_time"]) + data = energy(case["country"],case["start_time"],case["end_time"])["data"] data.to_csv("./data/"+case["file"]+".csv") print(case["file"]) @@ -127,5 +127,5 @@ def get_forecast_for_testing(): # get_forecast_for_testing() -data = energy("DE",datetime(2024,9,11),datetime(2024,9,12),"generation",False) +data = energy("DE",datetime(2024,9,11),datetime(2024,9,12),"generation",False)["data"] print(data) \ No newline at end of file diff --git a/tests/test1_predictions.py b/tests/test1_predictions.py index 403f51f..6f0d342 100644 --- a/tests/test1_predictions.py +++ b/tests/test1_predictions.py @@ -3,7 +3,7 @@ from codegreen_core.data import energy from datetime import datetime -e = energy("SE",datetime(2024,1,2),datetime(2024,1,3)) +e = energy("SE",datetime(2024,1,2),datetime(2024,1,3))["data"] # print(e) forecasts = predict.run("SE",e) print(forecasts) diff --git a/tests/test_data.py b/tests/test_data.py index 1cb6f35..7b34ead 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -75,7 +75,8 @@ def test_entsoe_generation_data(self): # intervals = int((case["end"].replace(minute=0, second=0, microsecond=0) - case["start"].replace(minute=0, second=0, microsecond=0)).total_seconds() // 3600) # print(intervals) if case["dtype"]=="generation": - data = energy(case["country"],case["start"],case["end"],case["dtype"],case["interval60"]) + d = energy(case["country"],case["start"],case["end"],case["dtype"],case["interval60"]) + data = d["data"] data_verify = pd.read_csv(case["file"]) data_verify['start_date'] = data_verify['MTU'].str.split(' - ').str[0] data_verify['end_date'] = data_verify['MTU'].str.split(' - ').str[1].str.replace(' (UTC)', '', regex=False) From e44a9bcbe26d8cf5b656e1d42f9360d74f6fc5eb Mon Sep 17 00:00:00 2001 From: shubh Date: Tue, 29 Oct 2024 14:59:20 +0100 Subject: [PATCH 03/10] energy format and test --- codegreen_core/data/main.py | 5 ++++- tests/test_data.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/codegreen_core/data/main.py b/codegreen_core/data/main.py index c059a9b..c67c79a 100644 --- a/codegreen_core/data/main.py +++ b/codegreen_core/data/main.py @@ -45,7 +45,7 @@ def energy(country,start_time,end_time,type="generation",interval60=True)-> dict :param str country: The 2 alphabet country code. :param datetime start_time: The start date for data retrieval. A Datetime object. Note that this date will be rounded to the nearest hour. :param datetime end_time: The end date for data retrieval. A datetime object. This date is also rounded to the nearest hour. - :param str type: The type of data to retrieve; either 'historical' or 'forecasted'. Defaults to 'historical'. + :param str type: The type of data to retrieve; either 'generation' or 'forecast'. Defaults to 'generation'. :return: A dictionary containing: - `error`: A string with an error message, empty if no errors. - `data_available`: A boolean indicating if data was successfully retrieved. @@ -63,6 +63,9 @@ def energy(country,start_time,end_time,type="generation",interval60=True)-> dict raise ValueError(Message.INVALID_ENERGY_TYPE) # check start end_time): + raise ValueError("Invalid time.End time should be greater than start time") + e_source = meta.get_country_energy_source(country) if e_source=="ENTSOE" : if type == "generation": diff --git a/tests/test_data.py b/tests/test_data.py index 7b34ead..ee62f67 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -17,6 +17,10 @@ def test_valid_starttime(self): def test_valid_endtime(self): with pytest.raises(ValueError): energy("DE",datetime(2024,1,2),"2024,1,1") + + def test_valid_time(self): + with pytest.raises(ValueError): + energy("DE",datetime(2024,1,2),datetime(2020,1,1)) def test_valid_type(self): with pytest.raises(ValueError): @@ -99,6 +103,12 @@ def test_entsoe_generation_data(self): assert sum_of_differences == 0.0 # else : # print("") + def check_return_value_actual(self): + actual = energy("DE",datetime(2024,1,1),datetime(2024,1,2)) + assert isinstance(actual,dict) + def check_return_value_actual(self): + forecast = energy("DE",datetime(2024,1,1),datetime(2024,1,2),"forecast") + assert isinstance(forecast,dict) """ todo - test cases where some data is missing and has to be replaced with average From 2778a28d85eddc99c29d0d974dcf27accff7bd23 Mon Sep 17 00:00:00 2001 From: shubh Date: Tue, 29 Oct 2024 15:19:15 +0100 Subject: [PATCH 04/10] tests for ci --- codegreen_core/tools/carbon_intensity.py | 55 +++++++++++++++++++++--- tests/test_carbon_intensity.py | 29 +++++++++++++ 2 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 tests/test_carbon_intensity.py diff --git a/codegreen_core/tools/carbon_intensity.py b/codegreen_core/tools/carbon_intensity.py index 0ddfee3..6abdaac 100644 --- a/codegreen_core/tools/carbon_intensity.py +++ b/codegreen_core/tools/carbon_intensity.py @@ -107,6 +107,15 @@ def compute_ci(country:str,start_time:datetime,end_time:datetime)-> pd.DataFrame The default CI values for all countries are stored in utilities/ci_default_values.csv. """ + if not isinstance(country, str): + raise ValueError("Invalid country") + + if not isinstance(start_time, datetime): + raise ValueError("Invalid start_time") + + if not isinstance(end_time, datetime): + raise ValueError("Invalid end_time") + e_source = get_country_energy_source(country) if e_source=="ENTSOE" : data = energy(country,start_time,end_time) @@ -121,20 +130,52 @@ def compute_ci(country:str,start_time:datetime,end_time:datetime)-> pd.DataFrame def compute_ci_from_energy(energy_data:pd.DataFrame,default_method="ci_ipcc_lifecycle_mean",base_values:dict=None)-> pd.DataFrame: """ - Given the energy time series, computes the Carbon intensity for each row. - You can choose the base value from several sources available or use your own base values - - :param energy_data: The data frame must include the following columns : `Coal_per, Petroleum_per, Biomass_per, Natural Gas_per, Geothermal_per, Hydroelectricity_per, Nuclear_per, Solar_per, Wind_per` - :param default_method: This option is to choose the base value of each energy source. By default, IPCC_lifecycle_mean values are used. List of all options: + Given the energy time series, computes the carbon intensity for each row. + You can choose the base value from several sources available or use your own base values. + + :param energy_data: A pandas DataFrame that must include the following columns, representing + the percentage of energy generated from each source: + + - `Coal_per` (float): Percentage of energy generated from coal. + - `Petroleum_per` (float): Percentage of energy generated from petroleum. + - `Biomass_per` (float): Percentage of energy generated from biomass. + - `Natural Gas_per` (float): Percentage of energy generated from natural gas. + - `Geothermal_per` (float): Percentage of energy generated from geothermal sources. + - `Hydroelectricity_per` (float): Percentage of energy generated from hydroelectric sources. + - `Nuclear_per` (float): Percentage of energy generated from nuclear sources. + - `Solar_per` (float): Percentage of energy generated from solar sources. + - `Wind_per` (float): Percentage of energy generated from wind sources. + + :param default_method: This parameter allows you to choose the base values for each energy source. + By default, the IPCC lifecycle mean values are used. Available options include: - `codecarbon` (Ref [6]) - `ipcc_lifecycle_min` (Ref [5]) - `ipcc_lifecycle_mean` (default) - `ipcc_lifecycle_max` - `eu_comm` (Ref [4]) - :param base_values: Custom base Carbon Intensity values of energy sources. Must include following keys : `Coal, Petroleum, Biomass, Natural Gas, Geothermal, Hydroelectricity, Nuclear, Solar, Wind` - + + :param base_values(optional): A dictionary of custom base carbon intensity values for energy sources. + Must include the following keys: + + - `Coal` (float): Base carbon intensity value for coal. + - `Petroleum` (float): Base carbon intensity value for petroleum. + - `Biomass` (float): Base carbon intensity value for biomass. + - `Natural Gas` (float): Base carbon intensity value for natural gas. + - `Geothermal` (float): Base carbon intensity value for geothermal energy. + - `Hydroelectricity` (float): Base carbon intensity value for hydroelectricity. + - `Nuclear` (float): Base carbon intensity value for nuclear energy. + - `Solar` (float): Base carbon intensity value for solar energy. + - `Wind` (float): Base carbon intensity value for wind energy. """ + + if not isinstance(energy_data, pd.DataFrame): + raise ValueError("Invalid energy data.") + + if not isinstance(default_method, str): + raise ValueError("Invalid default_method") + + if base_values: energy_data['ci_default'] = energy_data.apply(lambda row: _calculate_weighted_sum(row.to_dict(),base_values), axis=1) return energy_data diff --git a/tests/test_carbon_intensity.py b/tests/test_carbon_intensity.py new file mode 100644 index 0000000..0fa0ae0 --- /dev/null +++ b/tests/test_carbon_intensity.py @@ -0,0 +1,29 @@ +import pytest +from datetime import datetime +import codegreen_core.tools.carbon_intensity as ci + +class TestCarbonIntensity: + def test_if_incorrect_data_provided1(self): + with pytest.raises(ValueError): + ci.compute_ci("DE",datetime(2024,1,2),"2024,1,1") + + def test_if_incorrect_data_provided2(self): + with pytest.raises(ValueError): + ci.compute_ci("DE",123,datetime(2024,1,2)) + + def test_if_incorrect_data_provided3(self): + with pytest.raises(ValueError): + ci.compute_ci(123,datetime(2024,1,2),datetime(2024,1,3)) + + def test_if_incorrect_data_provided4(self): + with pytest.raises(ValueError): + ci.compute_ci_from_energy("DE",datetime(2024,1,2),"2024,1,1") + + def test_if_incorrect_data_provided5(self): + with pytest.raises(ValueError): + ci.compute_ci_from_energy("DE",123,datetime(2024,1,2)) + + def test_if_incorrect_data_provided6(self): + with pytest.raises(ValueError): + ci.compute_ci_from_energy(123,datetime(2024,1,2),datetime(2024,1,3)) + From ff484ebd555f5f88389eedc887456e99d176fcfa Mon Sep 17 00:00:00 2001 From: shubh Date: Tue, 29 Oct 2024 15:57:06 +0100 Subject: [PATCH 05/10] carbon emission plots --- codegreen_core/tools/carbon_emission.py | 239 ++++++++--- docs/plot.py | 5 +- docs/plots.ipynb | 502 +++++++++++++++++++++++- docs/tools.rst | 2 +- pyproject.toml | 3 +- setup.py | 2 +- tests/test_carbon_emissions.py | 0 tests/test_loadshift_location.py | 76 ++-- 8 files changed, 730 insertions(+), 99 deletions(-) create mode 100644 tests/test_carbon_emissions.py diff --git a/codegreen_core/tools/carbon_emission.py b/codegreen_core/tools/carbon_emission.py index 435cb4d..3537809 100644 --- a/codegreen_core/tools/carbon_emission.py +++ b/codegreen_core/tools/carbon_emission.py @@ -1,44 +1,53 @@ import pandas as pd import numpy as np +import matplotlib.pyplot as plt +import matplotlib.dates as mdates from datetime import datetime, timedelta from .carbon_intensity import compute_ci def compute_ce( - country: str, + server:dict, start_time:datetime, runtime_minutes: int, - number_core: int, - memory_gb: int, - power_draw_core:float=15.8, - usage_factor_core:int=1, - power_draw_mem:float=0.3725, - power_usage_efficiency:float=1.6 -): +)->tuple[float,pd.DataFrame]: """ - Calculates the carbon footprint of a job, given its hardware config, time and location of the job. - This method returns an hourly time series of the carbon emission. - The methodology is defined in the documentation - - :param country: The country code where the job was performed (required to fetch energy data) - :param start_time: The starting time of the computation as datetime object in local time zone - :param runtime_minutes: running time in minutes - :param number_core: the number of core - :param memory_gb: the size of memory available (in Gigabytes) - :param power_draw_core: power draw of a computing core (Watt) - :param usage_factor_core: the core usage factor (between 0 and 1) - :param power_draw_mem: power draw of memory (Watt) - :param power_usage_efficiency: efficiency coefficient of the data center + Calculates the carbon footprint of a job, given its hardware configuration, time, and location. + This method returns an hourly time series of the carbon emissions. + + The methodology is defined in the documentation. + + :param server: A dictionary containing the details about the server, including its hardware specifications. + The dictionary should include the following keys: + + - `country` (str): The country code where the job was performed (required to fetch energy data). + - `number_core` (int): The number of CPU cores. + - `memory_gb` (float): The size of memory available in Gigabytes. + - `power_draw_core` (float): Power draw of a computing core in Watts. + - `usage_factor_core` (float): The core usage factor, a value between 0 and 1. + - `power_draw_mem` (float): Power draw of memory in Watts. + - `power_usage_efficiency` (float): Efficiency coefficient of the data center. + + :param start_time: The start time of the job (datetime). + :param runtime_minutes: Total running time of the job in minutes (int). + + :return: A tuple containing: + - (float): The total carbon footprint of the job in kilograms of CO2 equivalent. + - (pandas.DataFrame): A DataFrame containing the hourly time series of carbon emissions. """ + # Round to the nearest hour (in minutes) # base valued taken from http://calculator.green-algorithms.org/ + + + rounded_runtime_minutes = round(runtime_minutes / 60) * 60 end_time = start_time + timedelta(minutes=rounded_runtime_minutes) - ci_ts = compute_ci(country, start_time, end_time) - ce_total,ce_df = compute_ce_from_energy(ci_ts, number_core,memory_gb,power_draw_core,usage_factor_core,power_draw_mem,power_usage_efficiency) + ci_ts = compute_ci(server['country'], start_time, end_time) + ce_total,ce_df = compute_ce_from_energy(server,ci_ts) return ce_total,ce_df -def compute_energy_used(runtime_minutes, number_core, power_draw_core, usage_factor_core, mem_size_gb, power_draw_mem, PUE): +def _compute_energy_used(runtime_minutes, number_core, power_draw_core, usage_factor_core, mem_size_gb, power_draw_mem, PUE): return round((runtime_minutes/60)*(number_core * power_draw_core * usage_factor_core + mem_size_gb * power_draw_mem) * PUE * 0.001, 2) def compute_savings_same_device(country_code,start_time_request,start_time_predicted,runtime,cpu_cores,cpu_memory): @@ -46,34 +55,170 @@ def compute_savings_same_device(country_code,start_time_request,start_time_predi ce_job2,ci2 = compute_ce(country_code,start_time_predicted,runtime,cpu_cores,cpu_memory) return ce_job1-ce_job2 # ideally this should be positive todo what if this is negative?, make a note in the comments +def compare_carbon_emissions(server1,server2,start_time1,start_time2,runtime_minutes): + """ + Compares the carbon emissions of running a job with the same duration on two different servers. + + :param server1: A dictionary containing the details of the first server's hardware and location specifications. + Required keys include: + + - `country` (str): The country code for the server's location (used for energy data). + - `number_core` (int): The number of CPU cores. + - `memory_gb` (float): The memory available in Gigabytes. + - `power_draw_core` (float): Power draw of each computing core in Watts. + - `usage_factor_core` (float): The core usage factor, a value between 0 and 1. + - `power_draw_mem` (float): Power draw of memory in Watts. + - `power_usage_efficiency` (float): Efficiency coefficient of the data center. + + :param server2: A dictionary containing the details of the second server's hardware and location specifications. + Required keys are identical to those in `server1`: + + - `country` (str): The country code for the server's location. + - `number_core` (int): The number of CPU cores. + - `memory_gb` (float): The memory available in Gigabytes. + - `power_draw_core` (float): Power draw of each computing core in Watts. + - `usage_factor_core` (float): The core usage factor, a value between 0 and 1. + - `power_draw_mem` (float): Power draw of memory in Watts. + - `power_usage_efficiency` (float): Efficiency coefficient of the data center. + + :param start_time1: The start time of the job on `server1` (datetime). + :param start_time2: The start time of the job on `server2` (datetime). + :param runtime_minutes: The total running time of the job in minutes (int). + + :return: A dictionary with the carbon emissions for each server and the percentage difference, structured as follows: + - `emissions_server1` (float): Total carbon emissions for `server1` in kilograms of CO2 equivalent. + - `emissions_server2` (float): Total carbon emissions for `server2` in kilograms of CO2 equivalent. + - `absolute_difference` (float): The absolute difference in emissions between the two servers. + - `higher_emission_server` (str): Indicates which server has higher emissions ("server1" or "server2"). + """ + ce1,ce1_ts =compute_ce(server1,start_time1,runtime_minutes) + ce2,ce2_ts = compute_ce(server2,start_time2,runtime_minutes) + abs_difference = ce2-ce1 + if ce1 > ce2: + higher_emission_server = "server1" + elif ce2 > ce1: + higher_emission_server = "server2" + else: + higher_emission_server = "equal" + + return ce1,ce2,abs_difference,higher_emission_server def compute_ce_from_energy( - ci_data:pd.DataFrame, - number_core: int, - memory_gb: int, - power_draw_core:float=15.8, - usage_factor_core:int=1, - power_draw_mem:float=0.3725, - power_usage_efficiency:float=1.6): + server, + ci_data:pd.DataFrame + ): """ - Calculates the carbon footprint for energy consumption time series - This method returns an hourly time series of the carbon emission. - The methodology is defined in the documentation - - :param ci_data: DataFrame of energy consumption. Required cols : startTimeUTC, ci_default - :param number_core: the number of core - :param memory_gb: the size of memory available (in Gigabytes) - :param power_draw_core: power draw of a computing core (Watt) - :param usage_factor_core: the core usage factor (between 0 and 1) - :param power_draw_mem: power draw of memory (Watt) - :param power_usage_efficiency: efficiency coefficient of the data center + Calculates the carbon footprint for energy consumption over a time series. + This method returns an hourly time series of the carbon emissions. + + The methodology is defined in the documentation. Note that the start and end + times for the computation are derived from the first and last rows of the + `ci_data` DataFrame. + + :param server: A dictionary containing details about the server, including its hardware specifications. + The dictionary should include: + + - `number_core` (int): The number of CPU cores. + - `memory_gb` (float): The size of memory available in Gigabytes. + - `power_draw_core` (float): Power draw of a computing core in Watts. + - `usage_factor_core` (float): The core usage factor, a value between 0 and 1. + - `power_draw_mem` (float): Power draw of memory in Watts. + - `power_usage_efficiency` (float): Efficiency coefficient of the data center. + + :param ci_data: A pandas DataFrame of energy consumption over time. + The DataFrame should include the following columns: + + - `startTimeUTC` (datetime): The start time of each energy measurement in UTC. + - `ci_default` (float): Carbon intensity values for the energy consumption. + + :return: A tuple containing: + - (float): The total carbon footprint of the job in kilograms of CO2 equivalent. + - (pandas.DataFrame): A DataFrame containing the hourly time series of carbon emissions. """ - time_diff = ci_data['startTimeUTC'].iloc[-1] - ci_data['startTimeUTC'].iloc[0] + date_format = "%Y%m%d%H%M" # Year, Month, Day, Hour, Minute + + server_defaults = { + "power_draw_core":15.8, + "usage_factor_core": 1, + "power_draw_mem": 0.3725, + "power_usage_efficiency" : 1.6 + } + server = server_defaults | server # set defaults if not provided + + + # to make sure startTimeUTC is in date format + if not pd.api.types.is_datetime64_any_dtype(ci_data['startTimeUTC']): + ci_data['startTimeUTC'] = pd.to_datetime(ci_data['startTimeUTC']) + + end = ci_data['startTimeUTC'].iloc[-1] + start = ci_data['startTimeUTC'].iloc[0] + + # note that the run time is calculated based on the energy data frame provided + time_diff = end-start runtime_minutes = time_diff.total_seconds() / 60 - energy_consumed = compute_energy_used(runtime_minutes, number_core, power_draw_core, - usage_factor_core, memory_gb, power_draw_mem, power_usage_efficiency) - e_hour = energy_consumed/(runtime_minutes*60) + + energy_consumed = _compute_energy_used(runtime_minutes, server["number_core"], server["power_draw_core"], + server["usage_factor_core"], server["memory_gb"], server["power_draw_mem"], server["power_usage_efficiency"]) + + e_hour = energy_consumed/(runtime_minutes*60) # assuming equal energy usage throughout the computation ci_data["carbon_emission"] = ci_data["ci_default"] * e_hour ce = round(sum(ci_data["carbon_emission"]),4) # grams CO2 equivalent - return ce,ci_data \ No newline at end of file + return ce,ci_data + + +def _compute_ce_bulk(server,jobs): + for job in jobs : + job.end_time= job["start_time"] + timedelta(minutes=job["runtime_minutes"]) + + min_start_date = min(job['start_time'] for job in jobs) + max_end_date = max(job['end_time'] for job in jobs) + # print(min_start_date) + # print(max_end_date) + energy_data = compute_ci(server["country"],min_start_date,max_end_date) + energy_data['startTimeUTC'] = pd.to_datetime(energy_data['startTimeUTC']) + for job in jobs : + filtered_energy = energy_data[(energy_data['startTimeUTC'] >= job["start_time"]) & (energy_data['startTimeUTC'] <= job["end_time"])] + job["emissions"],temp = compute_ce_from_energy(filtered_energy,server["number_core"],server["memory_gb"],server["power_draw_core"],server["usage_factor_core"],server["power_draw_mem"],server["power_usage_efficiency"]) + return energy_data,jobs, min_start_date, max_end_date + +def plot_ce_jobs(server,jobs): + energy_data,jobs, min_start_date, max_end_date = _compute_ce_bulk(server,jobs) + Color = { + "red":"#D6A99A", + "green":"#99D19C", + "blue":"#3DA5D9", + "yellow":"#E2C044", + "black":"#0F1A20" + } + fig, ax1 = plt.subplots(figsize=(10, 6)) + plt.title("Green Energy and Jobs") + end = energy_data['startTimeUTC'].iloc[-1] + start = energy_data['startTimeUTC'].iloc[0] + ax1.plot(energy_data['startTimeUTC'], energy_data['percentRenewable'], color=Color['green'], label='Percentage of Renewable Energy') + ax1.set_xlabel('Time') + ax1.set_ylabel('% Renewable energy') + ax1.tick_params(axis='y') + + # Set x-axis to show dates properly + ax1.xaxis.set_major_formatter(mdates.DateFormatter('%d-%m %H:%M')) + plt.xticks(rotation=45) + + # # Create a second y-axis + ax2 = ax1.twinx() + + # Define y-values for each job (e.g., 1 for Job A, 2 for Job B, etc.) + for idx, job in enumerate(jobs): + lbl = str(job["emissions"]) + ax2.plot([job['start_time'], job['end_time']], [idx+1 , idx+1], marker='o', linewidth=25,label=lbl,color=Color["blue"]) + # Calculate the midpoint for the text placement + labelpoint = job['start_time'] + (job['end_time'] - job['start_time']) / 2 # + timedelta(minutes=100) + ax2.text(labelpoint, idx+1, lbl, color='black', ha='center', va='center', fontsize=12) + + # Adjust y-axis labels to match the number of jobs + ax2.set_yticks(range(1, len(jobs) + 1)) + + # Add legend and show the plot + fig.tight_layout() + # plt.legend(loc='lower right') + plt.show() \ No newline at end of file diff --git a/docs/plot.py b/docs/plot.py index 894cd83..2f1c542 100644 --- a/docs/plot.py +++ b/docs/plot.py @@ -130,12 +130,13 @@ def plot_multiple_percentage_clean(dfs, labels,save_fig_path=None): def show_clean_energy(country,start,end,save_fig_path=None): """note that these plots are based on actual energy production and not the forecasts""" - actual1 = energy(country,start,end) + d = energy(country,start,end) + actual1 = d["data"] plot_percentage_clean(actual1,country,save_fig_path) def show_clean_energy_multiple(countries,start,end,save_fig_path=None): data = [] for c in countries : - data.append(energy(c,start,end)) + data.append(energy(c,start,end)["data"]) plot_multiple_percentage_clean(data,countries,save_fig_path) diff --git a/docs/plots.ipynb b/docs/plots.ipynb index 08f6bdb..5a1c265 100644 --- a/docs/plots.ipynb +++ b/docs/plots.ipynb @@ -1136,19 +1136,20 @@ }, { "cell_type": "code", - "execution_count": 46, + "execution_count": 6, "id": "b8fd01d4-dcbb-4577-860c-19539a0dc8a2", "metadata": {}, "outputs": [ { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 46, - "metadata": {}, - "output_type": "execute_result" + "ename": "NameError", + "evalue": "name 'il' is not defined", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mNameError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[6], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[43mil\u001b[49m\u001b[38;5;241m.\u001b[39mreload(lt)\n", + "\u001b[0;31mNameError\u001b[0m: name 'il' is not defined" + ] } ], "source": [ @@ -1257,6 +1258,489 @@ "ce1 = s1.jobs[\"j1\"].get_ce()\n", "print(ce1)" ] + }, + { + "cell_type": "code", + "execution_count": 62, + "id": "0c98230a-5415-4ccc-9818-8451ef2f8501", + "metadata": {}, + "outputs": [], + "source": [ + "import importlib as il\n", + "from datetime import datetime,timedelta \n", + "import codegreen_core.tools.carbon_emission as ce \n", + "import matplotlib.pyplot as plt\n", + "import matplotlib.dates as mdates" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dea4bde0-69be-47fa-976c-a4666c03d894", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1914a2db-d457-48cf-bc79-17b619cdcb8b", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 105, + "id": "98a0a396-9ca2-4b53-83b7-e193c92dab2a", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/svj/projects/codegreen/core/codegreen_core/tools/carbon_emission.py:93: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " return ce,ci_data\n", + "/Users/svj/projects/codegreen/core/codegreen_core/tools/carbon_emission.py:93: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " return ce,ci_data\n", + "/Users/svj/projects/codegreen/core/codegreen_core/tools/carbon_emission.py:93: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " return ce,ci_data\n", + "/Users/svj/projects/codegreen/core/codegreen_core/tools/carbon_emission.py:93: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " return ce,ci_data\n", + "/Users/svj/projects/codegreen/core/codegreen_core/tools/carbon_emission.py:93: SettingWithCopyWarning: \n", + "A value is trying to be set on a copy of a slice from a DataFrame.\n", + "Try using .loc[row_indexer,col_indexer] = value instead\n", + "\n", + "See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy\n", + " return ce,ci_data\n" + ] + } + ], + "source": [ + "il.reload(ce)\n", + "e,j = ce.plot_jobs(\n", + " {\"country\":\"DE\",\"number_core\":32,\"memory_gb\":254,\"power_draw_core\":15.8, \"usage_factor_core\":1, \"power_draw_mem\":0.3725, \"power_usage_efficiency\":1.6},\n", + " [\n", + " {\"start_time\":datetime(2024,10,1),\"runtime_minutes\":400},\n", + " {\"start_time\":datetime(2024,10,2),\"runtime_minutes\":1200},\n", + " {\"start_time\":datetime(2024,10,3),\"runtime_minutes\":2400},\n", + " {\"start_time\":datetime(2024,10,4),\"runtime_minutes\":600},\n", + " {\"start_time\":datetime(2024,10,1,4,30,0),\"runtime_minutes\":600},\n", + " \n", + " ]\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7c003784-7cea-4d82-b4ab-1836287d0287", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8f9f12d4-8c3c-4dc7-8715-6d6392760606", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6786a5d0-7703-4cc8-93d4-55e9a5815b5c", + "metadata": {}, + "outputs": [], + "source": [ + "server1 = {\"country\":\"DE\",\"number_core\":32,\"memory_gb\":254,\"power_draw_core\":15.8, \"usage_factor_core\":1, \"power_draw_mem\":0.3725, \"power_usage_efficiency\":1.6}\n", + "server2 = {\"country\":\"DE\",\"number_core\":128,\"memory_gb\":1024,\"power_draw_core\":15.8, \"usage_factor_core\":1, \"power_draw_mem\":0.3725, \"power_usage_efficiency\":1.6}\n", + "\n", + "job1 = [\n", + " {\"start_time\":datetime(2024,10,1),\"runtime_minutes\":400},\n", + " {\"start_time\":datetime(2024,10,2),\"runtime_minutes\":1200},\n", + " {\"start_time\":datetime(2024,10,3),\"runtime_minutes\":2400},\n", + " {\"start_time\":datetime(2024,10,4),\"runtime_minutes\":600},\n", + " {\"start_time\":datetime(2024,10,1,4,30,0),\"runtime_minutes\":600},\n", + " \n", + " ]\n", + "plot_ce_jobs()\n" + ] + }, + { + "cell_type": "code", + "execution_count": 104, + "id": "2e4784ae-8654-435a-9cfc-2952ecbc2397", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAJOCAYAAACqS2TfAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAADIY0lEQVR4nOzdeXiTZdYG8PvNniZp030BWqBQpCyCgmzKKu6IOuOM4qiI46cj4+A2OriMOIKonwt+4+44uC84iDru7C6g7IKAtGzd9zZtkmbP+/2RJm1pS5vS9E2T+3ddvWaavMtpBJqT5znnCKIoiiAiIiIiIiKiHieTOgAiIiIiIiKiSMWkm4iIiIiIiChEmHQTERERERERhQiTbiIiIiIiIqIQYdJNREREREREFCJMuomIiIiIiIhChEk3ERERERERUYgw6SYiIiIiIiIKESbdRERERERERCHCpJuIiCSzd+9e3HjjjcjOzoZWq4VWq8XQoUNx8803Y8eOHVKH123Tp0+HIAjtfg0cOFDq8CLG/Pnzu/R6Tp8+HSNHjuzWPQYOHIhLLrmkW+cSEREBgELqAIiIKDq9/PLL+POf/4xhw4Zh0aJFGDFiBARBwMGDB/Hee+9h/PjxOHz4MLKzs6UOtVsGDx6Md955p83jarVagmiIiIhIKky6iYio1/3www+49dZbcfHFF+M///kPVCpV4LmZM2di4cKF+PDDD6HVak96ncbGRsTExIQ63G7RarWYOHGi1GEACO/XiYiIKNJxezkREfW6Rx99FHK5HC+//HKrhLulK6+8EhkZGYHv58+fD71ej3379uG8886DwWDArFmzAABOpxNLly7FaaedBrVajeTkZNxwww2oqqpqc90PPvgAkyZNgk6ng16vx/nnn4/du3e3OsZ/r8OHD+Oiiy6CXq/HgAEDcNddd8HhcPTY6/D6669DEARs3LgRf/rTn5CUlITExERcccUVKC0tPaXY23udTCYTbrzxRiQkJECv1+Piiy/G0aNHIQgClixZAgD47rvvIAgC3nvvvTb3f/PNNyEIArZv397hz1RVVYVbb70Vubm50Ov1SElJwcyZM/Hdd9+1Ou748eMQBAFPPvkknn76aQwaNAh6vR6TJk3Cjz/+2O5rNWzYMKjVagwfPhxvvvlmp6/vydjtdixevBiDBg2CSqVCv379sHDhQphMpnaPX7NmDUaPHg2NRoPBgwfj//7v/1o97/V6sXTpUgwbNgxarRZGoxGjR4/Gs88+e0pxEhFR38ekm4iIepXH48HGjRsxbtw4pKenB3Wu0+nEpZdeipkzZ+KTTz7Bww8/DK/Xi7lz5+Kxxx7DvHnz8Pnnn+Oxxx7D2rVrMX36dNhstsD5jz76KK6++mrk5uZi1apVeOutt2A2m3HOOefgwIEDre7lcrlw6aWXYtasWfjkk0+wYMECPPPMM3j88ce7HK/b7W7z5fV62xz3xz/+EUqlEu+++y6eeOIJbNq0CX/4wx9aHRNM7B29TnPmzMG7776Le++9F2vWrMGECRNwwQUXtDr3nHPOwdixY/H888+3ifO5557D+PHjMX78+A5/5traWgDAQw89hM8//xwrV67E4MGDMX36dGzatKnN8c8//zzWrl2LFStW4J133oHVasVFF12E+vr6wDGvv/46brjhBgwfPhyrV6/GAw88gEceeQQbNmzoMI6TEUURl112GZ588klce+21+Pzzz3HnnXfijTfewMyZM9t8sLJnzx7cfvvtuOOOO7BmzRpMnjwZixYtwpNPPhk45oknnsCSJUtw9dVX4/PPP8cHH3yAG2+8scMknoiIoohIRETUi8rLy0UA4lVXXdXmObfbLbpcrsCX1+sNPHf99deLAMR///vfrc557733RADi6tWrWz2+fft2EYD4wgsviKIoioWFhaJCoRBvu+22VseZzWYxLS1N/N3vftfmXqtWrWp17EUXXSQOGzas059x2rRpIoB2v2688cbAcStXrhQBiLfeemur85944gkRgFhWVtbt2E98nT7//HMRgPjiiy+2enz58uUiAPGhhx5qE9fu3bsDj23btk0EIL7xxhud/vwt+f+bzpo1S7z88ssDjx87dkwEII4aNUp0u91t7vPee++JoiiKHo9HzMjIEM8444xWfx6OHz8uKpVKMSsrq9MYpk2bJo4YMSLw/VdffSUCEJ944olWx33wwQciAPGVV14JPJaVlSUKgiDu2bOn1bGzZ88WY2NjRavVKoqiKF5yySXimDFjuvCKEBFRtOFKNxERhY0zzzwTSqUy8PXUU0+1OeY3v/lNq+8/++wzGI1GzJkzp9WK8pgxY5CWlhZYXf3666/hdrtx3XXXtTpOo9Fg2rRpbVZhBUHAnDlzWj02evRoFBQUdOlnyc7Oxvbt29t8Pfjgg22OvfTSS9vcB0DgXsHG3t7rtHnzZgDA7373u1aPX3311W3Ovfrqq5GSktJqtfuf//wnkpOT8fvf/77Tn/2ll17CGWecAY1GA4VCAaVSifXr1+PgwYNtjr344oshl8s7/NkPHTqE0tJSzJs3D4IgBI7LysrC5MmTO42lPf4V8vnz57d6/Morr4ROp8P69etbPT5ixAicfvrprR6bN28eGhoasGvXLgDAWWedhZ9//hm33norvv76azQ0NHQrNiIiijxspEZERL0qKSkJWq223eT13XffRWNjI8rKytokogAQExOD2NjYVo9VVFTAZDJ1WBteXV0dOA5Ah1ujZbLWn0PHxMRAo9G0ekytVsNut3fwk7Wm0Wgwbty4Lh2bmJjY5j4AAlvjuxP7ia9TTU0NFAoFEhISWj2empra5npqtRo333wznnrqKfzv//4vXC4XVq1ahTvvvLPT7utPP/007rrrLtxyyy145JFHkJSUBLlcjgcffLDdpLuzn72mpgYAkJaW1ubctLQ0HD9+/KTxtMf/WiQnJ7d6XBAEpKWlBe7Z8j7t3btlfIsXL4ZOp8Pbb7+Nl156CXK5HFOnTsXjjz/e5T8HREQUmZh0ExFRr5LL5Zg5cya++eYblJWVtarrzs3NBYAOE6mWK51+/uZjX331VbvnGAyGwHEA8J///AdZWVmn8iP0umBjb+91SkxMhNvtRm1tbavEu7y8vN1r/OlPf8Jjjz2Gf//737Db7XC73bjllls6vffbb7+N6dOn48UXX2z1uNls7vTc9viT8vbi7Cj2rlzT7XajqqqqVeItiiLKy8vbfLhxsnv741MoFLjzzjtx5513wmQyYd26dbjvvvtw/vnno6ioiN3jiYiiGLeXExFRr1u8eDE8Hg9uueUWuFyuU7rWJZdcgpqaGng8HowbN67N17BhwwAA559/PhQKBY4cOdLuceG8GtkTsU+bNg2ArwN6S++//367x6enp+PKK6/ECy+8gJdeeglz5sxBZmZmp/cRBKHNavjevXuxdevWTs9tz7Bhw5Ceno733nsPoigGHi8oKMCWLVu6dU1/N/e333671eOrV6+G1WoNPO+3f/9+/Pzzz60ee/fdd2EwGHDGGWe0ub7RaMRvf/tbLFy4ELW1td1ajSciosjBlW4iIup1U6ZMwfPPP4/bbrsNZ5xxBv7nf/4HI0aMgEwmQ1lZGVavXg0AbbZIt+eqq67CO++8g4suugiLFi3CWWedBaVSieLiYmzcuBFz587F5ZdfjoEDB+If//gH7r//fhw9ehQXXHAB4uPjUVFRgW3btkGn0+Hhhx/usZ/RZrO1O/oKQNDzu3si9gsuuABTpkzBXXfdhYaGBpx55pnYunVrYPTWiVvUAWDRokWYMGECAGDlypVdivWSSy7BI488goceegjTpk3DoUOH8I9//AODBg2C2+0O6uf2x/XII4/gj3/8Iy6//HLcdNNNMJlMWLJkSbvbvjvScvV/9uzZOP/883HvvfeioaEBU6ZMwd69e/HQQw9h7NixuPbaa1udm5GRgUsvvRRLlixBeno63n77baxduxaPP/54YAV7zpw5GDlyJMaNG4fk5GQUFBRgxYoVyMrKwtChQ4P+uYmIKHIw6SYiIknccsstmDRpEp599lk888wzKC0thSAI6N+/PyZPnoz169dj5syZnV5HLpfj008/xbPPPou33noLy5cvh0KhQP/+/TFt2jSMGjUqcOzixYuRm5uLZ599Fu+99x4cDgfS0tIwfvz4Lm2dDsbRo0cxadKkdp9zuVxQKIL7FXyqsctkMvz3v//FXXfdhcceewxOpxNTpkzB22+/jYkTJ8JoNLY556yzzsLAgQOh1WrbrP525P7770djYyNee+01PPHEE8jNzcVLL72ENWvWtNvwrStuvPFGAMDjjz+OK664AgMHDsR9992HzZs3d+majY2NrVbfBUHAxx9/jCVLlmDlypVYtmwZkpKScO211+LRRx9ts1I/ZswY3HDDDXjooYeQn5+PjIwMPP3007jjjjsCx8yYMQOrV6/Gv/71LzQ0NCAtLQ2zZ8/Ggw8+CKVS2a2fm4iIIoMgttyrRURERFHl3XffxTXXXIMffvihTTfwvXv34vTTT8fzzz+PW2+9VaIIT43L5UJ6ejpmzpyJVatWSR0OERFFIa50ExERRYn33nsPJSUlGDVqFGQyGX788Uf87//+L6ZOndoq4T5y5AgKCgpw3333IT09vc1orb6goaEB27dvxzvvvIOamhpcc801UodERERRikk3ERFRlDAYDHj//fexdOlSWK3WQEK9dOnSVsc98sgjeOuttzB8+HB8+OGHfbLz9q5du3D++edj4MCB+L//+z/MnTtX6pCIiChKcXs5ERER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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "def plot(energy_data,jobs):\n", + " Color = {\n", + " \"red\":\"#D6A99A\",\n", + " \"green\":\"#99D19C\",\n", + " \"blue\":\"#3DA5D9\",\n", + " \"yellow\":\"#E2C044\",\n", + " \"black\":\"#0F1A20\"\n", + " }\n", + " fig, ax1 = plt.subplots(figsize=(10, 6))\n", + " plt.title(\"Green Energy and Jobs\")\n", + " end = energy_data['startTimeUTC'].iloc[-1]\n", + " start = energy_data['startTimeUTC'].iloc[0]\n", + " ax1.plot(energy_data['startTimeUTC'], energy_data['percentRenewable'], color=Color['green'], label='Percentage of Renewable Energy')\n", + " ax1.set_xlabel('Time')\n", + " ax1.set_ylabel('% Renewable energy')\n", + " ax1.tick_params(axis='y')\n", + "\n", + " # Set x-axis to show dates properly\n", + " ax1.xaxis.set_major_formatter(mdates.DateFormatter('%d-%m %H:%M'))\n", + " plt.xticks(rotation=45)\n", + " \n", + " # # Create a second y-axis\n", + " ax2 = ax1.twinx()\n", + "\n", + " # Define y-values for each job (e.g., 1 for Job A, 2 for Job B, etc.)\n", + " for idx, job in enumerate(jobs):\n", + " lbl = str(job[\"emissions\"])\n", + " ax2.plot([job['start_time'], job['end_time']], [idx+1 , idx+1], marker='o', linewidth=25,label=lbl,color=Color[\"blue\"])\n", + " # Calculate the midpoint for the text placement\n", + " labelpoint = job['start_time'] + (job['end_time'] - job['start_time']) / 2 # + timedelta(minutes=100)\n", + " ax2.text(labelpoint, idx+1, lbl, color='black', ha='center', va='center', fontsize=12)\n", + " \n", + " # Adjust y-axis labels to match the number of jobs\n", + " ax2.set_yticks(range(1, len(jobs) + 1))\n", + " \n", + " # Add legend and show the plot\n", + " fig.tight_layout()\n", + " # plt.legend(loc='lower right')\n", + " plt.show()\n", + "\n", + "plot(e,j)" + ] + }, + { + "cell_type": "code", + "execution_count": 66, + "id": "a80c21c0-08b2-4fb3-9978-033e5d745fd9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ci_codecarbon ci_ipcc_lifecycle_min ci_ipcc_lifecycle_mean \\\n", + "0 198.91 165.47 192.91 \n", + "1 196.39 165.91 194.46 \n", + "2 188.96 161.81 189.56 \n", + "3 206.08 173.18 202.66 \n", + "4 242.32 195.29 227.10 \n", + ".. ... ... ... \n", + "77 444.38 349.93 402.01 \n", + "78 426.88 332.12 382.94 \n", + "79 421.91 329.50 380.12 \n", + "80 407.47 315.90 364.63 \n", + "81 372.81 290.65 337.21 \n", + "\n", + " ci_ipcc_lifecycle_max ci_eu_comm Biomass Fossil Brown coal/Lignite \\\n", + "0 334.17 180.84 4139.25 5287.75 \n", + "1 338.12 176.04 4202.00 5297.00 \n", + "2 331.62 171.79 4243.25 5294.75 \n", + "3 368.31 185.71 4269.50 5551.00 \n", + "4 436.82 218.13 4311.00 7298.00 \n", + ".. ... ... ... ... \n", + "77 800.25 408.81 4476.00 11330.75 \n", + "78 823.64 390.92 4429.75 11284.00 \n", + "79 693.88 389.20 4356.50 11262.00 \n", + "80 549.48 377.53 4234.50 11236.50 \n", + "81 499.74 346.39 4150.25 11187.75 \n", + "\n", + " Fossil Gas Fossil Hard coal Fossil Oil ... Wind_per Solar_per \\\n", + "0 1887.75 2472.25 340.0 ... 63 0 \n", + "1 1915.25 2073.50 340.0 ... 63 0 \n", + "2 1711.50 1784.75 340.0 ... 63 0 \n", + "3 1900.75 1853.50 340.0 ... 61 0 \n", + "4 2534.50 2024.50 340.0 ... 55 0 \n", + ".. ... ... ... ... ... ... \n", + "77 4095.25 4318.25 320.0 ... 21 0 \n", + "78 4158.50 4772.75 320.0 ... 19 5 \n", + "79 3927.75 5005.50 320.0 ... 16 15 \n", + "80 3368.75 5208.50 320.0 ... 13 26 \n", + "81 3335.75 4245.00 320.0 ... 13 32 \n", + "\n", + " Nuclear_per Hydroelectricity_per Geothermal_per Natural Gas_per \\\n", + "0 0 4 0 3 \n", + "1 0 4 0 4 \n", + "2 0 4 0 3 \n", + "3 0 5 0 4 \n", + "4 0 7 0 5 \n", + ".. ... ... ... ... \n", + "77 0 15 0 9 \n", + "78 0 17 0 9 \n", + "79 0 11 0 8 \n", + "80 0 5 0 7 \n", + "81 0 4 0 6 \n", + "\n", + " Petroleum_per Coal_per Biomass_per ci_default \n", + "0 0 16 8 192.91 \n", + "1 0 15 9 194.46 \n", + "2 0 15 9 189.56 \n", + "3 0 16 9 202.66 \n", + "4 0 19 8 227.10 \n", + ".. ... ... ... ... \n", + "77 0 37 10 402.01 \n", + "78 0 35 9 382.94 \n", + "79 0 35 9 380.12 \n", + "80 0 34 8 364.63 \n", + "81 0 31 8 337.21 \n", + "\n", + "[82 rows x 37 columns] [{'start_time': datetime.datetime(2024, 10, 1, 0, 0), 'runtime_minutes': 1200, 'end_time': datetime.datetime(2024, 10, 1, 20, 0), 'emissions': 1.4624}, {'start_time': datetime.datetime(2024, 10, 4, 0, 0), 'runtime_minutes': 600, 'end_time': datetime.datetime(2024, 10, 4, 10, 0), 'emissions': 1.0622}]\n" + ] + } + ], + "source": [ + "print(e,j)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7b217d0e-1c57-4b31-aa5d-b16170765558", + "metadata": {}, + "outputs": [], + "source": [ + "# random code :\n", + "\n", + "\n", + "\n", + "from datetime import datetime,timedelta\n", + "\n", + "class Server:\n", + " def __init__(self,name,location,number_core,memory_gb,power_draw_core=15.8,power_draw_mem=0.3725,usage_factor_core=1,power_usage_efficiency=1.6):\n", + " self.name = name\n", + " self.location = location\n", + " self.number_core = number_core\n", + " self.memory_gb = memory_gb\n", + " self.power_draw_core = power_draw_core\n", + " self.power_draw_mem = power_draw_mem\n", + " self.usage_factor_core = usage_factor_core\n", + " self.power_usage_efficiency = power_usage_efficiency\n", + " self.ci = None\n", + " def get_carbon_intensity(self,start_time,end_time):\n", + " if self.ci is not None :\n", + " if self.ci['startTimeUTC'].min() <= start_time and self.ci['startTimeUTC'].max() >= end_time:\n", + " result = self.ci[(self.ci['startTime'] >= start_time) & (self.ci['startTime'] <= end_time)] \n", + " return result\n", + " else :\n", + " self.ci = carbon_intensity.compute_ci(self.location,start_time,end_time)\n", + " return self.ci\n", + " \n", + "\n", + "class Job:\n", + " def __init__(self,runtime_min,name=\"Job\"):\n", + " self.id = id\n", + " self.runtime_min = runtime_min\n", + " def carbon_emission(self,server:Server,start_time:datetime):\n", + " \"\"\"Determines the carbon emission of the job when a job is started to run on a server with the give specification \"\"\"\n", + " if start_time is None:\n", + " raise ValueError(\"Start time not provided\")\n", + " if start_time >= datetime.now():\n", + " raise ValueError(\"Carbon emission calculation can only be done for jobs in the past\")\n", + " ce_total,ce_ts = carbon_emission.compute_ce(\n", + " server.location,\n", + " start_time,\n", + " self.runtime_min,\n", + " server.number_core,\n", + " server.memory_gb,\n", + " server.power_draw_core,\n", + " server.usage_factor_core,\n", + " server.power_draw_mem,\n", + " server.power_usage_efficiency\n", + " )\n", + " return ce_total\n", + " def carbon_emission_from_energy(self,server:Server,start_time:datetime):\n", + " \"\"\"Determines the carbon emission of the job when a job is started to run on a server with the give specification \"\"\"\n", + " end_time = start_time + timedelta(minutes=self.runtime_min)\n", + " energy_data = server.get_carbon_intensity(start_time,end_time)\n", + " ce_total,ce_ts = carbon_emission.compute_ce_from_energy(\n", + " energy_data,\n", + " server.number_core,\n", + " server.memory_gb,\n", + " server.power_draw_core,\n", + " server.usage_factor_core,\n", + " server.power_draw_mem,\n", + " server.power_usage_efficiency\n", + " )\n", + " return ce_total,end_time\n", + " def optimal_time(server,start_date,hard_deadline:datetime):\n", + " \"\"\"Determines what is the optimal time to start the job on the given server such that it emits less carbon emissions\"\"\"\n", + "\n", + "\n", + " \n", + "\n", + "\n", + "def plot_jobs(server:Server,jobs:Job):\n", + " \n", + " \n", + " for job in jobs :\n", + " ce,end = job.carbon_emission_from_energy()\n", + " job.end_time= job[\"start_time\"] + timedelta(minutes=job[\"runtime_minutes\"])\n", + " \n", + " min_start_date = min(job['start_time'] for job in jobs)\n", + " max_end_date = max(job['end_time'] for job in jobs)\n", + " # print(min_start_date)\n", + " # print(max_end_date)\n", + " energy_data = compute_ci(server[\"country\"],min_start_date,max_end_date)\n", + " energy_data['startTimeUTC'] = pd.to_datetime(energy_data['startTimeUTC'])\n", + " for job in jobs :\n", + " # filter_energy = energy_data\n", + " filtered_energy = energy_data[(energy_data['startTimeUTC'] >= job[\"start_time\"]) & (energy_data['startTimeUTC'] <= job[\"end_time\"])]\n", + " # print(filtered_energy)\n", + " job[\"emissions\"],temp = compute_ce_from_energy(filtered_energy,server[\"number_core\"],server[\"memory_gb\"],server[\"power_draw_core\"],server[\"usage_factor_core\"],server[\"power_draw_mem\"],server[\"power_usage_efficiency\"])\n", + "\n", + " # print(energy_data)\n", + " # print(jobs)\n", + " return energy_data,jobs, min_start_date, max_end_date\n", + "\n", + " fig, ax1 = plt.subplots(figsize=(10, 6))\n", + " plt.title(\"Green Energy and Jobs\")\n", + " \n", + " ax1.plot(energy_data['startTimeUTC'], energy_data['percentRenewable'], color=Color['green'], label='Percentage Renewable')\n", + " ax1.set_xlabel('Time')\n", + " ax1.set_ylabel('% Renewable energy')\n", + " ax1.tick_params(axis='y')\n", + "\n", + " # Set x-axis to show dates properly\n", + " ax1.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))\n", + " plt.xticks(rotation=90)\n", + " \n", + " # # Create a second y-axis\n", + " ax2 = ax1.twinx()\n", + "\n", + " # Define y-values for each job (e.g., 1 for Job A, 2 for Job B, etc.)\n", + " for idx, job in enumerate(jobs):\n", + " ax2.plot([job['start_time'], job['end_time']], [idx+1 , idx+1], marker='o', linewidth=10)\n", + " # Calculate the midpoint for the text placement\n", + " #midpoint = job['start'] + (job['end'] - job['start']) / 2\n", + " #ax2.text(midpoint, idx + 1, f\"{job['savings']}% saved ({job[\"per\"]} % ren)\", color='black', ha='center', va='center', fontsize=10)\n", + " \n", + " # Adjust y-axis labels to match the number of jobs\n", + " ax2.set_yticks(range(1, len(jobs) + 1))\n", + " \n", + " # Add legend and show the plot\n", + " fig.tight_layout()\n", + " plt.legend(loc='lower right')\n", + " plt.show()\n", + "\n", + " \n", + " # then plot percentage renewable \n", + "\n", + " # find carbon emissions for each job\n", + " \n", + "\n", + "\n", + " # plot the jobs\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "def get_optimal_job_times(country,start,end,hour,renewPer,n_cores,n_mem_gb):\n", + " energy_data = energy(country,start,end) # again using histoirical data \n", + " # Convert the 'startTimeUTC' column to datetime\n", + " energy_data['startTimeUTC'] = pd.to_datetime(energy_data['startTimeUTC'], utc=True)\n", + " # Add 'posix_timestamp' column\n", + " \n", + " energy_data['posix_timestamp'] = energy_data['startTimeUTC'].astype(int) // 10**9 # Convert to POSIX timestamp (seconds)\n", + " energy_data['percent_renewable'] = energy_data[\"percentRenewable\"]\n", + " jobs = []\n", + " current_start_time = start\n", + " current_end_time = start + timedelta(hours=hour)\n", + " current_emission,ce_ts = calculate_carbon_footprint_job(country,current_start_time,hour*60,n_cores,n_mem_gb)\n", + " jobs.append({\"color\":Color[\"blue\"],\"label\":\"Original time CE(\"+str(current_emission)+\" gCO2e)\",\"start\":current_start_time,\"end\":current_end_time,\"emission\":current_emission,\"savings\":0 , \"per\":0 })\n", + " \n", + " for per in renewPer :\n", + " a,b,c = predict_optimal_time(energy_data,hour,0,per,end,start)\n", + " print(a,b,c)\n", + " s = datetime.fromtimestamp(a)\n", + " e = s + timedelta(hours=hour)\n", + " em,em_ts = calculate_carbon_footprint_job(country,s,hour*60,n_cores,n_mem_gb)\n", + " sv = int(((current_emission-em)/current_emission)*100)\n", + " clr = Color[\"green\"] if sv>0 else Color[\"red\"]\n", + " jobs.append({\"color\": clr ,\"label\":str(per)+ \" % Ren, CE(\"+str(round(em,3))+\" gCO2e)\",\"start\": s ,\"end\": e,\"emission\":em,\"savings\": sv,\"per\":per })\n", + "\n", + " print(jobs)\n", + " return energy_data,jobs\n", + "\n", + "\n", + "\n", + "\n", + "def plot_optimal_time(country,start,end,hour,renewPer,n_cores,n_mem_gb):\n", + " \n", + " energy_data,jobs = get_carbon_emission(country,start,end,hour,renewPer,n_cores,n_mem_gb)\n", + " # Create the figure and the first axis\n", + " fig, ax1 = plt.subplots(figsize=(10, 6))\n", + "\n", + " plt.title(\"Optimal time for \"+str(hour)+\" hr job in \"+str(country)+\" (b/w \"+str(start)+\"-\"+str(end)+\")\")\n", + " \n", + " ax1.plot(energy_data['startTimeUTC'], energy_data['percentRenewable'], color=Color['green'], label='Percentage Renewable')\n", + " ax1.set_xlabel('Time')\n", + " ax1.set_ylabel('% Renewable energy')\n", + " ax1.tick_params(axis='y')\n", + "\n", + " # Set x-axis to show dates properly\n", + " ax1.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))\n", + " plt.xticks(rotation=90)\n", + " \n", + " # Create a second y-axis\n", + " ax2 = ax1.twinx()\n", + "\n", + " # Define y-values for each job (e.g., 1 for Job A, 2 for Job B, etc.)\n", + " # for job in jobs:\n", + " for idx, job in enumerate(jobs):\n", + " ax2.plot([job['start'], job['end']], [idx , idx + 1], marker='o', linewidth=15, label=job['label'],color = job['color'])\n", + " \n", + " # Calculate the midpoint for the text placement\n", + " midpoint = job['start'] + (job['end'] - job['start']) / 2\n", + " ax2.text(midpoint, idx + 1, f\"{job['savings']}% saved ({job[\"per\"]} % ren)\", color='black', ha='center', va='center', fontsize=10)\n", + " \n", + " # Adjust y-axis labels to match the number of jobs\n", + " ax2.set_yticks(range(1, len(jobs) + 1))\n", + " #ax2.set_yticklabels(jobs['emissions'])\n", + " \n", + " # Add legend and show the plot\n", + " fig.tight_layout()\n", + " plt.legend(loc='lower right')\n", + " plt.show()\n", + "\n" + ] } ], "metadata": { diff --git a/docs/tools.rst b/docs/tools.rst index d30bf2c..16116c1 100644 --- a/docs/tools.rst +++ b/docs/tools.rst @@ -67,7 +67,7 @@ Carbon emission of a job depends on 2 factors : Energy consumed by the hardware - :math:`PUE` : efficiency coefficient of the data center - Emissions related to the production of the energy : represented by the Carbon Intensity of the energy mix during that period. Already implemented above - +- The result is Carbon emission in CO2e .. automodule:: codegreen_core.tools.carbon_emission :members: diff --git a/pyproject.toml b/pyproject.toml index 4d74598..a523c61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,8 @@ requires = ["setuptools>=61.0", "redis", "scikit-learn", "tensorflow", - "sphinx" + "sphinx", + "matplotlib" ] build-backend = "setuptools.build_meta" diff --git a/setup.py b/setup.py index 8845b26..db570c1 100644 --- a/setup.py +++ b/setup.py @@ -8,5 +8,5 @@ 'codegreen_core.utilities': ['country_list.json','ci_default_values.csv','model_details.json'], }, packages=find_packages(), - install_requires=["pandas","numpy","entsoe-py","redis","tensorflow","scikit-learn","sphinx"] + install_requires=["pandas","numpy","entsoe-py","redis","tensorflow","scikit-learn","sphinx","matplotlib"] ) diff --git a/tests/test_carbon_emissions.py b/tests/test_carbon_emissions.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_loadshift_location.py b/tests/test_loadshift_location.py index 1c66a9c..646297d 100644 --- a/tests/test_loadshift_location.py +++ b/tests/test_loadshift_location.py @@ -1,43 +1,43 @@ -from codegreen_core.tools.loadshift_location import predict_optimal_location,predict_optimal_location_now -from datetime import datetime,timedelta -import pandas as pd -import pytz +# from codegreen_core.tools.loadshift_location import predict_optimal_location,predict_optimal_location_now +# from datetime import datetime,timedelta +# import pandas as pd +# import pytz -def test_location_now(): - a,b,c,d = predict_optimal_location_now(["DE","HU","AT","FR","AU","NO"],5,0,50,datetime(2024,9,13)) - print(a,b,c,d) +# def test_location_now(): +# a,b,c,d = predict_optimal_location_now(["DE","HU","AT","FR","AU","NO"],5,0,50,datetime(2024,9,13)) +# print(a,b,c,d) -# test_location_now() +# # test_location_now() -def fetch_data(month_no,countries): - data = pd.read_csv("tests/data/prediction_testing_data.csv") - forecast_data = {} - for c in countries: - filter = data["file_id"] == c+""+str(month_no) - d = data[filter].copy() - if(len(d)>0): - forecast_data[c] = d - return forecast_data +# def fetch_data(month_no,countries): +# data = pd.read_csv("tests/data/prediction_testing_data.csv") +# forecast_data = {} +# for c in countries: +# filter = data["file_id"] == c+""+str(month_no) +# d = data[filter].copy() +# if(len(d)>0): +# forecast_data[c] = d +# return forecast_data -def test_locations(): - cases = [ - { - "month":1, - "c":["DE","NO","SW","ES","IT"], - "h":5, - "m":0, - "p":50, - "s":"2024-01-05 02:00:00", - "e": 10 - } - ] - for case in cases: - data = fetch_data(case["month"],case["c"]) - start_utc = datetime.strptime(case["s"], '%Y-%m-%d %H:%M:%S') - start_utc = pytz.UTC.localize(start_utc) - start = start_utc.astimezone(pytz.timezone('Europe/Berlin')) - end = (start + timedelta(hours=case["e"])) - a,b,c,d = predict_optimal_location(data,case["h"],case["m"],case["p"],end,start) - print(a,b,c,d) +# def test_locations(): +# cases = [ +# { +# "month":1, +# "c":["DE","NO","SW","ES","IT"], +# "h":5, +# "m":0, +# "p":50, +# "s":"2024-01-05 02:00:00", +# "e": 10 +# } +# ] +# for case in cases: +# data = fetch_data(case["month"],case["c"]) +# start_utc = datetime.strptime(case["s"], '%Y-%m-%d %H:%M:%S') +# start_utc = pytz.UTC.localize(start_utc) +# start = start_utc.astimezone(pytz.timezone('Europe/Berlin')) +# end = (start + timedelta(hours=case["e"])) +# a,b,c,d = predict_optimal_location(data,case["h"],case["m"],case["p"],end,start) +# print(a,b,c,d) -# test_locations() \ No newline at end of file +# # test_locations() \ No newline at end of file From 997d32227f059ab63646f62196e006a069c9fad1 Mon Sep 17 00:00:00 2001 From: shubh Date: Tue, 29 Oct 2024 16:03:38 +0100 Subject: [PATCH 06/10] test fix --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0cd7248..a7868f2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,7 +29,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest pandas numpy entsoe-py redis tensorflow scikit-learn sphinx + pip install pytest pandas numpy entsoe-py redis tensorflow scikit-learn sphinx matplotlib pip install . # Run pytest to execute tests From cd6ca7fa48a4a47c3c98026f42d9c3bfcc17f9ce Mon Sep 17 00:00:00 2001 From: shubh Date: Tue, 29 Oct 2024 19:07:28 +0100 Subject: [PATCH 07/10] integrated poetry --- .github/workflows/test.yml | 16 ++++++---- docs/conf.py | 2 +- pyproject.toml | 60 ++++++++++++++++++-------------------- setup.py | 12 -------- 4 files changed, 40 insertions(+), 50 deletions(-) delete mode 100644 setup.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a7868f2..744fecc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,14 +23,18 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.12.5' # Specify Python version (e.g., '3.9') + python-version: '3.11.9' # Specify Python version (e.g., '3.9') - # Install dependencies (you can specify requirements.txt or pyproject.toml) + # Install Poetry + - name: Install Poetry + run: | + curl -sSL https://install.python-poetry.org | python3 - + export PATH="$HOME/.local/bin:$PATH" + + # Install dependencies using Poetry - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install pytest pandas numpy entsoe-py redis tensorflow scikit-learn sphinx matplotlib - pip install . + poetry install # Run pytest to execute tests - name: Generate .config file inside the test folder @@ -40,4 +44,4 @@ jobs: echo "enable_energy_caching=false" >> .codegreencore.config - name: Run tests run: | - pytest + poetry run pytest diff --git a/docs/conf.py b/docs/conf.py index 3dfd9b2..7acc7dc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ exclude_patterns = [] -autodoc_mock_imports = ["redis","pandas","entsoe","dateutil","tensorflow","numpy","sklearn"] +autodoc_mock_imports = ["redis","pandas","entsoe","dateutil","tensorflow","numpy","sklearn","matplotlib"] extensions = ['sphinx.ext.autodoc','docs._extensions.country_table_extension','sphinx.ext.mathjax'] diff --git a/pyproject.toml b/pyproject.toml index a523c61..0bf10cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,34 +1,32 @@ -[build-system] -requires = ["setuptools>=61.0", - "requests", - "pandas", - "numpy", - "entsoe-py", - "codecarbon", - "redis", - "scikit-learn", - "tensorflow", - "sphinx", - "matplotlib" -] +[tool.poetry] +name = "codegreen-core" +version = "0.5.0" +description = "This package helps you become aware of the carbon footprint of your computation" +authors = ["Anne Hartebrodt ","Shubh Vardhan Jain "] +readme = "README.md" -build-backend = "setuptools.build_meta" +[tool.poetry.dependencies] +python = ">=3.10, <3.12" +entsoe-py = "^0.6.13" +redis = "^5.1.1" +requests = "^2.32.3" +pandas = "2.2.3" +numpy = "<2.0.0" +tensorflow = "^2.18.0" +matplotlib = "^3.9.2" +scikit-learn = "^1.5.2" -[project] -name = "codegreen_core" -version = "0.0.1" -authors = [ - { name="Anne Hartebrodt", email="anne.hartebrodt@fau.de" }, -] -description = "Codegreen -- make your computations carbon-aware" -readme = "README.md" -requires-python = ">=3.9" -classifiers = [ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", -] -[project.urls] -"Homepage" = "https://codegreen.world" -"Bug Tracker" = "https://github.com/bionetslab/codegreen-core/issues" \ No newline at end of file +[tool.poetry.group.dev.dependencies] +pytest = "^8.3.3" +Sphinx = "^8.1.3" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +# Specify additional package data (similar to include_package_data) +#[tool.poetry.package.include] +#"codegreen_core/utilities/country_list.json" = { format = "file" } +#"codegreen_core/utilities/ci_default_values.csv" = { format = "file" } +#"codegreen_core/utilities/model_details.json" = { format = "file" } \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index db570c1..0000000 --- a/setup.py +++ /dev/null @@ -1,12 +0,0 @@ -from setuptools import setup, find_packages - -setup( - name='codegreen_core', - version='0.5.0', - include_package_data=True, - package_data={ - 'codegreen_core.utilities': ['country_list.json','ci_default_values.csv','model_details.json'], - }, - packages=find_packages(), - install_requires=["pandas","numpy","entsoe-py","redis","tensorflow","scikit-learn","sphinx","matplotlib"] -) From 4706037aec208b4f44b020e90bba9764a0679fad Mon Sep 17 00:00:00 2001 From: shubh Date: Tue, 29 Oct 2024 19:25:30 +0100 Subject: [PATCH 08/10] using black to format code --- codegreen_core/__init__.py | 1 + codegreen_core/data/__init__.py | 3 +- codegreen_core/data/entsoe.py | 268 ++++++++---- codegreen_core/data/main.py | 101 ++--- codegreen_core/models/predict.py | 76 ++-- codegreen_core/models/train.py | 2 +- codegreen_core/tools/carbon_emission.py | 257 +++++++---- codegreen_core/tools/carbon_intensity.py | 168 ++++--- codegreen_core/tools/loadshift_location.py | 39 +- codegreen_core/tools/loadshift_time.py | 154 ++++--- codegreen_core/utilities/__init__.py | 2 +- codegreen_core/utilities/caching.py | 60 +-- codegreen_core/utilities/config.py | 102 +++-- codegreen_core/utilities/log.py | 32 +- codegreen_core/utilities/message.py | 14 +- codegreen_core/utilities/metadata.py | 123 +++--- docs/_extensions/country_table_extension.py | 29 +- docs/conf.py | 32 +- docs/plot.py | 172 +++++--- pyproject.toml | 1 + tests/get_data.py | 202 +++++---- tests/test1_predictions.py | 9 +- tests/test_carbon_intensity.py | 30 +- tests/test_data.py | 223 +++++----- tests/test_loadshift_location.py | 4 +- tests/test_loadshift_time.py | 461 +++++++++++--------- 26 files changed, 1492 insertions(+), 1073 deletions(-) diff --git a/codegreen_core/__init__.py b/codegreen_core/__init__.py index 6b19c4b..6c12424 100644 --- a/codegreen_core/__init__.py +++ b/codegreen_core/__init__.py @@ -1,2 +1,3 @@ from .utilities.config import Config + Config.load_config() diff --git a/codegreen_core/data/__init__.py b/codegreen_core/data/__init__.py index 7d3c63e..8dc6ff4 100644 --- a/codegreen_core/data/__init__.py +++ b/codegreen_core/data/__init__.py @@ -1,2 +1,3 @@ from .main import * -__all__ = ['energy'] \ No newline at end of file + +__all__ = ["energy"] diff --git a/codegreen_core/data/entsoe.py b/codegreen_core/data/entsoe.py index 2004dc8..7b9e308 100644 --- a/codegreen_core/data/entsoe.py +++ b/codegreen_core/data/entsoe.py @@ -5,35 +5,61 @@ import traceback -# constant values -renewableSources = ["Biomass","Geothermal", "Hydro Pumped Storage", "Hydro Run-of-river and poundage", - "Hydro Water Reservoir", "Marine", "Other renewable", "Solar", "Waste", "Wind Offshore", "Wind Onshore"] +# constant values +renewableSources = [ + "Biomass", + "Geothermal", + "Hydro Pumped Storage", + "Hydro Run-of-river and poundage", + "Hydro Water Reservoir", + "Marine", + "Other renewable", + "Solar", + "Waste", + "Wind Offshore", + "Wind Onshore", +] windSolarOnly = ["Solar", "Wind Offshore", "Wind Onshore"] -nonRenewableSources = [ "Fossil Brown coal/Lignite", "Fossil Coal-derived gas", "Fossil Gas", - "Fossil Hard coal", "Fossil Oil", "Fossil Oil shale", "Fossil Peal", "Nuclear", "Other"] +nonRenewableSources = [ + "Fossil Brown coal/Lignite", + "Fossil Coal-derived gas", + "Fossil Gas", + "Fossil Hard coal", + "Fossil Oil", + "Fossil Oil shale", + "Fossil Peal", + "Nuclear", + "Other", +] energy_type = { - "Wind":["Wind Offshore", "Wind Onshore"], - "Solar":["Solar"], + "Wind": ["Wind Offshore", "Wind Onshore"], + "Solar": ["Solar"], "Nuclear": ["Nuclear"], - "Hydroelectricity":[ "Hydro Pumped Storage", "Hydro Run-of-river and poundage", "Hydro Water Reservoir"], - "Geothermal":["Geothermal"], + "Hydroelectricity": [ + "Hydro Pumped Storage", + "Hydro Run-of-river and poundage", + "Hydro Water Reservoir", + ], + "Geothermal": ["Geothermal"], "Natural Gas": ["Fossil Coal-derived gas", "Fossil Gas"], - "Petroleum":["Fossil Oil", "Fossil Oil shale"], - "Coal":["Fossil Brown coal/Lignite","Fossil Hard coal","Fossil Peal"], - "Biomass":["Biomass"] + "Petroleum": ["Fossil Oil", "Fossil Oil shale"], + "Coal": ["Fossil Brown coal/Lignite", "Fossil Hard coal", "Fossil Peal"], + "Biomass": ["Biomass"], } # helper methods + def _get_API_token() -> str: - """ reads the ENTOSE api token required to access data from the portal. must be defined in the config file""" - return Config.get("ENTSOE_token") + """reads the ENTOSE api token required to access data from the portal. must be defined in the config file""" + return Config.get("ENTSOE_token") + def _refine_data(options, data1): - """Returns a refined version of the dataframe. - The Refining process involves finding missing values and substituting them with average values. - Additionally, a new column `startTimeUTC` is appended to the dataframe representing the start time in UTC - :param options + """Returns a refined version of the dataframe. + The Refining process involves finding missing values and substituting them with average values. + Additionally, a new column `startTimeUTC` is appended to the dataframe representing the start time in UTC + :param options :param data1 : the dataframe that has to be refined. Assuming it has a datetime index in local time zone with country info :returns {"data":Refined data frame, "refine_logs":["list of refinements made"]} """ @@ -42,8 +68,9 @@ def _refine_data(options, data1): durationMin = (data1.index[1] - data1.index[0]).total_seconds() / 60 # initializing the log list refine_logs = [] - refine_logs.append("Row count : Fetched = " + - str(len(data1)) + ", duration : "+str(durationMin)) + refine_logs.append( + "Row count : Fetched = " + str(len(data1)) + ", duration : " + str(durationMin) + ) """ Determining the list of records that are absent in the time series by initially creating a set containing all the expected timestamps within the start and end time range. Then, we calculate the difference between @@ -52,7 +79,8 @@ def _refine_data(options, data1): start_time = data1.index.min() end_time = data1.index.max() expected_timestamps = pd.date_range( - start=start_time, end=end_time, freq=f"{durationMin}min") + start=start_time, end=end_time, freq=f"{durationMin}min" + ) expected_df = pd.DataFrame(index=expected_timestamps) missing_indices = expected_df.index.difference(data1.index) """ Next, we fill in the missing values. @@ -65,25 +93,31 @@ def _refine_data(options, data1): rows_same_day = data1[data1.index.date == index.date()] if len(rows_same_day) > 0: avg_val = rows_same_day.mean().fillna(0).round().astype(int) - avg_type = "average day value " + \ - str(rows_same_day.index[0].date())+" " + avg_type = "average day value " + str(rows_same_day.index[0].date()) + " " else: avg_val = totalAverageValue avg_type = "whole data average " - refine_logs.append("Missing value: "+str(index) + " replaced with " + - avg_type + " : "+' '.join(avg_val.astype(str))) + refine_logs.append( + "Missing value: " + + str(index) + + " replaced with " + + avg_type + + " : " + + " ".join(avg_val.astype(str)) + ) new_row = pd.DataFrame([avg_val], columns=data1.columns, index=[index]) data1 = pd.concat([data1, new_row]) """ Currently, the datatime index is set in the time zone of the data's country of origin. We convert it into UTC and add it as a new column named 'startTimeUTC' in the 'YYYYMMDDhhmm' format. """ - data1['startTimeUTC'] = (data1.index.tz_convert('UTC')).strftime('%Y%m%d%H%M') + data1["startTimeUTC"] = (data1.index.tz_convert("UTC")).strftime("%Y%m%d%H%M") # data1['startTimeLocal'] = (data1.index).strftime('%Y%m%d%H%M') # since missing values are concatenated to the dataframe, it is also sorted based on the datetime index data1.sort_index(inplace=True) return {"data": data1, "refine_logs": refine_logs} + def _entsoe_get_actual_generation(options={"country": "", "start": "", "end": ""}): """Fetches the aggregated actual generation per production type data (16.1.B&C) for the given country within the given start and end date params: options = {country (2 letter country code),start,end} . Both the dates are in the YYYYMMDDhhmm format and the local time zone @@ -92,24 +126,30 @@ def _entsoe_get_actual_generation(options={"country": "", "start": "", "end": "" client1 = entsoePandas(api_key=_get_API_token()) data1 = client1.query_generation( options["country"], - start=pd.Timestamp(options["start"], tz='UTC'), - end=pd.Timestamp(options["end"], tz='UTC'), - psr_type=None) + start=pd.Timestamp(options["start"], tz="UTC"), + end=pd.Timestamp(options["end"], tz="UTC"), + psr_type=None, + ) # drop columns with actual consumption values (we want actual aggregated generation values) - columns_to_drop = [ - col for col in data1.columns if col[1] == 'Actual Consumption'] + columns_to_drop = [col for col in data1.columns if col[1] == "Actual Consumption"] data1 = data1.drop(columns=columns_to_drop) # If certain column names are in the format of a tuple like (energy_type, 'Actual Aggregated'), # these column names are transformed into strings using the value of energy_type. - data1.columns = [(col[0] if isinstance(col, tuple) else col) - for col in data1.columns] + data1.columns = [ + (col[0] if isinstance(col, tuple) else col) for col in data1.columns + ] # refine the dataframe. see the refine method data2 = _refine_data(options, data1) refined_data = data2["data"] refined_data = refined_data.reset_index(drop=True) # finding the duration of the time series data durationMin = (data1.index[1] - data1.index[0]).total_seconds() / 60 - return {"data": refined_data, "duration": durationMin, "refine_logs": data2["refine_logs"]} + return { + "data": refined_data, + "duration": durationMin, + "refine_logs": data2["refine_logs"], + } + def _entsoe_get_total_forecast(options={"country": "", "start": "", "end": ""}): """Fetches the aggregated day ahead total generation forecast data (14.1.C) for the given country within the given start and end date @@ -119,8 +159,9 @@ def _entsoe_get_total_forecast(options={"country": "", "start": "", "end": ""}): client = entsoePandas(api_key=_get_API_token()) data = client.query_generation_forecast( options["country"], - start=pd.Timestamp(options["start"], tz='UTC'), - end=pd.Timestamp(options["end"], tz='UTC')) + start=pd.Timestamp(options["start"], tz="UTC"), + end=pd.Timestamp(options["end"], tz="UTC"), + ) # if the data is a series instead of a dataframe, it will be converted to a dataframe if isinstance(data, pd.Series): data = data.to_frame(name="Actual Aggregated") @@ -129,10 +170,15 @@ def _entsoe_get_total_forecast(options={"country": "", "start": "", "end": ""}): data2 = _refine_data(options, data) refined_data = data2["data"] # rename the single column - newCol = {'Actual Aggregated': 'total'} + newCol = {"Actual Aggregated": "total"} refined_data.rename(columns=newCol, inplace=True) refined_data = refined_data.reset_index(drop=True) - return {"data": refined_data, "duration": durationMin, "refine_logs": data2["refine_logs"]} + return { + "data": refined_data, + "duration": durationMin, + "refine_logs": data2["refine_logs"], + } + def _entsoe_get_wind_solar_forecast(options={"country": "", "start": "", "end": ""}): """Fetches the aggregated day ahead wind and solar generation forecast data (14.1.D) for the given country within the given start and end date @@ -142,8 +188,9 @@ def _entsoe_get_wind_solar_forecast(options={"country": "", "start": "", "end": client = entsoePandas(api_key=_get_API_token()) data = client.query_wind_and_solar_forecast( options["country"], - start=pd.Timestamp(options["start"], tz='UTC'), - end=pd.Timestamp(options["end"], tz='UTC')) + start=pd.Timestamp(options["start"], tz="UTC"), + end=pd.Timestamp(options["end"], tz="UTC"), + ) durationMin = (data.index[1] - data.index[0]).total_seconds() / 60 # refining the data data2 = _refine_data(options, data) @@ -156,50 +203,58 @@ def _entsoe_get_wind_solar_forecast(options={"country": "", "start": "", "end": existingCol.append(col) refined_data["totalRenewable"] = refined_data[existingCol].sum(axis=1) refined_data = refined_data.reset_index(drop=True) - return {"data": refined_data, "duration": durationMin, "refine_logs": data2["refine_logs"]} + return { + "data": refined_data, + "duration": durationMin, + "refine_logs": data2["refine_logs"], + } + def _convert_to_60min_interval(rawData): - """Given the rawData obtained from the ENTSOE API methods, this function converts the DataFrame into - 60-minute time intervals by aggregating data from multiple rows. """ + """Given the rawData obtained from the ENTSOE API methods, this function converts the DataFrame into + 60-minute time intervals by aggregating data from multiple rows.""" duration = rawData["duration"] if duration == 60: - """ If the duration is already 60, return data """ + """If the duration is already 60, return data""" return rawData["data"] elif duration < 60: """ - First, we determine the number of rows needed to combine in order to obtain data in a 60-minute format. + First, we determine the number of rows needed to combine in order to obtain data in a 60-minute format. It is important to note that the rows are combined by taking the average of the row data, rather than the sum. """ # determining how many rows need to be combined to get data in 60 min format. - groupingFactor = int(60/duration) + groupingFactor = int(60 / duration) oldData = rawData["data"] - oldData["startTimeUTC"] = pd.to_datetime(oldData['startTimeUTC']) - start_time = oldData["startTimeUTC"] .min() - end_time = oldData["startTimeUTC"] .max() + oldData["startTimeUTC"] = pd.to_datetime(oldData["startTimeUTC"]) + start_time = oldData["startTimeUTC"].min() + end_time = oldData["startTimeUTC"].max() durationMin = 60 # removing the old timestamps (which are not 60 mins apart) - dataColToRemove = ['startTimeUTC'] + dataColToRemove = ["startTimeUTC"] # dataColToRemove = ['startTimeUTC','startTimeLocal'] oldData = oldData.drop(dataColToRemove, axis=1) - oldData['group_id'] = oldData.index // groupingFactor - newGroupedData = oldData.groupby('group_id').mean() - # new timestamps which are 60 min apart + oldData["group_id"] = oldData.index // groupingFactor + newGroupedData = oldData.groupby("group_id").mean() + # new timestamps which are 60 min apart new_timestamps = pd.date_range( - start=start_time, end=end_time, freq=f"{durationMin}min", tz='UTC') - new_timestamps = new_timestamps.strftime('%Y%m%d%H%M') + start=start_time, end=end_time, freq=f"{durationMin}min", tz="UTC" + ) + new_timestamps = new_timestamps.strftime("%Y%m%d%H%M") newGroupedData["startTimeUTC"] = new_timestamps return newGroupedData -def _convert_date_to_entsoe_format(dt:datetime): - return dt.replace(minute=0, second=0, microsecond=0).strftime('%Y%m%d%H%M') +def _convert_date_to_entsoe_format(dt: datetime): + return dt.replace(minute=0, second=0, microsecond=0).strftime("%Y%m%d%H%M") + + +# the main methods -# the main methods def get_actual_production_percentage(country, start, end, interval60=False) -> dict: - """Returns time series data containing the percentage of energy generated from various sources for the specified country within the selected time period. - It also includes the percentage of energy from renewable and non renewable sources. The data is fetched from the APIs is subsequently refined. + """Returns time series data containing the percentage of energy generated from various sources for the specified country within the selected time period. + It also includes the percentage of energy from renewable and non renewable sources. The data is fetched from the APIs is subsequently refined. To obtain data in 60-minute intervals (if not already available), set 'interval60' to True :param str country: The 2 alphabet country code. @@ -213,8 +268,13 @@ def get_actual_production_percentage(country, start, end, interval60=False) -> d - `time_interval` : the time interval of the DataFrame :rtype: dict """ - try : - options = {"country": country, "start": start,"end": end, "interval60": interval60} + try: + options = { + "country": country, + "start": start, + "end": end, + "interval60": interval60, + } # get actual generation data per production type and convert it into 60 min interval if required totalRaw = _entsoe_get_actual_generation(options) total = totalRaw["data"] @@ -238,39 +298,60 @@ def get_actual_production_percentage(country, start, end, interval60=False) -> d # calculate percent renewable table["percentRenewable"] = (table["renewableTotal"] / table["total"]) * 100 # refine percentage values : replacing missing values with 0 and converting to integer - table['percentRenewable'] = table['percentRenewable'].fillna(0) + table["percentRenewable"] = table["percentRenewable"].fillna(0) table["percentRenewable"] = table["percentRenewable"].round().astype(int) table["percentRenewableWS"] = (table["renewableTotalWS"] / table["total"]) * 100 - table['percentRenewableWS']= table['percentRenewableWS'].fillna(0) + table["percentRenewableWS"] = table["percentRenewableWS"].fillna(0) table["percentRenewableWS"] = table["percentRenewableWS"].round().astype(int) - # individual energy source percentage calculation - allAddkeys = ["Wind","Solar","Nuclear","Hydroelectricity","Geothermal","Natural Gas","Petroleum","Coal","Biomass"] + # individual energy source percentage calculation + allAddkeys = [ + "Wind", + "Solar", + "Nuclear", + "Hydroelectricity", + "Geothermal", + "Natural Gas", + "Petroleum", + "Coal", + "Biomass", + ] for ky in allAddkeys: - keys_available = list(set(allCols).intersection(energy_type[ky])) - #print(keys_available) - fieldName = ky+"_per" + keys_available = list(set(allCols).intersection(energy_type[ky])) + # print(keys_available) + fieldName = ky + "_per" # print(fieldName) table[fieldName] = table[keys_available].sum(axis=1) - table[fieldName] = (table[fieldName]/table["total"])*100 + table[fieldName] = (table[fieldName] / table["total"]) * 100 table[fieldName] = table[fieldName].fillna(0) - table[fieldName] = table[fieldName].astype(int) - - return {"data":table,"data_available":True,"time_interval": totalRaw["duration"]} + table[fieldName] = table[fieldName].astype(int) + + return { + "data": table, + "data_available": True, + "time_interval": totalRaw["duration"], + } except Exception as e: print(e) print(traceback.format_exc()) - return {"data": None,"data_available":False,"error":Exception,"time_interval": totalRaw["duration"]} + return { + "data": None, + "data_available": False, + "error": Exception, + "time_interval": totalRaw["duration"], + } + +def get_forecast_percent_renewable( + country: str, start: datetime, end: datetime +) -> dict: + """Returns time series data comprising the forecast of the percentage of energy generated from + renewable sources (specifically, wind and solar) for the specified country within the selected time period. -def get_forecast_percent_renewable(country:str, start:datetime, end:datetime) -> dict: - """Returns time series data comprising the forecast of the percentage of energy generated from - renewable sources (specifically, wind and solar) for the specified country within the selected time period. - - The data source is the ENTSOE APIs and involves combining data from 2 APIs : total forecast, wind and solar forecast. - The time interval is 60 min - the data frame includes : `startTimeUTC`, `totalRenewable`,`total`,`percent_renewable`,`posix_timestamp` - + :param str country: The 2 alphabet country code. :param datetime start: The start date for data retrieval. A Datetime object. Note that this date will be rounded to the nearest hour. :param datetime end: The end date for data retrieval. A datetime object. This date is also rounded to the nearest hour. @@ -285,7 +366,7 @@ def get_forecast_percent_renewable(country:str, start:datetime, end:datetime) -> # print(country,start,end) start = _convert_date_to_entsoe_format(start) end = _convert_date_to_entsoe_format(end) - options = {"country": country, "start": start,"end": end} + options = {"country": country, "start": start, "end": end} totalRaw = _entsoe_get_total_forecast(options) if totalRaw["duration"] != 60: total = _convert_to_60min_interval(totalRaw) @@ -297,14 +378,25 @@ def get_forecast_percent_renewable(country:str, start:datetime, end:datetime) -> else: windsolar = windsolarRaw["data"] windsolar["total"] = total["total"] - windsolar["percentRenewable"] = (windsolar['totalRenewable'] / windsolar['total']) * 100 - windsolar['percentRenewable']= windsolar['percentRenewable'].fillna(0) - windsolar["percentRenewable"] = windsolar["percentRenewable"].round().astype(int) - windsolar = windsolar.rename(columns={'percentRenewable': 'percent_renewable'}) - windsolar['startTimeUTC'] = pd.to_datetime(windsolar['startTimeUTC'], format='%Y%m%d%H%M') - windsolar["posix_timestamp"] = (windsolar['startTimeUTC'].astype(int) // 10**9) - return {"data": windsolar,"data_available":True,"time_interval":60} + windsolar["percentRenewable"] = ( + windsolar["totalRenewable"] / windsolar["total"] + ) * 100 + windsolar["percentRenewable"] = windsolar["percentRenewable"].fillna(0) + windsolar["percentRenewable"] = ( + windsolar["percentRenewable"].round().astype(int) + ) + windsolar = windsolar.rename(columns={"percentRenewable": "percent_renewable"}) + windsolar["startTimeUTC"] = pd.to_datetime( + windsolar["startTimeUTC"], format="%Y%m%d%H%M" + ) + windsolar["posix_timestamp"] = windsolar["startTimeUTC"].astype(int) // 10**9 + return {"data": windsolar, "data_available": True, "time_interval": 60} except Exception as e: print(e) print(traceback.format_exc()) - return {"data": None,"data_available":False,"error":Exception,"time_interval":60} + return { + "data": None, + "data_available": False, + "error": Exception, + "time_interval": 60, + } diff --git a/codegreen_core/data/main.py b/codegreen_core/data/main.py index c67c79a..de0fe22 100644 --- a/codegreen_core/data/main.py +++ b/codegreen_core/data/main.py @@ -1,12 +1,13 @@ import pandas as pd from datetime import datetime -from ..utilities.message import Message,CodegreenDataError -from ..utilities import metadata as meta +from ..utilities.message import Message, CodegreenDataError +from ..utilities import metadata as meta from . import entsoe as et -def energy(country,start_time,end_time,type="generation",interval60=True)-> dict: - """ + +def energy(country, start_time, end_time, type="generation", interval60=True) -> dict: + """ Returns hourly time series of energy production mix for a specified country and time range. This method fetches the energy data for the specified country between the specified duration. @@ -15,30 +16,30 @@ def energy(country,start_time,end_time,type="generation",interval60=True)-> dict For example, if the source is ENTSOE, the data contains: - ========================== ========== ================================================================ - Column type Description - ========================== ========== ================================================================ - startTimeUTC datetime Start date in UTC (60 min interval) - Biomass float64 - Fossil Hard coal float64 - Geothermal float64 - ....more energy sources float64 - **renewableTotal** float64 The total based on all renewable sources - renewableTotalWS float64 The total production using only Wind and Solar energy sources - nonRenewableTotal float64 - total float64 Total using all energy sources - percentRenewable int64 - percentRenewableWS int64 Percentage of energy produced using only wind and solar energy - Wind_per int64 Percentages of individual energy sources - Solar_per int64 - Nuclear_per int64 - Hydroelectricity_per int64 - Geothermal_per int64 - Natural Gas_per int64 - Petroleum_per int64 - Coal_per int64 - Biomass_per int64 - ========================== ========== ================================================================ + ========================== ========== ================================================================ + Column type Description + ========================== ========== ================================================================ + startTimeUTC datetime Start date in UTC (60 min interval) + Biomass float64 + Fossil Hard coal float64 + Geothermal float64 + ....more energy sources float64 + **renewableTotal** float64 The total based on all renewable sources + renewableTotalWS float64 The total production using only Wind and Solar energy sources + nonRenewableTotal float64 + total float64 Total using all energy sources + percentRenewable int64 + percentRenewableWS int64 Percentage of energy produced using only wind and solar energy + Wind_per int64 Percentages of individual energy sources + Solar_per int64 + Nuclear_per int64 + Hydroelectricity_per int64 + Geothermal_per int64 + Natural Gas_per int64 + Petroleum_per int64 + Coal_per int64 + Biomass_per int64 + ========================== ========== ================================================================ Note : fields marked bold are calculated based on the data fetched. @@ -53,25 +54,27 @@ def energy(country,start_time,end_time,type="generation",interval60=True)-> dict - `time_interval` : the time interval of the DataFrame :rtype: dict """ - if not isinstance(country, str): - raise ValueError("Invalid country") - if not isinstance(start_time,datetime): - raise ValueError("Invalid start date") - if not isinstance(end_time, datetime): - raise ValueError("Invalid end date") - if type not in ['generation', 'forecast']: - raise ValueError(Message.INVALID_ENERGY_TYPE) - # check start end_time): - raise ValueError("Invalid time.End time should be greater than start time") + if not isinstance(country, str): + raise ValueError("Invalid country") + if not isinstance(start_time, datetime): + raise ValueError("Invalid start date") + if not isinstance(end_time, datetime): + raise ValueError("Invalid end date") + if type not in ["generation", "forecast"]: + raise ValueError(Message.INVALID_ENERGY_TYPE) + # check start end_time: + raise ValueError("Invalid time.End time should be greater than start time") - e_source = meta.get_country_energy_source(country) - if e_source=="ENTSOE" : - if type == "generation": - return et.get_actual_production_percentage(country,start_time,end_time,interval60) - elif type == "forecast": - return et.get_forecast_percent_renewable(country,start_time,end_time) - else: - raise CodegreenDataError(Message.NO_ENERGY_SOURCE) - return None + e_source = meta.get_country_energy_source(country) + if e_source == "ENTSOE": + if type == "generation": + return et.get_actual_production_percentage( + country, start_time, end_time, interval60 + ) + elif type == "forecast": + return et.get_forecast_percent_renewable(country, start_time, end_time) + else: + raise CodegreenDataError(Message.NO_ENERGY_SOURCE) + return None diff --git a/codegreen_core/models/predict.py b/codegreen_core/models/predict.py index 9d443c2..15d16f6 100644 --- a/codegreen_core/models/predict.py +++ b/codegreen_core/models/predict.py @@ -12,52 +12,59 @@ # Path to the models directory models_dir = Path(__file__).parent / "files" + def predicted_energy(country): - # do the forecast from now , same return format as data.energy - return {"data":None} + # do the forecast from now , same return format as data.energy + return {"data": None} + # Function to load a specific model by name -def _load_prediction_model(country,version=None): +def _load_prediction_model(country, version=None): """Load a model by name""" - model_details = get_prediction_model_details(country,version) + model_details = get_prediction_model_details(country, version) model_path = models_dir / model_details["name"] print(model_path) if not model_path.exists(): raise FileNotFoundError(f"Model does not exist.") - - return load_model(model_path,compile=False) + return load_model(model_path, compile=False) -def _run(country,input,model_version=None): + +def _run(country, input, model_version=None): """Returns the prediction values""" - + seq_length = len(input) - date = input[['startTimeUTC']].copy() + date = input[["startTimeUTC"]].copy() # Convert 'startTimeUTC' column to datetime - date['startTimeUTC'] = pd.to_datetime(date['startTimeUTC']) + date["startTimeUTC"] = pd.to_datetime(date["startTimeUTC"]) # Get the last date value - last_date = date.iloc[-1]['startTimeUTC'] + last_date = date.iloc[-1]["startTimeUTC"] # Calculate the next hour next_hour = last_date + timedelta(hours=1) # Create a range of 48 hours starting from the next hour - next_48_hours = pd.date_range(next_hour, periods=48, freq='h') + next_48_hours = pd.date_range(next_hour, periods=48, freq="h") # Create a DataFrame with the next 48 hours next_48_hours_df = pd.DataFrame( - {'startTimeUTC': next_48_hours.strftime('%Y%m%d%H%M')}) - - model_details = get_prediction_model_details(country,model_version) - - lstm = load_prediction_model(country,model_version) #load_model(model_path,compile=False) + {"startTimeUTC": next_48_hours.strftime("%Y%m%d%H%M")} + ) + + model_details = get_prediction_model_details(country, model_version) + + lstm = load_prediction_model( + country, model_version + ) # load_model(model_path,compile=False) scaler = StandardScaler() - percent_renewable = input['percentRenewable'] + percent_renewable = input["percentRenewable"] forecast_values_total = [] prev_values_total = percent_renewable.values.flatten() for _ in range(48): scaled_prev_values_total = scaler.fit_transform( - prev_values_total.reshape(-1, 1)) - x_pred_total = scaled_prev_values_total[-( - seq_length-1):].reshape(1, (seq_length-1), 1) + prev_values_total.reshape(-1, 1) + ) + x_pred_total = scaled_prev_values_total[-(seq_length - 1) :].reshape( + 1, (seq_length - 1), 1 + ) # Make the prediction using the loaded model predicted_value_total = lstm.predict(x_pred_total, verbose=0) # Inverse transform the predicted value @@ -67,24 +74,29 @@ def _run(country,input,model_version=None): prev_values_total = prev_values_total[1:] # Create a DataFrame forecast_df = pd.DataFrame( - {'startTimeUTC': next_48_hours_df['startTimeUTC'], 'percentRenewableForecast': forecast_values_total}) - forecast_df["percentRenewableForecast"] = forecast_df["percentRenewableForecast"].round( - ).astype(int) - forecast_df['percentRenewableForecast'] = forecast_df['percentRenewableForecast'].apply( - lambda x: 0 if x <= 0 else x) - + { + "startTimeUTC": next_48_hours_df["startTimeUTC"], + "percentRenewableForecast": forecast_values_total, + } + ) + forecast_df["percentRenewableForecast"] = ( + forecast_df["percentRenewableForecast"].round().astype(int) + ) + forecast_df["percentRenewableForecast"] = forecast_df[ + "percentRenewableForecast" + ].apply(lambda x: 0 if x <= 0 else x) + input_percentage = input["percentRenewable"].tolist() input_start = input.iloc[0]["startTimeUTC"] - input_end = input.iloc[-1]["startTimeUTC"] - + input_end = input.iloc[-1]["startTimeUTC"] + return { "input": { "country": country, "model": model_details["name"], "percentRenewable": input_percentage, "start": input_start, - "end": input_end + "end": input_end, }, - "output": forecast_df + "output": forecast_df, } - diff --git a/codegreen_core/models/train.py b/codegreen_core/models/train.py index aa17912..e6cf34a 100644 --- a/codegreen_core/models/train.py +++ b/codegreen_core/models/train.py @@ -1 +1 @@ -# the code for model training comes here # todo later \ No newline at end of file +# the code for model training comes here # todo later diff --git a/codegreen_core/tools/carbon_emission.py b/codegreen_core/tools/carbon_emission.py index 3537809..859fd91 100644 --- a/codegreen_core/tools/carbon_emission.py +++ b/codegreen_core/tools/carbon_emission.py @@ -4,14 +4,15 @@ import matplotlib.dates as mdates from datetime import datetime, timedelta -from .carbon_intensity import compute_ci +from .carbon_intensity import compute_ci + def compute_ce( - server:dict, - start_time:datetime, + server: dict, + start_time: datetime, runtime_minutes: int, -)->tuple[float,pd.DataFrame]: - """ +) -> tuple[float, pd.DataFrame]: + """ Calculates the carbon footprint of a job, given its hardware configuration, time, and location. This method returns an hourly time series of the carbon emissions. @@ -19,7 +20,7 @@ def compute_ce( :param server: A dictionary containing the details about the server, including its hardware specifications. The dictionary should include the following keys: - + - `country` (str): The country code where the job was performed (required to fetch energy data). - `number_core` (int): The number of CPU cores. - `memory_gb` (float): The size of memory available in Gigabytes. @@ -35,33 +36,66 @@ def compute_ce( - (float): The total carbon footprint of the job in kilograms of CO2 equivalent. - (pandas.DataFrame): A DataFrame containing the hourly time series of carbon emissions. """ - - # Round to the nearest hour (in minutes) - # base valued taken from http://calculator.green-algorithms.org/ - + # Round to the nearest hour (in minutes) + # base valued taken from http://calculator.green-algorithms.org/ rounded_runtime_minutes = round(runtime_minutes / 60) * 60 end_time = start_time + timedelta(minutes=rounded_runtime_minutes) - ci_ts = compute_ci(server['country'], start_time, end_time) - ce_total,ce_df = compute_ce_from_energy(server,ci_ts) - return ce_total,ce_df + ci_ts = compute_ci(server["country"], start_time, end_time) + ce_total, ce_df = compute_ce_from_energy(server, ci_ts) + return ce_total, ce_df + + +def _compute_energy_used( + runtime_minutes, + number_core, + power_draw_core, + usage_factor_core, + mem_size_gb, + power_draw_mem, + PUE, +): + return round( + (runtime_minutes / 60) + * ( + number_core * power_draw_core * usage_factor_core + + mem_size_gb * power_draw_mem + ) + * PUE + * 0.001, + 2, + ) -def _compute_energy_used(runtime_minutes, number_core, power_draw_core, usage_factor_core, mem_size_gb, power_draw_mem, PUE): - return round((runtime_minutes/60)*(number_core * power_draw_core * usage_factor_core + mem_size_gb * power_draw_mem) * PUE * 0.001, 2) -def compute_savings_same_device(country_code,start_time_request,start_time_predicted,runtime,cpu_cores,cpu_memory): - ce_job1,ci1 = compute_ce(country_code,start_time_request,runtime,cpu_cores,cpu_memory) - ce_job2,ci2 = compute_ce(country_code,start_time_predicted,runtime,cpu_cores,cpu_memory) - return ce_job1-ce_job2 # ideally this should be positive todo what if this is negative?, make a note in the comments +def compute_savings_same_device( + country_code, + start_time_request, + start_time_predicted, + runtime, + cpu_cores, + cpu_memory, +): + ce_job1, ci1 = compute_ce( + country_code, start_time_request, runtime, cpu_cores, cpu_memory + ) + ce_job2, ci2 = compute_ce( + country_code, start_time_predicted, runtime, cpu_cores, cpu_memory + ) + return ( + ce_job1 - ce_job2 + ) # ideally this should be positive todo what if this is negative?, make a note in the comments -def compare_carbon_emissions(server1,server2,start_time1,start_time2,runtime_minutes): + +def compare_carbon_emissions( + server1, server2, start_time1, start_time2, runtime_minutes +): """ Compares the carbon emissions of running a job with the same duration on two different servers. :param server1: A dictionary containing the details of the first server's hardware and location specifications. Required keys include: - + - `country` (str): The country code for the server's location (used for energy data). - `number_core` (int): The number of CPU cores. - `memory_gb` (float): The memory available in Gigabytes. @@ -72,7 +106,7 @@ def compare_carbon_emissions(server1,server2,start_time1,start_time2,runtime_min :param server2: A dictionary containing the details of the second server's hardware and location specifications. Required keys are identical to those in `server1`: - + - `country` (str): The country code for the server's location. - `number_core` (int): The number of CPU cores. - `memory_gb` (float): The memory available in Gigabytes. @@ -91,9 +125,9 @@ def compare_carbon_emissions(server1,server2,start_time1,start_time2,runtime_min - `absolute_difference` (float): The absolute difference in emissions between the two servers. - `higher_emission_server` (str): Indicates which server has higher emissions ("server1" or "server2"). """ - ce1,ce1_ts =compute_ce(server1,start_time1,runtime_minutes) - ce2,ce2_ts = compute_ce(server2,start_time2,runtime_minutes) - abs_difference = ce2-ce1 + ce1, ce1_ts = compute_ce(server1, start_time1, runtime_minutes) + ce2, ce2_ts = compute_ce(server2, start_time2, runtime_minutes) + abs_difference = ce2 - ce1 if ce1 > ce2: higher_emission_server = "server1" elif ce2 > ce1: @@ -101,24 +135,21 @@ def compare_carbon_emissions(server1,server2,start_time1,start_time2,runtime_min else: higher_emission_server = "equal" - return ce1,ce2,abs_difference,higher_emission_server + return ce1, ce2, abs_difference, higher_emission_server -def compute_ce_from_energy( - server, - ci_data:pd.DataFrame - ): - - """ + +def compute_ce_from_energy(server, ci_data: pd.DataFrame): + """ Calculates the carbon footprint for energy consumption over a time series. This method returns an hourly time series of the carbon emissions. - The methodology is defined in the documentation. Note that the start and end - times for the computation are derived from the first and last rows of the + The methodology is defined in the documentation. Note that the start and end + times for the computation are derived from the first and last rows of the `ci_data` DataFrame. - :param server: A dictionary containing details about the server, including its hardware specifications. + :param server: A dictionary containing details about the server, including its hardware specifications. The dictionary should include: - + - `number_core` (int): The number of CPU cores. - `memory_gb` (float): The size of memory available in Gigabytes. - `power_draw_core` (float): Power draw of a computing core in Watts. @@ -126,9 +157,9 @@ def compute_ce_from_energy( - `power_draw_mem` (float): Power draw of memory in Watts. - `power_usage_efficiency` (float): Efficiency coefficient of the data center. - :param ci_data: A pandas DataFrame of energy consumption over time. + :param ci_data: A pandas DataFrame of energy consumption over time. The DataFrame should include the following columns: - + - `startTimeUTC` (datetime): The start time of each energy measurement in UTC. - `ci_default` (float): Carbon intensity values for the energy consumption. @@ -139,86 +170,128 @@ def compute_ce_from_energy( date_format = "%Y%m%d%H%M" # Year, Month, Day, Hour, Minute server_defaults = { - "power_draw_core":15.8, + "power_draw_core": 15.8, "usage_factor_core": 1, "power_draw_mem": 0.3725, - "power_usage_efficiency" : 1.6 + "power_usage_efficiency": 1.6, } - server = server_defaults | server # set defaults if not provided - + server = server_defaults | server # set defaults if not provided # to make sure startTimeUTC is in date format - if not pd.api.types.is_datetime64_any_dtype(ci_data['startTimeUTC']): - ci_data['startTimeUTC'] = pd.to_datetime(ci_data['startTimeUTC']) - - end = ci_data['startTimeUTC'].iloc[-1] - start = ci_data['startTimeUTC'].iloc[0] - - # note that the run time is calculated based on the energy data frame provided - time_diff = end-start - runtime_minutes = time_diff.total_seconds() / 60 - - energy_consumed = _compute_energy_used(runtime_minutes, server["number_core"], server["power_draw_core"], - server["usage_factor_core"], server["memory_gb"], server["power_draw_mem"], server["power_usage_efficiency"]) - - e_hour = energy_consumed/(runtime_minutes*60) # assuming equal energy usage throughout the computation + if not pd.api.types.is_datetime64_any_dtype(ci_data["startTimeUTC"]): + ci_data["startTimeUTC"] = pd.to_datetime(ci_data["startTimeUTC"]) + + end = ci_data["startTimeUTC"].iloc[-1] + start = ci_data["startTimeUTC"].iloc[0] + + # note that the run time is calculated based on the energy data frame provided + time_diff = end - start + runtime_minutes = time_diff.total_seconds() / 60 + + energy_consumed = _compute_energy_used( + runtime_minutes, + server["number_core"], + server["power_draw_core"], + server["usage_factor_core"], + server["memory_gb"], + server["power_draw_mem"], + server["power_usage_efficiency"], + ) + + e_hour = energy_consumed / ( + runtime_minutes * 60 + ) # assuming equal energy usage throughout the computation ci_data["carbon_emission"] = ci_data["ci_default"] * e_hour - ce = round(sum(ci_data["carbon_emission"]),4) # grams CO2 equivalent - return ce,ci_data + ce = round(sum(ci_data["carbon_emission"]), 4) # grams CO2 equivalent + return ce, ci_data -def _compute_ce_bulk(server,jobs): - for job in jobs : - job.end_time= job["start_time"] + timedelta(minutes=job["runtime_minutes"]) - - min_start_date = min(job['start_time'] for job in jobs) - max_end_date = max(job['end_time'] for job in jobs) +def _compute_ce_bulk(server, jobs): + for job in jobs: + job.end_time = job["start_time"] + timedelta(minutes=job["runtime_minutes"]) + + min_start_date = min(job["start_time"] for job in jobs) + max_end_date = max(job["end_time"] for job in jobs) # print(min_start_date) # print(max_end_date) - energy_data = compute_ci(server["country"],min_start_date,max_end_date) - energy_data['startTimeUTC'] = pd.to_datetime(energy_data['startTimeUTC']) - for job in jobs : - filtered_energy = energy_data[(energy_data['startTimeUTC'] >= job["start_time"]) & (energy_data['startTimeUTC'] <= job["end_time"])] - job["emissions"],temp = compute_ce_from_energy(filtered_energy,server["number_core"],server["memory_gb"],server["power_draw_core"],server["usage_factor_core"],server["power_draw_mem"],server["power_usage_efficiency"]) - return energy_data,jobs, min_start_date, max_end_date - -def plot_ce_jobs(server,jobs): - energy_data,jobs, min_start_date, max_end_date = _compute_ce_bulk(server,jobs) + energy_data = compute_ci(server["country"], min_start_date, max_end_date) + energy_data["startTimeUTC"] = pd.to_datetime(energy_data["startTimeUTC"]) + for job in jobs: + filtered_energy = energy_data[ + (energy_data["startTimeUTC"] >= job["start_time"]) + & (energy_data["startTimeUTC"] <= job["end_time"]) + ] + job["emissions"], temp = compute_ce_from_energy( + filtered_energy, + server["number_core"], + server["memory_gb"], + server["power_draw_core"], + server["usage_factor_core"], + server["power_draw_mem"], + server["power_usage_efficiency"], + ) + return energy_data, jobs, min_start_date, max_end_date + + +def plot_ce_jobs(server, jobs): + energy_data, jobs, min_start_date, max_end_date = _compute_ce_bulk(server, jobs) Color = { - "red":"#D6A99A", - "green":"#99D19C", - "blue":"#3DA5D9", - "yellow":"#E2C044", - "black":"#0F1A20" + "red": "#D6A99A", + "green": "#99D19C", + "blue": "#3DA5D9", + "yellow": "#E2C044", + "black": "#0F1A20", } fig, ax1 = plt.subplots(figsize=(10, 6)) plt.title("Green Energy and Jobs") - end = energy_data['startTimeUTC'].iloc[-1] - start = energy_data['startTimeUTC'].iloc[0] - ax1.plot(energy_data['startTimeUTC'], energy_data['percentRenewable'], color=Color['green'], label='Percentage of Renewable Energy') - ax1.set_xlabel('Time') - ax1.set_ylabel('% Renewable energy') - ax1.tick_params(axis='y') + end = energy_data["startTimeUTC"].iloc[-1] + start = energy_data["startTimeUTC"].iloc[0] + ax1.plot( + energy_data["startTimeUTC"], + energy_data["percentRenewable"], + color=Color["green"], + label="Percentage of Renewable Energy", + ) + ax1.set_xlabel("Time") + ax1.set_ylabel("% Renewable energy") + ax1.tick_params(axis="y") # Set x-axis to show dates properly - ax1.xaxis.set_major_formatter(mdates.DateFormatter('%d-%m %H:%M')) + ax1.xaxis.set_major_formatter(mdates.DateFormatter("%d-%m %H:%M")) plt.xticks(rotation=45) - + # # Create a second y-axis ax2 = ax1.twinx() # Define y-values for each job (e.g., 1 for Job A, 2 for Job B, etc.) for idx, job in enumerate(jobs): lbl = str(job["emissions"]) - ax2.plot([job['start_time'], job['end_time']], [idx+1 , idx+1], marker='o', linewidth=25,label=lbl,color=Color["blue"]) + ax2.plot( + [job["start_time"], job["end_time"]], + [idx + 1, idx + 1], + marker="o", + linewidth=25, + label=lbl, + color=Color["blue"], + ) # Calculate the midpoint for the text placement - labelpoint = job['start_time'] + (job['end_time'] - job['start_time']) / 2 # + timedelta(minutes=100) - ax2.text(labelpoint, idx+1, lbl, color='black', ha='center', va='center', fontsize=12) - + labelpoint = ( + job["start_time"] + (job["end_time"] - job["start_time"]) / 2 + ) # + timedelta(minutes=100) + ax2.text( + labelpoint, + idx + 1, + lbl, + color="black", + ha="center", + va="center", + fontsize=12, + ) + # Adjust y-axis labels to match the number of jobs ax2.set_yticks(range(1, len(jobs) + 1)) - + # Add legend and show the plot fig.tight_layout() # plt.legend(loc='lower right') - plt.show() \ No newline at end of file + plt.show() diff --git a/codegreen_core/tools/carbon_intensity.py b/codegreen_core/tools/carbon_intensity.py index 6abdaac..d5e67f5 100644 --- a/codegreen_core/tools/carbon_intensity.py +++ b/codegreen_core/tools/carbon_intensity.py @@ -2,6 +2,7 @@ from ..utilities.metadata import get_country_energy_source, get_default_ci_value from ..data import energy from datetime import datetime + base_carbon_intensity_values = { "codecarbon": { "values": { @@ -14,7 +15,7 @@ "Solar": 48, "Wind": 26, }, - "source": "https://mlco2.github.io/codecarbon/methodology.html#carbon-intensity (values in kb/MWh)" + "source": "https://mlco2.github.io/codecarbon/methodology.html#carbon-intensity (values in kb/MWh)", }, "ipcc_lifecycle_min": { "values": { @@ -25,9 +26,9 @@ "Hydroelectricity": 1, "Nuclear": 3.7, "Solar": 17.6, - "Wind": 7.5 + "Wind": 7.5, }, - "source": "https://www.ipcc.ch/site/assets/uploads/2018/02/ipcc_wg3_ar5_annex-iii.pdf#page=7" + "source": "https://www.ipcc.ch/site/assets/uploads/2018/02/ipcc_wg3_ar5_annex-iii.pdf#page=7", }, "ipcc_lifecycle_mean": { "values": { @@ -38,9 +39,9 @@ "Hydroelectricity": 24, "Nuclear": 12, "Solar": 38.6, - "Wind": 11.5 + "Wind": 11.5, }, - "source": "" + "source": "", }, "ipcc_lifecycle_max": { "values": { @@ -51,9 +52,9 @@ "Hydroelectricity": 2200, "Nuclear": 110, "Solar": 101, - "Wind": 45.5 + "Wind": 45.5, }, - "source": "" + "source": "", }, "eu_comm": { "values": { @@ -65,77 +66,99 @@ "Hydroelectricity": 19, "Nuclear": 24, "Solar": 40, - "Wind": 11 + "Wind": 11, }, - "source": "N. Scarlat, M. Prussi, and M. Padella, ‘Quantification of the carbon intensity of electricity produced and used in Europe’, Applied Energy, vol. 305, p. 117901, Jan. 2022, doi: 10.1016/j.apenergy.2021.117901." - } + "source": "N. Scarlat, M. Prussi, and M. Padella, 'Quantification of the carbon intensity of electricity produced and used in Europe', Applied Energy, vol. 305, p. 117901, Jan. 2022, doi: 10.1016/j.apenergy.2021.117901.", + }, } -def _calculate_weighted_sum(base,weight): + +def _calculate_weighted_sum(base, weight): """ Assuming weight are in percentage - weignt and base are dictionaries with the same keys + weignt and base are dictionaries with the same keys """ - return round(( - base.get("Coal",0)* weight.get("Coal_per",0) - + base.get("Petroleum",0) * weight.get("Petroleum_per",0) - + base.get("Biomass",0) * weight.get("Biomass_per",0) - + base.get("Natural Gas",0) * weight.get("Natural Gas_per",0) - + base.get("Geothermal",0) * weight.get("Geothermal_per",0) - + base.get("Hydroelectricity",0) * weight.get("Hydroelectricity_per",0) - + base.get("Nuclear",0) * weight.get("Nuclear_per",0) - + base.get("Solar",0) * weight.get("Solar_per",0) - + base.get("Wind",0) * weight.get("Wind_per",0))/100,2) + return round( + ( + base.get("Coal", 0) * weight.get("Coal_per", 0) + + base.get("Petroleum", 0) * weight.get("Petroleum_per", 0) + + base.get("Biomass", 0) * weight.get("Biomass_per", 0) + + base.get("Natural Gas", 0) * weight.get("Natural Gas_per", 0) + + base.get("Geothermal", 0) * weight.get("Geothermal_per", 0) + + base.get("Hydroelectricity", 0) * weight.get("Hydroelectricity_per", 0) + + base.get("Nuclear", 0) * weight.get("Nuclear_per", 0) + + base.get("Solar", 0) * weight.get("Solar_per", 0) + + base.get("Wind", 0) * weight.get("Wind_per", 0) + ) + / 100, + 2, + ) + def _calculate_ci_from_energy_mix(energy_mix): """ - To calculate multiple CI values for a data frame row (for the `apply` method) + To calculate multiple CI values for a data frame row (for the `apply` method) """ - methods = ["codecarbon","ipcc_lifecycle_min","ipcc_lifecycle_mean","ipcc_lifecycle_mean","ipcc_lifecycle_max","eu_comm"] + methods = [ + "codecarbon", + "ipcc_lifecycle_min", + "ipcc_lifecycle_mean", + "ipcc_lifecycle_mean", + "ipcc_lifecycle_max", + "eu_comm", + ] values = {} for m in methods: - sum = _calculate_weighted_sum(base_carbon_intensity_values[m]["values"],energy_mix) - values[str("ci_"+m)] = sum + sum = _calculate_weighted_sum( + base_carbon_intensity_values[m]["values"], energy_mix + ) + values[str("ci_" + m)] = sum return values -def compute_ci(country:str,start_time:datetime,end_time:datetime)-> pd.DataFrame: - """ - Computes carbon intensity data for a given country and time period. - - If energy data is available, the carbon intensity is calculated from actual energy data for the specified time range. - If energy data is not available for the country, a default carbon intensity value is used instead. - The default CI values for all countries are stored in utilities/ci_default_values.csv. - - """ - if not isinstance(country, str): - raise ValueError("Invalid country") - - if not isinstance(start_time, datetime): - raise ValueError("Invalid start_time") - - if not isinstance(end_time, datetime): - raise ValueError("Invalid end_time") - - e_source = get_country_energy_source(country) - if e_source=="ENTSOE" : - data = energy(country,start_time,end_time) - energy_data = data["data"] - ci_values = compute_ci_from_energy(energy_data) - return ci_values - else: - time_series = pd.date_range(start=start_time, end=end_time, freq='H') - df = pd.DataFrame(time_series, columns=['startTimeUTC']) - df["ci_default"] = get_default_ci_value(country) - return df - -def compute_ci_from_energy(energy_data:pd.DataFrame,default_method="ci_ipcc_lifecycle_mean",base_values:dict=None)-> pd.DataFrame: - """ - Given the energy time series, computes the carbon intensity for each row. + +def compute_ci(country: str, start_time: datetime, end_time: datetime) -> pd.DataFrame: + """ + Computes carbon intensity data for a given country and time period. + + If energy data is available, the carbon intensity is calculated from actual energy data for the specified time range. + If energy data is not available for the country, a default carbon intensity value is used instead. + The default CI values for all countries are stored in utilities/ci_default_values.csv. + + """ + if not isinstance(country, str): + raise ValueError("Invalid country") + + if not isinstance(start_time, datetime): + raise ValueError("Invalid start_time") + + if not isinstance(end_time, datetime): + raise ValueError("Invalid end_time") + + e_source = get_country_energy_source(country) + if e_source == "ENTSOE": + data = energy(country, start_time, end_time) + energy_data = data["data"] + ci_values = compute_ci_from_energy(energy_data) + return ci_values + else: + time_series = pd.date_range(start=start_time, end=end_time, freq="H") + df = pd.DataFrame(time_series, columns=["startTimeUTC"]) + df["ci_default"] = get_default_ci_value(country) + return df + + +def compute_ci_from_energy( + energy_data: pd.DataFrame, + default_method="ci_ipcc_lifecycle_mean", + base_values: dict = None, +) -> pd.DataFrame: + """ + Given the energy time series, computes the carbon intensity for each row. You can choose the base value from several sources available or use your own base values. - :param energy_data: A pandas DataFrame that must include the following columns, representing + :param energy_data: A pandas DataFrame that must include the following columns, representing the percentage of energy generated from each source: - + - `Coal_per` (float): Percentage of energy generated from coal. - `Petroleum_per` (float): Percentage of energy generated from petroleum. - `Biomass_per` (float): Percentage of energy generated from biomass. @@ -146,18 +169,18 @@ def compute_ci_from_energy(energy_data:pd.DataFrame,default_method="ci_ipcc_life - `Solar_per` (float): Percentage of energy generated from solar sources. - `Wind_per` (float): Percentage of energy generated from wind sources. - :param default_method: This parameter allows you to choose the base values for each energy source. + :param default_method: This parameter allows you to choose the base values for each energy source. By default, the IPCC lifecycle mean values are used. Available options include: - + - `codecarbon` (Ref [6]) - `ipcc_lifecycle_min` (Ref [5]) - `ipcc_lifecycle_mean` (default) - `ipcc_lifecycle_max` - `eu_comm` (Ref [4]) - - :param base_values(optional): A dictionary of custom base carbon intensity values for energy sources. + + :param base_values(optional): A dictionary of custom base carbon intensity values for energy sources. Must include the following keys: - + - `Coal` (float): Base carbon intensity value for coal. - `Petroleum` (float): Base carbon intensity value for petroleum. - `Biomass` (float): Base carbon intensity value for biomass. @@ -171,17 +194,20 @@ def compute_ci_from_energy(energy_data:pd.DataFrame,default_method="ci_ipcc_life if not isinstance(energy_data, pd.DataFrame): raise ValueError("Invalid energy data.") - + if not isinstance(default_method, str): raise ValueError("Invalid default_method") - if base_values: - energy_data['ci_default'] = energy_data.apply(lambda row: _calculate_weighted_sum(row.to_dict(),base_values), axis=1) + energy_data["ci_default"] = energy_data.apply( + lambda row: _calculate_weighted_sum(row.to_dict(), base_values), axis=1 + ) return energy_data else: - ci_values = energy_data.apply(lambda row: _calculate_ci_from_energy_mix(row.to_dict()),axis=1) + ci_values = energy_data.apply( + lambda row: _calculate_ci_from_energy_mix(row.to_dict()), axis=1 + ) ci = pd.DataFrame(ci_values.tolist()) - ci = pd.concat([ci,energy_data],axis=1) + ci = pd.concat([ci, energy_data], axis=1) ci["ci_default"] = ci[default_method] return ci diff --git a/codegreen_core/tools/loadshift_location.py b/codegreen_core/tools/loadshift_location.py index be67890..debd4e5 100644 --- a/codegreen_core/tools/loadshift_location.py +++ b/codegreen_core/tools/loadshift_location.py @@ -3,24 +3,38 @@ from ..data import energy from ..utilities.message import CodegreenDataError -def predict_optimal_location_now(country_list:list,estimated_runtime_hours:int,estimated_runtime_minutes:int,percent_renewable:int,hard_finish_date:datetime)->tuple: - """ + +def predict_optimal_location_now( + country_list: list, + estimated_runtime_hours: int, + estimated_runtime_minutes: int, + percent_renewable: int, + hard_finish_date: datetime, +) -> tuple: + """ Given a list of countries, returns the best location where a computation can be run based on the input criteria """ print() # first get data - start_time = datetime.now() - forecast_data = {} # will contain energy data for each country for which data is available + start_time = datetime.now() + forecast_data = ( + {} + ) # will contain energy data for each country for which data is available for country in country_list: try: print(country) - energy_data = energy(country,start_time,hard_finish_date,"forecast") + energy_data = energy(country, start_time, hard_finish_date, "forecast") forecast_data[country] = energy_data["data"] except CodegreenDataError as c: print(c) # print(forecast_data) - return predict_optimal_location( forecast_data, estimated_runtime_hours, estimated_runtime_minutes, percent_renewable,hard_finish_date) - + return predict_optimal_location( + forecast_data, + estimated_runtime_hours, + estimated_runtime_minutes, + percent_renewable, + hard_finish_date, + ) def predict_optimal_location( @@ -29,7 +43,7 @@ def predict_optimal_location( estimated_runtime_minutes, percent_renewable, hard_finish_date, - request_date=None + request_date=None, ): """ Determines the optimal location and time to run a computation using energy data of the selected locations @@ -40,7 +54,14 @@ def predict_optimal_location( best_country = "UTOPIA" for country in forecast_data: print(country) - optimal_start, message, avg_percentage_renewable = predict_optimal_time(forecast_data[country],estimated_runtime_hours,estimated_runtime_minutes,percent_renewable,hard_finish_date,request_date) + optimal_start, message, avg_percentage_renewable = predict_optimal_time( + forecast_data[country], + estimated_runtime_hours, + estimated_runtime_minutes, + percent_renewable, + hard_finish_date, + request_date, + ) best = { "optimal_start": optimal_start, "message": message, diff --git a/codegreen_core/tools/loadshift_time.py b/codegreen_core/tools/loadshift_time.py index b1d0639..89d4fac 100644 --- a/codegreen_core/tools/loadshift_time.py +++ b/codegreen_core/tools/loadshift_time.py @@ -2,70 +2,75 @@ from dateutil import tz import numpy as np import pandas as pd + # from greenerai.api.data.utils import Message from ..utilities.message import Message from ..utilities.metadata import check_prediction_model_exists from ..utilities.caching import get_cache_or_update -from ..data import energy -from ..models.predict import predicted_energy +from ..data import energy +from ..models.predict import predicted_energy from ..utilities.config import Config import redis import json import traceback + # ========= the main methods ============ -def _get_energy_data(country,start,end): + +def _get_energy_data(country, start, end): """ - Get energy data and check if it must be cached based on the options set + Get energy data and check if it must be cached based on the options set Check the country data file if models exists """ energy_mode = Config.get("default_energy_mode") - if Config.get("enable_energy_caching")==True: - # check prediction is enabled : get cache or update prediction - try : + if Config.get("enable_energy_caching") == True: + # check prediction is enabled : get cache or update prediction + try: # what if this fails ? - forecast = get_cache_or_update(country, start, end,energy_mode) + forecast = get_cache_or_update(country, start, end, energy_mode) forecast_data = pd.DataFrame(forecast["data"]) return forecast_data - except Exception as e : + except Exception as e: print(traceback.format_exc()) - else: - if energy_mode =="local_prediction": + else: + if energy_mode == "local_prediction": if check_prediction_model_exists(country): forecast = predicted_energy(country) else: # prediction models do not exists , fallback to energy forecasts from public_data - forecast = energy(country,start,end,"forecast") + forecast = energy(country, start, end, "forecast") elif energy_mode == "public_data": - forecast = energy(country,start,end,"forecast") - else : + forecast = energy(country, start, end, "forecast") + else: return None return forecast["data"] + def predict_now( - country: str, - estimated_runtime_hours: int, - estimated_runtime_minutes:int, - hard_finish_date:datetime, - criteria:str = "percent_renewable", - percent_renewable: int = 50)->tuple: + country: str, + estimated_runtime_hours: int, + estimated_runtime_minutes: int, + hard_finish_date: datetime, + criteria: str = "percent_renewable", + percent_renewable: int = 50, +) -> tuple: """ - Predicts optimal computation time in the given location starting now + Predicts optimal computation time in the given location starting now - :param country: The country code + :param country: The country code :type country: str :param estimated_runtime_hours: The estimated runtime in hours :type estimated_runtime_hours: int - :param estimated_runtime_minutes: The estimated runtime in minutes + :param estimated_runtime_minutes: The estimated runtime in minutes :type estimated_runtime_minutes: int - :param hard_finish_date: The latest possible finish time for the task. Datetime object in local time zone + :param hard_finish_date: The latest possible finish time for the task. Datetime object in local time zone :type hard_finish_date: datetime :param criteria: Criteria based on which optimal time is calculated. Valid value "percent_renewable" or "optimal_percent_renewable" :type criteria: str :param percent_renewable: The minimum percentage of renewable energy desired during the runtime - :type percent_renewable: int + :type percent_renewable: int :return: Tuple[timestamp, message, average_percent_renewable] :rtype: tuple """ @@ -73,15 +78,15 @@ def predict_now( try: start_time = datetime.now() # print(start_time,hard_finish_date) - energy_data = _get_energy_data(country,start_time,hard_finish_date) + energy_data = _get_energy_data(country, start_time, hard_finish_date) # print(energy_data) - if energy_data is not None : + if energy_data is not None: return predict_optimal_time( energy_data, estimated_runtime_hours, estimated_runtime_minutes, percent_renewable, - hard_finish_date + hard_finish_date, ) else: return _default_response(Message.ENERGY_DATA_FETCHING_ERROR) @@ -91,7 +96,9 @@ def predict_now( else: return _default_response(Message.INVALID_PREDICTION_CRITERIA) -# ======= Optimal prediction part ========= + +# ======= Optimal prediction part ========= + def predict_optimal_time( energy_data: pd.DataFrame, @@ -99,83 +106,89 @@ def predict_optimal_time( estimated_runtime_minutes: int, percent_renewable: int, hard_finish_date: datetime, - request_time : datetime = None + request_time: datetime = None, ) -> tuple: """ Predicts the optimal time window to run a task based in energy data, run time estimates and renewable energy target. :param energy_data: A DataFrame containing the energy data including startTimeUTC, totalRenewable,total,percent_renewable,posix_timestamp :param estimated_runtime_hours: The estimated runtime in hours - :param estimated_runtime_minutes: The estimated runtime in minutes + :param estimated_runtime_minutes: The estimated runtime in minutes :param percent_renewable: The minimum percentage of renewable energy desired during the runtime - :param hard_finish_date: The latest possible finish time for the task. + :param hard_finish_date: The latest possible finish time for the task. :param request_time: The time at which the prediction is requested. Defaults to None, then the current time is used. Assumed to be in local timezone :return: Tuple[timestamp, message, average_percent_renewable] :rtype: tuple """ - granularity = 60 # assuming that the granularity of time series is 60 minutes - + granularity = 60 # assuming that the granularity of time series is 60 minutes + # ============ data validation ========= - if not isinstance(hard_finish_date,datetime): + if not isinstance(hard_finish_date, datetime): raise ValueError("Invalid hard_finish_date. it must be a datetime object") if request_time is not None: - if not isinstance(request_time,datetime): + if not isinstance(request_time, datetime): raise ValueError("Invalid request_time. it must be a datetime object") if energy_data is None: - return _default_response(Message.NO_DATA,request_time) + return _default_response(Message.NO_DATA, request_time) if percent_renewable <= 0: - return _default_response(Message.NEGATIVE_PERCENT_RENEWABLE,request_time) + return _default_response(Message.NEGATIVE_PERCENT_RENEWABLE, request_time) if estimated_runtime_hours <= 0: # since energy data is for 60 min interval, it does not make sense to optimize jobs less than an hour - return _default_response(Message.INVALID_DATA,request_time) + return _default_response(Message.INVALID_DATA, request_time) if estimated_runtime_minutes < 0: - # min val can be 0 - return _default_response(Message.INVALID_DATA,request_time) - + # min val can be 0 + return _default_response(Message.INVALID_DATA, request_time) + total_runtime_in_minutes = estimated_runtime_hours * 60 + estimated_runtime_minutes if total_runtime_in_minutes <= 0: - return _default_response(Message.ZERO_OR_NEGATIVE_RUNTIME,request_time) - + return _default_response(Message.ZERO_OR_NEGATIVE_RUNTIME, request_time) + if request_time is not None: - # request time is provided in local time zone, first convert to utc then use it - req_time_utc = request_time.astimezone(tz.tzutc()) - else : - # request time is current time in utc - req_time_utc = datetime.now(timezone.utc) - + # request time is provided in local time zone, first convert to utc then use it + req_time_utc = request_time.astimezone(tz.tzutc()) + else: + # request time is current time in utc + req_time_utc = datetime.now(timezone.utc) + # if req_time_utc.minute >= granularity/2 : # current_time = (request_time_utc - timedelta(minutes=granularity)).timestamp() # else : # current_time = (request_time_utc).timestamp() - + current_time_hour = req_time_utc.replace(minute=0, second=0, microsecond=0) - current_time = int(current_time_hour.timestamp() ) + current_time = int(current_time_hour.timestamp()) - # dial back by 60 minutes to avoid waiting unnecessarily for the next full quarterhour. + # dial back by 60 minutes to avoid waiting unnecessarily for the next full quarterhour. # current_time = int((datetime.now(timezone.utc) - timedelta(minutes=granularity)).timestamp()) # current time is unix timestamp - estimated_finish_hour = current_time_hour + timedelta(minutes=total_runtime_in_minutes) - estimated_finish_time = int(estimated_finish_hour.timestamp()) # unix timestamp + estimated_finish_hour = current_time_hour + timedelta( + minutes=total_runtime_in_minutes + ) + estimated_finish_time = int(estimated_finish_hour.timestamp()) # unix timestamp - print(req_time_utc,current_time_hour,estimated_finish_hour) - # hard_finish_date is in local time zone so it's converted to timestamp + print(req_time_utc, current_time_hour, estimated_finish_hour) + # hard_finish_date is in local time zone so it's converted to timestamp if estimated_finish_time >= int(hard_finish_date.timestamp()): - return _default_response(Message.RUNTIME_LONGER_THAN_DEADLINE_ALLOWS,request_time) + return _default_response( + Message.RUNTIME_LONGER_THAN_DEADLINE_ALLOWS, request_time + ) # ========== the predication part =========== - # this is to make the old code from the web repo compatible with the new one. TODO refine it + # this is to make the old code from the web repo compatible with the new one. TODO refine it my_predictions = energy_data # Reduce data to the relevant time frame my_predictions = my_predictions[my_predictions["posix_timestamp"] >= current_time] - my_predictions = my_predictions[my_predictions["posix_timestamp"] <= hard_finish_date.timestamp()] + my_predictions = my_predictions[ + my_predictions["posix_timestamp"] <= hard_finish_date.timestamp() + ] # Possible that data has not been reported if my_predictions.shape[0] == 0: - return _default_response(Message.NO_DATA,request_time) + return _default_response(Message.NO_DATA, request_time) my_predictions = my_predictions.reset_index() # needs to be computed every time, because when time runs, the number of @@ -197,8 +210,8 @@ def predict_optimal_time( # index of starting time fullfilling the requirements time_slot = my_predictions[column_name].ge(time_units).argmax() - (time_units - 1) - #print("time_slot is: " + str(time_slot)) - #print("time_slot is: " + str(time_slot)) + # print("time_slot is: " + str(time_slot)) + # print("time_slot is: " + str(time_slot)) # print(f"time_slot = {time_slot}") # print(f"timeunits: {time_units}") @@ -222,9 +235,9 @@ def predict_optimal_time( for potential_time in potential_times: if potential_times[potential_time]["time_index"] >= 0: - potential_times[potential_time][ - "avg_percentage_renewable" - ] = my_predictions["rolling_average_pr"][time_slot + time_units - 1] + potential_times[potential_time]["avg_percentage_renewable"] = ( + my_predictions["rolling_average_pr"][time_slot + time_units - 1] + ) if ( 0 @@ -266,16 +279,17 @@ def _optimal_response(my_predictions, time_slot, time_units): return timestamp, message, average_percent_renewable -def _default_response(message,request_time=None): +def _default_response(message, request_time=None): average_percent_renewable = 0 - if request_time is None : + if request_time is None: timestamp = int(datetime.now(timezone.utc).timestamp()) - else : + else: # request time in local time is converted to utc timestamp timestamp = int(request_time.timestamp()) - + return timestamp, message, average_percent_renewable + def _compute_percentages(my_predictions, percent_renewable): """ Compute the percentage of renewables requested. diff --git a/codegreen_core/utilities/__init__.py b/codegreen_core/utilities/__init__.py index 5c72e30..30dfd8c 100644 --- a/codegreen_core/utilities/__init__.py +++ b/codegreen_core/utilities/__init__.py @@ -1 +1 @@ -from . import metadata \ No newline at end of file +from . import metadata diff --git a/codegreen_core/utilities/caching.py b/codegreen_core/utilities/caching.py index 20ae36e..d89f202 100644 --- a/codegreen_core/utilities/caching.py +++ b/codegreen_core/utilities/caching.py @@ -1,8 +1,8 @@ from datetime import datetime, timedelta, timezone from dateutil import tz import pandas as pd -from ..data import energy -from ..models.predict import predicted_energy +from ..data import energy +from ..models.predict import predicted_energy from .config import Config from .metadata import check_prediction_model_exists import redis @@ -10,10 +10,12 @@ import traceback import warnings -def _get_country_key(country_code,energy_mode="pubic_data"): - return "codegreen_optimal_"+energy_mode+"_"+country_code -def get_cache_or_update(country, start, deadline,energy_mode="public_data"): +def _get_country_key(country_code, energy_mode="pubic_data"): + return "codegreen_optimal_" + energy_mode + "_" + country_code + + +def get_cache_or_update(country, start, deadline, energy_mode="public_data"): """ The cache contains an entry for every country. It holds the country code, the last update time, the timestamp of the last entry and the data time series. @@ -22,44 +24,52 @@ def get_cache_or_update(country, start, deadline,energy_mode="public_data"): it attempts to pull the data from ENTSOE, if the last update time is at least one hour earlier. """ cache = redis.from_url(Config.get("energy_redis_path")) - if cache.exists(_get_country_key(country,energy_mode)): + if cache.exists(_get_country_key(country, energy_mode)): print("cache has country") - json_string = cache.get(_get_country_key(country,energy_mode)).decode("utf-8") + json_string = cache.get(_get_country_key(country, energy_mode)).decode("utf-8") data_object = json.loads(json_string) - last_prediction_time = datetime.fromtimestamp(data_object["last_prediction"], tz=timezone.utc) - deadline_time = deadline.astimezone(timezone.utc) # datetime.strptime("202308201230", "%Y%m%d%H%M").replace(tzinfo=timezone.utc) - last_cache_update_time = datetime.fromtimestamp(data_object["last_updated"], tz=timezone.utc) - current_time_plus_one = datetime.now(timezone.utc)+timedelta(hours=-1) - # utc_dt = utc_dt.astimezone(timezone.utc) + last_prediction_time = datetime.fromtimestamp( + data_object["last_prediction"], tz=timezone.utc + ) + deadline_time = deadline.astimezone( + timezone.utc + ) # datetime.strptime("202308201230", "%Y%m%d%H%M").replace(tzinfo=timezone.utc) + last_cache_update_time = datetime.fromtimestamp( + data_object["last_updated"], tz=timezone.utc + ) + current_time_plus_one = datetime.now(timezone.utc) + timedelta(hours=-1) + # utc_dt = utc_dt.astimezone(timezone.utc) # print(data_object) if data_object["data_available"] and last_prediction_time > deadline_time: return data_object else: - # check if the last update has been at least one hour earlier, + # check if the last update has been at least one hour earlier, if last_cache_update_time < current_time_plus_one: print("cache must be updated") - return _pull_data(country, start, deadline,energy_mode) + return _pull_data(country, start, deadline, energy_mode) else: return data_object else: print("caches has no country, calling _pull_data(country, start, deadline)") - return _pull_data(country, start, deadline,energy_mode) + return _pull_data(country, start, deadline, energy_mode) -def _pull_data(country, start, end,energy_mode="public_data"): +def _pull_data(country, start, end, energy_mode="public_data"): """Fetches the data and updates the cache""" print("_pull_data function started") try: cache = redis.from_url(Config.get("energy_redis_path")) if energy_mode == "public_data": - forecast_data = energy(country,start,end,"forecast") + forecast_data = energy(country, start, end, "forecast") elif energy_mode == "local_prediction": - if check_prediction_model_exists(country): - forecast_data = predicted_energy(country) + if check_prediction_model_exists(country): + forecast_data = predicted_energy(country) else: - warnings.warn("Predication model for "+country+" do not exist in the system.") - return None - else : + warnings.warn( + "Predication model for " + country + " do not exist in the system." + ) + return None + else: return None last_update = datetime.now().timestamp() if forecast_data["data_available"]: @@ -68,8 +78,8 @@ def _pull_data(country, start, end,energy_mode="public_data"): last_prediction = pd.Timestamp(datetime.now(), tz="UTC") df = forecast_data["data"] - df['startTimeUTC'] = pd.to_datetime(df['startTimeUTC']) - df['startTimeUTC'] = df['startTimeUTC'].dt.strftime('%Y%m%d%H%M').astype("str") + df["startTimeUTC"] = pd.to_datetime(df["startTimeUTC"]) + df["startTimeUTC"] = df["startTimeUTC"].dt.strftime("%Y%m%d%H%M").astype("str") cached_object = { "data": df.to_dict(), "time_interval": forecast_data["time_interval"], @@ -77,7 +87,7 @@ def _pull_data(country, start, end,energy_mode="public_data"): "last_updated": int(last_update), "last_prediction": int(last_prediction), } - cache.set(_get_country_key(country,energy_mode), json.dumps(cached_object)) + cache.set(_get_country_key(country, energy_mode), json.dumps(cached_object)) return cached_object except Exception as e: diff --git a/codegreen_core/utilities/config.py b/codegreen_core/utilities/config.py index 6ffb881..18f60ff 100644 --- a/codegreen_core/utilities/config.py +++ b/codegreen_core/utilities/config.py @@ -1,55 +1,63 @@ import os import configparser import redis + + class ConfigError(Exception): """Custom exception for configuration errors.""" + pass + class Config: - config_data = None - section_name="codegreen" - boolean_keys = {"enable_energy_caching","enable_time_prediction_logging"} - defaults = {"default_energy_mode":"public_data","enable_energy_caching":False} - @classmethod - def load_config(self,file_path=None): - """ to load configurations from the user config file - """ - config_file_name = ".codegreencore.config" - config_locations = [ - os.path.join(os.path.expanduser("~"),config_file_name), - os.path.join(os.getcwd(),config_file_name) - ] - for loc in config_locations: - if os.path.isfile(loc): - file_path = loc - break - - if file_path is None: - raise ConfigError("404 config") - - self.config_data = configparser.ConfigParser() - self.config_data.read(file_path) - - if self.get("enable_energy_caching") == True : - if self.get("energy_redis_path") is None : - raise ConfigError("Invalid configuration. If 'enable_energy_caching' is set, 'energy_redis_path' is also required ") - else: - r = redis.from_url(self.get("energy_redis_path")) - r.ping() - - @classmethod - def get(self,key): - if not self.config_data.sections(): - raise ConfigError("Configuration not loaded. Please call 'load_config' first.") - try: - value = self.config_data.get(self.section_name,key) - if value is None: - #if key not in self.defaults: - # raise KeyError(f"No default value provided for key: {key}") - value = self.defaults.get(key,None) - else: - if key in self.boolean_keys: - value = value.lower() == "true" - return value - except (configparser.NoSectionError, configparser.NoOptionError): - return None + config_data = None + section_name = "codegreen" + boolean_keys = {"enable_energy_caching", "enable_time_prediction_logging"} + defaults = {"default_energy_mode": "public_data", "enable_energy_caching": False} + + @classmethod + def load_config(self, file_path=None): + """to load configurations from the user config file""" + config_file_name = ".codegreencore.config" + config_locations = [ + os.path.join(os.path.expanduser("~"), config_file_name), + os.path.join(os.getcwd(), config_file_name), + ] + for loc in config_locations: + if os.path.isfile(loc): + file_path = loc + break + + if file_path is None: + raise ConfigError("404 config") + + self.config_data = configparser.ConfigParser() + self.config_data.read(file_path) + + if self.get("enable_energy_caching") == True: + if self.get("energy_redis_path") is None: + raise ConfigError( + "Invalid configuration. If 'enable_energy_caching' is set, 'energy_redis_path' is also required " + ) + else: + r = redis.from_url(self.get("energy_redis_path")) + r.ping() + + @classmethod + def get(self, key): + if not self.config_data.sections(): + raise ConfigError( + "Configuration not loaded. Please call 'load_config' first." + ) + try: + value = self.config_data.get(self.section_name, key) + if value is None: + # if key not in self.defaults: + # raise KeyError(f"No default value provided for key: {key}") + value = self.defaults.get(key, None) + else: + if key in self.boolean_keys: + value = value.lower() == "true" + return value + except (configparser.NoSectionError, configparser.NoOptionError): + return None diff --git a/codegreen_core/utilities/log.py b/codegreen_core/utilities/log.py index 795995c..d545531 100644 --- a/codegreen_core/utilities/log.py +++ b/codegreen_core/utilities/log.py @@ -7,18 +7,20 @@ def time_prediction(data): - if Config.get("enable_time_prediction_logging")==True: - current_date = datetime.now() - file_name = f"{current_date.strftime('%B')}_{current_date.year}.csv" - file_location = os.path.join(Config.get("time_prediction_log_folder_path"), file_name) - file_exists = os.path.exists(file_location) - # Open the file in append mode - with open(file_location, mode='a', newline='') as file: - writer = csv.DictWriter(file, fieldnames=data.keys()) - # If the file doesn't exist, write the header - if not file_exists: - writer.writeheader() - # Append the data to the file - writer.writerow(data) - else: - print("Logging not enabled") \ No newline at end of file + if Config.get("enable_time_prediction_logging") == True: + current_date = datetime.now() + file_name = f"{current_date.strftime('%B')}_{current_date.year}.csv" + file_location = os.path.join( + Config.get("time_prediction_log_folder_path"), file_name + ) + file_exists = os.path.exists(file_location) + # Open the file in append mode + with open(file_location, mode="a", newline="") as file: + writer = csv.DictWriter(file, fieldnames=data.keys()) + # If the file doesn't exist, write the header + if not file_exists: + writer.writeheader() + # Append the data to the file + writer.writerow(data) + else: + print("Logging not enabled") diff --git a/codegreen_core/utilities/message.py b/codegreen_core/utilities/message.py index d0fe2cb..23c4cfb 100644 --- a/codegreen_core/utilities/message.py +++ b/codegreen_core/utilities/message.py @@ -1,18 +1,20 @@ from enum import Enum -# this mod contains all the messages in the system + +# this mod contains all the messages in the system class Message(Enum): OPTIMAL_TIME = "OPTIMAL_TIME" NO_DATA = "NO_DATA" - RUNTIME_LONGER_THAN_DEADLINE_ALLOWS = "RUNTIME_LONGER_THAN_DEADLINE_ALLOWS", + RUNTIME_LONGER_THAN_DEADLINE_ALLOWS = ("RUNTIME_LONGER_THAN_DEADLINE_ALLOWS",) COUNTRY_404 = "COUNTRY_404" - INVALID_PREDICTION_CRITERIA = "INVALID_PREDICTION_CRITERIA" # valid criteria : "percent_renewable","carbon_intensity" + INVALID_PREDICTION_CRITERIA = "INVALID_PREDICTION_CRITERIA" # valid criteria : "percent_renewable","carbon_intensity" ZERO_OR_NEGATIVE_RUNTIME = "ZERO_OR_NEGATIVE_RUNTIME" NEGATIVE_PERCENT_RENEWABLE = "NEGATIVE_PERCENT_RENEWABLE" INVALID_ENERGY_TYPE = "INVALID_ENERGY_TYPE" - NO_ENERGY_SOURCE = "No energy source found for the country", - INVALID_DATA = "Invalid data provided", + NO_ENERGY_SOURCE = ("No energy source found for the country",) + INVALID_DATA = ("Invalid data provided",) ENERGY_DATA_FETCHING_ERROR = "Error in fetching energy data for the country" + class CodegreenDataError(Exception): - pass \ No newline at end of file + pass diff --git a/codegreen_core/utilities/metadata.py b/codegreen_core/utilities/metadata.py index 13e011e..6c51c54 100644 --- a/codegreen_core/utilities/metadata.py +++ b/codegreen_core/utilities/metadata.py @@ -1,70 +1,75 @@ -import json +import json import pandas as pd from pathlib import Path + current_dir = Path(__file__).parent + def get_country_metadata(): - """ - This method returns the "country_metadata.json" metadata file stored in the data folder. - This file contains a list of countries for which codegreen can fetch the required data to perform further calculations. - the key is the country code and the value contains - - country name - - energy_source : the source that can be used to fetch energy data for this country - - as of now we support fetching energy data from the ENTSOE portal for countries in the European Union - - carbon_intensity_method : this is the methodology to be used to calculate the CI values based on the energy fetched - - the current methodologies supported are described in "carbon_intensity.py" file - """ - json_file_path = current_dir / 'country_list.json' - with open(json_file_path, 'r') as json_file: - data = json.load(json_file) - return data['available'] + """ + This method returns the "country_metadata.json" metadata file stored in the data folder. + This file contains a list of countries for which codegreen can fetch the required data to perform further calculations. + the key is the country code and the value contains + - country name + - energy_source : the source that can be used to fetch energy data for this country + - as of now we support fetching energy data from the ENTSOE portal for countries in the European Union + - carbon_intensity_method : this is the methodology to be used to calculate the CI values based on the energy fetched + - the current methodologies supported are described in "carbon_intensity.py" file + """ + json_file_path = current_dir / "country_list.json" + with open(json_file_path, "r") as json_file: + data = json.load(json_file) + return data["available"] + def get_country_energy_source(country_code): - """ - Returns the energy source (if available) to gather energy data. These values are stored in the "country_metadata.json" file. - If the energy source does not exists, None is returned - """ - metadata = get_country_metadata() - if country_code in metadata.keys(): - return metadata[country_code]["energy_source"] - else : - return None - -def get_default_ci_value(country_code): - """ - This method returns the default average Carbon Intensity for a given country. These values are sourced from the International Electricity Factors, - https://www.carbonfootprint.com/international_electricity_factors.html (accessed 5 July 2024) and are stored in the "ci_default_value.csv" file. - """ - csv_file_path = current_dir / "ci_default_values.csv" - data = pd.read_csv(csv_file_path) - row = data.loc[data['code'] == country_code] - if not row.empty: - val = row.iloc[0]['kgCO2e_per_kWh'] - return val - else : - return None + """ + Returns the energy source (if available) to gather energy data. These values are stored in the "country_metadata.json" file. + If the energy source does not exists, None is returned + """ + metadata = get_country_metadata() + if country_code in metadata.keys(): + return metadata[country_code]["energy_source"] + else: + return None + -def get_prediction_model_details(country,version=None): - """Returns details about the energy forecast prediction model for the given country and version (latest version by default)""" - metadata = get_country_metadata() - if country in metadata.keys(): - if version is None : - if len(metadata[country]["models"])==0: - raise("No models exists") - return metadata[country]["models"][len(metadata[country]["models"])-1] +def get_default_ci_value(country_code): + """ + This method returns the default average Carbon Intensity for a given country. These values are sourced from the International Electricity Factors, + https://www.carbonfootprint.com/international_electricity_factors.html (accessed 5 July 2024) and are stored in the "ci_default_value.csv" file. + """ + csv_file_path = current_dir / "ci_default_values.csv" + data = pd.read_csv(csv_file_path) + row = data.loc[data["code"] == country_code] + if not row.empty: + val = row.iloc[0]["kgCO2e_per_kWh"] + return val else: - filter = next([d for d in metadata[country]["models"]],None) - if filter in None: - raise "Version does not exists" - return filter - else: - raise "Country not defined" - + return None + + +def get_prediction_model_details(country, version=None): + """Returns details about the energy forecast prediction model for the given country and version (latest version by default)""" + metadata = get_country_metadata() + if country in metadata.keys(): + if version is None: + if len(metadata[country]["models"]) == 0: + raise ("No models exists") + return metadata[country]["models"][len(metadata[country]["models"]) - 1] + else: + filter = next([d for d in metadata[country]["models"]], None) + if filter in None: + raise "Version does not exists" + return filter + else: + raise "Country not defined" + def check_prediction_model_exists(country): - """Checks if predication models exists for the give country""" - try: - m = get_prediction_model_details(country) - return m is not None - except Exception as e: - return False \ No newline at end of file + """Checks if predication models exists for the give country""" + try: + m = get_prediction_model_details(country) + return m is not None + except Exception as e: + return False diff --git a/docs/_extensions/country_table_extension.py b/docs/_extensions/country_table_extension.py index a296490..4b9f8a2 100644 --- a/docs/_extensions/country_table_extension.py +++ b/docs/_extensions/country_table_extension.py @@ -4,42 +4,50 @@ import json from datetime import datetime + class ProductsTableDirective(Directive): has_content = True def run(self): env = self.state.document.settings.env - json_path = os.path.join(env.srcdir, '../codegreen_core/utilities/country_list.json') + json_path = os.path.join( + env.srcdir, "../codegreen_core/utilities/country_list.json" + ) # Read and parse the JSON file - with open(json_path, 'r') as file: + with open(json_path, "r") as file: full_data = json.load(file) data = [] for key in full_data["available"]: c = full_data["available"][key] - data.append({"name": c["country"], "code":key ,"source":c["energy_source"]}) + data.append( + {"name": c["country"], "code": key, "source": c["energy_source"]} + ) # Create a note node with the generation date note = nodes.note() paragraph = nodes.paragraph() - date_str = datetime.now().strftime('%Y-%m-%d') - paragraph += nodes.Text(f"The following table is automatically generated from 'codegreen_core.utilities.country_list.json' on {date_str}") + date_str = datetime.now().strftime("%Y-%m-%d") + paragraph += nodes.Text( + f"The following table is automatically generated from 'codegreen_core.utilities.country_list.json' on {date_str}" + ) note += paragraph - list_node = nodes.bullet_list() for country in data: # Create a list item for the country list_item = nodes.list_item() paragraph = nodes.paragraph() paragraph += nodes.Text(f"{country['name']} (") - paragraph += nodes.literal(text=country['code']) # Inline code block for the country code + paragraph += nodes.literal( + text=country["code"] + ) # Inline code block for the country code paragraph += nodes.Text(f")") list_item += paragraph # Create a nested list for the "Source" item - if 'source' in country: + if "source" in country: nested_list = nodes.bullet_list() nested_item = nodes.list_item() nested_paragraph = nodes.paragraph() @@ -50,8 +58,9 @@ def run(self): # Add the country list item to the main list list_node += list_item - + return [note, list_node] + def setup(app): - app.add_directive('country_table', ProductsTableDirective) + app.add_directive("country_table", ProductsTableDirective) diff --git a/docs/conf.py b/docs/conf.py index 7acc7dc..3af2104 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,32 +6,46 @@ import os import sys -sys.path.insert(0, os.path.abspath('../')) # Adjust the path to your package location + +sys.path.insert(0, os.path.abspath("../")) # Adjust the path to your package location # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -project = 'codegreen_core' -copyright = '2024, Dr. Anne Hartebrodt' -author = 'Dr. Anne Hartebrodt' +project = "codegreen_core" +copyright = "2024, Dr. Anne Hartebrodt" +author = "Dr. Anne Hartebrodt" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -templates_path = ['_templates'] +templates_path = ["_templates"] exclude_patterns = [] -autodoc_mock_imports = ["redis","pandas","entsoe","dateutil","tensorflow","numpy","sklearn","matplotlib"] +autodoc_mock_imports = [ + "redis", + "pandas", + "entsoe", + "dateutil", + "tensorflow", + "numpy", + "sklearn", + "matplotlib", +] -extensions = ['sphinx.ext.autodoc','docs._extensions.country_table_extension','sphinx.ext.mathjax'] +extensions = [ + "sphinx.ext.autodoc", + "docs._extensions.country_table_extension", + "sphinx.ext.mathjax", +] # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = 'alabaster' -html_static_path = ['_static'] +html_theme = "alabaster" +html_static_path = ["_static"] # import codegreen_core diff --git a/docs/plot.py b/docs/plot.py index 2f1c542..d6ff718 100644 --- a/docs/plot.py +++ b/docs/plot.py @@ -1,142 +1,172 @@ -from datetime import datetime , timedelta +from datetime import datetime, timedelta import pandas as pd import matplotlib.pyplot as plt + # from codegreen_core.tools.carbon_intensity import calculate_from_energy_data -#from codegreen_core.tools.carbon_emission import calculate_carbon_footprint_job +# from codegreen_core.tools.carbon_emission import calculate_carbon_footprint_job from codegreen_core.data import energy from codegreen_core.tools.loadshift_time import predict_optimal_time import matplotlib.dates as mdates Color = { - "red":"#D6A99A", - "green":"#99D19C", - "blue":"#3DA5D9", - "yellow":"#E2C044", - "black":"#0F1A20" + "red": "#D6A99A", + "green": "#99D19C", + "blue": "#3DA5D9", + "yellow": "#E2C044", + "black": "#0F1A20", } -def plot_percentage_clean(df,country,save_fig_path=None): - df['startTimeUTC'] = pd.to_datetime(df['startTimeUTC']) - df["percentNonRenewable"] = round(((df["total"]-df["renewableTotal"])/df["total"])*100) +def plot_percentage_clean(df, country, save_fig_path=None): + df["startTimeUTC"] = pd.to_datetime(df["startTimeUTC"]) + df["percentNonRenewable"] = round( + ((df["total"] - df["renewableTotal"]) / df["total"]) * 100 + ) - df['hour'] = df['startTimeUTC'].dt.strftime('%H:%M') + df["hour"] = df["startTimeUTC"].dt.strftime("%H:%M") - date_start = df['startTimeUTC'].min().strftime('%Y-%m-%d') - date_end = df['startTimeUTC'].max().strftime('%Y-%m-%d') + date_start = df["startTimeUTC"].min().strftime("%Y-%m-%d") + date_end = df["startTimeUTC"].max().strftime("%Y-%m-%d") time_range_label = f"Time ({date_start} - {date_end})" - + # Create the plot - fig, ax = plt.subplots(figsize=(12,4)) - + fig, ax = plt.subplots(figsize=(12, 4)) + # Bar width bar_width = 0.85 bar_positions = range(len(df)) # Plot each bar for i, (index, row) in enumerate(df.iterrows()): - hour = row['hour'] - renewable = row['percentRenewable'] - non_renewable = row['percentNonRenewable'] - + hour = row["hour"] + renewable = row["percentRenewable"] + non_renewable = row["percentNonRenewable"] + # Plotting bars for renewable and non-renewable - ax.bar(i, renewable, bar_width, color=Color["green"],edgecolor=Color["green"]) - ax.bar(i, non_renewable, bar_width, bottom=renewable, color=Color['red'],edgecolor=Color["red"]) + ax.bar(i, renewable, bar_width, color=Color["green"], edgecolor=Color["green"]) + ax.bar( + i, + non_renewable, + bar_width, + bottom=renewable, + color=Color["red"], + edgecolor=Color["red"], + ) # Set x-ticks to be the hours if len(df) > 74: ax.set_xticks([]) # Hide x-ticks if too many entries - ax.set_xlabel('') # Remove x-label if too many entries + ax.set_xlabel("") # Remove x-label if too many entries else: ax.set_xticks(bar_positions) - ax.set_xticklabels(df['hour'], rotation=90, fontsize=7) + ax.set_xticklabels(df["hour"], rotation=90, fontsize=7) ax.set_xlabel(time_range_label) - - ax.set_ylabel('Percentage') - ax.set_title('Energy Generation Breakdown: Renewable and Non-Renewable by Hour ('+country+')') + + ax.set_ylabel("Percentage") + ax.set_title( + "Energy Generation Breakdown: Renewable and Non-Renewable by Hour (" + + country + + ")" + ) # ax.legend() - if save_fig_path : - plt.savefig(save_fig_path, dpi=300, bbox_inches='tight') - + if save_fig_path: + plt.savefig(save_fig_path, dpi=300, bbox_inches="tight") + plt.tight_layout() plt.show() - - - - -def plot_multiple_percentage_clean(dfs, labels,save_fig_path=None): +def plot_multiple_percentage_clean(dfs, labels, save_fig_path=None): num_dfs = len(dfs) num_cols = 2 # Number of columns in the subplot grid num_rows = (num_dfs + num_cols - 1) // num_cols # Compute number of rows needed - - fig, axes = plt.subplots(num_rows, num_cols, figsize=(15 * num_rows, 5 * num_rows), squeeze=False) - fig.suptitle('Energy Generation Breakdown: Renewable and Non-Renewable by Hour', fontsize=17, y=1) # Adjust y for positioning + + fig, axes = plt.subplots( + num_rows, num_cols, figsize=(15 * num_rows, 5 * num_rows), squeeze=False + ) + fig.suptitle( + "Energy Generation Breakdown: Renewable and Non-Renewable by Hour", + fontsize=17, + y=1, + ) # Adjust y for positioning # Flatten the axes array for easy iteration axes = axes.flatten() - + for i, (df, label) in enumerate(zip(dfs, labels)): ax = axes[i] - - # Ensure 'startTimeUTC' is in datetime format - df['startTimeUTC'] = pd.to_datetime(df['startTimeUTC']) - df["percentNonRenewable"] = round(((df["total"] - df["renewableTotal"]) / df["total"]) * 100) - df['hour'] = df['startTimeUTC'].dt.strftime('%H:%M') - date_start = df['startTimeUTC'].min().strftime('%Y-%m-%d') - date_end = df['startTimeUTC'].max().strftime('%Y-%m-%d') + # Ensure 'startTimeUTC' is in datetime format + df["startTimeUTC"] = pd.to_datetime(df["startTimeUTC"]) + df["percentNonRenewable"] = round( + ((df["total"] - df["renewableTotal"]) / df["total"]) * 100 + ) + df["hour"] = df["startTimeUTC"].dt.strftime("%H:%M") + + date_start = df["startTimeUTC"].min().strftime("%Y-%m-%d") + date_end = df["startTimeUTC"].max().strftime("%Y-%m-%d") time_range_label = f"Time ({date_start} - {date_end})" - + # Bar width bar_width = 0.85 bar_positions = range(len(df)) # Plot each bar for index, row in df.iterrows(): - hour = row['hour'] - renewable = row['percentRenewable'] - non_renewable = row['percentNonRenewable'] - + hour = row["hour"] + renewable = row["percentRenewable"] + non_renewable = row["percentNonRenewable"] + # Plotting bars for renewable and non-renewable - ax.bar(index, renewable, bar_width, color=Color["green"], edgecolor=Color["green"]) - ax.bar(index, non_renewable, bar_width, bottom=renewable, color=Color["red"], edgecolor=Color["red"]) + ax.bar( + index, + renewable, + bar_width, + color=Color["green"], + edgecolor=Color["green"], + ) + ax.bar( + index, + non_renewable, + bar_width, + bottom=renewable, + color=Color["red"], + edgecolor=Color["red"], + ) # Set x-ticks to be the hours - + if len(df) > 74: ax.set_xticks([]) # Hide x-ticks if too many entries - ax.set_xlabel('') # Remove x-label if too many entries + ax.set_xlabel("") # Remove x-label if too many entries else: ax.set_xticks(bar_positions) - ax.set_xticklabels(df['hour'], rotation=90, fontsize=7) + ax.set_xticklabels(df["hour"], rotation=90, fontsize=7) - ax.set_xlabel(time_range_label) - ax.set_ylabel('Percentage') - ax.set_title( label) - + ax.set_ylabel("Percentage") + ax.set_title(label) + # Hide any unused subplots for j in range(i + 1, len(axes)): - axes[j].axis('off') - - if save_fig_path : - plt.savefig(save_fig_path, dpi=300, bbox_inches='tight') + axes[j].axis("off") + + if save_fig_path: + plt.savefig(save_fig_path, dpi=300, bbox_inches="tight") plt.tight_layout() plt.show() -def show_clean_energy(country,start,end,save_fig_path=None): +def show_clean_energy(country, start, end, save_fig_path=None): """note that these plots are based on actual energy production and not the forecasts""" - d = energy(country,start,end) + d = energy(country, start, end) actual1 = d["data"] - plot_percentage_clean(actual1,country,save_fig_path) + plot_percentage_clean(actual1, country, save_fig_path) -def show_clean_energy_multiple(countries,start,end,save_fig_path=None): +def show_clean_energy_multiple(countries, start, end, save_fig_path=None): data = [] - for c in countries : - data.append(energy(c,start,end)["data"]) - plot_multiple_percentage_clean(data,countries,save_fig_path) + for c in countries: + data.append(energy(c, start, end)["data"]) + plot_multiple_percentage_clean(data, countries, save_fig_path) diff --git a/pyproject.toml b/pyproject.toml index 0bf10cb..c704169 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ scikit-learn = "^1.5.2" [tool.poetry.group.dev.dependencies] pytest = "^8.3.3" Sphinx = "^8.1.3" +black = "^24.10.0" [build-system] requires = ["poetry-core"] diff --git a/tests/get_data.py b/tests/get_data.py index 30b5a06..0c3a975 100644 --- a/tests/get_data.py +++ b/tests/get_data.py @@ -1,30 +1,33 @@ -# this file contains the methods to fetch country data to be used to test prediction times +# this file contains the methods to fetch country data to be used to test prediction times from codegreen_core.data import energy from codegreen_core.utilities.metadata import get_country_metadata -from codegreen_core.data.entsoe import renewableSources,nonRenewableSources +from codegreen_core.data.entsoe import renewableSources, nonRenewableSources from datetime import datetime import pandas as pd import numpy as np import traceback -def gen_test_case(start,end,label): - country_list = get_country_metadata() - cases = [] - for ci in country_list.keys(): - cdata = country_list[ci] - cdata["country"] = ci - cdata["start_time"] = start - cdata["end_time"]= end - cdata["file"] = ci+label - cases.append(cdata) - return cases + +def gen_test_case(start, end, label): + country_list = get_country_metadata() + cases = [] + for ci in country_list.keys(): + cdata = country_list[ci] + cdata["country"] = ci + cdata["start_time"] = start + cdata["end_time"] = end + cdata["file"] = ci + label + cases.append(cdata) + return cases + def fetch_data(case): - data = energy(case["country"],case["start_time"],case["end_time"])["data"] - data.to_csv("./data/"+case["file"]+".csv") - print(case["file"]) + data = energy(case["country"], case["start_time"], case["end_time"])["data"] + data.to_csv("./data/" + case["file"] + ".csv") + print(case["file"]) + # test_cases_1 = gen_test_case(datetime(2024,1,1),datetime(2024,1,5),"1") # for c in test_cases_1: @@ -34,98 +37,107 @@ def fetch_data(case): # for c in test_cases_2: # print(c) # fetch_data(c) - + + def test_cases_3(): - cases = [ - { - "country":"GR", - "start_time":datetime(2024,1,1), - "end_time":datetime(2024,6,30), - "file":"GR3" - }, - { - "country":"LT", - "start_time":datetime(2024,1,1), - "end_time":datetime(2024,6,30), - "file":"LT3" - }, - { - "country":"DE", - "start_time":datetime(2024,1,1), - "end_time":datetime(2024,6,30), - "file":"DE3" - } - ] - for c in cases: - fetch_data(c) + cases = [ + { + "country": "GR", + "start_time": datetime(2024, 1, 1), + "end_time": datetime(2024, 6, 30), + "file": "GR3", + }, + { + "country": "LT", + "start_time": datetime(2024, 1, 1), + "end_time": datetime(2024, 6, 30), + "file": "LT3", + }, + { + "country": "DE", + "start_time": datetime(2024, 1, 1), + "end_time": datetime(2024, 6, 30), + "file": "DE3", + }, + ] + for c in cases: + fetch_data(c) # test_cases_3() + # Defining a function to convert and format the datetime def convert_format(date_str): # Convert string to datetime - date_time_obj = datetime.strptime(date_str, '%d.%m.%Y %H:%M') + date_time_obj = datetime.strptime(date_str, "%d.%m.%Y %H:%M") # Format datetime object to the desired format - return date_time_obj.strftime('%Y%m%d%H%M') - -def compute_rrs_error(downloaded,fetched): - d = pd.read_csv("./data/"+downloaded+".csv") - d[['startTimeUTC', 'end']] = d['MTU'].str.extract(r'(\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}) - (\d{2}\.\d{2}\.\d{4} \d{2}:\d{2})') - # Applying the conversion function to the start and end columns - d['startTimeUTC'] = d['startTimeUTC'].apply(convert_format) - d['startTimeUTC'] = d['startTimeUTC'].astype('int64') - d['end'] = d['end'].apply(convert_format) - f = pd.read_csv("./data/"+fetched+".csv") - all_e = set(renewableSources + nonRenewableSources) - e_cols = set(f.columns.tolist()) - e_present = list(all_e & e_cols) - combined = f.merge(d,on="startTimeUTC") - summary = {} - for e in e_present: - #print(f.iloc[0][e]) - d_col = e+" - Actual Aggregated [MW]" - res_col = "residual-"+e - combined[res_col] = combined[d_col] - combined[e] - summary[e] = np.sqrt(np.sum(combined[res_col])) - #print(d.iloc[0][d_col]) - print(summary) - return summary - -#compute_rrs_error("gr_24_actual_downloaded","GR3") -#compute_rrs_error("de_24_actual_downloaded","DE3") -#compute_rrs_error("lt_24_actual_downloaded","LT3") + return date_time_obj.strftime("%Y%m%d%H%M") + + +def compute_rrs_error(downloaded, fetched): + d = pd.read_csv("./data/" + downloaded + ".csv") + d[["startTimeUTC", "end"]] = d["MTU"].str.extract( + r"(\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}) - (\d{2}\.\d{2}\.\d{4} \d{2}:\d{2})" + ) + # Applying the conversion function to the start and end columns + d["startTimeUTC"] = d["startTimeUTC"].apply(convert_format) + d["startTimeUTC"] = d["startTimeUTC"].astype("int64") + d["end"] = d["end"].apply(convert_format) + f = pd.read_csv("./data/" + fetched + ".csv") + all_e = set(renewableSources + nonRenewableSources) + e_cols = set(f.columns.tolist()) + e_present = list(all_e & e_cols) + combined = f.merge(d, on="startTimeUTC") + summary = {} + for e in e_present: + # print(f.iloc[0][e]) + d_col = e + " - Actual Aggregated [MW]" + res_col = "residual-" + e + combined[res_col] = combined[d_col] - combined[e] + summary[e] = np.sqrt(np.sum(combined[res_col])) + # print(d.iloc[0][d_col]) + print(summary) + return summary + + +# compute_rrs_error("gr_24_actual_downloaded","GR3") +# compute_rrs_error("de_24_actual_downloaded","DE3") +# compute_rrs_error("lt_24_actual_downloaded","LT3") def get_forecast_for_testing(): - try : - dates1 = [ - [datetime(2024,1,5),datetime(2024,1,10),1], - [datetime(2024,3,15),datetime(2024,3,20),3], - [datetime(2024,5,10),datetime(2024,5,15),5], - [datetime(2024,8,1),datetime(2024,8,10),8] - ] - clist = gen_test_case(datetime(2024,7,5),datetime(2024,7,10),"") - test_data = pd.DataFrame() - for c in clist : - for r in dates1: - try: - data = energy(c["country"],r[0],r[1],type="forecast") - print(c["country"]," ",r[2]) - # data["data"].to_csv("data/"+c["country"]+str(r[2])+"_forecast.csv") - data["data"]["file_id"] = c["country"]+str(r[2]) - print(data) - test_data = pd.concat([test_data,data["data"]], ignore_index=True) - except Exception as e: - print(traceback.format_exc()) - print(e) - - test_data.to_csv("data/prediction_testing_data.csv") - except Exception : - print(Exception) + try: + dates1 = [ + [datetime(2024, 1, 5), datetime(2024, 1, 10), 1], + [datetime(2024, 3, 15), datetime(2024, 3, 20), 3], + [datetime(2024, 5, 10), datetime(2024, 5, 15), 5], + [datetime(2024, 8, 1), datetime(2024, 8, 10), 8], + ] + clist = gen_test_case(datetime(2024, 7, 5), datetime(2024, 7, 10), "") + test_data = pd.DataFrame() + for c in clist: + for r in dates1: + try: + data = energy(c["country"], r[0], r[1], type="forecast") + print(c["country"], " ", r[2]) + # data["data"].to_csv("data/"+c["country"]+str(r[2])+"_forecast.csv") + data["data"]["file_id"] = c["country"] + str(r[2]) + print(data) + test_data = pd.concat([test_data, data["data"]], ignore_index=True) + except Exception as e: + print(traceback.format_exc()) + print(e) + + test_data.to_csv("data/prediction_testing_data.csv") + except Exception: + print(Exception) + # get_forecast_for_testing() -data = energy("DE",datetime(2024,9,11),datetime(2024,9,12),"generation",False)["data"] -print(data) \ No newline at end of file +data = energy("DE", datetime(2024, 9, 11), datetime(2024, 9, 12), "generation", False)[ + "data" +] +print(data) diff --git a/tests/test1_predictions.py b/tests/test1_predictions.py index 6f0d342..952ccb3 100644 --- a/tests/test1_predictions.py +++ b/tests/test1_predictions.py @@ -1,12 +1,9 @@ -# this code is not yet used +# this code is not yet used from codegreen_core.models import predict from codegreen_core.data import energy from datetime import datetime -e = energy("SE",datetime(2024,1,2),datetime(2024,1,3))["data"] +e = energy("SE", datetime(2024, 1, 2), datetime(2024, 1, 3))["data"] # print(e) -forecasts = predict.run("SE",e) +forecasts = predict.run("SE", e) print(forecasts) - - - diff --git a/tests/test_carbon_intensity.py b/tests/test_carbon_intensity.py index 0fa0ae0..a1ea625 100644 --- a/tests/test_carbon_intensity.py +++ b/tests/test_carbon_intensity.py @@ -2,28 +2,28 @@ from datetime import datetime import codegreen_core.tools.carbon_intensity as ci + class TestCarbonIntensity: def test_if_incorrect_data_provided1(self): - with pytest.raises(ValueError): - ci.compute_ci("DE",datetime(2024,1,2),"2024,1,1") + with pytest.raises(ValueError): + ci.compute_ci("DE", datetime(2024, 1, 2), "2024,1,1") def test_if_incorrect_data_provided2(self): - with pytest.raises(ValueError): - ci.compute_ci("DE",123,datetime(2024,1,2)) - + with pytest.raises(ValueError): + ci.compute_ci("DE", 123, datetime(2024, 1, 2)) + def test_if_incorrect_data_provided3(self): - with pytest.raises(ValueError): - ci.compute_ci(123,datetime(2024,1,2),datetime(2024,1,3)) + with pytest.raises(ValueError): + ci.compute_ci(123, datetime(2024, 1, 2), datetime(2024, 1, 3)) def test_if_incorrect_data_provided4(self): - with pytest.raises(ValueError): - ci.compute_ci_from_energy("DE",datetime(2024,1,2),"2024,1,1") + with pytest.raises(ValueError): + ci.compute_ci_from_energy("DE", datetime(2024, 1, 2), "2024,1,1") def test_if_incorrect_data_provided5(self): - with pytest.raises(ValueError): - ci.compute_ci_from_energy("DE",123,datetime(2024,1,2)) - - def test_if_incorrect_data_provided6(self): - with pytest.raises(ValueError): - ci.compute_ci_from_energy(123,datetime(2024,1,2),datetime(2024,1,3)) + with pytest.raises(ValueError): + ci.compute_ci_from_energy("DE", 123, datetime(2024, 1, 2)) + def test_if_incorrect_data_provided6(self): + with pytest.raises(ValueError): + ci.compute_ci_from_energy(123, datetime(2024, 1, 2), datetime(2024, 1, 3)) diff --git a/tests/test_data.py b/tests/test_data.py index ee62f67..9256888 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -5,110 +5,135 @@ from datetime import datetime import pandas as pd + class TestEnergyData: - def test_valid_country(self): - with pytest.raises(ValueError): - energy(91,datetime(2024,1,1),datetime(2024,1,2)) - - def test_valid_starttime(self): - with pytest.raises(ValueError): - energy("DE","2024,1,1",datetime(2024,1,2)) - - def test_valid_endtime(self): - with pytest.raises(ValueError): - energy("DE",datetime(2024,1,2),"2024,1,1") + def test_valid_country(self): + with pytest.raises(ValueError): + energy(91, datetime(2024, 1, 1), datetime(2024, 1, 2)) + + def test_valid_starttime(self): + with pytest.raises(ValueError): + energy("DE", "2024,1,1", datetime(2024, 1, 2)) + + def test_valid_endtime(self): + with pytest.raises(ValueError): + energy("DE", datetime(2024, 1, 2), "2024,1,1") + + def test_valid_time(self): + with pytest.raises(ValueError): + energy("DE", datetime(2024, 1, 2), datetime(2020, 1, 1)) + + def test_valid_type(self): + with pytest.raises(ValueError): + energy("DE", datetime(2024, 1, 1), datetime(2024, 1, 2), "magic") + + def test_country_no_vaild_energy_source(self): + with pytest.raises(CodegreenDataError): + energy("IN", datetime(2024, 1, 1), datetime(2024, 1, 2)) - def test_valid_time(self): - with pytest.raises(ValueError): - energy("DE",datetime(2024,1,2),datetime(2020,1,1)) - - def test_valid_type(self): - with pytest.raises(ValueError): - energy("DE",datetime(2024,1,1),datetime(2024,1,2),"magic") + def test_entsoe_generation_data(self): + cases = [ + { + "country": "DE", + "start": datetime(2024, 2, 1), + "end": datetime(2024, 2, 2), + "dtype": "generation", + "file": "tests/data/generation_DE_24_downloaded.csv", + "interval60": False, + }, + { + "country": "DE", + "start": datetime(2024, 3, 20), + "end": datetime(2024, 3, 24), + "dtype": "generation", + "file": "tests/data/generation_DE_24_downloaded.csv", + "interval60": False, + }, + # { + # "country":"DE", + # "start":datetime(2024,1,1), + # "end":datetime(2024,1,5), + # "dtype": 'generation' , + # "file": "data/DE_24_generation_downloaded.csv", + # "interval60": False, + # "note":"this has issues,Hydro Pumped Storage values do not match " + # }, + { + "country": "GR", + "start": datetime(2024, 3, 20), + "end": datetime(2024, 3, 24), + "dtype": "generation", + "file": "tests/data/generation_GR_24_downloaded.csv", + "interval60": True, + }, + { + "country": "GR", + "start": datetime(2024, 1, 25), + "end": datetime(2024, 1, 28), + "dtype": "generation", + "file": "tests/data/generation_GR_24_downloaded.csv", + "interval60": True, + }, + ] + for case in cases: + # intervals = int((case["end"].replace(minute=0, second=0, microsecond=0) - case["start"].replace(minute=0, second=0, microsecond=0)).total_seconds() // 3600) + # print(intervals) + if case["dtype"] == "generation": + d = energy( + case["country"], + case["start"], + case["end"], + case["dtype"], + case["interval60"], + ) + data = d["data"] + data_verify = pd.read_csv(case["file"]) + data_verify["start_date"] = data_verify["MTU"].str.split(" - ").str[0] + data_verify["end_date"] = ( + data_verify["MTU"] + .str.split(" - ") + .str[1] + .str.replace(" (UTC)", "", regex=False) + ) + data_verify["start_date"] = pd.to_datetime( + data_verify["start_date"], format="%d.%m.%Y %H:%M" + ) + data_verify["end_date"] = pd.to_datetime( + data_verify["end_date"], format="%d.%m.%Y %H:%M" + ) + start_utc = pd.to_datetime( + case["start"] + ) # case["start"].astimezone(pd.Timestamp.now(tz='UTC').tzinfo) if case["start"].tzinfo is None else case["start"] + end_utc = pd.to_datetime( + case["end"] + ) # case["end"].astimezone(pd.Timestamp.now(tz='UTC').tzinfo) if case["end"].tzinfo is None else case["end"] + filtered_df = data_verify[ + (data_verify["start_date"] >= start_utc) + & (data_verify["start_date"] < end_utc) + ] + allCols = data.columns.tolist() + renPresent = list(set(allCols).intersection(renewableSources)) + for e in renPresent: + difference = filtered_df[e + " - Actual Aggregated [MW]"] - data[e] + sum_of_differences = difference.sum() + print(e) + print(sum_of_differences) + print(filtered_df[e + " - Actual Aggregated [MW]"].to_list()) + print(data[e].to_list()) + print(difference.to_list()) + print("===") + assert sum_of_differences == 0.0 + # else : + # print("") - def test_country_no_vaild_energy_source(self): - with pytest.raises(CodegreenDataError): - energy("IN",datetime(2024,1,1),datetime(2024,1,2)) + def check_return_value_actual(self): + actual = energy("DE", datetime(2024, 1, 1), datetime(2024, 1, 2)) + assert isinstance(actual, dict) - def test_entsoe_generation_data(self): - cases = [ - { - "country":"DE", - "start":datetime(2024,2,1), - "end":datetime(2024,2,2), - "dtype": 'generation' , - "file": "tests/data/generation_DE_24_downloaded.csv", - "interval60": False - }, - { - "country":"DE", - "start":datetime(2024,3,20), - "end":datetime(2024,3,24), - "dtype": 'generation' , - "file": "tests/data/generation_DE_24_downloaded.csv", - "interval60": False - }, - # { - # "country":"DE", - # "start":datetime(2024,1,1), - # "end":datetime(2024,1,5), - # "dtype": 'generation' , - # "file": "data/DE_24_generation_downloaded.csv", - # "interval60": False, - # "note":"this has issues,Hydro Pumped Storage values do not match " - # }, - { - "country":"GR", - "start":datetime(2024,3,20), - "end":datetime(2024,3,24), - "dtype": 'generation' , - "file": "tests/data/generation_GR_24_downloaded.csv", - "interval60": True - }, - { - "country":"GR", - "start":datetime(2024,1,25), - "end":datetime(2024,1,28), - "dtype": 'generation' , - "file": "tests/data/generation_GR_24_downloaded.csv", - "interval60": True - } + def check_return_value_actual(self): + forecast = energy("DE", datetime(2024, 1, 1), datetime(2024, 1, 2), "forecast") + assert isinstance(forecast, dict) - ] - for case in cases: - # intervals = int((case["end"].replace(minute=0, second=0, microsecond=0) - case["start"].replace(minute=0, second=0, microsecond=0)).total_seconds() // 3600) - # print(intervals) - if case["dtype"]=="generation": - d = energy(case["country"],case["start"],case["end"],case["dtype"],case["interval60"]) - data = d["data"] - data_verify = pd.read_csv(case["file"]) - data_verify['start_date'] = data_verify['MTU'].str.split(' - ').str[0] - data_verify['end_date'] = data_verify['MTU'].str.split(' - ').str[1].str.replace(' (UTC)', '', regex=False) - data_verify['start_date'] = pd.to_datetime(data_verify['start_date'], format='%d.%m.%Y %H:%M') - data_verify['end_date'] = pd.to_datetime(data_verify['end_date'], format='%d.%m.%Y %H:%M') - start_utc = pd.to_datetime(case["start"]) # case["start"].astimezone(pd.Timestamp.now(tz='UTC').tzinfo) if case["start"].tzinfo is None else case["start"] - end_utc = pd.to_datetime(case["end"]) #case["end"].astimezone(pd.Timestamp.now(tz='UTC').tzinfo) if case["end"].tzinfo is None else case["end"] - filtered_df = data_verify[(data_verify['start_date'] >= start_utc) & (data_verify['start_date'] < end_utc)] - allCols = data.columns.tolist() - renPresent = list(set(allCols).intersection(renewableSources)) - for e in renPresent: - difference = filtered_df[e+" - Actual Aggregated [MW]"] - data[e] - sum_of_differences = difference.sum() - print(e) - print(sum_of_differences) - print(filtered_df[e+" - Actual Aggregated [MW]"].to_list()) - print(data[e].to_list()) - print(difference.to_list()) - print("===") - assert sum_of_differences == 0.0 - # else : - # print("") - def check_return_value_actual(self): - actual = energy("DE",datetime(2024,1,1),datetime(2024,1,2)) - assert isinstance(actual,dict) - def check_return_value_actual(self): - forecast = energy("DE",datetime(2024,1,1),datetime(2024,1,2),"forecast") - assert isinstance(forecast,dict) """ todo - test cases where some data is missing and has to be replaced with average diff --git a/tests/test_loadshift_location.py b/tests/test_loadshift_location.py index 646297d..dcebec2 100644 --- a/tests/test_loadshift_location.py +++ b/tests/test_loadshift_location.py @@ -18,7 +18,7 @@ # if(len(d)>0): # forecast_data[c] = d # return forecast_data - + # def test_locations(): # cases = [ # { @@ -40,4 +40,4 @@ # a,b,c,d = predict_optimal_location(data,case["h"],case["m"],case["p"],end,start) # print(a,b,c,d) -# # test_locations() \ No newline at end of file +# # test_locations() diff --git a/tests/test_loadshift_time.py b/tests/test_loadshift_time.py index b959173..65a6094 100644 --- a/tests/test_loadshift_time.py +++ b/tests/test_loadshift_time.py @@ -1,194 +1,247 @@ import pytest -from codegreen_core.utilities.message import CodegreenDataError,Message -from datetime import datetime,timezone,timedelta +from codegreen_core.utilities.message import CodegreenDataError, Message +from datetime import datetime, timezone, timedelta import codegreen_core.tools.loadshift_time as ts import pandas as pd import pytz -# Optimal time predications +# Optimal time predications class TestOptimalTimeCore: - - # some common data for testing - dummy_energy_data_1 = pd.DataFrame({"startTimeUTC":[1,2,3],"totalRenewable":[1,2,3],"percent_renewable":[1,2,3]}) - request_time_1 = datetime(2024,1,5,0,0) - request_time_2 = datetime(2024,1,10,0,0) - hard_finish_time_1 = datetime(2024,1,5,15,0) - hard_finish_time_2 = datetime(2024,1,15,15,0) - - - def test_energy_data_blank(self): - """test if no energy data is provided, the result defaults to the request time """ - timestamp, message, average_percent_renewable = ts.predict_optimal_time(None,1,1,1,self.hard_finish_time_1,self.request_time_1) - assert timestamp == int(self.request_time_1.timestamp()) - assert message == Message.NO_DATA - assert average_percent_renewable == 0 - - def test_neg_hour(self): - """test if negative hour value is provided, the result defaults to the request time """ - timestamp, message, average_percent_renewable = ts.predict_optimal_time(self.dummy_energy_data_1,-1,1,1,self.hard_finish_time_1,self.request_time_1) - assert timestamp == int(self.request_time_1.timestamp()) - assert message == Message.INVALID_DATA - assert average_percent_renewable == 0 - - def test_zero_hour(self): - """test if hour value is 0, the result defaults to the request time """ - timestamp, message, average_percent_renewable = ts.predict_optimal_time(self.dummy_energy_data_1,0,1,1,self.hard_finish_time_1,self.request_time_1) - assert timestamp == int(self.request_time_1.timestamp()) - assert message == Message.INVALID_DATA - assert average_percent_renewable == 0 - - def test_neg_min(self): - """test if negative hour value is provided, the result defaults to the request time """ - timestamp, message, average_percent_renewable = ts.predict_optimal_time(self.dummy_energy_data_1,1,-1,1,self.hard_finish_time_1,self.request_time_1) - assert timestamp == int(self.request_time_1.timestamp()) - assert message == Message.INVALID_DATA - assert average_percent_renewable == 0 - - def test_zero_per_renew(self): - """test if 0 % renewable , the result defaults to the request time """ - timestamp, message, average_percent_renewable = ts.predict_optimal_time(self.dummy_energy_data_1,1,0,-10,self.hard_finish_time_1,self.request_time_1) - assert timestamp == int(self.request_time_1.timestamp()) - assert message == Message.NEGATIVE_PERCENT_RENEWABLE - assert average_percent_renewable == 0 - - def test_neg_per_renew(self): - """test if negative -ve % renew is provided, the result defaults to the request time """ - timestamp, message, average_percent_renewable = ts.predict_optimal_time(self.dummy_energy_data_1,1,0,0,self.hard_finish_time_1,self.request_time_1) - assert timestamp == int(self.request_time_1.timestamp()) - assert message == Message.NEGATIVE_PERCENT_RENEWABLE - #assert average_percent_renewable == 0 - - def test_less_energy_data(self): - """to test if the request time + running time > hard finish , then return the request time """ - timestamp, message, average_percent_renewable = ts.predict_optimal_time(self.dummy_energy_data_1,20,0,10,self.hard_finish_time_1,self.request_time_1) - assert timestamp == int(self.request_time_1.timestamp()) - assert message == Message.RUNTIME_LONGER_THAN_DEADLINE_ALLOWS - - - def test_if_incorrect_data_provided(self): - """this is to test if energy data provided does not contain the data for the request time """ - data = pd.read_csv("tests/data/DE_forecast1.csv") - timestamp, message, average_percent_renewable = ts.predict_optimal_time(data,20,0,10,self.hard_finish_time_2,self.request_time_2) - assert timestamp == int(self.request_time_2.timestamp()) - assert message == Message.NO_DATA - - def test_multiple(self): - data = pd.read_csv("tests/data/DE_forecast1.csv") - hard_finish_time = datetime(2024,1,7,0,0) - request_time = datetime(2024,1,5,0,0) - cases = [ - { - "hd":hard_finish_time, - "rd":request_time, - "h":1, - "p":30, - "start":1704412800 - }, - { - "hd":hard_finish_time, - "rd":request_time, - "h":2, - "p":30, - "start":1704412800 - }, - { - "hd":hard_finish_time, - "rd":request_time, - "h":10, - "p":30, - "start":1704412800 - }, - { - "hd":hard_finish_time, - "rd":request_time, - "h":20, - "p":30, - "start":1704412800 - }, - { - "hd":hard_finish_time, - "rd":request_time, - "h":2, - "p":40, - "start":1704420000 - }, - { - "hd":hard_finish_time, - "rd":request_time, - "h":5, - "p":40, - "start":1704420000 - }, - { - "hd":hard_finish_time, - "rd":request_time, - "h":5, - "p":42, - "start":1704423600 - }, - { - "hd":hard_finish_time, - "rd":request_time, - "h":1, - "p":45, - "start":1704445200 # percent renewable prioritized over the start time - }, + + # some common data for testing + dummy_energy_data_1 = pd.DataFrame( { - "hd":hard_finish_time, - "rd":request_time, - "h":5, - "p":45, - "start":1704445200 - }, - { - "hd":hard_finish_time, - "rd":request_time, - "h":5, - "p":50, - "start":1704452400 # why 1704427200 - }, - { - "hd":hard_finish_time, - "rd":request_time, - "h":10, - "p":50, - "start":1704452400 - }, - { - "hd":hard_finish_time, - "rd":request_time, - "h":1, - "p":50, - "start":1704445200 - }, - # { - # "hd":hard_finish_time, - # "rd":request_time, - # "h":10, - # "p":60, - # "start":1704412800 # no match , just start now - # } - ] - assert 1==1 - - def test_data_validation_country(self): - timestamp1 = int(datetime.now(timezone.utc).timestamp()) - timestamp, message, average_percent_renewable = ts.predict_now("UFO",10,0,datetime(2024,9,7),"percent_renewable",30) - print(timestamp1,timestamp, message) - assert timestamp - timestamp1 <= 10 - assert message == Message.ENERGY_DATA_FETCHING_ERROR - # def test_all_country_test(self): - # test_cases = pd.read_csv("./data/test_cases_time.csv") - # data = pd.read_csv("./data/prediction_testing_data.csv") - # for index, row in test_cases.iterrows(): - # edata_filter = data["file_id"] == row["country"] - # energy_data = data[edata_filter].copy() - # start = datetime.strptime(row["start_time"], '%Y-%m-%d %H:%M:%S') - # end = (start + timedelta(hours=row["hard_deadline_hour"])) - # a,b,c = ts.predict_optimal_time(energy_data,row["runtime_hour"],row["runtime_min"],row["percent_renewable"],end,start) - # print(a,b,c) - # assert int(a) == row["expected_timestamp"] + "startTimeUTC": [1, 2, 3], + "totalRenewable": [1, 2, 3], + "percent_renewable": [1, 2, 3], + } + ) + request_time_1 = datetime(2024, 1, 5, 0, 0) + request_time_2 = datetime(2024, 1, 10, 0, 0) + hard_finish_time_1 = datetime(2024, 1, 5, 15, 0) + hard_finish_time_2 = datetime(2024, 1, 15, 15, 0) + + def test_energy_data_blank(self): + """test if no energy data is provided, the result defaults to the request time""" + timestamp, message, average_percent_renewable = ts.predict_optimal_time( + None, 1, 1, 1, self.hard_finish_time_1, self.request_time_1 + ) + assert timestamp == int(self.request_time_1.timestamp()) + assert message == Message.NO_DATA + assert average_percent_renewable == 0 + + def test_neg_hour(self): + """test if negative hour value is provided, the result defaults to the request time""" + timestamp, message, average_percent_renewable = ts.predict_optimal_time( + self.dummy_energy_data_1, + -1, + 1, + 1, + self.hard_finish_time_1, + self.request_time_1, + ) + assert timestamp == int(self.request_time_1.timestamp()) + assert message == Message.INVALID_DATA + assert average_percent_renewable == 0 + + def test_zero_hour(self): + """test if hour value is 0, the result defaults to the request time""" + timestamp, message, average_percent_renewable = ts.predict_optimal_time( + self.dummy_energy_data_1, + 0, + 1, + 1, + self.hard_finish_time_1, + self.request_time_1, + ) + assert timestamp == int(self.request_time_1.timestamp()) + assert message == Message.INVALID_DATA + assert average_percent_renewable == 0 + + def test_neg_min(self): + """test if negative hour value is provided, the result defaults to the request time""" + timestamp, message, average_percent_renewable = ts.predict_optimal_time( + self.dummy_energy_data_1, + 1, + -1, + 1, + self.hard_finish_time_1, + self.request_time_1, + ) + assert timestamp == int(self.request_time_1.timestamp()) + assert message == Message.INVALID_DATA + assert average_percent_renewable == 0 + + def test_zero_per_renew(self): + """test if 0 % renewable , the result defaults to the request time""" + timestamp, message, average_percent_renewable = ts.predict_optimal_time( + self.dummy_energy_data_1, + 1, + 0, + -10, + self.hard_finish_time_1, + self.request_time_1, + ) + assert timestamp == int(self.request_time_1.timestamp()) + assert message == Message.NEGATIVE_PERCENT_RENEWABLE + assert average_percent_renewable == 0 + + def test_neg_per_renew(self): + """test if negative -ve % renew is provided, the result defaults to the request time""" + timestamp, message, average_percent_renewable = ts.predict_optimal_time( + self.dummy_energy_data_1, + 1, + 0, + 0, + self.hard_finish_time_1, + self.request_time_1, + ) + assert timestamp == int(self.request_time_1.timestamp()) + assert message == Message.NEGATIVE_PERCENT_RENEWABLE + # assert average_percent_renewable == 0 + + def test_less_energy_data(self): + """to test if the request time + running time > hard finish , then return the request time""" + timestamp, message, average_percent_renewable = ts.predict_optimal_time( + self.dummy_energy_data_1, + 20, + 0, + 10, + self.hard_finish_time_1, + self.request_time_1, + ) + assert timestamp == int(self.request_time_1.timestamp()) + assert message == Message.RUNTIME_LONGER_THAN_DEADLINE_ALLOWS + + def test_if_incorrect_data_provided(self): + """this is to test if energy data provided does not contain the data for the request time""" + data = pd.read_csv("tests/data/DE_forecast1.csv") + timestamp, message, average_percent_renewable = ts.predict_optimal_time( + data, 20, 0, 10, self.hard_finish_time_2, self.request_time_2 + ) + assert timestamp == int(self.request_time_2.timestamp()) + assert message == Message.NO_DATA + + def test_multiple(self): + data = pd.read_csv("tests/data/DE_forecast1.csv") + hard_finish_time = datetime(2024, 1, 7, 0, 0) + request_time = datetime(2024, 1, 5, 0, 0) + cases = [ + { + "hd": hard_finish_time, + "rd": request_time, + "h": 1, + "p": 30, + "start": 1704412800, + }, + { + "hd": hard_finish_time, + "rd": request_time, + "h": 2, + "p": 30, + "start": 1704412800, + }, + { + "hd": hard_finish_time, + "rd": request_time, + "h": 10, + "p": 30, + "start": 1704412800, + }, + { + "hd": hard_finish_time, + "rd": request_time, + "h": 20, + "p": 30, + "start": 1704412800, + }, + { + "hd": hard_finish_time, + "rd": request_time, + "h": 2, + "p": 40, + "start": 1704420000, + }, + { + "hd": hard_finish_time, + "rd": request_time, + "h": 5, + "p": 40, + "start": 1704420000, + }, + { + "hd": hard_finish_time, + "rd": request_time, + "h": 5, + "p": 42, + "start": 1704423600, + }, + { + "hd": hard_finish_time, + "rd": request_time, + "h": 1, + "p": 45, + "start": 1704445200, # percent renewable prioritized over the start time + }, + { + "hd": hard_finish_time, + "rd": request_time, + "h": 5, + "p": 45, + "start": 1704445200, + }, + { + "hd": hard_finish_time, + "rd": request_time, + "h": 5, + "p": 50, + "start": 1704452400, # why 1704427200 + }, + { + "hd": hard_finish_time, + "rd": request_time, + "h": 10, + "p": 50, + "start": 1704452400, + }, + { + "hd": hard_finish_time, + "rd": request_time, + "h": 1, + "p": 50, + "start": 1704445200, + }, + # { + # "hd":hard_finish_time, + # "rd":request_time, + # "h":10, + # "p":60, + # "start":1704412800 # no match , just start now + # } + ] + assert 1 == 1 + + def test_data_validation_country(self): + timestamp1 = int(datetime.now(timezone.utc).timestamp()) + timestamp, message, average_percent_renewable = ts.predict_now( + "UFO", 10, 0, datetime(2024, 9, 7), "percent_renewable", 30 + ) + print(timestamp1, timestamp, message) + assert timestamp - timestamp1 <= 10 + assert message == Message.ENERGY_DATA_FETCHING_ERROR + + # def test_all_country_test(self): + # test_cases = pd.read_csv("./data/test_cases_time.csv") + # data = pd.read_csv("./data/prediction_testing_data.csv") + # for index, row in test_cases.iterrows(): + # edata_filter = data["file_id"] == row["country"] + # energy_data = data[edata_filter].copy() + # start = datetime.strptime(row["start_time"], '%Y-%m-%d %H:%M:%S') + # end = (start + timedelta(hours=row["hard_deadline_hour"])) + # a,b,c = ts.predict_optimal_time(energy_data,row["runtime_hour"],row["runtime_min"],row["percent_renewable"],end,start) + # print(a,b,c) + # assert int(a) == row["expected_timestamp"] # for case in cases: # #print(case) @@ -198,24 +251,32 @@ def test_data_validation_country(self): # assert timestamp == case["start"] -# test if request time is none current time is being used +# test if request time is none current time is being used def test_all_country(): test_cases = pd.read_csv("tests/data/test_cases_time.csv") data = pd.read_csv("tests/data/prediction_testing_data.csv") - for _ , row in test_cases.iterrows(): - print(row) - edata_filter = data["file_id"] == row["country"] - energy_data = data[edata_filter].copy() - - start_utc = datetime.strptime(row["start_time"], '%Y-%m-%d %H:%M:%S') - start_utc = pytz.UTC.localize(start_utc) - start = start_utc.astimezone(pytz.timezone('Europe/Berlin')) - end = (start + timedelta(hours=row["hard_deadline_hour"])) - - a,b,c = ts.predict_optimal_time(energy_data,row["runtime_hour"],row["runtime_min"],row["percent_renewable"],end,start) - print(a,b,c) - assert int(a) == row["expected_timestamp"] - print("====") + for _, row in test_cases.iterrows(): + print(row) + edata_filter = data["file_id"] == row["country"] + energy_data = data[edata_filter].copy() + + start_utc = datetime.strptime(row["start_time"], "%Y-%m-%d %H:%M:%S") + start_utc = pytz.UTC.localize(start_utc) + start = start_utc.astimezone(pytz.timezone("Europe/Berlin")) + end = start + timedelta(hours=row["hard_deadline_hour"]) + + a, b, c = ts.predict_optimal_time( + energy_data, + row["runtime_hour"], + row["runtime_min"], + row["percent_renewable"], + end, + start, + ) + print(a, b, c) + assert int(a) == row["expected_timestamp"] + print("====") + # test_all_country() @@ -224,8 +285,8 @@ def test_all_country(): # timestamp1 = int(datetime.now(timezone.utc).timestamp()) # timestamp, message, average_percent_renewable = ts.predict_now("DE",10,0,datetime(2024,9,7),"percent_renewable",30) # print(timestamp1,timestamp, message) -# #assert timestamp - timestamp1 <= 10 +# #assert timestamp - timestamp1 <= 10 # #assert message == Message.ENERGY_DATA_FETCHING_ERROR # data_validation_country() -# a,b,c = ts.predict_now("DE",2,30,datetime.fromtimestamp(1726092000),percent_renewable=50) \ No newline at end of file +# a,b,c = ts.predict_now("DE",2,30,datetime.fromtimestamp(1726092000),percent_renewable=50) From b0817e0ee8dc5b8292d67607c6eea654fc26e0a9 Mon Sep 17 00:00:00 2001 From: shubh Date: Tue, 29 Oct 2024 22:11:34 +0100 Subject: [PATCH 09/10] optimal time prediction now takes max percentage renewable --- codegreen_core/tools/loadshift_time.py | 15 +++------ codegreen_core/utilities/config.py | 16 ++++++++-- tests/test_loadshift_time.py | 42 +++++++++++++++----------- tests/use_tools.py | 13 ++++++++ 4 files changed, 57 insertions(+), 29 deletions(-) create mode 100644 tests/use_tools.py diff --git a/codegreen_core/tools/loadshift_time.py b/codegreen_core/tools/loadshift_time.py index 89d4fac..7d7fb8e 100644 --- a/codegreen_core/tools/loadshift_time.py +++ b/codegreen_core/tools/loadshift_time.py @@ -24,7 +24,6 @@ def _get_energy_data(country, start, end): Check the country data file if models exists """ energy_mode = Config.get("default_energy_mode") - if Config.get("enable_energy_caching") == True: # check prediction is enabled : get cache or update prediction try: @@ -43,6 +42,7 @@ def _get_energy_data(country, start, end): forecast = energy(country, start, end, "forecast") elif energy_mode == "public_data": forecast = energy(country, start, end, "forecast") + # print(forecast) else: return None return forecast["data"] @@ -53,8 +53,7 @@ def predict_now( estimated_runtime_hours: int, estimated_runtime_minutes: int, hard_finish_date: datetime, - criteria: str = "percent_renewable", - percent_renewable: int = 50, + criteria: str = "percent_renewable" ) -> tuple: """ Predicts optimal computation time in the given location starting now @@ -69,8 +68,6 @@ def predict_now( :type hard_finish_date: datetime :param criteria: Criteria based on which optimal time is calculated. Valid value "percent_renewable" or "optimal_percent_renewable" :type criteria: str - :param percent_renewable: The minimum percentage of renewable energy desired during the runtime - :type percent_renewable: int :return: Tuple[timestamp, message, average_percent_renewable] :rtype: tuple """ @@ -85,8 +82,7 @@ def predict_now( energy_data, estimated_runtime_hours, estimated_runtime_minutes, - percent_renewable, - hard_finish_date, + hard_finish_date ) else: return _default_response(Message.ENERGY_DATA_FETCHING_ERROR) @@ -104,7 +100,6 @@ def predict_optimal_time( energy_data: pd.DataFrame, estimated_runtime_hours: int, estimated_runtime_minutes: int, - percent_renewable: int, hard_finish_date: datetime, request_time: datetime = None, ) -> tuple: @@ -114,7 +109,6 @@ def predict_optimal_time( :param energy_data: A DataFrame containing the energy data including startTimeUTC, totalRenewable,total,percent_renewable,posix_timestamp :param estimated_runtime_hours: The estimated runtime in hours :param estimated_runtime_minutes: The estimated runtime in minutes - :param percent_renewable: The minimum percentage of renewable energy desired during the runtime :param hard_finish_date: The latest possible finish time for the task. :param request_time: The time at which the prediction is requested. Defaults to None, then the current time is used. Assumed to be in local timezone @@ -123,7 +117,7 @@ def predict_optimal_time( """ granularity = 60 # assuming that the granularity of time series is 60 minutes - + # print(percent_renewable) # ============ data validation ========= if not isinstance(hard_finish_date, datetime): raise ValueError("Invalid hard_finish_date. it must be a datetime object") @@ -133,6 +127,7 @@ def predict_optimal_time( raise ValueError("Invalid request_time. it must be a datetime object") if energy_data is None: return _default_response(Message.NO_DATA, request_time) + percent_renewable = int(energy_data["percent_renewable"].max()) #assuming we want the max possible percent renewable if percent_renewable <= 0: return _default_response(Message.NEGATIVE_PERCENT_RENEWABLE, request_time) if estimated_runtime_hours <= 0: diff --git a/codegreen_core/utilities/config.py b/codegreen_core/utilities/config.py index 18f60ff..90fc9e6 100644 --- a/codegreen_core/utilities/config.py +++ b/codegreen_core/utilities/config.py @@ -13,7 +13,12 @@ class Config: config_data = None section_name = "codegreen" boolean_keys = {"enable_energy_caching", "enable_time_prediction_logging"} - defaults = {"default_energy_mode": "public_data", "enable_energy_caching": False} + defaults = { + "default_energy_mode": "public_data", + "enable_energy_caching": False, + "enable_time_prediction_logging": False, + "energy_redis_path": None, + } @classmethod def load_config(self, file_path=None): @@ -34,6 +39,12 @@ def load_config(self, file_path=None): self.config_data = configparser.ConfigParser() self.config_data.read(file_path) + if self.section_name not in self.config_data: + self.config_data[self.section_name] = {} + for key, default_value in self.defaults.items(): + if not self.config_data.has_option(self.section_name, key): + self.config_data.set(self.section_name, key, str(default_value)) + if self.get("enable_energy_caching") == True: if self.get("energy_redis_path") is None: raise ConfigError( @@ -42,6 +53,7 @@ def load_config(self, file_path=None): else: r = redis.from_url(self.get("energy_redis_path")) r.ping() + # print(self.config_data["default_energy_mode"]) @classmethod def get(self, key): @@ -60,4 +72,4 @@ def get(self, key): value = value.lower() == "true" return value except (configparser.NoSectionError, configparser.NoOptionError): - return None + return self.defaults.get(key) # Return default if key is missing diff --git a/tests/test_loadshift_time.py b/tests/test_loadshift_time.py index 65a6094..2e4d0e9 100644 --- a/tests/test_loadshift_time.py +++ b/tests/test_loadshift_time.py @@ -25,8 +25,9 @@ class TestOptimalTimeCore: def test_energy_data_blank(self): """test if no energy data is provided, the result defaults to the request time""" timestamp, message, average_percent_renewable = ts.predict_optimal_time( - None, 1, 1, 1, self.hard_finish_time_1, self.request_time_1 + None, 1, 1, self.hard_finish_time_1, self.request_time_1 ) + # print(timestamp, message, average_percent_renewable) assert timestamp == int(self.request_time_1.timestamp()) assert message == Message.NO_DATA assert average_percent_renewable == 0 @@ -37,9 +38,8 @@ def test_neg_hour(self): self.dummy_energy_data_1, -1, 1, - 1, self.hard_finish_time_1, - self.request_time_1, + self.request_time_1 ) assert timestamp == int(self.request_time_1.timestamp()) assert message == Message.INVALID_DATA @@ -51,9 +51,8 @@ def test_zero_hour(self): self.dummy_energy_data_1, 0, 1, - 1, self.hard_finish_time_1, - self.request_time_1, + self.request_time_1 ) assert timestamp == int(self.request_time_1.timestamp()) assert message == Message.INVALID_DATA @@ -65,9 +64,8 @@ def test_neg_min(self): self.dummy_energy_data_1, 1, -1, - 1, self.hard_finish_time_1, - self.request_time_1, + self.request_time_1 ) assert timestamp == int(self.request_time_1.timestamp()) assert message == Message.INVALID_DATA @@ -75,11 +73,17 @@ def test_neg_min(self): def test_zero_per_renew(self): """test if 0 % renewable , the result defaults to the request time""" + dummy_energy_data_2 = pd.DataFrame( + { + "startTimeUTC": [1, 2, 3], + "totalRenewable": [1, 2, 3], + "percent_renewable": [0, 0, 0], + } + ) timestamp, message, average_percent_renewable = ts.predict_optimal_time( - self.dummy_energy_data_1, + dummy_energy_data_2, 1, 0, - -10, self.hard_finish_time_1, self.request_time_1, ) @@ -89,13 +93,19 @@ def test_zero_per_renew(self): def test_neg_per_renew(self): """test if negative -ve % renew is provided, the result defaults to the request time""" + dummy_energy_data_3 = pd.DataFrame( + { + "startTimeUTC": [1, 2, 3], + "totalRenewable": [1, 2, 3], + "percent_renewable": [-1, -4, -5], + } + ) timestamp, message, average_percent_renewable = ts.predict_optimal_time( - self.dummy_energy_data_1, + dummy_energy_data_3, 1, 0, - 0, self.hard_finish_time_1, - self.request_time_1, + self.request_time_1 ) assert timestamp == int(self.request_time_1.timestamp()) assert message == Message.NEGATIVE_PERCENT_RENEWABLE @@ -107,9 +117,8 @@ def test_less_energy_data(self): self.dummy_energy_data_1, 20, 0, - 10, self.hard_finish_time_1, - self.request_time_1, + self.request_time_1 ) assert timestamp == int(self.request_time_1.timestamp()) assert message == Message.RUNTIME_LONGER_THAN_DEADLINE_ALLOWS @@ -118,7 +127,7 @@ def test_if_incorrect_data_provided(self): """this is to test if energy data provided does not contain the data for the request time""" data = pd.read_csv("tests/data/DE_forecast1.csv") timestamp, message, average_percent_renewable = ts.predict_optimal_time( - data, 20, 0, 10, self.hard_finish_time_2, self.request_time_2 + data, 20, 0, self.hard_finish_time_2, self.request_time_2 ) assert timestamp == int(self.request_time_2.timestamp()) assert message == Message.NO_DATA @@ -225,7 +234,7 @@ def test_multiple(self): def test_data_validation_country(self): timestamp1 = int(datetime.now(timezone.utc).timestamp()) timestamp, message, average_percent_renewable = ts.predict_now( - "UFO", 10, 0, datetime(2024, 9, 7), "percent_renewable", 30 + "UFO", 10, 0, datetime(2024, 9, 7), "percent_renewable" ) print(timestamp1, timestamp, message) assert timestamp - timestamp1 <= 10 @@ -269,7 +278,6 @@ def test_all_country(): energy_data, row["runtime_hour"], row["runtime_min"], - row["percent_renewable"], end, start, ) diff --git a/tests/use_tools.py b/tests/use_tools.py new file mode 100644 index 0000000..94fcad7 --- /dev/null +++ b/tests/use_tools.py @@ -0,0 +1,13 @@ +from codegreen_core.utilities.message import CodegreenDataError, Message +from datetime import datetime, timezone, timedelta +import codegreen_core.tools.loadshift_time as ts +import pandas as pd +import pytz + +try: + a,b,c, = ts.predict_now("DE",12,0,datetime(2024,10,30,23,00,00)) +except Exception as e: + print(e) + + +#print(a,b,c) \ No newline at end of file From 3fa64e15f32ec995dcd9a6355aaf8f1b55607078 Mon Sep 17 00:00:00 2001 From: shubh Date: Wed, 13 Nov 2024 10:48:22 +0100 Subject: [PATCH 10/10] name fix --- pyproject.toml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c704169..5f435da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.poetry] -name = "codegreen-core" +name = "codegreen_core" version = "0.5.0" description = "This package helps you become aware of the carbon footprint of your computation" authors = ["Anne Hartebrodt ","Shubh Vardhan Jain "] @@ -25,9 +25,3 @@ black = "^24.10.0" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" - -# Specify additional package data (similar to include_package_data) -#[tool.poetry.package.include] -#"codegreen_core/utilities/country_list.json" = { format = "file" } -#"codegreen_core/utilities/ci_default_values.csv" = { format = "file" } -#"codegreen_core/utilities/model_details.json" = { format = "file" } \ No newline at end of file