Skip to content

Commit

Permalink
add battery cycle limits
Browse files Browse the repository at this point in the history
  • Loading branch information
dguittet committed Aug 23, 2023
1 parent 6fbf43e commit eb72d81
Show file tree
Hide file tree
Showing 14 changed files with 314 additions and 115 deletions.
56 changes: 48 additions & 8 deletions hybrid/battery.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from dataclasses import dataclass, asdict
from typing import Sequence

import PySAM.BatteryStateful as BatteryModel
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))

Expand All @@ -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.
Expand Down
38 changes: 26 additions & 12 deletions hybrid/battery_stateless.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand All @@ -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))

Expand Down
6 changes: 3 additions & 3 deletions hybrid/dispatch/hybrid_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions hybrid/dispatch/hybrid_dispatch_builder_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 12 additions & 1 deletion hybrid/dispatch/hybrid_dispatch_options.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import numpy as np
from hybrid.dispatch import (OneCycleBatteryDispatchHeuristic,
SimpleBatteryDispatchHeuristic,
SimpleBatteryDispatch,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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__))

Expand All @@ -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))
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ 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 = {},
use_exp_voltage_point: bool = False):
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):
Expand Down Expand Up @@ -147,13 +147,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ 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 = {},
use_exp_voltage_point: bool = False):
u.load_definitions_from_strings(['amp_hour = amp * hour = Ah = amphour'])

Expand All @@ -28,7 +28,7 @@ def __init__(self,
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):
Expand Down Expand Up @@ -158,12 +158,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ 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 = {}):
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):
Expand Down
Loading

0 comments on commit eb72d81

Please sign in to comment.