Skip to content

Commit 5ab22bb

Browse files
authored
Merge pull request #328 from EnergySystemsModellingLab/enforce_minimum_service_factor_limits
Enforce minimum service factor limits (take 2)
2 parents d331f46 + b444f68 commit 5ab22bb

5 files changed

Lines changed: 216 additions & 27 deletions

File tree

src/muse/constraints.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
11
r"""Investment constraints.
22
3-
Constraints on investements ensure that investements match some given criteria. For
3+
Constraints on investments ensure that investments match some given criteria. For
44
instance, the constraints could ensure that only so much of a new asset can be built
55
every year.
66
77
Functions to compute constraints should be registered via the decorator
88
:py:meth:`~muse.constraints.register_constraints`. This registration step makes it
99
possible for constraints to be declared in the TOML file.
1010
11-
Generally, LP solvers accept linear constraint defined as:
11+
Generally, LP solvers accept linear constraints defined as:
1212
1313
.. math::
1414
1515
A x \\leq b
1616
1717
with :math:`A` a matrix, :math:`x` the decision variables, and :math:`b` a vector.
1818
However, these quantities are dimensionless. They do no have timeslices, assets, or
19-
replacement technologies, or any other dimensions that users have set-up in their model.
20-
The crux is to translates from MUSE's data-structures to a consistent dimensionless
19+
replacement technologies, or any other dimensions that users have set up in their model.
20+
The crux is to translate from MUSE's data-structures to a consistent dimensionless
2121
format.
2222
2323
In MUSE, users can register constraints functions that return fully dimensional
@@ -44,8 +44,8 @@
4444
- Any dimension in :math:`A_c .* x_c` (:math:`A_p .* x_p`) that is also in :math:`b`
4545
defines diagonal entries into the left (right) submatrix of :math:`A`.
4646
- Any dimension in :math:`A_c .* x_c` (:math:`A_p .* x_b`) and missing from
47-
:math:`b` is reduce by summation over a row in the left (right) submatrix of
48-
:math:`A`. In other words, those dimension do become part of a standard tensor
47+
:math:`b` is reduced by summation over a row in the left (right) submatrix of
48+
:math:`A`. In other words, those dimensions become part of a standard tensor
4949
reduction or matrix multiplication.
5050
5151
There are two additional rules. However, they are likely to be the result of an
@@ -281,7 +281,7 @@ def max_capacity_expansion(
281281
:math:`y=y_1` is the year marking the end of the investment period.
282282
283283
Let :math:`\mathcal{A}^{i, r}_{t, \iota}(y)` be the current assets, before
284-
invesment, and let :math:`\Delta\mathcal{A}^{i,r}_t` be the future investements.
284+
investment, and let :math:`\Delta\mathcal{A}^{i,r}_t` be the future investments.
285285
The the constraint on agent :math:`i` are given as:
286286
287287
.. math::
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
ProcessName,RegionName,Time,Level,cap_par,cap_exp,fix_par,fix_exp,var_par,var_exp,MaxCapacityAddition,MaxCapacityGrowth,TotalCapacityLimit,TechnicalLife,UtilizationFactor,InterestRate,ScalingSize,Agent2,Type,Fuel,MinimumServiceFactor,Enduse
22
Unit,-,Year,-,MUS$2010/Mt,-,MUS$2010/Mt,-,MUS$2010/Mt,-,Mt,-,Mt,Years,-,-,-,Retrofit,-,-,-,-
33
procammonia_1,R1,2010,fixed,100,1,0.5,1,0,1,5,0.03,100,20,0.85,0.1,0.1,1,energy,fuel1,0.01,ammonia
4-
procammonia_1,R1,2050,fixed,100,1,0.5,1,0,1,5,0.03,100,20,0.85,0.1,0.1,1,energy,fuel1,0.9,ammonia
4+
procammonia_1,R1,2050,fixed,100,1,0.5,1,0,1,5,0.03,100,20,0.85,0.1,0.1,1,energy,fuel1,0.85,ammonia
55
procammonia_2,R1,2010,fixed,97.5,1,0.4875,1,0,1,5,0.03,100,20,0.85,0.1,0.1,1,energy,fuel2,0,ammonia
66
procammonia_2,R1,2050,fixed,97.5,1,0.4875,1,0,1,5,0.03,100,20,0.85,0.1,0.1,1,energy,fuel2,0,ammonia

src/muse/readers/csv.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,10 @@ def to_agent_share(name):
9898
data.columns.name = "technodata"
9999
data.index.name = "technology"
100100
data = data.drop(["process_name", "region_name", "time"], axis=1)
101-
102101
data = data.apply(to_numeric, axis=0)
103102

103+
check_utilization_and_minimum_service_factors(data, filename)
104+
104105
result = xr.Dataset.from_dataframe(data.sort_index())
105106
if "fuel" in result.variables:
106107
result["fuel"] = result.fuel.isel(region=0, year=0)
@@ -130,6 +131,7 @@ def to_agent_share(name):
130131

131132
if "year" in result.dims and len(result.year) == 1:
132133
result = result.isel(year=0, drop=True)
134+
133135
return result
134136

135137

@@ -145,7 +147,7 @@ def read_technodata_timeslices(filename: Union[str, Path]) -> xr.Dataset:
145147
data = csv[csv.technology != "Unit"]
146148

147149
data = data.apply(to_numeric)
148-
data = check_utilization_not_all_zero(data, filename)
150+
check_utilization_and_minimum_service_factors(data, filename)
149151

150152
ts = pd.MultiIndex.from_frame(
151153
data.drop(
@@ -269,7 +271,7 @@ def read_technologies(
269271
Arguments:
270272
technodata_path_or_sector: If `comm_out_path` and `comm_in_path` are not given,
271273
then this argument refers to the name of the sector. The three paths are
272-
then determined using standard locations and name. Specifically, thechnodata
274+
then determined using standard locations and name. Specifically, technodata
273275
looks for a "technodataSECTORNAME.csv" file in the standard location for
274276
that sector. However, if `comm_out_path` and `comm_in_path` are given, then
275277
this should be the path to the the technodata file.
@@ -920,18 +922,52 @@ def read_finite_resources(path: Union[str, Path]) -> xr.DataArray:
920922
return xr.Dataset.from_dataframe(data).to_array(dim="commodity")
921923

922924

923-
def check_utilization_not_all_zero(data, filename):
925+
def check_utilization_and_minimum_service_factors(data, filename):
924926
if "utilization_factor" not in data.columns:
925927
raise ValueError(
926928
f"""A technology needs to have a utilization factor defined for every
927929
timeslice. Please check file {filename}."""
928930
)
929931

932+
_check_utilization_not_all_zero(data, filename)
933+
_check_utilization_in_range(data, filename)
934+
935+
if "minimum_service_factor" in data.columns:
936+
_check_minimum_service_factors_in_range(data, filename)
937+
_check_utilization_not_below_minimum(data, filename)
938+
939+
940+
def _check_utilization_not_all_zero(data, filename):
930941
utilization_sum = data.groupby(["technology", "region", "year"]).sum()
931942

932943
if (utilization_sum.utilization_factor == 0).any():
933944
raise ValueError(
934945
f"""A technology can not have a utilization factor of 0 for every
935946
timeslice. Please check file {filename}."""
936947
)
937-
return data
948+
949+
950+
def _check_utilization_in_range(data, filename):
951+
utilization = data["utilization_factor"]
952+
if not np.all((0 <= utilization) & (utilization <= 1)):
953+
raise ValueError(
954+
f"""Utilization factor values must all be between 0 and 1 inclusive.
955+
Please check file {filename}."""
956+
)
957+
958+
959+
def _check_utilization_not_below_minimum(data, filename):
960+
if (data["utilization_factor"] < data["minimum_service_factor"]).any():
961+
raise ValueError(f"""Utilization factors must all be greater than or equal to
962+
their corresponding minimum service factors. Please check
963+
{filename}.""")
964+
965+
966+
def _check_minimum_service_factors_in_range(data, filename):
967+
min_service_factor = data["minimum_service_factor"]
968+
969+
if not np.all((0 <= min_service_factor) & (min_service_factor <= 1)):
970+
raise ValueError(
971+
f"""Minimum service factor values must all be between 0 and 1 inclusive.
972+
Please check file {filename}."""
973+
)

tests/test_minimum_service.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,39 @@
1+
from itertools import permutations
2+
from unittest.mock import patch
3+
4+
import numpy as np
15
from pytest import mark
26

37

48
def modify_minimum_service_factors(
5-
model_path, sector, process_name, minimum_service_factor
9+
model_path, sector, processes, minimum_service_factors
610
):
711
import pandas as pd
812

913
technodata_timeslices = pd.read_csv(
1014
model_path / "technodata" / sector / "TechnodataTimeslices.csv"
1115
)
1216

13-
technodata_timeslices.loc[
14-
technodata_timeslices["ProcessName"] == process_name[0], "MinimumServiceFactor"
15-
] = minimum_service_factor[0]
16-
17-
technodata_timeslices.loc[
18-
technodata_timeslices["ProcessName"] == process_name[1], "MinimumServiceFactor"
19-
] = minimum_service_factor[1]
17+
for process, minimum in zip(processes, minimum_service_factors):
18+
technodata_timeslices.loc[
19+
technodata_timeslices["ProcessName"] == process, "MinimumServiceFactor"
20+
] = minimum
2021

2122
return technodata_timeslices
2223

2324

24-
@mark.parametrize("process_name", [("gasCCGT", "windturbine")])
2525
@mark.parametrize(
26-
"minimum_service_factor", [([1, 2, 3, 4, 5, 6], [0] * 6), ([0], [1, 2, 3, 4, 5, 6])]
26+
"minimum_service_factors",
27+
permutations((np.linspace(0, 1, 6), [0] * 6)),
2728
)
28-
def test_minimum_service_factor(tmpdir, minimum_service_factor, process_name):
29+
@patch("muse.readers.csv.check_utilization_and_minimum_service_factors")
30+
def test_minimum_service_factor(check_mock, tmpdir, minimum_service_factors):
2931
import pandas as pd
3032
from muse import examples
3133
from muse.mca import MCA
3234

3335
sector = "power"
36+
processes = ("gasCCGT", "windturbine")
3437

3538
# Copy the model inputs to tmpdir
3639
model_path = examples.copy_model(
@@ -40,8 +43,8 @@ def test_minimum_service_factor(tmpdir, minimum_service_factor, process_name):
4043
technodata_timeslices = modify_minimum_service_factors(
4144
model_path=model_path,
4245
sector=sector,
43-
process_name=process_name,
44-
minimum_service_factor=minimum_service_factor,
46+
processes=processes,
47+
minimum_service_factors=minimum_service_factors,
4548
)
4649

4750
technodata_timeslices.to_csv(
@@ -50,10 +53,11 @@ def test_minimum_service_factor(tmpdir, minimum_service_factor, process_name):
5053

5154
with tmpdir.as_cwd():
5255
MCA.factory(model_path / "settings.toml").run()
56+
check_mock.assert_called()
5357

5458
supply_timeslice = pd.read_csv(tmpdir / "Results/MCAMetric_Supply.csv")
5559

56-
for process, service_factor in zip(process_name, minimum_service_factor):
60+
for process, service_factor in zip(processes, minimum_service_factors):
5761
for i, factor in enumerate(service_factor):
5862
assert (
5963
supply_timeslice[

tests/test_readers.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
from itertools import chain, permutations
12
from pathlib import Path
3+
from unittest.mock import patch
24

35
import toml
46
import xarray as xr
@@ -410,3 +412,150 @@ def test_read_trade_technodata(tmp_path):
410412
"max_capacity_growth",
411413
"total_capacity_limit",
412414
}
415+
416+
417+
def test_check_utilization_not_all_zero_success():
418+
import pandas as pd
419+
from muse.readers.csv import _check_utilization_not_all_zero
420+
421+
df = pd.DataFrame(
422+
{
423+
"utilization_factor": (0, 1, 1),
424+
"technology": ("gas", "gas", "solar"),
425+
"region": ("GB", "GB", "FR"),
426+
"year": (2010, 2010, 2011),
427+
}
428+
)
429+
_check_utilization_not_all_zero(df, "file.csv")
430+
431+
432+
def test_check_utilization_in_range_success():
433+
import pandas as pd
434+
from muse.readers.csv import _check_utilization_in_range
435+
436+
df = pd.DataFrame({"utilization_factor": (0, 1)})
437+
_check_utilization_in_range(df, "file.csv")
438+
439+
440+
@mark.parametrize(
441+
"values", chain.from_iterable(permutations((0, bad)) for bad in (-1, 2))
442+
)
443+
def test_check_utilization_in_range_fail(values):
444+
import pandas as pd
445+
from muse.readers.csv import _check_utilization_in_range
446+
447+
df = pd.DataFrame({"utilization_factor": values})
448+
with raises(ValueError):
449+
_check_utilization_in_range(df, "file.csv")
450+
451+
452+
def test_check_utilization_not_below_minimum_success():
453+
import pandas as pd
454+
from muse.readers.csv import _check_utilization_not_below_minimum
455+
456+
df = pd.DataFrame({"utilization_factor": (0, 1), "minimum_service_factor": (0, 0)})
457+
_check_utilization_not_below_minimum(df, "file.csv")
458+
459+
460+
def test_check_utilization_not_below_minimum_fail():
461+
import pandas as pd
462+
from muse.readers.csv import _check_utilization_not_below_minimum
463+
464+
df = pd.DataFrame(
465+
{"utilization_factor": (0, 1), "minimum_service_factor": (0.1, 0)}
466+
)
467+
with raises(ValueError):
468+
_check_utilization_not_below_minimum(df, "file.csv")
469+
470+
471+
def test_check_utilization_not_all_zero_fail_all_zero():
472+
import pandas as pd
473+
from muse.readers.csv import _check_utilization_not_all_zero
474+
475+
df = pd.DataFrame(
476+
{
477+
"utilization_factor": (0, 0, 1),
478+
"technology": ("gas", "gas", "solar"),
479+
"region": ("GB", "GB", "FR"),
480+
"year": (2010, 2010, 2011),
481+
}
482+
)
483+
484+
with raises(ValueError):
485+
_check_utilization_not_all_zero(df, "file.csv")
486+
487+
488+
def test_check_minimum_service_factors_in_range_success():
489+
import pandas as pd
490+
from muse.readers.csv import _check_minimum_service_factors_in_range
491+
492+
df = pd.DataFrame({"minimum_service_factor": (0, 1)})
493+
_check_minimum_service_factors_in_range(df, "file.csv")
494+
495+
496+
@mark.parametrize(
497+
"values", chain.from_iterable(permutations((0, bad)) for bad in (-1, 2))
498+
)
499+
def test_check_minimum_service_factors_in_range_fail(values):
500+
import pandas as pd
501+
from muse.readers.csv import _check_minimum_service_factors_in_range
502+
503+
df = pd.DataFrame({"minimum_service_factor": values})
504+
505+
with raises(ValueError):
506+
_check_minimum_service_factors_in_range(df, "file.csv")
507+
508+
509+
@patch("muse.readers.csv._check_utilization_in_range")
510+
@patch("muse.readers.csv._check_utilization_not_all_zero")
511+
@patch("muse.readers.csv._check_utilization_not_below_minimum")
512+
@patch("muse.readers.csv._check_minimum_service_factors_in_range")
513+
def test_check_utilization_and_minimum_service_factors(*mocks):
514+
import pandas as pd
515+
from muse.readers.csv import check_utilization_and_minimum_service_factors
516+
517+
df = pd.DataFrame(
518+
{"utilization_factor": (0, 0, 1), "minimum_service_factor": (0, 0, 0)}
519+
)
520+
check_utilization_and_minimum_service_factors(df, "file.csv")
521+
for mock in mocks:
522+
mock.assert_called_once_with(df, "file.csv")
523+
524+
525+
@patch("muse.readers.csv._check_utilization_in_range")
526+
@patch("muse.readers.csv._check_utilization_not_all_zero")
527+
@patch("muse.readers.csv._check_utilization_not_below_minimum")
528+
@patch("muse.readers.csv._check_minimum_service_factors_in_range")
529+
def test_check_utilization_and_minimum_service_factors_no_min(
530+
min_service_factor_mock, utilization_below_min_mock, *mocks
531+
):
532+
import pandas as pd
533+
from muse.readers.csv import check_utilization_and_minimum_service_factors
534+
535+
df = pd.DataFrame({"utilization_factor": (0, 0, 1)})
536+
check_utilization_and_minimum_service_factors(df, "file.csv")
537+
for mock in mocks:
538+
mock.assert_called_once_with(df, "file.csv")
539+
min_service_factor_mock.assert_not_called()
540+
utilization_below_min_mock.assert_not_called()
541+
542+
543+
@patch("muse.readers.csv._check_utilization_in_range")
544+
@patch("muse.readers.csv._check_utilization_not_all_zero")
545+
@patch("muse.readers.csv._check_utilization_not_below_minimum")
546+
@patch("muse.readers.csv._check_minimum_service_factors_in_range")
547+
def test_check_utilization_and_minimum_service_factors_fail_missing_utilization(*mocks):
548+
import pandas as pd
549+
from muse.readers.csv import check_utilization_and_minimum_service_factors
550+
551+
# NB: Required utilization_factor column is missing
552+
df = pd.DataFrame(
553+
{
554+
"technology": ("gas", "gas", "solar"),
555+
"region": ("GB", "GB", "FR"),
556+
"year": (2010, 2010, 2011),
557+
}
558+
)
559+
560+
with raises(ValueError):
561+
check_utilization_and_minimum_service_factors(df, "file.csv")

0 commit comments

Comments
 (0)