diff --git a/docs/user_guide/region.rst b/docs/user_guide/region.rst index 0ba316e2..0a323921 100644 --- a/docs/user_guide/region.rst +++ b/docs/user_guide/region.rst @@ -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 `_ 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 -------------- diff --git a/nomenclature/code.py b/nomenclature/code.py index 9c06bf36..036ad86d 100644 --- a/nomenclature/code.py +++ b/nomenclature/code.py @@ -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 diff --git a/nomenclature/codelist.py b/nomenclature/codelist.py index 685bb483..496a5411 100644 --- a/nomenclature/codelist.py +++ b/nomenclature/codelist.py @@ -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 diff --git a/tests/data/codelist/region_codelist/directional_non-existing_component/region.yaml b/tests/data/codelist/region_codelist/directional_non-existing_component/region.yaml new file mode 100644 index 00000000..941e5f3a --- /dev/null +++ b/tests/data/codelist/region_codelist/directional_non-existing_component/region.yaml @@ -0,0 +1,4 @@ +- countries: + - Austria +- directional: + - Austria>Germany diff --git a/tests/data/codelist/region_codelist/simple/region.yaml b/tests/data/codelist/region_codelist/simple/region.yaml index f069d7bb..58288897 100644 --- a/tests/data/codelist/region_codelist/simple/region.yaml +++ b/tests/data/codelist/region_codelist/simple/region.yaml @@ -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 diff --git a/tests/test_codelist.py b/tests/test_codelist.py index f0b56955..0a833c02 100644 --- a/tests/test_codelist.py +++ b/tests/test_codelist.py @@ -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`""" @@ -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( @@ -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" ) diff --git a/tests/test_model_registration_parser.py b/tests/test_model_registration_parser.py index c9142525..b2525363 100644 --- a/tests/test_model_registration_parser.py +++ b/tests/test_model_registration_parser.py @@ -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 @@ -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"]}