Repository: ioos/compliance-checker

Issue:
Add check for coordinate system definition
> All of a variable’s spatiotemporal dimensions that are not latitude, longitude, vertical, or time dimensions are required to be associated with the relevant latitude, longitude, vertical, or time coordinates via the new coordinates attribute of the variable. The value of the coordinates attribute is a blank separated list of the names of auxiliary coordinate variables. There is no restriction on the order in which the auxiliary coordinate variables appear in the coordinates attribute string. The dimensions of an auxiliary coordinate variable must be a subset of the dimensions of the variable with which the coordinate is associated, with two exceptions. First, string-valued coordinates (Section 6.1, "Labels") have a dimension for maximum string length. Second, in the ragged array representations of data (Chapter 9, Discrete Sampling Geometries), special methods are needed to connect the data and coordinates

This was a hard read but basically:

```
for each geophysical variable
  for each coordinate-type that is lat/lon/time/z
    assert coordinate-type is associated to the geophysical variable.
```

The association can be:
- Coordinate is a coordinate variable and is a dimension of the geophysical variable
- The coordinate is associated via the coordinates attribute.



Discussion:
The more I read this the more it hurts, and my interpretation has changed. CF allows for multiple coordinate variables, for example in ROMS datasets with staggered grids, there are 3 types of coordinates, eta_u, eta_v and rho. This is allowed, so the idea that every geophysical variable must be associated with every coordinate is wrong.

I think the best we can do with this one is to check that any auxiliary coordinates have a subset of dimensions, like we do with grids.


Resolving patch:
diff --git a/compliance_checker/cf/cf.py b/compliance_checker/cf/cf.py
--- a/compliance_checker/cf/cf.py
+++ b/compliance_checker/cf/cf.py
@@ -1901,6 +1901,53 @@ def _get_coord_vars(self, ds):
                 coord_vars.append(name)
         return coord_vars
 
+    def check_aux_coordinates(self, ds):
+        '''
+        Chapter 5 paragraph 3
+
+        The dimensions of an auxiliary coordinate variable must be a subset of
+        the dimensions of the variable with which the coordinate is associated,
+        with two exceptions. First, string-valued coordinates (Section 6.1,
+        "Labels") have a dimension for maximum string length. Second, in the
+        ragged array representations of data (Chapter 9, Discrete Sampling
+        Geometries), special methods are needed to connect the data and
+        coordinates.
+        '''
+
+        ret_val = []
+        geophysical_variables = self._find_geophysical_vars(ds)
+        for name in geophysical_variables:
+            variable = ds.variables[name]
+            coordinates = getattr(variable, 'coordinates', None)
+            # We use a set so we can assert
+            dim_set = set(variable.dimensions)
+            # No auxiliary coordinates, no check
+            if not isinstance(coordinates, basestring) or coordinates == '':
+                continue
+
+            valid_aux_coords = TestCtx(BaseCheck.HIGH,
+                                       "§5.0 Auxiliary Coordinates of {} must have a subset of {}'s dimensions"
+                                       "".format(name, name))
+
+            for aux_coord in coordinates.split():
+                valid_aux_coords.assert_true(aux_coord in ds.variables,
+                                             "auxiliary coordinate specified by the coordinates attribute, {}, "
+                                             "is not a variable in this dataset"
+                                             "".format(aux_coord))
+                if aux_coord not in ds.variables:
+                    continue
+
+                aux_coord_dims = set(ds.variables[aux_coord].dimensions)
+                valid_aux_coords.assert_true(aux_coord_dims.issubset(dim_set),
+                                             "dimensions for auxiliary coordinate variable {} ({}) "
+                                             "are not a subset of dimensions for variable {} ({})"
+                                             "".format(aux_coord,
+                                                       ', '.join(aux_coord_dims),
+                                                       name,
+                                                       ', '.join(dim_set)))
+            ret_val.append(valid_aux_coords.to_result())
+        return ret_val
+
     def check_duplicate_axis(self, ds):
         '''
         Checks that no variable contains two coordinates defining the same


Test patch:
diff --git a/compliance_checker/tests/data/illegal-aux-coords.cdl b/compliance_checker/tests/data/illegal-aux-coords.cdl
new file mode 100644
--- /dev/null
+++ b/compliance_checker/tests/data/illegal-aux-coords.cdl
@@ -0,0 +1,31 @@
+netcdf illegal-aux-cords {
+dimensions:
+    xc = 2;
+    yc = 2;
+
+variables:
+    double xc(xc);
+        xc:long_name = "X-coordinate map";
+        xc:axis = "X";
+
+    double yc(yc);
+        yc:long_name = "Y-coordinat map";
+        yc:axis = "Y";
+
+    double lat(xc, yc);
+        lat:standard_name = "latitude";
+        lat:units = "degrees_north";
+
+    double lon(xc, yc);
+        lon:standard_name = "longitude";
+        lon:units = "degrees_east";
+
+    double h_temp(xc);
+        h_temp:long_name = "Fixed temperature bins along the horizontal axis";
+        h_temp:units = "deg_C";
+        h_temp:coordinates = "lat lon"; //illegal
+
+    double sal(xc, yc);
+        sal:standard_name = "sea_water_salinity";
+        sal:coordinates = "lat lon";
+}
diff --git a/compliance_checker/tests/resources.py b/compliance_checker/tests/resources.py
--- a/compliance_checker/tests/resources.py
+++ b/compliance_checker/tests/resources.py
@@ -79,4 +79,5 @@ def generate_dataset(cdl_path, nc_path):
     '2d-static-grid'                       : get_filename('tests/data/2d-static-grid.cdl'),
     '3d-static-grid'                       : get_filename('tests/data/3d-static-grid.cdl'),
     'illegal-vertical'                     : get_filename('tests/data/illegal-vertical.cdl'),
+    'illegal-aux-coords'                   : get_filename('tests/data/illegal-aux-coords.cdl'),
 }
diff --git a/compliance_checker/tests/test_cf.py b/compliance_checker/tests/test_cf.py
--- a/compliance_checker/tests/test_cf.py
+++ b/compliance_checker/tests/test_cf.py
@@ -741,6 +741,19 @@ def test_check_calendar(self):
         assert 'Variable bad_time_1 should have a calendar attribute' in messages
         assert "Variable bad_time_2 should have a valid calendar: 'nope' is not a valid calendar" in messages
 
+    def test_check_aux_coordinates(self):
+        dataset = self.load_dataset(STATIC_FILES['illegal-aux-coords'])
+        results = self.cf.check_aux_coordinates(dataset)
+        result_dict = {result.name: result for result in results}
+        result = result_dict[u"§5.0 Auxiliary Coordinates of h_temp must have a subset of h_temp's dimensions"]
+        assert result.value == (2, 4)
+        regx = (r"dimensions for auxiliary coordinate variable lat \([xy]c, [xy]c\) are not a subset of dimensions for variable "
+                r"h_temp \(xc\)")
+        assert re.match(regx, result.msgs[0]) is not None
+
+        result = result_dict[u"§5.0 Auxiliary Coordinates of sal must have a subset of sal's dimensions"]
+        assert result.value == (4, 4)
+
     def test_check_grid_coordinates(self):
         dataset = self.load_dataset(STATIC_FILES['2dim'])
         results = self.cf.check_grid_coordinates(dataset)