Skip to content
Merged
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
8 changes: 8 additions & 0 deletions docs/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ What's New?
and :func:`pandas.value_counts`.
* Remove local pytest and blackdoc hooks from pre-commit.
* Switch from black to ruff format for autoformatting.
* New storage features to represent more types of storage:

* Storage charge rate can be different from discharge rate, to use, provide
``charge_mw`` column in ``storage_specs``.
* ``charge_eff`` and ``discharge_eff`` will replace ``roundtrip_eff`` in
``storage_specs`` to enable finer-grained control of when losses occur.
Previously ``roundtrip_eff`` was effectively treated as the charge efficiency and
there were no discharge losses.

Bug Fixes
^^^^^^^^^
Expand Down
75 changes: 55 additions & 20 deletions src/dispatch/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ def dispatch_engine_auto(
dispatchable_marginal_cost: np.ndarray,
dispatchable_min_uptime: np.ndarray,
storage_mw: np.ndarray,
storage_charge_mw: np.ndarray,
storage_hrs: np.ndarray,
storage_eff: np.ndarray,
storage_charge_eff: np.ndarray,
storage_discharge_eff: np.ndarray,
storage_op_hour: np.ndarray,
storage_dc_charge: np.ndarray,
storage_reserve: np.ndarray,
Expand All @@ -49,9 +51,11 @@ def dispatch_engine_auto(
generator in $/MWh rows are generators and columns are years
dispatchable_min_uptime: minimum hrs a generator must operate before its
output can be reduced
storage_mw: max charge/discharge rate for storage in MW
storage_mw: max discharge rate for storage in MW
storage_charge_mw: max charge rate for storage in MW
storage_hrs: duration of storage
storage_eff: storage round-trip efficiency
storage_charge_eff: storage charge efficiency
storage_discharge_eff: storage discharge efficiency
storage_op_hour: first hour in which storage is available, i.e. the index of
the operating date
storage_dc_charge: an array whose columns match each storage facility, and a
Expand Down Expand Up @@ -86,8 +90,10 @@ def dispatch_engine_auto(
dispatchable_startup_cost,
dispatchable_marginal_cost,
storage_mw,
storage_charge_mw,
storage_hrs,
storage_eff,
storage_charge_eff,
storage_discharge_eff,
storage_dc_charge,
storage_reserve,
)
Expand All @@ -107,8 +113,10 @@ def dispatch_engine_auto(
dispatchable_marginal_cost=dispatchable_marginal_cost,
dispatchable_min_uptime=dispatchable_min_uptime,
storage_mw=storage_mw,
storage_charge_mw=storage_charge_mw,
storage_hrs=storage_hrs,
storage_eff=storage_eff,
storage_charge_eff=storage_charge_eff,
storage_discharge_eff=storage_discharge_eff,
storage_op_hour=storage_op_hour,
storage_dc_charge=storage_dc_charge,
storage_reserve=storage_reserve,
Expand All @@ -128,8 +136,10 @@ def dispatch_engine_auto(
dispatchable_marginal_cost=dispatchable_marginal_cost,
dispatchable_min_uptime=dispatchable_min_uptime,
storage_mw=storage_mw,
storage_charge_mw=storage_charge_mw,
storage_hrs=storage_hrs,
storage_eff=storage_eff,
storage_charge_eff=storage_charge_eff,
storage_discharge_eff=storage_discharge_eff,
storage_op_hour=storage_op_hour,
storage_dc_charge=storage_dc_charge,
storage_reserve=storage_reserve,
Expand Down Expand Up @@ -167,8 +177,10 @@ def dispatch_engine( # noqa: C901
dispatchable_marginal_cost: np.ndarray,
dispatchable_min_uptime: np.ndarray,
storage_mw: np.ndarray,
storage_charge_mw: np.ndarray,
storage_hrs: np.ndarray,
storage_eff: np.ndarray,
storage_charge_eff: np.ndarray,
storage_discharge_eff: np.ndarray,
storage_op_hour: np.ndarray,
storage_dc_charge: np.ndarray,
storage_reserve: np.ndarray,
Expand Down Expand Up @@ -198,9 +210,11 @@ def dispatch_engine( # noqa: C901
generator in $/MWh rows are generators and columns are years
dispatchable_min_uptime: minimum hrs a generator must operate before its
output can be reduced
storage_mw: max charge/discharge rate for storage in MW
storage_mw: max discharge rate for storage in MW
storage_charge_mw: max charge rate for storage in MW
storage_hrs: duration of storage
storage_eff: storage round-trip efficiency
storage_charge_eff: storage charge efficiency
storage_discharge_eff: storage discharge efficiency
storage_op_hour: first hour in which storage is available, i.e. the index of
the operating date
storage_dc_charge: an array whose columns match each storage facility, and a
Expand Down Expand Up @@ -285,9 +299,9 @@ def dispatch_engine( # noqa: C901
state_of_charge=storage_soc_max[storage_idx]
* storage_reserve_[storage_idx],
dc_charge=storage_dc_charge[hr, storage_idx],
mw=storage_mw[storage_idx],
mw=storage_charge_mw[storage_idx],
max_state_of_charge=storage_soc_max[storage_idx],
eff=storage_eff[storage_idx],
eff=storage_charge_eff[storage_idx],
)

# update the deficit if we are grid charging
Expand Down Expand Up @@ -362,9 +376,9 @@ def dispatch_engine( # noqa: C901
hr - 1, SOC_IDX, storage_idx
], # previous state_of_charge
dc_charge=storage_dc_charge[hr, storage_idx],
mw=storage_mw[storage_idx],
mw=storage_charge_mw[storage_idx],
max_state_of_charge=storage_soc_max[storage_idx],
eff=storage_eff[storage_idx],
eff=storage_charge_eff[storage_idx],
)
# alias grid_charge
grid_charge = storage[hr, GRIDCHARGE_IDX, storage_idx]
Expand Down Expand Up @@ -410,10 +424,15 @@ def dispatch_engine( # noqa: C901
mw=storage_mw[storage_idx],
max_state_of_charge=storage_soc_max[storage_idx],
reserve=storage_reserve_[storage_idx],
eff=storage_discharge_eff[storage_idx],
)
storage[hr, DISCHARGE_IDX, storage_idx] = discharge
storage[hr, SOC_IDX, storage_idx] = (
storage[hr - 1, SOC_IDX, storage_idx] - discharge
# with discharge efficiency taken into account, we can encounter floating
# point errors that cause discharge to be a tiny bit larger than soc
storage[hr, SOC_IDX, storage_idx] = max(
0.0,
storage[hr - 1, SOC_IDX, storage_idx]
- discharge / storage_discharge_eff[storage_idx],
)
deficit -= discharge

Expand Down Expand Up @@ -466,9 +485,16 @@ def dispatch_engine( # noqa: C901
mw=storage_mw[storage_idx] - storage[hr, DISCHARGE_IDX, storage_idx],
max_state_of_charge=storage_soc_max[storage_idx],
reserve=0.0,
eff=storage_discharge_eff[storage_idx],
)
storage[hr, DISCHARGE_IDX, storage_idx] += discharge
storage[hr, SOC_IDX, storage_idx] -= discharge
# with discharge efficiency taken into account, we can encounter floating
# point errors that cause discharge to be a tiny bit larger than soc
storage[hr, SOC_IDX, storage_idx] = max(
0.0,
storage[hr, SOC_IDX, storage_idx]
- discharge / storage_discharge_eff[storage_idx],
)
deficit -= discharge

if deficit == 0.0:
Expand Down Expand Up @@ -601,6 +627,7 @@ def discharge_storage(
mw: float,
max_state_of_charge: float,
reserve: float = 0.0,
eff: float = 1.0,
) -> float:
"""Calculations for discharging storage.

Expand All @@ -610,15 +637,19 @@ def discharge_storage(
mw: storage power capacity
max_state_of_charge: storage energy capacity
reserve: prevent discharge below this portion of ``max_state_of_charge``
eff: discharge efficiency of storage

Returns: amount of storage discharge
"""
return min(
out = min(
desired_mw,
mw,
# prevent discharge below reserve (or full SOC if reserve is 0.0)
max(0.0, state_of_charge - max_state_of_charge * reserve),
max(0.0, state_of_charge - max_state_of_charge * reserve) * eff,
)
if out / eff > state_of_charge and not np.isclose(out / eff, state_of_charge):
raise ValueError("discharge / eff > state of charge")
return out


@njit(cache=True)
Expand Down Expand Up @@ -767,16 +798,20 @@ def validate_inputs(
startup_cost,
marginal_cost,
storage_mw,
storage_charge_mw,
storage_hrs,
storage_eff,
storage_charge_eff,
storage_discharge_eff,
storage_dc_charge,
storage_reserve,
) -> None:
"""Validate shape of inputs."""
if not (
len(storage_mw)
== len(storage_charge_mw)
== len(storage_hrs)
== len(storage_eff)
== len(storage_charge_eff)
== len(storage_discharge_eff)
== len(storage_reserve)
== storage_dc_charge.shape[1]
):
Expand Down
27 changes: 26 additions & 1 deletion src/dispatch/metadata.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Metadata and :mod:`pandera` stuff."""
import logging
import warnings
from typing import Any

import pandas as pd
Expand Down Expand Up @@ -65,13 +66,22 @@ def __init__(self, obj: Any, gen_set: pd.Index, re_set: pd.Index):
columns={
"capacity_mw": pa.Column(float, pa.Check.greater_than_or_equal_to(0)),
"duration_hrs": pa.Column(int, pa.Check.greater_than_or_equal_to(0)),
"roundtrip_eff": pa.Column(float, pa.Check.in_range(0, 1)),
"roundtrip_eff": pa.Column(
float, pa.Check.in_range(0, 1), nullable=False, required=False
),
"operating_date": pa.Column(
pa.Timestamp,
pa.Check.less_than(self.load_profile.index.max()),
description="operating_date in storage_specs",
),
"reserve": pa.Column(pa.Float, nullable=False, required=False),
"charge_mw": pa.Column(pa.Float, nullable=False, required=False),
"charge_eff": pa.Column(
pa.Float, pa.Check.in_range(0, 1), nullable=False, required=False
),
"discharge_eff": pa.Column(
pa.Float, pa.Check.in_range(0, 1), nullable=False, required=False
),
},
# strict=True,
coerce=True,
Expand Down Expand Up @@ -224,6 +234,21 @@ def dispatchable_cost(self, dispatchable_cost: pd.DataFrame) -> pd.DataFrame:

def storage_specs(self, storage_specs: pd.DataFrame) -> pd.DataFrame:
"""Validate storage_specs."""
if "roundtrip_eff" in storage_specs:
warnings.warn(
"use `charge_eff` and `discharge_eff` instead of `roundtrip_eff`, if "
"using `roundtrip_eff`, it is treated as `charge_eff` and "
"`discharge_eff` is set to 1.0",
DeprecationWarning,
stacklevel=2,
)
elif "charge_eff" not in storage_specs or "discharge_eff" not in storage_specs:
raise AssertionError(
"both `charge_eff` and `discharge_eff` are required, to replicate "
"previous behavior, set `charge_eff` as the roundtrip efficiency and "
"`discharge_eff` to 1.0"
)

out = self.storage_specs_schema.validate(storage_specs)
check_dup_ids = out.assign(
id_count=lambda x: x.groupby("plant_id_eia").capacity_mw.transform("count")
Expand Down
50 changes: 36 additions & 14 deletions src/dispatch/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@ def __init__(
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.
- charge_mw: [Optional] a peak charge rate that is different than
``capacity_mw``, which remains the discharge rate. If not provided,
this will be set to equal ``capacity_mw``.
- charge_eff: [Optional] when not provided, assumed to be the square
root of roundtrip_eff
- discharge_eff: [Optional] when not provided, assumed to be the
square root of roundtrip_eff

The index must be a :class:`pandas.MultiIndex` of
``['plant_id_eia', 'generator_id']``.
Expand Down Expand Up @@ -390,19 +397,19 @@ def __init__(
Generate a full, combined output of all resources at specified frequency.

>>> dm.full_output(freq="YS").round(1) # doctest: +NORMALIZE_WHITESPACE
capacity_mw historical_mwh historical_mmbtu ... duration_hrs roundtrip_eff reserve
capacity_mw historical_mwh historical_mmbtu ... charge_eff discharge_eff reserve
plant_id_eia generator_id datetime ...
0 curtailment 2020-01-01 NaN NaN NaN ... NaN NaN NaN
deficit 2020-01-01 NaN NaN NaN ... NaN NaN NaN
1 1 2020-01-01 350.0 0.0 0.0 ... NaN NaN NaN
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.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.0
0 curtailment 2020-01-01 NaN NaN NaN ... NaN NaN NaN
deficit 2020-01-01 NaN NaN NaN ... NaN NaN NaN
1 1 2020-01-01 350.0 0.0 0.0 ... NaN NaN NaN
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 ... 0.9 1.0 0.0
6 1 2020-01-01 500.0 NaN NaN ... NaN NaN NaN
7 1 2020-01-01 200.0 NaN NaN ... 0.5 1.0 0.0
<BLANKLINE>
[9 rows x 34 columns]
[9 rows x 37 columns]
"""
if not name and "balancing_authority_code_eia" in dispatchable_specs:
name = dispatchable_specs.balancing_authority_code_eia.mode().iloc[0]
Expand Down Expand Up @@ -441,8 +448,10 @@ def __init__(
self.dispatchable_cost: pd.DataFrame = validator.dispatchable_cost(
dispatchable_cost
).pipe(self._add_total_and_missing_cols)
self.storage_specs: pd.DataFrame = validator.storage_specs(storage_specs).pipe(
self._add_optional_cols, df_name="storage_specs"
self.storage_specs: pd.DataFrame = (
validator.storage_specs(storage_specs)
.pipe(self._upgrade_storage_specs)
.pipe(self._add_optional_cols, df_name="storage_specs")
)
self.dispatchable_profiles: pd.DataFrame = (
zero_profiles_outside_operating_dates(
Expand Down Expand Up @@ -578,6 +587,15 @@ def _add_optional_cols(df: pd.DataFrame, df_name) -> pd.DataFrame:
**{col: value for col, value in default_values[df_name] if col not in df}
)

def _upgrade_storage_specs(self, df: pd.DataFrame) -> pd.DataFrame:
if "charge_mw" not in df:
df = df.assign(charge_mw=lambda x: x.capacity_mw)
if all(
("charge_eff" not in df, "discharge_eff" not in df, "roundtrip_eff" in df)
):
df = df.assign(charge_eff=lambda x: x.roundtrip_eff, discharge_eff=1.0)
return df

def __setstate__(self, state: tuple[Any, dict]):
_, state = state
for k, v in state.items():
Expand Down Expand Up @@ -784,8 +802,12 @@ def __call__(self, **kwargs) -> DispatchModel:
dtype=np.int_
),
storage_mw=self.storage_specs.capacity_mw.to_numpy(dtype=np.float_),
storage_charge_mw=self.storage_specs.charge_mw.to_numpy(dtype=np.float_),
storage_hrs=self.storage_specs.duration_hrs.to_numpy(dtype=np.int64),
storage_eff=self.storage_specs.roundtrip_eff.to_numpy(dtype=np.float_),
storage_charge_eff=self.storage_specs.charge_eff.to_numpy(dtype=np.float_),
storage_discharge_eff=self.storage_specs.discharge_eff.to_numpy(
dtype=np.float_
),
# determine the index of the first hour that each storage resource could operate
storage_op_hour=np.argmax(
pd.concat(
Expand Down
Loading