Repository: ioos/compliance-checker

Issue:
Wrong error with flag_masks containing only one element
**Version of compliance checker running:** latest git master version at this time: 79c67c479a2e08247c85b95513e499a4cf5a7523

**Describe the checker this affects:** CF

**Attach a minimal CDL or NetCDF file which is able to reproduce the issue**
[flag_masks_one_elem.txt](https://github.com/ioos/compliance-checker/files/8411104/flag_masks_one_elem.txt)
```
netcdf flag_masks_one_elem {
dimensions:
	pixels = 10 ;
variables:
	byte flags(pixels) ;
		flags:long_name = "Flags" ;
		flags:standard_names = "status_flag" ;
		flags:flag_meanings = "LAND" ;
		flags:flag_masks = 1b ;

// global attributes:
		:Conventions = "CF-1.8" ;
		:title = "title" ;
		:history = "history" ;
}
```

**To Reproduce:**
```
ncgen flag_masks_one_elem.txt -o flag_masks_one_elem.nc
cchecker.py -t cf flag_masks_one_elem.nc
```

**Describe the issue below:**
flag_masks attribute containing only one element seems not well supported as it should be and gives this error:
```
                                     Errors                                     
--------------------------------------------------------------------------------
§3.5 Flags
* flags's flag_masks must be an array of values not int8

```
With at least two elements in flag_masks there is no error.
Maybe this is related to the fact that the netCDF4 or xarray Python modules return a scalar numpy.int8 if the attribute has only one elements but a numpy.ndarray if it has more than one, which is not always easy to handle.



Discussion:
Maybe related to #792
Hi, `flag_masks` here is a scalar, as you indicated.

It's been a while since I've reviewed implementation of the flags (although I am am recently re-reviewing the conformance pages). In the CF 1.6 conventions [flags](https://cfconventions.org/cf-conventions/v1.6.0/cf-conventions.html#flags) section, there is the following snippet (emphasis mine):

>The flag_masks and flag_meanings attributes describe a number of independent Boolean conditions using bit field notation by setting unique bits in each flag_masks value. The flag_masks attribute is the same type as the variable to which it is attached, and contains a **list of values** matching unique bit fields. 
Hi, do you mean that it is not allowed with CF to have a flags variable containing only one bit field? What is the reason? This seems not very logical to me.
Note also that the other NetCDF checker https://github.com/cedadev/cf-checker accepts scalar flag_masks.
Hi, it looks like netCDF4-Python casts one-element 1D arrays to the corresponding scalar type of the first (and only) element.  I checked both the NetCDF User Guide and the netCDF4 Python Documentation to see if there was something describing this behavior.  It looks like it may be related to the NetCDF4 classic data model wherein attributes are a 1D array of values: https://www.unidata.ucar.edu/software/netcdf/workshops/2011/datamodels/NcClassicModel.html  .  If this is the case, this will be a bug on our end, although the behavior could be made a little clearer in the Python NetCDF4 documentation.   Usually files with `flag_values` and/or `flag_masks` will have multiple values defined, which may explain why this situation has not occurred in the past.

```
(Pdb) dataset.variables["time"].leap_month = np.array([], dtype=np.uint8)
(Pdb) dataset.variables["time"].leap_month
array([], dtype=uint8)
(Pdb) dataset.variables["time"].leap_month = np.array([1], dtype=np.uint8)
(Pdb)
(Pdb) dataset.variables["time"].leap_month
1
(Pdb) dataset.variables["time"].leap_month = np.array([1], dtype=np.uint8)
(Pdb) dataset.variables["time"].leap_month = np.array([], dtype=np.uint8)
(Pdb) print(dataset.variables["time"].leap_month)
[]
(Pdb) dataset.variables["time"].leap_month = np.array([2, 5], dtype=np.uint8)
(Pdb) print(dataset.variables["time"].leap_month)
[2 5]
(Pdb) type(dataset.variables["time"].leap_month)
<class 'numpy.ndarray'>
(Pdb) single_element_1d = np.array([2], dtype=np.uint8)
(Pdb) dataset.variables["time"].leap_month = single_element_1d
(Pdb) dataset.variables["time"].leap_month == single_element_1d
array([ True])
(Pdb) #uh
*** SyntaxError: unexpected EOF while parsing
(Pdb) type(dataset.variables["time"].leap_month)
<class 'numpy.uint8'>
(Pdb) type(single_element_1d)
<class 'numpy.ndarray'>
(Pdb) single_element_1d.dtype
dtype('uint8')
(Pdb) dataset.variables["time"].leap_month.dtype
dtype('uint8')
```

Resolving patch:
diff --git a/compliance_checker/cf/cf_1_6.py b/compliance_checker/cf/cf_1_6.py
--- a/compliance_checker/cf/cf_1_6.py
+++ b/compliance_checker/cf/cf_1_6.py
@@ -1091,25 +1091,21 @@ def _check_flag_values(self, ds, name):
         flag_meanings = getattr(variable, "flag_meanings", None)
         valid_values = TestCtx(BaseCheck.HIGH, self.section_titles["3.5"])
 
-        # flag_values must be a list of values, not a string or anything else
-        valid_values.assert_true(
-            isinstance(flag_values, np.ndarray),
-            "{}'s flag_values must be an array of values not {}".format(
-                name, type(flag_values)
-            ),
+        # IMPLEMENTATION CONFORMANCE 3.5 REQUIRED 2/8
+        valid_values.assert_true(hasattr(variable, "flag_meanings"),
+            f"Variable {variable.name} must have attribute flag_meanings "
+            "defined when flag_values attribute is present"
         )
 
-        # We can't perform any more checks
-        if not isinstance(flag_values, np.ndarray):
-            return valid_values.to_result()
 
         # the flag values must be independent, no repeating values
         flag_set = set(flag_values)
         valid_values.assert_true(
-            len(flag_set) == len(flag_values),
+            len(flag_set) == np.array(flag_values).size,
             "{}'s flag_values must be independent and can not be repeated".format(name),
         )
 
+        # IMPLEMENTATION CONFORMANCE 3.5 REQUIRED 1/8
         # the data type for flag_values should be the same as the variable
         valid_values.assert_true(
             variable.dtype.type == flag_values.dtype.type,
@@ -1117,15 +1113,13 @@ def _check_flag_values(self, ds, name):
             "".format(flag_values.dtype.type, name, variable.dtype.type),
         )
 
+        # IMPLEMENTATION CONFORMANCE 3.5 REQUIRED 4/8
         if isinstance(flag_meanings, str):
             flag_meanings = flag_meanings.split()
             valid_values.assert_true(
-                len(flag_meanings) == len(flag_values),
-                "{}'s flag_meanings and flag_values should have the same number ".format(
-                    name
-                )
-                + "of elements.",
-            )
+                len(flag_meanings) == np.array(flag_values).size,
+                f"{name}'s flag_meanings and flag_values should have the same "
+                 "number of elements.")
 
         return valid_values.to_result()
 
@@ -1145,20 +1139,10 @@ def _check_flag_masks(self, ds, name):
         variable = ds.variables[name]
 
         flag_masks = variable.flag_masks
-        flag_meanings = getattr(ds, "flag_meanings", None)
+        flag_meanings = getattr(variable, "flag_meanings", None)
 
         valid_masks = TestCtx(BaseCheck.HIGH, self.section_titles["3.5"])
 
-        valid_masks.assert_true(
-            isinstance(flag_masks, np.ndarray),
-            "{}'s flag_masks must be an array of values not {}".format(
-                name, type(flag_masks).__name__
-            ),
-        )
-
-        if not isinstance(flag_masks, np.ndarray):
-            return valid_masks.to_result()
-
         valid_masks.assert_true(
             variable.dtype.type == flag_masks.dtype.type,
             "flag_masks ({}) mustbe the same data type as {} ({})"
@@ -1179,11 +1163,11 @@ def _check_flag_masks(self, ds, name):
         if isinstance(flag_meanings, str):
             flag_meanings = flag_meanings.split()
             valid_masks.assert_true(
-                len(flag_meanings) == len(flag_masks),
-                "{} flag_meanings and flag_masks should have the same number ".format(
-                    name
-                )
-                + "of elements.",
+                # cast to array here as single element arrays are returned as
+                # scalars from netCDF4 Python
+                len(flag_meanings) == np.array(flag_masks).size,
+                f"{name} flag_meanings and flag_masks should have the same "
+                "number of elements."
             )
 
         return valid_masks.to_result()
@@ -1222,6 +1206,7 @@ def _check_flag_meanings(self, ds, name):
             len(flag_meanings) > 0, "{}'s flag_meanings can't be empty".format(name)
         )
 
+        # IMPLEMENTATION CONFORMANCE REQUIRED 3.5 3/8
         flag_regx = regex.compile(r"^[0-9A-Za-z_\-.+@]+$")
         meanings = flag_meanings.split()
         for meaning in meanings:


Test patch:
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
@@ -658,12 +658,19 @@ def test_check_flag_masks(self):
         assert scored > 0
         assert scored == out_of
 
+        dataset = MockTimeSeries()
+        flags_var = dataset.createVariable("flags", "f8", ("time",))
+        flags_var.standard_name = "quality_flag"
+        flags_var.flag_meanings = "LAND"
+        flags_var.flag_masks = np.array([1], dtype="i2")
+        results = self.cf.check_flags(dataset)
+        assert scored > 0 and scored == out_of
+
     def test_check_bad_units(self):
         """Load a dataset with units that are expected to fail (bad_units.nc).
         There are 6 variables in this dataset, three of which should give
         an error:
             - time, with units "s" (should be <units> since <epoch>)
-            - lat, with units "degrees_E" (should be degrees)
             - lev, with units "level" (deprecated)"""
 
         dataset = self.load_dataset(STATIC_FILES["2dim"])
@@ -685,7 +692,7 @@ def test_check_bad_units(self):
 
         # check that all the expected variables have been hit
         assert all(
-            any(s in msg for msg in results_list) for s in ["time", "lat", "lev"]
+            any(s in msg for msg in results_list) for s in ["time", "lev"]
         )
 
     def test_latitude(self):
diff --git a/compliance_checker/tests/test_ioos_profile.py b/compliance_checker/tests/test_ioos_profile.py
--- a/compliance_checker/tests/test_ioos_profile.py
+++ b/compliance_checker/tests/test_ioos_profile.py
@@ -809,7 +809,9 @@ def test_check_qartod_variables_flags(self):
         # QARTOD variable with flag meanings, without flag_meanings
         qr.setncattr("flag_values", np.array([0, 1, 2], dtype=np.byte))
         results = self.ioos.check_qartod_variables_flags(ds)
-        self.assertEqual(results[0].value[0], results[0].value[1])  # should pass
+        self.assertIn("Variable depth_qc must have attribute flag_meanings defined when flag_values attribute is present",
+                      results[0].msgs)
+        self.assertNotEqual(results[0].value[0], results[0].value[1])  # should fail
         self.assertFalse(results[1].value)  # still fail
 
         # QARTOD variable with flag meanings, flag_values