From fa6b5ab1979c28d50cb4e1b098997d563c0b25c2 Mon Sep 17 00:00:00 2001 From: Darice L Guittet Date: Thu, 24 Aug 2023 13:58:01 -0600 Subject: [PATCH] Battery max cycles (#203) * pull in changes from pysam_update_capacity * fix import * update tests * add batttery_stateless * break out create_max_gross_profit_objective by tech * fix tests * fix minor comments * add battery cycle limits * fix tests * fix tests * fix mutable defaults --- hybrid/battery.py | 56 ++++++-- hybrid/battery_stateless.py | 38 ++++-- hybrid/dispatch/hybrid_dispatch.py | 6 +- .../hybrid_dispatch_builder_solver.py | 5 +- hybrid/dispatch/hybrid_dispatch_options.py | 13 +- .../linear_voltage_convex_battery_dispatch.py | 14 +- ...near_voltage_nonconvex_battery_dispatch.py | 15 ++- .../one_cycle_battery_dispatch_heuristic.py | 6 +- .../power_storage/power_storage_dispatch.py | 64 +++++++--- .../power_storage/simple_battery_dispatch.py | 10 +- .../simple_battery_dispatch_heuristic.py | 6 +- tests/analysis/test_custom_financial.py | 16 +-- tests/hybrid/test_battery.py | 57 +++++++-- tests/hybrid/test_dispatch.py | 120 ++++++++++++++---- tests/hybrid/test_hybrid.py | 30 ++--- 15 files changed, 331 insertions(+), 125 deletions(-) diff --git a/hybrid/battery.py b/hybrid/battery.py index b0424eb9e..5ab082d0e 100644 --- a/hybrid/battery.py +++ b/hybrid/battery.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, asdict from typing import Sequence import PySAM.BatteryStateful as BatteryModel @@ -7,17 +8,52 @@ from hybrid.power_source import * +@dataclass class BatteryOutputs: - def __init__(self, n_timesteps): + I: Sequence + P: Sequence + Q: Sequence + SOC: Sequence + T_batt: Sequence + gen: Sequence + n_cycles: Sequence + dispatch_I: Sequence + dispatch_P: Sequence + dispatch_SOC: Sequence + dispatch_lifecycles_per_day: Sequence + """ + The following outputs are simulated from the BatteryStateful model, an entry per timestep: + I: current [A] + P: power [kW] + Q: capacity [Ah] + SOC: state-of-charge [%] + T_batt: temperature [C] + gen: same as P + n_cycles: number of rainflow cycles elapsed since start of simulation [1] + + The next outputs, an entry per timestep, are from the HOPP dispatch model, which are then passed to the simulation: + dispatch_I: current [A], only applicable to battery dispatch models with current modeled + dispatch_P: power [mW] + dispatch_SOC: state-of-charge [%] + + This output has a different length, one entry per day: + dispatch_lifecycles_per_day: number of cycles per day + """ + + def __init__(self, n_timesteps, n_periods_per_day): """Class for storing stateful battery and dispatch outputs.""" - self.stateful_attributes = ['I', 'P', 'Q', 'SOC', 'T_batt', 'gen'] + self.stateful_attributes = ['I', 'P', 'Q', 'SOC', 'T_batt', 'gen', 'n_cycles'] for attr in self.stateful_attributes: - setattr(self, attr, [0.0]*n_timesteps) + setattr(self, attr, [0.0] * n_timesteps) - # dispatch output storage dispatch_attributes = ['I', 'P', 'SOC'] for attr in dispatch_attributes: - setattr(self, 'dispatch_'+attr, [0.0]*n_timesteps) + setattr(self, 'dispatch_'+attr, [0.0] * n_timesteps) + + self.dispatch_lifecycles_per_day = [None] * int(n_timesteps / n_periods_per_day) + + def export(self): + return asdict(self) class Battery(PowerSource): @@ -67,7 +103,7 @@ def __init__(self, super().__init__("Battery", site, system_model, financial_model) - self.Outputs = BatteryOutputs(n_timesteps=site.n_timesteps) + self.Outputs = BatteryOutputs(n_timesteps=site.n_timesteps, n_periods_per_day=site.n_periods_per_day) self.system_capacity_kw: float = battery_config['system_capacity_kw'] self.chemistry = chemistry BatteryTools.battery_model_sizing(self._system_model, @@ -198,6 +234,11 @@ def simulate_with_dispatch(self, n_periods: int, sim_start_time: int = None): self.Outputs.dispatch_SOC[time_slice] = self.dispatch.soc[0:n_periods] self.Outputs.dispatch_P[time_slice] = self.dispatch.power[0:n_periods] self.Outputs.dispatch_I[time_slice] = self.dispatch.current[0:n_periods] + if self.dispatch.options.include_lifecycle_count: + days_in_period = n_periods // (self.site.n_periods_per_day) + start_day = sim_start_time // self.site.n_periods_per_day + for d in range(days_in_period): + self.Outputs.dispatch_lifecycles_per_day[start_day + d] = self.dispatch.lifecycles[d] # logger.info("Battery Outputs at start time {}".format(sim_start_time, self.Outputs)) @@ -221,13 +262,12 @@ def update_battery_stored_values(self, time_step): :param time_step: time step where outputs will be stored. """ for attr in self.Outputs.stateful_attributes: - if hasattr(self._system_model.StatePack, attr): + if hasattr(self._system_model.StatePack, attr) or hasattr(self._system_model.StateCell, attr): getattr(self.Outputs, attr)[time_step] = self.value(attr) else: if attr == 'gen': getattr(self.Outputs, attr)[time_step] = self.value('P') - def validate_replacement_inputs(self, project_life): """ Checks that the battery replacement part of the model has the required inputs and that they are formatted correctly. diff --git a/hybrid/battery_stateless.py b/hybrid/battery_stateless.py index 8b76c1167..4bb53940d 100644 --- a/hybrid/battery_stateless.py +++ b/hybrid/battery_stateless.py @@ -8,19 +8,29 @@ @dataclass class BatteryStatelessOutputs: I: Sequence - P: Sequence + P: Sequence SOC: Sequence - - def __init__(self): + lifecycles_per_day: Sequence + """ + The following outputs are from the HOPP dispatch model, an entry per timestep: + I: current [A], only applicable to battery dispatch models with current modeled + P: power [kW] + SOC: state-of-charge [%] + + This output has a different length, one entry per day: + lifecycles_per_day: number of cycles per day + """ + def __init__(self, n_timesteps, n_periods_per_day): """Class for storing battery outputs.""" - self.I = [] - self.P = [] - self.SOC = [] - self.lifecycles_per_day = [] + self.I = [0.0] * n_timesteps + self.P = [0.0] * n_timesteps + self.SOC = [0.0] * n_timesteps + self.lifecycles_per_day = [None] * int(n_timesteps / n_periods_per_day) def export(self): return asdict(self) + class BatteryStateless(PowerSource): _financial_model: CustomFinancialModel @@ -67,7 +77,7 @@ def __init__(self, self.initial_SOC = battery_config['initial_SOC'] if 'initial_SOC' in battery_config.keys() else 10.0 self._dispatch = None - self.Outputs = BatteryStatelessOutputs() + self.Outputs = BatteryStatelessOutputs(n_timesteps=site.n_timesteps, n_periods_per_day=site.n_periods_per_day) super().__init__("Battery", site, system_model, financial_model) @@ -83,10 +93,14 @@ def simulate_with_dispatch(self, n_periods: int, sim_start_time: int = None): # Store Dispatch model values, converting to kW from mW if sim_start_time is not None: time_slice = slice(sim_start_time, sim_start_time + n_periods) - self.Outputs.SOC += [i for i in self.dispatch.soc[0:n_periods]] - self.Outputs.P += [i * 1e3 for i in self.dispatch.power[0:n_periods]] - self.Outputs.I += [i * 1e3 for i in self.dispatch.current[0:n_periods]] - self.Outputs.lifecycles_per_day.append(self.dispatch.lifecycles) + self.Outputs.SOC[time_slice] = [i for i in self.dispatch.soc[0:n_periods]] + self.Outputs.P[time_slice] = [i * 1e3 for i in self.dispatch.power[0:n_periods]] + self.Outputs.I[time_slice] = [i * 1e3 for i in self.dispatch.current[0:n_periods]] + if self.dispatch.options.include_lifecycle_count: + days_in_period = n_periods // (self.site.n_periods_per_day) + start_day = sim_start_time // self.site.n_periods_per_day + for d in range(days_in_period): + self.Outputs.lifecycles_per_day[start_day + d] = self.dispatch.lifecycles[d] # logger.info("Battery Outputs at start time {}".format(sim_start_time, self.Outputs)) diff --git a/hybrid/dispatch/hybrid_dispatch.py b/hybrid/dispatch/hybrid_dispatch.py index cf5689f43..77d5ad23d 100644 --- a/hybrid/dispatch/hybrid_dispatch.py +++ b/hybrid/dispatch/hybrid_dispatch.py @@ -282,8 +282,8 @@ def battery_profit_objective_rule(m): + tb[t].cost_per_discharge * self.blocks[t].battery_discharge) for t in self.blocks.index_set()) tb = self.power_sources['battery'].dispatch - if tb.include_lifecycle_count: - objective -= tb.model.lifecycle_cost * tb.model.lifecycles + if tb.options.include_lifecycle_count: + objective -= tb.model.lifecycle_cost * sum(tb.model.lifecycles) return objective self.model.battery_obj = pyomo.Expression(rule=battery_profit_objective_rule) @@ -343,7 +343,7 @@ def operating_cost_objective_rule(m): # Try to incentivize battery charging for t in self.blocks.index_set()) tb = self.power_sources['battery'].dispatch - if tb.include_lifecycle_count: + if tb.options.include_lifecycle_count: objective += tb.model.lifecycle_cost * tb.model.lifecycles return objective diff --git a/hybrid/dispatch/hybrid_dispatch_builder_solver.py b/hybrid/dispatch/hybrid_dispatch_builder_solver.py index 4902ad6dc..131bbec27 100644 --- a/hybrid/dispatch/hybrid_dispatch_builder_solver.py +++ b/hybrid/dispatch/hybrid_dispatch_builder_solver.py @@ -89,7 +89,8 @@ def _create_dispatch_optimization_model(self): model.forecast_horizon, tech._system_model, tech._financial_model, - include_lifecycle_count=self.options.include_lifecycle_count) + block_set_name=source, + dispatch_options=self.options) else: try: dispatch_class_name = getattr(module, source.capitalize() + "Dispatch") @@ -473,7 +474,7 @@ def simulate_with_dispatch(self, # TODO: we could just run the csp model without dispatch here else: self.solve_dispatch_model(start_time, n_days) - + store_outputs = True battery_sim_start_time = sim_start_time if i < n_initial_sims: diff --git a/hybrid/dispatch/hybrid_dispatch_options.py b/hybrid/dispatch/hybrid_dispatch_options.py index b6add67de..b9a827db9 100644 --- a/hybrid/dispatch/hybrid_dispatch_options.py +++ b/hybrid/dispatch/hybrid_dispatch_options.py @@ -1,3 +1,4 @@ +import numpy as np from hybrid.dispatch import (OneCycleBatteryDispatchHeuristic, SimpleBatteryDispatchHeuristic, SimpleBatteryDispatch, @@ -27,6 +28,8 @@ def __init__(self, dispatch_options: dict = None): 'grid_charging': bool (default=True), can the battery charge from the grid, 'pv_charging_only': bool (default=False), whether restricted to only charge from PV (ITC qualification) 'include_lifecycle_count': bool (default=True), should battery lifecycle counting be included, + 'lifecycle_cost_per_kWh_cycle': float (default=0.0265), if include_lifecycle_count, cost per kWh cycle, + 'max_lifecycle_per_day': int (default=None), if include_lifecycle_count, how many cycles allowed per day, 'n_look_ahead_periods': int (default=48), number of time periods dispatch looks ahead 'n_roll_periods': int (default=24), number of time periods simulation rolls forward after each dispatch, 'time_weighting_factor': (default=0.995) discount factor for the time periods in the look ahead period, @@ -43,6 +46,8 @@ def __init__(self, dispatch_options: dict = None): self.solver_options: dict = {} # used to update solver options, look at specific solver for option names self.battery_dispatch: str = 'simple' self.include_lifecycle_count: bool = True + self.lifecycle_cost_per_kWh_cycle: float = 0.0265 # Estimated using SAM output (lithium-ion battery) + self.max_lifecycle_per_day: int = np.inf self.grid_charging: bool = True self.pv_charging_only: bool = False self.n_look_ahead_periods: int = 48 @@ -63,7 +68,11 @@ def __init__(self, dispatch_options: dict = None): if type(getattr(self, key)) == type(value): setattr(self, key, value) else: - raise ValueError("'{}' is the wrong data type. Should be {}".format(key, type(getattr(self, key)))) + try: + value = type(getattr(self, key))(value) + setattr(self, key, value) + except: + raise ValueError("'{}' is the wrong data type. Should be {}".format(key, type(getattr(self, key)))) else: raise NameError("'{}' is not an attribute in {}".format(key, type(self).__name__)) @@ -90,5 +99,7 @@ def __init__(self, dispatch_options: dict = None): # Dispatch time duration is not set as of now... self.n_roll_periods = 24 self.n_look_ahead_periods = self.n_roll_periods + # dispatch cycle counting is not available in heuristics + self.include_lifecycle_count = False else: raise ValueError("'{}' is not currently a battery dispatch class.".format(self.battery_dispatch)) diff --git a/hybrid/dispatch/power_storage/linear_voltage_convex_battery_dispatch.py b/hybrid/dispatch/power_storage/linear_voltage_convex_battery_dispatch.py index 1b00a4031..6474b6e27 100644 --- a/hybrid/dispatch/power_storage/linear_voltage_convex_battery_dispatch.py +++ b/hybrid/dispatch/power_storage/linear_voltage_convex_battery_dispatch.py @@ -19,14 +19,16 @@ def __init__(self, system_model: BatteryModel.BatteryStateful, financial_model: Singleowner.Singleowner, block_set_name: str = 'convex_LV_battery', - include_lifecycle_count: bool = True, + dispatch_options: dict = None, use_exp_voltage_point: bool = False): + if dispatch_options is None: + dispatch_options = {} super().__init__(pyomo_model, index_set, system_model, financial_model, block_set_name=block_set_name, - include_lifecycle_count=include_lifecycle_count, + dispatch_options=dispatch_options, use_exp_voltage_point=use_exp_voltage_point) def dispatch_block_rule(self, battery): @@ -147,13 +149,15 @@ def _create_lv_battery_power_equation_constraints(battery): + battery.maximum_soc * battery.discharge_current - battery.maximum_soc * battery.minimum_discharge_current)) - def _lifecycle_count_rule(self, m): + def _lifecycle_count_rule(self, m, i): # current accounting # TODO: Check for cheating -> there seems to be a lot of error - return self.model.lifecycles == sum(self.blocks[t].time_duration + start = int(i * self.timesteps_per_day) + end = int((i + 1) * self.timesteps_per_day) + return self.model.lifecycles[i] == sum(self.blocks[t].time_duration * (0.8 * self.blocks[t].discharge_current - 0.8 * self.blocks[t].aux_discharge_current_soc) - / self.blocks[t].capacity for t in self.blocks.index_set()) + / self.blocks[t].capacity for t in range(start, end)) # Auxiliary Variables @property diff --git a/hybrid/dispatch/power_storage/linear_voltage_nonconvex_battery_dispatch.py b/hybrid/dispatch/power_storage/linear_voltage_nonconvex_battery_dispatch.py index cd2f299ca..941b1c231 100644 --- a/hybrid/dispatch/power_storage/linear_voltage_nonconvex_battery_dispatch.py +++ b/hybrid/dispatch/power_storage/linear_voltage_nonconvex_battery_dispatch.py @@ -19,16 +19,17 @@ def __init__(self, system_model: BatteryModel.BatteryStateful, financial_model: Singleowner.Singleowner, block_set_name: str = 'LV_battery', - include_lifecycle_count: bool = True, + dispatch_options: dict = None, use_exp_voltage_point: bool = False): u.load_definitions_from_strings(['amp_hour = amp * hour = Ah = amphour']) - + if dispatch_options is None: + dispatch_options = {} super().__init__(pyomo_model, index_set, system_model, financial_model, block_set_name=block_set_name, - include_lifecycle_count=include_lifecycle_count) + dispatch_options=dispatch_options) self.use_exp_voltage_point = use_exp_voltage_point def dispatch_block_rule(self, battery): @@ -158,12 +159,14 @@ def _create_lv_battery_power_equation_constraints(battery): - battery.average_current * battery.internal_resistance))) - def _lifecycle_count_rule(self, m): + def _lifecycle_count_rule(self, m, i): # current accounting - return self.model.lifecycles == sum(self.blocks[t].time_duration + start = int(i * self.timesteps_per_day) + end = int((i + 1) * self.timesteps_per_day) + return self.model.lifecycles[i] == sum(self.blocks[t].time_duration * (0.8 * self.blocks[t].discharge_current - 0.8 * self.blocks[t].discharge_current * self.blocks[t].soc0) - / self.blocks[t].capacity for t in self.blocks.index_set()) + / self.blocks[t].capacity for t in range(start, end)) def _set_control_mode(self): self._system_model.value("control_mode", 0.0) # Current control diff --git a/hybrid/dispatch/power_storage/one_cycle_battery_dispatch_heuristic.py b/hybrid/dispatch/power_storage/one_cycle_battery_dispatch_heuristic.py index 8a055fd00..962edc801 100644 --- a/hybrid/dispatch/power_storage/one_cycle_battery_dispatch_heuristic.py +++ b/hybrid/dispatch/power_storage/one_cycle_battery_dispatch_heuristic.py @@ -19,13 +19,15 @@ def __init__(self, system_model: BatteryModel.BatteryStateful, financial_model: Singleowner.Singleowner, block_set_name: str = 'one_cycle_heuristic_battery', - include_lifecycle_count: bool = False): + dispatch_options: dict = None): + if dispatch_options is None: + dispatch_options = {} super().__init__(pyomo_model, index_set, system_model, financial_model, block_set_name=block_set_name, - include_lifecycle_count=False) + dispatch_options=dispatch_options) self.prices = list([0.0] * len(self.blocks.index_set())) def _heuristic_method(self, gen): diff --git a/hybrid/dispatch/power_storage/power_storage_dispatch.py b/hybrid/dispatch/power_storage/power_storage_dispatch.py index 24a071425..0625297cb 100644 --- a/hybrid/dispatch/power_storage/power_storage_dispatch.py +++ b/hybrid/dispatch/power_storage/power_storage_dispatch.py @@ -1,3 +1,4 @@ +import numpy as np import pyomo.environ as pyomo from pyomo.network import Port from pyomo.environ import units as u @@ -15,22 +16,18 @@ def __init__(self, index_set: pyomo.Set, system_model, financial_model, - block_set_name: str = 'storage', - include_lifecycle_count: bool = True): - - try: - u.lifecycle - except AttributeError: - u.load_definitions_from_strings(['lifecycle = [energy] / [energy]']) + block_set_name: str, + dispatch_options): super().__init__(pyomo_model, index_set, system_model, financial_model, block_set_name=block_set_name) self._create_soc_linking_constraint() # TODO: we could remove this option and just have lifecycle count default - self.include_lifecycle_count = include_lifecycle_count - if self.include_lifecycle_count: + self.options = dispatch_options + if self.options.include_lifecycle_count: self._create_lifecycle_model() - self.lifecycle_cost_per_kWh_cycle = 0.0265 # Estimated using SAM output (lithium-ion battery) + if self.options.max_lifecycle_per_day < np.inf: + self._create_lifecycle_count_constraint() def dispatch_block_rule(self, storage): """ @@ -211,11 +208,20 @@ def storage_soc_linking_rule(m, t): doc=self.block_set_name + " state-of-charge block linking constraint", rule=storage_soc_linking_rule) + def _lifecycle_count_rule(self, m, i): + # Use full-energy cycles + start = int(i * self.timesteps_per_day) + end = int((i + 1) * self.timesteps_per_day) + return m.lifecycles[i] == sum(self.blocks[t].time_duration + * self.blocks[t].discharge_power + / self.blocks[t].capacity for t in range(start, end)) + def _create_lifecycle_model(self): - self.include_lifecycle_count = True ################################## # Parameters # ################################## + self.timesteps_per_day = 24 / pyomo.value(self.blocks[0].time_duration) + self.model.days = pyomo.RangeSet(0, int(len(self.blocks)) / self.timesteps_per_day - 1) self.model.lifecycle_cost = pyomo.Param( doc="Lifecycle cost of " + self.block_set_name + " [$/lifecycle]", default=0.0, @@ -226,6 +232,7 @@ def _create_lifecycle_model(self): # Variables # ################################## self.model.lifecycles = pyomo.Var( + self.model.days, doc=self.block_set_name + " lifecycle count", domain=pyomo.NonNegativeReals, units=u.lifecycle) @@ -233,10 +240,10 @@ def _create_lifecycle_model(self): # Constraints # ################################## self.model.lifecycle_count = pyomo.Constraint( + self.model.days, doc=self.block_set_name + " lifecycle counting", rule=self._lifecycle_count_rule ) - # self._create_lifecycle_count_constraint() ################################## # Ports # ################################## @@ -244,11 +251,19 @@ def _create_lifecycle_model(self): self.model.lifecycles_port.add(self.model.lifecycles) self.model.lifecycles_port.add(self.model.lifecycle_cost) - def _lifecycle_count_rule(self, m): - # Use full-energy cycles - return self.model.lifecycles == sum(self.blocks[t].time_duration - * self.blocks[t].discharge_power - / self.blocks[t].capacity for t in self.blocks.index_set()) + + def _create_lifecycle_count_constraint(self): + self.model.max_cycles_per_day = pyomo.Param( + doc="Max number of full energy cycles per day for " + self.block_set_name, + default=self.options.max_lifecycle_per_day, + within=pyomo.NonNegativeReals, + mutable=True, + units=u.lifecycle) + + self.model.lifecycle_count_constraint = pyomo.Constraint( + self.model.days, + rule=lambda m, i: m.lifecycles[i] <= m.max_cycles_per_day + ) def _check_initial_soc(self, initial_soc): if initial_soc > 1: @@ -406,6 +421,15 @@ def lifecycle_cost(self) -> float: def lifecycle_cost(self, lifecycle_cost: float): self.model.lifecycle_cost = lifecycle_cost + @property + def lifecycle_cost_per_kWh_cycle(self) -> float: + return self.options.lifecycle_cost_per_kWh_cycle + + @lifecycle_cost_per_kWh_cycle.setter + def lifecycle_cost_per_kWh_cycle(self, lifecycle_cost_per_kWh_cycle: float): + self.options.lifecycle_cost_per_kWh_cycle = lifecycle_cost_per_kWh_cycle + self.model.lifecycle_cost = lifecycle_cost_per_kWh_cycle * self._system_model.value('nominal_energy') + # Outputs @property def is_charging(self) -> list: @@ -429,10 +453,10 @@ def discharge_power(self) -> list: @property def lifecycles(self) -> float: - if self.include_lifecycle_count: - return self.model.lifecycles.value + if self.options.include_lifecycle_count: + return [pyomo.value(i) for _, i in self.model.lifecycles.items()] else: - return None + return [] @property def power(self) -> list: diff --git a/hybrid/dispatch/power_storage/simple_battery_dispatch.py b/hybrid/dispatch/power_storage/simple_battery_dispatch.py index 593712a34..d2c2e7417 100644 --- a/hybrid/dispatch/power_storage/simple_battery_dispatch.py +++ b/hybrid/dispatch/power_storage/simple_battery_dispatch.py @@ -19,18 +19,18 @@ def __init__(self, index_set: pyomo.Set, system_model: BatteryModel.BatteryStateful, financial_model: Singleowner.Singleowner, - block_set_name: str = 'battery', - include_lifecycle_count: bool = True): + block_set_name: str, + dispatch_options): super().__init__(pyomo_model, index_set, system_model, financial_model, block_set_name=block_set_name, - include_lifecycle_count=include_lifecycle_count) + dispatch_options=dispatch_options) def initialize_parameters(self): - if self.include_lifecycle_count: - self.lifecycle_cost = self.lifecycle_cost_per_kWh_cycle * self._system_model.value('nominal_energy') + if self.options.include_lifecycle_count: + self.lifecycle_cost = self.options.lifecycle_cost_per_kWh_cycle * self._system_model.value('nominal_energy') self.cost_per_charge = 0.75 # [$/MWh] self.cost_per_discharge = 0.75 # [$/MWh] diff --git a/hybrid/dispatch/power_storage/simple_battery_dispatch_heuristic.py b/hybrid/dispatch/power_storage/simple_battery_dispatch_heuristic.py index dc9870a75..30ab92efd 100644 --- a/hybrid/dispatch/power_storage/simple_battery_dispatch_heuristic.py +++ b/hybrid/dispatch/power_storage/simple_battery_dispatch_heuristic.py @@ -19,17 +19,19 @@ def __init__(self, financial_model: Singleowner.Singleowner, fixed_dispatch: list = None, block_set_name: str = 'heuristic_battery', - include_lifecycle_count: bool = False): + dispatch_options: dict = None): """ :param fixed_dispatch: list of normalized values [-1, 1] (Charging (-), Discharging (+)) """ + if dispatch_options is None: + dispatch_options = {} super().__init__(pyomo_model, index_set, system_model, financial_model, block_set_name=block_set_name, - include_lifecycle_count=False) + dispatch_options=dispatch_options) self.max_charge_fraction = list([0.0]*len(self.blocks.index_set())) self.max_discharge_fraction = list([0.0]*len(self.blocks.index_set())) diff --git a/tests/analysis/test_custom_financial.py b/tests/analysis/test_custom_financial.py index a6edf8459..c7a1ce7c5 100644 --- a/tests/analysis/test_custom_financial.py +++ b/tests/analysis/test_custom_financial.py @@ -274,12 +274,12 @@ def test_hybrid_simple_pv_with_wind_storage_dispatch(site): # Test wind + simple PV (pvwattsv8) + storage with dispatch hybrid plant with custom financial model annual_energy_expected_pv = 9857584 annual_energy_expected_wind = 33074859 - annual_energy_expected_battery = -31253 - annual_energy_expected_hybrid = 42901190 + annual_energy_expected_battery = -97180 + annual_energy_expected_hybrid = 42835263 npv_expected_pv = -1905544 npv_expected_wind = -4829660 - npv_expected_battery = -8164187 - npv_expected_hybrid = -14899381 + npv_expected_battery = -8183543 + npv_expected_hybrid = -14918736 interconnect_kw = 15000 pv_kw = 5000 @@ -344,12 +344,12 @@ def test_hybrid_detailed_pv_with_wind_storage_dispatch(site): # Test wind + detailed PV (pvsamv1) + storage with dispatch hybrid plant with custom financial model annual_energy_expected_pv = 20365655 annual_energy_expected_wind = 33462743 - annual_energy_expected_battery = -29994 - annual_energy_expected_hybrid = 53798060 + annual_energy_expected_battery = -90903 + annual_energy_expected_hybrid = 53736299 npv_expected_pv = -3621345 npv_expected_wind = -4715783 - npv_expected_battery = -8163817 - npv_expected_hybrid = -16501035 + npv_expected_battery = -8181700 + npv_expected_hybrid = -16519167 interconnect_kw = 15000 wind_kw = 10000 diff --git a/tests/hybrid/test_battery.py b/tests/hybrid/test_battery.py index 86a645517..2b82f3c3e 100644 --- a/tests/hybrid/test_battery.py +++ b/tests/hybrid/test_battery.py @@ -67,8 +67,6 @@ prices[index] = 100.0 # assuming high prices index += 1 -default_options = HybridDispatchOptions() - def create_test_objective_rule(m): return sum((m.battery[t].time_duration * ( @@ -77,7 +75,7 @@ def create_test_objective_rule(m): for t in m.battery.index_set()) -def test_simple_battery_dispatch(): +def test_batterystateless_dispatch(): expected_objective = 28957.15 # Run battery stateful as system model first @@ -97,7 +95,7 @@ def test_simple_battery_dispatch(): battery._system_model, battery._financial_model, 'battery', - default_options) + HybridDispatchOptions()) model.test_objective = pyomo.Objective( rule=create_test_objective_rule, @@ -121,6 +119,9 @@ def test_simple_battery_dispatch(): for i in range(24): dispatch_power = battery.dispatch.power[i] * 1e3 assert battery.Outputs.P[i] == pytest.approx(dispatch_power, 1e-3 * abs(dispatch_power)) + assert battery.Outputs.dispatch_lifecycles_per_day[0:2] == pytest.approx([0.75048, 1.50096], rel=1e-3) + assert battery.Outputs.n_cycles[23] == 0 + assert battery.Outputs.n_cycles[47] == 1 # Run battery stateless as system model to compare technologies['battery']['tracking'] = False @@ -138,7 +139,7 @@ def test_simple_battery_dispatch(): battery_sl._system_model, battery_sl._financial_model, 'battery', - default_options) + HybridDispatchOptions()) model_sl.test_objective = pyomo.Objective( rule=create_test_objective_rule, @@ -160,12 +161,52 @@ def test_simple_battery_dispatch(): dispatch_power = battery_sl.dispatch.power[i] * 1e3 assert battery_sl.Outputs.P[i] == pytest.approx(dispatch_power, 1e-3 * abs(dispatch_power)) - battery_dispatch = np.array(battery.dispatch.power) + battery_dispatch = np.array(battery.dispatch.power)[0:48] battery_actual = np.array(battery.generation_profile[0:dispatch_n_look_ahead]) * 1e-3 # convert to MWh - battery_sl_dispatch = np.array(battery_sl.dispatch.power) - battery_sl_actual = np.array(battery_sl.generation_profile) * 1e-3 # convert to MWh + battery_sl_dispatch = np.array(battery_sl.dispatch.power)[0:48] + battery_sl_actual = np.array(battery_sl.generation_profile)[0:48] * 1e-3 # convert to MWh assert sum(battery_dispatch - battery_sl_dispatch) == 0 assert sum(abs(battery_actual - battery_dispatch)) <= 33 assert sum(abs(battery_sl_actual - battery_sl_dispatch)) == 0 assert sum(abs(battery_actual - battery_sl_actual)) <= 33 + assert battery_sl.Outputs.lifecycles_per_day[0:2] == pytest.approx([0.75048, 1.50096], rel=1e-3) + + +def test_batterystateless_cycle_limits(): + expected_objective = 22513 # objective is less than above due to cycling limits + + technologies = technologies_input.copy() + technologies['battery']['tracking'] = False + model_sl = pyomo.ConcreteModel(name='battery_stateless') + model_sl.forecast_horizon = pyomo.Set(initialize=range(dispatch_n_look_ahead)) + model_sl.price = pyomo.Param(model_sl.forecast_horizon, + within=pyomo.Reals, + initialize=prices, + mutable=True, + units=u.USD / u.MWh) + + battery_sl = BatteryStateless(site, technologies['battery']) + battery_sl._dispatch = SimpleBatteryDispatch(model_sl, + model_sl.forecast_horizon, + battery_sl._system_model, + battery_sl._financial_model, + 'battery', + HybridDispatchOptions({'max_lifecycle_per_day': 1})) + + model_sl.test_objective = pyomo.Objective( + rule=create_test_objective_rule, + sense=pyomo.maximize) + + battery_sl.dispatch.initialize_parameters() + battery_sl.dispatch.update_time_series_parameters(0) + assert_units_consistent(model_sl) + results = HybridDispatchBuilderSolver.glpk_solve_call(model_sl) + + assert results.solver.termination_condition == TerminationCondition.optimal + assert pyomo.value(model_sl.test_objective) == pytest.approx(expected_objective, 1e-3) + + battery_sl.simulate_with_dispatch(48, 0) + + assert battery_sl.Outputs.lifecycles_per_day[0:2] == pytest.approx([0.75048, 1], rel=1e-3) + diff --git a/tests/hybrid/test_dispatch.py b/tests/hybrid/test_dispatch.py index 9da02b923..83bb0814d 100644 --- a/tests/hybrid/test_dispatch.py +++ b/tests/hybrid/test_dispatch.py @@ -384,7 +384,8 @@ def test_simple_battery_dispatch(site): model.forecast_horizon, battery._system_model, battery._financial_model, - include_lifecycle_count=False) + "battery", + dispatch_options=HybridDispatchOptions({'include_lifecycle_count': False})) # Manually creating objective for testing prices = {} @@ -434,8 +435,8 @@ def create_test_objective_rule(m): def test_simple_battery_dispatch_lifecycle_count(site): - expected_objective = 17024.52 - expected_lifecycles = 2.2514 + expected_objective = 23657 + expected_lifecycles = [0.75048, 1.50096] dispatch_n_look_ahead = 48 @@ -447,7 +448,8 @@ def test_simple_battery_dispatch_lifecycle_count(site): model.forecast_horizon, battery._system_model, battery._financial_model, - include_lifecycle_count=True) + "battery", + dispatch_options=HybridDispatchOptions({'include_lifecycle_count': True})) # Manually creating objective for testing prices = {} @@ -472,7 +474,7 @@ def create_test_objective_rule(m): (m.price[t] - m.battery[t].cost_per_discharge) * m.battery[t].discharge_power - (m.price[t] + m.battery[t].cost_per_charge) * m.battery[t].charge_power)) for t in m.battery.index_set()) - - m.lifecycle_cost * m.lifecycles) + - m.lifecycle_cost * sum(m.lifecycles)) model.test_objective = pyomo.Objective( rule=create_test_objective_rule, @@ -486,8 +488,9 @@ def create_test_objective_rule(m): results = HybridDispatchBuilderSolver.glpk_solve_call(model) assert results.solver.termination_condition == TerminationCondition.optimal - assert pyomo.value(model.test_objective) == pytest.approx(expected_objective, 1e-5) - assert pyomo.value(battery.dispatch.lifecycles) == pytest.approx(expected_lifecycles, 1e-3) + assert pyomo.value(model.test_objective) == pytest.approx(expected_objective, 1e-2) + assert pyomo.value(battery.dispatch.lifecycles[0]) == pytest.approx(expected_lifecycles[0], 1e-3) + assert pyomo.value(battery.dispatch.lifecycles[1]) == pytest.approx(expected_lifecycles[1], 1e-3) assert sum(battery.dispatch.charge_power) > 0.0 assert sum(battery.dispatch.discharge_power) > 0.0 @@ -496,8 +499,8 @@ def create_test_objective_rule(m): def test_detailed_battery_dispatch(site): - expected_objective = 37003.621 - expected_lifecycles = 0.331693 + expected_objective = 33508 + expected_lifecycles = [0.14300, 0.22169] # TODO: McCormick error is large enough to make objective 50% higher than # the value of simple battery dispatch objective @@ -510,7 +513,9 @@ def test_detailed_battery_dispatch(site): battery._dispatch = ConvexLinearVoltageBatteryDispatch(model, model.forecast_horizon, battery._system_model, - battery._financial_model) + battery._financial_model, + "convex_LV_battery", + HybridDispatchOptions()) # Manually creating objective for testing prices = {} @@ -535,7 +540,7 @@ def create_test_objective_rule(m): (m.price[t] - m.convex_LV_battery[t].cost_per_discharge) * m.convex_LV_battery[t].discharge_power - (m.price[t] + m.convex_LV_battery[t].cost_per_charge) * m.convex_LV_battery[t].charge_power)) for t in m.convex_LV_battery.index_set()) - - m.lifecycle_cost * m.lifecycles) + - m.lifecycle_cost * sum(m.lifecycles)) model.test_objective = pyomo.Objective( rule=create_test_objective_rule, @@ -553,7 +558,8 @@ def create_test_objective_rule(m): assert results.solver.termination_condition == TerminationCondition.optimal assert pyomo.value(model.test_objective) == pytest.approx(expected_objective, 1e-3) - assert pyomo.value(battery.dispatch.lifecycles) == pytest.approx(expected_lifecycles, 1e-3) + assert pyomo.value(battery.dispatch.lifecycles[0]) == pytest.approx(expected_lifecycles[0], 1e-3) + assert pyomo.value(battery.dispatch.lifecycles[1]) == pytest.approx(expected_lifecycles[1], 1e-3) assert sum(battery.dispatch.charge_power) > 0.0 assert sum(battery.dispatch.discharge_power) > 0.0 assert sum(battery.dispatch.charge_current) >= sum(battery.dispatch.discharge_current) - 1e-7 @@ -567,7 +573,7 @@ def test_pv_wind_battery_hybrid_dispatch(site): wind_solar_battery = {key: technologies[key] for key in ('pv', 'wind', 'battery', 'grid')} hybrid_plant = HybridSimulation(wind_solar_battery, site, - dispatch_options={'grid_charging': False, + dispatch_options={'grid_charging': False, 'include_lifecycle_count': False}) hybrid_plant.grid.value("federal_tax_rate", (0., )) hybrid_plant.grid.value("state_tax_rate", (0., )) @@ -612,8 +618,7 @@ def test_pv_wind_battery_hybrid_dispatch(site): def test_hybrid_dispatch_heuristic(site): - dispatch_options = {'battery_dispatch': 'heuristic', - 'grid_charging': False} + dispatch_options = {'battery_dispatch': 'heuristic', 'grid_charging': False} wind_solar_battery = {key: technologies[key] for key in ('pv', 'wind', 'battery', 'grid')} hybrid_plant = HybridSimulation(wind_solar_battery, @@ -633,8 +638,7 @@ def test_hybrid_dispatch_heuristic(site): def test_hybrid_dispatch_one_cycle_heuristic(site): - dispatch_options = {'battery_dispatch': 'one_cycle_heuristic', - 'grid_charging': False} + dispatch_options = {'battery_dispatch': 'one_cycle_heuristic', 'grid_charging': False} wind_solar_battery = {key: technologies[key] for key in ('pv', 'wind', 'battery', 'grid')} hybrid_plant = HybridSimulation(wind_solar_battery, @@ -646,7 +650,7 @@ def test_hybrid_dispatch_one_cycle_heuristic(site): def test_hybrid_solar_battery_dispatch(site): - expected_objective = 20819.456 + expected_objective = 23474 solar_battery_technologies = {k: technologies[k] for k in ('pv', 'battery', 'grid')} hybrid_plant = HybridSimulation(solar_battery_technologies, @@ -709,8 +713,7 @@ def test_hybrid_dispatch_financials(site): assert sum(hybrid_plant.battery.Outputs.P) < 0.0 -def test_desired_schedule_dispatch(): - +def test_desired_schedule_dispatch(site): # Creating a contrived schedule daily_schedule = [interconnect_mw]*10 daily_schedule.extend([20] * 8) @@ -728,15 +731,15 @@ def test_desired_schedule_dispatch(): 'tes_hours': 10.0} tower_pv_battery['pv'] = {'system_capacity_kw': 80 * 1000} - + dispatch_options = {'is_test_start_year': True, + 'is_test_end_year': False, + 'grid_charging': False, + 'pv_charging_only': True, + 'include_lifecycle_count': False + } hybrid_plant = HybridSimulation(tower_pv_battery, desired_schedule_site, - dispatch_options={'is_test_start_year': True, - 'is_test_end_year': False, - 'grid_charging': False, - 'pv_charging_only': True, - 'include_lifecycle_count': False - }) + dispatch_options=dispatch_options) hybrid_plant.ppa_price = (0.06, ) # Constant price @@ -774,3 +777,66 @@ def test_desired_schedule_dispatch(): assert sum(hybrid_plant.tower.dispatch.receiver_thermal_power) > 0.0 +def test_simple_battery_dispatch_lifecycle_limit(site): + expected_objective = 7561 + max_lifecycle_per_day = 0.5 + + dispatch_n_look_ahead = 48 + + battery = Battery(site, technologies['battery']) + + model = pyomo.ConcreteModel(name='battery_only') + model.forecast_horizon = pyomo.Set(initialize=range(dispatch_n_look_ahead)) + battery._dispatch = SimpleBatteryDispatch(model, + model.forecast_horizon, + battery._system_model, + battery._financial_model, + "battery", + dispatch_options=HybridDispatchOptions({'include_lifecycle_count': True, + 'max_lifecycle_per_day': max_lifecycle_per_day})) + + # Manually creating objective for testing + prices = {} + block_length = 8 + index = 0 + for i in range(int(dispatch_n_look_ahead / block_length)): + for j in range(block_length): + if i % 2 == 0: + prices[index] = 30.0 # assuming low prices + else: + prices[index] = 100.0 # assuming high prices + index += 1 + + model.price = pyomo.Param(model.forecast_horizon, + within=pyomo.Reals, + initialize=prices, + mutable=True, + units=u.USD / u.MWh) + + def create_test_objective_rule(m): + return (sum((m.battery[t].time_duration * ( + (m.price[t] - m.battery[t].cost_per_discharge) * m.battery[t].discharge_power + - (m.price[t] + m.battery[t].cost_per_charge) * m.battery[t].charge_power)) + for t in m.battery.index_set()) + - m.lifecycle_cost * sum(m.lifecycles)) + + model.test_objective = pyomo.Objective( + rule=create_test_objective_rule, + sense=pyomo.maximize) + + battery.dispatch.initialize_parameters() + battery.dispatch.update_time_series_parameters(0) + model.initial_SOC = battery.dispatch.minimum_soc # Set initial SOC to minimum + assert_units_consistent(model) + + results = HybridDispatchBuilderSolver.glpk_solve_call(model) + + assert results.solver.termination_condition == TerminationCondition.optimal + assert pyomo.value(model.test_objective) == pytest.approx(expected_objective, rel=1e-2) + assert pyomo.value(battery.dispatch.lifecycles[0]) == pytest.approx(max_lifecycle_per_day, 1e-3) + assert pyomo.value(battery.dispatch.lifecycles[1]) == pytest.approx(max_lifecycle_per_day, 1e-3) + + assert sum(battery.dispatch.charge_power) > 0.0 + assert sum(battery.dispatch.discharge_power) > 0.0 + assert (sum(battery.dispatch.charge_power) * battery.dispatch.round_trip_efficiency / 100.0 + == pytest.approx(sum(battery.dispatch.discharge_power))) diff --git a/tests/hybrid/test_hybrid.py b/tests/hybrid/test_hybrid.py index 86d202fec..7efd245e4 100644 --- a/tests/hybrid/test_hybrid.py +++ b/tests/hybrid/test_hybrid.py @@ -510,7 +510,6 @@ def test_hybrid(site): def test_wind_pv_with_storage_dispatch(site): wind_pv_battery = {key: technologies[key] for key in ('pv', 'wind', 'battery', 'grid')} hybrid_plant = HybridSimulation(wind_pv_battery, site) - hybrid_plant.battery.dispatch.lifecycle_cost_per_kWh_cycle = 0.01 hybrid_plant.ppa_price = (0.03, ) hybrid_plant.pv.dc_degradation = [0] * 25 hybrid_plant.simulate() @@ -528,7 +527,7 @@ def test_wind_pv_with_storage_dispatch(site): assert aeps.pv == approx(9882421, rel=0.05) assert aeps.wind == approx(33637983, rel=0.05) - assert aeps.battery == approx(-31287, rel=0.05) + assert aeps.battery == approx(-99103, rel=0.05) assert aeps.hybrid == approx(43489117, rel=0.05) assert npvs.pv == approx(-853226, rel=5e-2) @@ -543,8 +542,8 @@ def test_wind_pv_with_storage_dispatch(site): assert apv.pv[1] == approx(0, rel=5e-2) assert apv.wind[1] == approx(0, rel=5e-2) - assert apv.battery[1] == approx(40158, rel=5e-2) - assert apv.hybrid[1] == approx(3050, rel=5e-2) + assert apv.battery[1] == approx(97920, rel=5e-2) + assert apv.hybrid[1] == approx(7494, rel=5e-2) assert debt.pv[1] == approx(0, rel=5e-2) assert debt.wind[1] == approx(0, rel=5e-2) @@ -553,7 +552,7 @@ def test_wind_pv_with_storage_dispatch(site): assert esv.pv[1] == approx(353105, rel=5e-2) assert esv.wind[1] == approx(956067, rel=5e-2) - assert esv.battery[1] == approx(80449, rel=5e-2) + assert esv.battery[1] == approx(167944, rel=5e-2) assert esv.hybrid[1] == approx(1352445, rel=5e-2) assert depr.pv[1] == approx(762811, rel=5e-2) @@ -573,7 +572,7 @@ def test_wind_pv_with_storage_dispatch(site): assert rev.pv[1] == approx(353105, rel=5e-2) assert rev.wind[1] == approx(956067, rel=5e-2) - assert rev.battery[1] == approx(80449, rel=5e-2) + assert rev.battery[1] == approx(167944, rel=5e-2) assert rev.hybrid[1] == approx(1352445, rel=5e-2) assert tc.pv[1] == approx(1123104, rel=5e-2) @@ -811,7 +810,6 @@ def test_capacity_credit(): 'interconnect_kw': interconnection_size_kw } hybrid_plant = HybridSimulation(wind_pv_battery, site) - hybrid_plant.battery.dispatch.lifecycle_cost_per_kWh_cycle = 0.01 hybrid_plant.ppa_price = (0.03, ) hybrid_plant.pv.dc_degradation = [0] * 25 @@ -886,14 +884,14 @@ def reinstate_orig_values(): aeps = hybrid_plant.annual_energies assert aeps.pv == approx(9882421, rel=0.05) assert aeps.wind == approx(33637983, rel=0.05) - assert aeps.battery == approx(-31287, rel=0.05) + assert aeps.battery == approx(-97166, rel=0.05) assert aeps.hybrid == approx(43489117, rel=0.05) npvs = hybrid_plant.net_present_values assert npvs.pv == approx(-565098, rel=5e-2) assert npvs.wind == approx(-2232003, rel=5e-2) - assert npvs.battery == approx(-4773045, rel=5e-2) - assert npvs.hybrid == approx(-6143329, rel=5e-2) + assert npvs.battery == approx(-4490202, rel=5e-2) + assert npvs.hybrid == approx(-5809462, rel=5e-2) taxes = hybrid_plant.federal_taxes assert taxes.pv[1] == approx(86826, rel=5e-2) @@ -904,8 +902,8 @@ def reinstate_orig_values(): apv = hybrid_plant.energy_purchases_values assert apv.pv[1] == approx(0, rel=5e-2) assert apv.wind[1] == approx(0, rel=5e-2) - assert apv.battery[1] == approx(40158, rel=5e-2) - assert apv.hybrid[1] == approx(2980, rel=5e-2) + assert apv.battery[1] == approx(97920, rel=5e-2) + assert apv.hybrid[1] == approx(7494, rel=5e-2) debt = hybrid_plant.debt_payment assert debt.pv[1] == approx(0, rel=5e-2) @@ -916,8 +914,8 @@ def reinstate_orig_values(): esv = hybrid_plant.energy_sales_values assert esv.pv[1] == approx(353105, rel=5e-2) assert esv.wind[1] == approx(956067, rel=5e-2) - assert esv.battery[1] == approx(80449, rel=5e-2) - assert esv.hybrid[1] == approx(1352445, rel=5e-2) + assert esv.battery[1] == approx(167944, rel=5e-2) + assert esv.hybrid[1] == approx(1386692, rel=5e-2) depr = hybrid_plant.federal_depreciation_totals assert depr.pv[1] == approx(762811, rel=5e-2) @@ -940,8 +938,8 @@ def reinstate_orig_values(): rev = hybrid_plant.total_revenues assert rev.pv[1] == approx(393226, rel=5e-2) assert rev.wind[1] == approx(1288603, rel=5e-2) - assert rev.battery[1] == approx(375215, rel=5e-2) - assert rev.hybrid[1] == approx(2229976, rel=5e-2) + assert rev.battery[1] == approx(469290, rel=5e-2) + assert rev.hybrid[1] == approx(2272997, rel=5e-2) tc = hybrid_plant.tax_incentives assert tc.pv[1] == approx(1123104, rel=5e-2)