Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce SPORES in v0.7.0 as a generalisable mode #716

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
11 changes: 1 addition & 10 deletions docs/advanced/mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,6 @@ For this reason, `horizon` must always be equal to or larger than `window`.

## SPORES mode

!!! warning
SPORES mode has not yet been re-implemented in Calliope v0.7.

`SPORES` refers to Spatially-explicit Practically Optimal REsultS.
This run mode allows a user to generate any number of alternative results which are within a certain range of the optimal cost.
It follows on from previous work in the field of `modelling to generate alternatives` (MGA), with a particular emphasis on alternatives that vary maximally in the spatial dimension.
Expand All @@ -80,15 +77,9 @@ config.build.mode: spores
config.solve:
# The number of SPORES to generate:
spores_number: 10
# The cost class to optimise against when generating SPORES:
spores_score_cost_class: spores_score
# The initial system cost to limit the SPORES to fit within:
spores_cost_max: .inf
# The cost class to constrain to be less than or equal to `spores_cost_max`:
spores_slack_cost_group: monetary
parameters:
# The fraction above the cost-optimal cost to set the maximum cost during SPORES:
slack: 0.1
spores_slack: 0.1
```

You will now also need a `spores_score` cost class in your model.
Expand Down
1 change: 0 additions & 1 deletion docs/examples/loading_tabular_data.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# ---
# jupyter:
# jupytext:
# custom_cell_magics: kql
# text_representation:
# extension: .py
# format_name: percent
Expand Down
190 changes: 190 additions & 0 deletions docs/examples/modes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# ---
# jupyter:
# jupytext:
# text_representation:
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.16.4
# kernelspec:
# display_name: calliope_docs_build
# language: python
# name: calliope_docs_build
# ---

# %% [markdown]
# # Running models in different modes
#
# Models can be built and solved in different modes:

# - `plan` mode.
# In `plan` mode, the user defines upper and lower boundaries for technology capacities and the model decides on an optimal system configuration.
# In this configuration, the total cost of investing in technologies and then using them to meet demand in every _timestep_ (e.g., every hour) is as low as possible.
# - `operate` mode.
# In `operate` mode, all capacity constraints are fixed and the system is operated with a receding horizon control algorithm.
# This is sometimes known as a `dispatch` model - we're only concerned with the _dispatch_ of technologies whose capacities are already fixed.
# Optimisation is limited to a time horizon which
# - `spores` mode.
# `SPORES` refers to Spatially-explicit Practically Optimal REsultS.
# This run mode allows a user to generate any number of alternative results which are within a certain range of the optimal cost.

# In this notebook we will run the Calliope national scale example model in these three modes.

# More detail on these modes is given in the [_advanced_ section of the Calliope documentation](https://calliope.readthedocs.io/en/latest/advanced/mode/).

# %%

import plotly.express as px
import plotly.graph_objects as go
import xarray as xr

import calliope

# We update logging to show a bit more information but to hide the solver output, which can be long.
calliope.set_log_verbosity("INFO", include_solver_output=False)

# %% [markdown]
# ## Running in `plan` mode.

# %%
# We subset to the same time range as operate mode
model_plan = calliope.examples.national_scale(time_subset=["2005-01-01", "2005-01-10"])
model_plan.build()
model_plan.solve()

# %% [markdown]
# ## Running in `operate` mode.

# %%
model_operate = calliope.examples.national_scale(scenario="operate")
model_operate.build()
model_operate.solve()

# %% [markdown]
# Note how we have capacity variables as parameters in the inputs and only dispatch variables in the results

# %%
model_operate.inputs[["flow_cap", "storage_cap", "area_use"]]

# %%
model_operate.results

# %% [markdown]
# ## Running in `spores` mode.

# %%
# We subset to the same time range as operate/plan mode
model_spores = calliope.examples.national_scale(
scenario="spores", time_subset=["2005-01-01", "2005-01-10"]
)
model_spores.build()
model_spores.solve()

# %% [markdown]
# Note how we have a new `spores` dimension in our results.

# %%
model_spores.results

# %% [markdown]
# We can track the SPORES scores used between iterations using the `spores_score_cumulative` result.
# This scoring mechanism is based on increasing the score of any technology-node combination where the

# %%
# We do some prettification of the outputs
model_spores.results.spores_score_cumulative.to_series().where(
lambda x: x > 0
).dropna().unstack("spores")

# %% [markdown]
# ## Visualising results
#
# We can use [plotly](https://plotly.com/) to quickly examine our results.
# These are just some examples of how to visualise Calliope data.

# %%
# We set the color mapping to use in all our plots by extracting the colors defined in the technology definitions of our model.
# We also create some reusable plotting functions.
colors = model_plan.inputs.color.to_series().to_dict()


def plot_flows(results: xr.Dataset) -> go.Figure:
df_electricity = (
(results.flow_out.fillna(0) - results.flow_in.fillna(0))
.sel(carriers="power")
.sum("nodes")
.to_series()
.where(lambda x: x != 0)
.dropna()
.to_frame("Flow in/out (kWh)")
.reset_index()
)
df_electricity_demand = df_electricity[df_electricity.techs == "demand_power"]
df_electricity_other = df_electricity[df_electricity.techs != "demand_power"]

fig = px.bar(
df_electricity_other,
x="timesteps",
y="Flow in/out (kWh)",
color="techs",
color_discrete_map=colors,
)
fig.add_scatter(
x=df_electricity_demand.timesteps,
y=-1 * df_electricity_demand["Flow in/out (kWh)"],
marker_color="black",
name="demand",
)
return fig


def plot_capacity(results: xr.Dataset, **plotly_kwargs) -> go.Figure:
df_capacity = (
results.flow_cap.where(results.techs != "demand_power")
.sel(carriers="power")
.to_series()
.where(lambda x: x != 0)
.dropna()
.to_frame("Flow capacity (kW)")
.reset_index()
)

fig = px.bar(
df_capacity,
x="nodes",
y="Flow capacity (kW)",
color="techs",
color_discrete_map=colors,
**plotly_kwargs,
)
return fig


# %% [markdown]
# ## `plan` vs `operate`
# Here, we compare flows over the 10 days.
# Note how flows do not match as the rolling horizon makes it difficult to make the correct storage charge/discharge decisions.

# %%
fig_flows_plan = plot_flows(
model_plan.results.sel(timesteps=model_operate.results.timesteps)
)
fig_flows_plan.update_layout(title="Plan mode flows")


# %%
fig_flows_operate = plot_flows(model_operate.results)
fig_flows_operate.update_layout(title="Operate mode flows")

# %% [markdown]
# ## `plan` vs `spores`
# Here, we compare installed capacities between the baseline run (== `plan` mode) and the SPORES.
# Note how the baseline SPORE is the same as `plan` mode and then results deviate considerably.

# %%
fig_flows_plan = plot_capacity(model_plan.results)
fig_flows_plan.update_layout(title="Plan mode capacities")

# %%
fig_flows_spores = plot_capacity(model_spores.results, facet_col="spores")
fig_flows_spores.update_layout(title="SPORES mode capacities")
2 changes: 1 addition & 1 deletion docs/examples/national_scale/notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@

# %% [markdown]
# #### Plotting flows
# We do this by combinging in- and out-flows and separating demand from other technologies.
# We do this by combining in- and out-flows and separating demand from other technologies.
# First, we look at the aggregated result across all nodes, then we look at each node separately.

# %%
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ nav:
- examples/milp/index.md
- examples/milp/notebook.py
- examples/loading_tabular_data.py
- examples/modes.py
- examples/piecewise_constraints.py
- examples/calliope_model_object.py
- examples/calliope_logging.py
Expand Down
11 changes: 11 additions & 0 deletions src/calliope/backend/backend_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ class BackendModelGenerator(ABC):
"default",
"type",
"title",
"sense",
"math_repr",
"original_dtype",
]
Expand All @@ -68,6 +69,8 @@ class BackendModelGenerator(ABC):
_PARAM_DESCRIPTIONS = extract_from_schema(MODEL_SCHEMA, "description")
_PARAM_UNITS = extract_from_schema(MODEL_SCHEMA, "x-unit")
_PARAM_TYPE = extract_from_schema(MODEL_SCHEMA, "x-type")
objective: str
"""Optimisation problem objective name."""

def __init__(self, inputs: xr.Dataset, math: CalliopeMath, **kwargs):
"""Abstract base class to build a representation of the optimisation problem.
Expand Down Expand Up @@ -173,6 +176,14 @@ def add_objective(
objective_dict (parsing.UnparsedObjective): Unparsed objective configuration dictionary.
"""

@abstractmethod
def set_objective(self, name: str) -> None:
"""Set a built objective to be the optimisation objective.

Args:
name (str): name of the objective.
"""

def log(
self,
component_type: ALL_COMPONENTS_T,
Expand Down
25 changes: 16 additions & 9 deletions src/calliope/backend/gurobi_backend_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@
class GurobiBackendModel(backend_model.BackendModel):
"""gurobipy-specific backend functionality."""

OBJECTIVE_SENSE_DICT = {
"minimize": gurobipy.GRB.MINIMIZE,
"minimise": gurobipy.GRB.MINIMIZE,
"maximize": gurobipy.GRB.MAXIMIZE,
"maximise": gurobipy.GRB.MAXIMIZE,
}

def __init__(self, inputs: xr.Dataset, math: CalliopeMath, **kwargs) -> None:
"""Gurobi solver interface class.

Expand Down Expand Up @@ -130,14 +137,7 @@ def _variable_setter(where: xr.DataArray, references: set):
def add_objective( # noqa: D102, override
self, name: str, objective_dict: parsing.UnparsedObjective
) -> None:
sense_dict = {
"minimize": gurobipy.GRB.MINIMIZE,
"minimise": gurobipy.GRB.MINIMIZE,
"maximize": gurobipy.GRB.MAXIMIZE,
"maximise": gurobipy.GRB.MAXIMIZE,
}

sense = sense_dict[objective_dict["sense"]]
sense = self.OBJECTIVE_SENSE_DICT[objective_dict["sense"]]

def _objective_setter(
element: parsing.ParsedBackendEquation, where: xr.DataArray, references: set
Expand All @@ -146,13 +146,20 @@ def _objective_setter(

if name == self.inputs.attrs["config"].build.objective:
self._instance.setObjective(expr.item(), sense=sense)

self.objective = name
self.log("objectives", name, "Objective activated.")

return xr.DataArray(expr)

self._add_component(name, objective_dict, _objective_setter, "objectives")

def set_objective(self, name: str) -> None: # noqa: D102, override
to_set = self.objectives[name]
sense = self.OBJECTIVE_SENSE_DICT[to_set.attrs["sense"]]
self._instance.setObjective(to_set.item(), sense=sense)
self.objective = name
self.log("objectives", name, "Objective activated.", level="info")

def get_parameter( # noqa: D102, override
self, name: str, as_backend_objs: bool = True
) -> xr.DataArray:
Expand Down
9 changes: 8 additions & 1 deletion src/calliope/backend/latex_backend_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,12 @@ def _objective_setter(
equations=equation_strings,
sense=sense_dict[objective_dict["sense"]],
)
if name == self.inputs.attrs["config"].build.objective:
self.objective = name

def set_objective(self, name: str): # noqa: D102, override
self.objective = name
self.log("objectives", name, "Objective activated.", level="info")

def _create_obj_list(
self, key: str, component_type: backend_model._COMPONENTS_T
Expand Down Expand Up @@ -534,7 +540,8 @@ def generate_math_doc(
"yaml_snippet": da.attrs.get("yaml_snippet", None),
}
for name, da in sorted(getattr(self, objtype).data_vars.items())
if "math_string" in da.attrs
if (objtype == "objectives" and name == self.objective)
or (objtype != "objectives" and "math_string" in da.attrs)
or (objtype == "parameters" and da.attrs["references"])
]
for objtype in [
Expand Down
Loading
Loading