import logging
from collections.abc import Callable
from contextlib import nullcontext
from copy import deepcopy
from functools import partial
from importlib import import_module
from operator import itemgetter
from pathlib import Path
from typing import Optional, Union
from warnings import warn
import genno.config
import yaml
from genno import Key, KeyExistsError
from genno.compat.pyam import iamc as handle_iamc
from message_ix import Reporter, Scenario
from message_ix_models import Context, ScenarioInfo
from message_ix_models.util import minimum_version
from message_ix_models.util._logging import mark_time, silence_log
from .config import Config
__all__ = [
"Config",
"prepare_reporter",
"register",
"report",
]
log = logging.getLogger(__name__)
# Ignore a section in global.yaml used to define YAML anchors
try:
# genno ≥ 1.25
genno.config.handles("_iamc formats", False, False)(genno.config.store)
except AttributeError:
# genno < 1.25
# TODO Remove once the minimum supported version in message-ix-models is ≥ 1.25
@genno.config.handles("_iamc formats")
def _(c: Reporter, info):
pass
#: List of callbacks for preparing the Reporter.
CALLBACKS: list[Callable] = []
@genno.config.handles("iamc")
def iamc(c: Reporter, info):
"""Handle one entry from the ``iamc:`` config section.
This version overrides the version from :mod:`genno.config` to:
- Set some defaults for the `rename` argument for :meth:`.convert_pyam`:
- The `n` and `nl` dimensions are mapped to the "region" IAMC column.
- The `y`, `ya`, and `yv` dimensions are mapped to the "year" column.
- Use the MESSAGEix-GLOBIOM custom :func:`.util.collapse` callback to perform
renaming etc. while collapsing dimensions to the IAMC ones. The "var" key from
the entry, if any, is passed to the `var` argument of that function.
- Provide optional partial sums. The "sums" key of the entry can give a list of
strings such as ``["x", "y", "x-y"]``; in this case, the conversion to IAMC format
is also applied to the same "base" key with a partial sum over the dimension "x";
over "y", and over both "x" and "y". The corresponding dimensions are omitted from
"var". All data are concatenated.
"""
# FIXME the upstream key "variable" for the configuration is confusing; choose a
# better name
from message_ix_models.report.util import collapse
# Common
base_key = Key(info["base"])
# Use message_ix_models custom collapse() method
info.setdefault("collapse", {})
# Add standard renames
info.setdefault("rename", {})
for dim, target in (
("n", "region"),
("nl", "region"),
("y", "year"),
("ya", "year"),
("yv", "year"),
):
info["rename"].setdefault(dim, target)
# Iterate over partial sums
# TODO move some or all of this logic upstream
keys = [] # Resulting keys
for dims in [""] + info.pop("sums", []):
# Dimensions to partial
# TODO allow iterable of str
dims = dims.split("-")
label = f"{info['variable']} {'-'.join(dims) or 'full'}"
# Modified copy of `info` for this invocation
_info = info.copy()
# Base key: use the partial sum over any `dims`. Use a distinct variable name.
_info.update(base=base_key.drop(*dims), variable=label)
# Exclude any summed dimensions from the IAMC Variable to be constructed
_info["collapse"].update(
callback=partial(
collapse, var=list(filter(lambda v: v not in dims, info.get("var", [])))
)
)
# Invoke the genno built-in handler
handle_iamc(c, _info)
keys.append(f"{label}::iamc")
# Concatenate together the multiple tables
c.add("concat", f"{info['variable']}::iamc", *keys)
[docs]def register(name_or_callback: Union[Callable, str]) -> Optional[str]:
"""Register a callback function for :meth:`prepare_reporter`.
Each registered function is called by :meth:`prepare_reporter`, in order to add or
modify reporting keys. Specific model variants and projects can register a callback
to extend the reporting graph.
Callback functions must take two arguments: the Reporter, and a :class:`.Context`:
.. code-block:: python
from message_ix.report import Reporter
from message_ix_models import Context
from message_ix_models.report import register
def cb(rep: Reporter, ctx: Context):
# Modify `rep` by calling its methods ...
pass
register(cb)
Parameters
----------
name_or_callback
If a string, this may be a submodule of :mod:`.message_ix_models`, or
:mod:`message_data`, in which case the function
``{message_data,message_ix_models}.{name}.report.callback`` is used. Or, it may
be a fully-resolved package/module name, in which case ``{name}.callback`` is
used. If a callable (function), it is used directly.
"""
if isinstance(name_or_callback, str):
# Resolve a string
candidates = [
# As a fully-resolved package/module name
name_or_callback,
# As a submodule of message_ix_models
f"message_ix_models.{name_or_callback}.report",
# As a submodule of message_data
f"message_data.{name_or_callback}.report",
]
mod = None
for name in candidates:
try:
mod = import_module(name)
except ModuleNotFoundError:
continue
else:
break
if mod is None:
raise ModuleNotFoundError(" or ".join(candidates))
callback = mod.callback
else:
callback = name_or_callback
name = callback.__name__
if callback in CALLBACKS:
log.info(f"Already registered: {callback}")
return None
CALLBACKS.append(callback)
return name
def log_before(context, rep, key) -> None:
log.info(f"Prepare to report {'(DRY RUN)' if context.dry_run else ''}")
log.info(key)
log.log(
logging.INFO
if (context.core.dry_run or context.core.verbose)
else logging.DEBUG,
"\n" + rep.describe(key),
)
mark_time()
[docs]def report(context: Context, *args, **kwargs):
"""Report (post-process) solution data in a :class:`.Scenario`.
This function provides a single, common interface to call both the :mod:`genno`
-based (:mod:`message_ix_models.report`) and ‘legacy’ (
:mod:`message_ix_models.report.legacy`) reporting codes.
Parameters
----------
context : Context
The code responds to:
- :attr:`.dry_run`: if :obj:`True`, reporting is prepared but nothing is done.
- :attr:`~.Config.scenario_info` and :attr:`~.Config.platform_info`: used to
retrieve the Scenario to be reported.
- :py:`context.report`, which is an instance of :class:`.report.Config`; see
there for available configuration settings.
"""
from message_ix_models.util.ixmp import discard_on_error
# Handle deprecated usage that appears in:
# - .model.cli.new_baseline()
# - .model.create.solve()
# - .projects.covid.scenario_runner.ScenarioRunner.solve()
if isinstance(context, Scenario):
warn(
"Calling report(scenario, path, legacy=…); pass a Context instead",
category=DeprecationWarning,
)
# Ensure `context` is actually a Context object for the following code
scenario = context
context = Context.get_instance(-1)
# Transfer args, kwargs to context
context.set_scenario(scenario)
context.report.legacy.update(kwargs.pop("legacy", {}))
if len(args) + len(set(kwargs.keys()) & {"path"}) != 1:
raise TypeError(
f"Unknown mix of deprecated positional {args!r} "
f"and keyword arguments {kwargs!r}"
)
elif len(args) == 1:
out_dir = args[0]
else:
out_dir = kwargs.pop("path")
context.report.legacy.setdefault("out_dir", out_dir)
if context.report.legacy["use"]:
return _invoke_legacy_reporting(context)
with (
nullcontext()
if context.core.verbose
else silence_log("genno message_ix_models")
):
rep, key = prepare_reporter(context)
log_before(context, rep, key)
if context.dry_run:
return
with discard_on_error(rep.graph["scenario"]):
result = rep.get(key)
# Display information about the result
log.info(f"Result:\n\n{result}\n")
log.info(
f"File output(s), if any, written under:\n{rep.graph['config']['output_dir']}"
)
def _invoke_legacy_reporting(context):
from .legacy import iamc_report_hackathon
log.info("Using .report.legacy.iamc_report_hackathon.report")
# Convert "legacy" config to kwargs for .legacy.iamc_report_hackathon.report()
kwargs = deepcopy(context.report.legacy)
kwargs.pop("use")
# Read a legacy reporting configuration file and update the arguments
config_file_path = kwargs.pop("config_file_path", None)
if isinstance(config_file_path, Path) and config_file_path.exists():
with open(config_file_path, "r") as f:
kwargs.update(yaml.safe_load(f))
# Retrieve the Scenario and Platform
scen = context.get_scenario()
mp = scen.platform
mark_time()
# `context` is passed only for the "dry_run" setting; the function receives all its
# other settings via the `kwargs`
return iamc_report_hackathon.report(mp=mp, scen=scen, context=context, **kwargs)
[docs]@minimum_version("message_ix 3.6")
def prepare_reporter(
context: Context,
scenario: Optional[Scenario] = None,
reporter: Optional[Reporter] = None,
) -> tuple[Reporter, Key]:
"""Return a :class:`.Reporter` and `key` prepared to report a :class:`.Scenario`.
Parameters
----------
context : .Context
The code responds to :py:`context.report`, which is an instance of
:class:`.report.Config`.
scenario : .Scenario, optional
Scenario to report. If not given, :meth:`.Context.get_scenario` is used to
retrieve a Scenario.
reporter : .Reporter, optional
Existing reporter to extend with computations. If not given, it is created
using :meth:`message_ix.Reporter.from_scenario`.
Returns
-------
.Reporter
Reporter prepared with MESSAGEix-GLOBIOM calculations; if `reporter` is given,
this is a reference to the same object.
If :attr:`.cli_output` is given, a task with the key "cli-output" is added that
writes the :attr:`.Config.key` to that path.
.Key
Same as :attr:`.Config.key` if any, but in full resolution; else either
"default" or "cli-output" according to the other settings.
"""
log.info("Prepare reporter")
if reporter:
# Existing `Reporter` provided
rep = reporter
has_solution = True
if scenario:
log.warning(f"{scenario = } argument ignored")
scenario = rep.graph["scenario"]
else:
# Retrieve the scenario
scenario = scenario or context.get_scenario()
# Create a new Reporter
rep = Reporter.from_scenario(scenario)
has_solution = scenario.has_solution()
# Append the message_data operators
rep.require_compat("message_ix_models.report.operator")
# Force re-installation of the function iamc() in this file as the handler for
# "iamc:" sections in global.yaml. Until message_data.reporting is removed, then
# importing it will cause the iamc() function in *that* file to override the one
# registered above.
# TODO Remove, once message_data.reporting is removed.
genno.config.handles("iamc")(iamc)
if context.report.use_scenario_path:
# Construct ScenarioInfo
si = ScenarioInfo(scenario, empty=True)
# Use the scenario URL to extend the path
context.report.set_output_dir(context.report.output_dir.joinpath(si.path))
# Pass values to genno's configuration; deepcopy to protect from destructive
# operations
rep.configure(
**deepcopy(context.report.genno_config),
fail="raise" if has_solution else logging.NOTSET,
)
rep.configure(model=deepcopy(context.model))
# Apply callbacks for other modules which define additional reporting computations
for callback in CALLBACKS:
callback(rep, context)
key = context.report.key
if key:
# If just a bare name like "ACT" is given, infer the full key
if Key.bare_name(key):
inferred = rep.infer_keys(key)
if inferred != key:
log.info(f"Infer {inferred!r} for {key!r}")
key = inferred
if context.report.cli_output:
# Add a new task that writes `key` to the specified file
key = rep.add(
"cli-output", "write_report", key, path=context.report.cli_output
)
else:
key = rep.default_key
log.info(f"No key given; will use default: {key!r}")
# Create the output directory
context.report.mkdir()
log.info("…done")
return rep, key
def defaults(rep: Reporter, context: Context) -> None:
from message_ix_models.model.structure import get_codes
from .util import add_replacements
# Add mappings for conversions to IAMC data structures
add_replacements("c", get_codes("commodity"))
add_replacements("t", get_codes("technology"))
# Ensure "y::model" and "y0" are present
# TODO remove this once message-ix-models depends on message_ix > 3.7.0 at minimum
for comp in (
("y::model", "model_periods", "y", "cat_year"),
("y0", itemgetter(0), "y::model"),
):
try:
rep.add(*comp, strict=True)
except KeyExistsError:
pass # message_ix > 3.7.0; these are already defined
register(defaults)
register("message_ix_models.report.plot")