Skip to content

Commit dfb5014

Browse files
committed
Fix tests and algo, update example notebook
1 parent 9e2b54e commit dfb5014

File tree

4 files changed

+106
-34
lines changed

4 files changed

+106
-34
lines changed

docs/examples/modes.py

+37
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,29 @@ def plot_capacity(results: xr.Dataset, **plotly_kwargs) -> go.Figure:
160160
return fig
161161

162162

163+
# %% [markdown]
164+
# ### Using different `spores` scoring algorithms.
165+
#
166+
# We make a number of scoring algorithms accessible out-of-the-box, based on those we present in [Lombardi et al. (2023)](https://doi.org/10.1016/j.apenergy.2023.121002).
167+
# You can call them on solving the model.
168+
# Here, we'll compare the result on `flow_cap` from running each.
169+
170+
# %%
171+
# We subset to the same time range as operate/plan mode
172+
model_spores = calliope.examples.national_scale(
173+
scenario="spores", time_subset=["2005-01-01", "2005-01-10"]
174+
)
175+
model_spores.build()
176+
177+
spores_results = []
178+
for algorithm in ["integer", "evolving_average", "random", "relative_deployment"]:
179+
model_spores.solve(**{"spores.scoring_algorithm": algorithm}, force=True)
180+
spores_results.append(model_spores.results.expand_dims(algorithm=[algorithm]))
181+
182+
spores_results_da = xr.concat(spores_results, dim="algorithm")
183+
184+
spores_results_da.flow_cap.to_series().dropna().unstack("spores")
185+
163186
# %% [markdown]
164187
# ## `plan` vs `operate`
165188
# Here, we compare flows over the 10 days.
@@ -188,3 +211,17 @@ def plot_capacity(results: xr.Dataset, **plotly_kwargs) -> go.Figure:
188211
# %%
189212
fig_flows_spores = plot_capacity(model_spores.results, facet_col="spores")
190213
fig_flows_spores.update_layout(title="SPORES mode capacities")
214+
215+
# %% [markdown]
216+
# ## Comparing `spores` scoring algorithms
217+
# Here, we compare installed capacities between the different SPORES runs.
218+
219+
# %%
220+
fig_flows_spores = plot_capacity(
221+
spores_results_da, facet_col="spores", facet_row="algorithm"
222+
)
223+
fig_flows_spores.update_layout(
224+
title="SPORES mode capacities using different scoring algorithms",
225+
autosize=False,
226+
height=800,
227+
)

src/calliope/model.py

+34-18
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from __future__ import annotations
66

77
import logging
8-
import random
98
from collections.abc import Callable
109
from pathlib import Path
1110
from typing import TYPE_CHECKING
@@ -612,6 +611,9 @@ def _solve_spores(self, solver_config: config_schema.Solve) -> xr.Dataset:
612611
# We store the results from each iteration in the `results_list` to later concatenate into a single dataset.
613612
results_list: list[xr.Dataset] = [baseline_results]
614613
spore_range = range(1, spores_config.number + 1)
614+
LOGGER.info(
615+
f"Optimisation model | Running SPORES with `{spores_config.scoring_algorithm}` scoring algorithm."
616+
)
615617
for spore in spore_range:
616618
LOGGER.info(f"Optimisation model | Running SPORE {spore}.")
617619
self._spores_update_model(baseline_results, results_list, spores_config)
@@ -639,8 +641,21 @@ def _spores_update_model(
639641
all_previous_results: list[xr.Dataset],
640642
spores_config: config_schema.SolveSpores,
641643
):
644+
"""Assign SPORES scores for the next iteration of the model run.
645+
646+
Algorithms applied are based on those introduced in <https://doi.org/10.1016/j.apenergy.2023.121002>.
647+
648+
Args:
649+
baseline_results (xr.Dataset): The initial results (before applying SPORES scoring)
650+
all_previous_results (list[xr.Dataset]):
651+
A list of all previous iterations.
652+
This includes the baseline results, which will be the first item in the list.
653+
spores_config (config_schema.SolveSpores):
654+
The SPORES configuration.
655+
"""
656+
642657
def _score_integer() -> xr.DataArray:
643-
# Look at capacity deployment in the previous iteration
658+
"""Integer scoring algorithm."""
644659
previous_cap = latest_results["flow_cap"].where(spores_techs)
645660

646661
# Make sure that penalties are applied only to non-negligible deployments of capacity
@@ -658,36 +673,33 @@ def _score_integer() -> xr.DataArray:
658673
return new_score
659674

660675
def _score_relative_deployment() -> xr.DataArray:
661-
# Look at capacity deployment in the previous iteration
676+
"""Relative deployment scoring algorithm."""
662677
previous_cap = latest_results["flow_cap"].where(spores_techs)
663-
664-
# Look at capacity deployment in the previous iteration
665678
relative_cap = previous_cap / self.inputs["flow_cap_max"].where(
666679
spores_techs
667680
)
668681

669-
# Make sure that penalties are applied only to non-negligible deployments of capacity
670-
min_relevant_size = spores_config.score_threshold_factor * relative_cap
671-
672682
new_score = (
673-
# Where capacity was deployed more than the minimal relevant size, assign the relative deployment as a penalty (score)
674-
relative_cap.where(previous_cap > min_relevant_size)
683+
# Make sure that penalties are applied only to non-negligible relative capacities
684+
relative_cap.where(relative_cap > spores_config.score_threshold_factor)
675685
.fillna(0)
676686
.where(spores_techs)
677687
)
678-
return new_score.to_pandas()
688+
return new_score
679689

680690
def _score_random() -> xr.DataArray:
691+
"""Random scoring algorithm."""
681692
previous_cap = latest_results["flow_cap"].where(spores_techs)
682693
new_score = (
683694
previous_cap.fillna(0)
684-
.where(previous_cap.isnull(), other=lambda x: random.random())
695+
.where(previous_cap.isnull(), other=np.random.rand(*previous_cap.shape))
685696
.where(spores_techs)
686697
)
687698

688699
return new_score
689700

690701
def _score_evolving_average() -> xr.DataArray:
702+
"""Evolving average scoring algorithm."""
691703
previous_cap = latest_results["flow_cap"]
692704
evolving_average = sum(
693705
results["flow_cap"] for results in all_previous_results
@@ -700,17 +712,21 @@ def _score_evolving_average() -> xr.DataArray:
700712
new_score = _score_integer()
701713
else:
702714
# If capacity is exactly the same as the average, we give the relative difference an arbitrarily small value
703-
relative_change = (
704-
relative_change.clip(min=0.001)
705-
.where(relative_change != np.inf, other=0)
706-
.where(spores_techs)
715+
# which will give it a _large_ score since we take the reciprocal of the change.
716+
cleaned_relative_change = (
717+
relative_change.clip(min=0.001).fillna(0).where(spores_techs)
718+
)
719+
# Any zero values that make their way through to the scoring are kept as zero after taking the reciprocal.
720+
new_score = (cleaned_relative_change**-1).where(
721+
cleaned_relative_change > 0, other=0
707722
)
708-
new_score = relative_change**-1
709723

710724
return new_score
711725

712726
latest_results = all_previous_results[-1]
713-
allowed_methods: dict[str, Callable[[], xr.DataArray]] = {
727+
allowed_methods: dict[
728+
config_schema.SPORES_SCORING_OPTIONS, Callable[[], xr.DataArray]
729+
] = {
714730
"integer": _score_integer,
715731
"relative_deployment": _score_relative_deployment,
716732
"random": _score_random,

src/calliope/schemas/config_schema.py

+9-4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414

1515
LOGGER = logging.getLogger(__name__)
1616

17+
SPORES_SCORING_OPTIONS = Literal[
18+
"integer", "relative_deployment", "random", "evolving_average"
19+
]
20+
1721

1822
class Init(CalliopeBaseModel):
1923
"""All configuration options used when initialising a Calliope model."""
@@ -131,10 +135,11 @@ class SolveSpores(CalliopeBaseModel):
131135

132136
model_config = {"title": "Model solve SPORES mode configuration"}
133137

134-
scoring_algorithm: Literal[
135-
"integer", "relative_deployment", "random", "evolving_average"
136-
] = "integer"
137-
"""Algorithm to apply to update the SPORES score between iterations."""
138+
scoring_algorithm: SPORES_SCORING_OPTIONS = "integer"
139+
"""
140+
Algorithm to apply to update the SPORES score between iterations.
141+
For more information on each option, see [Lombardi et al. (2023)](https://doi.org/10.1016/j.apenergy.2023.121002).
142+
"""
138143

139144
number: int = Field(default=3)
140145
"""SPORES mode number of iterations after the initial base run."""

tests/test_core_model.py

+26-12
Original file line numberDiff line numberDiff line change
@@ -237,12 +237,15 @@ def spores_model_save_per_spore_and_log(self, tmp_path_factory, request):
237237

238238
return model, log
239239

240-
@pytest.fixture(scope="class")
241-
def spores_model_with_tracker(self):
240+
@pytest.fixture(
241+
scope="class",
242+
params=["integer", "relative_deployment", "random", "evolving_average"],
243+
)
244+
def spores_model_with_tracker(self, request):
242245
"""Iterate 2 times in SPORES mode with a SPORES score tracking parameter."""
243246
model = build_model({}, "spores,spores_tech_tracking,investment_costs")
244247
model.build(mode="spores")
245-
model.solve()
248+
model.solve(**{"spores.scoring_algorithm": request.param})
246249

247250
return model
248251

@@ -264,36 +267,47 @@ def test_spores_mode_success(self, spores_model_and_log_algorithms):
264267
spores_model, _ = spores_model_and_log_algorithms
265268
assert spores_model.results.attrs["termination_condition"] == "optimal"
266269

267-
def test_spores_mode_3_results(self, spores_model_and_log):
270+
def test_spores_mode_3_results(self, spores_model_and_log_algorithms):
268271
"""Solving in spores mode should lead to 3 sets of results."""
269-
spores_model, _ = spores_model_and_log
272+
spores_model, _ = spores_model_and_log_algorithms
270273
assert not set(spores_model.results.spores.values).symmetric_difference(
271274
["baseline", 1, 2]
272275
)
273276

274-
def test_spores_scores(self, spores_model_and_log):
277+
def test_spores_scores(self, spores_model_and_log_algorithms):
275278
"""All techs should have a spores score defined."""
276-
spores_model, _ = spores_model_and_log
279+
spores_model, _ = spores_model_and_log_algorithms
277280
fill_gaps = ~spores_model._model_data.definition_matrix
278281
assert (
279282
spores_model._model_data.spores_score_cumulative.notnull() | fill_gaps
280283
).all()
281284

282-
def test_spores_caps(self, spores_model_and_log):
285+
def test_spores_caps(self, spores_model_and_log_algorithms):
283286
"""There should be some changes in capacities between SPORES."""
284-
spores_model, _ = spores_model_and_log
287+
spores_model, _ = spores_model_and_log_algorithms
285288
cap_diffs = spores_model.results.flow_cap.diff(dim="spores")
286289
assert (cap_diffs != 0).any()
287290

288-
def test_spores_scores_never_decrease(self, spores_model_and_log):
289-
"""SPORES scores can never decrease."""
291+
def test_spores_algo_log(self, spores_model_and_log_algorithms):
292+
"""The scoring algorithm being used should be logged correctly."""
293+
model, log = spores_model_and_log_algorithms
294+
assert (
295+
f"Running SPORES with `{model.config.solve.spores.scoring_algorithm}` scoring algorithm."
296+
in log
297+
)
298+
299+
def test_spores_scores_never_decrease_integer_algo(self, spores_model_and_log):
300+
"""SPORES scores can never decrease.
301+
302+
This is not true for all algorithms (e.g. random scoring) so we test with integer scoring.
303+
"""
290304
spores_model, _ = spores_model_and_log
291305
assert (
292306
spores_model._model_data.spores_score_cumulative.fillna(0).diff("spores")
293307
>= 0
294308
).all()
295309

296-
def test_spores_scores_increasing_with_cap(self, spores_model_and_log):
310+
def test_spores_scores_increasing_with_cap_integer_algo(self, spores_model_and_log):
297311
"""SPORES scores increase when a tech has a finite flow_cap in the previous iteration."""
298312
spores_model, _ = spores_model_and_log
299313
has_cap = spores_model.results.flow_cap > 0

0 commit comments

Comments
 (0)