diff --git a/docs/release_notes.rst b/docs/release_notes.rst index 1a541ab6..bd91341a 100644 --- a/docs/release_notes.rst +++ b/docs/release_notes.rst @@ -57,6 +57,8 @@ What's New? there were no discharge losses. * Add support for Python 3.12. +* New :meth:`.DispatchModel.redispatch_lambda` and + :meth:`.DispatchModel.historical_lambda` to calculate hourly marginal costs. Bug Fixes ^^^^^^^^^ diff --git a/src/dispatch/model.py b/src/dispatch/model.py index 8fad0731..89058e2a 100644 --- a/src/dispatch/model.py +++ b/src/dispatch/model.py @@ -874,6 +874,30 @@ def _cost(self, profiles: pd.DataFrame) -> dict[str, pd.DataFrame]: ) return {"fuel": fuel_cost, "vom": vom_cost, "startup": start_cost, "fom": fom} + def redispatch_lambda(self) -> pd.DataFrame: + """Return hourly marginal cost (aka system lambda) of redispatch.""" + return ( + self.dispatchable_cost[["fuel_per_mwh", "vom_per_mwh"]] + .sum(axis=1) + .reset_index() + .pivot(index="datetime", columns=["plant_id_eia", "generator_id"]) + .droplevel(0, axis=1) + .reindex(index=self.load_profile.index, method="ffill") + * (self.redispatch > 0).astype(int) + ).max(axis=1) + + def historical_lambda(self) -> pd.DataFrame: + """Return hourly marginal cost (aka system lambda) of historic dispatch.""" + return ( + self.dispatchable_cost[["fuel_per_mwh", "vom_per_mwh"]] + .sum(axis=1) + .reset_index() + .pivot(index="datetime", columns=["plant_id_eia", "generator_id"]) + .droplevel(0, axis=1) + .reindex(index=self.load_profile.index, method="ffill") + * (self.dispatchable_profiles > 0).astype(int) + ).max(axis=1) + def grouper( self, df: pd.DataFrame | dict[str, pd.DataFrame], diff --git a/tests/data/bad_adj.zip b/tests/data/bad_adj.zip new file mode 100644 index 00000000..032a224a Binary files /dev/null and b/tests/data/bad_adj.zip differ diff --git a/tests/model_test.py b/tests/model_test.py index 801cb8e6..90be9f0d 100644 --- a/tests/model_test.py +++ b/tests/model_test.py @@ -188,6 +188,8 @@ def test_storage_summary(self, mini_dm): ("storage_capacity", {}, (), "notna"), ("hourly_data_check", {}, (), "notna"), ("dc_charge", {}, (), "notna"), + ("redispatch_lambda", {}, (), "notna"), + ("historical_lambda", {}, (), "notna"), pytest.param("full_output", {}, (), "notna", marks=pytest.mark.xfail), ( "dispatchable_summary", @@ -220,6 +222,8 @@ def test_outputs_parametric(self, ent_dm, func, args, drop_cols, expected): """Test that outputs are not empty or do not have unexpected nans.""" ind, ent_dm = ent_dm df = getattr(ent_dm, func)(**args) + if isinstance(df, pd.Series): + df = df.to_frame(name=func) df = df[[c for c in df if c not in drop_cols]] if expected == "notna": assert df.notna().all().all() @@ -836,3 +840,14 @@ def test_file(ent_fresh): self.plot_year(2008) raise AssertionError + + +@pytest.mark.skip(reason="for debugging only") +def test_weird_adj(test_dir): + """Investigation of unexpected hourly load adjustment. + + Potential testing for fossil startup to charge storage. + """ + with DataZip(test_dir / "data/bad_adj.zip") as z: + dm = DispatchModel(**z["data"], jit=False) + dm()