Repository: ioos/compliance-checker
Pull request #858 (2021-04-05 16:25:44)

WIP refactoring/adding featureType checks
### Motivation

Outstanding issue #845 and discussion #857 reveal the nuances and challenges faced when validating a data set's `featureType`. The CF Discrete Sampling Geometries provide many flexible options for the logical structure of storing discretely sampled data in a NetCDF file, but with flexibility comes complexity. This is a fairly large change in the logic behind checking featureTypes.

### What's changed?

Existing Compliance Checker logic checked orthogonal multidimensional array representations. This is the trivial way of storing data and the most easily conceptualized. The CF Discrete Sampling Geometries allow data to be stored in ragged array formats, but the Compliance Checker did not check the validity of such structures. Moreover, because of this, undesirable behavior was documented when running the compliance checker on data sets which in fact did conform to the standard (see #845).

This pull request addresses both the documented issue and the underlying source by adding methods to properly check for DSGs represented by ragged array storage structure. It also addresses the use of outdated features, such as "multi-timeseries-orthogonal" or "profile-incomplete", which are no longer specified in the CF specification. The methods which produced these results are not completely removed as their logic is still strong, but are instead incorporated into "wrapper methods" which output the parent feature instead of the sub-type.

Unit tests are included with the new methods and existing tests are adapted.

### Proof of concept

The undesired behavior in #845 is no longer exhibited:

```
(env38) dalton@ubuntu:~/compliance-checker$ python cchecker.py -t cf test_ragged_profile.nc                         
Running Compliance Checker on the datasets from: ['test_ragged_profile.nc']


--------------------------------------------------------------------------------
                         IOOS Compliance Checker Report                         
                          Version 4.3.4.dev79+g250f0b4                          
                     Report generated 2021-04-05T16:20:02Z                      
                                     cf:1.7                                     
http://cfconventions.org/Data/cf-conventions/cf-conventions-1.7/cf-conventions.html
--------------------------------------------------------------------------------
                               Corrective Actions                               
test_ragged_profile.nc has 9 potential issues


                                     Errors                                     
--------------------------------------------------------------------------------
§3.3 Standard Name
* Attribute long_name or/and standard_name is highly recommended for variable LONGITUDE
* Attribute long_name or/and standard_name is highly recommended for variable TIME
* Attribute long_name or/and standard_name is highly recommended for variable LATITUDE
* Attribute long_name or/and standard_name is highly recommended for variable TEMP
* Attribute long_name or/and standard_name is highly recommended for variable NOMINAL_DEPTH

§4.1 Latitude Coordinate
* latitude variable 'LATITUDE' must define units

§4.2 Longitude Coordinate
* longitude variable 'LONGITUDE' must define units

§4.3 Vertical Coordinate
* NOMINAL_DEPTH: vertical coordinates not defining pressure must include a positive attribute that is either 'up' or 'down'
* §4.3.1 NOMINAL_DEPTH's units must be defined for vertical coordinates, there is no default

§4.4 Time Coordinate
* TIME does not have units


                                    Warnings                                    
--------------------------------------------------------------------------------
§2.6 Attributes
* §2.6.2 global attribute title should exist and be a non-empty string
* §2.6.2 global attribute history should exist and be a non-empty string
* §2.6.1 Conventions field is not present

§3.1 Units
* units (None) attribute of 'LONGITUDE' must be a string compatible with UDUNITS
* units (None) attribute of 'TIME' must be a string compatible with UDUNITS
* units (None) attribute of 'LATITUDE' must be a string compatible with UDUNITS
* units (None) attribute of 'TEMP' must be a string compatible with UDUNITS
* units (None) attribute of 'NOMINAL_DEPTH' must be a string compatible with UDUNITS

§4.1 Latitude Coordinate
* latitude variable 'LATITUDE' should define valid units for latitude

§4.2 Longitude Coordinate
* longitude variable 'LONGITUDE' should define valid units for longitude
```

@mhidas
@benjwadams
@openSourcerer9000
@ocefpaf

This is a WIP, so all feedback is welcome. We can add more here or make modifications elsewhere.



Patch:
diff --git a/compliance_checker/cf/cf.py b/compliance_checker/cf/cf.py
index fab3aa1..82b6ef9 100644
--- a/compliance_checker/cf/cf.py
+++ b/compliance_checker/cf/cf.py
@@ -3041,6 +3041,26 @@ class CF1_6Check(CFNCCheck):
         """
 
         ret_val = []
+
+        # for contiguous ragged array/indexed ragged array representations,
+        # coodinates are not required to adhere to the same principles;
+        # these representaitions can be identified by two attributes:
+
+        # required for contiguous
+        count_vars = ds.get_variables_by_attributes(
+            sample_dimension= lambda x: x is not None
+        )
+
+        # required for indexed
+        index_vars = ds.get_variables_by_attributes(
+            instance_dimension= lambda x: x is not None
+        )
+
+        # if these attributes exist, we don't need to test
+        # the coordinates
+        if count_vars or index_vars:
+            return ret_val
+
         geophysical_variables = self._find_geophysical_vars(ds)
         for name in geophysical_variables:
             variable = ds.variables[name]
@@ -3160,7 +3180,11 @@ class CF1_6Check(CFNCCheck):
 
         return ret_val
 
+    # NOTE **********
+    # IS THIS EVEN NEEDED ANYMORE?
+    # ***************
     def check_grid_coordinates(self, ds):
+    #def _check_grid_coordinates(self, ds):
         """
         5.6 When the coordinate variables for a horizontal grid are not
         longitude and latitude, it is required that the true latitude and
@@ -4324,57 +4348,6 @@ class CF1_6Check(CFNCCheck):
     # Chapter 9: Discrete Sampling Geometries
     ###############################################################################
 
-    def check_all_features_are_same_type(self, ds):
-        """
-        Check that the feature types in a dataset are all the same.
-
-        9.1 The features contained within a collection must always be of the same type; and all the collections in a CF file
-        must be of the same feature type.
-
-        point, timeSeries, trajectory, profile, timeSeriesProfile, trajectoryProfile.
-
-        The space-time coordinates that are indicated for each feature are mandatory.  However a featureType may also include
-        other space-time coordinates which are not mandatory (notably the z coordinate).
-
-        :param netCDF4.Dataset ds: An open netCDF dataset
-        :rtype: compliance_checker.base.Result
-        """
-        all_the_same = TestCtx(BaseCheck.HIGH, self.section_titles["9.1"])
-        feature_types_found = defaultdict(list)
-        # iterate all geophysical variables with at least one dimension
-        for name in (
-            name
-            for name in self._find_geophysical_vars(ds)
-            if ds.variables[name].ndim > 0
-        ):
-            feature = cfutil.guess_feature_type(ds, name)
-            # If we can't figure out the feature type, penalize. Originally,
-            # it was not penalized. However, this led to the issue that the
-            # message did not appear in the output of compliance checker if
-            # no other error/warning/information was printed out for section
-            # 9.1.
-            found = False
-            if feature is not None:
-                feature_types_found[feature].append(name)
-                found = True
-            all_the_same.assert_true(
-                found, "Unidentifiable feature for variable {}" "".format(name)
-            )
-        feature_description = ", ".join(
-            [
-                "{} ({})".format(ftr, ", ".join(vrs))
-                for ftr, vrs in feature_types_found.items()
-            ]
-        )
-
-        all_the_same.assert_true(
-            len(feature_types_found) < 2,
-            "Different feature types discovered in this dataset: {}"
-            "".format(feature_description),
-        )
-
-        return all_the_same.to_result()
-
     def check_feature_type(self, ds):
         """
         Check the global attribute featureType for valid CF featureTypes
@@ -4441,12 +4414,14 @@ class CF1_6Check(CFNCCheck):
 
     def check_variable_features(self, ds):
         """
-        Checks the variable feature types match the dataset featureType attribute
+        Checks the variable feature types match the dataset featureType attribute.
+        If more than one unique feature type is found, report this as an error.
 
         :param netCDF4.Dataset ds: An open netCDF dataset
         :rtype: list
         :return: List of results
         """
+        feature_types_found = defaultdict(list)
         ret_val = []
         feature_list = [
             "point",
@@ -4457,48 +4432,49 @@ class CF1_6Check(CFNCCheck):
             "trajectoryProfile",
         ]
         # Don't bother checking if it's not a legal featureType
+        # if the featureType attribute doesn't exist
         feature_type = getattr(ds, "featureType", None)
         if feature_type not in feature_list:
             return []
 
-        feature_type_map = {
-            "point": ["point"],
-            "timeSeries": [
-                "timeseries",
-                "multi-timeseries-orthogonal",
-                "multi-timeseries-incomplete",
-            ],
-            "trajectory": [
-                "cf-trajectory",
-                "single-trajectory",
-            ],
-            "profile": ["profile-orthogonal", "profile-incomplete"],
-            "timeSeriesProfile": [
-                "timeseries-profile-single-station",
-                "timeseries-profile-multi-station",
-                "timeseries-profile-single-ortho-time",
-                "timeseries-profile-multi-ortho-time",
-                "timeseries-profile-ortho-depth",
-                "timeseries-profile-incomplete",
-            ],
-            "trajectoryProfile": [
-                "trajectory-profile-orthogonal",
-                "trajectory-profile-incomplete",
-            ],
-        }
+        # If a data set is detected as a valid ragged array representation,
+        # regardless of the featureType, all geophysical variables must also
+        # be detected as valid ragged array variables.
+        if cfutil.is_dataset_valid_ragged_array_repr_featureType(ds, feature_type):
+            _feature = "ragged-array"
+        else:
+            _feature = feature_type
+
         for name in self._find_geophysical_vars(ds):
             variable_feature = cfutil.guess_feature_type(ds, name)
             # If we can't figure it out, don't check it.
             if variable_feature is None:
                 continue
+            feature_types_found[variable_feature].append(name)            
             matching_feature = TestCtx(BaseCheck.MEDIUM, self.section_titles["9.1"])
             matching_feature.assert_true(
-                variable_feature in feature_type_map[feature_type],
+                variable_feature == _feature,
                 "{} is not a {}, it is detected as a {}"
-                "".format(name, feature_type, variable_feature),
+                "".format(name, _feature, variable_feature),
             )
             ret_val.append(matching_feature.to_result())
 
+        # create explanation of all of the different featureTypes
+        # found in the dataset
+        feature_description = ", ".join(
+            [
+                "{} ({})".format(ftr, ", ".join(vrs))
+                for ftr, vrs in feature_types_found.items()
+            ]
+        )
+        all_same_features = TestCtx(BaseCheck.HIGH, self.section_titles["9.1"])
+        all_same_features.assert_true(
+            len(feature_types_found) < 2,
+            "Different feature types discovered in this dataset: {}"
+            "".format(feature_description),
+        )
+        ret_val.append(all_same_features.to_result())
+
         return ret_val
 
     def check_hints(self, ds):
@@ -4549,7 +4525,7 @@ class CF1_7Check(CF1_6Check):
     appendix_a = appendix_a_base.copy()
     appendix_a.update(
         {
-            "actual_range": {
+              "actual_range": {
                 "Type": "N",
                 "attr_loc": {"D", "C"},
                 "cf_section": "2.5.1",
diff --git a/compliance_checker/cfutil.py b/compliance_checker/cfutil.py
index 9fcb62f..407f3bb 100644
--- a/compliance_checker/cfutil.py
+++ b/compliance_checker/cfutil.py
@@ -240,8 +240,8 @@ def is_geophysical(ds, variable):
     if variable in get_platform_variables(ds):
         return False
 
-    # Skip count variables too
-    if hasattr(ncvar, "sample_dimension"):
+    # Skip count/index variables too
+    if hasattr(ncvar, "sample_dimension") or hasattr(ncvar, "instance_dimension"):
         return False
 
     return True
@@ -882,6 +882,161 @@ def coordinate_dimension_matrix(nc):
     return retval
 
 
+def is_dataset_valid_ragged_array_repr_featureType(
+    nc,
+    feature_type: str
+):
+    """
+    Check if a data set is a valid representation of a ragged
+    array structure. See inline comments.
+    """
+
+    is_compound = False
+    if feature_type.lower() in {'timeseriesprofile', 'trajectoryprofile'}:
+        is_compound = True
+        ftype = feature_type.lower().split("profile")[0]
+    else:
+        ftype = feature_type.lower()
+
+
+    # regardless of if compound type or not, must have a cf_role
+    # variable; if compound, this will be the first part of the
+    # feature_type as we'll have to search for one with profile_id
+    # regardless; if single feature type, cf_role must match that
+    # featureType
+    cf_role_vars = nc.get_variables_by_attributes(cf_role=lambda x: x is not None)
+    if not cf_role_vars or \
+        (len(cf_role_vars)>1 and not is_compound) or \
+        (len(cf_role_vars)>2 and is_compound):
+        return False
+    cf_role_var = nc.get_variables_by_attributes(cf_role="{}_id".format(ftype))[0]
+    if cf_role_var.cf_role.split("_id")[0].lower() != ftype:
+        return False
+
+    # now we'll check dimensions for singular feature types and/or
+    # the first half of the compound featureType
+    instance_dim = cf_role_var.dimensions
+    if len(instance_dim) != 1:
+        return False
+
+    # Wow we check for the presence of an index variable or count variable;
+    # NOTE that if no index or count variables exist, we can't determine with
+    # certainty that this is invalid, because single-instance data sets
+    # are valid representations of the ragged array structures. Instead,
+    # if the index/count variable is present, we check that only one of
+    # each is present and that their dimensions are correct
+    index_vars = nc.get_variables_by_attributes(instance_dimension=lambda x: x is not None)
+    count_vars = nc.get_variables_by_attributes(sample_dimension=lambda x: x is not None)
+
+    # if the featureType isn't compound, shouldn't have both count and index
+    if (index_vars and count_vars and not is_compound):
+        return False
+
+    # single featureType, checking for valid index variable
+    elif index_vars and not is_compound:
+        if len(index_vars) > 1:
+            return False
+        # the index variable's attr 'instance_dimension'
+        # must be the same as the actual instance dimension,
+        # which we get from the cf_role variable
+        if index_vars[0].instance_dimension != instance_dim[0]:
+            return False
+
+    # single featureType, checking for valid count variable
+    elif count_vars and not is_compound:
+        if len(count_vars) > 1:
+            return False
+        # the count variable must have the same dimensions
+        # as the instance variable, which has the instance
+        # dimension as its dimension
+        if count_vars[0].dimensions != instance_dim:
+            return False
+
+
+    # Now, if the featureType is compound, an index variable
+    # must be present for the profile variable. To verify this, we will
+    # check that the dimension of the index variable is the same dimension
+    # that is present on the variable which has the attribute cf_role=profile_id.
+    # The attribute of the index variable 'instance_dimension' should point to the
+    # name of the dimension of the cf_role variable for either timeSeries or trajectory.
+    # A count variable must also be present, and should have the same dimension,
+    # but its attribute 'sample_dimension' must refer to the dimension, which is
+    # DIFFERENT than the variable with the attribute cf_role=ftype, where ftype is the
+    # first half of the compound featureType (so either timeseries or trajectory).
+    # Thus, the dimension of the count variable must be the same dimension as the
+    # dimension that all the other geophysical variables have.
+    elif (index_vars and count_vars and is_compound):
+        if len(index_vars) > 1 or len(count_vars) > 1:
+            return False
+
+        profile_cf_role_vars = nc.get_variables_by_attributes(cf_role="profile_id")
+        if len(profile_cf_role_vars) > 1:
+            return False
+        profile_cf_role_var = profile_cf_role_vars[0]
+
+        # we first check the dimension of the index variable
+        if index_vars[0].dimensions != profile_cf_role_var.dimensions:
+            return False
+
+        # the attribute 'instance_dimension' must point to the dimension
+        # of the timeseries or trajectory cf_role var
+        if index_vars[0].instance_dimension != cf_role_var.dimensions[0]:
+            return False
+
+        # get all geophysical dims
+        geophysical_dims = [nc[v].dimensions for v in get_geophysical_variables(nc)]
+        if len(set(geophysical_dims)) != 1:
+            return False
+
+        # check the dimension of the count var is the same and that the
+        # sample_dimension attribute points to the same dimension that
+        # the geophysical variables have
+        if count_vars[0].dimensions != profile_cf_role_var.dimensions or \
+           (count_vars[0].sample_dimension,) != geophysical_dims[0]:
+            return False
+
+    else:
+        return False
+
+    return True
+
+
+def is_variable_valid_ragged_array_repr_featureType(
+    nc,
+    variable: str) -> bool:
+    """
+    This method returns a boolean indicating whether the variable
+    is a valid member of a contiguous ragged array representation
+    or indexed ragged array representation of any of the three singular
+    CF featureType types.
+
+    For any ragged array representation, any DATA VARIABLE must have
+    the sample dimension as its sole dimension. Additionally, for any
+    featureType or compound featureType (e.g. timeSeriesProfile), the
+    data variable must have the sample dimension as its dimension.
+    """
+
+    # Get all geophysical variables; should have only one
+    # dimension in the set, and the dimension of the variable
+    # should be equal.
+    geo_vars = get_geophysical_variables(nc)
+    dim_tuples = [nc.variables[v].dimensions for v in geo_vars]
+    if len(set(dim_tuples)) < 1:
+        return False
+
+    # NOTE
+    # Each dimension tuple - there should be only one - should only
+    # have the sample dimension as its sole value. If there are more
+    # than one, we assume the sample dimension is the first. Is this
+    # an appropriate assumption?
+    dim = dim_tuples[0]
+    if len(dim) != 1:
+        return False
+
+    # this is the only thing we have to work with
+    return nc.variables[variable].dimensions == dim
+
+
 def is_point(nc, variable):
     """
     Returns true if the variable is a point feature type
@@ -1037,6 +1192,22 @@ def is_multi_timeseries_incomplete(nc, variable):
         return True
     return False
 
+def isTimeSeries(nc, variable):
+    """
+    Attempt to consolidate all of the disparate timeseries checks.
+    Is this being pragmatic or lazy? I'll argue pragmatic.
+    Confidently pragmatic.
+    """
+
+    # first three check if variable is a valid multidimensional
+    # representation, the last checks if it's a valid ragged array
+    if is_timeseries(nc, variable) or \
+        is_multi_timeseries_orthogonal(nc, variable) or \
+        is_multi_timeseries_incomplete(nc, variable):
+        return True
+
+    return False
+
 
 def is_cf_trajectory(nc, variable):
     """
@@ -1099,6 +1270,17 @@ def is_single_trajectory(nc, variable):
         return False
     return True
 
+def isTrajectory(nc, variable):
+    """
+    Wrapper method for checking if a variable is detected as
+    a trajectory featureType.
+    """
+
+    if is_cf_trajectory(nc, variable) or \
+        is_single_trajectory(nc, variable):
+        return True
+
+    return False
 
 def is_profile_orthogonal(nc, variable):
     """
@@ -1167,6 +1349,40 @@ def is_profile_incomplete(nc, variable):
         return True
     return False
 
+def isProfile(nc, variable: str):
+    """
+    Per Ch 9 of the CF spec, profiles are a part of the CF Discrete
+    Sampling Geometries. Profile data can be logically represented
+    in a file one of four ways: orthogonal multidimensional array,
+    incomplete multidimensional array, contiguous ragged array,
+    and indexed ragged array. If the variable is found to be any
+    valid for any one of these representations, return "profile"
+    else return None.
+
+    The very first part of this function attempts to use legacy code
+    to test if the variable is a profile in the orthogonal multidimensional
+    array representation or the incomplete multidimensional array
+    representation. If neither of these are true, it moves on to testing
+    for the contiguous ragged array and indexed ragged array representations.
+
+    Parameters
+    ----------
+    nc      : netCDF4 Dataset
+    variable: str name of variable
+
+    Returns
+    -------
+    str or None
+    """
+
+    # NOTE
+    # TODO
+    # Does this take into account a single profile? This is a valid profile.
+
+    # first check for orthogonal, incomplete
+    if is_profile_orthogonal(nc, variable) or \
+        is_profile_incomplete(nc, variable):
+        return True
 
 def is_timeseries_profile_single_station(nc, variable):
     """
@@ -1397,6 +1613,26 @@ def is_timeseries_profile_incomplete(nc, variable):
         return True
     return False
 
+def isTimeSeriesProfile(nc, variable):
+    """
+    Wrapper method.
+    Verify if a variable matches with the timeSeriesProfile
+    featureType. According to the CF specification, a data set
+    with timeSeriesProfile features has two cf_role variables:
+    one for the "station" (cf_role=timeseries_id) and one for the
+    "profile" (cf_role=profile_id).
+    """
+
+    if is_timeseries_profile_single_station(nc, variable) or \
+       is_timeseries_profile_multi_station(nc, variable) or \
+       is_timeseries_profile_single_ortho_time(nc, variable) or \
+       is_timeseries_profile_multi_ortho_time(nc, variable) or \
+       is_timeseries_profile_ortho_depth(nc, variable) or \
+       is_timeseries_profile_incomplete(nc, variable):
+        return True
+
+    return False
+
 
 def is_trajectory_profile_orthogonal(nc, variable):
     """
@@ -1474,6 +1710,19 @@ def is_trajectory_profile_incomplete(nc, variable):
     return False
 
 
+def isTrajectoryProfile(nc, variable):
+    """
+    Wrapper method
+    """
+
+    # NOTE
+    # does this take into account single trajectory profile?
+    if is_trajectory_profile_orthogonal(nc, variable) or \
+        is_trajectory_profile_incomplete(nc, variable):
+        return True
+
+    return False
+
 def is_2d_regular_grid(nc, variable):
     """
     Returns True if the variable is a 2D Regular grid.
@@ -1721,36 +1970,19 @@ def guess_feature_type(nc, variable):
     """
     if is_point(nc, variable):
         return "point"
-    if is_timeseries(nc, variable):
+    if isProfile(nc, variable):
+        return "profile"
+    if isTimeSeries(nc, variable):
         return "timeseries"
-    if is_multi_timeseries_orthogonal(nc, variable):
-        return "multi-timeseries-orthogonal"
-    if is_multi_timeseries_incomplete(nc, variable):
-        return "multi-timeseries-incomplete"
-    if is_cf_trajectory(nc, variable):
-        return "cf-trajectory"
-    if is_single_trajectory(nc, variable):
-        return "single-trajectory"
-    if is_profile_orthogonal(nc, variable):
-        return "profile-orthogonal"
-    if is_profile_incomplete(nc, variable):
-        return "profile-incomplete"
-    if is_timeseries_profile_single_station(nc, variable):
-        return "timeseries-profile-single-station"
-    if is_timeseries_profile_multi_station(nc, variable):
-        return "timeseries-profile-multi-station"
-    if is_timeseries_profile_single_ortho_time(nc, variable):
-        return "timeseries-profile-single-ortho-time"
-    if is_timeseries_profile_multi_ortho_time(nc, variable):
-        return "timeseries-profile-multi-ortho-time"
-    if is_timeseries_profile_ortho_depth(nc, variable):
-        return "timeseries-profile-ortho-depth"
-    if is_timeseries_profile_incomplete(nc, variable):
-        return "timeseries-profile-incomplete"
-    if is_trajectory_profile_orthogonal(nc, variable):
-        return "trajectory-profile-orthogonal"
-    if is_trajectory_profile_incomplete(nc, variable):
-        return "trajectory-profile-incomplete"
+    if isTrajectory(nc, variable):
+        return "trajectory"
+    if isTimeSeriesProfile(nc, variable):
+        return "timeseriesprofile"
+    if isTrajectoryProfile(nc, variable):
+        return "trajectoryprofile"
+
+    # TODO
+    # consolidate below into "isGrid" ?
     if is_2d_regular_grid(nc, variable):
         return "2d-regular-grid"
     if is_2d_static_grid(nc, variable):
@@ -1764,6 +1996,13 @@ def guess_feature_type(nc, variable):
     if is_reduced_grid(nc, variable):
         return "reduced-grid"
 
+    # regardless of the declared featureType, a variable can be a valid
+    # ragged array variable if it has a single dimension and if that
+    # dimension is equal to the sample dimension; this information will
+    # be used later to determine if the variable's "feature" matches
+    # the dataset's declared featureType
+    if is_variable_valid_ragged_array_repr_featureType(nc, variable):
+        return "ragged-array"
 
 def units_convertible(units1, units2, reftimeistime=True):
     """


Test patch:
diff --git a/compliance_checker/tests/helpers.py b/compliance_checker/tests/helpers.py
index 0bb78e3..f0d0ae9 100644
--- a/compliance_checker/tests/helpers.py
+++ b/compliance_checker/tests/helpers.py
@@ -64,3 +64,174 @@ class MockVariable(object):
             for att in vars(self)
             if att not in {"ndim", "name", "dtype", "dimensions"}
         ]
+
+class MockRaggedArrayRepr(MockNetCDF):
+    """
+    Class to construct a fake NetCDF dataset using the ragged
+    array structure. User must specify whether the structure should
+    be contiguous ragged array or incomplete ragged array.
+    The user must also specify the featureType of the data set;
+    compound featureTypes are acceptable, and all are case-insensitive.
+
+    Users should note that if a compound featureType is given,
+    only the valid-CF representation is available. This means
+    profiles are organized by contiguous ragged array and an index
+    variable is present to denote which "station" the profile belongs
+    to.
+
+    Data sets which follow the contiguous ragged array structure
+    have a variable called the "count variable" denoting how many
+    elements are in a particular instance. This variable must have the
+    instance dimension as its dimension. The varible will have an
+    attribute "sample_dimension" denoting which dimension is the
+    sample dimension.
+
+    Data sets which follow the indexed ragged array structure have
+    a variable called the "index variable" denoting which instance
+    a particular element belongs to. This variable will have an
+    attribute "instance_dimension" denoting the sample dimension.
+    The variable's dimension is the sample dimension.
+
+    The netCDF4.Dataset interface available to the user will contain
+    a very simple representation of the data model, yielding no
+    extraneous dimensions/variables.
+
+    Parameters
+    ----------
+    feature_type: str (
+        profile, timeseries, trajectory,
+        timeseriesprofile, trajectoryprofile)
+
+    structure: str (contiguous|indexed)
+    """
+
+    def __init__(self, feature_type: str, structure="contiguous"):
+        super(MockRaggedArrayRepr, self).__init__()
+
+        if structure.lower() not in ("contiguous", "indexed"):
+            raise ValueError("Must initialize MockRaggedArray as contiguous or indexed")
+        
+        if feature_type.lower() not in {
+            "point",
+            "profile",
+            "timeseries",
+            "trajectory",
+            "timeseriesprofile",
+            "trajectoryprofile"
+        }:
+            raise ValueError("Must initialize MockRaggedArray with valid featureType")
+
+        is_compound = False
+        if feature_type.lower() in {"timeseriesprofile", "trajectoryprofile"}:
+            is_compound = True
+
+        # the data will have 10 instances of whatever and 100
+        # total elements
+        self.createDimension("INSTANCE_DIMENSION", 10)
+        self.createDimension("SAMPLE_DIMENSION", 100)
+
+        # create a variable as the cf_role variable; if a compound
+        # featureType is given, multiple variables are created
+        if is_compound:
+            # compound, need another dimension as well; this will
+            # become the "station dimension"
+            self.createDimension("STATION_DIMENSION", 5)
+
+            # one variable for "timeseries" or "trajectory" which
+            # has the station dimension and cf_role
+            _var_name = feature_type.lower().split("profile")[0]
+            self.createVariable(
+                "{}_id_variable".format(_var_name),
+                str,
+                ("STATION_DIMENSION",),
+                fill_value=None
+            )
+
+            # set the cf_role
+            self.variables["{}_id_variable".format(_var_name)].setncattr(
+                "cf_role", 
+                "{}_id".format(_var_name)
+            )
+
+            # there will be one for the profile
+            self.createVariable(
+                "profile_id_variable",
+                str,
+                ("INSTANCE_DIMENSION",),
+                fill_value=None
+            )
+            self.variables["profile_id_variable"].setncattr(
+                "cf_role", 
+                "profile_id"
+            )
+
+            # will need a station index variable
+            self.createVariable(
+                "station_index_variable",
+                int,
+                ("INSTANCE_DIMENSION",),
+                fill_value=None
+            )
+
+            self.variables["station_index_variable"].setncattr(
+                "instance_dimension", 
+                "STATION_DIMENSION"
+            )
+
+            # also need counter variable, as compound featureTypes
+            # are represented by having a contiguous repr for the
+            # profiles and the indexed repr for the timeseries/trajectory
+            # organization
+            self.createVariable(
+                "counter_var",
+                "i", # integer type
+                ("INSTANCE_DIMENSION",),
+                fill_value=None
+            )
+
+            self.variables["counter_var"].setncattr(
+                "sample_dimension",
+                "SAMPLE_DIMENSION"
+            )
+            
+        else: # just a single featureType
+            self.createVariable(
+                "{}_id_variable".format(feature_type),
+                str,
+                ("INSTANCE_DIMENSION",),
+                fill_value=None
+            )
+
+            self.variables["{}_id_variable".format(feature_type)].setncattr(
+                "cf_role",
+                "{}_id".format(feature_type)
+            )
+
+            if structure == "contiguous":
+
+                # create count variable
+                self.createVariable(
+                    "counter_var",
+                    "i", # integer type
+                    ("INSTANCE_DIMENSION",),
+                    fill_value=None
+                )
+
+                self.variables["counter_var"].setncattr(
+                    "sample_dimension",
+                    "SAMPLE_DIMENSION"
+                )
+
+            else:
+
+                # create index variable
+                self.createVariable(
+                    "index_var",
+                    "i", # integer type
+                    ("SAMPLE_DIMENSION",),
+                    fill_value=None
+                )
+                self.variables["index_var"].setncattr(
+                    "instance_dimension",
+                    "INSTANCE_DIMENSION"
+                )
diff --git a/compliance_checker/tests/test_cf.py b/compliance_checker/tests/test_cf.py
index b321232..159abe3 100644
--- a/compliance_checker/tests/test_cf.py
+++ b/compliance_checker/tests/test_cf.py
@@ -32,7 +32,7 @@ from compliance_checker.cf.util import (
 )
 from compliance_checker.suite import CheckSuite
 from compliance_checker.tests import BaseTestCase
-from compliance_checker.tests.helpers import MockTimeSeries, MockVariable
+from compliance_checker.tests.helpers import MockTimeSeries, MockVariable, MockRaggedArrayRepr
 from compliance_checker.tests.resources import STATIC_FILES
 
 
@@ -1259,14 +1259,14 @@ class TestCF1_6(BaseTestCase):
         self.assertFalse(results[0].value)
         self.assertFalse(results[1].value)
 
-    def test_check_all_features_are_same_type(self):
-        dataset = self.load_dataset(STATIC_FILES["rutgers"])
-        result = self.cf.check_all_features_are_same_type(dataset)
-        assert result
+    #def test_check_all_features_are_same_type(self):
+    #    dataset = self.load_dataset(STATIC_FILES["rutgers"])
+    #    result = self.cf.check_all_features_are_same_type(dataset)
+    #    assert result
 
-        dataset = self.load_dataset(STATIC_FILES["featureType"])
-        result = self.cf.check_all_features_are_same_type(dataset)
-        assert result
+    #    dataset = self.load_dataset(STATIC_FILES["featureType"])
+    #    result = self.cf.check_all_features_are_same_type(dataset)
+    #    assert result
 
     def test_featureType_is_case_insensitive(self):
         """
@@ -1351,7 +1351,7 @@ class TestCF1_6(BaseTestCase):
         dataset = self.load_dataset(STATIC_FILES["bad-trajectory"])
         results = self.cf.check_variable_features(dataset)
         scored, out_of, messages = get_results(results)
-        assert len(results) == 1
+        assert len(results) == 2
         assert scored < out_of
         assert len([r for r in results if r.value[0] < r.value[1]]) == 1
         assert all(r.name == u"§9.1 Features and feature types" for r in results)
@@ -2196,3 +2196,500 @@ class TestCF1_7(BaseTestCase):
         temp.setncattr("scale_factor", np.int(5))
         r = self.cf._check_add_offset_scale_factor_type(temp, "scale_factor")
         self.assertTrue(r.value)
+
+class TestCFUtil(BaseTestCase):
+    """
+    Class to test the cfutil module.
+    """
+
+    def test_is_variable_valid_ragged_array_repr_featureType(self):
+        nc = MockRaggedArrayRepr(
+            "timeseries",
+            "indexed"
+        )
+
+        # add a variable that isn't recognized as geophysical
+        v = nc.createVariable(
+            "data1",
+            "d",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        v.setncattr("cf_role", "blah")
+        self.assertFalse(cfutil.is_variable_valid_ragged_array_repr_featureType(nc, "data1"))
+
+        # add geophysical variable with correct dimension
+        nc = MockRaggedArrayRepr(
+            "timeseries",
+            "indexed"
+        )
+        v = nc.createVariable(
+            "data1",
+            "d",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        v.setncattr("standard_name", "sea_water_pressure")
+        # test the variable
+        self.assertTrue(cfutil.is_variable_valid_ragged_array_repr_featureType(nc, "data1"))
+
+        # add good variable and another variable, this time with the improper dimension
+        nc = MockRaggedArrayRepr(
+            "timeseries",
+            "indexed"
+        )
+        v = nc.createVariable(
+            "data1",
+            "d",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        v.setncattr("standard_name", "sea_water_pressure")
+        v2 = nc.createVariable(
+            "data2",
+            "d",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None
+        )
+        v2.setncattr("standard_name", "sea_water_salinity")
+
+        # good variable should pass, second should fail
+        self.assertTrue(cfutil.is_variable_valid_ragged_array_repr_featureType(nc, "data1"))
+        self.assertFalse(cfutil.is_variable_valid_ragged_array_repr_featureType(nc, "data2"))
+
+    def test_is_dataset_valid_ragged_array_repr_featureType(self):
+
+        # first test single featureType
+
+        # ----- timeseries, indexed ----- #
+
+        nc = MockRaggedArrayRepr(
+            "timeseries",
+            "indexed"
+        )
+        self.assertTrue(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseries")
+        )
+
+        # we'll add another cf_role variable
+        nc = MockRaggedArrayRepr(
+            "timeseries",
+            "indexed"
+        )
+        v = nc.createVariable(
+            "var2",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("cf_role", "yeetyeet_id")
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseries")
+        )
+
+        # we'll add another index variable, also bad
+        nc = MockRaggedArrayRepr(
+            "timeseries",
+            "indexed"
+        )
+        v = nc.createVariable(
+            "index_var2",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("instance_dimension", "INSTANCE_DIMENSION")
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseries")
+        )
+
+
+        # ----- timeseries, contiguous ----- #
+        nc = MockRaggedArrayRepr(
+            "timeseries",
+            "contiguous"
+        )
+        self.assertTrue(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseries")
+        )
+
+        # add another cf_role var, bad
+        nc = MockRaggedArrayRepr(
+            "timeseries",
+            "contiguous"
+        )
+        v = nc.createVariable(
+            "var2",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("cf_role", "yeetyeet_id")
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseries")
+        )
+
+        # add another count variable, bad
+        v = nc.createVariable(
+            "count_var2",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("sample_dimension", "SAMPLE_DIMENSION")
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseries")
+        )
+
+        # ----- profile, indexed ----- #
+
+        nc = MockRaggedArrayRepr(
+            "profile",
+            "indexed"
+        )
+        self.assertTrue(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "profile")
+        )
+
+        # add another cf_role var
+        nc = MockRaggedArrayRepr(
+            "profile",
+            "indexed"
+        )
+        v = nc.createVariable(
+            "var2",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("cf_role", "yeetyeet_id")
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "profile")
+        )
+
+        # we'll add another index variable, also bad
+        nc = MockRaggedArrayRepr(
+            "profile",
+            "indexed"
+        )
+        v = nc.createVariable(
+            "index_var2",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("instance_dimension", "INSTANCE_DIMENSION")
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "profile")
+        )
+
+        # ----- profile, contiguous ----- #
+        nc = MockRaggedArrayRepr(
+            "profile",
+            "contiguous"
+        )
+        self.assertTrue(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "profile")
+        )
+
+        # add another cf_role var
+        nc = MockRaggedArrayRepr(
+            "profile",
+            "contiguous"
+        )
+        v = nc.createVariable(
+            "var2",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("cf_role", "yeetyeet_id")
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "profile")
+        )
+
+        # we'll add another count variable, also bad
+        nc = MockRaggedArrayRepr(
+            "profile",
+            "contiguous"
+        )
+        v = nc.createVariable(
+            "index_var2",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("sample_dimension", "SAMPLE_DIMENSION")
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "profile")
+        )
+
+        # ----- trajectory, indexed ----- #
+        nc = MockRaggedArrayRepr(
+            "trajectory",
+            "indexed"
+        )
+        self.assertTrue(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectory")
+        )
+
+        # add another cf_role var
+        nc = MockRaggedArrayRepr(
+            "trajectory",
+            "indexed"
+        )
+        v = nc.createVariable(
+            "var2",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("cf_role", "yeetyeet_id")
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectory")
+        )
+
+        # we'll add another index variable, also bad
+        nc = MockRaggedArrayRepr(
+            "trajectory",
+            "indexed"
+        )
+        v = nc.createVariable(
+            "index_var2",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("instance_dimension", "INSTANCE_DIMENSION")
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectory")
+        )
+
+        # ----- trajectory, contiguous ----- #
+        nc = MockRaggedArrayRepr(
+            "trajectory",
+            "contiguous"
+        )
+        self.assertTrue(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectory")
+        )
+
+        # add another cf_role var
+        nc = MockRaggedArrayRepr(
+            "trajectory",
+            "contiguous"
+        )
+        v = nc.createVariable(
+            "var2",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("cf_role", "yeetyeet_id")
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectory")
+        )
+
+        # we'll add another count variable, also bad
+        nc = MockRaggedArrayRepr(
+            "trajectory",
+            "contiguous"
+        )
+        v = nc.createVariable(
+            "index_var2",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("sample_dimension", "SAMPLE_DIMENSION")
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectory")
+        )
+
+        # ----- now test compound featureType ----- #
+
+        # ----- timeSeriesProfile ----- #
+        nc = MockRaggedArrayRepr(
+            "timeSeriesProfile"
+        )
+
+        # NOTE
+        # has no geophysical vars, so should (?) (will) fail
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseriesprofile")
+        )
+
+        # add a geophysical variable and test again
+        nc = MockRaggedArrayRepr(
+            "timeSeriesProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        v1.setncattr("standard_name", "pressure")
+        self.assertTrue(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseriesprofile")
+        )
+
+        nc = MockRaggedArrayRepr(
+            "timeSeriesProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        # add a thid cf_role variable - this should fail
+        v = nc.createVariable(
+            "cf_role_var3",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("cf_role", "yeetyeet_id")
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseriesprofile")
+        )
+
+
+        # set the index variable to have an incorrect attr
+        nc = MockRaggedArrayRepr(
+            "timeSeriesProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        nc.variables["station_index_variable"].instance_dimension = "SIKE!"
+
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseriesprofile")
+        )
+
+        # change the sample_dimension attr on the count variable, bad
+        nc = MockRaggedArrayRepr(
+            "timeSeriesProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        nc.variables["counter_var"].sample_dimension = "SIKE!"
+
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseriesprofile")
+        )
+
+        # give another geophysical data variable a different dimension
+        nc = MockRaggedArrayRepr(
+            "timeSeriesProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        v1 = nc.createVariable(
+            "data2",
+            "i",
+            ("STATION_DIMENSION",), # bad!
+            fill_value=None
+        )
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseriesprofile")
+        )
+
+        # ----- trajectoryProfile ----- #
+        nc = MockRaggedArrayRepr(
+            "trajectoryProfile"
+        )
+
+        # NOTE
+        # has no geophysical vars, so should (?) (will) fail
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectoryprofile")
+        )
+
+        # add a geophysical variable and test again
+        nc = MockRaggedArrayRepr(
+            "trajectoryProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        v1.setncattr("standard_name", "pressure")
+        self.assertTrue(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectoryprofile")
+        )
+
+        nc = MockRaggedArrayRepr(
+            "trajectoryProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        # add a thid cf_role variable - this should fail
+        v = nc.createVariable(
+            "cf_role_var3",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("cf_role", "yeetyeet_id")
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectoryprofile")
+        )
+
+
+        # set the index variable to have an incorrect attr
+        nc = MockRaggedArrayRepr(
+            "trajectoryProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        nc.variables["station_index_variable"].instance_dimension = "SIKE!"
+
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectoryprofile")
+        )
+
+        # change the sample_dimension attr on the count variable, bad
+        nc = MockRaggedArrayRepr(
+            "trajectoryProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        nc.variables["counter_var"].sample_dimension = "SIKE!"
+
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectoryprofile")
+        )
+
+        # give another geophysical data variable a different dimension
+        nc = MockRaggedArrayRepr(
+            "trajectoryProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        v1 = nc.createVariable(
+            "data2",
+            "i",
+            ("STATION_DIMENSION",), # bad!
+            fill_value=None
+        )
+        self.assertFalse(
+            cfutil.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectoryprofile")
+        )
diff --git a/compliance_checker/tests/test_feature_detection.py b/compliance_checker/tests/test_feature_detection.py
index 7b8253e..31a278d 100644
--- a/compliance_checker/tests/test_feature_detection.py
+++ b/compliance_checker/tests/test_feature_detection.py
@@ -10,6 +10,7 @@ from netCDF4 import Dataset
 
 from compliance_checker import cfutil as util
 from compliance_checker.tests import resources
+from compliance_checker.tests.helpers import MockRaggedArrayRepr
 
 
 class TestFeatureDetection(TestCase):
@@ -346,7 +347,7 @@ class TestFeatureDetection(TestCase):
             assert axis_map["X"] == []
 
         with Dataset(resources.STATIC_FILES["index_ragged"]) as nc:
-            assert util.guess_feature_type(nc, "temperature") == "single-trajectory"
+            assert util.guess_feature_type(nc, "temperature") == "trajectory"
 
             axis_map = util.get_axis_map(nc, "temperature")
             assert axis_map["T"] == ["time"]
@@ -357,7 +358,7 @@ class TestFeatureDetection(TestCase):
         with Dataset(resources.STATIC_FILES["mapping"]) as nc:
             assert (
                 util.guess_feature_type(nc, "sea_surface_height")
-                == "multi-timeseries-orthogonal"
+                == "timeseries"
             )
 
             axis_map = util.get_axis_map(nc, "sea_surface_height")
@@ -376,7 +377,7 @@ class TestFeatureDetection(TestCase):
             assert axis_map["X"] == ["rlon", "lon"]
 
         with Dataset(resources.STATIC_FILES["rutgers"]) as nc:
-            assert util.guess_feature_type(nc, "temperature") == "single-trajectory"
+            assert util.guess_feature_type(nc, "temperature") == "trajectory"
 
             axis_map = util.get_axis_map(nc, "temperature")
             assert axis_map["T"] == ["time"]
@@ -410,3 +411,496 @@ class TestFeatureDetection(TestCase):
             assert axis_map["Y"] == ["lat"]
             assert axis_map["T"] == []
             assert axis_map["Z"] == ["depth"]
+
+
+    def test_is_variable_valid_ragged_array_repr_featureType(self):
+        nc = MockRaggedArrayRepr(
+            "timeseries",
+            "indexed"
+        )
+
+        # add a variable that isn't recognized as geophysical
+        v = nc.createVariable(
+            "data1",
+            "d",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        v.setncattr("cf_role", "blah")
+        self.assertFalse(util.is_variable_valid_ragged_array_repr_featureType(nc, "data1"))
+
+        # add geophysical variable with correct dimension
+        nc = MockRaggedArrayRepr(
+            "timeseries",
+            "indexed"
+        )
+        v = nc.createVariable(
+            "data1",
+            "d",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        v.setncattr("standard_name", "sea_water_pressure")
+        # test the variable
+        self.assertTrue(util.is_variable_valid_ragged_array_repr_featureType(nc, "data1"))
+
+        # add good variable and another variable, this time with the improper dimension
+        nc = MockRaggedArrayRepr(
+            "timeseries",
+            "indexed"
+        )
+        v = nc.createVariable(
+            "data1",
+            "d",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        v.setncattr("standard_name", "sea_water_pressure")
+        v2 = nc.createVariable(
+            "data2",
+            "d",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None
+        )
+        v2.setncattr("standard_name", "sea_water_salinity")
+
+        # good variable should pass, second should fail
+        self.assertTrue(util.is_variable_valid_ragged_array_repr_featureType(nc, "data1"))
+        self.assertFalse(util.is_variable_valid_ragged_array_repr_featureType(nc, "data2"))
+
+    def test_is_dataset_valid_ragged_array_repr_featureType(self):
+
+        # first test single featureType
+
+        # ----- timeseries, indexed ----- #
+
+        nc = MockRaggedArrayRepr(
+            "timeseries",
+            "indexed"
+        )
+        self.assertTrue(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseries")
+        )
+
+        # we'll add another cf_role variable
+        nc = MockRaggedArrayRepr(
+            "timeseries",
+            "indexed"
+        )
+        v = nc.createVariable(
+            "var2",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("cf_role", "yeetyeet_id")
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseries")
+        )
+
+        # we'll add another index variable, also bad
+        nc = MockRaggedArrayRepr(
+            "timeseries",
+            "indexed"
+        )
+        v = nc.createVariable(
+            "index_var2",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("instance_dimension", "INSTANCE_DIMENSION")
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseries")
+        )
+
+
+        # ----- timeseries, contiguous ----- #
+        nc = MockRaggedArrayRepr(
+            "timeseries",
+            "contiguous"
+        )
+        self.assertTrue(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseries")
+        )
+
+        # add another cf_role var, bad
+        nc = MockRaggedArrayRepr(
+            "timeseries",
+            "contiguous"
+        )
+        v = nc.createVariable(
+            "var2",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("cf_role", "yeetyeet_id")
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseries")
+        )
+
+        # add another count variable, bad
+        v = nc.createVariable(
+            "count_var2",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("sample_dimension", "SAMPLE_DIMENSION")
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseries")
+        )
+
+        # ----- profile, indexed ----- #
+
+        nc = MockRaggedArrayRepr(
+            "profile",
+            "indexed"
+        )
+        self.assertTrue(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "profile")
+        )
+
+        # add another cf_role var
+        nc = MockRaggedArrayRepr(
+            "profile",
+            "indexed"
+        )
+        v = nc.createVariable(
+            "var2",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("cf_role", "yeetyeet_id")
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "profile")
+        )
+
+        # we'll add another index variable, also bad
+        nc = MockRaggedArrayRepr(
+            "profile",
+            "indexed"
+        )
+        v = nc.createVariable(
+            "index_var2",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("instance_dimension", "INSTANCE_DIMENSION")
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "profile")
+        )
+
+        # ----- profile, contiguous ----- #
+        nc = MockRaggedArrayRepr(
+            "profile",
+            "contiguous"
+        )
+        self.assertTrue(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "profile")
+        )
+
+        # add another cf_role var
+        nc = MockRaggedArrayRepr(
+            "profile",
+            "contiguous"
+        )
+        v = nc.createVariable(
+            "var2",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("cf_role", "yeetyeet_id")
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "profile")
+        )
+
+        # we'll add another count variable, also bad
+        nc = MockRaggedArrayRepr(
+            "profile",
+            "contiguous"
+        )
+        v = nc.createVariable(
+            "index_var2",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("sample_dimension", "SAMPLE_DIMENSION")
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "profile")
+        )
+
+        # ----- trajectory, indexed ----- #
+        nc = MockRaggedArrayRepr(
+            "trajectory",
+            "indexed"
+        )
+        self.assertTrue(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectory")
+        )
+
+        # add another cf_role var
+        nc = MockRaggedArrayRepr(
+            "trajectory",
+            "indexed"
+        )
+        v = nc.createVariable(
+            "var2",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("cf_role", "yeetyeet_id")
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectory")
+        )
+
+        # we'll add another index variable, also bad
+        nc = MockRaggedArrayRepr(
+            "trajectory",
+            "indexed"
+        )
+        v = nc.createVariable(
+            "index_var2",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("instance_dimension", "INSTANCE_DIMENSION")
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectory")
+        )
+
+        # ----- trajectory, contiguous ----- #
+        nc = MockRaggedArrayRepr(
+            "trajectory",
+            "contiguous"
+        )
+        self.assertTrue(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectory")
+        )
+
+        # add another cf_role var
+        nc = MockRaggedArrayRepr(
+            "trajectory",
+            "contiguous"
+        )
+        v = nc.createVariable(
+            "var2",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("cf_role", "yeetyeet_id")
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectory")
+        )
+
+        # we'll add another count variable, also bad
+        nc = MockRaggedArrayRepr(
+            "trajectory",
+            "contiguous"
+        )
+        v = nc.createVariable(
+            "index_var2",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("sample_dimension", "SAMPLE_DIMENSION")
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectory")
+        )
+
+        # ----- now test compound featureType ----- #
+
+        # ----- timeSeriesProfile ----- #
+        nc = MockRaggedArrayRepr(
+            "timeSeriesProfile"
+        )
+
+        # NOTE
+        # has no geophysical vars, so should (?) (will) fail
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseriesprofile")
+        )
+
+        # add a geophysical variable and test again
+        nc = MockRaggedArrayRepr(
+            "timeSeriesProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        v1.setncattr("standard_name", "pressure")
+        self.assertTrue(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseriesprofile")
+        )
+
+        nc = MockRaggedArrayRepr(
+            "timeSeriesProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        # add a thid cf_role variable - this should fail
+        v = nc.createVariable(
+            "cf_role_var3",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("cf_role", "yeetyeet_id")
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseriesprofile")
+        )
+
+
+        # set the index variable to have an incorrect attr
+        nc = MockRaggedArrayRepr(
+            "timeSeriesProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        nc.variables["station_index_variable"].instance_dimension = "SIKE!"
+
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseriesprofile")
+        )
+
+        # change the sample_dimension attr on the count variable, bad
+        nc = MockRaggedArrayRepr(
+            "timeSeriesProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        nc.variables["counter_var"].sample_dimension = "SIKE!"
+
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseriesprofile")
+        )
+
+        # give another geophysical data variable a different dimension
+        nc = MockRaggedArrayRepr(
+            "timeSeriesProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        v1 = nc.createVariable(
+            "data2",
+            "i",
+            ("STATION_DIMENSION",), # bad!
+            fill_value=None
+        )
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "timeseriesprofile")
+        )
+
+        # ----- trajectoryProfile ----- #
+        nc = MockRaggedArrayRepr(
+            "trajectoryProfile"
+        )
+
+        # NOTE
+        # has no geophysical vars, so should (?) (will) fail
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectoryprofile")
+        )
+
+        # add a geophysical variable and test again
+        nc = MockRaggedArrayRepr(
+            "trajectoryProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        v1.setncattr("standard_name", "pressure")
+        self.assertTrue(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectoryprofile")
+        )
+
+        nc = MockRaggedArrayRepr(
+            "trajectoryProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        # add a thid cf_role variable - this should fail
+        v = nc.createVariable(
+            "cf_role_var3",
+            "i",
+            ("INSTANCE_DIMENSION",),
+            fill_value=None)
+        v.setncattr("cf_role", "yeetyeet_id")
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectoryprofile")
+        )
+
+
+        # set the index variable to have an incorrect attr
+        nc = MockRaggedArrayRepr(
+            "trajectoryProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        nc.variables["station_index_variable"].instance_dimension = "SIKE!"
+
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectoryprofile")
+        )
+
+        # change the sample_dimension attr on the count variable, bad
+        nc = MockRaggedArrayRepr(
+            "trajectoryProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        nc.variables["counter_var"].sample_dimension = "SIKE!"
+
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectoryprofile")
+        )
+
+        # give another geophysical data variable a different dimension
+        nc = MockRaggedArrayRepr(
+            "trajectoryProfile"
+        )
+        v1 = nc.createVariable(
+            "data1",
+            "i",
+            ("SAMPLE_DIMENSION",),
+            fill_value=None
+        )
+        v1 = nc.createVariable(
+            "data2",
+            "i",
+            ("STATION_DIMENSION",), # bad!
+            fill_value=None
+        )
+        self.assertFalse(
+            util.is_dataset_valid_ragged_array_repr_featureType(nc, "trajectoryprofile")
+        )