From d49afdcfa2aab1f4db213e4f1b765cf60050c6ef Mon Sep 17 00:00:00 2001 From: jbadsdata Date: Mon, 5 May 2025 13:23:53 -0400 Subject: [PATCH 1/5] optimizer subpackage --- README.md | 261 +----- optimizer/Optimizer README.md | 221 ++++++ optimizer/__init__.py | 2 + .../optimizer => optimizer}/alg/__init__.py | 0 {watttime/optimizer => optimizer}/alg/moer.py | 0 .../optimizer => optimizer}/alg/optCharger.py | 0 {watttime => optimizer}/api_convert.py | 0 optimizer/api_opt.py | 751 ++++++++++++++++++ .../evaluator/evaluator.py | 0 {watttime => optimizer}/evaluator/sessions.py | 0 {watttime => optimizer}/evaluator/utils.py | 0 .../examples/_example_synthetic_data.ipynb | 0 setup.py | 2 +- tests/test_optimizer.py | 2 +- watttime/api.py | 632 --------------- watttime/optimizer/Optimizer README.md | 89 --- watttime/optimizer/test.py | 149 ---- 17 files changed, 978 insertions(+), 1131 deletions(-) create mode 100644 optimizer/Optimizer README.md create mode 100644 optimizer/__init__.py rename {watttime/optimizer => optimizer}/alg/__init__.py (100%) rename {watttime/optimizer => optimizer}/alg/moer.py (100%) rename {watttime/optimizer => optimizer}/alg/optCharger.py (100%) rename {watttime => optimizer}/api_convert.py (100%) create mode 100644 optimizer/api_opt.py rename {watttime => optimizer}/evaluator/evaluator.py (100%) rename {watttime => optimizer}/evaluator/sessions.py (100%) rename {watttime => optimizer}/evaluator/utils.py (100%) rename _example_synthetic_data.ipynb => optimizer/examples/_example_synthetic_data.ipynb (100%) delete mode 100644 watttime/optimizer/Optimizer README.md delete mode 100644 watttime/optimizer/test.py diff --git a/README.md b/README.md index 31bae236..0357dcfd 100644 --- a/README.md +++ b/README.md @@ -145,263 +145,6 @@ wt.region_from_loc( wt.get_maps_json('co2_moer') ``` -# Using the Optimizer Class -`WattTime.Optimizer` produces a power consumption schedule that minimizes carbon emissions subject to user and device constraints. +# Optimizer Package -The `WattTime.Optimizer` class requires 4 things: - -- Watttime’s forecast of marginal emissions -- device capacity and energy needs -- region -- window start -- window end - - -| optimization\_method | ASAP | Charging curve | Time constraint | Contiguous | -| :---- | :---- | :---- | :---- | :---- | -| baseline | Yes | Constant | No | No | -| simple | No | Constant | No | No | -| sophisticated | No | Variable | Yes | No | -| contiguous | No | Variable | Yes | Intervals at fixed lengths | -| Variable contiguous | No | Variable | Yes | Intervals at variable lengths | -| auto | No | Chooses the fastest algorithm that can still process all inputs | | | - -Click any of the thumbnails below to see the notebook that generated it. - -1. Naive Smart device charging: needs 30 minutes to reach full charge, expected plug out time within the next 4 hours. Simple use case. -2. Requery: update usage plan every 20 minutes using new forecast for the next 4 hours. Simple use case with recalculation -3. Partial charging guarantee: charge 75% by 8am. User constraint -4. Data center workloads: estimated runtime is 2 hours and it needs to complete by 12pm Contiguous (single period, fixed length) -5. Dishwasher: needs to run over two usage intervals of lengths 80 min and 40 min. They must complete in that order. Contiguous (multiple periods, fixed length) -6. Compressor: needs to run 120 minutes over the next 12 hours; each cycle needs to be at least 20 minutes long, and any number of contiguous intervals (from one to six) is okay. Contiguous (multiple periods, variable length) - -**Naive Smart Device Charging** - * Simple - uses the MOER forecast at window start to find the set of intervals that minimize emissions, and outputs a charge schedule based on that. - -```py -from datetime import datetime, timedelta -import pandas as pd -from pytz import UTC -from watttime import WattTimeOptimizer -import os - -username = os.getenv("WATTTIME_USER") -password = os.getenv("WATTTIME_PASSWORD") -wt_opt = WattTimeOptimizer(username, password) - -# 12 hour charge window (720/60 = 12) -now = datetime.now(UTC) -window_start = now -window_end = now + timedelta(minutes=720) - -usage_plan = wt_opt.get_optimal_usage_plan( - region="CAISO_NORTH", - usage_window_start=window_start, - usage_window_end=window_end, - usage_time_required_minutes=240, - usage_power_kw=12, - optimization_method="auto", -) - -print(usage_plan.head()) -print(usage_plan["usage"].tolist()) -print(usage_plan.sum()) -``` - -**Partial Charging Guarantee - Introducing Constraints** - * Sophisticated - total charge window 12 hours long, 75% charged by hour 8. - -```py -from datetime import datetime, timedelta -import pandas as pd -from pytz import UTC -from watttime import WattTimeOptimizer -import os - -username = os.getenv("WATTTIME_USER") -password = os.getenv("WATTTIME_PASSWORD") -wt_opt = WattTimeOptimizer(username, password) - -# 12 hour charge window (720/60 = 12) -# Minute 480 is time context when the constraint, i.e. 75% charge, must be satisfied -# 75% of 240 (required charge expressed in minutes) is 180 - -now = datetime.now(UTC) -window_start = now -window_end = now + timedelta(minutes=720) -usage_time_required_minutes = 240 -constraint_time = now + timedelta(minutes=480) -constraint_usage_time_required_minutes = 180 -usage_power_kw = 12.0 - -# map the constraint to the time context -constraints = {constraint_time:constraint_usage_time_required_minutes} - -usage_plan = wt_opt.get_optimal_usage_plan( - region="CAISO_NORTH", - usage_window_start=window_start, - usage_window_end=window_end, - usage_time_required_minutes=240, - usage_power_kw=12, - constraints=constraints, - optimization_method="auto", -) - -print(usage_plan.head()) -print(usage_plan["usage"].tolist()) -print(usage_plan.sum()) -``` - -**Variable Charging Curve - EV** - * Sophisticated - total charge window 12 hours long, 75% charged by hour 8. - -```py -from datetime import datetime, timedelta -import pandas as pd -from pytz import UTC -from watttime import WattTimeOptimizer -import os - -username = os.getenv("WATTTIME_USER") -password = os.getenv("WATTTIME_PASSWORD") -wt_opt = WattTimeOptimizer(username, password) - -# 12 hour charge window (720/60 = 12) -# Minute 480 is time context when the constraint, i.e. 75% charge, must be satisfied -# 75% of 240 (required charge expressed in minutes) is 180 - -now = datetime.now(UTC) -window_start = now -window_end = now + timedelta(minutes=720) -variable_usage_power = '' - -usage_plan = wt_opt.get_optimal_usage_plan( - region="CAISO_NORTH", - usage_window_start=window_start, - usage_window_end=window_end, - usage_time_required_minutes=240, - usage_power_kw=variable_usage_power, - constraints=constraints, - optimization_method="auto", -) - -print(usage_plan.head()) -print(usage_plan["usage"].tolist()) -print(usage_plan.sum()) -``` - - -* **Data Center Workload**: - * Fixed Contiguous (single period, fixed length) - charging schedule to be composed of contiguous interval(s) of fixed length - -```py -## AI model training - estimated runtime is 2 hours and it needs to complete within 12 hours - -from datetime import datetime, timedelta -import pandas as pd -from pytz import UTC -from watttime.api import WattTimeOptimizer -import os - -username = os.getenv("WATTTIME_USER") -password = os.getenv("WATTTIME_PASSWORD") -wt_opt = WattTimeOptimizer(username, password) - -now = datetime.now(UTC) -window_start = now -window_end = now + timedelta(minutes=720) - -usage_power_kw = 12.0 -region = "CAISO_NORTH" - -# by passing a single interval of 120 minutes to charge_per_interval, the Optimizer will know to fit call the fixed contigous modeling function. -usage_plan = wt_opt.get_optimal_usage_plan( - region=region, - usage_window_start=window_start, - usage_window_end=window_end, - usage_time_required_minutes=120, - usage_power_kw=12, - charge_per_interval=[120], - optimization_method="auto", - verbose = False -) - -print(usage_plan.head()) -print(usage_plan["usage"].tolist()) -print(usage_plan.sum()) -``` - -**Dishwasher**: - * Fixed Contiguous (multiple periods, fixed length) - runs over two usage intervals of lengths 80 min and 40 min. The order of the intervals is immutable. - -```py -## Dishwasher - there are two cycles of length 80 min and 40 min each, and they must be completed in that order. - -from datetime import datetime, timedelta -import pandas as pd -from pytz import UTC -from watttime import WattTimeOptimizer -import os - -username = os.getenv("WATTTIME_USER") -password = os.getenv("WATTTIME_PASSWORD") -wt_opt = WattTimeOptimizer(username, password) - -# Suppose that the time now is 12 midnight -now = datetime.now(UTC) -window_start = now -window_end = now + timedelta(minutes=720) - -# Pass two values to charge_per_interval instead of one. -usage_plan = wt_opt.get_optimal_usage_plan( - region="CAISO_NORTH", - usage_window_start=window_start, - usage_window_end=window_end, - usage_time_required_minutes=120, # 80 + 40 - usage_power_kw=12, - charge_per_interval=[80,40], - optimization_method="auto", -) - -print(usage_plan.head()) -print(usage_plan["usage"].tolist()) -print(usage_plan.sum()) -``` - -**Compressor**: - * Contiguous (multiple periods, variable length) - runs 120 minutes over the next 12 hours; each cycle needs to be at least 20 minutes long, and any number of intervals (from one to six) is okay. - -```py -## Compressor - needs to run 120 minutes over the next 12 hours; each cycle needs to be at least 20 minutes long, and any number of contiguous intervals (from one to six) is okay. - -from datetime import datetime, timedelta -import pandas as pd -from pytz import UTC -from watttime import WattTimeOptimizer -import os - -username = os.getenv("WATTTIME_USER") -password = os.getenv("WATTTIME_PASSWORD") -wt_opt = WattTimeOptimizer(username, password) - -# Suppose that the time now is 12 midnight -now = datetime.now(UTC) -window_start = now -window_end = now + timedelta(minutes=720) - -usage_plan = wt_opt.get_optimal_usage_plan( - region="CAISO_NORTH", - usage_window_start=window_start, - usage_window_end=window_end, - usage_time_required_minutes=120, - usage_power_kw=12, - # Here _None_ implies that there is no upper bound, and replacing None by 120 would have the exact same effect. - charge_per_interval=[(20,None),(20,None),(20,None),(20,None),(20,None),(20,None)], - optimization_method="auto", - use_all_intervals=False -) - -print(usage_plan.head()) -print(usage_plan["usage"].tolist()) -print(usage_plan.sum()) -``` \ No newline at end of file +Insert link to Optimizer README.md and add a brief description of functionality \ No newline at end of file diff --git a/optimizer/Optimizer README.md b/optimizer/Optimizer README.md new file mode 100644 index 00000000..2d0a1bb4 --- /dev/null +++ b/optimizer/Optimizer README.md @@ -0,0 +1,221 @@ +# Using the Optimizer Class +`WattTime.Optimizer` produces a power consumption schedule that minimizes carbon emissions subject to user and device constraints. + +The `WattTime.Optimizer` class requires 4 things: + +- Watttime’s forecast of marginal emissions +- device capacity and energy needs +- region +- window start +- window end + + +| optimization\_method | ASAP | Charging curve | Time constraint | Contiguous | +| :---- | :---- | :---- | :---- | :---- | +| baseline | Yes | Constant | No | No | +| simple | No | Constant | No | No | +| sophisticated | No | Variable | Yes | No | +| contiguous | No | Variable | Yes | Intervals at fixed lengths | +| Variable contiguous | No | Variable | Yes | Intervals at variable lengths | +| auto | No | Chooses the fastest algorithm that can still process all inputs | | | + +Click any of the thumbnails below to see the notebook that generated it. + +1. Naive Smart device charging: needs 30 minutes to reach full charge, expected plug out time within the next 4 hours. Simple use case. +2. Requery: update usage plan every 20 minutes using new forecast for the next 4 hours. Simple use case with recalculation +3. Partial charging guarantee: charge 75% by 8am. User constraint +4. Data center workloads: estimated runtime is 2 hours and it needs to complete by 12pm Contiguous (single period, fixed length) +5. Dishwasher: needs to run over two usage intervals of lengths 80 min and 40 min. They must complete in that order. Contiguous (multiple periods, fixed length) +6. Compressor: needs to run 120 minutes over the next 12 hours; each cycle needs to be at least 20 minutes long, and any number of contiguous intervals (from one to six) is okay. Contiguous (multiple periods, variable length) + +**Naive Smart Device Charging [EV or pluggable batter-powered device]** + +```py +from datetime import datetime, timedelta +import pandas as pd +from pytz import UTC +from optimizer import WattTimeOptimizer +import os + +username = os.getenv("WATTTIME_USER") +password = os.getenv("WATTTIME_PASSWORD") +wt_opt = WattTimeOptimizer(username, password) + +# 12 hour charge window (720/60 = 12) +now = datetime.now(UTC) +window_start = now +window_end = now + timedelta(minutes=720) + +usage_plan = wt_opt.get_optimal_usage_plan( + region="CAISO_NORTH", + usage_window_start=window_start, + usage_window_end=window_end, + usage_time_required_minutes=240, + usage_power_kw=12, + optimization_method="auto", +) + +print(usage_plan.head()) +print(usage_plan["usage"].tolist()) +print(usage_plan.sum()) +``` + +**Partial Charging Guarantee - Introducing Constraints** + * Sophisticated - total charge window 12 hours long, 75% charged by hour 8. + +```py +from datetime import datetime, timedelta +import pandas as pd +from pytz import UTC +from optimizer import WattTimeOptimizer +import os + +username = os.getenv("WATTTIME_USER") +password = os.getenv("WATTTIME_PASSWORD") +wt_opt = WattTimeOptimizer(username, password) + +# 12 hour charge window (720/60 = 12) +# Minute 480 is time context when the constraint, i.e. 75% charge, must be satisfied +# 75% of 240 (required charge expressed in minutes) is 180 + +now = datetime.now(UTC) +window_start = now +window_end = now + timedelta(minutes=720) +usage_time_required_minutes = 240 +constraint_time = now + timedelta(minutes=480) +constraint_usage_time_required_minutes = 180 +usage_power_kw = 12.0 + +# map the constraint to the time context +constraints = {constraint_time:constraint_usage_time_required_minutes} + +usage_plan = wt_opt.get_optimal_usage_plan( + region="CAISO_NORTH", + usage_window_start=window_start, + usage_window_end=window_end, + usage_time_required_minutes=240, + usage_power_kw=12, + constraints=constraints, + optimization_method="auto", +) + +print(usage_plan.head()) +print(usage_plan["usage"].tolist()) +print(usage_plan.sum()) +``` + +**Variable Charging Curve - EV** + * Sophisticated - total charge window 12 hours long, 75% charged by hour 8. + +```py +from datetime import datetime, timedelta +import pandas as pd +from pytz import UTC +from optimizer import WattTimeOptimizer +import os + +username = os.getenv("WATTTIME_USER") +password = os.getenv("WATTTIME_PASSWORD") +wt_opt = WattTimeOptimizer(username, password) + +# 12 hour charge window (720/60 = 12) +# Minute 480 is time context when the constraint, i.e. 75% charge, must be satisfied +# 75% of 240 (required charge expressed in minutes) is 180 + +now = datetime.now(UTC) +window_start = now +window_end = now + timedelta(minutes=720) +variable_usage_power = '' + +usage_plan = wt_opt.get_optimal_usage_plan( + region="CAISO_NORTH", + usage_window_start=window_start, + usage_window_end=window_end, + usage_time_required_minutes=240, + usage_power_kw=variable_usage_power, + constraints=constraints, + optimization_method="auto", +) + +print(usage_plan.head()) +print(usage_plan["usage"].tolist()) +print(usage_plan.sum()) +``` + + +* **Data Center Workload 1**: + * (single period, fixed length) - charging schedule to be composed of contiguous interval(s) of fixed length + +```py +## AI model training - estimated runtime is 2 hours and it needs to complete within 12 hours + +from datetime import datetime, timedelta +import pandas as pd +from pytz import UTC +from optimizer import WattTimeOptimizer +import os + +username = os.getenv("WATTTIME_USER") +password = os.getenv("WATTTIME_PASSWORD") +wt_opt = WattTimeOptimizer(username, password) + +now = datetime.now(UTC) +window_start = now +window_end = now + timedelta(minutes=720) + +usage_power_kw = 12.0 +region = "CAISO_NORTH" + +# by passing a single interval of 120 minutes to charge_per_interval, the Optimizer will know to fit call the fixed contigous modeling function. +usage_plan = wt_opt.get_optimal_usage_plan( + region=region, + usage_window_start=window_start, + usage_window_end=window_end, + usage_time_required_minutes=120, + usage_power_kw=12, + charge_per_interval=[120], + optimization_method="auto", + verbose = False +) + +print(usage_plan.head()) +print(usage_plan["usage"].tolist()) +print(usage_plan.sum()) +``` + +**Data Center Workload 2**: + * (multiple periods, fixed length) - runs over two usage intervals of lengths 80 min and 40 min. The order of the intervals is immutable. + +```py +## there are two cycles of length 80 min and 40 min each, and they must be completed in that order. + +from datetime import datetime, timedelta +import pandas as pd +from pytz import UTC +from optimizer import WattTimeOptimizer +import os + +username = os.getenv("WATTTIME_USER") +password = os.getenv("WATTTIME_PASSWORD") +wt_opt = WattTimeOptimizer(username, password) + +# Suppose that the time now is 12 midnight +now = datetime.now(UTC) +window_start = now +window_end = now + timedelta(minutes=720) + +# Pass two values to charge_per_interval instead of one. +usage_plan = wt_opt.get_optimal_usage_plan( + region="CAISO_NORTH", + usage_window_start=window_start, + usage_window_end=window_end, + usage_time_required_minutes=120, # 80 + 40 + usage_power_kw=12, + charge_per_interval=[80,40], + optimization_method="auto", +) + +print(usage_plan.head()) +print(usage_plan["usage"].tolist()) +print(usage_plan.sum()) +``` \ No newline at end of file diff --git a/optimizer/__init__.py b/optimizer/__init__.py new file mode 100644 index 00000000..7a5180c3 --- /dev/null +++ b/optimizer/__init__.py @@ -0,0 +1,2 @@ +from watttime.api import * +from optimizer.api_opt import * \ No newline at end of file diff --git a/watttime/optimizer/alg/__init__.py b/optimizer/alg/__init__.py similarity index 100% rename from watttime/optimizer/alg/__init__.py rename to optimizer/alg/__init__.py diff --git a/watttime/optimizer/alg/moer.py b/optimizer/alg/moer.py similarity index 100% rename from watttime/optimizer/alg/moer.py rename to optimizer/alg/moer.py diff --git a/watttime/optimizer/alg/optCharger.py b/optimizer/alg/optCharger.py similarity index 100% rename from watttime/optimizer/alg/optCharger.py rename to optimizer/alg/optCharger.py diff --git a/watttime/api_convert.py b/optimizer/api_convert.py similarity index 100% rename from watttime/api_convert.py rename to optimizer/api_convert.py diff --git a/optimizer/api_opt.py b/optimizer/api_opt.py new file mode 100644 index 00000000..a701fe21 --- /dev/null +++ b/optimizer/api_opt.py @@ -0,0 +1,751 @@ +import os +import math +from datetime import datetime, timedelta +from typing import Any, Literal, Optional, Union + +import pandas as pd +from dateutil.parser import parse +from pytz import UTC, timezone +from optimizer.alg import optCharger, moer +from itertools import accumulate +import bisect + +from watttime.api import WattTimeForecast + + +OPT_INTERVAL = 5 +MAX_PREDICTION_HOURS = 72 + + +class WattTimeOptimizer(WattTimeForecast): + """ + This class inherits from WattTimeForecast, with additional methods to generate + optimal usage plans for energy consumption based on various parameters and + constraints. + + Additional Methods: + -------- + get_optimal_usage_plan(region, usage_window_start, usage_window_end, + usage_time_required_minutes, usage_power_kw, + usage_time_uncertainty_minutes, optimization_method, + moer_data_override) + Generates an optimal usage plan for energy consumption. + """ + + OPT_INTERVAL = 5 + MAX_PREDICTION_HOURS = 72 + MAX_INT = 99999999999999999 + + def get_optimal_usage_plan( + self, + region: str, + usage_window_start: datetime, + usage_window_end: datetime, + usage_time_required_minutes: Optional[Union[int, float]] = None, + usage_power_kw: Optional[Union[int, float, pd.DataFrame]] = None, + energy_required_kwh: Optional[Union[int, float]] = None, + usage_time_uncertainty_minutes: Optional[Union[int, float]] = 0, + charge_per_interval: Optional[list] = None, + use_all_intervals: bool = True, + constraints: Optional[dict] = None, + optimization_method: Optional[ + Literal["baseline", "simple", "sophisticated", "auto"] + ] = "baseline", + moer_data_override: Optional[pd.DataFrame] = None, + verbose=True, + ) -> pd.DataFrame: + """ + Generates an optimal usage plan for energy consumption based on given parameters. + + This method calculates the most efficient energy usage schedule within a specified + time window, considering factors such as regional data, power requirements, and + optimization methods. + + You should pass in exactly 2 of 3 parameters of (usage_time_required_minutes, usage_power_kw, energy_required_kwh) + + Parameters: + ----------- + region : str + The region for which forecast data is requested. + usage_window_start : datetime + Start time of the window when power consumption is allowed. + usage_window_end : datetime + End time of the window when power consumption is allowed. + usage_time_required_minutes : Optional[Union[int, float]], default=None + Required usage time in minutes. + usage_power_kw : Optional[Union[int, float, pd.DataFrame]], default=None + Power usage in kilowatts. Can be a constant value or a DataFrame for variable power. + energy_required_kwh : Optional[Union[int, float]], default=None + Energy required in kwh + usage_time_uncertainty_minutes : Optional[Union[int, float]], default=0 + Uncertainty in usage time, in minutes. + charge_per_interval : Optional[list], default=None + Either a list of length-2 tuples representing minimium and maximum (inclusive) charging minutes per interval, + or a list of ints representing both the min and max. + use_all_intervals : Optional[bool], default=False + If true, use all intervals provided by charge_per_interval; if false, can use the first few intervals and skip the rest. + constraints : Optional[dict], default=None + A dictionary containing contraints on how much usage must be used before the given time point + optimization_method : Optional[Literal["baseline", "simple", "sophisticated", "auto"]], default="baseline" + The method used for optimization. + moer_data_override : Optional[pd.DataFrame], default=None + Pre-generated MOER (Marginal Operating Emissions Rate) DataFrame, if available. + verbose : default = True + If false, suppresses print statements in the opt charger class. + + Returns: + -------- + pd.DataFrame + A DataFrame representing the optimal usage plan, including columns for + predicted MOER, usage, CO2 emissions, and energy usage. + + Raises: + ------- + AssertionError + If input parameters do not meet specified conditions (e.g., timezone awareness, + valid time ranges, supported optimization methods). + + Notes: + ------ + - The method uses WattTime forecast data unless overridden by moer_data_override. + - It supports various optimization methods and can handle both constant and variable power usage. + - The resulting plan aims to minimize emissions while meeting the specified energy requirements. + """ + + def is_tz_aware(dt): + return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None + + def minutes_to_units(x, floor=False): + """Converts minutes to forecase intervals. Rounds UP by default.""" + if x: + if floor: + return int(x // self.OPT_INTERVAL) + else: + return int(math.ceil(x / self.OPT_INTERVAL)) + return x + + assert is_tz_aware(usage_window_start), "Start time is not tz-aware" + assert is_tz_aware(usage_window_end), "End time is not tz-aware" + + if constraints is None: + constraints = {} + else: + # Convert constraints to a standardized format + raw_constraints = constraints.copy() + constraints = {} + + for ( + constraint_time_clock, + constraint_usage_minutes, + ) in raw_constraints.items(): + constraint_time_minutes = ( + constraint_time_clock - usage_window_start + ).total_seconds() / 60 + constraint_time_units = minutes_to_units(constraint_time_minutes) + constraint_usage_units = minutes_to_units(constraint_usage_minutes) + + constraints.update( + {constraint_time_units: (constraint_usage_units, None)} + ) + + num_inputs = 0 + for input in (usage_time_required_minutes, usage_power_kw, energy_required_kwh): + if input is not None: + num_inputs += 1 + assert ( + num_inputs == 2 + ), "Exactly 2 of 3 inputs in (usage_time_required_minutes, usage_power_kw, energy_required_kwh) required" + if usage_power_kw is None: + usage_power_kw = energy_required_kwh / usage_time_required_minutes * 60 + print("Implied usage_power_kw =", usage_power_kw) + if usage_time_required_minutes is None: + if type(usage_power_kw) in (float, int) and type(energy_required_kwh) in ( + float, + int, + ): + usage_time_required_minutes = energy_required_kwh / usage_power_kw * 60 + print("Implied usage time required =", usage_time_required_minutes) + else: + # TODO: Implement and test + raise NotImplementedError( + "When usage_time_required_minutes is None, only float or int usage_power_kw and energy_required_kwh is supported." + ) + + # Perform these checks if we are using live data + if moer_data_override is None: + datetime_now = datetime.now(UTC) + assert ( + usage_window_end > datetime_now + ), "Error, Window end is before current datetime" + assert usage_window_end - datetime_now < timedelta( + hours=self.MAX_PREDICTION_HOURS + ), "End time is too far in the future" + assert optimization_method in ("baseline", "simple", "sophisticated", "auto"), ( + "Unsupported optimization method:" + optimization_method + ) + if moer_data_override is None: + forecast_df = self.get_forecast_pandas( + region=region, + signal_type="co2_moer", + horizon_hours=self.MAX_PREDICTION_HOURS, + ) + else: + forecast_df = moer_data_override.copy() + forecast_df = forecast_df.set_index("point_time") + forecast_df.index = pd.to_datetime(forecast_df.index) + + # relevant_forecast_df = forecast_df[usage_window_start:usage_window_end] + relevant_forecast_df = forecast_df[forecast_df.index >= usage_window_start] + relevant_forecast_df = relevant_forecast_df[ + relevant_forecast_df.index < usage_window_end + ] + relevant_forecast_df = relevant_forecast_df.rename( + columns={"value": "pred_moer"} + ) + result_df = relevant_forecast_df[["pred_moer"]] + moer_values = relevant_forecast_df["pred_moer"].values + + m = moer.Moer(mu=moer_values) + + model = optCharger.OptCharger(verbose=verbose) + + total_charge_units = minutes_to_units(usage_time_required_minutes) + if optimization_method in ("sophisticated", "auto"): + # Give a buffer time equal to the uncertainty + buffer_time = usage_time_uncertainty_minutes + buffer_periods = minutes_to_units(buffer_time) if buffer_time else 0 + buffer_enforce_time = max( + total_charge_units, len(moer_values) - buffer_periods + ) + constraints.update({buffer_enforce_time: (total_charge_units, None)}) + else: + assert ( + usage_time_uncertainty_minutes == 0 + ), "usage_time_uncertainty_minutes is only supported in optimization_method='sophisticated' or 'auto'" + + if type(usage_power_kw) in (int, float): + # Convert to the MWh used in an optimization interval + # expressed as a function to meet the parameter requirements for OptC function + emission_multiplier_fn = ( + lambda sc, ec: float(usage_power_kw) * 0.001 * self.OPT_INTERVAL / 60.0 + ) + else: + usage_power_kw = usage_power_kw.copy() + # Resample usage power dataframe to an OPT_INTERVAL frequency + usage_power_kw["time_step"] = usage_power_kw["time"] / self.OPT_INTERVAL + usage_power_kw_new_index = pd.DataFrame( + index=[float(x) for x in range(total_charge_units + 1)] + ) + usage_power_kw = pd.merge_asof( + usage_power_kw_new_index, + usage_power_kw.set_index("time_step"), + left_index=True, + right_index=True, + direction="backward", + allow_exact_matches=True, + ) + + def emission_multiplier_fn(sc: float, ec: float) -> float: + """ + Calculate the approximate mean power in the given time range, + in units of MWh used per optimizer time unit. + + sc and ec are float values representing the start and end time of + the time range, in optimizer time units. + """ + value = ( + usage_power_kw[sc : max(sc, ec - 1e-12)]["power_kw"].mean() + * 0.001 + * self.OPT_INTERVAL + / 60.0 + ) + return value + + if charge_per_interval: + # Handle the charge_per_interval input by converting it from minutes to units, rounding up + converted_charge_per_interval = [] + for c in charge_per_interval: + if isinstance(c, int): + converted_charge_per_interval.append(minutes_to_units(c)) + else: + assert ( + len(c) == 2 + ), "Length of tuples in charge_per_interval is not 2" + interval_start_units = minutes_to_units(c[0]) if c[0] else 0 + interval_end_units = ( + minutes_to_units(c[1]) if c[1] else self.MAX_INT + ) + converted_charge_per_interval.append( + (interval_start_units, interval_end_units) + ) + else: + converted_charge_per_interval = None + model.fit( + total_charge=total_charge_units, + total_time=len(moer_values), + moer=m, + constraints=constraints, + charge_per_interval=converted_charge_per_interval, + use_all_intervals=use_all_intervals, + emission_multiplier_fn=emission_multiplier_fn, + optimization_method=optimization_method, + ) + + optimizer_result = model.get_schedule() + result_df = self._reconcile_constraints( + optimizer_result, + result_df, + model, + usage_time_required_minutes, + charge_per_interval, + ) + + return result_df + + def _reconcile_constraints( + self, + optimizer_result, + result_df, + model, + usage_time_required_minutes, + charge_per_interval, + ): + # Make a copy of charge_per_interval if necessary + if charge_per_interval is not None: + charge_per_interval = charge_per_interval[::] + for i in range(len(charge_per_interval)): + if type(charge_per_interval[i]) == int: + charge_per_interval[i] = ( + charge_per_interval[i], + charge_per_interval[i], + ) + assert len(charge_per_interval[i]) == 2 + processed_start = ( + charge_per_interval[i][0] + if charge_per_interval[i][0] is not None + else 0 + ) + processed_end = ( + charge_per_interval[i][1] + if charge_per_interval[i][1] is not None + else self.MAX_INT + ) + + charge_per_interval[i] = (processed_start, processed_end) + + if not charge_per_interval: + # Handle case without charge_per_interval constraints + total_usage_intervals = sum(optimizer_result) + current_usage_intervals = 0 + usage_list = [] + for to_charge_binary in optimizer_result: + current_usage_intervals += to_charge_binary + if current_usage_intervals < total_usage_intervals: + usage_list.append(to_charge_binary * float(self.OPT_INTERVAL)) + else: + # Partial interval + minutes_to_trim = ( + total_usage_intervals * self.OPT_INTERVAL + - usage_time_required_minutes + ) + usage_list.append( + to_charge_binary * float(self.OPT_INTERVAL - minutes_to_trim) + ) + result_df["usage"] = usage_list + else: + # Process charge_per_interval constraints + result_df["usage"] = [ + x * float(self.OPT_INTERVAL) for x in optimizer_result + ] + usage = result_df["usage"].values + sections = [] + interval_ids = model.get_interval_ids() + + def get_min_max_indices(lst, x): + # Find the first occurrence of x + min_index = lst.index(x) + # Find the last occurrence of x + max_index = len(lst) - 1 - lst[::-1].index(x) + return min_index, max_index + + for interval_id in range(0, max(interval_ids) + 1): + assert ( + interval_id in interval_ids + ), "interval_id not found in interval_ids" + sections.append(get_min_max_indices(interval_ids, interval_id)) + + # Adjust sections to satisfy charge_per_interval constraints + for i, (start, end) in enumerate(sections): + section_usage = usage[start : end + 1] + total_minutes = section_usage.sum() + + # Get the constraints for this section + if isinstance(charge_per_interval[i], int): + min_minutes, max_minutes = ( + charge_per_interval[i], + charge_per_interval[i], + ) + else: + min_minutes, max_minutes = charge_per_interval[i] + + # Adjust the section to fit the constraints + if total_minutes < min_minutes: + raise ValueError( + f"Cannot meet the minimum charging constraint of {min_minutes} minutes for section {i}." + ) + elif total_minutes > max_minutes: + # Reduce usage to fit within the max_minutes + excess_minutes = total_minutes - max_minutes + for j in range(len(section_usage)): + if section_usage[j] > 0: + reduction = min(section_usage[j], excess_minutes) + section_usage[j] -= reduction + excess_minutes -= reduction + if excess_minutes <= 0: + break + usage[start : end + 1] = section_usage + result_df["usage"] = usage + + # Recalculate these values approximately, based on the new "usage" column + # Note: This is approximate since it assumes that + # the charging emissions over time of the unrounded values are similar to the rounded values + result_df["emissions_co2e_lb"] = ( + model.get_charging_emissions_over_time() + * result_df["usage"] + / self.OPT_INTERVAL + ) + result_df["energy_usage_mwh"] = ( + model.get_energy_usage_over_time() * result_df["usage"] / self.OPT_INTERVAL + ) + + return result_df + + +class WattTimeRecalculator: + """A class to manage and update charging schedules over time. + + This class maintains a list of charging schedules and their associated time contexts, + allowing for updates and recalculations of remaining charging time required. + + Attributes: + all_schedules (list): List of tuples containing (schedule, time_context) pairs + total_time_required (int): Total charging time needed in minutes + end_time (datetime): Final deadline for the charging schedule + charge_per_interval (list): List of charging durations per interval + is_contiguous (bool): Flag indicating if charging must be contiguous + sleep_delay(bool): Flag indicating if next query time must be delayed + contiguity_values_dict (dict): Dictionary storing contiguity-related values + """ + + def __init__( + self, + initial_schedule: pd.DataFrame, + start_time: datetime, + end_time: datetime, + total_time_required: int, + contiguous=False, + charge_per_interval: Optional[list] = None, + ) -> None: + """Initialize the Recalculator with an initial schedule. + + Args: + initial_schedule (pd.DataFrame): Starting charging schedule + start_time (datetime): Start time for the schedule + end_time (datetime): End time for the schedule + total_time_required (int): Total charging time needed in minutes + charge_per_interval (list): List of charging durations per interval + """ + self.OPT_INTERVAL = 5 + self.all_schedules = [(initial_schedule, (start_time, end_time))] + self.end_time = end_time + self.total_time_required = total_time_required + self.charge_per_interval = charge_per_interval + self.is_contiguous = contiguous + self.sleep_delay = False + self.contiguity_values_dict = { + "delay_usage_window_start": None, + "delay_in_minutes": None, + "delay_in_intervals": None, + "remaining_time_required": None, + "remaining_units_required": None, + "num_segments_complete": None, + } + + self.total_available_units = self.minutes_to_units( + int(int((self.end_time - start_time).total_seconds()) / 60) + ) + + def is_tz_aware(dt): + return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None + + def minutes_to_units(self, x, floor=False): + """Converts minutes to forecase intervals. Rounds UP by default.""" + if x: + if floor: + return int(x // self.OPT_INTERVAL) + else: + return int(math.ceil(x / self.OPT_INTERVAL)) + return x + + def get_remaining_units_required(self, next_query_time): + _minutes = self.get_remaining_time_required(next_query_time) + return self.minutes_to_units(_minutes) + + def get_remaining_time_required(self, next_query_time: datetime): + """Calculate remaining charging time needed at a given query time. + + Args: + next_query_time (datetime): Time from which to calculate remaining time + + Returns: + int: Remaining charging time required in minutes + """ + if len(self.all_schedules) == 0: + return self.total_time_required + + combined_schedule = self.get_combined_schedule() + t = next_query_time - timedelta(minutes=5) + + usage_in_minutes = combined_schedule.loc[:t]["usage"].sum() + + return self.total_time_required - usage_in_minutes + + def set_last_schedule_end_time(self, next_query_time: datetime): + """Update the end time of the most recent schedule. + + Args: + next_query_time (datetime): New end time for the last schedule + + Raises: + AssertionError: If new end time is before start time + """ + if len(self.all_schedules) > 0: + schedule, ctx = self.all_schedules[-1] + self.all_schedules[-1] = (schedule, (ctx[0], next_query_time)) + assert ctx[0] < next_query_time + + def update_charging_schedule( + self, + next_query_time: datetime, + next_new_schedule_start_time=None, + new_schedule: Optional[pd.DataFrame] = None, + ): + """ + Update charging schedule and contiguity values. + + Args: + next_query_time: Current query time + next_new_schedule_start_time: Start time for next schedule + new_schedule: New charging schedule to add + """ + + def _protocol_no_new_schedule(next_new_schedule_start_time): + """ + 1. Confirm that charging is not in progress and sleep delay is not required + """ + if self.is_contiguous is True: + self.sleep_delay = self.check_if_contiguity_sleep_required( + self.all_schedules[0][0], next_new_schedule_start_time + ) + else: + pass + + def _protocol_new_schedule( + new_schedule, next_query_time, next_new_schedule_start_time + ): + """ + 1. Modify previous schedule to end at "next_query_time" + 2. Append new schedule to record of existing schedules + 3. Confirm that charging is not in progress and sleep delay is not required + """ + self.set_last_schedule_end_time(next_query_time) + self.all_schedules.append((new_schedule, (next_query_time, self.end_time))) + if self.is_contiguous is True: + self.sleep_delay = self.check_if_contiguity_sleep_required( + new_schedule, next_new_schedule_start_time + ) + + def _protocol_sleep_delay(next_new_schedule_start_time): + print("sleep protocol activated...") + assert ( + next_new_schedule_start_time is not None + ), "Sleep delay next new time is None" + s = ( + self.get_combined_schedule().loc[next_new_schedule_start_time:]["usage"] + == 0 + ) + delay_time = ( + self.end_time + if s[s == True].empty == True + else s[s == True].index.min() + ) + self.contiguity_values_dict = { + "delay_usage_window_start": delay_time, + "delay_in_minutes": len(s[s == False]) * 5, + "delay_in_intervals": len(s[s == False]), + "remaining_units_required": self.get_remaining_units_required( + delay_time + ), + "remaining_time_required": self.get_remaining_time_required(delay_time), + } + + self.contiguity_values_dict["num_segments_complete"] = ( + self.number_segments_complete( + next_query_time=self.contiguity_values_dict[ + "delay_usage_window_start" + ] + ) + ) + + if new_schedule is None: + _protocol_no_new_schedule(next_new_schedule_start_time) + else: + _protocol_new_schedule( + new_schedule, next_query_time, next_new_schedule_start_time + ) + + if self.sleep_delay is True: + _protocol_sleep_delay(next_new_schedule_start_time) + else: + self.contiguity_values_dict = { + "delay_usage_window_start": None, + "delay_in_minutes": None, + "delay_in_intervals": None, + "remaining_units_required": self.get_remaining_units_required( + next_query_time + ), + "remaining_time_required": self.get_remaining_time_required( + next_query_time + ), + "num_segments_complete": self.number_segments_complete( + next_query_time=next_query_time + ), + } + + def get_combined_schedule(self, end_time: datetime = None) -> pd.DataFrame: + """Combine all schedules into a single DataFrame. + + Args: + end_time (datetime, optional): Optional cutoff time for the combined schedule + + Returns: + pd.DataFrame: Combined schedule of all charging segments + """ + schedule_segments = [] + for s, ctx in self.all_schedules: + schedule_segments.append(s[s.index < ctx[1]]) + combined_schedule = pd.concat(schedule_segments) + + if end_time: + last_segment_start_time = end_time + combined_schedule = combined_schedule.loc[:last_segment_start_time] + + return combined_schedule + + def check_if_contiguity_sleep_required(self, usage_plan, next_query_time): + """Check if charging needs to be paused for contiguity. + + Args: + usage_plan (pd.DataFrame): Planned charging schedule + next_query_time (datetime): Time of next schedule update + + Returns: + bool: True if charging needs to be paused + """ + return bool( + usage_plan.loc[(next_query_time - timedelta(minutes=5))]["usage"] > 0 + ) + + def number_segments_complete(self, next_query_time: datetime = None): + """Calculate number of completed charging segments. + + Args: + next_query_time (datetime, optional): Time to check completion status + + Returns: + int: Number of completed charging segments + """ + if self.is_contiguous is True: + combined_schedule = self.get_combined_schedule() + completed_schedule = combined_schedule.loc[:next_query_time] + charging_indicator = completed_schedule["usage"].astype(bool).sum() + return bisect.bisect_right( + list(accumulate(self.charge_per_interval)), (charging_indicator * 5) + ) + else: + return None + + +class RequerySimulator: + def __init__( + self, + moers_list, + requery_dates, + region="CAISO_NORTH", + window_start=datetime(2025, 1, 1, hour=21, second=1, tzinfo=UTC), + window_end=datetime(2025, 1, 2, hour=8, second=1, tzinfo=UTC), + usage_time_required_minutes=240, + usage_power_kw=2, + charge_per_interval=None, + ): + self.moers_list = moers_list + self.requery_dates = requery_dates + self.region = region + self.window_start = window_start + self.window_end = window_end + self.usage_time_required_minutes = usage_time_required_minutes + self.usage_power_kw = usage_power_kw + self.charge_per_interval = charge_per_interval + + self.username = os.getenv("WATTTIME_USER") + self.password = os.getenv("WATTTIME_PASSWORD") + self.wt_opt = WattTimeOptimizer(self.username, self.password) + + def _get_initial_plan(self): + return self.wt_opt.get_optimal_usage_plan( + region=self.region, + usage_window_start=self.window_start, + usage_window_end=self.window_end, + usage_time_required_minutes=self.usage_time_required_minutes, + usage_power_kw=self.usage_power_kw, + charge_per_interval=self.charge_per_interval, + optimization_method="simple", + moer_data_override=self.moers_list[0][["point_time", "value"]], + ) + + def simulate(self): + initial_plan = self._get_initial_plan() + recalculator = WattTimeRecalculator( + initial_schedule=initial_plan, + start_time=self.window_start, + end_time=self.window_end, + total_time_required=self.usage_time_required_minutes, + charge_per_interval=self.charge_per_interval, + ) + + # check to see the status of my segments to know if I should requery at all + # if I do need to requery, then I need time required + segments remaining + # if I don't then I store the state of my recalculator as is + + for i, new_window_start in enumerate(self.requery_dates[1:], 1): + new_time_required = recalculator.get_remaining_time_required( + new_window_start + ) + if new_time_required > 0.0: + next_plan = self.wt_opt.get_optimal_usage_plan( + region=self.region, + usage_window_start=new_window_start, + usage_window_end=self.window_end, + usage_time_required_minutes=new_time_required, + usage_power_kw=self.usage_power_kw, + charge_per_interval=self.charge_per_interval, + optimization_method="simple", + moer_data_override=self.moers_list[i][["point_time", "value"]], + ) + recalculator.update_charging_schedule( + new_schedule=next_plan, + next_query_time=new_window_start, + next_new_schedule_start_time=None, + ) + else: + return recalculator diff --git a/watttime/evaluator/evaluator.py b/optimizer/evaluator/evaluator.py similarity index 100% rename from watttime/evaluator/evaluator.py rename to optimizer/evaluator/evaluator.py diff --git a/watttime/evaluator/sessions.py b/optimizer/evaluator/sessions.py similarity index 100% rename from watttime/evaluator/sessions.py rename to optimizer/evaluator/sessions.py diff --git a/watttime/evaluator/utils.py b/optimizer/evaluator/utils.py similarity index 100% rename from watttime/evaluator/utils.py rename to optimizer/evaluator/utils.py diff --git a/_example_synthetic_data.ipynb b/optimizer/examples/_example_synthetic_data.ipynb similarity index 100% rename from _example_synthetic_data.ipynb rename to optimizer/examples/_example_synthetic_data.ipynb diff --git a/setup.py b/setup.py index 86ba5e47..d138bddf 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ long_description=open('README.md').read(), long_description_content_type="text/markdown", version="v1.2.1", - packages=["watttime"], + packages=["watttime","optimizer"], python_requires=">=3.8", install_requires=["requests", "pandas>1.0.0", "python-dateutil"], ) diff --git a/tests/test_optimizer.py b/tests/test_optimizer.py index 92927895..3c5e4d9c 100644 --- a/tests/test_optimizer.py +++ b/tests/test_optimizer.py @@ -3,7 +3,7 @@ import unittest import pandas as pd from pytz import UTC -from watttime.api import WattTimeOptimizer +from optimizer import WattTimeOptimizer REGION = "CAISO_NORTH" diff --git a/watttime/api.py b/watttime/api.py index 6b74937c..1e918af8 100644 --- a/watttime/api.py +++ b/watttime/api.py @@ -10,7 +10,6 @@ import requests from dateutil.parser import parse from pytz import UTC, timezone -from watttime.optimizer.alg import optCharger, moer from itertools import accumulate import bisect @@ -545,415 +544,6 @@ def get_historical_forecast_pandas( out = pd.concat([out, _df]) return out - -OPT_INTERVAL = 5 -MAX_PREDICTION_HOURS = 72 - - -class WattTimeOptimizer(WattTimeForecast): - """ - This class inherits from WattTimeForecast, with additional methods to generate - optimal usage plans for energy consumption based on various parameters and - constraints. - - Additional Methods: - -------- - get_optimal_usage_plan(region, usage_window_start, usage_window_end, - usage_time_required_minutes, usage_power_kw, - usage_time_uncertainty_minutes, optimization_method, - moer_data_override) - Generates an optimal usage plan for energy consumption. - """ - - OPT_INTERVAL = 5 - MAX_PREDICTION_HOURS = 72 - MAX_INT = 99999999999999999 - - def get_optimal_usage_plan( - self, - region: str, - usage_window_start: datetime, - usage_window_end: datetime, - usage_time_required_minutes: Optional[Union[int, float]] = None, - usage_power_kw: Optional[Union[int, float, pd.DataFrame]] = None, - energy_required_kwh: Optional[Union[int, float]] = None, - usage_time_uncertainty_minutes: Optional[Union[int, float]] = 0, - charge_per_interval: Optional[list] = None, - use_all_intervals: bool = True, - constraints: Optional[dict] = None, - optimization_method: Optional[ - Literal["baseline", "simple", "sophisticated", "auto"] - ] = "baseline", - moer_data_override: Optional[pd.DataFrame] = None, - verbose = True - ) -> pd.DataFrame: - """ - Generates an optimal usage plan for energy consumption based on given parameters. - - This method calculates the most efficient energy usage schedule within a specified - time window, considering factors such as regional data, power requirements, and - optimization methods. - - You should pass in exactly 2 of 3 parameters of (usage_time_required_minutes, usage_power_kw, energy_required_kwh) - - Parameters: - ----------- - region : str - The region for which forecast data is requested. - usage_window_start : datetime - Start time of the window when power consumption is allowed. - usage_window_end : datetime - End time of the window when power consumption is allowed. - usage_time_required_minutes : Optional[Union[int, float]], default=None - Required usage time in minutes. - usage_power_kw : Optional[Union[int, float, pd.DataFrame]], default=None - Power usage in kilowatts. Can be a constant value or a DataFrame for variable power. - energy_required_kwh : Optional[Union[int, float]], default=None - Energy required in kwh - usage_time_uncertainty_minutes : Optional[Union[int, float]], default=0 - Uncertainty in usage time, in minutes. - charge_per_interval : Optional[list], default=None - Either a list of length-2 tuples representing minimium and maximum (inclusive) charging minutes per interval, - or a list of ints representing both the min and max. - use_all_intervals : Optional[bool], default=False - If true, use all intervals provided by charge_per_interval; if false, can use the first few intervals and skip the rest. - constraints : Optional[dict], default=None - A dictionary containing contraints on how much usage must be used before the given time point - optimization_method : Optional[Literal["baseline", "simple", "sophisticated", "auto"]], default="baseline" - The method used for optimization. - moer_data_override : Optional[pd.DataFrame], default=None - Pre-generated MOER (Marginal Operating Emissions Rate) DataFrame, if available. - verbose : default = True - If false, suppresses print statements in the opt charger class. - - Returns: - -------- - pd.DataFrame - A DataFrame representing the optimal usage plan, including columns for - predicted MOER, usage, CO2 emissions, and energy usage. - - Raises: - ------- - AssertionError - If input parameters do not meet specified conditions (e.g., timezone awareness, - valid time ranges, supported optimization methods). - - Notes: - ------ - - The method uses WattTime forecast data unless overridden by moer_data_override. - - It supports various optimization methods and can handle both constant and variable power usage. - - The resulting plan aims to minimize emissions while meeting the specified energy requirements. - """ - - def is_tz_aware(dt): - return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None - - def minutes_to_units(x, floor=False): - '''Converts minutes to forecase intervals. Rounds UP by default.''' - if x: - if floor: - return int(x // self.OPT_INTERVAL) - else: - return int(math.ceil(x / self.OPT_INTERVAL)) - return x - - assert is_tz_aware(usage_window_start), "Start time is not tz-aware" - assert is_tz_aware(usage_window_end), "End time is not tz-aware" - - if constraints is None: - constraints = {} - else: - # Convert constraints to a standardized format - raw_constraints = constraints.copy() - constraints = {} - - for ( - constraint_time_clock, - constraint_usage_minutes, - ) in raw_constraints.items(): - constraint_time_minutes = ( - constraint_time_clock - usage_window_start - ).total_seconds() / 60 - constraint_time_units = minutes_to_units(constraint_time_minutes) - constraint_usage_units = minutes_to_units(constraint_usage_minutes) - - constraints.update( - {constraint_time_units: (constraint_usage_units, None)} - ) - - num_inputs = 0 - for input in (usage_time_required_minutes, usage_power_kw, energy_required_kwh): - if input is not None: - num_inputs += 1 - assert ( - num_inputs == 2 - ), "Exactly 2 of 3 inputs in (usage_time_required_minutes, usage_power_kw, energy_required_kwh) required" - if usage_power_kw is None: - usage_power_kw = energy_required_kwh / usage_time_required_minutes * 60 - print("Implied usage_power_kw =", usage_power_kw) - if usage_time_required_minutes is None: - if type(usage_power_kw) in (float, int) and type(energy_required_kwh) in ( - float, - int, - ): - usage_time_required_minutes = energy_required_kwh / usage_power_kw * 60 - print("Implied usage time required =", usage_time_required_minutes) - else: - # TODO: Implement and test - raise NotImplementedError( - "When usage_time_required_minutes is None, only float or int usage_power_kw and energy_required_kwh is supported." - ) - - # Perform these checks if we are using live data - if moer_data_override is None: - datetime_now = datetime.now(UTC) - assert ( - usage_window_end > datetime_now - ), "Error, Window end is before current datetime" - assert usage_window_end - datetime_now < timedelta( - hours=self.MAX_PREDICTION_HOURS - ), "End time is too far in the future" - assert optimization_method in ("baseline", "simple", "sophisticated", "auto"), ( - "Unsupported optimization method:" + optimization_method - ) - if moer_data_override is None: - forecast_df = self.get_forecast_pandas( - region=region, - signal_type="co2_moer", - horizon_hours=self.MAX_PREDICTION_HOURS, - ) - else: - forecast_df = moer_data_override.copy() - forecast_df = forecast_df.set_index("point_time") - forecast_df.index = pd.to_datetime(forecast_df.index) - - # relevant_forecast_df = forecast_df[usage_window_start:usage_window_end] - relevant_forecast_df = forecast_df[forecast_df.index >= usage_window_start] - relevant_forecast_df = relevant_forecast_df[ - relevant_forecast_df.index < usage_window_end - ] - relevant_forecast_df = relevant_forecast_df.rename( - columns={"value": "pred_moer"} - ) - result_df = relevant_forecast_df[["pred_moer"]] - moer_values = relevant_forecast_df["pred_moer"].values - - m = moer.Moer(mu=moer_values) - - model = optCharger.OptCharger(verbose=verbose) - - total_charge_units = minutes_to_units(usage_time_required_minutes) - if optimization_method in ("sophisticated", "auto"): - # Give a buffer time equal to the uncertainty - buffer_time = usage_time_uncertainty_minutes - buffer_periods = minutes_to_units(buffer_time) if buffer_time else 0 - buffer_enforce_time = max( - total_charge_units, len(moer_values) - buffer_periods - ) - constraints.update({buffer_enforce_time: (total_charge_units, None)}) - else: - assert ( - usage_time_uncertainty_minutes == 0 - ), "usage_time_uncertainty_minutes is only supported in optimization_method='sophisticated' or 'auto'" - - if type(usage_power_kw) in (int, float): - # Convert to the MWh used in an optimization interval - # expressed as a function to meet the parameter requirements for OptC function - emission_multiplier_fn = ( - lambda sc, ec: float(usage_power_kw) * 0.001 * self.OPT_INTERVAL / 60.0 - ) - else: - usage_power_kw = usage_power_kw.copy() - # Resample usage power dataframe to an OPT_INTERVAL frequency - usage_power_kw["time_step"] = usage_power_kw["time"] / self.OPT_INTERVAL - usage_power_kw_new_index = pd.DataFrame( - index=[float(x) for x in range(total_charge_units + 1)] - ) - usage_power_kw = pd.merge_asof( - usage_power_kw_new_index, - usage_power_kw.set_index("time_step"), - left_index=True, - right_index=True, - direction="backward", - allow_exact_matches=True, - ) - - def emission_multiplier_fn(sc: float, ec: float) -> float: - """ - Calculate the approximate mean power in the given time range, - in units of MWh used per optimizer time unit. - - sc and ec are float values representing the start and end time of - the time range, in optimizer time units. - """ - value = ( - usage_power_kw[sc : max(sc, ec - 1e-12)]["power_kw"].mean() - * 0.001 - * self.OPT_INTERVAL - / 60.0 - ) - return value - - if charge_per_interval: - # Handle the charge_per_interval input by converting it from minutes to units, rounding up - converted_charge_per_interval = [] - for c in charge_per_interval: - if isinstance(c, int): - converted_charge_per_interval.append(minutes_to_units(c)) - else: - assert ( - len(c) == 2 - ), "Length of tuples in charge_per_interval is not 2" - interval_start_units = minutes_to_units(c[0]) if c[0] else 0 - interval_end_units = ( - minutes_to_units(c[1]) if c[1] else self.MAX_INT - ) - converted_charge_per_interval.append( - (interval_start_units, interval_end_units) - ) - else: - converted_charge_per_interval = None - model.fit( - total_charge=total_charge_units, - total_time=len(moer_values), - moer=m, - constraints=constraints, - charge_per_interval=converted_charge_per_interval, - use_all_intervals=use_all_intervals, - emission_multiplier_fn=emission_multiplier_fn, - optimization_method=optimization_method, - ) - - optimizer_result = model.get_schedule() - result_df = self._reconcile_constraints( - optimizer_result, - result_df, - model, - usage_time_required_minutes, - charge_per_interval, - ) - - return result_df - - def _reconcile_constraints( - self, - optimizer_result, - result_df, - model, - usage_time_required_minutes, - charge_per_interval, - ): - # Make a copy of charge_per_interval if necessary - if charge_per_interval is not None: - charge_per_interval = charge_per_interval[::] - for i in range(len(charge_per_interval)): - if type(charge_per_interval[i]) == int: - charge_per_interval[i] = ( - charge_per_interval[i], - charge_per_interval[i], - ) - assert len(charge_per_interval[i]) == 2 - processed_start = ( - charge_per_interval[i][0] - if charge_per_interval[i][0] is not None - else 0 - ) - processed_end = ( - charge_per_interval[i][1] - if charge_per_interval[i][1] is not None - else self.MAX_INT - ) - - charge_per_interval[i] = (processed_start, processed_end) - - if not charge_per_interval: - # Handle case without charge_per_interval constraints - total_usage_intervals = sum(optimizer_result) - current_usage_intervals = 0 - usage_list = [] - for to_charge_binary in optimizer_result: - current_usage_intervals += to_charge_binary - if current_usage_intervals < total_usage_intervals: - usage_list.append(to_charge_binary * float(self.OPT_INTERVAL)) - else: - # Partial interval - minutes_to_trim = ( - total_usage_intervals * self.OPT_INTERVAL - - usage_time_required_minutes - ) - usage_list.append( - to_charge_binary * float(self.OPT_INTERVAL - minutes_to_trim) - ) - result_df["usage"] = usage_list - else: - # Process charge_per_interval constraints - result_df["usage"] = [ - x * float(self.OPT_INTERVAL) for x in optimizer_result - ] - usage = result_df["usage"].values - sections = [] - interval_ids = model.get_interval_ids() - - def get_min_max_indices(lst, x): - # Find the first occurrence of x - min_index = lst.index(x) - # Find the last occurrence of x - max_index = len(lst) - 1 - lst[::-1].index(x) - return min_index, max_index - - for interval_id in range(0, max(interval_ids) + 1): - assert ( - interval_id in interval_ids - ), "interval_id not found in interval_ids" - sections.append(get_min_max_indices(interval_ids, interval_id)) - - # Adjust sections to satisfy charge_per_interval constraints - for i, (start, end) in enumerate(sections): - section_usage = usage[start : end + 1] - total_minutes = section_usage.sum() - - # Get the constraints for this section - if isinstance(charge_per_interval[i], int): - min_minutes, max_minutes = ( - charge_per_interval[i], - charge_per_interval[i], - ) - else: - min_minutes, max_minutes = charge_per_interval[i] - - # Adjust the section to fit the constraints - if total_minutes < min_minutes: - raise ValueError( - f"Cannot meet the minimum charging constraint of {min_minutes} minutes for section {i}." - ) - elif total_minutes > max_minutes: - # Reduce usage to fit within the max_minutes - excess_minutes = total_minutes - max_minutes - for j in range(len(section_usage)): - if section_usage[j] > 0: - reduction = min(section_usage[j], excess_minutes) - section_usage[j] -= reduction - excess_minutes -= reduction - if excess_minutes <= 0: - break - usage[start : end + 1] = section_usage - result_df["usage"] = usage - - # Recalculate these values approximately, based on the new "usage" column - # Note: This is approximate since it assumes that - # the charging emissions over time of the unrounded values are similar to the rounded values - result_df["emissions_co2e_lb"] = ( - model.get_charging_emissions_over_time() - * result_df["usage"] - / self.OPT_INTERVAL - ) - result_df["energy_usage_mwh"] = ( - model.get_energy_usage_over_time() * result_df["usage"] / self.OPT_INTERVAL - ) - - return result_df - - class WattTimeMaps(WattTimeBase): def get_maps_json( self, @@ -981,229 +571,7 @@ def get_maps_json( rsp.raise_for_status() return rsp.json() -class WattTimeRecalculator: - """A class to manage and update charging schedules over time. - - This class maintains a list of charging schedules and their associated time contexts, - allowing for updates and recalculations of remaining charging time required. - - Attributes: - all_schedules (list): List of tuples containing (schedule, time_context) pairs - total_time_required (int): Total charging time needed in minutes - end_time (datetime): Final deadline for the charging schedule - charge_per_interval (list): List of charging durations per interval - is_contiguous (bool): Flag indicating if charging must be contiguous - sleep_delay(bool): Flag indicating if next query time must be delayed - contiguity_values_dict (dict): Dictionary storing contiguity-related values - """ - def __init__( - self, - initial_schedule: pd.DataFrame, - start_time: datetime, - end_time: datetime, - total_time_required: int, - contiguous = False, - charge_per_interval: Optional[list] = None - ) -> None: - """Initialize the Recalculator with an initial schedule. - - Args: - initial_schedule (pd.DataFrame): Starting charging schedule - start_time (datetime): Start time for the schedule - end_time (datetime): End time for the schedule - total_time_required (int): Total charging time needed in minutes - charge_per_interval (list): List of charging durations per interval - """ - self.OPT_INTERVAL = 5 - self.all_schedules = [(initial_schedule, (start_time, end_time))] - self.end_time=end_time - self.total_time_required = total_time_required - self.charge_per_interval = charge_per_interval - self.is_contiguous = contiguous - self.sleep_delay = False - self.contiguity_values_dict = { - "delay_usage_window_start": None, - "delay_in_minutes": None, - "delay_in_intervals": None, - "remaining_time_required": None, - "remaining_units_required":None, - "num_segments_complete": None - } - - self.total_available_units = self.minutes_to_units( - int(int((self.end_time - start_time).total_seconds()) / 60) - ) - - def is_tz_aware(dt): - return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None - - def minutes_to_units(self, x, floor=False): - '''Converts minutes to forecase intervals. Rounds UP by default.''' - if x: - if floor: - return int(x // self.OPT_INTERVAL) - else: - return int(math.ceil(x / self.OPT_INTERVAL)) - return x - - def get_remaining_units_required(self,next_query_time): - _minutes = self.get_remaining_time_required(next_query_time) - return self.minutes_to_units(_minutes) - - def get_remaining_time_required(self, next_query_time: datetime): - """Calculate remaining charging time needed at a given query time. - - Args: - next_query_time (datetime): Time from which to calculate remaining time - - Returns: - int: Remaining charging time required in minutes - """ - if len(self.all_schedules) == 0: - return self.total_time_required - - combined_schedule = self.get_combined_schedule() - t = next_query_time - timedelta(minutes=5) - - usage_in_minutes = combined_schedule.loc[:t]["usage"].sum() - - return self.total_time_required - usage_in_minutes - - def set_last_schedule_end_time(self, next_query_time: datetime): - """Update the end time of the most recent schedule. - - Args: - next_query_time (datetime): New end time for the last schedule - - Raises: - AssertionError: If new end time is before start time - """ - if len(self.all_schedules) > 0: - schedule, ctx = self.all_schedules[-1] - self.all_schedules[-1] = (schedule, (ctx[0], next_query_time)) - assert ctx[0] < next_query_time - - def update_charging_schedule( - self, - next_query_time: datetime, - next_new_schedule_start_time = None, - new_schedule: Optional[pd.DataFrame] = None - ): - """ - Update charging schedule and contiguity values. - - Args: - next_query_time: Current query time - next_new_schedule_start_time: Start time for next schedule - new_schedule: New charging schedule to add - """ - - def _protocol_no_new_schedule(next_new_schedule_start_time): - ''' - 1. Confirm that charging is not in progress and sleep delay is not required - ''' - if self.is_contiguous is True: - self.sleep_delay = self.check_if_contiguity_sleep_required(self.all_schedules[0][0], next_new_schedule_start_time) - else: - pass - - def _protocol_new_schedule(new_schedule, next_query_time, next_new_schedule_start_time): - ''' - 1. Modify previous schedule to end at "next_query_time" - 2. Append new schedule to record of existing schedules - 3. Confirm that charging is not in progress and sleep delay is not required - ''' - self.set_last_schedule_end_time(next_query_time) - self.all_schedules.append((new_schedule, (next_query_time, self.end_time))) - if self.is_contiguous is True: - self.sleep_delay = self.check_if_contiguity_sleep_required(new_schedule, next_new_schedule_start_time) - - def _protocol_sleep_delay(next_new_schedule_start_time): - print('sleep protocol activated...') - assert next_new_schedule_start_time is not None, "Sleep delay next new time is None" - s = self.get_combined_schedule().loc[next_new_schedule_start_time:]['usage'] == 0 - delay_time = self.end_time if s[s == True].empty == True else s[s == True].index.min() - self.contiguity_values_dict = { - "delay_usage_window_start": delay_time, - "delay_in_minutes": len(s[s == False]) * 5, - "delay_in_intervals": len(s[s == False]), - "remaining_units_required": self.get_remaining_units_required(delay_time), - "remaining_time_required": self.get_remaining_time_required(delay_time) - } - - self.contiguity_values_dict["num_segments_complete"] = self.number_segments_complete(next_query_time=self.contiguity_values_dict["delay_usage_window_start"]) - - if new_schedule is None: - _protocol_no_new_schedule(next_new_schedule_start_time) - else: - _protocol_new_schedule(new_schedule, next_query_time, next_new_schedule_start_time) - - if self.sleep_delay is True: - _protocol_sleep_delay(next_new_schedule_start_time) - else: - self.contiguity_values_dict = { - "delay_usage_window_start": None, - "delay_in_minutes": None, - "delay_in_intervals": None, - "remaining_units_required": self.get_remaining_units_required(next_query_time), - "remaining_time_required": self.get_remaining_time_required(next_query_time), - "num_segments_complete": self.number_segments_complete(next_query_time=next_query_time) - } - - def get_combined_schedule(self, end_time: datetime = None) -> pd.DataFrame: - """Combine all schedules into a single DataFrame. - - Args: - end_time (datetime, optional): Optional cutoff time for the combined schedule - - Returns: - pd.DataFrame: Combined schedule of all charging segments - """ - schedule_segments = [] - for s, ctx in self.all_schedules: - schedule_segments.append(s[s.index < ctx[1]]) - combined_schedule = pd.concat(schedule_segments) - - if end_time: - last_segment_start_time = end_time - combined_schedule = combined_schedule.loc[:last_segment_start_time] - - return combined_schedule - - def check_if_contiguity_sleep_required(self, usage_plan, next_query_time): - """Check if charging needs to be paused for contiguity. - - Args: - usage_plan (pd.DataFrame): Planned charging schedule - next_query_time (datetime): Time of next schedule update - - Returns: - bool: True if charging needs to be paused - """ - return bool(usage_plan.loc[(next_query_time - timedelta(minutes=5))]['usage'] > 0) - - def number_segments_complete(self, next_query_time: datetime = None): - """Calculate number of completed charging segments. - - Args: - next_query_time (datetime, optional): Time to check completion status - - Returns: - int: Number of completed charging segments - """ - if self.is_contiguous is True: - combined_schedule = self.get_combined_schedule() - completed_schedule = combined_schedule.loc[:next_query_time] - charging_indicator = completed_schedule["usage"].astype(bool).sum() - return bisect.bisect_right( - list(accumulate(self.charge_per_interval)), - (charging_indicator * 5) - ) - else: - return None - -class RequerySimulator: def __init__(self, moers_list, requery_dates, diff --git a/watttime/optimizer/Optimizer README.md b/watttime/optimizer/Optimizer README.md deleted file mode 100644 index 10d2356f..00000000 --- a/watttime/optimizer/Optimizer README.md +++ /dev/null @@ -1,89 +0,0 @@ -# Optimizer: basic principles and options - -The **basic intuition of the algorithm** is that when a device is plugged in for longer than the time required to fully charge it there exist ways to pick charging vs. non-charging time intervals such that the device draws power from the grid during cleaner intervals and thus minimizes emissions. **The algorithm takes as inputs** user and device parameters such as: the plug-in and plug-out times of the device, as well as the charging curve that determines the time it takes to charge it as well as the power that it needs to draw. **As an output, it produces** a charging schedule that divides the time between plug-in and plug-out time into charging and non-charging intervals such that emissions are minimized. Watttime’s forecast provides the basic building block for these algorithms as it forecasts when those relatively cleaner grid periods occur. - -There are **three different optimization algorithms** that are implemented in the API (alongside a baseline algorithm that just charges the device from the moment it’s plugged in to when it is fully charged, which is what devices do out of the box). We first start with **a simple algorithm** that, under full information about plug out time, uses the forecast to find the lowest possible emission interval that charges the device and outputs a charge schedule based on that. We then follow with a **sophisticated** version of the algorithm which takes into account variable charging curves and implements a dynamic optimization algorithm to adjust for the fact that device charging curves are non-linear. We provide additional functionality in the **fixed contiguous** and **contiguous** versions of the algorithms, which can enforce the charging schedule to be composed of several contiguous intervals; the length of each interval is either fixed or falls in a provided range. - -| optimization\_method | ASAP | Charging curve | Time constraint | Contiguous | -| :---- | :---- | :---- | :---- | :---- | -| baseline | Yes | Constant | No | No | -| simple | No | Constant | No | No | -| sophisticated | No | Variable | Yes | No | -| contiguous | No | Variable | Yes | Intervals at fixed lengths | -| Variable contiguous | No | Variable | Yes | Intervals at variable lengths | -| auto | No | Chooses the fastest algorithm that can still process all inputs | | | - -### Raw Inputs - -*What we simulate for each use case* - -- Capacity C - - Might also need init battery capacity if we don’t start from 0% - - Unit: kWh - - Type: energy -- Power usage curve from capacity Q:cp - - Marginal power usage to charge battery when it’s currently at capacity c - - Unit: kW - - Type: power -- Marginal emission rate M:tm - - Unit: lb/MWh - - Type: emission per energy - - We convert this to lb/kWh by multiplying M by 0.001 -- OPT\_INTERVAL - - Smallest interval on which we have constant charging behavior and emissions. - - Currently set to 5 minutes - - We have now discretized time into L=T/ intervals of length . The l-th interval is lt\<(l+1). -- Contiguity -- Constraints - -### API Inputs - -*When calling the API* - -- usage\_time\_required\_minutes Tr - - We compute this using C and Q. See example below. - - Unit: mins -- usage\_power\_kw P:tp - - Marginal power usage to charge battery when it has been charged for t minutes. Converted from Q. - - Unit: kW -- usage\_window\_start, usage\_window\_end - - These are timestamps to specify the charging window - -### Algorithm - -Find schedule s0,...,sL-1 that minimizes total emission 60l=0L-1slPl'=0l-1sl' Ml1000subject to - -* sl0,1 - * sl=1 if we charge on interval l - * sl=0 if we do not charge on interval l - * As an extension for future use cases, suppose we can supercharge the battery by consuming up to K times as much power. The DP algorithm will also be able to handle this optimization and output a schedule with sl0,1,...,K -* l=0L-1sl=Tr - * This just means that we charge for a total of Tr minutes according to this schedule. - -### API Output - - A data frame with \-spaced timestamp index and charging usage. For example, - -| time | usage (min) | energe\_use\_mwh | emissions\_co2e\_lb | -| :---- | :---- | :---- | :---- | -| 2024-7-26 15:00:00+00:00 | 5 | 0.0001 | 1.0 | -| 2024-7-26 15:05:00+00:00 | 5 | 0.0001 | 1.0 | -| 2024-7-26 15:10:00+00:00 | 0 | 0\. | 0.0 | -| 2024-7-26 15:15:00+00:00 | 5 | 0.00005 | 0.5 | - -This would mean that we charge from 15:00-15:10 and then from 15:15-15:20. Note that the last column reflects **forecast emissions** based on forecast emission rate rather than the actuals. To compute actual emissions, you can take the dot product of energey\_use\_mwh and actual emission rates. - -In mathematical terms, we compute these three columns as follows. For the l-th interval tl,l+1, we have - -* usage: sl -* energy\_use\_mwh: 1100060slPl'=0l-1sl', where 60 reflects interval length in hours and Pl'=0l-1sl' is the power. 11000 is a conversion factor from kWh to mWh -* emissions\_co2e\_lb: energy\_us\_mwh \* Mt. - -# FAQs - -## How much co2 can we expect to avoid by using the optimizer? - -The amount of emission you can avoid will vary significantly based on a range of factors. For example: - -* The grid where the charging is occurring. -* The amount of “slack time” available, that is, possible charging time beyond the minimum amount required for charging. \ No newline at end of file diff --git a/watttime/optimizer/test.py b/watttime/optimizer/test.py deleted file mode 100644 index 02784b54..00000000 --- a/watttime/optimizer/test.py +++ /dev/null @@ -1,149 +0,0 @@ -from alg import moer, optCharger - -model = optCharger.OptCharger() - -m = moer.Moer( - mu=[10, 10, 10, 1, 13, 3, 2, 3], -) -print("Length of schedule:", len(m)) - -print("greedy algo") -model.fit(total_charge=3, total_time=8, moer=m, optimization_method="baseline") -model.summary() -print("simple sorting algo") -model.fit( - total_charge=3, - total_time=8, - moer=m, -) -model.summary() -print("sophisticated algo that produces same answer as simple") -model.fit(total_charge=3, total_time=8, moer=m, optimization_method="sophisticated") -model.summary() -print("incorrect pairing of simple sorting algo + variable charge rate") -model.fit( - total_charge=3, - total_time=8, - moer=m, - emission_multiplier_fn=lambda x, y: [1.0, 2.0, 1.0][x], - optimization_method="simple", -) -model.summary() -print("sophisticated algo + variable charge rate") -model.fit( - total_charge=3, - total_time=8, - moer=m, - emission_multiplier_fn=lambda x, y: [1.0, 2.0, 1.0][x], -) -model.summary() -print("sophisticated algo + constraints") -model.fit(total_charge=3, total_time=8, moer=m, constraints={2: (2, None)}) -model.summary() - -m = moer.Moer( - mu=[2, 1, 10, 10, 10, 1, 13, 3], -) - -# Fixed Contiguous -print("One contiguous interval") -model.fit(total_charge=3, total_time=8, moer=m, charge_per_interval=[3]) -model.summary() -print("Two contiguous intervals") -model.fit(total_charge=3, total_time=8, moer=m, charge_per_interval=[2, 1]) -model.summary() -print("Two contiguous intervals, one of which given as intervals + variable power rate") -model.fit( - total_charge=3, - total_time=8, - moer=m, - charge_per_interval=[(2, 2), 1], - emission_multiplier_fn=lambda x, y: [1.0, 0.1, 1.0][x], -) -model.summary() -print("Two contiguous intervals, one of which given as intervals + variable power rate") -model.fit( - total_charge=3, - total_time=8, - moer=m, - charge_per_interval=[2, (1, 1)], - emission_multiplier_fn=lambda x, y: [1.0, 0.1, 1.0][x], -) -model.summary() -print("Two contiguous intervals, one of which given as intervals + variable power rate") -model.fit( - total_charge=3, - total_time=8, - moer=m, - charge_per_interval=[(2, 2), (1, 1)], - emission_multiplier_fn=lambda x, y: [1.0, 0.1, 1.0][x], -) -model.summary() -print("Two contiguous intervals + variable power rate + constraints") -model.fit( - total_charge=4, - total_time=8, - moer=m, - charge_per_interval=[3, 1], - constraints={2: (None, 1), 5: (3, None)}, -) -model.summary() - -# Variable Contiguous -print("One contiguous interval") -model.fit(total_charge=3, total_time=8, moer=m, charge_per_interval=[(0, 3)]) -model.summary() -print("Two contiguous intervals") -model.fit(total_charge=3, total_time=8, moer=m, charge_per_interval=[(1, 2), (0, 3)]) -model.summary() -print("Two contiguous intervals + variable power rate") -model.fit( - total_charge=3, - total_time=8, - moer=m, - charge_per_interval=[(1, 2), (1, 2)], - emission_multiplier_fn=lambda x, y: [1.0, 0.1, 1.0][x], -) -model.summary() -print("Two contiguous intervals + variable power rate") -model.fit(total_charge=4, total_time=8, moer=m, charge_per_interval=[(1, 3), (0, 3)]) -model.summary() -print("Two contiguous intervals + variable power rate + constraints") -model.fit( - total_charge=4, - total_time=8, - moer=m, - charge_per_interval=[(1, 3), (0, 3)], - constraints={2: (None, 1)}, -) -model.summary() -print("Two contiguous intervals + variable power rate + constraints") -model.fit( - total_charge=4, - total_time=8, - moer=m, - charge_per_interval=[(1, 3), (0, 3)], - constraints={2: (None, 1), 5: (3, None)}, -) -model.summary() - -m = moer.Moer( - mu=[10, 1, 1, 1, 10, 1, 1, 1], -) -print("Three contiguous intervals of fixed lengths") -model.fit(total_charge=6, total_time=8, moer=m, charge_per_interval=[2] * 3) -model.summary() -print("Three contiguous intervals of variable lengths") -model.fit(total_charge=6, total_time=8, moer=m, charge_per_interval=[(2, 6)] * 3) -model.summary() -print( - "Three contiguous intervals of variable lengths, but doesnt need to charge all intervals" -) -model.fit( - total_charge=6, - total_time=8, - moer=m, - charge_per_interval=[(2, 6)] * 3, - use_all_intervals=False, -) -model.summary() From 66aaafef24bdc59823bede3fbe1362ae25f34ce0 Mon Sep 17 00:00:00 2001 From: jbadsdata Date: Mon, 5 May 2025 17:36:01 -0400 Subject: [PATCH 2/5] synthetic notebook error corrected, other minor fixes --- optimizer/__init__.py | 2 - .../examples/_example_synthetic_data.ipynb | 832 -------------- setup.py | 4 +- tests/test_optimizer.py | 2 +- .../Optimizer README.md | 28 +- watttime_optimizer/__init__.py | 4 + .../alg/__init__.py | 0 {optimizer => watttime_optimizer}/alg/moer.py | 0 .../alg/optCharger.py | 0 .../api_convert.py | 0 {optimizer => watttime_optimizer}/api_opt.py | 6 +- watttime_optimizer/battery.py | 65 ++ watttime_optimizer/evaluator/__init__.py | 1 + watttime_optimizer/evaluator/analysis.py | 68 ++ .../evaluator/evaluator.py | 11 +- .../evaluator/sessions.py | 7 +- .../evaluator/utils.py | 0 .../examples/synthetic_data.ipynb | 1021 +++++++++++++++++ 18 files changed, 1193 insertions(+), 858 deletions(-) delete mode 100644 optimizer/__init__.py delete mode 100644 optimizer/examples/_example_synthetic_data.ipynb rename {optimizer => watttime_optimizer}/Optimizer README.md (90%) create mode 100644 watttime_optimizer/__init__.py rename {optimizer => watttime_optimizer}/alg/__init__.py (100%) rename {optimizer => watttime_optimizer}/alg/moer.py (100%) rename {optimizer => watttime_optimizer}/alg/optCharger.py (100%) rename {optimizer => watttime_optimizer}/api_convert.py (100%) rename {optimizer => watttime_optimizer}/api_opt.py (99%) create mode 100644 watttime_optimizer/battery.py create mode 100644 watttime_optimizer/evaluator/__init__.py create mode 100644 watttime_optimizer/evaluator/analysis.py rename {optimizer => watttime_optimizer}/evaluator/evaluator.py (96%) rename {optimizer => watttime_optimizer}/evaluator/sessions.py (98%) rename {optimizer => watttime_optimizer}/evaluator/utils.py (100%) create mode 100644 watttime_optimizer/examples/synthetic_data.ipynb diff --git a/optimizer/__init__.py b/optimizer/__init__.py deleted file mode 100644 index 7a5180c3..00000000 --- a/optimizer/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from watttime.api import * -from optimizer.api_opt import * \ No newline at end of file diff --git a/optimizer/examples/_example_synthetic_data.ipynb b/optimizer/examples/_example_synthetic_data.ipynb deleted file mode 100644 index 8d36e686..00000000 --- a/optimizer/examples/_example_synthetic_data.ipynb +++ /dev/null @@ -1,832 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Synthetic Data for Testing" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [], - "source": [ - "from watttime.evaluator.sessions import SessionsGenerator" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Example: At home EV charging\n", - "\n", - "- Covers a 5.5 - 8.5 hour variable length window\n", - "- The vehicle has a BMW and has an average power draw of 42.5\n", - "- Battery is usually typically 50% charged at plug in time.\n", - "- Charging occurs during the workday" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [], - "source": [ - "ev_kwargs = {\n", - " \"max_power_output_rates\": [42.5],\n", - " \"max_percent_capacity\": 0.95, # highest level of charge achieved by battery\n", - " \"power_output_efficiency\": 0.75, # power loss. 1 = no power loss.\n", - " \"minimum_battery_starting_capacity\": 0.2, # minimum starting percent charged\n", - " \"minimum_usage_window_start_time\": \"08:00:00\", # session can start as early as 8am\n", - " \"maximum_usage_window_start_time\": \"22:00:00\", # session can start as late as 9pm\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [], - "source": [ - "s_ev = SessionsGenerator(**ev_kwargs)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now we can generate synthetic data for users and devices with the attributes set above. The synthetic data creates one example per day." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [], - "source": [ - "# the class has a helper function to generate a random list of unique dates\n", - "distinct_date_list = s_ev.assign_random_dates(years=[2025])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "You can generate data from a single user." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
0
distinct_dates2025-01-06
user_typer27.7525_tc55_avglc25812_sdlc7930
usage_window_start2025-01-06 19:15:00
usage_window_end2025-01-07 03:30:00
initial_charge0.607095
usage_time_required_in_minutes40.774277
expected_baseline_charge_complete_timestamp2025-01-06 19:55:46.456627530
window_length_in_minutes495.0
final_charge_time2025-01-06 19:55:46.456627530
total_capacity55
usage_power_kw27.7525
total_intervals_plugged_in99.0
MWh_fraction0.002313
early_session_stopFalse
\n", - "
" - ], - "text/plain": [ - " 0\n", - "distinct_dates 2025-01-06\n", - "user_type r27.7525_tc55_avglc25812_sdlc7930\n", - "usage_window_start 2025-01-06 19:15:00\n", - "usage_window_end 2025-01-07 03:30:00\n", - "initial_charge 0.607095\n", - "usage_time_required_in_minutes 40.774277\n", - "expected_baseline_charge_complete_timestamp 2025-01-06 19:55:46.456627530\n", - "window_length_in_minutes 495.0\n", - "final_charge_time 2025-01-06 19:55:46.456627530\n", - "total_capacity 55\n", - "usage_power_kw 27.7525\n", - "total_intervals_plugged_in 99.0\n", - "MWh_fraction 0.002313\n", - "early_session_stop False" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "s_ev.synthetic_user_data(distinct_date_list=[distinct_date_list[0]]).T" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Or for multiple users." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "100%|██████████| 10/10 [00:00<00:00, 344.44it/s]\n" - ] - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
indexdistinct_datesuser_typeusage_window_startusage_window_endinitial_chargeusage_time_required_in_minutesexpected_baseline_charge_complete_timestampwindow_length_in_minutesfinal_charge_timetotal_capacityusage_power_kwtotal_intervals_plugged_inMWh_fractionearly_session_stop
002025-01-06r28.05_tc74_avglc25436_sdlc71722025-01-06 09:35:002025-01-06 17:30:000.59276856.5458222025-01-06 10:31:32.749326972475.02025-01-06 10:31:32.7493269727428.050095.00.002337False
102025-01-06r31.7475_tc22_avglc29817_sdlc70982025-01-06 14:35:002025-01-07 00:55:000.60350614.4065442025-01-06 14:49:24.392666208620.02025-01-06 14:49:24.3926662082231.7475124.00.002646False
202025-01-06r32.81_tc36_avglc27775_sdlc71412025-01-06 18:45:002025-01-07 03:35:000.48278130.7586702025-01-06 19:15:45.520208802530.02025-01-06 19:15:45.5202088023632.8100106.00.002734False
302025-01-06r27.37_tc32_avglc24666_sdlc72352025-01-06 14:00:002025-01-06 23:35:000.47000533.6715282025-01-06 14:33:40.291679670575.02025-01-06 14:33:40.2916796703227.3700115.00.002281False
402025-01-06r29.919999999999998_tc88_avglc29832_sdlc70322025-01-06 21:25:002025-01-07 09:55:000.320038111.1697062025-01-06 23:16:10.182363414750.02025-01-06 23:16:10.1823634148829.9200150.00.002493False
502025-01-06r25.5425_tc36_avglc28647_sdlc68202025-01-06 21:15:002025-01-07 07:15:000.55420833.4701322025-01-06 21:48:28.207902324600.02025-01-06 21:48:28.2079023243625.5425120.00.002129False
602025-01-06r30.09_tc77_avglc22791_sdlc79172025-01-06 19:30:002025-01-07 01:15:000.71811535.6035362025-01-06 20:05:36.212165958345.02025-01-06 20:05:36.2121659587730.090069.00.002507False
702025-01-06r31.365_tc113_avglc26690_sdlc74262025-01-06 20:55:002025-01-07 02:35:000.462890105.2958372025-01-06 22:40:17.750211624340.02025-01-06 22:40:17.75021162411331.365068.00.002614False
802025-01-06r31.28_tc116_avglc24040_sdlc72832025-01-06 19:40:002025-01-07 04:40:000.336079136.6014022025-01-06 21:56:36.084147294540.02025-01-06 21:56:36.08414729411631.2800108.00.002607False
902025-01-06r36.975_tc32_avglc23053_sdlc75992025-01-06 21:05:002025-01-07 05:30:000.67235214.4174312025-01-06 21:19:25.045830924505.02025-01-06 21:19:25.0458309243236.9750101.00.003081False
\n", - "
" - ], - "text/plain": [ - " index distinct_dates user_type \\\n", - "0 0 2025-01-06 r28.05_tc74_avglc25436_sdlc7172 \n", - "1 0 2025-01-06 r31.7475_tc22_avglc29817_sdlc7098 \n", - "2 0 2025-01-06 r32.81_tc36_avglc27775_sdlc7141 \n", - "3 0 2025-01-06 r27.37_tc32_avglc24666_sdlc7235 \n", - "4 0 2025-01-06 r29.919999999999998_tc88_avglc29832_sdlc7032 \n", - "5 0 2025-01-06 r25.5425_tc36_avglc28647_sdlc6820 \n", - "6 0 2025-01-06 r30.09_tc77_avglc22791_sdlc7917 \n", - "7 0 2025-01-06 r31.365_tc113_avglc26690_sdlc7426 \n", - "8 0 2025-01-06 r31.28_tc116_avglc24040_sdlc7283 \n", - "9 0 2025-01-06 r36.975_tc32_avglc23053_sdlc7599 \n", - "\n", - " usage_window_start usage_window_end initial_charge \\\n", - "0 2025-01-06 09:35:00 2025-01-06 17:30:00 0.592768 \n", - "1 2025-01-06 14:35:00 2025-01-07 00:55:00 0.603506 \n", - "2 2025-01-06 18:45:00 2025-01-07 03:35:00 0.482781 \n", - "3 2025-01-06 14:00:00 2025-01-06 23:35:00 0.470005 \n", - "4 2025-01-06 21:25:00 2025-01-07 09:55:00 0.320038 \n", - "5 2025-01-06 21:15:00 2025-01-07 07:15:00 0.554208 \n", - "6 2025-01-06 19:30:00 2025-01-07 01:15:00 0.718115 \n", - "7 2025-01-06 20:55:00 2025-01-07 02:35:00 0.462890 \n", - "8 2025-01-06 19:40:00 2025-01-07 04:40:00 0.336079 \n", - "9 2025-01-06 21:05:00 2025-01-07 05:30:00 0.672352 \n", - "\n", - " usage_time_required_in_minutes expected_baseline_charge_complete_timestamp \\\n", - "0 56.545822 2025-01-06 10:31:32.749326972 \n", - "1 14.406544 2025-01-06 14:49:24.392666208 \n", - "2 30.758670 2025-01-06 19:15:45.520208802 \n", - "3 33.671528 2025-01-06 14:33:40.291679670 \n", - "4 111.169706 2025-01-06 23:16:10.182363414 \n", - "5 33.470132 2025-01-06 21:48:28.207902324 \n", - "6 35.603536 2025-01-06 20:05:36.212165958 \n", - "7 105.295837 2025-01-06 22:40:17.750211624 \n", - "8 136.601402 2025-01-06 21:56:36.084147294 \n", - "9 14.417431 2025-01-06 21:19:25.045830924 \n", - "\n", - " window_length_in_minutes final_charge_time total_capacity \\\n", - "0 475.0 2025-01-06 10:31:32.749326972 74 \n", - "1 620.0 2025-01-06 14:49:24.392666208 22 \n", - "2 530.0 2025-01-06 19:15:45.520208802 36 \n", - "3 575.0 2025-01-06 14:33:40.291679670 32 \n", - "4 750.0 2025-01-06 23:16:10.182363414 88 \n", - "5 600.0 2025-01-06 21:48:28.207902324 36 \n", - "6 345.0 2025-01-06 20:05:36.212165958 77 \n", - "7 340.0 2025-01-06 22:40:17.750211624 113 \n", - "8 540.0 2025-01-06 21:56:36.084147294 116 \n", - "9 505.0 2025-01-06 21:19:25.045830924 32 \n", - "\n", - " usage_power_kw total_intervals_plugged_in MWh_fraction \\\n", - "0 28.0500 95.0 0.002337 \n", - "1 31.7475 124.0 0.002646 \n", - "2 32.8100 106.0 0.002734 \n", - "3 27.3700 115.0 0.002281 \n", - "4 29.9200 150.0 0.002493 \n", - "5 25.5425 120.0 0.002129 \n", - "6 30.0900 69.0 0.002507 \n", - "7 31.3650 68.0 0.002614 \n", - "8 31.2800 108.0 0.002607 \n", - "9 36.9750 101.0 0.003081 \n", - "\n", - " early_session_stop \n", - "0 False \n", - "1 False \n", - "2 False \n", - "3 False \n", - "4 False \n", - "5 False \n", - "6 False \n", - "7 False \n", - "8 False \n", - "9 False " - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "s_ev.generate_synthetic_dataset(distinct_date_list=[distinct_date_list[0]], number_of_users=10)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Example: AI Model Training\n", - "- Model training can occur at any time of day\n", - "- There are 3 server models that consume 240, 310, and 640 watt-hour on average\n", - "- Early stopping is not an option" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [], - "source": [ - "ai_kwargs = {\n", - " \"max_percent_capacity\":1.0, # job must run to completion\n", - " \"max_power_output_rates\": [24,31,64], # assuming a bare metal usecase, k8s or vm rescale to vCPU\n", - " \"minimum_usage_window_start_time\": \"00:00:00\", # earliest session can start\n", - " \"maximum_usage_window_start_time\": \"23:59:00\", # latest session can start\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [], - "source": [ - "s_ai = SessionsGenerator(**ai_kwargs)" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "100%|██████████| 10/10 [00:00<00:00, 272.29it/s]\n" - ] - } - ], - "source": [ - "df_ai = s_ai.generate_synthetic_dataset(distinct_date_list=distinct_date_list, number_of_users=10)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Optimization" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "import numpy as np\n", - "from watttime.evaluator.evaluator import OptChargeEvaluator\n", - "from watttime.evaluator.evaluator import ImpactEvaluator\n", - "from watttime.evaluator.evaluator import RecalculationOptChargeEvaluator" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "username = os.getenv(\"WATTTIME_USER\")\n", - "password = os.getenv(\"WATTTIME_PASSWORD\")\n", - "region = \"CAISO_NORTH\"\n", - "oce = OptChargeEvaluator(username=username,password=password)\n", - "roce = RecalculationOptChargeEvaluator(username,password)\n", - "\n", - "# single instance\n", - "df_ev_sample = s_ev.synthetic_user_data(distinct_date_list=[distinct_date_list[0]])\n", - "df_ev_sample = df_ev_sample.rename({\"usage_time_required_in_minutes\":\"time_needed\"},axis=1)" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": {}, - "outputs": [], - "source": [ - "%%capture\n", - "input_dict = df_ev_sample[['usage_window_start',\n", - " 'usage_window_end',\n", - " 'time_needed',\n", - " 'usage_power_kw'\n", - " ]].T.to_dict()\n", - "\n", - "value = input_dict[0]\n", - "value.update({'region':region,'tz_convert':True, \"verbose\":False})" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'usage_window_start': Timestamp('2025-01-06 19:15:00'),\n", - " 'usage_window_end': Timestamp('2025-01-07 02:55:00'),\n", - " 'time_needed': 51.67684008874637,\n", - " 'usage_power_kw': 37.4425,\n", - " 'region': 'CAISO_NORTH',\n", - " 'tz_convert': True,\n", - " 'verbose': False}" - ] - }, - "execution_count": 16, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "value" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "metadata": {}, - "outputs": [], - "source": [ - "df = oce.get_schedule_and_cost_api(**value)\n", - "r = ImpactEvaluator(username,password,df).get_all_emissions_values(region=region)" - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'baseline': 31.979059944544282,\n", - " 'forecast': 29.951449198552897,\n", - " 'actual': 30.192892487355493}" - ] - }, - "execution_count": 18, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "r" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Requery" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "metadata": {}, - "outputs": [], - "source": [ - "value.update({\"optimization_method\": \"simple\", \"interval\":15, \"charge_per_interval\":None})" - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'usage_window_start': Timestamp('2025-01-06 19:15:00'),\n", - " 'usage_window_end': Timestamp('2025-01-07 02:55:00'),\n", - " 'time_needed': 51.67684008874637,\n", - " 'usage_power_kw': 37.4425,\n", - " 'region': 'CAISO_NORTH',\n", - " 'tz_convert': True,\n", - " 'verbose': False,\n", - " 'optimization_method': 'simple',\n", - " 'interval': 15,\n", - " 'charge_per_interval': None}" - ] - }, - "execution_count": 20, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "value" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%%timeit\n", - "df_requery = roce.fit_recalculator(**value)\n", - "r_requery = ImpactEvaluator(username,password,df_requery).get_all_emissions_values(region=region)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Helpful Functions" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# 4 seconds per row, mostly API call\n", - "def analysis_loop(region, input_dict):\n", - " results = {}\n", - " for key,value in input_dict.items():\n", - " value.update({'region':region,'tz_convert':True, \"verbose\":False})\n", - " df = oce.get_schedule_and_cost_api(**value)\n", - " m, b = np.polyfit(np.arange(len(df.pred_moer.values)),df.pred_moer.values, 1)\n", - " stddev = df.pred_moer.std()\n", - " r = ImpactEvaluator(username,password,df).get_all_emissions_values(region=region)\n", - " r.update({'m':m,'b':b,'stddev':stddev})\n", - " results.update({key:r})\n", - " return results\n", - "\n", - "# 4 seconds per row, mostly API call\n", - "def analysis_loop_requery(region, input_dict, interval):\n", - " results = {}\n", - " for key,value in tqdm.tqdm(input_dict.items()):\n", - " value.update(\n", - " {'region':region,\n", - " 'tz_convert':True, \n", - " \"optimization_method\": \"simple\", \n", - " \"verbose\":False,\n", - " \"interval\":interval,\n", - " \"charge_per_interval\":None}\n", - " )\n", - " df = roce.fit_recalculator(**value)\n", - " m, b = np.polyfit(np.arange(len(df.pred_moer.values)),df.pred_moer.values, 1)\n", - " stddev = df.pred_moer.std()\n", - " r = ImpactEvaluator(username,password,df).get_all_emissions_values(region=region)\n", - " r.update({'m':m,'b':b,'stddev':stddev})\n", - " results.update({key:r})\n", - " return results" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "df_many_sessions = s_ev.generate_synthetic_dataset(distinct_date_list=[distinct_date_list[0]], number_of_users=10)\n", - "\n", - "input_dict = df_many_sessions[['usage_window_start',\n", - " 'usage_window_end',\n", - " 'time_needed',\n", - " 'usage_power_kw'\n", - " ]].T.to_dict()\n", - "\n", - "# no requery\n", - "df_many_sessions_optimized = analysis_loop(region, input_dict)\n", - "\n", - "# requery\n", - "df_many_sessions_optimized_requery = analysis_loop_requery(region, input_dict, 45)\n", - "\n", - "# pd.concat([df_many_sessions, df_many_sessions_optimized]) or pd.concat([df_many_sessions, df_many_sessions_optimized_requery])" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "watttime", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.21" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/setup.py b/setup.py index d138bddf..d5823dac 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ long_description=open('README.md').read(), long_description_content_type="text/markdown", version="v1.2.1", - packages=["watttime","optimizer"], + packages=["watttime","watttime_optimizer"], python_requires=">=3.8", - install_requires=["requests", "pandas>1.0.0", "python-dateutil"], + install_requires=["requests", "pandas>1.0.0", "python-dateutil","tqdm"], ) diff --git a/tests/test_optimizer.py b/tests/test_optimizer.py index 3c5e4d9c..f2ec8a54 100644 --- a/tests/test_optimizer.py +++ b/tests/test_optimizer.py @@ -3,7 +3,7 @@ import unittest import pandas as pd from pytz import UTC -from optimizer import WattTimeOptimizer +from watttime_optimizer import WattTimeOptimizer REGION = "CAISO_NORTH" diff --git a/optimizer/Optimizer README.md b/watttime_optimizer/Optimizer README.md similarity index 90% rename from optimizer/Optimizer README.md rename to watttime_optimizer/Optimizer README.md index 2d0a1bb4..439ee83b 100644 --- a/optimizer/Optimizer README.md +++ b/watttime_optimizer/Optimizer README.md @@ -28,7 +28,7 @@ Click any of the thumbnails below to see the notebook that generated it. 5. Dishwasher: needs to run over two usage intervals of lengths 80 min and 40 min. They must complete in that order. Contiguous (multiple periods, fixed length) 6. Compressor: needs to run 120 minutes over the next 12 hours; each cycle needs to be at least 20 minutes long, and any number of contiguous intervals (from one to six) is okay. Contiguous (multiple periods, variable length) -**Naive Smart Device Charging [EV or pluggable batter-powered device]** +**Naive Smart Device Charging [EV or pluggable battery-powered device]** ```py from datetime import datetime, timedelta @@ -105,27 +105,39 @@ print(usage_plan.sum()) ``` **Variable Charging Curve - EV** - * Sophisticated - total charge window 12 hours long, 75% charged by hour 8. +I know the model of my vehicle and want to match device characteristics. If we have a 10 kWh battery which initially charges at 20kW, the charge rate then linearly decreases to 10kW as the battery is 50% +charged, and then remains at 10kW for the rest of the charging. This is the charging curve. ```py from datetime import datetime, timedelta import pandas as pd from pytz import UTC from optimizer import WattTimeOptimizer +from watttime_optimizer.battery import Battery import os username = os.getenv("WATTTIME_USER") password = os.getenv("WATTTIME_PASSWORD") wt_opt = WattTimeOptimizer(username, password) -# 12 hour charge window (720/60 = 12) -# Minute 480 is time context when the constraint, i.e. 75% charge, must be satisfied -# 75% of 240 (required charge expressed in minutes) is 180 - now = datetime.now(UTC) window_start = now window_end = now + timedelta(minutes=720) -variable_usage_power = '' + +battery = Battery( + initial_soc=0.0, + charging_curve=pd.DataFrame( + columns=["SoC", "kW"], + data=[ + [0.0, 20.0], + [0.5, 10.0], + [1.0, 10.0], + ] + ), + capacity_kWh=10.0, +) + +variable_usage_power = battery.get_usage_power_kw_df() usage_plan = wt_opt.get_optimal_usage_plan( region="CAISO_NORTH", @@ -133,7 +145,6 @@ usage_plan = wt_opt.get_optimal_usage_plan( usage_window_end=window_end, usage_time_required_minutes=240, usage_power_kw=variable_usage_power, - constraints=constraints, optimization_method="auto", ) @@ -142,7 +153,6 @@ print(usage_plan["usage"].tolist()) print(usage_plan.sum()) ``` - * **Data Center Workload 1**: * (single period, fixed length) - charging schedule to be composed of contiguous interval(s) of fixed length diff --git a/watttime_optimizer/__init__.py b/watttime_optimizer/__init__.py new file mode 100644 index 00000000..3f5ce00d --- /dev/null +++ b/watttime_optimizer/__init__.py @@ -0,0 +1,4 @@ +from watttime.api import * +from watttime_optimizer.api_opt import * +from watttime_optimizer.api_opt import * +from watttime_optimizer.evaluator import * \ No newline at end of file diff --git a/optimizer/alg/__init__.py b/watttime_optimizer/alg/__init__.py similarity index 100% rename from optimizer/alg/__init__.py rename to watttime_optimizer/alg/__init__.py diff --git a/optimizer/alg/moer.py b/watttime_optimizer/alg/moer.py similarity index 100% rename from optimizer/alg/moer.py rename to watttime_optimizer/alg/moer.py diff --git a/optimizer/alg/optCharger.py b/watttime_optimizer/alg/optCharger.py similarity index 100% rename from optimizer/alg/optCharger.py rename to watttime_optimizer/alg/optCharger.py diff --git a/optimizer/api_convert.py b/watttime_optimizer/api_convert.py similarity index 100% rename from optimizer/api_convert.py rename to watttime_optimizer/api_convert.py diff --git a/optimizer/api_opt.py b/watttime_optimizer/api_opt.py similarity index 99% rename from optimizer/api_opt.py rename to watttime_optimizer/api_opt.py index a701fe21..2db9e6e0 100644 --- a/optimizer/api_opt.py +++ b/watttime_optimizer/api_opt.py @@ -6,7 +6,7 @@ import pandas as pd from dateutil.parser import parse from pytz import UTC, timezone -from optimizer.alg import optCharger, moer +from watttime_optimizer.alg import optCharger, moer from itertools import accumulate import bisect @@ -81,7 +81,7 @@ def get_optimal_usage_plan( Uncertainty in usage time, in minutes. charge_per_interval : Optional[list], default=None Either a list of length-2 tuples representing minimium and maximum (inclusive) charging minutes per interval, - or a list of ints representing both the min and max. + or a list of ints representing both the min and max. [180] OR [(180,180)] use_all_intervals : Optional[bool], default=False If true, use all intervals provided by charge_per_interval; if false, can use the first few intervals and skip the rest. constraints : Optional[dict], default=None @@ -409,7 +409,7 @@ def get_min_max_indices(lst, x): # Recalculate these values approximately, based on the new "usage" column # Note: This is approximate since it assumes that # the charging emissions over time of the unrounded values are similar to the rounded values - result_df["emissions_co2e_lb"] = ( + result_df["emissions_co2_lb"] = ( model.get_charging_emissions_over_time() * result_df["usage"] / self.OPT_INTERVAL diff --git a/watttime_optimizer/battery.py b/watttime_optimizer/battery.py new file mode 100644 index 00000000..ef236a26 --- /dev/null +++ b/watttime_optimizer/battery.py @@ -0,0 +1,65 @@ +# encode the variable power curves +from dataclasses import dataclass +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt + +@dataclass +class Battery: + capacity_kWh: float + charging_curve: pd.DataFrame # columns SoC and kW + initial_soc: float = 0.2 + + def plot_charging_curve(self, ax=None): + """Plot the variabel charging curve of the battery""" + ax = self.charging_curve.set_index("SoC").plot( + ax=ax, + grid=True, + ylabel="kW", + legend=False, + title=f"Charging curve \nBattery capacity: {self.capacity_kWh} kWh" + ) + if ax is None: + plt.show() + + def get_usage_power_kw_df(self, max_capacity_fraction=0.95): + """ + Output the variable charging curve in the format that optimizer accepts. + That is, dataframe with index "time" in minutes and "power_kw" which + tells us the average power consumption in a five minute interval + after an elapsed amount of time of charging. + """ + capacity_kWh = self.capacity_kWh + initial_soc = self.initial_soc + # convert SoC column to numpy array for faster access + soc_array = self.charging_curve["SoC"].values + kW_array = self.charging_curve["kW"].values + + def get_kW_at_SoC(soc): + """Linear interpolation to get charging rate at any SoC.""" + idx = np.searchsorted(soc_array, soc) + if idx == 0: + return kW_array[0] + elif idx >= len(soc_array): + return kW_array[-1] + m1, m2 = soc_array[idx - 1], soc_array[idx] + p1, p2 = kW_array[idx - 1], kW_array[idx] + return p1 + (soc - m1) / (m2 - m1) * (p2 - p1) + + # iterate over seconds + result = [] + secs_elapsed = 0 + charged_kWh = capacity_kWh * initial_soc + kW_by_second = [] + while charged_kWh < capacity_kWh * max_capacity_fraction: + secs_elapsed += 1 + curr_soc = charged_kWh / capacity_kWh + curr_kW = get_kW_at_SoC(curr_soc) + kW_by_second.append(curr_kW) + charged_kWh += curr_kW / 3600 + + if secs_elapsed % 300 == 0: + result.append((int(secs_elapsed / 60 - 5), pd.Series(kW_by_second).mean())) + kW_by_second = [] + + return pd.DataFrame(columns=["time", "power_kw"], data=result) \ No newline at end of file diff --git a/watttime_optimizer/evaluator/__init__.py b/watttime_optimizer/evaluator/__init__.py new file mode 100644 index 00000000..faf2b222 --- /dev/null +++ b/watttime_optimizer/evaluator/__init__.py @@ -0,0 +1 @@ +from watttime.api import * \ No newline at end of file diff --git a/watttime_optimizer/evaluator/analysis.py b/watttime_optimizer/evaluator/analysis.py new file mode 100644 index 00000000..32d92b1b --- /dev/null +++ b/watttime_optimizer/evaluator/analysis.py @@ -0,0 +1,68 @@ + +from watttime_optimizer.evaluator.evaluator import RecalculationOptChargeEvaluator +from watttime_optimizer.evaluator.evaluator import OptChargeEvaluator +from watttime_optimizer.evaluator.evaluator import ImpactEvaluator +import numpy as np +import tqdm + + +# 4 seconds per row, mostly API call +def analysis_loop(region, input_dict,username,password): + oce = OptChargeEvaluator(username=username,password=password) + results = {} + for key,value in tqdm.tqdm(input_dict.items()): + value.update({'region':region,'tz_convert':True, "verbose":False}) + df = oce.get_schedule_and_cost_api(**value) + m, b = np.polyfit(np.arange(len(df.pred_moer.values)),df.pred_moer.values, 1) + stddev = df.pred_moer.std() + r = ImpactEvaluator(username,password,df).get_all_emissions_values(region=region) + r.update({'m':m,'b':b,'stddev':stddev}) + results.update({key:r}) + return results + +# 4 seconds per row, mostly API call +def analysis_loop_requery(region, input_dict, interval,username,password): + roce = RecalculationOptChargeEvaluator(username,password) + results = {} + for key,value in tqdm.tqdm(input_dict.items()): + value.update( + {'region':region, + 'tz_convert':True, + "optimization_method": "auto", + "verbose":False, + "interval":interval, + "charge_per_interval":None} + ) + df = roce.fit_recalculator(**value) + m, b = np.polyfit(np.arange(len(df.pred_moer.values)),df.pred_moer.values, 1) + stddev = df.pred_moer.std() + r = ImpactEvaluator(username,password,df).get_all_emissions_values(region=region) + r.update({'m':m,'b':b,'stddev':stddev}) + results.update({key:r}) + return results + +# 4 seconds per row, mostly API call +def analysis_loop_requery_contiguous(region, input_dict, interval,username,password): + roce = RecalculationOptChargeEvaluator(username,password) + results = {} + for key,value in tqdm.tqdm(input_dict.items()): + try: + value.update( + {'region':region, + 'tz_convert':True, + "optimization_method": "auto", + "verbose":False, + "interval":interval, + "contiguous":True + } + ) + df = roce.fit_recalculator(**value).get_combined_schedule() + m, b = np.polyfit(np.arange(len(df.pred_moer.values)),df.pred_moer.values, 1) + stddev = df.pred_moer.std() + r = ImpactEvaluator(username,password,df).get_all_emissions_values(region=region) + r.update({'m':m,'b':b,'stddev':stddev}) + results.update({key:r}) + except: + print('error') + pass + return results \ No newline at end of file diff --git a/optimizer/evaluator/evaluator.py b/watttime_optimizer/evaluator/evaluator.py similarity index 96% rename from optimizer/evaluator/evaluator.py rename to watttime_optimizer/evaluator/evaluator.py index 1b4bbb8f..62fa1c6e 100644 --- a/optimizer/evaluator/evaluator.py +++ b/watttime_optimizer/evaluator/evaluator.py @@ -1,6 +1,7 @@ -from watttime.api import WattTimeOptimizer, WattTimeForecast, WattTimeHistorical, WattTimeRecalculator +from watttime.api import WattTimeForecast, WattTimeHistorical +from watttime_optimizer.api_opt import WattTimeOptimizer, WattTimeRecalculator import pandas as pd -from watttime.evaluator.utils import convert_to_utc, get_timezone_from_dict +from watttime_optimizer.evaluator.utils import convert_to_utc, get_timezone_from_dict import numpy as np from typing import Optional from datetime import timedelta @@ -87,11 +88,11 @@ def get_forecast_emissions(self): """ Calculate total CO2 emissions in pounds Args: - x: Input dictionary containing 'emissions_co2e_lb' key + x: Input dictionary containing 'emissions_co2_lb' key Returns: Sum of CO2 emissions """ - return self.obj["emissions_co2e_lb"].sum() + return self.obj["emissions_co2_lb"].sum() def get_baseline_emissions(self,region:str): """ @@ -204,7 +205,7 @@ def get_schedule_and_cost_api( ) # Validate emissions data - if schedule["emissions_co2e_lb"].sum() == 0.0: + if schedule["emissions_co2_lb"].sum() == 0.0: self._log_zero_emissions_warning( usage_power_kw, time_needed, diff --git a/optimizer/evaluator/sessions.py b/watttime_optimizer/evaluator/sessions.py similarity index 98% rename from optimizer/evaluator/sessions.py rename to watttime_optimizer/evaluator/sessions.py index 9c7736d3..98226a58 100644 --- a/optimizer/evaluator/sessions.py +++ b/watttime_optimizer/evaluator/sessions.py @@ -1,6 +1,5 @@ -from typing import List, Any, Optional +from typing import List, Any from datetime import datetime, timedelta, date -import pytz import pandas as pd import random import numpy as np @@ -163,7 +162,7 @@ def synthetic_user_data(self, distinct_date_list, **kwargs) -> pd.DataFrame: user_df["initial_charge"] = user_df.apply( lambda _: random.uniform(self.minimum_battery_starting_capacity, 0.8), axis=1 ) - user_df["usage_time_required_in_minutes"] = user_df["initial_charge"].apply( + user_df["time_needed"] = user_df["initial_charge"].apply( lambda x: total_capacity * (self.max_percent_capacity - x) / power_output_max_rate @@ -172,7 +171,7 @@ def synthetic_user_data(self, distinct_date_list, **kwargs) -> pd.DataFrame: # What time will the battery reach max capacity user_df["expected_baseline_charge_complete_timestamp"] = user_df["usage_window_start"] + pd.to_timedelta( - user_df["usage_time_required_in_minutes"], unit="m" + user_df["time_needed"], unit="m" ) user_df["window_length_in_minutes"] = ( user_df.usage_window_end - user_df.usage_window_start diff --git a/optimizer/evaluator/utils.py b/watttime_optimizer/evaluator/utils.py similarity index 100% rename from optimizer/evaluator/utils.py rename to watttime_optimizer/evaluator/utils.py diff --git a/watttime_optimizer/examples/synthetic_data.ipynb b/watttime_optimizer/examples/synthetic_data.ipynb new file mode 100644 index 00000000..de6c8921 --- /dev/null +++ b/watttime_optimizer/examples/synthetic_data.ipynb @@ -0,0 +1,1021 @@ +{wattwatttime_optimizer.izer. + "cells": [ + {watttime_optimizer. + "cwatttime_optimizer. "markdown", + "mwatttime_optimizer.{}, + "source": [ + "# Synthetic Data for Testing" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "os.chdir(path=os.path.dirname(os.path.dirname(os.path.abspath(os.curdir))))" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "from optimizer.evaluator.sessions import SessionsGenerator" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example: At home EV charging\n", + "\n", + "- Covers a 5.5 - 8.5 hour variable length window\n", + "- The vehicle has a BMW and has an average power draw of 42.5\n", + "- Battery is usually typically 50% charged at plug in time.\n", + "- Charging occurs during the workday" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "ev_kwargs = {\n", + " \"max_power_output_rates\": [42.5],\n", + " \"max_percent_capacity\": 0.95, # highest level of charge achieved by battery\n", + " \"power_output_efficiency\": 0.75, # power loss. 1 = no power loss.\n", + " \"minimum_battery_starting_capacity\": 0.2, # minimum starting percent charged\n", + " \"minimum_usage_window_start_time\": \"08:00:00\", # session can start as early as 8am\n", + " \"maximum_usage_window_start_time\": \"22:00:00\", # session can start as late as 9pm\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "s_ev = SessionsGenerator(**ev_kwargs)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now we can generate synthetic data for users and devices with the attributes set above. The synthetic data creates one example per day." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "# the class has a helper function to generate a random list of unique dates\n", + "distinct_date_list = s_ev.assign_random_dates(years=[2025])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can generate data from a single user." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
0
distinct_dates2025-01-10
user_typer27.37_tc28_avglc25631_sdlc6925
usage_window_start2025-01-10 14:20:00
usage_window_end2025-01-10 19:05:00
initial_charge0.675072
time_needed16.875384
expected_baseline_charge_complete_timestamp2025-01-10 14:36:52.523068458
window_length_in_minutes285.0
final_charge_time2025-01-10 14:36:52.523068458
total_capacity28
usage_power_kw27.37
total_intervals_plugged_in57.0
MWh_fraction0.002281
early_session_stopFalse
\n", + "
" + ], + "text/plain": [ + " 0\n", + "distinct_dates 2025-01-10\n", + "user_type r27.37_tc28_avglc25631_sdlc6925\n", + "usage_window_start 2025-01-10 14:20:00\n", + "usage_window_end 2025-01-10 19:05:00\n", + "initial_charge 0.675072\n", + "time_needed 16.875384\n", + "expected_baseline_charge_complete_timestamp 2025-01-10 14:36:52.523068458\n", + "window_length_in_minutes 285.0\n", + "final_charge_time 2025-01-10 14:36:52.523068458\n", + "total_capacity 28\n", + "usage_power_kw 27.37\n", + "total_intervals_plugged_in 57.0\n", + "MWh_fraction 0.002281\n", + "early_session_stop False" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s_ev.synthetic_user_data(distinct_date_list=[distinct_date_list[0]]).T" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Or for multiple users." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 10/10 [00:00<00:00, 318.49it/s]\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
indexdistinct_datesuser_typeusage_window_startusage_window_endinitial_chargetime_neededexpected_baseline_charge_complete_timestampwindow_length_in_minutesfinal_charge_timetotal_capacityusage_power_kwtotal_intervals_plugged_inMWh_fractionearly_session_stop
002025-01-10r29.1125_tc103_avglc20348_sdlc76222025-01-10 09:15:002025-01-10 18:40:000.53935587.1716492025-01-10 10:42:10.298935944565.02025-01-10 10:42:10.29893594410329.1125113.00.002426False
102025-01-10r21.42_tc91_avglc20436_sdlc68912025-01-10 19:00:002025-01-11 02:30:000.64806576.9638062025-01-10 20:16:57.828383058450.02025-01-10 20:16:57.8283830589121.420090.00.001785False
202025-01-10r24.7775_tc112_avglc23284_sdlc69042025-01-10 17:25:002025-01-10 23:30:000.67285875.1647542025-01-10 18:40:09.885255132365.02025-01-10 18:40:09.88525513211224.777573.00.002065False
302025-01-10r29.070000000000004_tc115_avglc23635_sdlc73792025-01-10 19:35:002025-01-11 01:55:000.303492153.4539522025-01-10 22:08:27.237103968380.02025-01-10 22:08:27.23710396811529.070076.00.002423False
402025-01-10r21.93_tc22_avglc21043_sdlc69802025-01-10 18:05:002025-01-10 21:15:000.41165632.4037682025-01-10 18:37:24.226103616190.02025-01-10 18:37:24.2261036162221.930038.00.001827False
502025-01-10r28.687500000000004_tc21_avglc22986_sdlc69432025-01-10 19:00:002025-01-11 00:35:000.29884528.5997612025-01-10 19:28:35.985664014335.02025-01-10 19:28:35.9856640142128.687567.00.002391False
602025-01-10r32.215_tc109_avglc22829_sdlc72812025-01-10 21:40:002025-01-11 04:00:000.346887122.4385672025-01-10 23:42:26.314030590380.02025-01-10 23:42:26.31403059010932.215076.00.002685False
702025-01-10r21.59_tc110_avglc26220_sdlc74212025-01-10 14:40:002025-01-10 21:30:000.72594568.4928552025-01-10 15:48:29.571296238410.02025-01-10 15:48:29.57129623811021.590082.00.001799False
802025-01-10r33.9575_tc91_avglc23444_sdlc79082025-01-10 17:55:002025-01-11 01:00:000.77702827.8121012025-01-10 18:22:48.726031014425.02025-01-10 18:22:48.7260310149133.957585.00.002830False
902025-01-10r35.7425_tc68_avglc20339_sdlc77102025-01-10 14:20:002025-01-10 21:45:000.73686624.3292432025-01-10 14:44:19.754600988445.02025-01-10 14:44:19.7546009886835.742589.00.002979False
\n", + "
" + ], + "text/plain": [ + " index distinct_dates user_type \\\n", + "0 0 2025-01-10 r29.1125_tc103_avglc20348_sdlc7622 \n", + "1 0 2025-01-10 r21.42_tc91_avglc20436_sdlc6891 \n", + "2 0 2025-01-10 r24.7775_tc112_avglc23284_sdlc6904 \n", + "3 0 2025-01-10 r29.070000000000004_tc115_avglc23635_sdlc7379 \n", + "4 0 2025-01-10 r21.93_tc22_avglc21043_sdlc6980 \n", + "5 0 2025-01-10 r28.687500000000004_tc21_avglc22986_sdlc6943 \n", + "6 0 2025-01-10 r32.215_tc109_avglc22829_sdlc7281 \n", + "7 0 2025-01-10 r21.59_tc110_avglc26220_sdlc7421 \n", + "8 0 2025-01-10 r33.9575_tc91_avglc23444_sdlc7908 \n", + "9 0 2025-01-10 r35.7425_tc68_avglc20339_sdlc7710 \n", + "\n", + " usage_window_start usage_window_end initial_charge time_needed \\\n", + "0 2025-01-10 09:15:00 2025-01-10 18:40:00 0.539355 87.171649 \n", + "1 2025-01-10 19:00:00 2025-01-11 02:30:00 0.648065 76.963806 \n", + "2 2025-01-10 17:25:00 2025-01-10 23:30:00 0.672858 75.164754 \n", + "3 2025-01-10 19:35:00 2025-01-11 01:55:00 0.303492 153.453952 \n", + "4 2025-01-10 18:05:00 2025-01-10 21:15:00 0.411656 32.403768 \n", + "5 2025-01-10 19:00:00 2025-01-11 00:35:00 0.298845 28.599761 \n", + "6 2025-01-10 21:40:00 2025-01-11 04:00:00 0.346887 122.438567 \n", + "7 2025-01-10 14:40:00 2025-01-10 21:30:00 0.725945 68.492855 \n", + "8 2025-01-10 17:55:00 2025-01-11 01:00:00 0.777028 27.812101 \n", + "9 2025-01-10 14:20:00 2025-01-10 21:45:00 0.736866 24.329243 \n", + "\n", + " expected_baseline_charge_complete_timestamp window_length_in_minutes \\\n", + "0 2025-01-10 10:42:10.298935944 565.0 \n", + "1 2025-01-10 20:16:57.828383058 450.0 \n", + "2 2025-01-10 18:40:09.885255132 365.0 \n", + "3 2025-01-10 22:08:27.237103968 380.0 \n", + "4 2025-01-10 18:37:24.226103616 190.0 \n", + "5 2025-01-10 19:28:35.985664014 335.0 \n", + "6 2025-01-10 23:42:26.314030590 380.0 \n", + "7 2025-01-10 15:48:29.571296238 410.0 \n", + "8 2025-01-10 18:22:48.726031014 425.0 \n", + "9 2025-01-10 14:44:19.754600988 445.0 \n", + "\n", + " final_charge_time total_capacity usage_power_kw \\\n", + "0 2025-01-10 10:42:10.298935944 103 29.1125 \n", + "1 2025-01-10 20:16:57.828383058 91 21.4200 \n", + "2 2025-01-10 18:40:09.885255132 112 24.7775 \n", + "3 2025-01-10 22:08:27.237103968 115 29.0700 \n", + "4 2025-01-10 18:37:24.226103616 22 21.9300 \n", + "5 2025-01-10 19:28:35.985664014 21 28.6875 \n", + "6 2025-01-10 23:42:26.314030590 109 32.2150 \n", + "7 2025-01-10 15:48:29.571296238 110 21.5900 \n", + "8 2025-01-10 18:22:48.726031014 91 33.9575 \n", + "9 2025-01-10 14:44:19.754600988 68 35.7425 \n", + "\n", + " total_intervals_plugged_in MWh_fraction early_session_stop \n", + "0 113.0 0.002426 False \n", + "1 90.0 0.001785 False \n", + "2 73.0 0.002065 False \n", + "3 76.0 0.002423 False \n", + "4 38.0 0.001827 False \n", + "5 67.0 0.002391 False \n", + "6 76.0 0.002685 False \n", + "7 82.0 0.001799 False \n", + "8 85.0 0.002830 False \n", + "9 89.0 0.002979 False " + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s_ev.generate_synthetic_dataset(distinct_date_list=[distinct_date_list[0]], number_of_users=10)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example: AI Model Training\n", + "- Model training can occur at any time of day\n", + "- There are 3 server models that consume 240, 310, and 640 watt-hour on average\n", + "- Early stopping is not an option" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "ai_kwargs = {\n", + " \"max_percent_capacity\":1.0, # job must run to completion\n", + " \"max_power_output_rates\": [24,31,64], # assuming a bare metal usecase, k8s or vm rescale to vCPU\n", + " \"minimum_usage_window_start_time\": \"00:00:00\", # earliest session can start\n", + " \"maximum_usage_window_start_time\": \"23:59:00\", # latest session can start\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "s_ai = SessionsGenerator(**ai_kwargs)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 10/10 [00:00<00:00, 334.45it/s]\n" + ] + } + ], + "source": [ + "df_ai = s_ai.generate_synthetic_dataset(distinct_date_list=distinct_date_list, number_of_users=10)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Optimization" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import pandas as pd\n", + "from optimizer.evaluator.evaluator import OptChargeEvaluator\n", + "from optimizer.evaluator.evaluator import ImpactEvaluator\n", + "from optimizer.evaluator.evaluator import RecalculationOptChargeEvaluator" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "username = os.getenv(\"WATTTIME_USER\")\n", + "password = os.getenv(\"WATTTIME_PASSWORD\")\n", + "region = \"CAISO_NORTH\"\n", + "oce = OptChargeEvaluator(username=username,password=password)\n", + "roce = RecalculationOptChargeEvaluator(username,password)\n", + "\n", + "# single instance\n", + "df_ev_sample = s_ev.synthetic_user_data(distinct_date_list=[distinct_date_list[0]])\n", + "df_ev_sample = df_ev_sample.rename({\"usage_time_required_in_minutes\":\"time_needed\"},axis=1)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "%%capture\n", + "input_dict = df_ev_sample[['usage_window_start',\n", + " 'usage_window_end',\n", + " 'time_needed',\n", + " 'usage_power_kw'\n", + " ]].T.to_dict()\n", + "\n", + "value = input_dict[0]\n", + "value.update({'region':region,'tz_convert':True, \"verbose\":False})" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'usage_window_start': Timestamp('2025-01-10 15:25:00'),\n", + " 'usage_window_end': Timestamp('2025-01-10 22:25:00'),\n", + " 'time_needed': 15.131315076819533,\n", + " 'usage_power_kw': 35.275,\n", + " 'region': 'CAISO_NORTH',\n", + " 'tz_convert': True,\n", + " 'verbose': False}" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "value" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "df = oce.get_schedule_and_cost_api(**value)\n", + "r = ImpactEvaluator(username,password,df).get_all_emissions_values(region=region)" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'baseline': 5.38833458467993,\n", + " 'forecast': 2.2544024516577843,\n", + " 'actual': 5.38833458467993}" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Requery" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "value.update({\"optimization_method\": \"simple\", \"interval\":15, \"charge_per_interval\":None})" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'usage_window_start': Timestamp('2025-01-10 15:25:00'),\n", + " 'usage_window_end': Timestamp('2025-01-10 22:25:00'),\n", + " 'time_needed': 15.131315076819533,\n", + " 'usage_power_kw': 35.275,\n", + " 'region': 'CAISO_NORTH',\n", + " 'tz_convert': True,\n", + " 'verbose': False,\n", + " 'optimization_method': 'simple',\n", + " 'interval': 15,\n", + " 'charge_per_interval': None}" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "value" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "tz converting...\n" + ] + } + ], + "source": [ + "%%timeit\n", + "df_requery = roce.fit_recalculator(**value)\n", + "r_requery = ImpactEvaluator(username,password,df_requery).get_all_emissions_values(region=region)" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'baseline': 5.38833458467993,\n", + " 'forecast': 2.2544024516577843,\n", + " 'actual': 5.38833458467993}" + ] + }, + "execution_count": 25, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r_requery" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Iterate over multiple rows of data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [], + "source": [ + "from optimizer.evaluator.analysis import analysis_loop, analysis_loop_requery, analysis_loop_requery_contiguous" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 10/10 [00:00<00:00, 285.20it/s]\n" + ] + } + ], + "source": [ + "df_ev_samples = s_ev.generate_synthetic_dataset(distinct_date_list=distinct_date_list, number_of_users=10).sample(10)\n", + "\n", + "input_dict = df_ev_samples[['usage_window_start',\n", + " 'usage_window_end',\n", + " 'time_needed',\n", + " 'usage_power_kw'\n", + " ]].T.to_dict()" + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + " 0%| | 0/10 [00:00\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
baselineforecastactualmbstddev
1900.5120440.6378700.51204414.728153-33.707037362.355836
14330.51430429.14283430.5160922.399780944.16571413.217914
22011.86441310.84740610.941964-0.877926987.69791524.444685
2232.9504315.6554652.7483272.144935243.187723181.510740
504.0864143.7123713.758234-0.9342821010.35689027.061581
33312.41530310.87992411.971757-1.006296177.44795722.169530
17615.74589714.96057515.111659-0.367786988.71877413.428916
2811.89666811.13736011.772867-0.855962978.72101223.713533
443.9018643.6540033.9190420.341336965.91157215.653204
9652.75360629.87850652.7536066.135591561.793558233.229645
\n", + "" + ], + "text/plain": [ + " baseline forecast actual m b stddev\n", + "190 0.512044 0.637870 0.512044 14.728153 -33.707037 362.355836\n", + "143 30.514304 29.142834 30.516092 2.399780 944.165714 13.217914\n", + "220 11.864413 10.847406 10.941964 -0.877926 987.697915 24.444685\n", + "223 2.950431 5.655465 2.748327 2.144935 243.187723 181.510740\n", + "50 4.086414 3.712371 3.758234 -0.934282 1010.356890 27.061581\n", + "333 12.415303 10.879924 11.971757 -1.006296 177.447957 22.169530\n", + "176 15.745897 14.960575 15.111659 -0.367786 988.718774 13.428916\n", + "28 11.896668 11.137360 11.772867 -0.855962 978.721012 23.713533\n", + "44 3.901864 3.654003 3.919042 0.341336 965.911572 15.653204\n", + "96 52.753606 29.878506 52.753606 6.135591 561.793558 233.229645" + ] + }, + "execution_count": 36, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pd.DataFrame.from_dict(\n", + " results,\n", + " orient=\"index\"\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "metadata": {}, + "outputs": [], + "source": [ + "#analysis_loop_requery(region=\"CAISO_NORTH\",interval = 15, input_dict=input_dict,username=username,password=password)\n", + "#analysis_loop_requery_contiguous(region=\"CAISO_NORTH\",interval = 15, input_dict=input_dict,username=username,password=password)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "watttime", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.21" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} From 587b5515ab23f7ecb22b6fd0eb57c6e8b8b9a1dc Mon Sep 17 00:00:00 2001 From: jbadsdata Date: Mon, 5 May 2025 21:14:18 -0400 Subject: [PATCH 3/5] interval to segment --- tests/test_optimizer.py | 74 +++++++------- watttime/api.py | 10 +- watttime_optimizer/Optimizer README.md | 8 +- watttime_optimizer/alg/optCharger.py | 54 +++++----- watttime_optimizer/api_convert.py | 4 +- watttime_optimizer/api_opt.py | 98 +++++++++---------- watttime_optimizer/evaluator/analysis.py | 2 +- watttime_optimizer/evaluator/evaluator.py | 14 +-- .../examples/synthetic_data.ipynb | 4 +- 9 files changed, 134 insertions(+), 134 deletions(-) diff --git a/tests/test_optimizer.py b/tests/test_optimizer.py index f2ec8a54..3a3dc607 100644 --- a/tests/test_optimizer.py +++ b/tests/test_optimizer.py @@ -263,19 +263,19 @@ def test_dp_input_constant_power_energy(self): usage_plan["energy_usage_mwh"].sum() * 1000, 180 * 5 / 60 ) - def test_dp_two_intervals_unbounded(self): - """Test auto mode with two intervals.""" + def test_dp_two_segments_unbounded(self): + """Test auto mode with two segments.""" usage_plan = self.wt_opt.get_optimal_usage_plan( region=self.region, usage_window_start=self.window_start_test, usage_window_end=self.window_end_test, usage_time_required_minutes=160, usage_power_kw=self.usage_power_kw, - charge_per_interval=[(0, 999999), (0, 999999)], + charge_per_segment=[(0, 999999), (0, 999999)], optimization_method="auto", ) print( - "Using auto mode with two unbounded intervals\n", + "Using auto mode with two unbounded segments\n", pretty_format_usage(usage_plan), ) print(usage_plan.sum()) @@ -293,19 +293,19 @@ def test_dp_two_intervals_unbounded(self): # Check number of components self.assertLessEqual(len(get_contiguity_info(usage_plan)), 2) - def test_dp_two_intervals_flexible_length(self): - """Test auto mode with two variable length intervals.""" + def test_dp_two_segments_flexible_length(self): + """Test auto mode with two variable length segments.""" usage_plan = self.wt_opt.get_optimal_usage_plan( region=self.region, usage_window_start=self.window_start_test, usage_window_end=self.window_end_test, usage_time_required_minutes=160, usage_power_kw=self.usage_power_kw, - charge_per_interval=[(60, 100), (60, 100)], + charge_per_segment=[(60, 100), (60, 100)], optimization_method="auto", ) print( - "Using auto mode with two flexible intervals\n", + "Using auto mode with two flexible segments\n", pretty_format_usage(usage_plan), ) print(usage_plan.sum()) @@ -335,19 +335,19 @@ def test_dp_two_intervals_flexible_length(self): # Check combined component length self.assertAlmostEqual(contiguity_info[0]["sum"], 160) - def test_dp_two_intervals_one_sided_length(self): - """Test auto mode with two variable length intervals.""" + def test_dp_two_segments_one_sided_length(self): + """Test auto mode with two variable length segments.""" usage_plan = self.wt_opt.get_optimal_usage_plan( region=self.region, usage_window_start=self.window_start_test, usage_window_end=self.window_end_test, usage_time_required_minutes=160, usage_power_kw=self.usage_power_kw, - charge_per_interval=[(30, None), (30, None), (30, None), (30, None)], + charge_per_segment=[(30, None), (30, None), (30, None), (30, None)], optimization_method="auto", ) print( - "Using auto mode with one-sided intervals\n", + "Using auto mode with one-sided segments\n", pretty_format_usage(usage_plan), ) print(usage_plan.sum()) @@ -370,20 +370,20 @@ def test_dp_two_intervals_one_sided_length(self): # Check component length self.assertGreaterEqual(contiguity_info[i]["sum"], 30) - def test_dp_two_intervals_one_sided_length_use_all_false(self): - """Test auto mode with two variable length intervals.""" + def test_dp_two_segments_one_sided_length_use_all_false(self): + """Test auto mode with two variable length segments.""" usage_plan = self.wt_opt.get_optimal_usage_plan( region=self.region, usage_window_start=self.window_start_test, usage_window_end=self.window_end_test, usage_time_required_minutes=160, usage_power_kw=self.usage_power_kw, - charge_per_interval=[(40, None), (40, None), (40, None), (40, None)], - use_all_intervals=False, + charge_per_segment=[(40, None), (40, None), (40, None), (40, None)], + use_all_segments=False, optimization_method="auto", ) print( - "Using auto mode with one-sided intervals\n", + "Using auto mode with one-sided segments\n", pretty_format_usage(usage_plan), ) print(usage_plan.sum()) @@ -406,19 +406,19 @@ def test_dp_two_intervals_one_sided_length_use_all_false(self): # Check component length self.assertGreaterEqual(contiguity_info[i]["sum"], 40) - def test_dp_two_intervals_exact_input_a(self): - """Test auto mode with two intervals.""" + def test_dp_two_segments_exact_input_a(self): + """Test auto mode with two segments.""" usage_plan = self.wt_opt.get_optimal_usage_plan( region=self.region, usage_window_start=self.window_start_test, usage_window_end=self.window_end_test, usage_time_required_minutes=160, usage_power_kw=self.usage_power_kw, - charge_per_interval=[(60, 60), (100, 100)], + charge_per_segment=[(60, 60), (100, 100)], optimization_method="auto", ) print( - "Using auto mode with two exact intervals\n", + "Using auto mode with two exact segments\n", pretty_format_usage(usage_plan), ) print(usage_plan.sum()) @@ -446,18 +446,18 @@ def test_dp_two_intervals_exact_input_a(self): # Check combined component length self.assertAlmostEqual(contiguity_info[0]["sum"], 160) - def test_dp_two_intervals_exact_input_b(self): - """Test auto mode with two intervals.""" + def test_dp_two_segments_exact_input_b(self): + """Test auto mode with two segments.""" usage_plan = self.wt_opt.get_optimal_usage_plan( region=self.region, usage_window_start=self.window_start_test, usage_window_end=self.window_end_test, usage_time_required_minutes=160, usage_power_kw=self.usage_power_kw, - charge_per_interval=[60, 100], + charge_per_segment=[60, 100], optimization_method="auto", ) - print("Using auto mode, but with two intervals") + print("Using auto mode, but with two segments") print(pretty_format_usage(usage_plan)) print(usage_plan.sum()) @@ -484,19 +484,19 @@ def test_dp_two_intervals_exact_input_b(self): # Check combined component length self.assertAlmostEqual(contiguity_info[0]["sum"], 160) - def test_dp_two_intervals_exact_unround(self): - """Test auto mode with two intervals, specified via list of tuple.""" + def test_dp_two_segments_exact_unround(self): + """Test auto mode with two segments, specified via list of tuple.""" usage_plan = self.wt_opt.get_optimal_usage_plan( region=self.region, usage_window_start=self.window_start_test, usage_window_end=self.window_end_test, usage_time_required_minutes=160, usage_power_kw=self.usage_power_kw, - charge_per_interval=[(67, 67), (93, 93)], + charge_per_segment=[(67, 67), (93, 93)], optimization_method="auto", ) print( - "Using auto mode with two exact unround intervals\n", + "Using auto mode with two exact unround segments\n", pretty_format_usage(usage_plan), ) print(usage_plan.sum()) @@ -524,19 +524,19 @@ def test_dp_two_intervals_exact_unround(self): # Check combined component length self.assertAlmostEqual(contiguity_info[0]["sum"], 160) - def test_dp_two_intervals_exact_unround_alternate_input(self): - """Test auto mode with two intervals, specified via list of ints.""" + def test_dp_two_segments_exact_unround_alternate_input(self): + """Test auto mode with two segments, specified via list of ints.""" usage_plan = self.wt_opt.get_optimal_usage_plan( region=self.region, usage_window_start=self.window_start_test, usage_window_end=self.window_end_test, usage_time_required_minutes=160, usage_power_kw=self.usage_power_kw, - charge_per_interval=[67, 93], + charge_per_segment=[67, 93], optimization_method="auto", ) print( - "Using auto mode with two exact unround intervals\n", + "Using auto mode with two exact unround segments\n", pretty_format_usage(usage_plan), ) print(usage_plan.sum()) @@ -564,18 +564,18 @@ def test_dp_two_intervals_exact_unround_alternate_input(self): # Check combined component length self.assertAlmostEqual(contiguity_info[0]["sum"], 160) - def test_dp_two_intervals_exact_inconsistent_b(self): - """Test auto mode with one interval that is inconsistent with usage_time_required.""" + def test_dp_two_segments_exact_inconsistent_b(self): + """Test auto mode with one segment that is inconsistent with usage_time_required.""" usage_plan = self.wt_opt.get_optimal_usage_plan( region=self.region, usage_window_start=self.window_start_test, usage_window_end=self.window_end_test, usage_time_required_minutes=160, usage_power_kw=self.usage_power_kw, - charge_per_interval=[(65, 65)], + charge_per_segment=[(65, 65)], optimization_method="auto", ) - print("Using auto mode, but with two intervals") + print("Using auto mode, but with two segments") print(pretty_format_usage(usage_plan)) print(usage_plan.sum()) diff --git a/watttime/api.py b/watttime/api.py index 1e918af8..28b19920 100644 --- a/watttime/api.py +++ b/watttime/api.py @@ -580,7 +580,7 @@ def __init__(self, window_end=datetime(2025, 1, 2, hour=8, second=1, tzinfo=UTC), usage_time_required_minutes=240, usage_power_kw=2, - charge_per_interval=None): + charge_per_segment=None): self.moers_list = moers_list self.requery_dates = requery_dates self.region = region @@ -588,7 +588,7 @@ def __init__(self, self.window_end = window_end self.usage_time_required_minutes = usage_time_required_minutes self.usage_power_kw = usage_power_kw - self.charge_per_interval = charge_per_interval + self.charge_per_segment = charge_per_segment self.username = os.getenv("WATTTIME_USER") self.password = os.getenv("WATTTIME_PASSWORD") @@ -601,7 +601,7 @@ def _get_initial_plan(self): usage_window_end=self.window_end, usage_time_required_minutes=self.usage_time_required_minutes, usage_power_kw=self.usage_power_kw, - charge_per_interval=self.charge_per_interval, + charge_per_segment=self.charge_per_segment, optimization_method="simple", moer_data_override=self.moers_list[0][["point_time","value"]] ) @@ -613,7 +613,7 @@ def simulate(self): start_time=self.window_start, end_time=self.window_end, total_time_required=self.usage_time_required_minutes, - charge_per_interval=self.charge_per_interval + charge_per_segment=self.charge_per_segment ) # check to see the status of my segments to know if I should requery at all @@ -629,7 +629,7 @@ def simulate(self): usage_window_end=self.window_end, usage_time_required_minutes=new_time_required, usage_power_kw=self.usage_power_kw, - charge_per_interval=self.charge_per_interval, + charge_per_segment=self.charge_per_segment, optimization_method="simple", moer_data_override=self.moers_list[i][["point_time","value"]] ) diff --git a/watttime_optimizer/Optimizer README.md b/watttime_optimizer/Optimizer README.md index 439ee83b..03732392 100644 --- a/watttime_optimizer/Optimizer README.md +++ b/watttime_optimizer/Optimizer README.md @@ -176,14 +176,14 @@ window_end = now + timedelta(minutes=720) usage_power_kw = 12.0 region = "CAISO_NORTH" -# by passing a single interval of 120 minutes to charge_per_interval, the Optimizer will know to fit call the fixed contigous modeling function. +# by passing a single interval of 120 minutes to charge_per_segment, the Optimizer will know to fit call the fixed contigous modeling function. usage_plan = wt_opt.get_optimal_usage_plan( region=region, usage_window_start=window_start, usage_window_end=window_end, usage_time_required_minutes=120, usage_power_kw=12, - charge_per_interval=[120], + charge_per_segment=[120], optimization_method="auto", verbose = False ) @@ -214,14 +214,14 @@ now = datetime.now(UTC) window_start = now window_end = now + timedelta(minutes=720) -# Pass two values to charge_per_interval instead of one. +# Pass two values to charge_per_segment instead of one. usage_plan = wt_opt.get_optimal_usage_plan( region="CAISO_NORTH", usage_window_start=window_start, usage_window_end=window_end, usage_time_required_minutes=120, # 80 + 40 usage_power_kw=12, - charge_per_interval=[80,40], + charge_per_segment=[80,40], optimization_method="auto", ) diff --git a/watttime_optimizer/alg/optCharger.py b/watttime_optimizer/alg/optCharger.py index d2b76016..51b128dd 100644 --- a/watttime_optimizer/alg/optCharger.py +++ b/watttime_optimizer/alg/optCharger.py @@ -284,7 +284,7 @@ def __contiguous_fit( total_time: int, moer: Moer, emission_multiplier_fn, - charge_per_interval: list = [], + charge_per_segment: list = [], constraints: dict = {}, ): """ @@ -304,7 +304,7 @@ def __contiguous_fit( An object representing Marginal Operating Emissions Rate. emission_multiplier_fn : callable A function that calculates emission multipliers. - charge_per_interval : list of int + charge_per_segment : list of int The exact charging amount per interval. constraints : dict, optional A dictionary of charging constraints for specific time steps. Constraints are one-indexed: t:(a,b) means that after t minutes, we have to have charged for between a and b minutes inclusive, so that 1<=t<=total_time @@ -321,13 +321,13 @@ def __contiguous_fit( This is the __diagonal_fit() algorithm with further constraint on contiguous charging intervals and their respective length """ self.verbose_on("== Fixed contiguous fit! ==") - total_interval = len(charge_per_interval) + total_interval = len(charge_per_segment) # This is a matrix with size = number of time states x number of intervals charged so far max_util = np.full((total_time + 1, total_interval + 1), np.nan) max_util[0, 0] = 0.0 path_history = np.full((total_time, total_interval + 1), False, dtype=bool) cum_charge = [0] - for c in charge_per_interval: + for c in charge_per_segment: cum_charge.append(cum_charge[-1] + c) charge_array_cache = [ @@ -354,8 +354,8 @@ def __contiguous_fit( max_util[t, k] = max_util[t - 1, k] init_val = False ## charging - if (k > 0) and (charge_per_interval[k - 1] <= t): - dc = charge_per_interval[k - 1] + if (k > 0) and (charge_per_segment[k - 1] <= t): + dc = charge_per_segment[k - 1] if not np.isnan( max_util[t - dc, k - 1] ) and OptCharger.__check_constraint( @@ -389,7 +389,7 @@ def __contiguous_fit( t_curr -= 1 else: ## charge - dc = charge_per_interval[curr_state - 1] + dc = charge_per_segment[curr_state - 1] t_curr -= dc curr_state -= 1 if dc > 0: @@ -406,8 +406,8 @@ def __variable_contiguous_fit( total_time: int, moer: Moer, emission_multiplier_fn, - charge_per_interval: list = [], - use_all_intervals: bool = True, + charge_per_segment: list = [], + use_all_segments: bool = True, constraints: dict = {}, ): """ @@ -427,10 +427,10 @@ def __variable_contiguous_fit( An object representing Marginal Operating Emissions Rate. emission_multiplier_fn : callable A function that calculates emission multipliers. - charge_per_interval : list of (int, int) + charge_per_segment : list of (int, int) The minimium and maximum (inclusive) charging amount per interval. - use_all_intervals : bool - If true, use all intervals provided by charge_per_interval; if false, can use the first few intervals and skip the rest. + use_all_segments : bool + If true, use all intervals provided by charge_per_segment; if false, can use the first few intervals and skip the rest. constraints : dict, optional A dictionary of charging constraints for specific time steps. Constraints are one-indexed: t:(a,b) means that after t minutes, we have to have charged for between a and b minutes inclusive, so that 1<=t<=total_time @@ -446,7 +446,7 @@ def __variable_contiguous_fit( This is the __diagonal_fit() algorithm with further constraint on contiguous charging intervals and their respective length """ self.verbose_on("== Variable contiguous fit! ==") - total_interval = len(charge_per_interval) + total_interval = len(charge_per_segment) # This is a matrix with size = number of time states x number of charge states x number of intervals charged so far max_util = np.full( (total_time + 1, total_charge + 1, total_interval + 1), np.nan @@ -483,8 +483,8 @@ def __variable_contiguous_fit( ## charging if k > 0: for dc in range( - charge_per_interval[k - 1][0], - min(charge_per_interval[k - 1][1], t, c) + 1, + charge_per_segment[k - 1][0], + min(charge_per_segment[k - 1][1], t, c) + 1, ): if not np.isnan( max_util[t - dc, c - dc, k - 1] @@ -505,7 +505,7 @@ def __variable_contiguous_fit( total_interval, max_util[total_time, total_charge, total_interval], ) - if not use_all_intervals: + if not use_all_segments: for k in range(0, total_interval): if np.isnan(max_util[total_time, total_charge, optimal_interval]) or ( not np.isnan(max_util[total_time, total_charge, k]) @@ -547,8 +547,8 @@ def fit( total_charge: int, total_time: int, moer: Moer, - charge_per_interval=None, - use_all_intervals: bool = True, + charge_per_segment=None, + use_all_segments: bool = True, constraints: dict = {}, emission_multiplier_fn=None, optimization_method: str = "auto", @@ -568,10 +568,10 @@ def fit( The total time available for charging. moer : Moer An object representing Marginal Operating Emissions Rate. - charge_per_interval : list of int or (int,int), optional + charge_per_segment : list of int or (int,int), optional The minimium and maximum (inclusive) charging amount per interval. If int instead of tuple, interpret as both min and max. - use_all_intervals : bool - If true, use all intervals provided by charge_per_interval; if false, can use the first few intervals and skip the rest. This can only be false if charge_per_interval is provided as a range. + use_all_segments : bool + If true, use all intervals provided by charge_per_segment; if false, can use the first few intervals and skip the rest. This can only be false if charge_per_segment is provided as a range. constraints : dict, optional A dictionary of charging constraints for specific time steps. emission_multiplier_fn : callable, optional @@ -620,7 +620,7 @@ def fit( self.__greedy_fit(total_charge, total_time, moer) elif ( not constraints - and not charge_per_interval + and not charge_per_segment and constant_emission_multiplier and optimization_method == "auto" ) or (optimization_method == "simple"): @@ -629,7 +629,7 @@ def fit( "Warning: Emissions function is non-constant. Using the simple algorithm is suboptimal." ) self.__simple_fit(total_charge, total_time, moer) - elif not charge_per_interval: + elif not charge_per_segment: self.__diagonal_fit( total_charge, total_time, @@ -651,7 +651,7 @@ def convert_input(c): return c[0], c, True return None, c, False - for c in charge_per_interval: + for c in charge_per_segment: if use_fixed_alg: sc, tc, use_fixed_alg = convert_input(c) single_cpi.append(sc) @@ -660,7 +660,7 @@ def convert_input(c): tuple_cpi.append(convert_input(c)[1]) if use_fixed_alg: assert ( - use_all_intervals + use_all_segments ), "Must use all intervals when interval lengths are fixed!" self.__contiguous_fit( total_charge, @@ -681,7 +681,7 @@ def convert_input(c): emission_multiplier_fn, total_charge ), tuple_cpi, - use_all_intervals, + use_all_segments, constraints, ) @@ -714,7 +714,7 @@ def get_interval_ids(self) -> list: Returns list of the interval ids for each interval. Has a value of -1 for non-charging intervals. Intervals are labeled starting from 0 to n-1 when there are n intervals - Only defined when charge_per_interval variable is given to some fit function + Only defined when charge_per_segment variable is given to some fit function """ return self.__interval_ids diff --git a/watttime_optimizer/api_convert.py b/watttime_optimizer/api_convert.py index 5109d056..048c6a64 100644 --- a/watttime_optimizer/api_convert.py +++ b/watttime_optimizer/api_convert.py @@ -22,9 +22,9 @@ def convert_soc_to_soe(soc_power_df, voltage_curve, battery_capacity_coulombs): # Calculate differential SoC for numerical integration delta_soc = np.diff(soc, prepend=0) - charge_per_interval = delta_soc * battery_capacity_coulombs + charge_per_segment = delta_soc * battery_capacity_coulombs # Energy is voltage * charge - energy_kwh = np.cumsum(voltage * charge_per_interval * 0.001 / 3600) + energy_kwh = np.cumsum(voltage * charge_per_segment * 0.001 / 3600) # Normalize so that State of energy goes from 0 to 1 soe_array = energy_kwh / energy_kwh.iloc[-1] diff --git a/watttime_optimizer/api_opt.py b/watttime_optimizer/api_opt.py index 2db9e6e0..1597f40c 100644 --- a/watttime_optimizer/api_opt.py +++ b/watttime_optimizer/api_opt.py @@ -45,8 +45,8 @@ def get_optimal_usage_plan( usage_power_kw: Optional[Union[int, float, pd.DataFrame]] = None, energy_required_kwh: Optional[Union[int, float]] = None, usage_time_uncertainty_minutes: Optional[Union[int, float]] = 0, - charge_per_interval: Optional[list] = None, - use_all_intervals: bool = True, + charge_per_segment: Optional[list] = None, + use_all_segments: bool = True, constraints: Optional[dict] = None, optimization_method: Optional[ Literal["baseline", "simple", "sophisticated", "auto"] @@ -79,11 +79,11 @@ def get_optimal_usage_plan( Energy required in kwh usage_time_uncertainty_minutes : Optional[Union[int, float]], default=0 Uncertainty in usage time, in minutes. - charge_per_interval : Optional[list], default=None + charge_per_segment : Optional[list], default=None Either a list of length-2 tuples representing minimium and maximum (inclusive) charging minutes per interval, or a list of ints representing both the min and max. [180] OR [(180,180)] - use_all_intervals : Optional[bool], default=False - If true, use all intervals provided by charge_per_interval; if false, can use the first few intervals and skip the rest. + use_all_segments : Optional[bool], default=False + If true, use all intervals provided by charge_per_segment; if false, can use the first few intervals and skip the rest. constraints : Optional[dict], default=None A dictionary containing contraints on how much usage must be used before the given time point optimization_method : Optional[Literal["baseline", "simple", "sophisticated", "auto"]], default="baseline" @@ -261,32 +261,32 @@ def emission_multiplier_fn(sc: float, ec: float) -> float: ) return value - if charge_per_interval: - # Handle the charge_per_interval input by converting it from minutes to units, rounding up - converted_charge_per_interval = [] - for c in charge_per_interval: + if charge_per_segment: + # Handle the charge_per_segment input by converting it from minutes to units, rounding up + converted_charge_per_segment = [] + for c in charge_per_segment: if isinstance(c, int): - converted_charge_per_interval.append(minutes_to_units(c)) + converted_charge_per_segment.append(minutes_to_units(c)) else: assert ( len(c) == 2 - ), "Length of tuples in charge_per_interval is not 2" + ), "Length of tuples in charge_per_segment is not 2" interval_start_units = minutes_to_units(c[0]) if c[0] else 0 interval_end_units = ( minutes_to_units(c[1]) if c[1] else self.MAX_INT ) - converted_charge_per_interval.append( + converted_charge_per_segment.append( (interval_start_units, interval_end_units) ) else: - converted_charge_per_interval = None + converted_charge_per_segment = None model.fit( total_charge=total_charge_units, total_time=len(moer_values), moer=m, constraints=constraints, - charge_per_interval=converted_charge_per_interval, - use_all_intervals=use_all_intervals, + charge_per_segment=converted_charge_per_segment, + use_all_segments=use_all_segments, emission_multiplier_fn=emission_multiplier_fn, optimization_method=optimization_method, ) @@ -297,7 +297,7 @@ def emission_multiplier_fn(sc: float, ec: float) -> float: result_df, model, usage_time_required_minutes, - charge_per_interval, + charge_per_segment, ) return result_df @@ -308,33 +308,33 @@ def _reconcile_constraints( result_df, model, usage_time_required_minutes, - charge_per_interval, + charge_per_segment, ): - # Make a copy of charge_per_interval if necessary - if charge_per_interval is not None: - charge_per_interval = charge_per_interval[::] - for i in range(len(charge_per_interval)): - if type(charge_per_interval[i]) == int: - charge_per_interval[i] = ( - charge_per_interval[i], - charge_per_interval[i], + # Make a copy of charge_per_segment if necessary + if charge_per_segment is not None: + charge_per_segment = charge_per_segment[::] + for i in range(len(charge_per_segment)): + if type(charge_per_segment[i]) == int: + charge_per_segment[i] = ( + charge_per_segment[i], + charge_per_segment[i], ) - assert len(charge_per_interval[i]) == 2 + assert len(charge_per_segment[i]) == 2 processed_start = ( - charge_per_interval[i][0] - if charge_per_interval[i][0] is not None + charge_per_segment[i][0] + if charge_per_segment[i][0] is not None else 0 ) processed_end = ( - charge_per_interval[i][1] - if charge_per_interval[i][1] is not None + charge_per_segment[i][1] + if charge_per_segment[i][1] is not None else self.MAX_INT ) - charge_per_interval[i] = (processed_start, processed_end) + charge_per_segment[i] = (processed_start, processed_end) - if not charge_per_interval: - # Handle case without charge_per_interval constraints + if not charge_per_segment: + # Handle case without charge_per_segment constraints total_usage_intervals = sum(optimizer_result) current_usage_intervals = 0 usage_list = [] @@ -353,7 +353,7 @@ def _reconcile_constraints( ) result_df["usage"] = usage_list else: - # Process charge_per_interval constraints + # Process charge_per_segment constraints result_df["usage"] = [ x * float(self.OPT_INTERVAL) for x in optimizer_result ] @@ -374,19 +374,19 @@ def get_min_max_indices(lst, x): ), "interval_id not found in interval_ids" sections.append(get_min_max_indices(interval_ids, interval_id)) - # Adjust sections to satisfy charge_per_interval constraints + # Adjust sections to satisfy charge_per_segment constraints for i, (start, end) in enumerate(sections): section_usage = usage[start : end + 1] total_minutes = section_usage.sum() # Get the constraints for this section - if isinstance(charge_per_interval[i], int): + if isinstance(charge_per_segment[i], int): min_minutes, max_minutes = ( - charge_per_interval[i], - charge_per_interval[i], + charge_per_segment[i], + charge_per_segment[i], ) else: - min_minutes, max_minutes = charge_per_interval[i] + min_minutes, max_minutes = charge_per_segment[i] # Adjust the section to fit the constraints if total_minutes < min_minutes: @@ -431,7 +431,7 @@ class WattTimeRecalculator: all_schedules (list): List of tuples containing (schedule, time_context) pairs total_time_required (int): Total charging time needed in minutes end_time (datetime): Final deadline for the charging schedule - charge_per_interval (list): List of charging durations per interval + charge_per_segment (list): List of charging durations per interval is_contiguous (bool): Flag indicating if charging must be contiguous sleep_delay(bool): Flag indicating if next query time must be delayed contiguity_values_dict (dict): Dictionary storing contiguity-related values @@ -444,7 +444,7 @@ def __init__( end_time: datetime, total_time_required: int, contiguous=False, - charge_per_interval: Optional[list] = None, + charge_per_segment: Optional[list] = None, ) -> None: """Initialize the Recalculator with an initial schedule. @@ -453,13 +453,13 @@ def __init__( start_time (datetime): Start time for the schedule end_time (datetime): End time for the schedule total_time_required (int): Total charging time needed in minutes - charge_per_interval (list): List of charging durations per interval + charge_per_segment (list): List of charging durations per interval """ self.OPT_INTERVAL = 5 self.all_schedules = [(initial_schedule, (start_time, end_time))] self.end_time = end_time self.total_time_required = total_time_required - self.charge_per_interval = charge_per_interval + self.charge_per_segment = charge_per_segment self.is_contiguous = contiguous self.sleep_delay = False self.contiguity_values_dict = { @@ -670,7 +670,7 @@ def number_segments_complete(self, next_query_time: datetime = None): completed_schedule = combined_schedule.loc[:next_query_time] charging_indicator = completed_schedule["usage"].astype(bool).sum() return bisect.bisect_right( - list(accumulate(self.charge_per_interval)), (charging_indicator * 5) + list(accumulate(self.charge_per_segment)), (charging_indicator * 5) ) else: return None @@ -686,7 +686,7 @@ def __init__( window_end=datetime(2025, 1, 2, hour=8, second=1, tzinfo=UTC), usage_time_required_minutes=240, usage_power_kw=2, - charge_per_interval=None, + charge_per_segment=None, ): self.moers_list = moers_list self.requery_dates = requery_dates @@ -695,7 +695,7 @@ def __init__( self.window_end = window_end self.usage_time_required_minutes = usage_time_required_minutes self.usage_power_kw = usage_power_kw - self.charge_per_interval = charge_per_interval + self.charge_per_segment = charge_per_segment self.username = os.getenv("WATTTIME_USER") self.password = os.getenv("WATTTIME_PASSWORD") @@ -708,7 +708,7 @@ def _get_initial_plan(self): usage_window_end=self.window_end, usage_time_required_minutes=self.usage_time_required_minutes, usage_power_kw=self.usage_power_kw, - charge_per_interval=self.charge_per_interval, + charge_per_segment=self.charge_per_segment, optimization_method="simple", moer_data_override=self.moers_list[0][["point_time", "value"]], ) @@ -720,7 +720,7 @@ def simulate(self): start_time=self.window_start, end_time=self.window_end, total_time_required=self.usage_time_required_minutes, - charge_per_interval=self.charge_per_interval, + charge_per_segment=self.charge_per_segment, ) # check to see the status of my segments to know if I should requery at all @@ -738,7 +738,7 @@ def simulate(self): usage_window_end=self.window_end, usage_time_required_minutes=new_time_required, usage_power_kw=self.usage_power_kw, - charge_per_interval=self.charge_per_interval, + charge_per_segment=self.charge_per_segment, optimization_method="simple", moer_data_override=self.moers_list[i][["point_time", "value"]], ) diff --git a/watttime_optimizer/evaluator/analysis.py b/watttime_optimizer/evaluator/analysis.py index 32d92b1b..48e47c22 100644 --- a/watttime_optimizer/evaluator/analysis.py +++ b/watttime_optimizer/evaluator/analysis.py @@ -31,7 +31,7 @@ def analysis_loop_requery(region, input_dict, interval,username,password): "optimization_method": "auto", "verbose":False, "interval":interval, - "charge_per_interval":None} + "charge_per_segment":None} ) df = roce.fit_recalculator(**value) m, b = np.polyfit(np.arange(len(df.pred_moer.values)),df.pred_moer.values, 1) diff --git a/watttime_optimizer/evaluator/evaluator.py b/watttime_optimizer/evaluator/evaluator.py index 62fa1c6e..4cc362bc 100644 --- a/watttime_optimizer/evaluator/evaluator.py +++ b/watttime_optimizer/evaluator/evaluator.py @@ -158,7 +158,7 @@ def get_schedule_and_cost_api( region:str = 'CAISO_NORTH', optimization_method: str = "auto", constraints: Optional[dict] = None, - charge_per_interval: Optional[list] = None, + charge_per_segment: Optional[list] = None, tz_convert: bool = False, verbose:bool=False ) -> pd.DataFrame: @@ -177,7 +177,7 @@ def get_schedule_and_cost_api( MOER forecast data optimization_method : str, optional Optimization method (default: "auto") - charge_per_interval : list, optional + charge_per_segment : list, optional List of charging constraints per interval Returns: @@ -199,7 +199,7 @@ def get_schedule_and_cost_api( usage_power_kw=usage_power_kw, optimization_method=optimization_method, moer_data_override=self.moer_data_override(start_time = usage_window_start, end_time = usage_window_end, region=region), - charge_per_interval=charge_per_interval, + charge_per_segment=charge_per_segment, constraints=constraints, verbose=verbose ) @@ -245,7 +245,7 @@ def fit_recalculator( region:str = 'CAISO_NORTH', optimization_method: str = "auto", constraints: Optional[dict] = None, - charge_per_interval: Optional[list] = None, + charge_per_segment: Optional[list] = None, tz_convert: bool = False, verbose:bool=False, contiguous:bool=False @@ -261,7 +261,7 @@ def fit_recalculator( usage_window_end=usage_window_end, time_needed=time_needed, usage_power_kw=usage_power_kw, - charge_per_interval=charge_per_interval, + charge_per_segment=charge_per_segment, optimization_method=optimization_method, constraints=constraints, verbose=verbose, @@ -273,7 +273,7 @@ def fit_recalculator( start_time=usage_window_start, end_time=usage_window_end, total_time_required=time_needed, - charge_per_interval=charge_per_interval, + charge_per_segment=charge_per_segment, contiguous=contiguous ) @@ -291,7 +291,7 @@ def fit_recalculator( usage_window_end=usage_window_end, usage_time_required_minutes=optimization_outcomes["remaining_time_required"], usage_power_kw=usage_power_kw, - charge_per_interval=[int(optimization_outcomes["remaining_time_required"])] if recalculator.is_contiguous else None, + charge_per_segment=[int(optimization_outcomes["remaining_time_required"])] if recalculator.is_contiguous else None, optimization_method=optimization_method, moer_data_override=self.moer_data_override(start_time,usage_window_end,region), verbose=verbose diff --git a/watttime_optimizer/examples/synthetic_data.ipynb b/watttime_optimizer/examples/synthetic_data.ipynb index de6c8921..cfd3d578 100644 --- a/watttime_optimizer/examples/synthetic_data.ipynb +++ b/watttime_optimizer/examples/synthetic_data.ipynb @@ -692,7 +692,7 @@ "metadata": {}, "outputs": [], "source": [ - "value.update({\"optimization_method\": \"simple\", \"interval\":15, \"charge_per_interval\":None})" + "value.update({\"optimization_method\": \"simple\", \"interval\":15, \"charge_per_segment\":None})" ] }, { @@ -712,7 +712,7 @@ " 'verbose': False,\n", " 'optimization_method': 'simple',\n", " 'interval': 15,\n", - " 'charge_per_interval': None}" + " 'charge_per_segment': None}" ] }, "execution_count": 18, From 9aed59a3ca47a3bd1e83c7ea4de16a282f43d5a6 Mon Sep 17 00:00:00 2001 From: jbadsdata Date: Tue, 6 May 2025 17:14:06 -0400 Subject: [PATCH 4/5] fit selection error fixed --- tests/test_optimizer.py | 4 +- watttime/api.py | 74 +------------------------- watttime_optimizer/Optimizer README.md | 14 ++--- watttime_optimizer/api_opt.py | 12 ++--- 4 files changed, 16 insertions(+), 88 deletions(-) diff --git a/tests/test_optimizer.py b/tests/test_optimizer.py index 3a3dc607..597363c1 100644 --- a/tests/test_optimizer.py +++ b/tests/test_optimizer.py @@ -151,7 +151,7 @@ def test_dp_fixed_power_rate_with_uncertainty(self): optimization_method="sophisticated", ) print("Using DP Plan w/ fixed power rate and charging uncertainty") - print(usage_plan["emissions_co2e_lb"].sum()) + print(usage_plan["emissions_co2_lb"].sum()) # Check time required self.assertAlmostEqual(usage_plan["usage"].sum(), 240) @@ -179,7 +179,7 @@ def test_dp_variable_power_rate(self): optimization_method="auto", ) print("Using DP Plan w/ variable power rate") - print(usage_plan["emissions_co2e_lb"].sum()) + print(usage_plan["emissions_co2_lb"].sum()) # Check time required self.assertAlmostEqual(usage_plan["usage"].sum(), 320) diff --git a/watttime/api.py b/watttime/api.py index 28b19920..bafaa150 100644 --- a/watttime/api.py +++ b/watttime/api.py @@ -10,8 +10,6 @@ import requests from dateutil.parser import parse from pytz import UTC, timezone -from itertools import accumulate -import bisect class WattTimeBase: @@ -569,74 +567,4 @@ def get_maps_json( params = {"signal_type": signal_type} rsp = requests.get(url, headers=headers, params=params) rsp.raise_for_status() - return rsp.json() - - - def __init__(self, - moers_list, - requery_dates, - region="CAISO_NORTH", - window_start=datetime(2025, 1, 1, hour=21, second=1, tzinfo=UTC), - window_end=datetime(2025, 1, 2, hour=8, second=1, tzinfo=UTC), - usage_time_required_minutes=240, - usage_power_kw=2, - charge_per_segment=None): - self.moers_list = moers_list - self.requery_dates = requery_dates - self.region = region - self.window_start = window_start - self.window_end = window_end - self.usage_time_required_minutes = usage_time_required_minutes - self.usage_power_kw = usage_power_kw - self.charge_per_segment = charge_per_segment - - self.username = os.getenv("WATTTIME_USER") - self.password = os.getenv("WATTTIME_PASSWORD") - self.wt_opt = WattTimeOptimizer(self.username, self.password) - - def _get_initial_plan(self): - return self.wt_opt.get_optimal_usage_plan( - region=self.region, - usage_window_start=self.window_start, - usage_window_end=self.window_end, - usage_time_required_minutes=self.usage_time_required_minutes, - usage_power_kw=self.usage_power_kw, - charge_per_segment=self.charge_per_segment, - optimization_method="simple", - moer_data_override=self.moers_list[0][["point_time","value"]] - ) - - def simulate(self): - initial_plan = self._get_initial_plan() - recalculator = WattTimeRecalculator( - initial_schedule=initial_plan, - start_time=self.window_start, - end_time=self.window_end, - total_time_required=self.usage_time_required_minutes, - charge_per_segment=self.charge_per_segment - ) - - # check to see the status of my segments to know if I should requery at all - # if I do need to requery, then I need time required + segments remaining - # if I don't then I store the state of my recalculator as is - - for i, new_window_start in enumerate(self.requery_dates[1:], 1): - new_time_required = recalculator.get_remaining_time_required(new_window_start) - if new_time_required > 0.0: - next_plan = self.wt_opt.get_optimal_usage_plan( - region=self.region, - usage_window_start=new_window_start, - usage_window_end=self.window_end, - usage_time_required_minutes=new_time_required, - usage_power_kw=self.usage_power_kw, - charge_per_segment=self.charge_per_segment, - optimization_method="simple", - moer_data_override=self.moers_list[i][["point_time","value"]] - ) - recalculator.update_charging_schedule( - new_schedule=next_plan, - next_query_time=new_window_start, - next_new_schedule_start_time = None - ) - else: - return recalculator \ No newline at end of file + return rsp.json() \ No newline at end of file diff --git a/watttime_optimizer/Optimizer README.md b/watttime_optimizer/Optimizer README.md index 03732392..085c78af 100644 --- a/watttime_optimizer/Optimizer README.md +++ b/watttime_optimizer/Optimizer README.md @@ -34,7 +34,7 @@ Click any of the thumbnails below to see the notebook that generated it. from datetime import datetime, timedelta import pandas as pd from pytz import UTC -from optimizer import WattTimeOptimizer +from watttime_optimizer import WattTimeOptimizer import os username = os.getenv("WATTTIME_USER") @@ -67,7 +67,7 @@ print(usage_plan.sum()) from datetime import datetime, timedelta import pandas as pd from pytz import UTC -from optimizer import WattTimeOptimizer +from watttime_optimizer import WattTimeOptimizer import os username = os.getenv("WATTTIME_USER") @@ -112,7 +112,7 @@ charged, and then remains at 10kW for the rest of the charging. This is the char from datetime import datetime, timedelta import pandas as pd from pytz import UTC -from optimizer import WattTimeOptimizer +from watttime_optimizer import WattTimeOptimizer from watttime_optimizer.battery import Battery import os @@ -154,7 +154,7 @@ print(usage_plan.sum()) ``` * **Data Center Workload 1**: - * (single period, fixed length) - charging schedule to be composed of contiguous interval(s) of fixed length + * (single segment, fixed length) - charging schedule to be composed of a single contiguous, i.e. "block" segment of fixed length ```py ## AI model training - estimated runtime is 2 hours and it needs to complete within 12 hours @@ -162,7 +162,7 @@ print(usage_plan.sum()) from datetime import datetime, timedelta import pandas as pd from pytz import UTC -from optimizer import WattTimeOptimizer +from watttime_optimizer import WattTimeOptimizer import os username = os.getenv("WATTTIME_USER") @@ -194,7 +194,7 @@ print(usage_plan.sum()) ``` **Data Center Workload 2**: - * (multiple periods, fixed length) - runs over two usage intervals of lengths 80 min and 40 min. The order of the intervals is immutable. + * (multiple segments, fixed length) - runs over two usage periods of lengths 80 min and 40 min. The order of the segments is immutable. ```py ## there are two cycles of length 80 min and 40 min each, and they must be completed in that order. @@ -202,7 +202,7 @@ print(usage_plan.sum()) from datetime import datetime, timedelta import pandas as pd from pytz import UTC -from optimizer import WattTimeOptimizer +from watttime_optimizer import WattTimeOptimizer import os username = os.getenv("WATTTIME_USER") diff --git a/watttime_optimizer/api_opt.py b/watttime_optimizer/api_opt.py index 1597f40c..2007074b 100644 --- a/watttime_optimizer/api_opt.py +++ b/watttime_optimizer/api_opt.py @@ -194,7 +194,6 @@ def minutes_to_units(x, floor=False): forecast_df = forecast_df.set_index("point_time") forecast_df.index = pd.to_datetime(forecast_df.index) - # relevant_forecast_df = forecast_df[usage_window_start:usage_window_end] relevant_forecast_df = forecast_df[forecast_df.index >= usage_window_start] relevant_forecast_df = relevant_forecast_df[ relevant_forecast_df.index < usage_window_end @@ -213,11 +212,12 @@ def minutes_to_units(x, floor=False): if optimization_method in ("sophisticated", "auto"): # Give a buffer time equal to the uncertainty buffer_time = usage_time_uncertainty_minutes - buffer_periods = minutes_to_units(buffer_time) if buffer_time else 0 - buffer_enforce_time = max( - total_charge_units, len(moer_values) - buffer_periods - ) - constraints.update({buffer_enforce_time: (total_charge_units, None)}) + if buffer_time > 0: + buffer_periods = minutes_to_units(buffer_time) if buffer_time else 0 + buffer_enforce_time = max( + total_charge_units, len(moer_values) - buffer_periods + ) + constraints.update({buffer_enforce_time: (total_charge_units, None)}) else: assert ( usage_time_uncertainty_minutes == 0 From 79cad41e7d0a559590459619437cc633aa015137 Mon Sep 17 00:00:00 2001 From: jbadsdata Date: Thu, 8 May 2025 21:19:15 -0400 Subject: [PATCH 5/5] notebook and battery class updates --- tests/test_battery.py | 16 + watttime_optimizer/battery.py | 251 +++++- watttime_optimizer/evaluator/analysis.py | 2 +- watttime_optimizer/evaluator/evaluator.py | 3 + watttime_optimizer/examples/ev.ipynb | 199 +++++ .../examples/synthetic_data.ipynb | 762 +++++++++--------- 6 files changed, 847 insertions(+), 386 deletions(-) create mode 100644 tests/test_battery.py create mode 100644 watttime_optimizer/examples/ev.ipynb diff --git a/tests/test_battery.py b/tests/test_battery.py new file mode 100644 index 00000000..505a0453 --- /dev/null +++ b/tests/test_battery.py @@ -0,0 +1,16 @@ +from watttime_optimizer.battery import Battery, CARS +import pandas as pd + +tesla_charging_curve = pd.DataFrame( + columns=["SoC", "kW"], + data = CARS['tesla'] + ) + +capacity_kWh = 70 +initial_soc = .50 + +batt = Battery(tesla_charging_curve) + +df = batt.get_usage_power_kw_df(capacity_kWh=capacity_kWh, initial_soc=initial_soc) + +print(df.head()) \ No newline at end of file diff --git a/watttime_optimizer/battery.py b/watttime_optimizer/battery.py index ef236a26..eaa8c536 100644 --- a/watttime_optimizer/battery.py +++ b/watttime_optimizer/battery.py @@ -62,4 +62,253 @@ def get_kW_at_SoC(soc): result.append((int(secs_elapsed / 60 - 5), pd.Series(kW_by_second).mean())) kW_by_second = [] - return pd.DataFrame(columns=["time", "power_kw"], data=result) \ No newline at end of file + return pd.DataFrame(columns=["time", "power_kw"], data=result) + + +CARS = { + # pulled data from https://www.fastnedcharging.com/en/brands-overview + # this is a subset of the cars + "audi": [ # 71kWh, https://www.fastnedcharging.com/en/brands-overview/audi + [0.0, 120.0], + [0.6, 120.0], + [1.0, 30.0], + ], + "bmw": [ # 42.2kWh, https://www.fastnedcharging.com/en/brands-overview/bmw + [0.0, 40.0], + [0.85, 50.0], + [1.0, 5.0], + ], + 'bolt':[ + [0.0, 50.0], + [0.5, 50.0], + [0.93, 20.0], + [1.0, 0.5], + ], + "honda": [ # 35.5kWh, https://www.fastnedcharging.com/en/brands-overview/honda + [0.0, 40.0], + [0.4, 40.0], + [0.41, 30.0], + [0.70, 30.0], + [0.71, 20.0], + [0.95, 20.0], + [1.0, 10.0], + ], + "lucid": [ # 112kWh https://www.fastnedcharging.com/en/brands-overview/lucid + [0.0, 300.0], + [1.0, 50.0], + ], + "mazda": [ #35.5kWh https://www.fastnedcharging.com/en/brands-overview/mazda + [0.0, 50.0], + [0.2, 50.0], + [0.21, 40.0], + [1.0, 10.0], + ], + "subaru": [ # 75kWh https://www.fastnedcharging.com/en/brands-overview/subaru + [0.0, 150.0], + [0.25, 150.0], + [0.85, 30.0], + [1.00, 30.0], + ], + "tesla": [ # ??kWh https://www.fastnedcharging.com/en/brands-overview/tesla + [0.0, 180.0], + [0.4, 190.0], + [0.9, 40.0], + [1.0, 40.0], + ], + "volkswagen": [ # 24.2kWh https://www.fastnedcharging.com/en/brands-overview/volkswagen?model=e-Golf + [0.0, 40.0], + [0.1, 40.0], + [0.75, 45.0], + [0.81, 23.0], + [0.92, 17.0], + [0.95, 9.0], + [1.0, 9.0], + ] +} + +TZ_DICTIONARY = { + "AECI": "America/Chicago", + "AVA": "America/Los_Angeles", + "AZPS": "America/Phoenix", + "BANC": "America/Los_Angeles", + "BPA": "America/Los_Angeles", + "CAISO_ESCONDIDO": "America/Los_Angeles", + "CAISO_LONGBEACH": "America/Los_Angeles", + "CAISO_NORTH": "America/Los_Angeles", + "CAISO_PALMSPRINGS": "America/Los_Angeles", + "CAISO_REDDING": "America/Los_Angeles", + "CAISO_SANBERNARDINO": "America/Los_Angeles", + "CAISO_SANDIEGO": "America/Los_Angeles", + "CHPD": "America/Los_Angeles", + "CPLE": "America/New_York", + "CPLW": "America/New_York", + "DOPD": "America/Los_Angeles", + "DUK": "America/New_York", + "ELE": "America/Denver", + "ERCOT_AUSTIN": "America/Chicago", + "ERCOT_COAST": "America/Chicago", + "ERCOT_EASTTX": "America/Chicago", + "ERCOT_HIDALGO": "America/Chicago", + "ERCOT_NORTHCENTRAL": "America/Chicago", + "ERCOT_PANHANDLE": "America/Chicago", + "ERCOT_SANANTONIO": "America/Chicago", + "ERCOT_SECOAST": "America/Chicago", + "ERCOT_SOUTHTX": "America/Chicago", + "ERCOT_WESTTX": "America/Chicago", + "FMPP": "America/New_York", + "FPC": "America/New_York", + "FPL": "America/New_York", + "GVL": "America/New_York", + "IID": "America/Los_Angeles", + "IPCO": "America/Boise", + "ISONE_CT": "America/New_York", + "ISONE_ME": "America/New_York", + "ISONE_NEMA": "America/New_York", + "ISONE_NH": "America/New_York", + "ISONE_RI": "America/New_York", + "ISONE_SEMA": "America/New_York", + "ISONE_VT": "America/New_York", + "ISONE_WCMA": "America/New_York", + "JEA": "America/New_York", + "LDWP": "America/Los_Angeles", + "LGEE": "America/New_York", + "MISO_INDIANAPOLIS": "America/Indiana/Indianapolis", + "MISO_N_DAKOTA": "America/North_Dakota/Center", + "MPCO": "America/Denver", + "NEVP": "America/Los_Angeles", + "NYISO_NYC": "America/New_York", + "PACE": "America/Denver", + "PACW": "America/Los_Angeles", + "PGE": "America/Los_Angeles", + "PJM_CHICAGO": "America/Chicago", + "PJM_DC": "America/New_York", + "PJM_EASTERN_KY": "America/New_York", + "PJM_EASTERN_OH": "America/New_York", + "PJM_ROANOKE": "America/New_York", + "PJM_NJ": "America/New_York", + "PJM_SOUTHWEST_OH": "America/New_York", + "PJM_WESTERN_KY": "America/New_York", + "PNM": "America/Denver", + "PSCO": "America/Denver", + "PSEI": "America/Los_Angeles", + "SC": "America/New_York", + "SCEG": "America/New_York", + "SCL": "America/Los_Angeles", + "SEC": "America/New_York", + "SOCO": "America/Chicago", + "SPA": "America/Chicago", + "SPP_FORTPECK": "America/Denver", + "SPP_KANSAS": "America/Chicago", + "SPP_KC": "America/Chicago", + "SPP_MEMPHIS": "America/Chicago", + "SPP_ND": "America/North_Dakota/Beulah", + "SPP_OKCTY": "America/Chicago", + "SPP_SIOUX": "America/Chicago", + "SPP_SPRINGFIELD": "America/Chicago", + "SPP_SWOK": "America/Chicago", + "SPP_TX": "America/Chicago", + "SPP_WESTNE": "America/Chicago", + "SRP": "America/Phoenix", + "TAL": "America/New_York", + "TEC": "America/New_York", + "TEPC": "America/Phoenix", + "TID": "America/Los_Angeles", + "TPWR": "America/Los_Angeles", + "TVA": "America/Chicago", + "WACM": "America/Denver", + "WALC": "America/Phoenix", + "WAUW": "America/Denver", +} + +MOER_REGION_LIST = [ + "AECI", + "AVA", + "AZPS", + "BANC", + "BPA", + "CAISO_ESCONDIDO", + "CAISO_LONGBEACH", + "CAISO_NORTH", + "CAISO_PALMSPRINGS", + "CAISO_REDDING", + "CAISO_SANBERNARDINO", + "CAISO_SANDIEGO", + "CHPD", + "CPLE", + "CPLW", + "DOPD", + "DUK", + "ELE", + "ERCOT_AUSTIN", + "ERCOT_COAST", + "ERCOT_EASTTX", + "ERCOT_HIDALGO", + "ERCOT_NORTHCENTRAL", + "ERCOT_PANHANDLE", + "ERCOT_SANANTONIO", + "ERCOT_SECOAST", + "ERCOT_SOUTHTX", + "ERCOT_WESTTX", + "FMPP", + "FPC", + "FPL", + "GVL", + "IID", + "IPCO", + "ISONE_CT", + "ISONE_ME", + "ISONE_NEMA", + "ISONE_NH", + "ISONE_RI", + "ISONE_SEMA", + "ISONE_VT", + "ISONE_WCMA", + "JEA", + "LDWP", + "LGEE", + "MISO_INDIANAPOLIS", + "MISO_N_DAKOTA", + "MPCO", + "NEVP", + "NYISO_NYC", + "PACE", + "PACW", + "PGE", + "PJM_CHICAGO", + "PJM_DC", + "PJM_EASTERN_KY", + "PJM_EASTERN_OH", + "PJM_NJ", + "PJM_SOUTHWEST_OH", + "PJM_WESTERN_KY", + "PNM", + "PSCO", + "PSEI", + "SC", + "SCEG", + "SCL", + "SEC", + "SOCO", + "SPA", + "SPP_FORTPECK", + "SPP_KANSAS", + "SPP_KC", + "SPP_MEMPHIS", + "SPP_ND", + "SPP_OKCTY", + "SPP_SIOUX", + "SPP_SPRINGFIELD", + "SPP_SWOK", + "SPP_TX", + "SPP_WESTNE", + "SRP", + "TAL", + "TEC", + "TEPC", + "TID", + "TPWR", + "TVA", + "WACM", + "WALC", + "WAUW", +] \ No newline at end of file diff --git a/watttime_optimizer/evaluator/analysis.py b/watttime_optimizer/evaluator/analysis.py index 48e47c22..9b4607dc 100644 --- a/watttime_optimizer/evaluator/analysis.py +++ b/watttime_optimizer/evaluator/analysis.py @@ -33,7 +33,7 @@ def analysis_loop_requery(region, input_dict, interval,username,password): "interval":interval, "charge_per_segment":None} ) - df = roce.fit_recalculator(**value) + df = roce.fit_recalculator(**value).get_combined_schedule() m, b = np.polyfit(np.arange(len(df.pred_moer.values)),df.pred_moer.values, 1) stddev = df.pred_moer.std() r = ImpactEvaluator(username,password,df).get_all_emissions_values(region=region) diff --git a/watttime_optimizer/evaluator/evaluator.py b/watttime_optimizer/evaluator/evaluator.py index 4cc362bc..a877f78c 100644 --- a/watttime_optimizer/evaluator/evaluator.py +++ b/watttime_optimizer/evaluator/evaluator.py @@ -112,6 +112,9 @@ def get_all_emissions_values(self,region:str): 'actual':self.get_actual_emissions(region) } + def plot_predicated_moer(self): + self.obj["pred_moer"].plot() + def get_timeseries_stats(self,df: pd.DataFrame, col:str = "pred_moer"): ''' Dispersion, slope, and intercept of the moer forecast''' m, b = np.polyfit(np.arange(len(df[col].values)),df[col].values, 1) diff --git a/watttime_optimizer/examples/ev.ipynb b/watttime_optimizer/examples/ev.ipynb new file mode 100644 index 00000000..e4967d1e --- /dev/null +++ b/watttime_optimizer/examples/ev.ipynb @@ -0,0 +1,199 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# EV Smart Scheduling" + ] + }, + { + "cell_type": "code", + "execution_count": 118, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "os.chdir(path=os.path.dirname(os.path.dirname(os.path.abspath(os.curdir))))" + ] + }, + { + "cell_type": "code", + "execution_count": 119, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "== Simple fit! ==\n" + ] + } + ], + "source": [ + "from datetime import datetime, timedelta\n", + "import pandas as pd\n", + "from pytz import UTC\n", + "from watttime_optimizer import WattTimeOptimizer\n", + "import os\n", + "\n", + "username = os.getenv(\"WATTTIME_USER\")\n", + "password = os.getenv(\"WATTTIME_PASSWORD\")\n", + "wt_opt = WattTimeOptimizer(username, password)\n", + "\n", + "# 12 hour charge window (720/60 = 12)\n", + "now = datetime.now(UTC)\n", + "window_start = now\n", + "window_end = now + timedelta(minutes=720)\n", + "\n", + "usage_plan = wt_opt.get_optimal_usage_plan(\n", + " region=\"CAISO_NORTH\",\n", + " usage_window_start=window_start,\n", + " usage_window_end=window_end,\n", + " usage_time_required_minutes=240,\n", + " usage_power_kw=12,\n", + " optimization_method=\"auto\",\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 120, + "metadata": {}, + "outputs": [], + "source": [ + "def plot_predicated_moer(df):\n", + " df.pred_moer.plot()" + ] + }, + { + "cell_type": "code", + "execution_count": 121, + "metadata": {}, + "outputs": [], + "source": [ + "def plot_charging_kw(df):\n", + " df.usage.plot(kind=\"bar\")" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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fvhhd12111l7jdOmTYtaZ901Tp482UaNGuWuq6E1Rn7n/fv3N5/PZ6tWrbJhw4aZz+dzD5tt27bNfdHPnz//S3+XoVDIxo8fbxMnTqz3u6y9xmnTplkgELALL7zQXRfPN883zzfPN8/38fF8Dx482M444wz3+Z42bZr9z//8z1ez2SguLrY33nij3nj16tXm8/nswgsvNJ/PZ6tXrzZJNmTIEPeBXHjhhRYOh61Fixb27LPP2vvvv+92lddff729+OKLJsm+853v2IsvvmjFxcXWv39/69Onj6WlpdnXv/51d9vMrFu3btaxY0e7+OKLo7Yj6/rtb39rPp8varv2Oh3n8JfWFBYWuk/86NGj7ZZbbjHHcY66Rsdx7Omnn7YXX3zRwuGwdenSxV1X7XXWXVftcd01vvHGG/bee++56zraGhcuXGgpKSnWokUL+9///V9LSUmJWlekE77rrru+9Hd5xhln2H333WfnnXdevd9l7TXW/r3WXiPPN883zzfPN8/3V//5NjP3O80i6o7j4WmzMXr0aPe9n7rjs88+29LS0tyF1x0/8cQT1q9fP1u9erV1797devfubY7jNPheVeT+2267zVq2bGmSrHPnzlGHgyRZMBi0Ll26mOM4lpuba6Wlpe66JkyYYIMHD47arruuumuMrLNHjx4xr1GSe4isR48eNnDgQHedkTUOHjzYevToEbXOumucPHmyrVu3zgYPHhzTGiO/y8jht9onC9Vd49F+l5GfS09Pr/e7rPu7qz3m+eb55vnm+eb5Pn6ebzNz1xRRdxwPT6+zsXLlSi1fvlzXXnttvfHOnTu1du1avfbaa7rrrrvqjf/2t78pOTlZgwcP1sGDB3XllVfq/fff12uvvaaWLVvqjjvu0Pz58/X888+rbdu2buZLL72klStX6tChQ1q8eLF27typlJQUpaamqqioSIWFherbt6+GDRsmn89Xb11111x7XTfeeGPUGiW56+zXr19CaywrK9PGjRt18OBBtWjRQqmpqXIcRwUFBVHrbIo1Dh48WC+99JJ+//vfa926dZo1a5Z+/etfN7jGRH6XPN883zzfPN883yfG893UuKgXAADwlKdfxAYAAHDMriDatm1btWvXTq+//ro73rNnjzIzM2VmateuncxM69evl5nFXVu4cKEGDx7cqDnIJptssskmm+zo/davXy9J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q7i4WLNmzXK3//d//1d/+tOflJmZqaKiIt1zzz1q166dW2vXrp0GDRrU5LW62WamF198Ue3atZMk7d+/X126dNGGDRskHT7iGTmva9WqVXr77bcTrjmOo29/+9vuUY5Y58jKytKKFSv09ttva968eZKkV155RaeeeqpycnJUXV2t9PR0lZSUSDp8tD8vLy+qFtn+5z//qezs7CPud7Q5Yqm9/vrrysvLU15enrvW5s2ba/Xq1aqurtauXbvcn4scAY2sJyMjQ5LUvn177dy5UwlJqEWJ0cUXX2w//OEPGxxffPHFdt1115njOPXGsdYi861YscIcx7FevXrZPffcU2/bzCwvL89SUlLcfx1FthOt9erVy4YOHWpZWVn1akebIycnx1JTU91aVlaWW+vWrZt997vftTZt2piZWYcOHez++++3tm3bRtW6detmjzzyiM2fP9/atm17xP2ONseX1b7//e9bMBi09PR0u+666ywQCFhKSor7lcY+n8/OP/9869Gjh0myLl26WOfOnU2S9ezZ06ZOndrktbrZhYWF5vP5bOLEidamTRvz+/32yCOPJPx6iue1FuscdV+Htcdev9biqdV+Xebk5Lj/mjwWr7VYX+erVq2y+fPnW+vWrW3cuHF2yimnuN+I2aFDB1u5cqUFAgH7xje+Yd26dbPFixc3uhbvHNLh98I7duzo/uu8a9eu1rNnT5MOn3MQDAYtLS3NPQcmcq5MpKb//Ms5cr7AkfY72hxfVnv88cetoKDAkpKS7LrrrnPXGll3ZA2Ro3HZ2dlWUFBgkiwlJcW6du3a5LW62ZHt888/36TD5xK98sorZnb4qHlZWZm99tprUduJ1iJHoGpqahqsHWmO73//+9a8eXMbPny4tW3b1j0/pkuXLu5/Q8Fg0HJyctxzkwoLC6NqycnJ7nk64XD4iPsdbY4vq0V+n927d7eePXua4xz+wrVLL73UUlNTrW3bttaiRQsrKiqycDhshYWF9s9//tO6d+/uHh0xO3yk45RTTvmyP/0N8vTTKG+++aYqKip0wQUX1Bu/+eab2rFjh7KzszVo0KCosc/ni6nm8/lUUVGhAQMGaNmyZVq0aJEWLFigO++8M2p70KBBmjFjhh599FGVlpZq+vTp7nZNTU1CtenTp+vNN99UmzZt9Oijj9arHWmOX/3qV/rrX/+qv/3tb6qpqdGjjz7qjn/1q19p/vz5Sk9P1+OPP65HH33UHZ955plR28XFxVq0aJG2bt1arxbLHF9Wkw5fwOXtt99WeXl5vX+tNcT5z6dEjlUtIhAIqH379ho5cqTuvfde97UW7+spntda3dqR5mjodVj7Nerlay2eWt3XYe1tr19rsb7OH3/8cX3/+993t0eOHKnXXntNlZWVuv/++/WTn/xE27dv16pVq7Ry5UrdfPPN2r59e5PUYt3v+uuvV1lZmV599VXt379fF110kc4880xJ0oIFC7R161b5/X5dfvnlWrdundavX689e/YoNzfXreXm5qqoqMit9ejRo8H9jjbHl9X69Omjl19+WfPmzZPjOFq7dq2qq6sVDAbdoyl+v181NTVyHEcpKSlyHEcHDhxQVVWVZ7Xa2YcOHVIgEFDLli31+eefa86cORo6dKikw1egXrZsmXJycqK2E60lOkd1dbXuu+8+LV68WGeffbamTJmiq6++Wi+99JIcx1HHjh1VU1OjjRs3yu/3KyMjQ2VlZVG1DRs26NChQ+45U4FAoMH9jjZHLDW/3+8e5QqFQqqurlZVVZVycnLcc8zOOussmZmWL1+umpoaderUSd/5znc0adIkSdJrr72m3bt3u+ewxYOLeuFLHTp0yL1qXGZmpnu4u/Z2bm6uJDW4nxe1uvsFg8Gmf+D4yistLdW7776roUOHaufOne52ampqk9cSnSM1NfW//WsCJElffPGFqqqqlJaWdsTtuvs1FZoNAADgrYTefInDZ599Zr///e/tlVdescrKyqjxmjVr7NJLL3Vr69atc8ex1jZu3GiPP/64XXPNNQnPQXbDtYULF1rXrl2tTZs27nuKgUDAPW8iEAiY3++3YDBoqamplpqaasFg0Px+v2c1sskmm2yyj012KBSy5ORka9OmjV199dW2dOnShHsBT5uNf/3rX5aVlWUZGRmWnJxsxcXFlpGRYRkZGRYKhdyPEUVqkQv1xFqLbKemppqkhOYgu+FaYWGhe8LbN7/5TfdjXWeddZZ7glfv3r2tTZs2JslatmxpLVu2NEnWpk0bGzlyZJPXyCabbLLJPjbZHTt2NL/fb7fccos9+OCDNnz4cEtKSrI5c+Yk1A94+jbKeeedp1atWumxxx5TRUWFOnfurO3bt+vtt9/Wbbfdps2bN6ukpERvvvmmRo8erW3btung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kpJi69evjxqvXLnS+vbtaz6fzxYuXGiO47jjuXPnmqSYaz6fz/r27WsrV650a/HOQTbZZJNNNtlkR2efe+65tn79eneOyNjn+wo2G6eeeqq98sor9cZ79+61cDhsbdu2NZ/PFzV2HCfmWvv27a1jx47WvXv3qFo8c5BNNtlkk0022dHZffv2te7du7tz1B4nwtNPowwbNkxPPPFEvXFaWprGjx+v3bt3S1LU2HGcmGsXXHCBOnbsqHA4HFWLZw6yySabbLLJJjs6++9//7vC4bA7R+1xIjy9zkZ5ebk+++wzdenSpd64vLxc69at0/79+zVo0KCocbdu3WKqdevWTZ999plat26t5cuX16s1dn6yySabbLLJPhmzBw0apH379mn58uUaNGiQJNUbx4OLegEAAE95/kVsFRUVevbZZ7Vo0SKVlZWpurpaFRUVkqRwOKxt27bpiy++UDgcVl5enr744ou4aklJSZKkgwcPJjwH2WSTTTbZZJMdvV9qaqoCgYDy8/PVv39/jR49WqmpqUpIU5wIeiSrV6+2Fi1aWFZWlo0YMcJGjRplKSkpFgwGze/3m+M4FgwGraCgwILBoDmOY4FAIOZabm6uSTLp8MVJEpmDbLLJJptsssmOzg4EApaSkmKXX365jRgxwrKysqxly5a2evXqhPoBT99GOeecc1RQUKCnnnpKSUlJUeNhw4Zpw4YNatOmjRYuXKjBgwe7Y8dxYqr5fD7l5eVJkrZt2yYzi3sOsskmm2yyySY7Ovv111/XhAkTtGXLFs2fP18HDx6MGsetUYcuvkRycnJUF1R7nJycbHPmzLHk5OR641hrkflWrVrVYK2x85NNNtlkk032yZhtZu79EXXH8fD0nI3s7GyVlJSoc+fO9cbZ2dlavHixsrOz3Vrtcay1kpISmVmDtaaYn2yyySabbLJPxux169a52w2N45JQixKju+66yzIzM23mzJm2YsUK++53v2sZGRl2yy23WJ8+fUySDRo0yGbPnm0DBw40Sda3b9+Ya5dffrmFQiELh8M2atSohOYgm2yyySabbLKjs2+55RbLzMy02267zVasWGEzZ8607Oxs+9GPfpRQP+Bps2Fmdt9991lhYaE5zuFLozqOY5K4cePGjRs3bl/hW+2/24WFhXb//fcn3Ascs+tsbNiwQWVlZZKkgoICSXLHhw4dUjAY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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plot_charging_kw(usage_plan)" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plot_predicated_moer(usage_plan)" + ] + }, + { + "cell_type": "code", + "execution_count": 117, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/var/folders/kf/cqbq1hkx74v5lt73jq2w60x40000gn/T/ipykernel_51362/1120087637.py:29: UserWarning: set_ticklabels() should only be used with a fixed number of ticks, i.e. after set_ticks() or using a FixedLocator.\n", + " ax.set_xticklabels(ticks)\n" + ] + }, + { + "data": { + "image/png": "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90R98yA9zyI5Th/0/dN28hBEE6mpqXjyySdx5MgR5OTk4L777kPv3r3x3nvvwe2mvyuiZUOCgGixlJSU4JlnnkHHjh2xdOlSvPHGG9i9ezemTZvWorLl0yzB8whKKWQQlcTFxeHee+/FwYMH8eijj2LevHno1q0bXn31VdhsNq3NI4gGQYKAaHGUl5djzpw56NixIzZu3IhPP/0UW7duxRVXXAGWbXn/pNPqyCMod3ggihSrjlb0ej1uuukm/P333/j3v/+NDz74AJ06dcJ//vMfOJ1Orc0jiLBoeU9PotXidDrxr3/9C126dMGmTZvw/fff4/vvv8eoUaNadK14XR4CQZRQHqAkkYguOI7DNddcg+3bt+P999/H+++/j549e2LRokUQBCodJVoGJAiIqEcQBLz33nvo0aMHPvzwQyxZsgQ//vgjBg8erLVpESHNYqwzsZDyCFoODMNg/Pjx2LlzJ+bOnYvnnnsO5557LlatWkVVCUTUQ4KAiFokScIXX3yBc845B//4xz8wb9487Ny5E5dcckmL9gjURM+zSDIHTyykPIKWB8uymDJlCvbu3Yvbb78dt956K4YNG4aNGzdqbRpBBIUEARGVbN26FRdccAFuu+023HHHHdizZw8mT57cInMEQqHOxEIqPWyx6PV63HPPPTh48CDGjBmDSy+9FBMnTsS+ffu0No0gahGbT1eixVJcXIwZM2Zg5MiRGDNmDA4ePIi7774ben3wHXQsUFdiIXkIWj4WiwVPP/00Dh48iA4dOuC8887D448/ThUJRFRBgoCICgRBwFtvvYUePXrgxIkT2L17N5599tmo7yMQKeryEFAOQeyQlpaGN954A5s2bcL69evRq1cvLF++nPILiKiABAGhOVu3bsXAgQPxz3/+Ex988AFWrVqFLl26aG1Ws5KWELxjIQ05ij369u2LjRs34tlnn8Udd9yBSy65hMIIhOaQICA049SpU7j11lsxcuRIXH755fjrr78wYcIErc3SBAPPoY0peDMlmmkQe7Asi5tuugm5ubno3r07zj33XAojEJpCgoBodiRJwuLFi9GzZ0+cOnUKf/31F5566ikYjXUP+ol16hp0RIIgdklKSsL8+fOxZcsW/PTTT+jduze+++47rc0iWiEkCIhmpaioCFdccQUefPBBvPvuu/jiiy/QuXNnrc2KCuoahVxK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zJnUybAFQoKgBfL000+joqICr7zyitamACBR0JykWWqHDaj0sHUSbWIAqJp58P333+O9997T2hwiTEgQtDDWrl2L119/HZ988klEOhFGChIFzUMgDwFVGrQ+olEMKGRkZGDJkiWYNWsW/vrrL63NIcKABEELorCwEDk5OXjttdfQu3dvrc2pBYmCpidQYiGFDFoX0SwGFEaPHo17770XkyZNgt1u19ocIkRIELQQBEHA1KlTMXbsWEyfPl1rc4JCoqBpiTfwiDNULy8tpdLDVkNLEAMKzz77LNq0aYN7771Xa1OIECFB0EL4z3/+g2PHjuHNN99s0Djj5oREQdNS00tAQ45aBy1JDAAAz/P4+OOPsXz5cqxYsUJrc4gQIEHQAtizZw+efvppLFq0CBaLRWtzQoJEQdMRqIUxDTmKbVqaGFBo3749Xn/9dcycORPFxcVam0PUAwmCKEcQBNx000244447MHToUK3NCQsSBU1DwDwCSiyMWVqqGFCYPHkyhgwZgnvuuUdrU4h6IEEQ5fzrX/9CWVkZnn/+ea1NaRAkCiJPoBbGlFgYm7R0MQDIz4C33noLa9euxeeff661OUQdkCCIYvbs2YNnn30W7733Hkwmk9bmNBgSBZHFYtTVSiyk0sPYIxbEgEJGRgZef/113HHHHRQ6iGJIEEQpXq8X06dPx5133okhQ4ZobU6jIVEQWWo2KKIhR7FFLIkBhRtuuAFDhw7F3XffrbUpRBBIEEQp//rXv1BeXo7nnntOa1MiBomCyFGzQRENOYodYlEMAPLv/3//+1989913WL58udbmEAEgQRCF7N27F8899xwWLVrUokMFgSBREBlqeghoyFFsEKtiQCEjIwPz58/HHXfcgZKSEq3NIWpAgiDKkCQJd911F2699VYMHjxYa3OaBBIFjSedWhjHHLEuBhSuv/56DBw4EI8//rjWphA1IEEQZSxbtgx//fVXTIUKAkGioHFYjDqY9dUTC8uo0qDF0lrEACD/7r/66qv48MMP8euvv2ptDuEHCYIowmq14oEHHsA///lPJCYmam1Ok0OioHHUzCMgD0HLpDWJAYWuXbvi4Ycfxl133QVRFLU2h/BBgiCKeP7559G5c2dMnTpVa1OaDRIFDSe9Rh4B9SJoebRGMaDw2GOP4dSpU1iwYIHWphA+SBBECXv27MFrr72GN954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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "# Fixing random state for reproducibility\n", + "#np.random.seed(19680801)\n", + "\n", + "# Compute pie slices\n", + "N = len(usage_plan.index.values)\n", + "theta, width = np.linspace(0.0, 2 * np.pi, N, endpoint=False, retstep=True)\n", + "radii_1 = usage_plan.emissions_co2_lb.values\n", + "\n", + "l = int(240/5)\n", + "radii_2 = (usage_plan.pred_moer*0.001)[:l]\n", + "\n", + "offset = np.pi / 8\n", + "theta2 = theta + offset\n", + "\n", + "ax = plt.subplot(projection='polar')\n", + "ax.bar(theta, radii_1, width=width, bottom=0, alpha=0.5)\n", + "#ax.bar(theta2, radii_2, width=0.4, alpha=0.5)\n", + "\n", + "ax.set_theta_zero_location(\"N\")\n", + "ax.set_theta_direction(-1)\n", + "ax.grid(True)\n", + "ax.spines['polar'].set_visible(True)\n", + "ax.set_rticks([])\n", + "\n", + "ticks = usage_plan.index.strftime('%r').values[3-1::3]\n", + "ax.set_xticklabels(ticks)\n", + "\n", + "plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "watttime", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.21" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/watttime_optimizer/examples/synthetic_data.ipynb b/watttime_optimizer/examples/synthetic_data.ipynb index cfd3d578..0cc189c4 100644 --- a/watttime_optimizer/examples/synthetic_data.ipynb +++ b/watttime_optimizer/examples/synthetic_data.ipynb @@ -1,8 +1,8 @@ -{wattwatttime_optimizer.izer. +{ "cells": [ - {watttime_optimizer. - "cwatttime_optimizer. "markdown", - "mwatttime_optimizer.{}, + { + "cell_type": "markdown", + "metadata": {}, "source": [ "# Synthetic Data for Testing" ] @@ -23,7 +23,7 @@ "metadata": {}, "outputs": [], "source": [ - "from optimizer.evaluator.sessions import SessionsGenerator" + "from watttime_optimizer.evaluator.sessions import SessionsGenerator" ] }, { @@ -119,55 +119,55 @@ " \n", " \n", " distinct_dates\n", - " 2025-01-10\n", + " 2025-01-07\n", " \n", " \n", " user_type\n", - " r27.37_tc28_avglc25631_sdlc6925\n", + " r26.945_tc71_avglc28763_sdlc7299\n", " \n", " \n", " usage_window_start\n", - " 2025-01-10 14:20:00\n", + " 2025-01-07 20:30:00\n", " \n", " \n", " usage_window_end\n", - " 2025-01-10 19:05:00\n", + " 2025-01-08 09:15:00\n", " \n", " \n", " initial_charge\n", - " 0.675072\n", + " 0.340737\n", " \n", " \n", " time_needed\n", - " 16.875384\n", + " 96.324327\n", " \n", " \n", " expected_baseline_charge_complete_timestamp\n", - " 2025-01-10 14:36:52.523068458\n", + " 2025-01-07 22:06:19.459590312\n", " \n", " \n", " window_length_in_minutes\n", - " 285.0\n", + " 765.0\n", " \n", " \n", " final_charge_time\n", - " 2025-01-10 14:36:52.523068458\n", + " 2025-01-07 22:06:19.459590312\n", " \n", " \n", " total_capacity\n", - " 28\n", + " 71\n", " \n", " \n", " usage_power_kw\n", - " 27.37\n", + " 26.945\n", " \n", " \n", " total_intervals_plugged_in\n", - " 57.0\n", + " 153.0\n", " \n", " \n", " MWh_fraction\n", - " 0.002281\n", + " 0.002245\n", " \n", " \n", " early_session_stop\n", @@ -178,21 +178,21 @@ "" ], "text/plain": [ - " 0\n", - "distinct_dates 2025-01-10\n", - "user_type r27.37_tc28_avglc25631_sdlc6925\n", - "usage_window_start 2025-01-10 14:20:00\n", - "usage_window_end 2025-01-10 19:05:00\n", - "initial_charge 0.675072\n", - "time_needed 16.875384\n", - "expected_baseline_charge_complete_timestamp 2025-01-10 14:36:52.523068458\n", - "window_length_in_minutes 285.0\n", - "final_charge_time 2025-01-10 14:36:52.523068458\n", - "total_capacity 28\n", - "usage_power_kw 27.37\n", - "total_intervals_plugged_in 57.0\n", - "MWh_fraction 0.002281\n", - "early_session_stop False" + " 0\n", + "distinct_dates 2025-01-07\n", + "user_type r26.945_tc71_avglc28763_sdlc7299\n", + "usage_window_start 2025-01-07 20:30:00\n", + "usage_window_end 2025-01-08 09:15:00\n", + "initial_charge 0.340737\n", + "time_needed 96.324327\n", + "expected_baseline_charge_complete_timestamp 2025-01-07 22:06:19.459590312\n", + "window_length_in_minutes 765.0\n", + "final_charge_time 2025-01-07 22:06:19.459590312\n", + "total_capacity 71\n", + "usage_power_kw 26.945\n", + "total_intervals_plugged_in 153.0\n", + "MWh_fraction 0.002245\n", + "early_session_stop False" ] }, "execution_count": 6, @@ -220,7 +220,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "100%|██████████| 10/10 [00:00<00:00, 318.49it/s]\n" + "100%|██████████| 10/10 [00:00<00:00, 345.29it/s]\n" ] }, { @@ -265,181 +265,181 @@ " \n", " 0\n", " 0\n", - " 2025-01-10\n", - " r29.1125_tc103_avglc20348_sdlc7622\n", - " 2025-01-10 09:15:00\n", - " 2025-01-10 18:40:00\n", - " 0.539355\n", - " 87.171649\n", - " 2025-01-10 10:42:10.298935944\n", - " 565.0\n", - " 2025-01-10 10:42:10.298935944\n", - " 103\n", - " 29.1125\n", - " 113.0\n", - " 0.002426\n", + " 2025-01-07\n", + " r31.2375_tc104_avglc29369_sdlc7128\n", + " 2025-01-07 13:35:00\n", + " 2025-01-07 20:10:00\n", + " 0.476759\n", + " 94.534494\n", + " 2025-01-07 15:09:32.069628156\n", + " 395.0\n", + " 2025-01-07 15:09:32.069628156\n", + " 104\n", + " 31.2375\n", + " 79.0\n", + " 0.002603\n", " False\n", " \n", " \n", " 1\n", " 0\n", - " 2025-01-10\n", - " r21.42_tc91_avglc20436_sdlc6891\n", - " 2025-01-10 19:00:00\n", - " 2025-01-11 02:30:00\n", - " 0.648065\n", - " 76.963806\n", - " 2025-01-10 20:16:57.828383058\n", - " 450.0\n", - " 2025-01-10 20:16:57.828383058\n", - " 91\n", - " 21.4200\n", - " 90.0\n", - " 0.001785\n", + " 2025-01-07\n", + " r25.415_tc27_avglc22738_sdlc7758\n", + " 2025-01-07 09:25:00\n", + " 2025-01-07 12:50:00\n", + " 0.262952\n", + " 43.793704\n", + " 2025-01-07 10:08:47.622239934\n", + " 205.0\n", + " 2025-01-07 10:08:47.622239934\n", + " 27\n", + " 25.4150\n", + " 41.0\n", + " 0.002118\n", " False\n", " \n", " \n", " 2\n", " 0\n", - " 2025-01-10\n", - " r24.7775_tc112_avglc23284_sdlc6904\n", - " 2025-01-10 17:25:00\n", - " 2025-01-10 23:30:00\n", - " 0.672858\n", - " 75.164754\n", - " 2025-01-10 18:40:09.885255132\n", - " 365.0\n", - " 2025-01-10 18:40:09.885255132\n", - " 112\n", - " 24.7775\n", - " 73.0\n", - " 0.002065\n", + " 2025-01-07\n", + " r31.195_tc69_avglc28996_sdlc7437\n", + " 2025-01-07 19:25:00\n", + " 2025-01-08 02:15:00\n", + " 0.268479\n", + " 90.447105\n", + " 2025-01-07 20:55:26.826279198\n", + " 410.0\n", + " 2025-01-07 20:55:26.826279198\n", + " 69\n", + " 31.1950\n", + " 82.0\n", + " 0.002600\n", " False\n", " \n", " \n", " 3\n", " 0\n", - " 2025-01-10\n", - " r29.070000000000004_tc115_avglc23635_sdlc7379\n", - " 2025-01-10 19:35:00\n", - " 2025-01-11 01:55:00\n", - " 0.303492\n", - " 153.453952\n", - " 2025-01-10 22:08:27.237103968\n", - " 380.0\n", - " 2025-01-10 22:08:27.237103968\n", - " 115\n", - " 29.0700\n", - " 76.0\n", - " 0.002423\n", + " 2025-01-07\n", + " r28.687500000000004_tc29_avglc20434_sdlc6948\n", + " 2025-01-07 13:55:00\n", + " 2025-01-07 16:55:00\n", + " 0.402831\n", + " 33.187754\n", + " 2025-01-07 14:28:11.265212730\n", + " 180.0\n", + " 2025-01-07 14:28:11.265212730\n", + " 29\n", + " 28.6875\n", + " 36.0\n", + " 0.002391\n", " False\n", " \n", " \n", " 4\n", " 0\n", - " 2025-01-10\n", - " r21.93_tc22_avglc21043_sdlc6980\n", - " 2025-01-10 18:05:00\n", - " 2025-01-10 21:15:00\n", - " 0.411656\n", - " 32.403768\n", - " 2025-01-10 18:37:24.226103616\n", - " 190.0\n", - " 2025-01-10 18:37:24.226103616\n", - " 22\n", - " 21.9300\n", - " 38.0\n", - " 0.001827\n", + " 2025-01-07\n", + " r34.7225_tc102_avglc26861_sdlc7446\n", + " 2025-01-07 19:40:00\n", + " 2025-01-08 05:05:00\n", + " 0.779481\n", + " 30.054798\n", + " 2025-01-07 20:10:03.287860500\n", + " 565.0\n", + " 2025-01-07 20:10:03.287860500\n", + " 102\n", + " 34.7225\n", + " 113.0\n", + " 0.002894\n", " False\n", " \n", " \n", " 5\n", " 0\n", - " 2025-01-10\n", - " r28.687500000000004_tc21_avglc22986_sdlc6943\n", - " 2025-01-10 19:00:00\n", - " 2025-01-11 00:35:00\n", - " 0.298845\n", - " 28.599761\n", - " 2025-01-10 19:28:35.985664014\n", - " 335.0\n", - " 2025-01-10 19:28:35.985664014\n", - " 21\n", - " 28.6875\n", - " 67.0\n", - " 0.002391\n", + " 2025-01-07\n", + " r25.84_tc84_avglc26756_sdlc7776\n", + " 2025-01-07 16:20:00\n", + " 2025-01-08 02:30:00\n", + " 0.401695\n", + " 106.944858\n", + " 2025-01-07 18:06:56.691487974\n", + " 610.0\n", + " 2025-01-07 18:06:56.691487974\n", + " 84\n", + " 25.8400\n", + " 122.0\n", + " 0.002153\n", " False\n", " \n", " \n", " 6\n", " 0\n", - " 2025-01-10\n", - " r32.215_tc109_avglc22829_sdlc7281\n", - " 2025-01-10 21:40:00\n", - " 2025-01-11 04:00:00\n", - " 0.346887\n", - " 122.438567\n", - " 2025-01-10 23:42:26.314030590\n", - " 380.0\n", - " 2025-01-10 23:42:26.314030590\n", - " 109\n", - " 32.2150\n", - " 76.0\n", - " 0.002685\n", + " 2025-01-07\n", + " r32.1725_tc61_avglc25508_sdlc7508\n", + " 2025-01-07 16:05:00\n", + " 2025-01-08 01:25:00\n", + " 0.461958\n", + " 55.520505\n", + " 2025-01-07 17:00:31.230272429\n", + " 560.0\n", + " 2025-01-07 17:00:31.230272429\n", + " 61\n", + " 32.1725\n", + " 112.0\n", + " 0.002681\n", " False\n", " \n", " \n", " 7\n", " 0\n", - " 2025-01-10\n", - " r21.59_tc110_avglc26220_sdlc7421\n", - " 2025-01-10 14:40:00\n", - " 2025-01-10 21:30:00\n", - " 0.725945\n", - " 68.492855\n", - " 2025-01-10 15:48:29.571296238\n", - " 410.0\n", - " 2025-01-10 15:48:29.571296238\n", - " 110\n", - " 21.5900\n", - " 82.0\n", - " 0.001799\n", + " 2025-01-07\n", + " r33.1925_tc116_avglc28830_sdlc7740\n", + " 2025-01-07 16:45:00\n", + " 2025-01-07 22:40:00\n", + " 0.510567\n", + " 92.142966\n", + " 2025-01-07 18:17:08.577960305\n", + " 355.0\n", + " 2025-01-07 18:17:08.577960305\n", + " 116\n", + " 33.1925\n", + " 71.0\n", + " 0.002766\n", " False\n", " \n", " \n", " 8\n", " 0\n", - " 2025-01-10\n", - " r33.9575_tc91_avglc23444_sdlc7908\n", - " 2025-01-10 17:55:00\n", - " 2025-01-11 01:00:00\n", - " 0.777028\n", - " 27.812101\n", - " 2025-01-10 18:22:48.726031014\n", - " 425.0\n", - " 2025-01-10 18:22:48.726031014\n", - " 91\n", - " 33.9575\n", - " 85.0\n", - " 0.002830\n", + " 2025-01-07\n", + " r29.2825_tc29_avglc23359_sdlc6996\n", + " 2025-01-07 18:00:00\n", + " 2025-01-07 22:35:00\n", + " 0.314793\n", + " 37.744748\n", + " 2025-01-07 18:37:44.684857398\n", + " 275.0\n", + " 2025-01-07 18:37:44.684857398\n", + " 29\n", + " 29.2825\n", + " 55.0\n", + " 0.002440\n", " False\n", " \n", " \n", " 9\n", " 0\n", - " 2025-01-10\n", - " r35.7425_tc68_avglc20339_sdlc7710\n", - " 2025-01-10 14:20:00\n", - " 2025-01-10 21:45:00\n", - " 0.736866\n", - " 24.329243\n", - " 2025-01-10 14:44:19.754600988\n", - " 445.0\n", - " 2025-01-10 14:44:19.754600988\n", - " 68\n", - " 35.7425\n", - " 89.0\n", - " 0.002979\n", + " 2025-01-07\n", + " r36.082499999999996_tc121_avglc27590_sdlc6912\n", + " 2025-01-07 11:20:00\n", + " 2025-01-07 18:20:00\n", + " 0.703789\n", + " 49.538951\n", + " 2025-01-07 12:09:32.337083280\n", + " 420.0\n", + " 2025-01-07 12:09:32.337083280\n", + " 121\n", + " 36.0825\n", + " 84.0\n", + " 0.003007\n", " False\n", " \n", " \n", @@ -448,64 +448,64 @@ ], "text/plain": [ " index distinct_dates user_type \\\n", - "0 0 2025-01-10 r29.1125_tc103_avglc20348_sdlc7622 \n", - "1 0 2025-01-10 r21.42_tc91_avglc20436_sdlc6891 \n", - "2 0 2025-01-10 r24.7775_tc112_avglc23284_sdlc6904 \n", - "3 0 2025-01-10 r29.070000000000004_tc115_avglc23635_sdlc7379 \n", - "4 0 2025-01-10 r21.93_tc22_avglc21043_sdlc6980 \n", - "5 0 2025-01-10 r28.687500000000004_tc21_avglc22986_sdlc6943 \n", - "6 0 2025-01-10 r32.215_tc109_avglc22829_sdlc7281 \n", - "7 0 2025-01-10 r21.59_tc110_avglc26220_sdlc7421 \n", - "8 0 2025-01-10 r33.9575_tc91_avglc23444_sdlc7908 \n", - "9 0 2025-01-10 r35.7425_tc68_avglc20339_sdlc7710 \n", + "0 0 2025-01-07 r31.2375_tc104_avglc29369_sdlc7128 \n", + "1 0 2025-01-07 r25.415_tc27_avglc22738_sdlc7758 \n", + "2 0 2025-01-07 r31.195_tc69_avglc28996_sdlc7437 \n", + "3 0 2025-01-07 r28.687500000000004_tc29_avglc20434_sdlc6948 \n", + "4 0 2025-01-07 r34.7225_tc102_avglc26861_sdlc7446 \n", + "5 0 2025-01-07 r25.84_tc84_avglc26756_sdlc7776 \n", + "6 0 2025-01-07 r32.1725_tc61_avglc25508_sdlc7508 \n", + "7 0 2025-01-07 r33.1925_tc116_avglc28830_sdlc7740 \n", + "8 0 2025-01-07 r29.2825_tc29_avglc23359_sdlc6996 \n", + "9 0 2025-01-07 r36.082499999999996_tc121_avglc27590_sdlc6912 \n", "\n", " usage_window_start usage_window_end initial_charge time_needed \\\n", - "0 2025-01-10 09:15:00 2025-01-10 18:40:00 0.539355 87.171649 \n", - "1 2025-01-10 19:00:00 2025-01-11 02:30:00 0.648065 76.963806 \n", - "2 2025-01-10 17:25:00 2025-01-10 23:30:00 0.672858 75.164754 \n", - "3 2025-01-10 19:35:00 2025-01-11 01:55:00 0.303492 153.453952 \n", - "4 2025-01-10 18:05:00 2025-01-10 21:15:00 0.411656 32.403768 \n", - "5 2025-01-10 19:00:00 2025-01-11 00:35:00 0.298845 28.599761 \n", - "6 2025-01-10 21:40:00 2025-01-11 04:00:00 0.346887 122.438567 \n", - "7 2025-01-10 14:40:00 2025-01-10 21:30:00 0.725945 68.492855 \n", - "8 2025-01-10 17:55:00 2025-01-11 01:00:00 0.777028 27.812101 \n", - "9 2025-01-10 14:20:00 2025-01-10 21:45:00 0.736866 24.329243 \n", + "0 2025-01-07 13:35:00 2025-01-07 20:10:00 0.476759 94.534494 \n", + "1 2025-01-07 09:25:00 2025-01-07 12:50:00 0.262952 43.793704 \n", + "2 2025-01-07 19:25:00 2025-01-08 02:15:00 0.268479 90.447105 \n", + "3 2025-01-07 13:55:00 2025-01-07 16:55:00 0.402831 33.187754 \n", + "4 2025-01-07 19:40:00 2025-01-08 05:05:00 0.779481 30.054798 \n", + "5 2025-01-07 16:20:00 2025-01-08 02:30:00 0.401695 106.944858 \n", + "6 2025-01-07 16:05:00 2025-01-08 01:25:00 0.461958 55.520505 \n", + "7 2025-01-07 16:45:00 2025-01-07 22:40:00 0.510567 92.142966 \n", + "8 2025-01-07 18:00:00 2025-01-07 22:35:00 0.314793 37.744748 \n", + "9 2025-01-07 11:20:00 2025-01-07 18:20:00 0.703789 49.538951 \n", "\n", " expected_baseline_charge_complete_timestamp window_length_in_minutes \\\n", - "0 2025-01-10 10:42:10.298935944 565.0 \n", - "1 2025-01-10 20:16:57.828383058 450.0 \n", - "2 2025-01-10 18:40:09.885255132 365.0 \n", - "3 2025-01-10 22:08:27.237103968 380.0 \n", - "4 2025-01-10 18:37:24.226103616 190.0 \n", - "5 2025-01-10 19:28:35.985664014 335.0 \n", - "6 2025-01-10 23:42:26.314030590 380.0 \n", - "7 2025-01-10 15:48:29.571296238 410.0 \n", - "8 2025-01-10 18:22:48.726031014 425.0 \n", - "9 2025-01-10 14:44:19.754600988 445.0 \n", + "0 2025-01-07 15:09:32.069628156 395.0 \n", + "1 2025-01-07 10:08:47.622239934 205.0 \n", + "2 2025-01-07 20:55:26.826279198 410.0 \n", + "3 2025-01-07 14:28:11.265212730 180.0 \n", + "4 2025-01-07 20:10:03.287860500 565.0 \n", + "5 2025-01-07 18:06:56.691487974 610.0 \n", + "6 2025-01-07 17:00:31.230272429 560.0 \n", + "7 2025-01-07 18:17:08.577960305 355.0 \n", + "8 2025-01-07 18:37:44.684857398 275.0 \n", + "9 2025-01-07 12:09:32.337083280 420.0 \n", "\n", " final_charge_time total_capacity usage_power_kw \\\n", - "0 2025-01-10 10:42:10.298935944 103 29.1125 \n", - "1 2025-01-10 20:16:57.828383058 91 21.4200 \n", - "2 2025-01-10 18:40:09.885255132 112 24.7775 \n", - "3 2025-01-10 22:08:27.237103968 115 29.0700 \n", - "4 2025-01-10 18:37:24.226103616 22 21.9300 \n", - "5 2025-01-10 19:28:35.985664014 21 28.6875 \n", - "6 2025-01-10 23:42:26.314030590 109 32.2150 \n", - "7 2025-01-10 15:48:29.571296238 110 21.5900 \n", - "8 2025-01-10 18:22:48.726031014 91 33.9575 \n", - "9 2025-01-10 14:44:19.754600988 68 35.7425 \n", + "0 2025-01-07 15:09:32.069628156 104 31.2375 \n", + "1 2025-01-07 10:08:47.622239934 27 25.4150 \n", + "2 2025-01-07 20:55:26.826279198 69 31.1950 \n", + "3 2025-01-07 14:28:11.265212730 29 28.6875 \n", + "4 2025-01-07 20:10:03.287860500 102 34.7225 \n", + "5 2025-01-07 18:06:56.691487974 84 25.8400 \n", + "6 2025-01-07 17:00:31.230272429 61 32.1725 \n", + "7 2025-01-07 18:17:08.577960305 116 33.1925 \n", + "8 2025-01-07 18:37:44.684857398 29 29.2825 \n", + "9 2025-01-07 12:09:32.337083280 121 36.0825 \n", "\n", " total_intervals_plugged_in MWh_fraction early_session_stop \n", - "0 113.0 0.002426 False \n", - "1 90.0 0.001785 False \n", - "2 73.0 0.002065 False \n", - "3 76.0 0.002423 False \n", - "4 38.0 0.001827 False \n", - "5 67.0 0.002391 False \n", - "6 76.0 0.002685 False \n", - "7 82.0 0.001799 False \n", - "8 85.0 0.002830 False \n", - "9 89.0 0.002979 False " + "0 79.0 0.002603 False \n", + "1 41.0 0.002118 False \n", + "2 82.0 0.002600 False \n", + "3 36.0 0.002391 False \n", + "4 113.0 0.002894 False \n", + "5 122.0 0.002153 False \n", + "6 112.0 0.002681 False \n", + "7 71.0 0.002766 False \n", + "8 55.0 0.002440 False \n", + "9 84.0 0.003007 False " ] }, "execution_count": 7, @@ -559,7 +559,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "100%|██████████| 10/10 [00:00<00:00, 334.45it/s]\n" + "100%|██████████| 10/10 [00:00<00:00, 259.96it/s]\n" ] } ], @@ -576,15 +576,15 @@ }, { "cell_type": "code", - "execution_count": 34, + "execution_count": 11, "metadata": {}, "outputs": [], "source": [ "import os\n", "import pandas as pd\n", - "from optimizer.evaluator.evaluator import OptChargeEvaluator\n", - "from optimizer.evaluator.evaluator import ImpactEvaluator\n", - "from optimizer.evaluator.evaluator import RecalculationOptChargeEvaluator" + "from watttime_optimizer.evaluator.evaluator import OptChargeEvaluator\n", + "from watttime_optimizer.evaluator.evaluator import ImpactEvaluator\n", + "from watttime_optimizer.evaluator.evaluator import RecalculationOptChargeEvaluator" ] }, { @@ -606,7 +606,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 20, "metadata": {}, "outputs": [], "source": [ @@ -623,22 +623,22 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 21, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "{'usage_window_start': Timestamp('2025-01-10 15:25:00'),\n", - " 'usage_window_end': Timestamp('2025-01-10 22:25:00'),\n", - " 'time_needed': 15.131315076819533,\n", - " 'usage_power_kw': 35.275,\n", + "{'usage_window_start': Timestamp('2025-01-07 12:30:00'),\n", + " 'usage_window_end': Timestamp('2025-01-07 22:45:00'),\n", + " 'time_needed': 87.57432605789094,\n", + " 'usage_power_kw': 24.862499999999997,\n", " 'region': 'CAISO_NORTH',\n", " 'tz_convert': True,\n", " 'verbose': False}" ] }, - "execution_count": 14, + "execution_count": 21, "metadata": {}, "output_type": "execute_result" } @@ -649,7 +649,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 22, "metadata": {}, "outputs": [], "source": [ @@ -659,18 +659,18 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 23, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "{'baseline': 5.38833458467993,\n", - " 'forecast': 2.2544024516577843,\n", - " 'actual': 5.38833458467993}" + "{'baseline': 24.462852599962222,\n", + " 'forecast': 19.10658972072091,\n", + " 'actual': 25.489088724775343}" ] }, - "execution_count": 16, + "execution_count": 23, "metadata": {}, "output_type": "execute_result" } @@ -688,7 +688,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 24, "metadata": {}, "outputs": [], "source": [ @@ -697,66 +697,45 @@ }, { "cell_type": "code", - "execution_count": 18, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'usage_window_start': Timestamp('2025-01-10 15:25:00'),\n", - " 'usage_window_end': Timestamp('2025-01-10 22:25:00'),\n", - " 'time_needed': 15.131315076819533,\n", - " 'usage_power_kw': 35.275,\n", - " 'region': 'CAISO_NORTH',\n", - " 'tz_convert': True,\n", - " 'verbose': False,\n", - " 'optimization_method': 'simple',\n", - " 'interval': 15,\n", - " 'charge_per_segment': None}" - ] - }, - "execution_count": 18, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "value" - ] - }, - { - "cell_type": "code", - "execution_count": null, + "execution_count": 37, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "tz converting...\n" + "tz converting...\n", + "tz converting...\n", + "tz converting...\n", + "tz converting...\n", + "tz converting...\n", + "tz converting...\n", + "tz converting...\n", + "tz converting...\n", + "7.92 s ± 2.39 s per loop (mean ± std. dev. of 7 runs, 1 loop each)\n" ] } ], "source": [ "%%timeit\n", - "df_requery = roce.fit_recalculator(**value)\n", + "df_requery = roce.fit_recalculator(**value).get_combined_schedule()\n", "r_requery = ImpactEvaluator(username,password,df_requery).get_all_emissions_values(region=region)" ] }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 38, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "{'baseline': 5.38833458467993,\n", - " 'forecast': 2.2544024516577843,\n", - " 'actual': 5.38833458467993}" + "{'baseline': 24.462852599962222,\n", + " 'forecast': 19.10658972072091,\n", + " 'actual': 25.489088724775343}" ] }, - "execution_count": 25, + "execution_count": 38, "metadata": {}, "output_type": "execute_result" } @@ -779,23 +758,23 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 39, "metadata": {}, "outputs": [], "source": [ - "from optimizer.evaluator.analysis import analysis_loop, analysis_loop_requery, analysis_loop_requery_contiguous" + "from watttime_optimizer.evaluator.analysis import analysis_loop" ] }, { "cell_type": "code", - "execution_count": 32, + "execution_count": 40, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - "100%|██████████| 10/10 [00:00<00:00, 285.20it/s]\n" + "100%|██████████| 10/10 [00:00<00:00, 262.90it/s]\n" ] } ], @@ -811,14 +790,14 @@ }, { "cell_type": "code", - "execution_count": 35, + "execution_count": 41, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - " 0%| | 0/10 [00:00\n", " \n", " \n", - " 190\n", - " 0.512044\n", - " 0.637870\n", - " 0.512044\n", - " 14.728153\n", - " -33.707037\n", - " 362.355836\n", - " \n", - " \n", - " 143\n", - " 30.514304\n", - " 29.142834\n", - " 30.516092\n", - " 2.399780\n", - " 944.165714\n", - " 13.217914\n", - " \n", - " \n", - " 220\n", - " 11.864413\n", - " 10.847406\n", - " 10.941964\n", - " -0.877926\n", - " 987.697915\n", - " 24.444685\n", - " \n", - " \n", - " 223\n", - " 2.950431\n", - " 5.655465\n", - " 2.748327\n", - " 2.144935\n", - " 243.187723\n", - " 181.510740\n", - " \n", - " \n", - " 50\n", - " 4.086414\n", - " 3.712371\n", - " 3.758234\n", - " -0.934282\n", - " 1010.356890\n", - " 27.061581\n", - " \n", - " \n", - " 333\n", - " 12.415303\n", - " 10.879924\n", - " 11.971757\n", - " -1.006296\n", - " 177.447957\n", - " 22.169530\n", - " \n", - " \n", - " 176\n", - " 15.745897\n", - " 14.960575\n", - " 15.111659\n", - " -0.367786\n", - " 988.718774\n", - " 13.428916\n", - " \n", - " \n", - " 28\n", - " 11.896668\n", - " 11.137360\n", - " 11.772867\n", - " -0.855962\n", - " 978.721012\n", - " 23.713533\n", - " \n", - " \n", - " 44\n", - " 3.901864\n", - " 3.654003\n", - " 3.919042\n", - " 0.341336\n", - " 965.911572\n", - " 15.653204\n", - " \n", - " \n", - " 96\n", - " 52.753606\n", - " 29.878506\n", - " 52.753606\n", - " 6.135591\n", - " 561.793558\n", - " 233.229645\n", + " 74\n", + " 18.785587\n", + " 17.989601\n", + " 18.131132\n", + " -0.153361\n", + " 958.348989\n", + " 4.492026\n", + " \n", + " \n", + " 87\n", + " 7.239583\n", + " 9.885909\n", + " 7.149756\n", + " 7.037200\n", + " -64.524592\n", + " 306.813320\n", + " \n", + " \n", + " 164\n", + " 3.531891\n", + " 4.478827\n", + " 27.828149\n", + " -2.277382\n", + " 350.811568\n", + " 110.248641\n", + " \n", + " \n", + " 36\n", + " 51.959712\n", + " 49.601364\n", + " 51.277886\n", + " -0.850466\n", + " 987.026339\n", + " 16.902915\n", + " \n", + " \n", + " 98\n", + " 18.336274\n", + " 11.711657\n", + " 17.373445\n", + " 6.201609\n", + " 22.024319\n", + " 214.884382\n", + " \n", + " \n", + " 249\n", + " 67.523255\n", + " 64.249195\n", + " 66.979611\n", + " -0.395036\n", + " 978.947001\n", + " 15.076282\n", + " \n", + " \n", + " 81\n", + " 55.829117\n", + " 53.346816\n", + " 53.685575\n", + " -0.475130\n", + " 961.037489\n", + " 10.587760\n", + " \n", + " \n", + " 10\n", + " 32.056373\n", + " 30.638461\n", + " 30.998585\n", + " -0.485953\n", + " 964.179230\n", + " 11.088391\n", + " \n", + " \n", + " 27\n", + " 6.885002\n", + " 8.666217\n", + " 7.030633\n", + " 11.099877\n", + " -47.994144\n", + " 399.276077\n", + " \n", + " \n", + " 241\n", + " 13.175851\n", + " 12.180933\n", + " 13.041130\n", + " -0.024084\n", + " 947.862326\n", + " 9.670335\n", " \n", " \n", "\n", "" ], "text/plain": [ - " baseline forecast actual m b stddev\n", - "190 0.512044 0.637870 0.512044 14.728153 -33.707037 362.355836\n", - "143 30.514304 29.142834 30.516092 2.399780 944.165714 13.217914\n", - "220 11.864413 10.847406 10.941964 -0.877926 987.697915 24.444685\n", - "223 2.950431 5.655465 2.748327 2.144935 243.187723 181.510740\n", - "50 4.086414 3.712371 3.758234 -0.934282 1010.356890 27.061581\n", - "333 12.415303 10.879924 11.971757 -1.006296 177.447957 22.169530\n", - "176 15.745897 14.960575 15.111659 -0.367786 988.718774 13.428916\n", - "28 11.896668 11.137360 11.772867 -0.855962 978.721012 23.713533\n", - "44 3.901864 3.654003 3.919042 0.341336 965.911572 15.653204\n", - "96 52.753606 29.878506 52.753606 6.135591 561.793558 233.229645" + " baseline forecast actual m b stddev\n", + "74 18.785587 17.989601 18.131132 -0.153361 958.348989 4.492026\n", + "87 7.239583 9.885909 7.149756 7.037200 -64.524592 306.813320\n", + "164 3.531891 4.478827 27.828149 -2.277382 350.811568 110.248641\n", + "36 51.959712 49.601364 51.277886 -0.850466 987.026339 16.902915\n", + "98 18.336274 11.711657 17.373445 6.201609 22.024319 214.884382\n", + "249 67.523255 64.249195 66.979611 -0.395036 978.947001 15.076282\n", + "81 55.829117 53.346816 53.685575 -0.475130 961.037489 10.587760\n", + "10 32.056373 30.638461 30.998585 -0.485953 964.179230 11.088391\n", + "27 6.885002 8.666217 7.030633 11.099877 -47.994144 399.276077\n", + "241 13.175851 12.180933 13.041130 -0.024084 947.862326 9.670335" ] }, - "execution_count": 36, + "execution_count": 49, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "pd.DataFrame.from_dict(\n", - " results,\n", - " orient=\"index\"\n", - " )" + "results_loop" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Interate Over Multiple Rows of Data Using Requery" ] }, { "cell_type": "code", - "execution_count": 39, + "execution_count": 48, "metadata": {}, "outputs": [], "source": [ - "#analysis_loop_requery(region=\"CAISO_NORTH\",interval = 15, input_dict=input_dict,username=username,password=password)\n", - "#analysis_loop_requery_contiguous(region=\"CAISO_NORTH\",interval = 15, input_dict=input_dict,username=username,password=password)" + "#from watttime_optimizer.evaluator.analysis import analysis_loop_requery_contiguous" ] } ],