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

Add .tools.sankey and tutorial #770

Merged
merged 30 commits into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4fd993f
init sankey
mabudz Jan 7, 2024
ef6d36e
Apply formatting and fix import from pyam
glatterf42 Jan 10, 2024
98f3932
Move sankey_mapper to own file
glatterf42 Mar 14, 2024
20c2cd9
Add plotly as optional dependency
glatterf42 Mar 14, 2024
dec4ecf
Update tutorial
glatterf42 Mar 14, 2024
4ba7194
Remove forgotten temp output
glatterf42 Mar 14, 2024
f71e013
Add test for sankey_mapper
glatterf42 Mar 14, 2024
072009d
init sankey
mabudz Jan 7, 2024
08cd18c
Apply formatting and fix import from pyam
glatterf42 Jan 10, 2024
5cfe1a6
Move sankey_mapper to own file
glatterf42 Mar 14, 2024
9e1b18d
Extract sankey functionality from init and update the westeros_sankey…
daymontas1 Jun 5, 2024
05ea18c
Extract sankey functionality from init and update the westeros_sankey…
daymontas1 Jun 5, 2024
42c40e2
Extract sankey functionality from init and update the westeros_sankey…
daymontas1 Jun 5, 2024
4310654
Clean up report/sankey after rebase
glatterf42 Sep 23, 2024
59d151f
Clean up westeros_sankey after rebase
glatterf42 Sep 23, 2024
2895862
Restore mysteriously deleted line
glatterf42 Sep 24, 2024
23d9bb1
Allow adding sankey-computations as Reporter. function
glatterf42 Sep 24, 2024
6f3794f
Test reporter.add_sankey
glatterf42 Sep 24, 2024
705a49f
Refactor map_for_sankey and corresponding test
glatterf42 Sep 24, 2024
f90a84a
Clean up tutorial
glatterf42 Sep 24, 2024
e975ada
Add new tutorial to test suite
glatterf42 Sep 24, 2024
ecfbe94
Exclude submodules of pyam from mypy, too
glatterf42 Sep 24, 2024
7feb1d9
Fix LiteralString import for old Python versions
glatterf42 Sep 24, 2024
9c0e422
Fix List type hint for Python 3.8
glatterf42 Sep 24, 2024
0d0e7ee
Fix Dict type hint for Python 3.8
glatterf42 Sep 24, 2024
d589925
Add PR to release notes
glatterf42 Sep 24, 2024
915aa86
Mention new tutorial in docs
glatterf42 Sep 24, 2024
7392929
Add new functionality to docs
glatterf42 Sep 24, 2024
be42839
Fix typo in docs
glatterf42 Sep 24, 2024
9926003
Address review comments
khaeru Jan 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions RELEASE_NOTES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ All changes

- :mod:`message_ix` is tested and compatible with `Python 3.13 <https://www.python.org/downloads/release/python-3130/>`__ (:pull:`881`).
- Support for Python 3.8 is dropped (:pull:`881`), as it has reached end-of-life.
- Add functionality to create Sankey diagrams from :class:`.Reporter` together with a new tutorial showcase (:pull:`770`).
- Add option to :func:`.util.copy_model` from a non-default location of model files (:pull:`877`).

.. _v3.9.0:
Expand Down
2 changes: 2 additions & 0 deletions doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,8 @@ Utility methods
.. automodule:: message_ix.util
:members: expand_dims, copy_model, make_df

.. automodule:: message_ix.util.sankey
:members: map_for_sankey

Testing utilities
-----------------
Expand Down
1 change: 1 addition & 0 deletions doc/reporting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ These automatic contents are prepared using:
.. autosummary::
add
add_queue
add_sankey
add_single
apply
check_keys
Expand Down
21 changes: 21 additions & 0 deletions message_ix/report/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,24 @@ def add_tasks(self, fail_action: Union[int, str] = "raise") -> None:

# Use a queue pattern via Reporter.add_queue()
self.add_queue(get_tasks(), fail=fail_action)

def add_sankey(self, fail_action: Union[int, str] = "raise") -> None:
"""Add the calculations required to produce Sankey plots.

Parameters
----------
fail_action : "raise" or int
:mod:`logging` level or level name, passed to the `fail` argument of
:meth:`.Reporter.add_queue`.
"""
# NOTE This includes just one task for the base version, but could later be
# expanded.
self.add_queue(
[
(
("message::sankey", "concat", "out::pyam", "in::pyam"),
dict(strict=True),
)
],
fail=fail_action,
)
17 changes: 17 additions & 0 deletions message_ix/tests/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,3 +289,20 @@ def add_tm(df, name="Activity"):
# Results have the expected units
assert all(df5["unit"] == "centiUSD / case")
assert_series_equal(df4["value"], df5["value"] / 100.0)


def test_reporter_add_sankey(test_mp, request):
scen = make_westeros(
test_mp, emissions=True, solve=True, quiet=True, request=request
)

# Reporter.from_scenario can handle Westeros example model
rep = Reporter.from_scenario(scen)

# Westeros-specific configuration: '-' is a reserved character in pint
configure(units={"replace": {"-": ""}})

# Add Sankey calculation(s)
rep.add_sankey()

assert rep.check_keys("message::sankey")
1 change: 1 addition & 0 deletions message_ix/tests/test_tutorials.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def _t(group: Union[str, None], basename: str, *, check=None, marks=None):
_t("w0", f"{W}_addon_technologies"),
_t("w0", f"{W}_historical_new_capacity"),
_t("w0", f"{W}_multinode_energy_trade"),
_t("w0", f"{W}_sankey"),
# NB this is the same value as in test_reporter()
_t(None, f"{W}_report", check=[("len-rep-graph", 13724)]),
_t("at0", "austria", check=[("solve-objective-value", 206321.90625)]),
Expand Down
45 changes: 45 additions & 0 deletions message_ix/tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import pytest

from message_ix import Scenario, make_df
from message_ix.report import Reporter
from message_ix.testing import make_dantzig, make_westeros
from message_ix.util.sankey import map_for_sankey


def test_make_df():
Expand Down Expand Up @@ -59,3 +61,46 @@ def test_testing_make_scenario(test_mp, request):
# Westeros model can be created
scen = make_westeros(test_mp, solve=True, request=request)
assert isinstance(scen, Scenario)


def test_map_for_sankey(test_mp, request):
# NB: we actually only need a pyam.IamDataFrame that has the same form as the result
# of these setup steps, so maybe this can be simplified
scen = make_westeros(test_mp, solve=True, request=request)
rep = Reporter.from_scenario(scen)
rep.configure(units={"replace": {"-": ""}})
rep.add_sankey()
df = rep.get("message::sankey")

# Set expectations
expected_all = {
"in|final|electricity|bulb|standard": ("final|electricity", "bulb|standard"),
"in|secondary|electricity|grid|standard": (
"secondary|electricity",
"grid|standard",
),
"out|final|electricity|grid|standard": ("grid|standard", "final|electricity"),
"out|secondary|electricity|coal_ppl|standard": (
"coal_ppl|standard",
"secondary|electricity",
),
"out|secondary|electricity|wind_ppl|standard": (
"wind_ppl|standard",
"secondary|electricity",
),
"out|useful|light|bulb|standard": ("bulb|standard", "useful|light"),
}
expected_without_final_electricity = {
key: value
for (key, value) in expected_all.items()
if "final|electricity" not in value
}

# Load all variables
mapping_all = map_for_sankey(df, year=700, region="Westeros")
assert mapping_all == expected_all

mapping_without_final_electricity = map_for_sankey(
df, year=700, region="Westeros", exclude=["final|electricity"]
)
assert mapping_without_final_electricity == expected_without_final_electricity
75 changes: 75 additions & 0 deletions message_ix/util/sankey.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from typing import Any, Dict, List, Optional, Tuple, Union

from pyam import IamDataFrame

try:
from pyam.str import get_variable_components
except ImportError: # Python < 3.10, pandas < 2.0
from pyam.utils import get_variable_components

Check warning on line 8 in message_ix/util/sankey.py

View check run for this annotation

Codecov / codecov/patch

message_ix/util/sankey.py#L7-L8

Added lines #L7 - L8 were not covered by tests

try:
from typing import LiteralString
except ImportError: # Python < 3.11
from typing_extensions import LiteralString

Check warning on line 13 in message_ix/util/sankey.py

View check run for this annotation

Codecov / codecov/patch

message_ix/util/sankey.py#L12-L13

Added lines #L12 - L13 were not covered by tests


def map_for_sankey(
iam_df: IamDataFrame,
year: int,
region: str,
exclude: List[Optional[str]] = [],
) -> Dict[str, Tuple[Union[List, Any, LiteralString], Union[List, Any, LiteralString]]]:
"""Maps input to output flows to enable Sankey plots.

Parameters
----------
iam_df: :class:`pyam.IamDataframe`
The IAMC-format DataFrame holding the data to plot as Sankey diagrams.
year: int
The year to display in the Sankey diagram.
region: str
The region to display in the Sankey diagram.
exclude: list[str], optional
If provided, exclude these keys from the Sankey diagram. Defaults to an empty
list, i.e. showing all flows.

Returns
-------
mapping: dict
A mapping from variable names to their inputs and outputs.
"""
return {
var: get_source_and_target(var)
for var in iam_df.filter(region=region + "*", year=year).variable
if not exclude_flow(get_source_and_target(var), exclude)
}


def get_source_and_target(
variable: str,
) -> Tuple[Union[List, Any, LiteralString], Union[List, Any, LiteralString]]:
"""Get source and target for the `variable` flow."""
start_idx, end_idx = set_start_and_end_index(variable)
return (
get_variable_components(variable, start_idx, join=True),
get_variable_components(variable, end_idx, join=True),
)


def set_start_and_end_index(variable: str) -> Tuple[List[int], List[int]]:
"""Get indices of source and target in variable name."""
return (
([1, 2], [3, 4])
if get_variable_components(variable, 0) == "in"
else ([3, 4], [1, 2])
)


def exclude_flow(
flow: Tuple[Union[List, Any, LiteralString], Union[List, Any, LiteralString]],
exclude: List[Optional[str]],
) -> bool:
"""Exclude sources or targets of variable flow if requested."""
if flow[0] in exclude or flow[1] in exclude:
return True
return False
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ docs = [
"sphinx_rtd_theme",
"sphinxcontrib-bibtex",
]
tutorial = ["jupyter", "matplotlib", "message_ix[report]"]
tutorial = ["jupyter", "matplotlib", "message_ix[report]", "plotly"]
report = ["ixmp[report]"]
tests = [
"asyncssh",
Expand Down Expand Up @@ -96,7 +96,7 @@ local_partial_types = true
[[tool.mypy.overrides]]
# Packages/modules for which no type hints are available.
module = [
"pyam",
"pyam.*",
"scipy.*",
# Indirectly via ixmp; this should be a subset of the list in ixmp's pyproject.toml
"jpype",
Expand Down
4 changes: 4 additions & 0 deletions tutorial/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ framework, such as used in global research applications of |MESSAGEix|.
module to ‘report’ results, e.g. do post-processing, plotting, and other
calculations (:tut:`westeros/westeros_report.ipynb`).

#. After familiarizing yourself with ‘reporting’, learn how to quickly assess
variable flows by plotting Sankey diagrams
(:tut:`westeros/westeros_sankey.ipynb`).

#. Build the baseline scenario using data stored in Excel files to
populate sets and parameters:

Expand Down
Loading
Loading