Skip to content

Commit

Permalink
Support directional region validation (#440)
Browse files Browse the repository at this point in the history
Co-authored-by: Philip Hackstock <[email protected]>
  • Loading branch information
danielhuppmann and phackstock authored Dec 19, 2024
1 parent e2c3484 commit c3ad7da
Show file tree
Hide file tree
Showing 7 changed files with 76 additions and 10 deletions.
14 changes: 14 additions & 0 deletions docs/user_guide/region.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ Regions can have attributes, for example a description or ISO3-codes. If the att
`iso3_codes` is provided, the item(s) are validated against a list of valid codes taken
from the `pycountry <https://github.com/flyingcircusio/pycountry>`_ package.

Directional data
----------------

For reporting of directional data (e.g., trade flows), "directional regions" can be
defined using a *>* separator. The region before the separator is the *origin*,
the region after the separator is the *destination*.

.. code:: yaml
- Trade Connections:
- China>Europe
Both the origin and destination regions must be defined in the region codelist.

Common regions
--------------

Expand Down
16 changes: 16 additions & 0 deletions nomenclature/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,22 @@ def check_iso3_codes(cls, v: list[str], info: ValidationInfo) -> list[str]:
raise ValueError(errors)
return v

@property
def is_directional(self) -> bool:
return ">" in self.name

@property
def destination(self) -> str:
if not self.is_directional:
raise ValueError("Non directional region does not have a destination")
return self.name.split(">")[1]

@property
def origin(self) -> str:
if not self.is_directional:
raise ValueError("Non directional region does not have an origin")
return self.name.split(">")[0]


class MetaCode(Code):
"""Code object with allowed values list
Expand Down
19 changes: 19 additions & 0 deletions nomenclature/codelist.py
Original file line number Diff line number Diff line change
Expand Up @@ -788,10 +788,29 @@ def from_directory(
)
)
mapping[code.name] = code

if errors:
raise ValueError(errors)
return cls(name=name, mapping=mapping)

@field_validator("mapping")
@classmethod
def check_directional_regions(cls, v: dict[str, RegionCode]):
missing_regions = []
for region in v.values():
if region.is_directional:
if region.origin not in v:
missing_regions.append(
f"Origin '{region.origin}' not defined for '{region.name}'"
)
if region.destination not in v:
missing_regions.append(
f"Destination '{region.destination}' not defined for '{region.name}'"
)
if missing_regions:
raise ValueError("\n".join(missing_regions))
return v

@property
def hierarchy(self) -> list[str]:
"""Return the hierarchies defined in the RegionCodeList
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- countries:
- Austria
- directional:
- Austria>Germany
12 changes: 7 additions & 5 deletions tests/data/codelist/region_codelist/simple/region.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
- common:
- World:
definition: The entire world
- World:
definition: The entire world
- countries:
- Some Country:
iso2: XY
iso3: XYZ
- Some Country:
iso2: XY
iso3: XYZ
- directional:
- Some Country>World
15 changes: 14 additions & 1 deletion tests/test_codelist.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ def test_region_codelist():
assert code["Some Country"].hierarchy == "countries"
assert code["Some Country"].iso2 == "XY"

assert "Some Country>World" in code
assert code["Some Country>World"].hierarchy == "directional"


def test_region_codelist_nonexisting_country_name():
"""Check that countries are validated against `nomenclature.countries`"""
Expand All @@ -205,6 +208,17 @@ def test_region_codelist_nonexisting_country_name():
)


def test_directional_region_codelist_nonexisting_country_name():
"""Check that directional regions have defined origin and destination"""
with pytest.raises(ValueError, match="Destination 'Germany' .* 'Austria>Germany'"):
RegionCodeList.from_directory(
"region",
MODULE_TEST_DATA_DIR
/ "region_codelist"
/ "directional_non-existing_component",
)


def test_region_codelist_str_country_name():
"""Check that country name as string is validated against `nomenclature.countries`"""
code = RegionCodeList.from_directory(
Expand Down Expand Up @@ -380,7 +394,6 @@ def test_RegionCodeList_filter():
def test_RegionCodeList_hierarchy():
"""Verifies that the hierarchy method returns a list"""


rcl = RegionCodeList.from_directory(
"Region", MODULE_TEST_DATA_DIR / "region_to_filter_codelist"
)
Expand Down
6 changes: 2 additions & 4 deletions tests/test_model_registration_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ def test_parse_model_registration(tmp_path):
)

# Test model mapping
with open(tmp_path / "Model 1.1_mapping.yaml", "r", encoding="utf-8") \
as file:
with open(tmp_path / "Model 1.1_mapping.yaml", "r", encoding="utf-8") as file:
obs_model_mapping = yaml.safe_load(file)
with open(
TEST_DATA_DIR
Expand All @@ -30,8 +29,7 @@ def test_parse_model_registration(tmp_path):
assert obs_model_mapping == exp_model_mapping

# Test model regions
with open(tmp_path / "Model 1.1_regions.yaml", "r", encoding="utf-8") \
as file:
with open(tmp_path / "Model 1.1_regions.yaml", "r", encoding="utf-8") as file:
obs_model_regions = yaml.safe_load(file)
exp_model_regions = [
{"Model 1.1": ["Model 1.1|Region 1", "Region 2", "Model 1.1|Region 3"]}
Expand Down

0 comments on commit c3ad7da

Please sign in to comment.