Skip to content
Merged

Dev #124

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

---------------------------------------------------------------------------------------
Expand Down
43 changes: 39 additions & 4 deletions src/dispatch/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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],
Expand All @@ -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,
),
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -376,14 +408,17 @@ 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(
np.minimum(
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,
),
)
Expand Down
27 changes: 11 additions & 16 deletions src/dispatch/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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']``.
Expand Down Expand Up @@ -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
<BLANKLINE>
[9 rows x 30 columns]
"""
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
79 changes: 67 additions & 12 deletions tests/engine_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
charge_storage,
discharge_storage,
dispatch_engine,
dynamic_reserve,
make_rank_arrays,
validate_inputs,
)
Expand Down Expand Up @@ -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)),
Expand All @@ -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)


Expand Down Expand Up @@ -141,6 +147,7 @@ def test_validate_inputs(override, expected):


# fmt: off
@pytest.mark.parametrize("py_func", [True, False])
@pytest.mark.parametrize(
("kwargs", "expected"),
[
Expand Down Expand Up @@ -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"),
[
Expand All @@ -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"),
[
Expand All @@ -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"),
[
Expand Down Expand Up @@ -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


Expand All @@ -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]))
16 changes: 8 additions & 8 deletions tests/model_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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),
Expand Down