From aa620e80b53f2997efe934673c9abc23617a6979 Mon Sep 17 00:00:00 2001 From: Alex Engel Date: Sat, 4 Nov 2023 15:08:03 -0600 Subject: [PATCH] new storage features incl different charge rate and different charge/discharge efficiency --- docs/release_notes.rst | 8 +++++ src/dispatch/engine.py | 75 +++++++++++++++++++++++++++++----------- src/dispatch/metadata.py | 27 ++++++++++++++- src/dispatch/model.py | 50 +++++++++++++++++++-------- tests/engine_test.py | 34 ++++++++++++++---- tests/model_test.py | 59 +++++++++++++++++++++++++++++++ 6 files changed, 212 insertions(+), 41 deletions(-) diff --git a/docs/release_notes.rst b/docs/release_notes.rst index 19d4b988..c7611ae7 100644 --- a/docs/release_notes.rst +++ b/docs/release_notes.rst @@ -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 ^^^^^^^^^ diff --git a/src/dispatch/engine.py b/src/dispatch/engine.py index 77804468..02572774 100644 --- a/src/dispatch/engine.py +++ b/src/dispatch/engine.py @@ -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, @@ -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 @@ -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, ) @@ -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, @@ -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, @@ -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, @@ -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 @@ -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 @@ -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] @@ -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 @@ -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: @@ -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. @@ -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) @@ -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] ): diff --git a/src/dispatch/metadata.py b/src/dispatch/metadata.py index 5ecf7191..f7cf0c74 100644 --- a/src/dispatch/metadata.py +++ b/src/dispatch/metadata.py @@ -1,5 +1,6 @@ """Metadata and :mod:`pandera` stuff.""" import logging +import warnings from typing import Any import pandas as pd @@ -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, @@ -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") diff --git a/src/dispatch/model.py b/src/dispatch/model.py index 1bc5fbc7..da93d08c 100644 --- a/src/dispatch/model.py +++ b/src/dispatch/model.py @@ -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']``. @@ -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 - [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] @@ -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( @@ -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(): @@ -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( diff --git a/tests/engine_test.py b/tests/engine_test.py index cda836e5..b33b5d58 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -53,8 +53,10 @@ def test_engine(py_func, marginal_for_startup_rank): dispatchable_marginal_cost=np.array([[10.0], [20.0], [50.0]]), dispatchable_min_uptime=np.zeros_like(CAP, dtype=np.int_), storage_mw=np.array([400, 200]), + storage_charge_mw=np.array([400, 200]), storage_hrs=np.array([4, 12]), - storage_eff=np.array((0.9, 0.9)), + storage_charge_eff=np.array((0.9, 0.9)), + storage_discharge_eff=np.array((1.0, 1.0)), storage_op_hour=np.array((0, 0)), storage_dc_charge=np.zeros((len(NL), 2)), storage_reserve=np.array([0.1, 0.1]), @@ -93,8 +95,10 @@ def test_dispatch_engine_auto(py_func, coeff, reserve, marginal_for_startup_rank dispatchable_marginal_cost=np.array([[10.0], [20.0], [50.0]], dtype=float), dispatchable_min_uptime=np.zeros_like(CAP, dtype=np.int_), storage_mw=np.array([400, 200], dtype=float), + storage_charge_mw=np.array([400, 200], dtype=float), storage_hrs=np.array([4, 12], dtype=float), - storage_eff=np.array((0.9, 0.9), dtype=float), + storage_charge_eff=np.array((0.9, 0.9), dtype=float), + storage_discharge_eff=np.array((1.0, 1.0), dtype=float), storage_op_hour=np.array((0, 0)), storage_dc_charge=np.zeros((len(NL), 2), dtype=float), storage_reserve=np.array([reserve, reserve], dtype=float), @@ -119,8 +123,10 @@ def test_engine_pyfunc_numba(): dispatchable_marginal_cost=np.array([[10.0], [20.0], [50.0]]), dispatchable_min_uptime=np.zeros_like(CAP, dtype=np.int_), storage_mw=np.array([400, 200]), + storage_charge_mw=np.array([400, 200]), storage_hrs=np.array([4, 12]), - storage_eff=np.array((0.9, 0.9)), + storage_charge_eff=np.array((0.9, 0.9)), + storage_discharge_eff=np.array((1.0, 1.0), dtype=float), storage_op_hour=np.array((0, 0)), storage_dc_charge=np.zeros((len(NL), 2)), storage_reserve=np.array([0.1, 0.1]), @@ -136,8 +142,10 @@ def test_engine_pyfunc_numba(): dispatchable_marginal_cost=np.array([[10.0], [20.0], [50.0]]), dispatchable_min_uptime=np.zeros_like(CAP, dtype=np.int_), storage_mw=np.array([400, 200]), + storage_charge_mw=np.array([400, 200]), storage_hrs=np.array([4, 12]), - storage_eff=np.array((0.9, 0.9)), + storage_charge_eff=np.array((0.9, 0.9)), + storage_discharge_eff=np.array((1.0, 1.0), dtype=float), storage_op_hour=np.array((0, 0)), storage_dc_charge=np.zeros((len(NL), 2)), storage_reserve=np.array([0.1, 0.1]), @@ -161,8 +169,10 @@ def test_engine_marginal_for_startup_rank(): dispatchable_marginal_cost=np.array([[10.0], [20.0], [50.0]]), dispatchable_min_uptime=np.zeros_like(CAP, dtype=np.int_), storage_mw=np.array([400, 200]), + storage_charge_mw=np.array([400, 200]), storage_hrs=np.array([4, 12]), - storage_eff=np.array((0.9, 0.9)), + storage_charge_eff=np.array((0.9, 0.9)), + storage_discharge_eff=np.array((1.0, 1.0), dtype=float), storage_op_hour=np.array((0, 0)), storage_dc_charge=np.zeros((len(NL), 2)), storage_reserve=np.array([0.1, 0.1]), @@ -209,8 +219,10 @@ def test_validate_inputs(override, expected): startup_cost=np.array([[1000.0], [1000.0], [100.0]]), marginal_cost=np.array([[10.0], [20.0], [50.0]]), storage_mw=np.array([400, 200]), + storage_charge_mw=np.array([400, 200]), storage_hrs=np.array([4, 12]), - storage_eff=np.array((0.9, 0.9)), + storage_charge_eff=np.array((0.9, 0.9)), + storage_discharge_eff=np.array((1.0, 1.0)), storage_dc_charge=np.zeros((len(NL), 2)), storage_reserve=np.array((0.0, 0.1)), ) @@ -320,6 +332,16 @@ def test_charge_storage(py_func, kwargs, expected): ({"desired_mw": 1, "state_of_charge": 5, "mw": 3, "max_state_of_charge": 6, "reserve": 0.5}, 1), ({"desired_mw": 5, "state_of_charge": 2, "mw": 3, "max_state_of_charge": 6, "reserve": 0.5}, 0), ({"desired_mw": 9, "state_of_charge": 5, "mw": 3, "max_state_of_charge": 6, "reserve": 0.5}, 2), + + ({"desired_mw": 1, "state_of_charge": 2, "mw": 3, "max_state_of_charge": 6, "eff": 0.5}, 1), + ({"desired_mw": 1, "state_of_charge": 5, "mw": 3, "max_state_of_charge": 6, "eff": 0.5}, 1), + ({"desired_mw": 5, "state_of_charge": 2, "mw": 3, "max_state_of_charge": 6, "eff": 0.5}, 1), + ({"desired_mw": 9, "state_of_charge": 5, "mw": 3, "max_state_of_charge": 6, "eff": 0.5}, 2.5), + + ({"desired_mw": 1, "state_of_charge": 2, "mw": 3, "max_state_of_charge": 6, "reserve": 0.5, "eff": 0.5}, 0), + ({"desired_mw": 1, "state_of_charge": 5, "mw": 3, "max_state_of_charge": 6, "reserve": 0.5, "eff": 0.5}, 1), + ({"desired_mw": 5, "state_of_charge": 2, "mw": 3, "max_state_of_charge": 6, "reserve": 0.5, "eff": 0.5}, 0), + ({"desired_mw": 9, "state_of_charge": 5, "mw": 3, "max_state_of_charge": 6, "reserve": 0.5, "eff": 0.5}, 1), ], ids=idfn, ) diff --git a/tests/model_test.py b/tests/model_test.py index a2c2458c..ab5b6950 100644 --- a/tests/model_test.py +++ b/tests/model_test.py @@ -538,6 +538,65 @@ def test_no_limit_late_operating_date(ent_redispatch): assert np.all(df["2031":] == 0.0) +def test_different_charge_rate(ent_redispatch): + """Test that storage having a higher charge rate increases peak charging.""" + dm0 = DispatchModel(**ent_redispatch)() + ent_redispatch["storage_specs"] = ent_redispatch["storage_specs"].assign( + charge_mw=lambda x: x.capacity_mw * 2 + ) + dm1 = DispatchModel(**ent_redispatch)() + + assert ( + dm1.storage_dispatch.loc[:, "charge"].max() + > dm0.storage_dispatch.loc[:, "charge"].max() + ).all() + + +def test_equivalent_efficiency(ent_redispatch): + """Test that storage that legacy storage efficiency behavior is consistent.""" + dm0 = DispatchModel(**ent_redispatch)() + ent_redispatch["storage_specs"] = ( + ent_redispatch["storage_specs"] + .rename(columns={"roundtrip_eff": "charge_eff"}) + .assign(discharge_eff=1.0) + ) + dm1 = DispatchModel(**ent_redispatch)() + pd.testing.assert_frame_equal(dm0.storage_dispatch, dm1.storage_dispatch) + + +def test_non_equivalent_efficiency(ent_redispatch): + """Test that splitting storage efficiency makes deficits worse.""" + dm0 = DispatchModel(**ent_redispatch)() + ent_redispatch["storage_specs"] = ( + ent_redispatch["storage_specs"] + .rename(columns={"roundtrip_eff": "charge_eff"}) + .assign( + charge_eff=lambda x: np.sqrt(x.charge_eff), + discharge_eff=lambda x: x.charge_eff, + ) + ) + dm1 = DispatchModel(**ent_redispatch)() + assert ( + ( + (dm1.system_level_summary() - dm0.system_level_summary())[ + ["deficit_mwh", "curtailment_mwh", "deficit_gt_2pct_count"] + ] + >= 0 + ) + .all() + .all() + ) + + +def test_bad_efficiency(ent_redispatch): + """Test that ``charge_eff`` alone raises error.""" + ent_redispatch["storage_specs"] = ent_redispatch["storage_specs"].rename( + columns={"roundtrip_eff": "charge_eff"} + ) + with pytest.raises(AssertionError): + _ = DispatchModel(**ent_redispatch) + + @pytest.mark.parametrize( ("re_ids", "expected"), [