Skip to content

Commit

Permalink
BatteryStateless (#196)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
dguittet authored Aug 21, 2023
1 parent 1051727 commit c7615f0
Show file tree
Hide file tree
Showing 15 changed files with 487 additions and 104 deletions.
15 changes: 10 additions & 5 deletions hybrid/battery.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,12 @@ def __init__(self,
:param site: Power source site information (SiteInfo object)
:param battery_config: Battery configuration with the following keys:
#. ``tracking``: bool, must be False, otherwise BatteryStateless will be used instead
#. ``system_capacity_kwh``: float, Battery energy capacity [kWh]
#. ``system_capacity_kw``: float, Battery rated power capacity [kW]
#. ``minimum_SOC``: float, (default=10) Minimum state of charge [%]
#. ``maximum_SOC``: float, (default=90) Maximum state of charge [%]
#. ``initial_SOC``: float, (default=10) Initial state of charge [%]
:param chemistry: Battery storage chemistry, options include:
Expand Down Expand Up @@ -80,9 +84,9 @@ def __init__(self,
self._system_model.value("control_mode", 0.0)
self._system_model.value("input_current", 0.0)
self._system_model.value("dt_hr", 1.0)
self._system_model.value("minimum_SOC", 10.0)
self._system_model.value("maximum_SOC", 90.0)
self._system_model.value("initial_SOC", 10.0)
self._system_model.value("minimum_SOC", battery_config['minimum_SOC'] if 'minimum_SOC' in battery_config.keys() else 10.0)
self._system_model.value("maximum_SOC", battery_config['maximum_SOC'] if 'maximum_SOC' in battery_config.keys() else 90.0)
self._system_model.value("initial_SOC", battery_config['initial_SOC'] if 'initial_SOC' in battery_config.keys() else 10.0)

self._dispatch = None

Expand Down Expand Up @@ -258,6 +262,9 @@ def simulate_financials(self, interconnect_kw: float, project_life: int, cap_cre
"""
if not isinstance(self._financial_model, Singleowner.Singleowner):
self._financial_model.assign(self._system_model.export(), ignore_missing_vals=True) # copy system parameter values having same name
else:
self._financial_model.value('om_batt_nameplate', self.system_capacity_kw)
self._financial_model.value('ppa_soln_mode', 1)

self._financial_model.value('batt_computed_bank_capacity', self.system_capacity_kwh)

Expand All @@ -268,15 +275,13 @@ def simulate_financials(self, interconnect_kw: float, project_life: int, cap_cre
else:
self._financial_model.value('system_use_lifetime_output', 0)
self._financial_model.value('analysis_period', project_life)
self._financial_model.value('om_batt_nameplate', self.system_capacity_kw)
try:
if self._financial_model.value('om_production') != 0:
raise ValueError("Battery's 'om_production' must be 0. For variable O&M cost based on battery discharge, "
"use `om_batt_variable_cost`, which is in $/MWh.")
except:
# om_production not set, so ok
pass
self._financial_model.value('ppa_soln_mode', 1)

if len(self.Outputs.gen) == self.site.n_timesteps:
single_year_gen = self.Outputs.gen
Expand Down
213 changes: 213 additions & 0 deletions hybrid/battery_stateless.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
from typing import Sequence
from dataclasses import dataclass, asdict

from hybrid.financial.custom_financial_model import CustomFinancialModel
from hybrid.power_source import *


@dataclass
class BatteryStatelessOutputs:
I: Sequence
P: Sequence
SOC: Sequence

def __init__(self):
"""Class for storing battery outputs."""
self.I = []
self.P = []
self.SOC = []
self.lifecycles_per_day = []

def export(self):
return asdict(self)

class BatteryStateless(PowerSource):
_financial_model: CustomFinancialModel

def __init__(self,
site: SiteInfo,
battery_config: dict):
"""
Battery Storage class with no system model for tracking the state of the battery
The state variables are pulled directly from the BatteryDispatch pyomo model.
Therefore, this battery model is compatible only with dispatch methods that use pyomo
such as
'simple': SimpleBatteryDispatch,
'convex_LV': ConvexLinearVoltageBatteryDispatch}
'non_convex_LV': NonConvexLinearVoltageBatteryDispatch,
:param site: Power source site information (SiteInfo object)
:param battery_config: Battery configuration with the following keys:
#. ``tracking``: bool, must be True, otherwise Battery will be used instead
#. ``system_capacity_kwh``: float, Battery energy capacity [kWh]
#. ``minimum_SOC``: float, (default=10) Minimum state of charge [%]
#. ``maximum_SOC``: float, (default=90) Maximum state of charge [%]
#. ``initial_SOC``: float, (default=50) Initial state of charge [%]
#. ``fin_model``: CustomFinancialModel, instance of financial model
"""
for key in ('system_capacity_kwh', 'system_capacity_kw'):
if key not in battery_config.keys():
raise ValueError

system_model = self

if 'fin_model' in battery_config.keys():
financial_model = self.import_financial_model(battery_config['fin_model'], system_model, None)
else:
raise ValueError("When using 'BatteryStateless', an instantiated CustomFinancialModel must be provided as the 'fin_model' in the battery_config")

self._system_capacity_kw: float = battery_config['system_capacity_kw']
self._system_capacity_kwh: float = battery_config['system_capacity_kwh']

# Minimum set of parameters to set to get statefulBattery to work
self.minimum_SOC = battery_config['minimum_SOC'] if 'minimum_SOC' in battery_config.keys() else 10.0
self.maximum_SOC = battery_config['maximum_SOC'] if 'maximum_SOC' in battery_config.keys() else 90.0
self.initial_SOC = battery_config['initial_SOC'] if 'initial_SOC' in battery_config.keys() else 10.0

self._dispatch = None
self.Outputs = BatteryStatelessOutputs()

super().__init__("Battery", site, system_model, financial_model)

logger.info("Initialized battery with parameters")

def simulate_with_dispatch(self, n_periods: int, sim_start_time: int = None):
"""
Step through dispatch solution for battery to collect outputs
:param n_periods: Number of hours to simulate [hrs]
:param sim_start_time: Start hour of simulation horizon
"""
# 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)

# logger.info("Battery Outputs at start time {}".format(sim_start_time, self.Outputs))

def simulate_power(self, time_step=None):
"""
Runs battery simulate and stores values if time step is provided
:param time_step: (optional) if provided outputs are stored, o.w. they are not stored.
"""
pass

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.
`batt_bank_replacement` is a required array of length (project_life + 1), where year 0 is "financial year 0" and is prior to system operation
If the battery replacements are to follow a schedule (`batt_replacement_option` == 2), the `batt_replacement_schedule_percent` is required.
This array is of length (project_life), where year 0 is the first year of system operation.
"""
pass

def export(self):
"""
Return all the battery system configuration in a dictionary for the financial model
"""
config = {
'system_capacity': self.system_capacity_kw,
'batt_computed_bank_capacity': self.system_capacity_kwh,
'minimum_SOC': self.minimum_SOC,
'maximum_SOC': self.maximum_SOC,
'initial_SOC': self.initial_SOC,
'Outputs': self.Outputs.export()
}
return config

def simulate_financials(self, interconnect_kw: float, project_life: int):
"""
Sets-up and simulates financial model for the battery
:param interconnect_kw: Interconnection limit [kW]
:param project_life: Analysis period [years]
"""
self._financial_model.assign(self._system_model.export(), ignore_missing_vals=True) # copy system parameter values having same name

if project_life > 1:
self._financial_model.value('system_use_lifetime_output', 1)
else:
self._financial_model.value('system_use_lifetime_output', 0)
self._financial_model.value('analysis_period', project_life)

if len(self.Outputs.P) == self.site.n_timesteps:
single_year_gen = self.Outputs.P
self._financial_model.value('gen', list(single_year_gen) * project_life)

self._financial_model.value('system_pre_curtailment_kwac', list(single_year_gen) * project_life)
self._financial_model.value('annual_energy_pre_curtailment_ac', sum(single_year_gen))
self._financial_model.value('batt_annual_discharge_energy', [sum(i for i in single_year_gen if i > 0)] * project_life)
self._financial_model.value('batt_annual_charge_energy', [sum(i for i in single_year_gen if i < 0)] * project_life)
self._financial_model.value('batt_annual_charge_from_system', (0,))
else:
raise RuntimeError

self._financial_model.execute(0)
logger.info("{} simulation executed".format('battery'))

@property
def system_capacity_kwh(self) -> float:
"""Battery energy capacity [kWh]"""
return self._system_capacity_kwh

@system_capacity_kwh.setter
def system_capacity_kwh(self, size_kwh: float):
self._financial_model.value("batt_computed_bank_capacity", size_kwh)
self.system_capacity_kwh = size_kwh

@property
def system_capacity_kw(self) -> float:
"""Battery power rating [kW]"""
return self._system_capacity_kw

@system_capacity_kw.setter
def system_capacity_kw(self, size_kw: float):
self._financial_model.value("system_capacity", size_kw)
self.system_capacity_kw = size_kw

@property
def system_nameplate_mw(self) -> float:
"""System nameplate [MW]"""
return self._system_capacity_kw * 1e-3

@property
def nominal_energy(self) -> float:
"""Battery energy capacity [kWh]"""
return self._system_capacity_kwh

@property
def capacity_factor(self) -> float:
"""System capacity factor [%]"""
return None

@property
def generation_profile(self) -> Sequence:
if self.system_capacity_kwh:
return self.Outputs.P
else:
return [0] * self.site.n_timesteps

@property
def annual_energy_kwh(self) -> float:
if self.system_capacity_kw > 0:
return sum(self.Outputs.P)
else:
return 0

@property
def SOC(self) -> float:
if len(self.Outputs.SOC):
return self.Outputs.SOC[0]
else:
return self.initial_SOC

@property
def lifecycles(self) -> float:
return self.Outputs.lifecycles_per_day
3 changes: 2 additions & 1 deletion hybrid/csp_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,8 @@ def simulate_financials(self, interconnect_kw: float, project_life: int = 25, ca
"""
if not isinstance(self._financial_model, Singleowner.Singleowner):
self._financial_model.assign(self._system_model.export(), ignore_missing_vals=True) # copy system parameter values having same name
else:
self._financial_model.value('ppa_soln_mode', 1)

if project_life > 1:
self._financial_model.value('system_use_lifetime_output', 1)
Expand All @@ -675,7 +677,6 @@ def simulate_financials(self, interconnect_kw: float, project_life: int = 25, ca
self.gen_max_feasible = self.calc_gen_max_feasible_kwh(interconnect_kw, cap_cred_avail_storage)
self.capacity_credit_percent = self.calc_capacity_credit_percent(interconnect_kw)

self._financial_model.value('ppa_soln_mode', 1)

if len(self.generation_profile) == self.site.n_timesteps:
single_year_gen = self.generation_profile
Expand Down
21 changes: 0 additions & 21 deletions hybrid/detailed_pv_plant.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,27 +142,6 @@ def processed_assign(self, params):

self._layout.set_layout_params(self.system_capacity, self._layout.parameters)

def simulate_financials(self, interconnect_kw: float, project_life: int):
"""
Runs the financial model
:param interconnect_kw: ``float``,
Hybrid interconnect limit [kW]
:param project_life: ``int``,
Number of year in the analysis period (execepted project lifetime) [years]
:return:
"""
if not self._financial_model:
return
if self.system_capacity_kw <= 0:
return

self._financial_model.value('batt_replacement_option', self._system_model.BatterySystem.batt_replacement_option)
self._financial_model.value('en_standalone_batt', self._system_model.BatterySystem.en_standalone_batt)
self._financial_model.value('om_batt_replacement_cost', self._system_model.SystemCosts.om_batt_replacement_cost)
self._financial_model.value('om_replacement_cost_escal', self._system_model.SystemCosts.om_replacement_cost_escal)
super().simulate_financials(interconnect_kw, project_life)

def get_pv_module(self, only_ref_vals=True) -> dict:
"""
Returns the PV module attributes for either the PVsamv1 or PVWattsv8 models
Expand Down
9 changes: 4 additions & 5 deletions hybrid/dispatch/dispatch.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import pyomo.environ as pyomo
from pyomo.environ import units as u

try:
u.USD
except AttributeError:
u.load_definitions_from_strings(['USD = [currency]', 'lifecycle = [energy] / [energy]'])

class Dispatch:
"""
Expand All @@ -13,11 +17,6 @@ def __init__(self,
financial_model,
block_set_name: str = 'dispatch'):

try:
u.USD
except AttributeError:
u.load_definitions_from_strings(['USD = [currency]'])

self.block_set_name = block_set_name
self.round_digits = int(4)

Expand Down
Loading

0 comments on commit c7615f0

Please sign in to comment.