Repository: ioos/compliance-checker
Pull request #1217 (2025-06-28 00:43:35)

Add 'bounds inherit' checks from Appendix A


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 25ec4bf..236aaac 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -31,7 +31,7 @@ repos:
 
 
 - repo: https://github.com/astral-sh/ruff-pre-commit
-  rev: v0.12.0
+  rev: v0.12.2
   hooks:
     - id: ruff
       args: ["--fix", "--show-fixes"]
@@ -57,7 +57,7 @@ repos:
           )$
 
 - repo: https://github.com/woodruffw/zizmor-pre-commit
-  rev: v1.9.0
+  rev: v1.11.0
   hooks:
     - id: zizmor
 
diff --git a/compliance_checker/cf/appendix_a.py b/compliance_checker/cf/appendix_a.py
new file mode 100644
index 0000000..d4f295e
--- /dev/null
+++ b/compliance_checker/cf/appendix_a.py
@@ -0,0 +1,58 @@
+appendix_a = {
+    "actual_range": {"Type": "N", "Use": ["C", "D", "BO"]},
+    "add_offset": {"Type": "N", "Use": ["C", "D", "BO"]},
+    "ancillary_variables": {"Type": "S", "Use": ["D"]},
+    "axis": {"Type": "S", "Use": ["C", "BI"]},
+    "bounds": {"Type": "S", "Use": ["C"]},
+    "calendar": {"Type": "S", "Use": ["C", "BI"]},
+    "cell_measures": {"Type": "S", "Use": ["D", "Do"]},
+    "cell_methods": {"Type": "S", "Use": ["D"]},
+    "cf_role": {"Type": "S", "Use": ["C", "BI"]},
+    "climatology": {"Type": "S", "Use": ["C"]},
+    "comment": {"Type": "S", "Use": ["G", "C", "D"]},
+    "compress": {"Type": "S", "Use": ["C"]},
+    "computed_standard_name": {"Type": "S", "Use": ["C", "BI"]},
+    "Conventions": {"Type": "S", "Use": ["G"]},
+    "coordinate_interpolation": {"Type": "S", "Use": ["D", "Do"]},
+    "coordinates": {"Type": "S", "Use": ["D", "M", "Do"]},
+    "dimensions": {"Type": "S", "Use": ["Do"]},
+    "external_variables": {"Type": "S", "Use": ["G"]},
+    "_FillValue": {"Type": "D", "Use": ["C", "D", "BO"]},
+    "featureType": {"Type": "S", "Use": ["G"]},
+    "flag_masks": {"Type": "D", "Use": ["D"]},
+    "flag_meanings": {"Type": "S", "Use": ["D"]},
+    "flag_values": {"Type": "D", "Use": ["D"]},
+    "formula_terms": {"Type": "S", "Use": ["C", "BO"]},
+    "geometry": {"Type": "S", "Use": ["C", "D", "Do"]},
+    "geometry_type": {"Type": "S", "Use": ["M"]},
+    "grid_mapping": {"Type": "S", "Use": ["D", "M", "Do"]},
+    "history": {"Type": "S", "Use": ["G", "Gr"]},
+    "instance_dimension": {"Type": "S", "Use": []},
+    "institution": {"Type": "S", "Use": ["G", "D"]},
+    "interior_ring": {"Type": "S", "Use": ["M"]},
+    "leap_month": {"Type": "N", "Use": ["C", "BI"]},
+    "leap_year": {"Type": "N", "Use": ["C", "BI"]},
+    "location": {"Type": "S", "Use": ["D", "Do"]},
+    "location_index_set": {"Type": "S", "Use": ["D", "Do"]},
+    "long_name": {"Type": "S", "Use": ["C", "D", "Do", "BI"]},
+    "mesh": {"Type": "S", "Use": ["D", "Do"]},
+    "missing_value": {"Type": "D", "Use": ["C", "D", "BO"]},
+    "month_lengths": {"Type": "N", "Use": ["C", "BI"]},
+    "node_coordinates": {"Type": "S", "Use": ["M"]},
+    "node_count": {"Type": "S", "Use": ["M"]},
+    "nodes": {"Type": "S", "Use": ["C"]},
+    "part_node_count": {"Type": "S", "Use": ["M"]},
+    "positive": {"Type": "S", "Use": ["C", "BI"]},
+    "references": {"Type": "S", "Use": ["G", "D"]},
+    "sample_dimension": {"Type": "S", "Use": []},
+    "scale_factor": {"Type": "N", "Use": ["C", "D", "BO"]},
+    "source": {"Type": "S", "Use": ["G", "D"]},
+    "standard_error_multiplier": {"Type": "N", "Use": ["D"]},
+    "standard_name": {"Type": "S", "Use": ["C", "D", "BI"]},
+    "title": {"Type": "S", "Use": ["G", "Gr"]},
+    "units": {"Type": "S", "Use": ["C", "D", "BI"]},
+    "units_metadata": {"Type": "S", "Use": ["C", "D", "BI"]},
+    "valid_max": {"Type": "N", "Use": ["C", "D", "BO"]},
+    "valid_min": {"Type": "N", "Use": ["C", "D", "BO"]},
+    "valid_range": {"Type": "N", "Use": ["C", "D", "BO"]},
+}
diff --git a/compliance_checker/cf/cf_1_11.py b/compliance_checker/cf/cf_1_11.py
index eb831b8..360a958 100644
--- a/compliance_checker/cf/cf_1_11.py
+++ b/compliance_checker/cf/cf_1_11.py
@@ -1,7 +1,9 @@
 from functools import lru_cache
 
 from compliance_checker.base import BaseCheck, TestCtx
+from compliance_checker.cf.appendix_a import appendix_a
 from compliance_checker.cf.cf_1_10 import CF1_10Check
+from compliance_checker.cf.util import VariableReferenceError, reference_attr_variables
 
 
 @lru_cache
@@ -106,6 +108,86 @@ class CF1_11Check(CF1_10Check):
 
         return [time_units_metadata_ctx.to_result()]
 
+    def check_bounds_inherit_attributes(self, ds):
+        """
+        A boundary variable inherits the values of some attributes from its parent coordinate variable.
+        If a coordinate variable has any of the attributes marked "BI" (for "inherit") in the "Use" column of <<attribute-appendix>>, they are assumed to apply to its bounds variable as well.
+        It is recommended that BI attributes not be included on a boundary variable.
+        If a BI attribute is included, it must also be present in the parent variable, and it must exactly match the parent attribute's data type and value.
+        A boundary variable can only have inheritable attributes if they are also present on its parent coordinate variable.
+        A bounds variable may have any of the attributes marked "BO" for ("own") in the "Use" column of <<attribute-appendix>>.
+        These attributes take precedence over any corresponding attributes of the parent variable.
+        In these cases, the parent variable's attribute does not apply to the bounds variable, regardless of whether the latter has its own attribute.
+        """
+        results = []
+
+        appendix_a_bi_attrs = {
+            attribute_name
+            for attribute_name, data_dict in appendix_a.items()
+            if "BI" in data_dict["Use"]
+        }
+        for parent_variable in ds.get_variables_by_attributes(
+            bounds=lambda b: b is not None,
+        ):
+            parent_bi_attrs = set(parent_variable.ncattrs()) & appendix_a_bi_attrs
+            bounds_variable = reference_attr_variables(ds, parent_variable.bounds)[0]
+            # nonexistent bounds variable, skip
+            if isinstance(bounds_variable, VariableReferenceError):
+                continue
+
+            bounds_bi_ctx = self.get_test_ctx(
+                BaseCheck.MEDIUM,
+                self.section_titles["7.1"],
+            )
+            bounds_ncattr_set = set(bounds_variable.ncattrs())
+            # IMPLEMENTATION CONFORMANCE 7.3 REQUIRED 4
+            # A boundary variable can only have inheritable attributes, i.e. any of those marked "BI" in the "Use" column of Appendix A, if they are also present on its parent coordinate variable.
+            bounds_bi_attrs = bounds_ncattr_set & appendix_a_bi_attrs
+            # failure case 1, BI attr is only in bounds variable
+            bounds_bi_only_attrs = bounds_bi_attrs - parent_bi_attrs
+            # If a boundary variable has an inheritable attribute then its data type and its value must be exactly the same as the parent variable’s attribute.
+            bounds_bi_ctx.out_of += 1
+            if bounds_bi_only_attrs:
+                bounds_bi_ctx.messages.append(
+                    f"Bounds variable {bounds_variable.name} has the following attributes which must appear on the parent variable {parent_variable.name}: "
+                    f"{sorted(bounds_bi_only_attrs)}",
+                )
+            else:
+                bounds_bi_ctx.score += 1
+            # failure case 2, BI attrs are in both bounds and parent variable
+            both_bi_attr = bounds_bi_attrs & parent_bi_attrs
+            no_match_attrs, match_attrs = [], []
+            for bi_attr in both_bi_attr:
+                bounds_bi_ctx.out_of += 1
+                parent_attr_val = getattr(parent_variable, bi_attr)
+                bounds_attr_val = getattr(bounds_variable, bi_attr)
+                # IMPLEMENTATION CONFORMANCE 7.3 REQUIRED 5
+                # If a boundary variable has an inheritable attribute then its data type and its value must be exactly the same as the parent variable’s attribute.
+                if (
+                    type(parent_attr_val) is not type(bounds_attr_val)
+                    or parent_attr_val != bounds_attr_val
+                ):
+                    no_match_attrs.append(bi_attr)
+                else:
+                    match_attrs.append(bi_attr)
+
+                pass_bi_both = True
+                if no_match_attrs:
+                    pass_bi_both = False
+                    bounds_bi_ctx.messages.append(
+                        f"Bounds variable {bounds_variable.name} and parent variable {parent_variable.name} have the following non matching boundary related attributes: {sorted(no_match_attrs)}",
+                    )
+
+                if match_attrs:
+                    pass_bi_both = False
+                    bounds_bi_ctx.messages.append(
+                        f"Bounds variable {bounds_variable.name} and parent variable {parent_variable.name} have the following matching attributes {sorted(match_attrs)}.  It is recommended that only the parent variable of the bounds variable contains these attributes",
+                    )
+                bounds_bi_ctx.score += pass_bi_both
+
+            results.append(bounds_bi_ctx.to_result())
+        return results
+
     def check_single_cf_role(self, ds):
         test_ctx = self.get_test_ctx(
             BaseCheck.HIGH,


Test patch:
diff --git a/compliance_checker/tests/test_cf.py b/compliance_checker/tests/test_cf.py
index 080e016..0f32409 100644
--- a/compliance_checker/tests/test_cf.py
+++ b/compliance_checker/tests/test_cf.py
@@ -3700,6 +3700,36 @@ class TestCF1_11(BaseTestCase):
         scored, out_of, _ = get_results(results)
         assert scored == 1 and out_of == 2
 
+    @pytest.fixture
+    def bounds_ds(self):
+        ds = MockTimeSeries()
+        ds.createDimension("bounds", 2)
+        ds.createVariable("time_bounds", "f8", ("time", "bounds"))
+        ds.variables["time"].bounds = "time_bounds"
+        return ds
+
+    def test_bounds_inherit_attributes(self, bounds_ds):
+        # TEST CONFORMANCE 7.3 REQUIRED 4, 5
+        results = self.cf.check_bounds_inherit_attributes(bounds_ds)
+        scored, out_of, _ = get_results(results)
+        assert scored == out_of
+        time_bounds = bounds_ds.variables["time_bounds"]
+        # expected failure, not in parent variable
+        time_bounds.cf_role = "timeseries_id"
+        # expected failure, differing value of BI attribute (no seconds)
+        time_bounds.calendar = "gregorian"
+        # repeated BI attributes variable aren't recommended
+        time_bounds.axis = "T"
+        results = self.cf.check_bounds_inherit_attributes(bounds_ds)
+        scored, out_of, messages = get_results(results)
+        assert scored < out_of
+        expected_msgs = {
+            "Bounds variable time_bounds has the following attributes which must appear on the parent variable time: ['cf_role']",
+            "Bounds variable time_bounds and parent variable time have the following non matching boundary related attributes: ['calendar']",
+            "Bounds variable time_bounds and parent variable time have the following matching attributes ['axis'].  It is recommended that only the parent variable of the bounds variable contains these attributes",
+        }
+        assert expected_msgs == set(messages)
+
     def test_single_cf_role(self):
         ds = MockTimeSeries()
         ds.createDimension("ts_no")