From d14ccd0e928db77d12fdfc8d29937ce29a47334c Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Mon, 13 Jan 2025 14:08:54 +0100 Subject: [PATCH 1/4] Add .tools.res_marg - Copied from message_data `ssp_dev` branch - File message_data/scenario_generation/reserve_margin/res_marg.py - Last modified by commit f3efc8b104044676434695aa461d26a7b20e5cd7: Author: FRICKO Oliver Date: Wed Nov 27 13:48:26 2024 +0100 Update reserve_margin script to remove print statement - message_single_country `SSP_Dev_2023` branch contains an identical file. --- message_ix_models/tools/res_marg.py | 127 ++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 message_ix_models/tools/res_marg.py diff --git a/message_ix_models/tools/res_marg.py b/message_ix_models/tools/res_marg.py new file mode 100644 index 0000000000..e9678ffa21 --- /dev/null +++ b/message_ix_models/tools/res_marg.py @@ -0,0 +1,127 @@ +import argparse + + +def main(scen, contin=0.2): + """Updates the reserve margin. + For a given scenario, regional reserve margin (=peak load factor) values + are updated based on the electricity demand in the industry and res/comm + sector. + This is based on the approach described in Johnsonn et al. (2017): + DOI: https://doi.org/10.1016/j.eneco.2016.07.010 + (see section 2.2.1. Firm capacity requirement) + + Parameters + ---------- + scen : :class:`message_ix.Scenario` + scenario to which changes should be applied + contin : float + Backup capacity for contingency reasons as percentage of peak capacity + (default 20%) + """ + + demands = scen.par("demand") + demands = ( + demands[demands.commodity.isin(["i_spec", "rc_spec"])] + .set_index(["node", "commodity", "year", "level", "time", "unit"]) + .sort_index() + ) + input_eff = ( + scen.par("input", {"technology": ["elec_t_d"]}) + .set_index( + [ + "node_loc", + "year_act", + "year_vtg", + "commodity", + "level", + "mode", + "node_origin", + "technology", + "time", + "time_origin", + "unit", + ] + ) + .sort_index() + ) + + with scen.transact("Update reserve-margin constraint"): + for reg in demands.index.get_level_values("node").unique(): + if "_GLB" in reg: + continue + for year in demands.index.get_level_values("year").unique(): + rc_spec = float( + demands.loc[reg, "rc_spec", year, "useful", "year"].iloc[0].value + ) + i_spec = float( + demands.loc[reg, "i_spec", year, "useful", "year"].iloc[0].value + ) + inp = float( + input_eff.loc[ + reg, + year, + year, + "electr", + "secondary", + "M1", + reg, + "elec_t_d", + "year", + "year", + ] + .iloc[0] + .value + ) + val = ( + ((i_spec * 1.0 + rc_spec * 2.0) / (i_spec + rc_spec)) + * (1.0 + contin) + * inp + * -1.0 + ) + scen.add_par( + "relation_activity", + { + "relation": ["res_marg"], + "node_rel": [reg], + "year_rel": [year], + "node_loc": [reg], + "technology": ["elec_t_d"], + "year_act": [year], + "mode": ["M1"], + "value": [val], + "unit": ["GWa"], + }, + ) + + +if __name__ == "__main__": + descr = """ + Reserve margin calculation + + Example usage: + python res_marg.py --version [X] [model name] [scenario name] + + """ + parser = argparse.ArgumentParser( + description=descr, formatter_class=argparse.RawDescriptionHelpFormatter + ) + version = "--version : string\n ix-scenario name" + parser.add_argument("--version", help=version) + model = "model : string\n ix-model name" + parser.add_argument("model", help=model) + scenario = "scenario : string\n ix-scenario name" + parser.add_argument("scenario", help=scenario) + + # parse cli + args = parser.parse_args() + model = args.model + scenario = args.scenario + version = int(args.version) if args.version else None + + import ixmp + import message_ix + + mp = ixmp.Platform() + scen = message_ix.Scenario(mp, model, scenario, version=version, cache=True) + + main(scen) From bc1e3eef046b9e0f00209e44f854265e5c67dcc3 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Mon, 13 Jan 2025 14:35:18 +0100 Subject: [PATCH 2/4] Minimal changes for #277 - Format docstring according to code style, rewrap. - Add type hints. - Ensure the module appears in the docs. - Add (incomplete) tests for all parts of the code. --- doc/api/tools.rst | 14 +++++--- .../tests/tools/test_res_marg.py | 15 +++++++++ message_ix_models/tools/res_marg.py | 32 +++++++++++-------- 3 files changed, 43 insertions(+), 18 deletions(-) create mode 100644 message_ix_models/tests/tools/test_res_marg.py diff --git a/doc/api/tools.rst b/doc/api/tools.rst index 7ddbda28ec..edb5b8be87 100644 --- a/doc/api/tools.rst +++ b/doc/api/tools.rst @@ -6,21 +6,25 @@ General purpose modeling tools (:mod:`.tools`) - Codes for retrieving data from specific data sources and adapting it for use with :mod:`message_ix_models`. - Codes for modifying scenarios; although tools for building models should go in :mod:`message_ix_models.model`. +.. currentmodule:: message_ix_models.tools + On other pages: - :doc:`tools-costs` +.. autosummary:: + :toctree: _autosummary + :template: autosummary-module.rst + :recursive: + + res_marg + On this page: .. contents:: :local: :backlinks: none -.. currentmodule:: message_ix_models.tools - -.. automodule:: message_ix_models.tools - :members: - .. currentmodule:: message_ix_models.tools.exo_data Exogenous data (:mod:`.tools.exo_data`) diff --git a/message_ix_models/tests/tools/test_res_marg.py b/message_ix_models/tests/tools/test_res_marg.py new file mode 100644 index 0000000000..44eeec7a73 --- /dev/null +++ b/message_ix_models/tests/tools/test_res_marg.py @@ -0,0 +1,15 @@ +import pytest + +from message_ix_models.tools.res_marg import main + + +@pytest.mark.xfail(reason="Incomplete test") +def test_cli() -> None: + # TODO Complete + assert False + + +@pytest.mark.xfail(reason="Incomplete test") +def test_main() -> None: + # TODO Complete + main() diff --git a/message_ix_models/tools/res_marg.py b/message_ix_models/tools/res_marg.py index e9678ffa21..ae6f279712 100644 --- a/message_ix_models/tools/res_marg.py +++ b/message_ix_models/tools/res_marg.py @@ -1,24 +1,30 @@ +"""Update the reserve margin.""" + import argparse +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from message_ix import Scenario + +def main(scen: "Scenario", contin: float = 0.2) -> None: + """Update the reserve margin. + + For a given scenario, regional reserve margin (=peak load factor) values are updated + based on the electricity demand in the industry and res/comm sector. -def main(scen, contin=0.2): - """Updates the reserve margin. - For a given scenario, regional reserve margin (=peak load factor) values - are updated based on the electricity demand in the industry and res/comm - sector. This is based on the approach described in Johnsonn et al. (2017): - DOI: https://doi.org/10.1016/j.eneco.2016.07.010 - (see section 2.2.1. Firm capacity requirement) + DOI: https://doi.org/10.1016/j.eneco.2016.07.010 (see section 2.2.1. Firm capacity + requirement) Parameters ---------- - scen : :class:`message_ix.Scenario` - scenario to which changes should be applied - contin : float - Backup capacity for contingency reasons as percentage of peak capacity - (default 20%) + scen : + Scenario to which changes should be applied. + contin : + Backup capacity for contingency reasons as percentage of peak capacity (default + 20%). """ - demands = scen.par("demand") demands = ( demands[demands.commodity.isin(["i_spec", "rc_spec"])] From cca6f184498253a5c2739fd767af3fcb9e9fa2cd Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Mon, 13 Jan 2025 14:55:05 +0100 Subject: [PATCH 3/4] Expand tests of .res_marg --- .../tests/tools/test_res_marg.py | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/message_ix_models/tests/tools/test_res_marg.py b/message_ix_models/tests/tools/test_res_marg.py index 44eeec7a73..2594eef334 100644 --- a/message_ix_models/tests/tools/test_res_marg.py +++ b/message_ix_models/tests/tools/test_res_marg.py @@ -1,15 +1,30 @@ +from subprocess import CalledProcessError, check_call + import pytest from message_ix_models.tools.res_marg import main -@pytest.mark.xfail(reason="Incomplete test") def test_cli() -> None: - # TODO Complete - assert False + """Run :func:`.res_marg.main` via its command-line interface.""" + command = [ + "python", + "-m", + "message_ix_models.tools.res_marg", + "--version=123", + "model_name", + "scenario_name", + ] + + # Fails: the model name, scenario name, and version do not exit + with pytest.raises(CalledProcessError): + check_call(command) + +@pytest.mark.xfail(reason="Function does not run on the snapshot") +def test_main(loaded_snapshot) -> None: + """Run :func:`.res_marg.main` on the snapshot scenarios.""" + scen = loaded_snapshot -@pytest.mark.xfail(reason="Incomplete test") -def test_main() -> None: - # TODO Complete - main() + # Function runs + main(scen, None) From c07aaff0a1f631caf0a93d71d44c40d6e1c20664 Mon Sep 17 00:00:00 2001 From: Paul Natsuo Kishimoto Date: Mon, 13 Jan 2025 15:02:27 +0100 Subject: [PATCH 4/4] Provide res-marg CLI via .cli and associated tools - Integrate with existing `mix-models` CLI. - Remove duplicated code for model/scenario/version arguments, loading platform and scenario, etc. - Simplify tests. --- message_ix_models/cli.py | 1 + .../tests/tools/test_res_marg.py | 18 +++---- message_ix_models/tools/res_marg.py | 47 ++++++------------- 3 files changed, 22 insertions(+), 44 deletions(-) diff --git a/message_ix_models/cli.py b/message_ix_models/cli.py index 46db15a455..2b898992e1 100644 --- a/message_ix_models/cli.py +++ b/message_ix_models/cli.py @@ -168,6 +168,7 @@ def _log_threads(k: int, n: int): "message_ix_models.report.cli", "message_ix_models.model.material.cli", "message_ix_models.testing.cli", + "message_ix_models.tools.res_marg", "message_ix_models.util.pooch", "message_ix_models.util.slurm", ] diff --git a/message_ix_models/tests/tools/test_res_marg.py b/message_ix_models/tests/tools/test_res_marg.py index 2594eef334..b04dc81c41 100644 --- a/message_ix_models/tests/tools/test_res_marg.py +++ b/message_ix_models/tests/tools/test_res_marg.py @@ -1,24 +1,20 @@ -from subprocess import CalledProcessError, check_call - import pytest from message_ix_models.tools.res_marg import main -def test_cli() -> None: +def test_cli(mix_models_cli) -> None: """Run :func:`.res_marg.main` via its command-line interface.""" command = [ - "python", - "-m", - "message_ix_models.tools.res_marg", + "--model=model_name", + "--scenario=scenario_name", "--version=123", - "model_name", - "scenario_name", + "res-marg", ] - # Fails: the model name, scenario name, and version do not exit - with pytest.raises(CalledProcessError): - check_call(command) + # Fails: the model name, scenario name, and version do not exist + with pytest.raises(RuntimeError): + mix_models_cli.assert_exit_0(command) @pytest.mark.xfail(reason="Function does not run on the snapshot") diff --git a/message_ix_models/tools/res_marg.py b/message_ix_models/tools/res_marg.py index ae6f279712..085740de7a 100644 --- a/message_ix_models/tools/res_marg.py +++ b/message_ix_models/tools/res_marg.py @@ -1,11 +1,18 @@ -"""Update the reserve margin.""" +"""Update the reserve margin. + +:func:`main` can also be invoked using the CLI command +:program:`mix-models --url=… res-marg`. +""" -import argparse from typing import TYPE_CHECKING +import click + if TYPE_CHECKING: from message_ix import Scenario + from message_ix_models import Context + def main(scen: "Scenario", contin: float = 0.2) -> None: """Update the reserve margin. @@ -100,34 +107,8 @@ def main(scen: "Scenario", contin: float = 0.2) -> None: ) -if __name__ == "__main__": - descr = """ - Reserve margin calculation - - Example usage: - python res_marg.py --version [X] [model name] [scenario name] - - """ - parser = argparse.ArgumentParser( - description=descr, formatter_class=argparse.RawDescriptionHelpFormatter - ) - version = "--version : string\n ix-scenario name" - parser.add_argument("--version", help=version) - model = "model : string\n ix-model name" - parser.add_argument("model", help=model) - scenario = "scenario : string\n ix-scenario name" - parser.add_argument("scenario", help=scenario) - - # parse cli - args = parser.parse_args() - model = args.model - scenario = args.scenario - version = int(args.version) if args.version else None - - import ixmp - import message_ix - - mp = ixmp.Platform() - scen = message_ix.Scenario(mp, model, scenario, version=version, cache=True) - - main(scen) +@click.command("res-marg") +@click.pass_obj +def cli(ctx: "Context"): + """Reserve margin calculation.""" + main(ctx.get_scenario())