diff --git a/docs/release_notes.rst b/docs/release_notes.rst index 9cbf36ee..2050e243 100644 --- a/docs/release_notes.rst +++ b/docs/release_notes.rst @@ -2,6 +2,26 @@ Release Notes ======================================================================================= +.. _release-v0-6-0: + +--------------------------------------------------------------------------------------- +0.6.0 (2023-XX-XX) +--------------------------------------------------------------------------------------- + +What's New? +^^^^^^^^^^^ +* Initial implementation of dynamic storage reserves. If storage does not have a + reserve or the reserve is ``0.0``, we calculate a dynamic reserve based on the next + 24 hours of net load using :eq:`reserve`. + +.. math:: + :label: reserve + + ramp &= \frac{max(load_{h+1}, ..., load_{h+24})}{load_h} - 1 + + reserve &= 1 - e^{-1.5 ramp} + + .. _release-v0-5-0: --------------------------------------------------------------------------------------- diff --git a/src/dispatch/engine.py b/src/dispatch/engine.py index 8abaeae7..6a7c88eb 100644 --- a/src/dispatch/engine.py +++ b/src/dispatch/engine.py @@ -112,6 +112,14 @@ def dispatch_engine( # noqa: C901 # the big loop where we iterate through all the hours for hr, (deficit, yr) in enumerate(zip(net_load, hr_to_cost_idx)): + # storage reserves are dynamic so we need to determine the current hour's + # reserve to use throughout + storage_reserve_ = dynamic_reserve( + hr=hr, + reserve=storage_reserve, + net_load=net_load, + ) + # because of the look-backs, the first hour has to be done differently # here we just skip it because its only one hour and we assume # historical fossil dispatch @@ -127,7 +135,7 @@ def dispatch_engine( # noqa: C901 deficit=deficit, # previous state_of_charge state_of_charge=storage_soc_max[storage_idx] - * storage_reserve[storage_idx], + * storage_reserve_[storage_idx], dc_charge=storage_dc_charge[hr, storage_idx], mw=storage_mw[storage_idx], max_state_of_charge=storage_soc_max[storage_idx], @@ -145,7 +153,7 @@ def dispatch_engine( # noqa: C901 + adjust_for_storage_reserve( state_of_charge=storage[hr - 1, 2, :], mw=storage_mw, - reserve=storage_reserve, + reserve=storage_reserve_, max_state_of_charge=storage_soc_max, ), ) @@ -247,7 +255,7 @@ def dispatch_engine( # noqa: C901 state_of_charge=storage[hr - 1, 2, storage_idx], mw=storage_mw[storage_idx], max_state_of_charge=storage_soc_max[storage_idx], - reserve=storage_reserve[storage_idx], + reserve=storage_reserve_[storage_idx], ) storage[hr, 1, storage_idx] = discharge storage[hr, 2, storage_idx] = storage[hr - 1, 2, storage_idx] - discharge @@ -346,6 +354,30 @@ def dispatch_engine( # noqa: C901 return redispatch, storage, system_level, starts +@njit +def dynamic_reserve( + hr: int, + reserve: np.ndarray, + net_load: np.ndarray, +) -> np.ndarray: + """Adjust storage reserve based on 24hr net load. + + Args: + hr: hour index + reserve: storage reserve defaults + net_load: net load profile + + Returns: adjusted reserves + """ + projected = net_load[hr + 1 : hr + 24] + if len(projected) == 0: + return reserve + projected_increase = max(0.0, np.max(projected) / net_load[hr] - 1) + return np.where( + reserve == 0.0, np.around(1 - np.exp(-1.5 * projected_increase), 2), reserve + ) + + @njit def adjust_for_storage_reserve( state_of_charge: np.ndarray, @@ -376,6 +408,9 @@ def adjust_for_storage_reserve( ) if augment_deficit < 0.0: return -augment_deficit + + # reserve cannot exceed one + _reserve = np.minimum(1.0, 2.0 * reserve) # but if we don't need to restore the reserve, we want to check if we have # excess reserve and adjust down the provisional deficit return -sum( @@ -383,7 +418,7 @@ def adjust_for_storage_reserve( mw, # keep SOC above 2x typical reserve np.maximum( - state_of_charge - 2.0 * reserve * max_state_of_charge, + state_of_charge - _reserve * max_state_of_charge, 0.0, ), ) diff --git a/src/dispatch/model.py b/src/dispatch/model.py index 54fc989d..ee6bf2c5 100644 --- a/src/dispatch/model.py +++ b/src/dispatch/model.py @@ -4,14 +4,10 @@ import logging import warnings from datetime import datetime -from typing import TYPE_CHECKING import numpy as np import pandas as pd -if TYPE_CHECKING: - from collections.abc import Callable - try: import plotly.express as px from plotly.graph_objects import Figure @@ -132,8 +128,10 @@ def __init__( - duration_hrs: storage duration in hours. - roundtrip_eff: roundtrip efficiency. - operating_date: datetime unit starts operating. - - reserve: % of state of charge to hold in reserve until - after dispatchable startup. + - reserve: [Optional] % of state of charge to hold in reserve until + after dispatchable startup. If this is not provided or the reserve + is 0.0, the reserve will be set dynamically each hour looking + out 24 hours. The index must be a :class:`pandas.MultiIndex` of ``['plant_id_eia', 'generator_id']``. @@ -386,9 +384,9 @@ def __init__( 2 2020-01-01 500.0 0.0 0.0 ... NaN NaN NaN 2 1 2020-01-01 600.0 0.0 0.0 ... NaN NaN NaN 5 1 2020-01-01 500.0 NaN NaN ... NaN NaN NaN - es 2020-01-01 250.0 NaN NaN ... 4.0 0.9 0.2 + es 2020-01-01 250.0 NaN NaN ... 4.0 0.9 0.0 6 1 2020-01-01 500.0 NaN NaN ... NaN NaN NaN - 7 1 2020-01-01 200.0 NaN NaN ... 12.0 0.5 0.2 + 7 1 2020-01-01 200.0 NaN NaN ... 12.0 0.5 0.0 [9 rows x 30 columns] """ @@ -529,14 +527,14 @@ def _add_total_and_missing_cols(self, df: pd.DataFrame) -> pd.DataFrame: @staticmethod def _add_optional_cols(df: pd.DataFrame, df_name) -> pd.DataFrame: - """Add ``exclude`` column if not already present.""" + """Add optional column if not already present.""" default_values = { "dispatchable_specs": ( ("min_uptime", 0), ("exclude", False), ("no_limit", False), ), - "storage_specs": (("reserve", 0.2),), + "storage_specs": (("reserve", 0.0),), } return df.assign( **{col: value for col, value in default_values[df_name] if col not in df} @@ -630,11 +628,6 @@ def from_fresh( jit=jit, ) - @property - def dispatch_func(self) -> Callable: - """Appropriate dispatch engine depending on ``jit`` setting.""" - return dispatch_engine if self._metadata["jit"] else dispatch_engine.py_func - @property def is_redispatch(self) -> bool: """Determine if this is a redispatch. @@ -693,7 +686,9 @@ def __call__(self) -> DispatchModel: self.dispatchable_specs.loc[no_limit, "capacity_mw"].to_numpy(), ) - fos_prof, storage, deficits, starts = self.dispatch_func( + func = dispatch_engine if self._metadata["jit"] else dispatch_engine.py_func + + fos_prof, storage, deficits, starts = func( net_load=self.net_load_profile.to_numpy(dtype=np.float_), hr_to_cost_idx=( self.net_load_profile.index.year diff --git a/tests/engine_test.py b/tests/engine_test.py index a14fc04c..f816061c 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -9,6 +9,7 @@ charge_storage, discharge_storage, dispatch_engine, + dynamic_reserve, make_rank_arrays, validate_inputs, ) @@ -37,9 +38,10 @@ CAP = [500, 400, 300] -def test_engine(): +@pytest.mark.parametrize("py_func", [True, False], ids=idfn) +def test_engine(py_func): """Trivial test for the dispatch engine.""" - redispatch, es, sl, st = dispatch_engine.py_func( + in_dict = dict( # noqa: C408 net_load=np.array(NL), hr_to_cost_idx=np.zeros(len(NL), dtype=int), historical_dispatch=np.array([CAP] * len(NL)), @@ -54,6 +56,10 @@ def test_engine(): storage_dc_charge=np.zeros((len(NL), 2)), storage_reserve=np.array([0.1, 0.1]), ) + if py_func: + redispatch, es, sl, st = dispatch_engine.py_func(**in_dict) + else: + redispatch, es, sl, st = dispatch_engine(**in_dict) assert np.all(redispatch.sum(axis=1) + es[:, 1, :].sum(axis=1) >= NL) @@ -141,6 +147,7 @@ def test_validate_inputs(override, expected): # fmt: off +@pytest.mark.parametrize("py_func", [True, False]) @pytest.mark.parametrize( ("kwargs", "expected"), [ @@ -219,11 +226,15 @@ def test_validate_inputs(override, expected): ], ids=idfn, ) -def test_charge_storage(kwargs, expected): +def test_charge_storage(py_func, kwargs, expected): """Test storage charging calculations.""" - assert charge_storage.py_func(**kwargs) == expected + if py_func: + assert charge_storage.py_func(**kwargs) == expected + else: + assert charge_storage(**kwargs) == expected +@pytest.mark.parametrize("py_func", [True, False]) @pytest.mark.parametrize( ("kwargs", "expected"), [ @@ -238,11 +249,15 @@ def test_charge_storage(kwargs, expected): ], ids=idfn, ) -def test_discharge_storage(kwargs, expected): +def test_discharge_storage(py_func, kwargs, expected): """Test storage discharging calculations.""" - assert discharge_storage.py_func(**kwargs) == expected + if py_func: + assert discharge_storage.py_func(**kwargs) == expected + else: + assert discharge_storage(**kwargs) == expected +@pytest.mark.parametrize("py_func", [True, False]) @pytest.mark.parametrize( ("kwargs", "expected"), [ @@ -269,13 +284,19 @@ def test_discharge_storage(kwargs, expected): ], ids=idfn, ) -def test_adjust_for_storage_reserve(kwargs, expected): +def test_adjust_for_storage_reserve(py_func, kwargs, expected): """Test adjustments for storage reserve calculations.""" - assert adjust_for_storage_reserve.py_func( - **{k: np.array(v, dtype=float) for k, v in kwargs.items()} - ) == expected + if py_func: + assert adjust_for_storage_reserve.py_func( + **{k: np.array(v, dtype=float) for k, v in kwargs.items()} + ) == expected + else: + assert adjust_for_storage_reserve( + **{k: np.array(v, dtype=float) for k, v in kwargs.items()} + ) == expected +@pytest.mark.parametrize("py_func", [True, False]) @pytest.mark.parametrize( ("kwargs", "expected"), [ @@ -308,9 +329,12 @@ def test_adjust_for_storage_reserve(kwargs, expected): ], ids=idfn, ) -def test_dispatch_generator(kwargs, expected): +def test_dispatch_generator(py_func, kwargs, expected): """Test the logic of the calculate_generator_output function.""" - assert calculate_generator_output.py_func(**kwargs) == expected + if py_func: + assert calculate_generator_output.py_func(**kwargs) == expected + else: + assert calculate_generator_output(**kwargs) == expected # fmt: on @@ -325,3 +349,34 @@ def test_make_rank_arrays(py_func): m, s = make_rank_arrays(m_cost, s_cost) assert np.all(m == np.array([[1, 0], [0, 1]])) assert np.all(s == np.array([[0, 0], [1, 1]])) + + +@pytest.mark.parametrize("py_func", [True, False]) +@pytest.mark.parametrize( + ("a", "b", "expected"), + [ + (12, 24, 0.95), + (12, 18, 0.89), + (12, 16, 0.86), + (12, 14, 0.83), + (12, 10, 0.71), + (12, 8, 0.63), + (12, 6, 0.53), + (12, 4, 0.39), + (12, 3, 0.31), + (12, 2, 0.22), + (12, 1, 0.12), + (12, 0, 0.0), + ], + ids=idfn, +) +def test_dynamic_reserve(py_func, a, b, expected): + """Test dynamic reserve.""" + net_load = a + b * np.sin(np.arange(0, 4, np.pi / 24)) + if py_func: + result = dynamic_reserve.py_func( + hr=0, reserve=np.array([0.0, 0.1]), net_load=net_load + ) + else: + result = dynamic_reserve(hr=0, reserve=np.array([0.0, 0.1]), net_load=net_load) + assert np.all(result == np.array([expected, 0.1])) diff --git a/tests/model_test.py b/tests/model_test.py index d1aecb5d..b40bc2de 100644 --- a/tests/model_test.py +++ b/tests/model_test.py @@ -41,10 +41,10 @@ def test_new_no_dates(fossil_profiles, re_profiles, fossil_specs, fossil_cost): @pytest.mark.parametrize( ("attr", "expected"), [ - ("redispatch", {"f": 449456776, "r": 383683946}), - ("storage_dispatch", {"f": 269892073, "r": 221453923}), - ("system_data", {"f": 112821177, "r": 95078855}), - ("starts", {"f": 77109, "r": 54628}), + ("redispatch", {"f": 456_721_046, "r": 388_909_588}), + ("storage_dispatch", {"f": 298_137_664, "r": 250_620_351}), + ("system_data", {"f": 121_399_436, "r": 99_388_747}), + ("starts", {"f": 72_932, "r": 54_035}), ], ids=idfn, ) @@ -411,10 +411,10 @@ def test_dispatchable_exclude( @pytest.mark.parametrize( ("gen", "col_set", "col", "expected"), [ - ((55380, "CTG1"), "redispatch_", "mwh", 25367968), - ((55380, "CTG1"), "redispatch_", "cost_fuel", 364137734), - ((55380, "CTG1"), "redispatch_", "cost_vom", 11065819), - ((55380, "CTG1"), "redispatch_", "cost_startup", 64935667), + ((55380, "CTG1"), "redispatch_", "mwh", 26250540), + ((55380, "CTG1"), "redispatch_", "cost_fuel", 376806368), + ((55380, "CTG1"), "redispatch_", "cost_vom", 11450807), + ((55380, "CTG1"), "redispatch_", "cost_startup", 65282580), ((55380, "CTG1"), "redispatch_", "cost_fom", 1689013.875), ((55380, "CTG1"), "historical_", "mwh", 1.0), ((55380, "CTG1"), "historical_", "cost_fuel", 1.0),