Repository: ioos/compliance-checker
Pull request #848 (2020-08-14 19:32:49)

Addresses issue 828
Addresses issue #828.

Enable any variable, not just platform variables, to have
the cf_role variable. Break out into modular functions and
adjust unit tests accordingly. Squashed messages below.

WIP cf_role issue 128

WIP Make cf_role checking funcs individual funcs

WIP individual func and unit test for timeseries (single, multiple station)

WIP func for featureType=timeseriesprofile, unit test

WIP func for featureType=trajectory

WIP func for featureType=trajectoryprofile, unit test

WIP func for featureType=profile, fix up timeseries

WIP remove old function and unit test

WIP backtrack on some new issue comments

All cf_role vars for timeseries_id, profile, trajectory must
have dimensions of 1.

Add space before period

Remove old CF DSG check and unit test

### Current Report example:

```
--------------------------------------------------------------------------------
                         IOOS Compliance Checker Report                         
                                Version unknown                                 
                     Report generated 2020-08-14T19:29:37Z                      
                                    ioos:1.2                                    
      https://ioos.github.io/ioos-metadata/ioos-metadata-profile-v1-2.html      
--------------------------------------------------------------------------------
                               Corrective Actions                               
OOI_CE09OSSM.ncCF has 29 potential issues


                               Highly Recommended                               
--------------------------------------------------------------------------------
CF Discrete Sampling Geometry Compliance
* Dimension length of the variable with cf_role='timeseries_id (the 'station' dimension) is 8. Note that the IOOS profile restricts timeSeries datasets w
ith multiple features to share the same lat/lon position (ie. to exist on the same platform). Datasets that include multiple platforms are not valid and 
will cause harvesting errors.

NDBC/GTS Ingest Requirements
* The following variables qualified for NDBC/GTS Ingest: 

* The following variables did not qualify for NDBC/GTS Ingest: net_downward_longwave_flux_in_air, sea_water_pressure, sea_water_salinity, sea_water_sigma
_t, wind_from_direction, mass_concentration_of_oxygen_in_sea_water, wind_speed, sea_surface_wave_significant_height, sea_water_ph_reported_on_total_scale
, surface_partial_pressure_of_carbon_dioxide_in_sea_water, H2_Conductiv, mole_concentration_of_nitrate_in_sea_water, cdom, surface_air_pressure, air_temp
erature, mass_concentration_of_chlorophyll_in_sea_water, sea_surface_wave_mean_period, eastward_sea_water_velocity, relative_humidity, net_downward_short
wave_flux_in_air, northward_sea_water_velocity, surface_partial_pressure_of_carbon_dioxide_in_air, sea_water_temperature, sea_surface_wave_peak_period, s
ea_surface_wave_to_direction


cf_role
* Platform variable "station_platform" must have a cf_role attribute with one of the values ['profile_id', 'timeseries_id', 'trajectory_id']
...
```

### Report with new checks:
```
--------------------------------------------------------------------------------
                         IOOS Compliance Checker Report                         
                                Version unknown                                 
                     Report generated 2020-08-14T19:30:54Z                      
                                    ioos:1.2                                    
      https://ioos.github.io/ioos-metadata/ioos-metadata-profile-v1-2.html      
--------------------------------------------------------------------------------
                               Corrective Actions                               
OOI_CE09OSSM.ncCF has 28 potential issues


                               Highly Recommended                               
--------------------------------------------------------------------------------
CF DSG: featureType=timeseries
* Dimension length of variable with cf_role=timeseries_id (the 'station' dimension) is 8. The IOOS Profile restricts timeSeries datasets with multiple fe
atures to share the same lat/lon position (i.e. to exist on the same platform). Datasets that include multiple platforms are not valid and will cause har
vesting errors.

NDBC/GTS Ingest Requirements
* The following variables qualified for NDBC/GTS Ingest: 

* The following variables did not qualify for NDBC/GTS Ingest: relative_humidity, sea_water_ph_reported_on_total_scale, cdom, sea_surface_wave_significan
t_height, surface_air_pressure, wind_from_direction, surface_partial_pressure_of_carbon_dioxide_in_sea_water, net_downward_longwave_flux_in_air, mass_con
centration_of_oxygen_in_sea_water, net_downward_shortwave_flux_in_air, eastward_sea_water_velocity, sea_surface_wave_mean_period, sea_water_pressure, mol
e_concentration_of_nitrate_in_sea_water, sea_water_temperature, air_temperature, northward_sea_water_velocity, sea_surface_wave_to_direction, surface_par
tial_pressure_of_carbon_dioxide_in_air, sea_water_salinity, H2_Conductiv, mass_concentration_of_chlorophyll_in_sea_water, sea_surface_wave_peak_period, s
ea_water_sigma_t, wind_speed
```

All variables are now checked for `cf_role` attribute rather than _just_ the "platform variables". The attribute is validated that it matches what is expected.

Checks are refactored to be more modular.


Patch:
diff --git a/compliance_checker/ioos.py b/compliance_checker/ioos.py
index 20e5edb..a10c0a2 100644
--- a/compliance_checker/ioos.py
+++ b/compliance_checker/ioos.py
@@ -818,39 +818,294 @@ class IOOS1_2Check(IOOSNCCheck):
                 )
         return results
 
-    def check_platform_variable_cf_role(self, ds):
+    def check_cf_role_variables(self, ds):
+        """
+        The IOOS-1.2 specification details the following requirements regarding
+        the cf_role attribute and its relation to variable dimensionality:
+
+          cf_role may be applied to the "Platform Variable", as indicated by
+          geophysical_variable:platform, but it may also be an independent
+          variable. To comply with the single platform per dataset rule of
+          the IOOS Metadata Profile, the cf_role variable will typically
+          have a dimension of 1, unless it is a TimeSeries dataset following
+          the 'TimeSeries - multiple station' format.
+
+        To summarize the rules checked in this method:
+          - 'timeseries', cf_role var must have dim 1
+          - 'timeseriesprofile' must have
+            cf_role=timeseries_id variable have dim 1 and dim of cf_role=profile_id
+            can be > 1
+          - 'trajectory' or 'trajectoryprofile' variable with cf_role=trajectory_id
+            must have dim 1, cf_role=profile_id variable can be > 1
+
+        Relevant documentation found in the specification as well as GitHub issues:
+        https://github.com/ioos/compliance-checker/issues/748#issuecomment-606659685
+        https://github.com/ioos/compliance-checker/issues/828
         """
-        Verify that any platform variables have valid CF roles
 
-        Args:
-            ds (netCDF-4 Dataset): open Dataset object
+        fType = getattr(ds, "featureType", None)
+        if (not fType) or (not isinstance(fType, str)):  # can't do anything, pass
+            return Result(
+                BaseCheck.MEDIUM,
+                False,
+                "CF DSG: Invalid featureType",
+                [
+                    (
+                        f"Invalid featureType '{fType}'; please see the "
+                        "IOOS 1.2 Profile and CF-1.7 Conformance documents for valid featureType"
+                    )
+                ]
+            )
+        featType = fType.lower()
 
-        Returns:
-            list of Result objects
-        """
-        valid_cf_roles = {"timeseries_id", "profile_id", "trajectory_id"}
-
-        cf_role_results = []
-        for var in self.platform_vars:
-            attr_check(
-                ("cf_role", valid_cf_roles),
-                ds,
-                BaseCheck.HIGH,
-                cf_role_results,
-                var_name=var.name,
+        if featType == "timeseries":
+            return self._check_feattype_timeseries_cf_role(ds)
+
+        elif featType == "timeseriesprofile":
+            return self._check_feattype_timeseriesprof_cf_role(ds)
+
+        elif featType == "trajectory":
+            return self._check_feattype_trajectory_cf_role(ds)
+
+        elif featType == "trajectoryprofile":
+            return self._check_feattype_trajprof_cf_role(ds)
+
+        elif featType == "profile":
+            return self._check_feattype_profile_cf_role(ds)
+
+        elif featType == "point":
+            return good_result  # can't do anything
+
+        else:
+            return Result(
+                BaseCheck.MEDIUM,
+                False,
+                "CF DSG: Unknown featureType",
+                [
+                    (
+                        f"Invalid featureType '{featType}'; please see the "
+                        "IOOS 1.2 Profile and CF-1.7 Conformance documents for valid featureType"
+                    )
+                ],
             )
-        # there should really only be one platform variable, but in case
-        # someone mistakenly puts multiple, print out any errors for each,
-        # as multiple platform vars will raise its own separate errors
-        for result in cf_role_results:
-            if result.value[0] != 2:
-                result.msgs[0] = (
-                    'Platform variable "{}" must have a '
-                    "cf_role attribute with one of the "
-                    "values {}".format(result.variable_name, sorted(valid_cf_roles))
+
+    def _check_feattype_timeseries_cf_role(self, ds):
+
+        ts_msg = (
+            "Dimension length of variable with cf_role={cf_role} "
+            "(the '{dim_type}' dimension) is {dim_len}. "
+            "The IOOS Profile restricts timeSeries "
+            "datasets with multiple features to share the same lat/lon position "
+            "(i.e. to exist on the same platform). Datasets that include multiple "
+            "platforms are not valid and will cause harvesting errors."
+        )
+
+        # looking for cf_role=timeseries_id
+        cf_role_vars = ds.get_variables_by_attributes(cf_role="timeseries_id")
+        if (not cf_role_vars) or (len(cf_role_vars) > 1):
+            _val = False
+            msgs = [
+                (
+                    "The IOOS-1.2 Profile specifies a single variable "
+                    "must be present with attribute cf_role=timeseries_id"
+                )
+            ]
+
+        else:
+            _v = cf_role_vars[0]
+            _dims = _v.get_dims()
+            if not _dims:
+                _dimsize = 0
+            else:
+                _dimsize = _dims[0].size
+
+            # dimension size must be == 1
+            _val = _dimsize == 1
+            msgs = [
+                ts_msg.format(
+                    cf_role="timeseries_id", dim_type="station", dim_len=_dimsize
+                )
+            ]
+
+        return Result(
+            BaseCheck.HIGH,
+            _val,
+            "CF DSG: featureType=timeseries",
+            msgs,
+        )
+
+    def _check_feattype_timeseriesprof_cf_role(self, ds):
+
+        ts_prof_msg = (
+            "Dimension length of non-platform variable with cf_role={cf_role} "
+            " (the '{dim_type}' dimension) is {dim_len}. "
+            "The IOOS profile restricts timeSeriesProfile datasets to a "
+            "single platform (ie. station) per dataset "
+            "(the profile dimension is permitted to be >= 1."
+        )
+
+        # looking for cf_roles timeseries_id and profile_id
+        cf_role_vars = []  # extend in specific order for easier checking
+        cf_role_vars.extend(ds.get_variables_by_attributes(cf_role="timeseries_id"))
+        cf_role_vars.extend(ds.get_variables_by_attributes(cf_role="profile_id"))
+
+        if len(cf_role_vars) != 2:
+            _val = False
+            msgs = [
+                (
+                    "Datasets of featureType=timeSeriesProfile must have variables "
+                    "containing cf_role=timeseries_id and cf_role=profile_id"
+                )
+            ]
+
+        else:
+            _ts_id_dims = cf_role_vars[0].get_dims()  # timeseries_id dimensions
+            if not _ts_id_dims:
+                _ts_id_dimsize = 0
+            else:
+                _ts_id_dimsize = _ts_id_dims[0].size
+
+            _pf_id_dims = cf_role_vars[1].get_dims()  # profilie_id dimensions
+            if not _pf_id_dims:
+                _pf_id_dimsize = 0
+            else:
+                _pf_id_dimsize = _pf_id_dims[0].size
+
+            # timeseries_id must be == 1, profile >= 1
+            _val = _ts_id_dimsize == 1 and _pf_id_dimsize >= 1
+            msgs = [
+                       ts_prof_msg.format(
+                           cf_role="timeseries_id",
+                           dim_type="station",
+                           dim_len=_ts_id_dimsize
+                       )
+                   ]
+
+        return Result(
+            BaseCheck.HIGH, _val, "CF DSG: featureType=timeSeriesProfile", msgs
+        )
+
+    def _check_feattype_trajectory_cf_role(self, ds):
+        trj_msg = (
+            "Dimension length of non-platform variable with cf_role={cf_role} "
+            " (the '{dim_type}' dimension) is {dim_len}. "
+            "The IOOS profile restricts trjectory "
+            "datasets to a single platform (i.e. trajectory) per dataset."
+        )
+
+        cf_role_vars = ds.get_variables_by_attributes(cf_role="trajectory_id")
+
+        if len(cf_role_vars) != 1:
+            _val = False
+            msgs = [
+                (
+                    "Datasets of featureType=trajectory must have a variable "
+                    "containing cf_role=trajectory_id"
+                )
+            ]
+
+        else:
+            _v = cf_role_vars[0]
+            _dims = _v.get_dims()
+            if not _dims:
+                _dimsize = 0
+            else:
+                _dimsize = _dims[0].size
+
+            # trajectory dimension must be 1
+            _val = _dimsize == 1
+            msgs = [
+                trj_msg.format(
+                    cf_role="trajectory_id", dim_type="station", dim_len=_dimsize
                 )
+            ]
+
+        return Result(BaseCheck.HIGH, _val, "CF DSG: featureType=trajectory", msgs)
+
+    def _check_feattype_trajectoryprof_cf_role(self, ds):
+        trj_prof_msg = (
+            "Dimension length of non-platform variable with cf_role={cf_role} "
+            "(the '{dim_type}' dimension) is {dim_len}. "
+            "The IOOS profile restricts trjectory and trajectoryProfile "
+            "datasets to a single platform (ie. trajectory) per dataset "
+            "(the profile dimension is permitted to be >= 1)."
+        )
+
+        # looking for cf_roles trajectory_id and profile_id
+        cf_role_vars = []  # extend in specific order for easier checking
+        cf_role_vars.extend(ds.get_variables_by_attributes(cf_role="trajectory_id"))
+        cf_role_vars.extend(ds.get_variables_by_attributes(cf_role="profile_id"))
+
+        if len(cf_role_vars) != 2:
+            _val = False
+            msgs = [
+                (
+                    "Datasets of featureType=trajectoryProfile must have variables "
+                    "containing cf_role=trajectory_id and cf_role=profile_id"
+                )
+            ]
+
+        else:
+            _trj_id_dims = cf_role_vars[0].get_dims()
+            if not _trj_id_dims:
+                _trj_id_dimsize = 0
+            else:
+                _trj_id_dimsize = _trj_id_dims[0].size
+
+            _prf_id_dims = cf_role_vars[1].get_dims()
 
-        return cf_role_results
+            if not _prf_id_dims:
+                _prf_id_dimsize = 0
+            else:
+                _prf_id_dimsize = _prf_id_dims[0].size
+
+            # trajectory dim must be == 1, profile must be >= 1
+            _val = _trj_id_dimsize == 1 and _prf_id_dimsize >= 1
+            msgs = [
+                       trj_prof_msg.format(
+                           cf_role="trajectory_id",
+                           dim_type="station",
+                           dim_len=_trj_id_dimsize
+                        )
+                   ]
+
+        return Result(
+            BaseCheck.HIGH, _val, "CF DSG: featureType=trajectoryProfile", msgs
+        )
+
+    def _check_feattype_profile_cf_role(self, ds):
+        prof_msg = (
+            "Dimension length of non-platform variable with cf_role={cf_role} "
+            " (the '{dim_type}' dimension) is {dim_len}. "
+            "The IOOS profile restricts profile datasets to a single "
+            "platform (ie. profile) per dataset."
+        )
+
+        # looking for cf_role=profile_id
+        cf_role_vars = ds.get_variables_by_attributes(cf_role="profile_id")
+        if (not cf_role_vars) or (len(cf_role_vars) > 1):
+            _val = False
+            msgs = [
+                "None or multiple variables found with cf_role=profile_id; only one is allowed"
+            ]
+
+        else:
+            _v = cf_role_vars[0]
+            _dims = _v.get_dims()
+            if not _dims:
+                _dimsize = 0
+            else:
+                _dimsize = _dims[0].size
+
+            # only one profile valid
+            _val = _dimsize == 1
+            msgs = [
+                prof_msg.format(
+                    cf_role="profile_id", dim_type="profile", dim_len=_dimsize
+                )
+            ]
+
+        return Result(BaseCheck.HIGH, _val, "CF DSG: featureType=profile", msgs)
 
     def check_creator_and_publisher_type(self, ds):
         """
@@ -963,103 +1218,6 @@ class IOOS1_2Check(IOOSNCCheck):
 
         return Result(BaseCheck.HIGH, val, "platform", None if val else [msg])
 
-    def check_cf_dsg(self, ds):
-        """
-        Check that the dataset follows the restrictions for CF Discrete
-        Sampling Geometries set by the IOOS Metadata Profile.
-
-        Examine each variable with a cf_role. For each (featureType, cf_role):
-            - (timeSeries, timeseries_id)
-            - (timeSeriesProfile, timeseries_id)
-            - (trajectory, trajectory_id)
-            - (trajectoryProfile, trajectory_id)
-            - (profile, profile_id)
-        the dimension of the variable should be 1. For datasets with
-        the "point" featureType, do nothing.
-
-
-        https://github.com/ioos/compliance-checker/issues/748#issuecomment-606659685
-
-        Parameters
-        ----------
-        ds: netCDF4.Dataset (open)
-
-        Returns
-        -------
-        list of Result objects
-        """
-
-        results = []
-
-        feature_type = getattr(ds, "featureType", "").lower()
-        if not feature_type:
-            return results
-
-        # loop through all variables with cf_role
-        cf_role_vars = ds.get_variables_by_attributes(cf_role=lambda x: x is not None)
-        num_cf_role_vars = len(cf_role_vars)
-
-        for var in cf_role_vars:
-            cf_role = getattr(var, "cf_role")
-            shp = var.shape[0] if len(var.shape) > 0 else 1
-
-            if feature_type == "timeseries" and cf_role == "timeseries_id":
-                msg = (
-                    "Dimension length of the variable with "
-                    "cf_role='timeseries_id (the 'station' dimension) "
-                    "is {dim}. Note that the IOOS profile restricts "
-                    "timeSeries datasets with multiple features to share "
-                    "the same lat/lon position (ie. to exist on the same "
-                    "platform). Datasets that include multiple platforms "
-                    "are not valid and will cause harvesting errors."
-                ).format(dim=shp)
-
-                _val = shp <= 1  # fails if > 1
-
-            elif (  # even though tested condition is the same, different msg
-                feature_type == "profile"
-                and cf_role == "profile_id"
-                or feature_type == "trajectory"
-                and cf_role == "trajectory_id"
-                or feature_type == "timeseriesprofile"
-                and cf_role == "timeseries_id"
-                or feature_type == "trajectoryprofile"
-                and cf_role == "trajectory_id"
-            ):
-
-                msg = (
-                    "Dimension length of the variable `{cf_role_var}` with "
-                    "cf_role=`{cf_role}` (the 'station/trajectory/profile' "
-                    "dimension) must be equal to 1 (it is {dim}). The IOOS "
-                    "profile restricts {feature_type} datasets to a "
-                    "single platform (ie. station/trajectory/profile) per dataset."
-                ).format(
-                    cf_role_var=var.name,
-                    cf_role=cf_role,
-                    dim=shp,
-                    feature_type=feature_type,
-                )
-
-                _val = shp == 1
-
-            elif feature_type == "point":  # do nothing
-                _val = True
-
-            else:  # featureType and cf_role don't match up to our restrictions
-                _val = True
-
-            if not _val:
-                results.append(
-                    Result(
-                        BaseCheck.HIGH,
-                        _val,
-                        "CF Discrete Sampling Geometry Compliance",
-                        [msg],
-                    )
-                )
-
-        return results
-
     def check_platform_vocabulary(self, ds):
         """
         The platform_vocabulary attribute is recommended to be a URL to


Test patch:
diff --git a/compliance_checker/tests/test_ioos_profile.py b/compliance_checker/tests/test_ioos_profile.py
index 7e32706..4648e95 100644
--- a/compliance_checker/tests/test_ioos_profile.py
+++ b/compliance_checker/tests/test_ioos_profile.py
@@ -687,37 +687,6 @@ class TestIOOS1_2(BaseTestCase):
         v = IOOS1_2_PlatformIDValidator()
         self.assertFalse(v.validate(attn, attv)[0])
 
-    def test_check_platform_cf_role(self):
-        """
-        Check that cf_role inside platform variables only allows certain
-        values, namely "profile_id", "timeseries_id", or "trajectory_id"
-        """
-        ds = MockTimeSeries()
-        plat_var = ds.createVariable("platform", np.int8, ())
-        ds.variables["depth"].platform = "platform"
-        self.ioos.setup(ds)
-        results = self.ioos.check_platform_variable_cf_role(ds)
-        # don't set attribute, should raise error about attribute not
-        # existing
-        self.assertEqual(len(results), 1)
-        score, out_of = results[0].value
-        self.assertLess(score, out_of)
-        # set to invalid value
-        plat_var.setncattr("cf_role", "bad_value")
-        results = self.ioos.check_platform_variable_cf_role(ds)
-        self.assertLess(score, out_of)
-        expected_vals = {"profile_id", "timeseries_id", "trajectory_id"}
-        expect_msg = (
-            'Platform variable "platform" must have a cf_role attribute '
-            "with one of the values {}".format(sorted(expected_vals))
-        )
-        self.assertEqual(results[0].msgs, [expect_msg])
-        # set to valid value
-        plat_var.setncattr("cf_role", "timeseries_id")
-        results = self.ioos.check_platform_variable_cf_role(ds)
-        score, out_of = results[0].value
-        self.assertEqual(score, out_of)
-
     def test_check_platform_global(self):
         ds = MockTimeSeries()  # time, lat, lon, depth
 
@@ -775,158 +744,6 @@ class TestIOOS1_2(BaseTestCase):
         result = self.ioos.check_single_platform(ds)
         self.assertFalse(result.value)
 
-    def test_check_cf_dsg(self):
-
-        ds = MockTimeSeries()  # time, lat, lon, depth
-        ds.setncattr("platform", "single_string")
-
-        # correct cf_role & featureType, pass
-        ds.setncattr("featureType", "profile")
-        ds.createDimension("profile", 1)
-        temp = ds.createVariable("temp", "d", ("time"))
-        temp.setncattr("platform", "platform_var")
-        plat = ds.createVariable("platform_var", np.byte)
-        cf_role_var = ds.createVariable("cf_role_var", np.byte, ("profile",))
-        cf_role_var.setncattr("cf_role", "timeseries_id")
-        results = self.ioos.check_cf_dsg(ds)
-        self.assertTrue(all(r.value for r in results))
-        self.assertTrue(all(r.msgs == [] for r in results))
-
-        # correct featureType, incorrect cf_role var dimension
-        ds = MockTimeSeries()  # time, lat, lon, depth
-        ds.setncattr("featureType", "trajectoryprofile")
-        ds.createDimension("trajectory", 2)  # should only be 1
-        temp = ds.createVariable("temp", "d", ("time"))
-        temp.setncattr("platform", "platform_var")
-        plat = ds.createVariable("platform_var", np.byte)
-        cf_role_var = ds.createVariable("cf_role_var", np.byte, ("trajectory",))
-        cf_role_var.setncattr("cf_role", "trajectory_id")
-        results = self.ioos.check_cf_dsg(ds)
-        self.assertFalse(results[0].value)
-
-        # featureType==timeSeries, cf_role=timeseries_id
-        ds = MockTimeSeries()
-        ds.setncattr("featureType", "timeSeries")
-        ds.createDimension("station", 1)
-        temp = ds.createVariable("temp", "d", ("time"))
-        temp.setncattr("platform", "platform_var")
-        plat = ds.createVariable("platform_var", np.byte)
-        cf_role_var = ds.createVariable("cf_role_var", np.byte, ("station",))
-        cf_role_var.setncattr("cf_role", "timeseries_id")
-        results = self.ioos.check_cf_dsg(ds)
-
-        # check should pass with no results
-        self.assertEqual(results, [])
-
-        # featureType==timeSeriesProfile, cf_role==timeseries_id, dim 1, pass
-        ds = MockTimeSeries()
-        ds.setncattr("featureType", "timeSeriesProfile")
-        ds.createDimension("station", 1)
-        temp = ds.createVariable("temp", "d", ("time"))
-        temp.setncattr("platform", "platform_var")
-        plat = ds.createVariable("platform_var", np.byte)
-        cf_role_var = ds.createVariable("cf_role_var", np.byte, ("station",))
-        cf_role_var.setncattr("cf_role", "timeseries_id")
-        results = self.ioos.check_cf_dsg(ds)
-        self.assertEqual(results, [])
-
-        # featureType==timeSeriesProfile, cf_role==timeseries_id, dim 2, fail
-        ds = MockTimeSeries()
-        ds.setncattr("platform", "platform")
-        ds.setncattr("featureType", "timeSeriesProfile")
-        ds.createDimension("station", 2)
-        temp = ds.createVariable("temp", "d", ("time"))
-        temp.setncattr("platform", "platform_var")
-        plat = ds.createVariable("platform_var", np.byte)
-        cf_role_var = ds.createVariable("cf_role_var", np.byte, ("station",))
-        cf_role_var.setncattr("cf_role", "timeseries_id")
-        results = self.ioos.check_cf_dsg(ds)
-        self.assertFalse(results[0].value)
-
-        # featureType==trajectory, cf_role==trajectory_id, dim 1, pass
-        ds = MockTimeSeries()
-        ds.setncattr("featureType", "trajectory")
-        ds.createDimension("trajectory", 1)
-        temp = ds.createVariable("temp", "d", ("time"))
-        temp.setncattr("platform", "platform_var")
-        plat = ds.createVariable("platform_var", np.byte)
-        cf_role_var = ds.createVariable("cf_role_var", np.byte, ("trajectory",))
-        cf_role_var.setncattr("cf_role", "trajectory_id")
-        results = self.ioos.check_cf_dsg(ds)
-        self.assertEqual(results, [])
-
-        # featureType==trajectory, cf_role==trajectory, dim 2, fail
-        ds = MockTimeSeries()
-        ds.setncattr("featureType", "trajectory")
-        ds.createDimension("trajectory", 2)
-        temp = ds.createVariable("temp", "d", ("time"))
-        temp.setncattr("platform", "platform_var")
-        plat = ds.createVariable("platform_var", np.byte)
-        cf_role_var = ds.createVariable("cf_role_var", np.byte, ("trajectory",))
-        cf_role_var.setncattr("cf_role", "trajectory_id")
-        results = self.ioos.check_cf_dsg(ds)
-        self.assertFalse(results[0].value)
-
-        # featureType==trajectoryProfile, cf_role==trajectory_id, dim 1, pass
-        ds = MockTimeSeries()
-        ds.setncattr("featureType", "trajectoryProfile")
-        ds.createDimension("trajectoryprof", 1)
-        temp = ds.createVariable("temp", "d", ("time"))
-        temp.setncattr("platform", "platform_var")
-        plat = ds.createVariable("platform_var", np.byte)
-        cf_role_var = ds.createVariable("cf_role_var", np.byte, ("trajectoryprof",))
-        cf_role_var.setncattr("cf_role", "trajectory_id")
-        results = self.ioos.check_cf_dsg(ds)
-        self.assertEqual(results, [])
-
-        # featureType==trajectoryProfile, cf_role==trajectory_id, dim 2, fail
-        ds = MockTimeSeries()
-        ds.setncattr("featureType", "trajectoryProfile")
-        ds.createDimension("trajectoryprof", 2)
-        temp = ds.createVariable("temp", "d", ("time"))
-        temp.setncattr("platform", "platform_var")
-        plat = ds.createVariable("platform_var", np.byte)
-        cf_role_var = ds.createVariable("cf_role_var", np.byte, ("trajectoryprof",))
-        cf_role_var.setncattr("cf_role", "trajectory_id")
-        results = self.ioos.check_cf_dsg(ds)
-        self.assertFalse(results[0].value)
-
-        # featureType==profile, cf_role==profile_id, dim 1, pass
-        ds = MockTimeSeries()
-        ds.setncattr("featureType", "profile")
-        ds.createDimension("prof", 1)
-        temp = ds.createVariable("temp", "d", ("time"))
-        temp.setncattr("platform", "platform_var")
-        plat = ds.createVariable("platform_var", np.byte)
-        cf_role_var = ds.createVariable("cf_role_var", np.byte, ("prof",))
-        cf_role_var.setncattr("cf_role", "profile_id")
-        results = self.ioos.check_cf_dsg(ds)
-        self.assertEqual(results, [])
-
-        # featureType==profile, cf_role==profile_id, dim 2, fail
-        ds = MockTimeSeries()
-        ds.setncattr("featureType", "profile")
-        ds.createDimension("prof", 2)
-        temp = ds.createVariable("temp", "d", ("time"))
-        temp.setncattr("platform", "platform_var")
-        plat = ds.createVariable("platform_var", np.byte)
-        cf_role_var = ds.createVariable("cf_role_var", np.byte, ("prof",))
-        cf_role_var.setncattr("cf_role", "profile_id")
-        results = self.ioos.check_cf_dsg(ds)
-        self.assertFalse(results[0].value)
-
-        # featureType==point -- do nothing
-        ds = MockTimeSeries()
-        ds.setncattr("featureType", "point")
-        ds.createDimension("blah", 2)
-        temp = ds.createVariable("temp", "d", ("time"))
-        temp.setncattr("platform", "platform_var")
-        plat = ds.createVariable("platform_var", np.byte)
-        cf_role_var = ds.createVariable("cf_role_var", np.byte, ("blah",))
-        cf_role_var.setncattr("cf_role", "profile_id")
-        results = self.ioos.check_cf_dsg(ds)
-        self.assertEqual(results, [])
-
     def test_check_platform_vocabulary(self):
         ds = MockTimeSeries()  # time, lat, lon, depth
         ds.setncattr("platform_vocabulary", "http://google.com")
@@ -1085,6 +902,234 @@ class TestIOOS1_2(BaseTestCase):
             result = self.ioos.check_vertical_coordinates(nc_obj)[0]
             self.assertEqual(*result.value)
 
+    def test_check_feattype_timeseries_cf_role(self):
+
+        ### featureType: timeseries and timeseries - msingle station require same tests ###
+
+        # for ftype in ("timeseries", "timeseries - single station", "timeseries - multiple station"):
+        ftype = "timeseries"
+        ds = MockTimeSeries()  # time, lat, lon, depth
+        ds.setncattr("featureType", ftype)
+        temp = ds.createVariable("temp", "d", ("time"))
+
+        # no platform variables or geophys vars with cf_role=timeseries_id, fail
+        result = self.ioos._check_feattype_timeseries_cf_role(ds)
+        self.assertFalse(result.value)
+
+        # create dimensionless platform variable, set bad cf_role
+        plat = ds.createVariable("station", "|S1", ())
+        plat.setncattr("cf_role", "badbadbad")
+        ds.variables["temp"].setncattr("platform", "station")
+
+        # should still fail as no vars with cf_role=timeseries_id exist
+        result = self.ioos._check_feattype_timeseries_cf_role(ds)
+        self.assertFalse(result.value)
+
+        # set correct cf_role on platform var, should fail as dim == 0
+        plat.setncattr("cf_role", "timeseries_id")
+        result = self.ioos._check_feattype_timeseries_cf_role(ds)
+        self.assertFalse(result.value)
+
+        # remove platform variable, put cf_role on variable "station", should fail as dim = 0
+        ds = MockTimeSeries()  # time, lat, lon, depth
+        ds.createDimension("timeseries_dim", 0)
+        ds.setncattr("featureType", ftype)
+        plat = ds.createVariable("station", "|S1", dimensions=("timeseries_dim"))
+        plat.setncattr("cf_role", "timeseries_id")
+        result = self.ioos._check_feattype_timeseries_cf_role(ds)
+        self.assertFalse(result.value)
+
+        # remove cf_role from station variable, put it on another with dim = 1
+        ds = MockTimeSeries()  # time, lat, lon, depth
+        ds.createDimension("station_dim", 1)
+        ds.setncattr("featureType", ftype)
+        plat = ds.createVariable("station", "|S1", dimensions=("station_dim"))
+        plat.setncattr("cf_role", "timeseries_id")
+        result = self.ioos._check_feattype_timeseries_cf_role(ds)
+        self.assertTrue(result.value)
+
+        # featureType = timeseries - multiple station can have cf_role be on a var with dim >= 1
+        # but still fails the check so as to display message
+        ds = MockTimeSeries()  # time, lat, lon, depth
+        ds.createDimension("station_dim", 21)
+        ds.setncattr("featureType", "timeseries")
+        temp = ds.createVariable("temp", "d", ("time"))
+        plat = ds.createVariable("station", "|S1", dimensions=("station_dim"))
+        plat.setncattr("cf_role", "timeseries_id")
+        result = self.ioos._check_feattype_timeseries_cf_role(ds)
+        self.assertFalse(result.value)
+
+    def test_check_feattype_timeseriesprof_cf_role(self):
+        ftype = "timeseriesprofile"
+        ds = MockTimeSeries()  # time, lat, lon, depth
+        ds.setncattr("featureType", ftype)
+        ds.createDimension("station_dim", 1)
+        temp = ds.createVariable("temp", "d", ("time"))
+
+        # no platform variables or geophys vars with cf_role=timeseries_id, fail
+        result = self.ioos._check_feattype_timeseriesprof_cf_role(ds)
+        self.assertFalse(result.value)
+
+        # create dimensionless platform variable, set bad cf_role
+        plat = ds.createVariable("station", "|S1", ("station_dim"))
+        plat.setncattr("cf_role", "badbadbad")
+        ds.variables["temp"].setncattr("platform", "station")
+
+        # should still fail as no vars with cf_role=timeseries_id or profile_id exist
+        result = self.ioos._check_feattype_timeseriesprof_cf_role(ds)
+        self.assertFalse(result.value)
+
+        # set correct cf_role on platform var, should still fail, no variable with profile_id
+        plat.setncattr("cf_role", "timeseries_id")
+        result = self.ioos._check_feattype_timeseriesprof_cf_role(ds)
+        self.assertFalse(result.value)
+
+        # add profile_id var with bad dim, fail
+        ds.createDimension("profile_dim", 0)
+        pf = ds.createVariable("profile", "|S1", ("profile_dim"))
+        pf.setncattr("cf_role", "profile_id")
+        result = self.ioos._check_feattype_timeseriesprof_cf_role(ds)
+        self.assertFalse(result.value)
+
+        # make the profile dim 1, no platform variable though
+        ds = MockTimeSeries()  # time, lat, lon, depth
+        ds.setncattr("featureType", ftype)
+        ds.createDimension("station_dim", 1)
+        ds.createDimension("profile_dim", 1)
+        temp = ds.createVariable("temp", "d", ("time"))
+        plat = ds.createVariable("station", "|S1", ("station_dim"))
+        plat.setncattr("cf_role", "timeseries_id")
+        pf = ds.createVariable("profile", "|S1", ("profile_dim"))
+        pf.setncattr("cf_role", "profile_id")
+        result = self.ioos._check_feattype_timeseriesprof_cf_role(ds)
+        self.assertTrue(result.value)
+
+        # profile dim > 1, pass
+        ds = MockTimeSeries()  # time, lat, lon, depth
+        ds.setncattr("featureType", ftype)
+        ds.createDimension("station_dim", 1)
+        ds.createDimension("profile_dim", 21)
+        temp = ds.createVariable("temp", "d", ("time"))
+        plat = ds.createVariable("station", "|S1", ("station_dim"))
+        plat.setncattr("cf_role", "timeseries_id")
+        pf = ds.createVariable("profile", "|S1", ("profile_dim"))
+        pf.setncattr("cf_role", "profile_id")
+        result = self.ioos._check_feattype_timeseriesprof_cf_role(ds)
+        self.assertTrue(result.value)
+
+    def test_check_feattype_trajectory_cf_role(self):
+        ds = MockTimeSeries()  # time, lat, lon, depth
+        ds.setncattr("featureType", "trajectory")
+
+        # no platform variables or geophys vars with cf_role=trajectory_id, fail
+        result = self.ioos._check_feattype_trajectory_cf_role(ds)
+        self.assertFalse(result.value)
+
+        # create dimensionless platform variable, set bad cf_role
+        ds.createDimension("trajectory_dim", 0)
+        trj = ds.createVariable("trajectory", "|S1", ("trajectory_dim"))
+        trj.setncattr("cf_role", "badbadbad")
+        result = self.ioos._check_feattype_trajectory_cf_role(ds)
+        self.assertFalse(result.value)
+
+        # set correct cf_role, should still fail as trajectory is not 1
+        trj.setncattr("cf_role", "trajectory_id")
+        result = self.ioos._check_feattype_trajectory_cf_role(ds)
+        self.assertFalse(result.value)
+
+        # set correct dim size
+        ds = MockTimeSeries()  # time, lat, lon, depth
+        ds.setncattr("featureType", "trajectory")
+        ds.createDimension("trajectory_dim", 1)
+        trj = ds.createVariable("trajectory", "|S1", ("trajectory_dim"))
+        trj.setncattr("cf_role", "trajectory_id")
+        result = self.ioos._check_feattype_trajectory_cf_role(ds)
+        self.assertTrue(result.value)
+
+    def test_check_feattype_trajectoryprof_cf_role(self):
+        ds = MockTimeSeries()  # time, lat, lon, depth
+        ds.setncattr("featureType", "trajectoryprofile")
+        ds.createDimension("trajectory_dim", 1)
+
+        # no platform variables or geophys vars with cf_role=timeseries_id, fail
+        result = self.ioos._check_feattype_trajectoryprof_cf_role(ds)
+        self.assertFalse(result.value)
+
+        # create dimensionless platform variable, set bad cf_role
+        trj = ds.createVariable("station", "|S1", ("trajectory_dim"))
+        trj.setncattr("cf_role", "badbadbad")
+
+        # should still fail as no vars with cf_role=timeseries_id or profile_id exist
+        result = self.ioos._check_feattype_trajectoryprof_cf_role(ds)
+        self.assertFalse(result.value)
+
+        # set correct cf_role on platform var, should still fail, no variable with profile_id
+        trj.setncattr("cf_role", "trajectory_id")
+        result = self.ioos._check_feattype_trajectoryprof_cf_role(ds)
+        self.assertFalse(result.value)
+
+        # add profile_id var with bad dim, fail
+        ds.createDimension("profile_dim", 0)
+        pf = ds.createVariable("profile", "|S1", ("profile_dim"))
+        pf.setncattr("cf_role", "profile_id")
+        result = self.ioos._check_feattype_trajectoryprof_cf_role(ds)
+        self.assertFalse(result.value)
+
+        # make the profile dim 1, no platform variable though
+        ds = MockTimeSeries()  # time, lat, lon, depth
+        ds.setncattr("featureType", "trajectoryprofile")
+        ds.createDimension("trajectory_dim", 1)
+        ds.createDimension("profile_dim", 1)
+        temp = ds.createVariable("temp", "d", ("time"))
+        trj = ds.createVariable("trajectory", "|S1", ("trajectory_dim"))
+        trj.setncattr("cf_role", "trajectory_id")
+        pf = ds.createVariable("profile", "|S1", ("profile_dim"))
+        pf.setncattr("cf_role", "profile_id")
+        result = self.ioos._check_feattype_trajectoryprof_cf_role(ds)
+        self.assertTrue(result.value)
+
+        # profile dim > 1, pass
+        ds = MockTimeSeries()  # time, lat, lon, depth
+        ds.setncattr("featureType", "trajectoryprofile")
+        ds.createDimension("trajectory_dim", 1)
+        ds.createDimension("profile_dim", 21)
+        temp = ds.createVariable("temp", "d", ("time"))
+        trj = ds.createVariable("trajectory", "|S1", ("trajectory_dim"))
+        trj.setncattr("cf_role", "trajectory_id")
+        pf = ds.createVariable("profile", "|S1", ("profile_dim"))
+        pf.setncattr("cf_role", "profile_id")
+        result = self.ioos._check_feattype_trajectoryprof_cf_role(ds)
+        self.assertTrue(result.value)
+
+    def test_check_feattype_profile_cf_role(self):
+        ds = MockTimeSeries()  # time, lat, lon, depth
+        ds.setncattr("featureType", "profile")
+
+        # no platform variables or geophys vars with cf_role=profile_id, fail
+        result = self.ioos._check_feattype_profile_cf_role(ds)
+        self.assertFalse(result.value)
+
+        # create dimensionless platform variable, set bad cf_role
+        ds.createDimension("profile_dim", 0)
+        prf = ds.createVariable("profile", "|S1", ("profile_dim"))
+        prf.setncattr("cf_role", "badbadbad")
+        result = self.ioos._check_feattype_profile_cf_role(ds)
+        self.assertFalse(result.value)
+
+        # set correct cf_role, should still fail as profile is not 1
+        prf.setncattr("cf_role", "profile_id")
+        result = self.ioos._check_feattype_profile_cf_role(ds)
+        self.assertFalse(result.value)
+
+        # set correct dim size
+        ds = MockTimeSeries()  # time, lat, lon, depth
+        ds.setncattr("featureType", "profile")
+        ds.createDimension("profile_dim", 1)
+        prf = ds.createVariable("profile", "|S1", ("profile_dim"))
+        prf.setncattr("cf_role", "profile_id")
+        result = self.ioos._check_feattype_profile_cf_role(ds)
+        self.assertTrue(result.value)
+
     def test_check_instrument_make_model_calib_date(self):
         """
         Per the IOOS-1.2 spec, instrument variables should have
@@ -1119,4 +1164,4 @@ class TestIOOS1_2(BaseTestCase):
         inst.setncattr("calibration_date", "2020-08-19T00:00:00") # ISO, pass
         results = self.ioos.check_instrument_make_model_calib_date(ds)
         scored, out_of, messages = get_results(results)
-        self.assertEqual(scored, out_of)
+        self.assertEqual(scored, out_of)
\ No newline at end of file