Skip to content

Commit

Permalink
new storage features incl different charge rate and different charge/…
Browse files Browse the repository at this point in the history
…discharge efficiency
  • Loading branch information
arengel committed Nov 7, 2023
1 parent 375445b commit aa620e8
Show file tree
Hide file tree
Showing 6 changed files with 212 additions and 41 deletions.
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

0 comments on commit aa620e8

Please sign in to comment.