Repository: IAMconsortium/nomenclature

Issue:
Make "tier" a known attribute as integer
We often use `tier` as a variable-attribute to indicate importance of a variable - tier 1 is mandatory to be reported in a scenario-comparison project, tier 2 and 3 are optional.

This could be added as a standard attribute (as integer) to the VariableCode, but with a tag-list twist (a very special case of #402).

- If the Variable has a tier and the tag-list doesn't: easy
- If the Variable has a tier and the tag-list has a tier-attribute of the type "+1" - add the value to the tier.
- If the Variable doesn't have a tier: ignore

[I suggest the format "+1" to clearly indicate that this is added. If the tier in a tag-list is an integer, an error should be raised.]

This would help in cases like [here](https://github.com/IAMconsortium/common-definitions/blob/main/definitions/variable/energy/final-energy.yaml#L26) where `Final Energy|Non-Energy Use|Chemicals|Liquids` should have tier 2 but `Final Energy|Non-Energy Use|Chemicals|High Value Chemicals|Liquids|Biomass` should probably be tier 4. The of these variables are made of the same tag-lists (each combining different priorities), so having this flexibility to indicate priority within a tag list would be very helpful...


Discussion:
danielhuppmann: Sweet!

Patch:
diff --git a/nomenclature/code.py b/nomenclature/code.py
index 47762ce..f983edc 100644
--- a/nomenclature/code.py
+++ b/nomenclature/code.py
@@ -125,12 +125,26 @@ class Code(BaseModel):
         def _replace_or_recurse(_attr, _value):
             # if the attribute is a string and contains "{tag}" replace
             if isinstance(_value, str) and "{" + tag + "}" in _value:
-                # if the the target has the corresponding attribute replace
+                # if the target has the attribute, replace the tag with the value
                 if _attr in target.flattened_dict:
                     return _value.replace("{" + tag + "}", getattr(target, _attr))
                 # otherwise return the name
                 else:
                     return _value.replace("{" + tag + "}", getattr(target, "name"))
+            # if the attribute is an integer and "tier"
+            elif _attr == "tier" and isinstance(_value, int):
+                # if tier in tag is str formatted as "^1"/"^2"
+                if (tag_tier := getattr(target, _attr, None)) in {"^1", "^2"}:
+                    return _value + int(tag_tier[-1])
+                # if tag doesn't have tier attribute
+                elif not tag_tier:
+                    return _value
+                # else misformatted tier in tag
+                else:
+                    raise ValueError(
+                        f"Invalid 'tier' attribute in '{tag}' tag '{target.name}': {tag_tier}\n"
+                        "Allowed values are '^1' or '^2'."
+                    )
             # if the attribute is a list, iterate over the items and replace tags
             elif isinstance(_value, list):
                 return [_replace_or_recurse(_attr, _v) for _v in _value]
@@ -164,6 +178,7 @@ class Code(BaseModel):
 
 class VariableCode(Code):
     unit: Union[str, List[str]] = Field(...)
+    tier: int | str | None = None
     weight: str | None = None
     region_aggregation: List[Dict[str, Dict]] | None = Field(
         default=None, alias="region-aggregation"


Test patch:
diff --git a/tests/data/codelist/tier_attribute/invalid/tag_fuel.yaml b/tests/data/codelist/tier_attribute/invalid/tag_fuel.yaml
new file mode 100644
index 0000000..5c50579
--- /dev/null
+++ b/tests/data/codelist/tier_attribute/invalid/tag_fuel.yaml
@@ -0,0 +1,4 @@
+- Fuel:
+  - Coal:
+      description: coal
+      tier: +1
diff --git a/tests/data/codelist/tier_attribute/invalid/variables.yaml b/tests/data/codelist/tier_attribute/invalid/variables.yaml
new file mode 100644
index 0000000..a1bed6d
--- /dev/null
+++ b/tests/data/codelist/tier_attribute/invalid/variables.yaml
@@ -0,0 +1,8 @@
+- Primary Energy:
+    description: Total primary energy consumption
+    unit: EJ/yr
+- Primary Energy|{Fuel}:
+    description: Primary energy consumption of {Fuel}
+    unit: EJ/yr
+    tier: 1
+
diff --git a/tests/data/codelist/tier_attribute/valid/tag_fuel.yaml b/tests/data/codelist/tier_attribute/valid/tag_fuel.yaml
new file mode 100644
index 0000000..d62e410
--- /dev/null
+++ b/tests/data/codelist/tier_attribute/valid/tag_fuel.yaml
@@ -0,0 +1,5 @@
+- Fuel:
+  - Coal:
+      description: coal
+  - Coal|Lignite:
+      tier: ^1
diff --git a/tests/data/codelist/tier_attribute/valid/tag_sector.yaml b/tests/data/codelist/tier_attribute/valid/tag_sector.yaml
new file mode 100644
index 0000000..984d493
--- /dev/null
+++ b/tests/data/codelist/tier_attribute/valid/tag_sector.yaml
@@ -0,0 +1,6 @@
+- Sector:
+    - Industry:
+        description: industrial sector excluding non-energy use (e.g.feedstocks)
+    - Industry|Chemicals:
+        description: chemicals industry
+        tier: ^1
diff --git a/tests/data/codelist/tier_attribute/valid/variables.yaml b/tests/data/codelist/tier_attribute/valid/variables.yaml
new file mode 100644
index 0000000..6132cb1
--- /dev/null
+++ b/tests/data/codelist/tier_attribute/valid/variables.yaml
@@ -0,0 +1,16 @@
+- Primary Energy:
+    description: Total primary energy consumption
+    unit: EJ/yr
+- Primary Energy|{Fuel}:
+    description: Primary energy consumption of {Fuel}
+    unit: EJ/yr
+    # this variable does not have a tier to test that no tier is added at initialization    
+
+- Final Energy|{Fuel}|{Sector}:
+    description: Final energy consumption of {Fuel} in {Sector}
+    unit: EJ/yr
+    tier: 1
+- Primary Energy|{Fuel} [Share]:
+    description: Share of {Fuel} in the total primary energy mix
+    unit: '%'
+    tier: 2
diff --git a/tests/test_codelist.py b/tests/test_codelist.py
index a5c3642..85b0121 100644
--- a/tests/test_codelist.py
+++ b/tests/test_codelist.py
@@ -147,6 +147,38 @@ def test_tags_in_list_attributes():
             assert getattr(code[code_name], attr_name) == value
 
 
+def test_tier_attribute_in_tags():
+    """Check for tier attribute functionality ('tier' in tags upgrade CodeList's):
+    1) 'tier' is not added when not present in Code or tag;
+    2) 'tier' is/are upgraded when present in Code and matching tag(s)"""
+    code_list = VariableCodeList.from_directory(
+        "variable", MODULE_TEST_DATA_DIR / "tier_attribute" / "valid"
+    )
+    # check tier attribute is upgraded correctly
+    assert code_list["Final Energy|Coal|Industry"].tier == 1
+    assert code_list["Final Energy|Coal|Lignite|Industry"].tier == 2
+    assert code_list["Final Energy|Coal|Industry|Chemicals"].tier == 2
+    assert code_list["Primary Energy|Coal [Share]"].tier == 2
+    assert code_list["Primary Energy|Coal|Lignite [Share]"].tier == 3
+
+    # check multiple tier attributes upgrade cumulatively
+    assert code_list["Final Energy|Coal|Lignite|Industry|Chemicals"].tier == 3
+
+    # check codes without tier attributes don't change
+    assert not code_list["Primary Energy"].tier
+
+
+def test_misformatted_tier_fails():
+    """Check misformatted 'tier' attributes raise errors"""
+
+    match = "Invalid 'tier' attribute in 'Fuel' tag 'Coal': 1\n"
+    "Allowed values are '^1' or '^2'."
+    with pytest.raises(ValueError, match=match):
+        VariableCodeList.from_directory(
+            "variable", MODULE_TEST_DATA_DIR / "tier_attribute" / "invalid"
+        )
+
+
 def test_region_codelist():
     """Check replacing top-level hierarchy of yaml file as attribute for regions"""
     code = RegionCodeList.from_directory(