diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0cd7248..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 - 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/.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/__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 3711c74..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,121 +203,170 @@ 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) -> pd.DataFrame: - """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. +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 :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 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 { + "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"], + } - # 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 +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) -> pd.DataFrame: - """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) 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) @@ -282,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 cdc9ec4..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)-> 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. This method fetches the energy data for the specified country between the specified duration. @@ -15,56 +16,65 @@ def energy(country,start_time,end_time,type="generation",interval60=True)-> pd.D 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. :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'. - :return: A DataFrame containing the hourly energy production mix. - :rtype: pd.DataFrame + :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. + - `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") - 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 diff --git a/codegreen_core/models/predict.py b/codegreen_core/models/predict.py index 5bfe973..15d16f6 100644 --- a/codegreen_core/models/predict.py +++ b/codegreen_core/models/predict.py @@ -11,50 +11,60 @@ # 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_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 @@ -64,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 435cb4d..859fd91 100644 --- a/codegreen_core/tools/carbon_emission.py +++ b/codegreen_core/tools/carbon_emission.py @@ -1,79 +1,297 @@ 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 +from .carbon_intensity import compute_ci + def compute_ce( - country: str, - start_time:datetime, + 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 -): - """ - 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 +) -> 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. + + 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/ + # 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) - 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_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_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): - - """ - 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 + 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_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 +): """ - time_diff = ci_data['startTimeUTC'].iloc[-1] - ci_data['startTimeUTC'].iloc[0] - 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) + 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(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 + `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. + """ + 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, + 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 + 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) + # 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() diff --git a/codegreen_core/tools/carbon_intensity.py b/codegreen_core/tools/carbon_intensity.py index 57549f9..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,81 +66,148 @@ "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. - - """ - e_source = get_country_energy_source(country) - if e_source=="ENTSOE" : - energy_data = energy(country,start_time,end_time) - 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: 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: - + +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 + 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) + 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 1cc2736..7d7fb8e 100644 --- a/codegreen_core/tools/loadshift_time.py +++ b/codegreen_core/tools/loadshift_time.py @@ -2,131 +2,72 @@ 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.log import time_prediction as log_time_prediction -from ..utilities.metadata import get_country_energy_source -from ..data import entsoe as e -from ..data import energy +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 +# ========= the main methods ============ -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. +def _get_energy_data(country, start, end): """ - 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 - + Get energy data and check if it must be cached based on the options set -# ========= 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 """ - if Config.get("enable_energy_caching")==True: - try : - forecast = _get_cache_or_update(country, start, end) + energy_mode = Config.get("default_energy_mode") + 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_data = pd.DataFrame(forecast["data"]) return forecast_data - except Exception as e : + except Exception as e: print(traceback.format_exc()) - else: - forecast = energy(country,start,end,"forecast") + 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") + elif energy_mode == "public_data": + forecast = energy(country, start, end, "forecast") + # print(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" +) -> 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 :return: Tuple[timestamp, message, average_percent_renewable] :rtype: tuple """ @@ -134,14 +75,13 @@ 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 ) else: @@ -149,130 +89,101 @@ 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) -# ======= Optimal prediction part ========= + +# ======= Optimal prediction part ========= + 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 + 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 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 estimated_runtime_minutes: The estimated runtime in minutes + :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 + # print(percent_renewable) # ============ 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) + 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) + 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 @@ -294,8 +205,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}") @@ -319,9 +230,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 @@ -363,16 +274,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 new file mode 100644 index 0000000..d89f202 --- /dev/null +++ b/codegreen_core/utilities/caching.py @@ -0,0 +1,96 @@ +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..90fc9e6 100644 --- a/codegreen_core/utilities/config.py +++ b/codegreen_core/utilities/config.py @@ -1,50 +1,75 @@ 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_prediction_models","enable_time_prediction_logging"} - @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() - # print("Redis pinged") - - @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 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, + "enable_time_prediction_logging": False, + "energy_redis_path": None, + } + + @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.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( + "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() + # print(self.config_data["default_energy_mode"]) + + @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 self.defaults.get(key) # Return default if key is missing 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 fec2fcc..6c51c54 100644 --- a/codegreen_core/utilities/metadata.py +++ b/codegreen_core/utilities/metadata.py @@ -1,59 +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 - -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 : - return metadata[country]["models"][len(metadata[country]["models"])-1] + """ + 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: - filter = next([d for d in metadata[country]["models"]],None) - if filter in None: - raise "Version does not exists" - return filter - else: - raise "No models exists for this country" \ No newline at end of file + 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 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 3dfd9b2..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"] +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 894cd83..d6ff718 100644 --- a/docs/plot.py +++ b/docs/plot.py @@ -1,141 +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""" - actual1 = energy(country,start,end) - plot_percentage_clean(actual1,country,save_fig_path) + 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): +def show_clean_energy_multiple(countries, start, end, save_fig_path=None): data = [] - for c in countries : - data.append(energy(c,start,end)) - 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/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..5f435da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,33 +1,27 @@ -[build-system] -requires = ["setuptools>=61.0", - "requests", - "pandas", - "numpy", - "entsoe-py", - "codecarbon", - "redis", - "scikit-learn", - "tensorflow", - "sphinx" -] - -build-backend = "setuptools.build_meta" - -[project] +[tool.poetry] name = "codegreen_core" -version = "0.0.1" -authors = [ - { name="Anne Hartebrodt", email="anne.hartebrodt@fau.de" }, -] -description = "Codegreen -- make your computations carbon-aware" +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" -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.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" + + +[tool.poetry.group.dev.dependencies] +pytest = "^8.3.3" +Sphinx = "^8.1.3" +black = "^24.10.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/setup.py b/setup.py deleted file mode 100644 index 8845b26..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"] -) diff --git a/tests/get_data.py b/tests/get_data.py index 15e53a5..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.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) -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 403f51f..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)) +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_emissions.py b/tests/test_carbon_emissions.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_carbon_intensity.py b/tests/test_carbon_intensity.py new file mode 100644 index 0000000..a1ea625 --- /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)) diff --git a/tests/test_data.py b/tests/test_data.py index 1cb6f35..9256888 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -5,99 +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_type(self): - with pytest.raises(ValueError): - energy("DE",datetime(2024,1,1),datetime(2024,1,2),"magic") + 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_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": - data = energy(case["country"],case["start"],case["end"],case["dtype"],case["interval60"]) - 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("") """ 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 1c66a9c..dcebec2 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 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 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 -# test_locations() \ No newline at end of file +# 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() diff --git a/tests/test_loadshift_time.py b/tests/test_loadshift_time.py index b959173..2e4d0e9 100644 --- a/tests/test_loadshift_time.py +++ b/tests/test_loadshift_time.py @@ -1,194 +1,256 @@ 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 - }, + + # some common data for testing + dummy_energy_data_1 = pd.DataFrame( { - "hd":hard_finish_time, - "rd":request_time, - "h":1, - "p":45, - "start":1704445200 # percent renewable prioritized over the start time - }, + "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, 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 + + 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, + 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, + 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, + 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""" + dummy_energy_data_2 = 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": [0, 0, 0], + } + ) + timestamp, message, average_percent_renewable = ts.predict_optimal_time( + dummy_energy_data_2, + 1, + 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_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( + dummy_energy_data_3, + 1, + 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, + 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, 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" + ) + 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 +260,31 @@ 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"], + end, + start, + ) + print(a, b, c) + assert int(a) == row["expected_timestamp"] + print("====") + # test_all_country() @@ -224,8 +293,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) 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