Repository: ioos/compliance-checker
Pull request #1198 (2025-05-15 00:41:15)

Implementation for CF1-8 Section 5: Coordinate Systems
Related to issue #949 
 
Listing Requirement Implementation for CF1-8 Section 5 : 

New Implementations:

1. Checks that the `coordinates` attribute is a space-separated list of valid variable names, and all listed variables exist in the dataset. Function: check_coordinates_attribute_format()
	
2. Checks that a coordinate variable must not have the _FillValue or missing_value attributes. Function: check_invalid_coordinate_attr()

3. Checks that spatial/temporal dimensions (time, lat, lon, height)  used in geophysical variables have proper coordinate variables. Function: check_spatiotemporal_dims_have_coordinate_vars()

Existing Implementations:

4. Satisfying CF 1.8 Chapter 5, paragraph 3: Dimensions of auxiliary coordinates must be a subset of the variable they’re attached to (with two documented exceptions)
Function: check_aux_coordinates()  

5. Checks that data in coordinate variables is either monotonically  increasing or decreasing. Function: check_coordinate_variables_strict_monotonicity()


Patch:
diff --git a/compliance_checker/cf/cf_1_6.py b/compliance_checker/cf/cf_1_6.py
index 693e3db..4cab4b8 100644
--- a/compliance_checker/cf/cf_1_6.py
+++ b/compliance_checker/cf/cf_1_6.py
@@ -560,7 +560,7 @@ class CF1_6Check(CFNCCheck):
             )
         return valid_globals.to_result()
 
-    # IMPLEMENTATION
+    # IMPLEMENTATION CONFORMANCE 1.2
     def check_coordinate_variables_strict_monotonicity(self, ds):
         """
         Checks that data in coordinate variables is either monotonically
@@ -571,7 +571,7 @@ class CF1_6Check(CFNCCheck):
         for coord_var_name in self._find_coord_vars(ds):
             coord_var = ds.variables[coord_var_name]
             arr_diff = np.diff(coord_var)
-            monotonicity = TestCtx(BaseCheck.HIGH, self.section_titles["5"])
+            monotonicity = TestCtx(BaseCheck.HIGH, self.section_titles["1.2"])
             monotonicity.assert_true(
                 np.all(arr_diff > 0) or np.all(arr_diff < 0),
                 f'Coordinate variable "{coord_var_name}" must be strictly monotonic',
@@ -2172,6 +2172,131 @@ class CF1_6Check(CFNCCheck):
             ret_val.append(valid_aux_coords.to_result())
         return ret_val
 
+    # IMPLEMENTATION Section 5 Coordinate Systems and Domain
+    def check_coordinates_attribute_format(self, ds):
+        """
+        Checks that the `coordinates` attribute is a space-separated list of valid variable names,
+        and all listed variables exist in the dataset.
+        """
+        ret_val = []
+
+        geophysical_variables = self._find_geophysical_vars(ds)
+
+        for var_name in geophysical_variables:
+            var = ds.variables[var_name]
+            coord_attr = getattr(var, "coordinates", None)
+
+            if not coord_attr:
+                continue  # No coordinates attribute, nothing to check
+
+            check_coords_attrs_format = TestCtx(
+                BaseCheck.HIGH,
+                self.section_titles["5"],
+            )
+
+            # Check that it is a proper string
+            check_coords_attrs_format.assert_true(
+                isinstance(coord_attr, str),
+                f"The 'coordinates' attribute of variable '{var_name}' must be a string.",
+            )
+
+            # Check for unexpected characters (e.g., commas or brackets)
+            if isinstance(coord_attr, str):
+                has_invalid_chars = any(
+                    char in coord_attr for char in [",", "[", "]", "(", ")"]
+                )
+                check_coords_attrs_format.assert_true(
+                    not has_invalid_chars,
+                    f"The 'coordinates' attribute of variable '{var_name}' contains invalid characters: {coord_attr}",
+                )
+
+                # Check that each token is a valid variable name
+                for token in coord_attr.split():
+                    check_coords_attrs_format.assert_true(
+                        token in ds.variables,
+                        f"The 'coordinates' attribute of variable '{var_name}' references non-existent variable '{token}'.",
+                    )
+
+            ret_val.append(check_coords_attrs_format.to_result())
+
+        return ret_val
+
+    # IMPLEMENTATION Section 5.1 Independent Latitude, Longitude, Vertical, and Time Axes,
+    def check_spatiotemporal_dims_have_coordinate_vars(self, ds):
+        """
+        Checks that spatial/temporal dimensions (time, lat, lon, height, ...)
+        used in geophysical variables have proper coordinate variables.
+        """
+
+        # CF-recognized standard names for coordinate axes
+        expected_standard_names = {
+            "time": "time",
+            "lat": "latitude",
+            "latitude": "latitude",
+            "lon": "longitude",
+            "longitude": "longitude",
+            "height": "height",
+            "depth": "depth",
+            "altitude": "altitude",
+            "pressure": "air_pressure",
+        }
+
+        ret_val = []
+
+        geophysical_variables = self._find_geophysical_vars(ds)
+        for var_name in geophysical_variables:
+            var = ds.variables[var_name]
+            check_spatiotemporal_dims_coords = TestCtx(
+                BaseCheck.HIGH,
+                self.section_titles["5.1"],
+            )
+
+            for dim in var.dimensions:
+                if dim in expected_standard_names:
+                    if dim not in ds.variables:
+                        check_spatiotemporal_dims_coords.assert_true(
+                            False,
+                            f"Dimension '{dim}' in variable '{var_name}' is expected to be a coordinate axis "
+                            f"but no variable with that name exists.",
+                        )
+                        continue
+
+                    coord_var = ds.variables[dim]
+                    std_name = getattr(coord_var, "standard_name", None)
+
+                    check_spatiotemporal_dims_coords.assert_true(
+                        std_name == expected_standard_names[dim],
+                        f"Coordinate variable '{dim}' should have standard_name='{expected_standard_names[dim]}', "
+                        f"found: '{std_name}'",
+                    )
+
+            ret_val.append(check_spatiotemporal_dims_coords.to_result())
+        return ret_val
+
+    # IMPLEMENTATION Section 2.5.1 Coordinate Systems and Domain
+    def check_invalid_coordinate_attr(self, ds):
+        """
+        Checks that a coordinate variable must not have the _FillValue or missing_value attributes.
+        """
+        ret_val = []
+        for coord_var_name in cfutil.get_coordinate_variables(ds):
+            coord_var = ds.variables[coord_var_name]
+            valid_coords_attr = TestCtx(BaseCheck.HIGH, self.section_titles["2.5.1"])
+
+            valid_coords_attr.assert_true(
+                "_FillValue" not in coord_var.ncattrs(),
+                f"The coordinate variable '{coord_var_name}' must not have the _FillValue attribute.",
+            )
+
+            valid_coords_attr.assert_true(
+                "missing_value" not in coord_var.ncattrs(),
+                f"The coordinate variable '{coord_var_name}' must not have the missing_value attribute.",
+            )
+
+            ret_val.append(valid_coords_attr.to_result())
+
+        return ret_val
+
     def check_duplicate_axis(self, ds):
         """
         Checks that no variable contains two coordinates defining the same
diff --git a/compliance_checker/cf/cf_base.py b/compliance_checker/cf/cf_base.py
index 2ff8e3e..9168b38 100644
--- a/compliance_checker/cf/cf_base.py
+++ b/compliance_checker/cf/cf_base.py
@@ -40,11 +40,13 @@ class CFBaseCheck(BaseCheck):
         self._std_names = util.StandardNameTable()
 
         self.section_titles = {  # dict of section headers shared by grouped checks
+            "1.2": "§1.2 Terminology",
             "2.1": "§2.1 Filename",
             "2.2": "§2.2 Data Types",
             "2.3": "§2.3 Naming Conventions",
             "2.4": "§2.4 Dimensions",
             "2.5": "§2.5 Variables",
+            "2.5.1": "§2.5.1. Missing data, valid and actual range of data",
             "2.6": "§2.6 Attributes",
             "2.6.3": "§2.6.3 External Variables",
             "3.1": "§3.1 Units",
diff --git a/compliance_checker/cf/util.py b/compliance_checker/cf/util.py
index 50d1ed5..b986703 100644
--- a/compliance_checker/cf/util.py
+++ b/compliance_checker/cf/util.py
@@ -432,6 +432,7 @@ def get_auxiliary_coordinate_variables(nc):
 
     :param netCDf4.Dataset nc: An open netCDF dataset
     """
+
     aux_vars = []
     # get any variables referenced by the coordinates attribute
     for ncvar in nc.get_variables_by_attributes(
@@ -446,12 +447,10 @@ def get_auxiliary_coordinate_variables(nc):
                 and referenced_variable not in aux_vars
             ):
                 aux_vars.append(referenced_variable)
-
     # axis variables are automatically in
     for variable in get_axis_variables(nc):
         if variable not in aux_vars:
             aux_vars.append(variable)
-
     # Last are any variables that define the common coordinate standard names
     coordinate_standard_names = [
         "time",
@@ -462,14 +461,12 @@ def get_auxiliary_coordinate_variables(nc):
         "altitude",
     ]
     coordinate_standard_names += DIMENSIONLESS_VERTICAL_COORDINATES
-
     # Some datasets like ROMS use multiple variables to define coordinates
     for ncvar in nc.get_variables_by_attributes(
         standard_name=lambda x: x in coordinate_standard_names,
     ):
         if ncvar.name not in aux_vars:
             aux_vars.append(ncvar.name)
-
     # Remove any that are purely coordinate variables
     ret_val = []
     for aux_var in aux_vars:


Test patch:
diff --git a/compliance_checker/tests/test_cf.py b/compliance_checker/tests/test_cf.py
index 06b3462..034dabb 100644
--- a/compliance_checker/tests/test_cf.py
+++ b/compliance_checker/tests/test_cf.py
@@ -86,6 +86,7 @@ class TestCF1_6(BaseTestCase):
 
     def test_coord_data_vars(self):
         """Check that coordinate data variables are properly handled"""
+
         ds = MockTimeSeries()
         ds.createDimension("siglev", 20)
 
@@ -97,7 +98,9 @@ class TestCF1_6(BaseTestCase):
         )
         temp.coordinates = "sigma noexist"
         ds.createVariable("sigma", np.float64, dimensions=("siglev",))
+
         self.cf.setup(ds)
+
         # time is a NUG coordinate variable, sigma is not, but is referred to in
         # variables, so both should show up in cf_coord_data_vars.
         # noexist does not exist in the dataset's variables, so it is not
@@ -106,8 +109,16 @@ class TestCF1_6(BaseTestCase):
 
         ds = MockTimeSeries()
         ds.variables["time"][:3] = np.array([20, -2, 0])
+
+        # Section 1.2 Terminology:
+        # Coordinate Variable
+        # We use this term precisely as it is defined in the NUG section on coordinate variables.
+        # It is a one-dimensional variable with the same name as its dimension [e.g., time(time)],
+        # and it is defined as a numeric data type with values that are ordered monotonically.
+        # Missing values are not allowed in coordinate variables.
         result = self.cf.check_coordinate_variables_strict_monotonicity(ds)
         _, _, messages = get_results(result)
+
         assert 'Coordinate variable "time" must be strictly monotonic' in messages
 
     # --------------------------------------------------------------------------------
@@ -1407,11 +1418,46 @@ class TestCF1_6(BaseTestCase):
         assert result.msgs == []  # shouldn't have any messages
         assert result.value == (4, 4)
 
+    def test_check_coordinates_attribute_format(self):
+        dataset = self.load_dataset(STATIC_FILES["illegal-aux-coords"])
+        results = self.cf.check_coordinates_attribute_format(dataset)
+        result_dict = {result.name: result for result in results}
+        result = result_dict["§5 Coordinate Systems"]
+        assert result.msgs == []  # shouldn't have any messages
+        assert result.value == (4, 4)
+
+    def test_check_spatiotemporal_dims_have_coordinate_vars(self):
+        """
+        Section 5.1 Independent Latitude, Longitude, Vertical, and Time Axes:
+        When each of a variable’s spatiotemporal dimensions is a latitude, longitude,
+        vertical, or time dimension, then each axis is identified by a coordinate variable.
+        """
+        dataset = self.load_dataset(STATIC_FILES["example-grid"])
+        results = self.cf.check_spatiotemporal_dims_have_coordinate_vars(dataset)
+        result_dict = {result.name: result for result in results}
+        result = result_dict[
+            "§5.1 Independent Latitude, Longitude, Vertical, and Time Axes"
+        ]
+        assert result.msgs == []  # shouldn't have any messages
+        assert result.value == (4, 4)
+
+    def test_check_invalid_coordinate_attr(self):
+        """
+        Section 2.5.1. Missing data, valid and actual range of data:
+         "Missing data is not allowed in coordinate variables."
+        _FillValue or missing_value should not be an attributes of the Coordinate variables.
+        """
+        dataset = self.load_dataset(STATIC_FILES["example-grid"])
+        results = self.cf.check_invalid_coordinate_attr(dataset)
+        result_dict = {result.name: result for result in results}
+        result = result_dict["§2.5.1. Missing data, valid and actual range of data"]
+        assert result.msgs == []  # shouldn't have any messages
+        assert result.value == (2, 2)
+
     def test_check_grid_coordinates(self):
         dataset = self.load_dataset(STATIC_FILES["2dim"])
         results = self.cf.check_grid_coordinates(dataset)
         scored, out_of, messages = get_results(results)
-
         result_dict = {result.name: result for result in results}
         result = result_dict[
             "§5.6 Horizontal Coordinate Reference Systems, Grid Mappings, Projections"