Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ Daniel Huppmann <huppmann@iiasa.ac.at> (@danielhuppmann, IIASA)
Francesco Lovat <lovat@iiasa.ac.at> (@francescolovat, IIASA)
Philip Hackstock <hackstock@iiasa.ac.at> (@phackstock, IIASA)
Mia Werning <werning@iiasa.ac.at> (@mwerning, IIASA)
Vignesh Raghunathan <raghunathan@iiasa.ac.at> (IIASA)
17 changes: 17 additions & 0 deletions DEVELOPING.rst
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,20 @@ Update these files using the command::

The update submodule writes the context files.
When adding a new context file, make sure to ``@import`` it in emissions.txt and expand the tests.


Generated data files for currency conversions
=============================================

``iam_units/currency_data.py`` stores generated currency-conversion data for the
supported ``(method, period)`` combinations. This module is committed and loaded at
runtime without any dependency on ``sdmx1``.

Update these files using::

$ python -m iam_units.update currency

The current generator uses OECD Table 4 via ``sdmx1``. ``EUR`` rows are derived from the
``DEU`` series in that table: this is exact for exchange-rate methods because Germany's
national currency is EUR, but it is a substantive modeling choice for PPP methods and
should remain explicit in code and documentation.
13 changes: 9 additions & 4 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -191,11 +191,16 @@ To enable conversions between *different* currencies, use the function ``configu
>>> qty.to("EUR_2005")
26.022132012144635 <Unit('EUR_2005')>

Currently ``iam_units`` only supports:
Calling ``configure_currency()`` again with the same method and currency/period pair on
the same registry is a no-op. Calling it with a different method for an already
configured pair raises ``ValueError`` rather than silently reusing the first definition.

- period-average exchange rates for annual periods (method="EXC");
- period="2005"; and
- the two currencies mentioned above.
Currently ``iam_units`` supports:

- annual OECD Table 4 exchange-rate / PPP methods ``EXC``, ``EXCE``, ``PPPGDP``,
``PPPPRC``, and ``PPPP41``;
- EUR for the periods 2005, 2010, 2015, 2020, and 2024; and
- USD as the base currency for those conversions.

Up to v2025.9.12, ``configure_currency("EXC", 2005)`` was called automatically.

Expand Down
71 changes: 43 additions & 28 deletions iam_units/currency.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@
"""Currency conversions.

See the inline comments (NB) for possible extensions of this code; also
iam_units.update.currency.
"""
"""Currency conversions."""

from enum import Enum, auto
from typing import TYPE_CHECKING

from .currency_data import DATA

if TYPE_CHECKING:
from pint import UnitRegistry

#: Exchange rate data for method=EXC, period=2005, from
#: https://data.oecd.org/conversion/exchange-rates.htm
#:
#: NB this data could be extended to cover other currencies.
DATA = {
("EUR", "2005"): 0.8038,
}
CONFIGURED_CURRENCY_ATTR = "_iam_units_configured_currency_methods"


class METHOD(Enum):
Expand Down Expand Up @@ -44,7 +36,7 @@ def configure_currency(
*,
_registry: "UnitRegistry | None" = None,
) -> None:
"""Configure currency conversions.
"""Configure currency conversions on a registry.

Parameters
----------
Expand All @@ -53,11 +45,18 @@ def configure_currency(
period : int or str
Time period (e.g. year) for exchange rates.

Notes
-----
Currency units can only be configured once per ``(currency, period)`` pair and
method on a given registry. Repeated calls with the same method are a no-op; a
different method raises an exception for any already-configured pairs.

Raises
------
NotImplementedError
For unsupported values of `method` or `period`. Currently, only the defaults are
supported.
For unsupported values of `method` or `period`.
ValueError
For repeated calls with different `method`.
"""
if _registry is None:
from iam_units import registry
Expand All @@ -73,20 +72,36 @@ def configure_currency(
# Ensure string
period = str(period)

# Select data for (method, period)
if method is METHOD.EXC and period == "2005":
# NB this code could be extended to:
# - Load data for other combinations of (method, period).
# - Load from file, instead of copying from values embedded in code.
data = DATA.copy()
else:
message = []
if method is not METHOD.EXC:
message.append(f"method={method!r}")
if period != "2005":
message.append(f"period={period}")
raise NotImplementedError(", ".join(message))
try:
data = DATA[method.name, period].copy()
except KeyError:
raise NotImplementedError(
f"Convert currency for method={method!r}, period={period}; use one of:\n"
+ repr(sorted(DATA))
)

# Maybe retrieve a dict mapping from (other, period): method for every already-
# configured currency
configured = dict(getattr(registry, CONFIGURED_CURRENCY_ATTR, {}))

# Identify any conflicting, existing configurations: keys appearing in both `data`
# and `configured` with different methods
if conflicts := {
k: m for k, m in configured.items() if k in data and m is not method
}:
unit_list = sorted(
(f"{other}_{period} (configured with method={method_configured.name!r})")
for (other, period), method_configured in conflicts.items()
)
raise ValueError(
f"configure_currency() cannot change to method={method.name!r} for already "
f"defined units: {', '.join(unit_list)}"
)

# Insert definitions
for (other, period), value in data.items():
registry.define(f"{other}_{period} = USD_{period} / {value} = {other}")
configured[(other, period)] = method

# Store information about configuration
setattr(registry, CONFIGURED_CURRENCY_ATTR, configured)
33 changes: 33 additions & 0 deletions iam_units/currency_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# This file was generated using:
# python -m iam_units.update currency
# source=OECD flow=DSD_NAMAIN10@DF_TABLE4
# representative_area[EUR]=DEU
# DO NOT ALTER THIS FILE MANUALLY!

DATA = {
("EXC", "2005"): {("EUR", "2005"): 0.803800},
("EXC", "2010"): {("EUR", "2010"): 0.754309},
("EXC", "2015"): {("EUR", "2015"): 0.901296},
("EXC", "2020"): {("EUR", "2020"): 0.875506},
("EXC", "2024"): {("EUR", "2024"): 0.923890},
("EXCE", "2005"): {("EUR", "2005"): 0.847673},
("EXCE", "2010"): {("EUR", "2010"): 0.748391},
("EXCE", "2015"): {("EUR", "2015"): 0.918527},
("EXCE", "2020"): {("EUR", "2020"): 0.814930},
("EXCE", "2024"): {("EUR", "2024"): 0.962557},
("PPPGDP", "2005"): {("EUR", "2005"): 0.872721},
("PPPGDP", "2010"): {("EUR", "2010"): 0.805269},
("PPPGDP", "2015"): {("EUR", "2015"): 0.778122},
("PPPGDP", "2020"): {("EUR", "2020"): 0.706836},
("PPPGDP", "2024"): {("EUR", "2024"): 0.700862},
("PPPP41", "2005"): {("EUR", "2005"): 0.845792},
("PPPP41", "2010"): {("EUR", "2010"): 0.801812},
("PPPP41", "2015"): {("EUR", "2015"): 0.782330},
("PPPP41", "2020"): {("EUR", "2020"): 0.702282},
("PPPP41", "2024"): {("EUR", "2024"): 0.660241},
("PPPPRC", "2005"): {("EUR", "2005"): 0.907968},
("PPPPRC", "2010"): {("EUR", "2010"): 0.852878},
("PPPPRC", "2015"): {("EUR", "2015"): 0.832913},
("PPPPRC", "2020"): {("EUR", "2020"): 0.750322},
("PPPPRC", "2024"): {("EUR", "2024"): 0.701547},
}
121 changes: 112 additions & 9 deletions iam_units/test_all.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,39 @@
import importlib
import importlib.util
from pathlib import Path

import numpy as np
import pint
import pytest
from numpy.testing import assert_almost_equal, assert_array_almost_equal
from pint.util import UnitsContainer

from iam_units import configure_currency, convert_gwp, emissions, format_mass, registry
import iam_units.update as update_module
from iam_units import (
configure_currency,
convert_gwp,
emissions,
format_mass,
registry,
)
from iam_units.currency import METHOD
from iam_units.update import _write_currency_module

DEFAULTS = pint.get_application_registry()

NIE = pytest.mark.xfail(raises=NotImplementedError)


@pytest.fixture(scope="function")
def local_registry() -> pint.UnitRegistry:
"""A new registry with freshly-loaded definitions."""
loaded_registry: pint.UnitRegistry = pint.UnitRegistry()
loaded_registry.load_definitions(
str(Path(__file__).parent / "data" / "definitions.txt")
)
return loaded_registry


# Parameters for test_units(), tuple of:
# 1. A literal string to be parsed as a unit.
# 2. Expected dimensionality of the parsed unit.
Expand Down Expand Up @@ -67,18 +88,100 @@ def test_kt() -> None:
@pytest.mark.parametrize(
"method, period",
(
# Not supported method
pytest.param("PPPGDP", 2005, marks=NIE),
pytest.param(METHOD.PPPGDP, 2005, marks=NIE),
# Not supported period
pytest.param("EXC", 2010, marks=NIE),
pytest.param(METHOD.EXC, 2010, marks=NIE),
("PPPGDP", 2005),
(METHOD.PPPGDP, 2005),
("EXC", 2010),
(METHOD.EXC, 2010),
# Invalid method str
pytest.param("FOO", 2005, marks=pytest.mark.xfail(raises=ValueError)),
),
)
def test_currency(method: METHOD | str, period: int) -> None:
configure_currency(method, period)
def test_currency(
local_registry: pint.UnitRegistry, method: METHOD | str, period: int
) -> None:
configure_currency(method, period, _registry=local_registry)


def test_currency_data_file(local_registry: pint.UnitRegistry) -> None:
configure_currency("EXC", 2005, _registry=local_registry)
quantity = local_registry("42.1 USD_2020")
converted = quantity.to("EUR_2005")

assert_almost_equal(converted.magnitude, 26.022132012144635)


def test_currency_pppgdp_data_file(local_registry: pint.UnitRegistry) -> None:
configure_currency("PPPGDP", 2005, _registry=local_registry)
quantity = local_registry("42.1 USD_2020")
converted = quantity.to("EUR_2005")

assert converted.magnitude == pytest.approx(28.25337281882418)


def test_currency_rejects_redefinition_with_different_method(
local_registry: pint.UnitRegistry,
) -> None:
configure_currency("EXC", 2005, _registry=local_registry)

with pytest.raises(
ValueError,
match=(
"cannot change to method='PPPGDP' for already defined units: EUR_2005 "
r"\(configured with method='EXC'\)"
),
):
configure_currency("PPPGDP", 2005, _registry=local_registry)

converted = local_registry("42.1 USD_2020").to("EUR_2005")
assert converted.magnitude == pytest.approx(26.022132012144635)


def test_currency_allows_idempotent_redefinition_with_same_method(
local_registry: pint.UnitRegistry,
) -> None:
configure_currency("EXC", 2005, _registry=local_registry)
configure_currency("EXC", 2005, _registry=local_registry)

converted = local_registry("42.1 USD_2020").to("EUR_2005")
assert converted.magnitude == pytest.approx(26.022132012144635)


def test_write_currency_module(tmp_path: Path) -> None:
path = tmp_path / "currency_data.py"
_write_currency_module(
path,
{("EXC", "2005"): (("EUR", "2005", 0.8038),)},
)

spec = importlib.util.spec_from_file_location("test_currency_data", path)
assert spec is not None
assert spec.loader is not None

module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)

assert module.DATA == {("EXC", "2005"): {("EUR", "2005"): 0.8038}}


def test_update_currency(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
path = tmp_path / "currency_data.py"
monkeypatch.setattr(update_module, "CURRENCY_DATA_PATH", path)
monkeypatch.setattr(
update_module,
"_fetch_currency_rows_oecd",
lambda: {("EXC", "2005"): (("EUR", "2005", 0.8038),)},
)

update_module.currency()

spec = importlib.util.spec_from_file_location("test_generated_currency_data", path)
assert spec is not None
assert spec.loader is not None

module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)

assert module.DATA == {("EXC", "2005"): {("EUR", "2005"): 0.8038}}


def test_emissions_gwp_versions() -> None:
Expand Down
Loading
Loading