#!/usr/bin/env python
# -*- coding: utf-8 -*-
'''
compliance_checker/cfutil.py
'''
from cf_units import Unit
from pkg_resources import resource_filename
from collections import defaultdict
import warnings
from functools import partial
import six
import csv
import re
try:
    from functools import lru_cache
# Fallback for Python < 3.2
except ImportError:
    from functools32 import lru_cache

# For python2/python3 support
try:
    basestring
except NameError:
    basestring = str


_UNITLESS_DB = None
_SEA_NAMES = None

VALID_LAT_UNITS = {
    'degrees_north',
    'degree_north',
    'degree_n',
    'degrees_n',
    'degreen',
    'degreesn'
}
VALID_LON_UNITS = {
    'degrees_east',
    'degree_east',
    'degree_e',
    'degrees_e',
    'degreee',
    'degreese'
}


# We can't import appendix d without getting circular imports
DIMENSIONLESS_VERTICAL_COORDINATES = {
    'ocean_s_coordinate',
    'ocean_s_coordinate_g1',
    'ocean_s_coordinate_g2',
    'atmosphere_hybrid_sigma_pressure_coordinate',
    'atmosphere_hybrid_height_coordinate',
    'ocean_double_sigma_coordinate',
    'ocean_sigma_z_coordinate',
    'ocean_sigma_coordinate',
    'atmosphere_sigma_coordinate',
    'atmosphere_ln_pressure_coordinate',
    'atmosphere_sleve_coordinate'
}

def attr_membership(attr_val, value_set, attr_type=basestring,
                          modifier_fn=lambda x: x):
    """
    Helper function passed to netCDF4.Dataset.get_attributes_by_value
    Checks that `attr_val` exists, has the same type as `attr_type`,
    and is contained in `value_set`
    attr_val: The value of the attribute being checked
    attr_type: A type object that the `attr_val` is expected to have the same
               type as.  If the type is not the same, a warning is issued and
               the code attempts to cast `attr_val` to the expected type.
    value_set: The set against which membership for `attr_val` is tested
    modifier_fn: A function to apply to attr_val prior to applying the set
                 membership test

    """
    if attr_val is None:
        return False

    if not isinstance(attr_val, attr_type):
        warnings.warn("Attribute is of type {}, {} expected. "
                      "Attempting to cast to expected type.".format(type(attr_val),
                                                                    attr_type))
        try:
            # if the expected type is basestring, try casting to unicode type
            # since basestring can't be instantiated
            if attr_type is basestring:
                new_attr_val = six.text_type(attr_val)
            else:
                new_attr_val = attr_type(attr_val)
        # catch casting errors
        except (ValueError, UnicodeEncodeError) as e:
            warnings.warn("Could not cast to type {}".format(attr_type))
            return False
    else:
        new_attr_val = attr_val

    try:
        is_in_set = modifier_fn(new_attr_val) in value_set
    except Exception as e:
        warnings.warn('Could not apply modifier function {} to value: '
                      ' {}'.format(modifier_fn, e.msg))
        return False

    return is_in_set


@lru_cache(128)
def is_dimensionless_standard_name(xml_tree, standard_name):
    '''
    Returns True if the units for the associated standard name are
    dimensionless.  Dimensionless standard names include those that have no
    units and units that are defined as constant units in the CF standard name
    table i.e. '1', or '1e-3'.
    '''
    # standard_name must be string, so if it is not, it is *wrong* by default
    if not isinstance(standard_name, basestring):
        return False
    found_standard_name = xml_tree.find(".//entry[@id='{}']".format(standard_name))
    if found_standard_name is not None:
        canonical_units = found_standard_name.find('canonical_units')
        # so far, standard name XML table includes
        # 1 and 1e-3 for constant units, but expanding to valid udunits
        # prefixes to be on the safe side
        # taken from CF Table 3.1 of valid UDUnits prefixes
        dimless_units = r'1(?:e-?(?:1|2|3|6|9|12|15|18|21|24))?$'
        return canonical_units is None or re.match(dimless_units,
                                                   canonical_units.text)
    # if the standard name is not found, assume we need units for the time being
    else:
        return False


def get_sea_names():
    '''
    Returns a list of NODC sea names

    source of list: https://www.nodc.noaa.gov/General/NODC-Archive/seanamelist.txt
    '''
    global _SEA_NAMES
    if _SEA_NAMES is None:
        buf = {}
        with open(resource_filename('compliance_checker', 'data/seanames.csv'), 'r') as f:
            reader = csv.reader(f)
            for code, sea_name in reader:
                buf[sea_name] = code
        _SEA_NAMES = buf
    return _SEA_NAMES


def is_unitless(ds, variable):
    '''
    Returns true if the variable is unitless

    Note units of '1' are considered whole numbers or parts but still represent
    physical units and not the absence of units.

    :param netCDF4.Dataset ds: An open netCDF dataset
    :param str variable: Name of the variable
    '''
    units = getattr(ds.variables[variable], 'units', None)
    return units is None or units == ''


def is_geophysical(ds, variable):
    '''
    Returns true if the dataset's variable is likely a geophysical variable

    :param netCDF4.Dataset ds: An open netCDF dataset
    :param str variable: Name of the variable
    '''
    ncvar = ds.variables[variable]

    if getattr(ncvar, 'cf_role', None):
        return False

    # Check for axis
    if getattr(ncvar, 'axis', None):
        return False

    standard_name_test = getattr(ncvar, 'standard_name', '')
    unitless = is_unitless(ds, variable)

    if not isinstance(standard_name_test, basestring):
        warnings.warn("Variable {} has non string standard name, "
                      "Attempting cast to string".format(variable))
        try:
            standard_name = str(standard_name_test)
        except ValueError:
            warnings.warn("Unable to cast standard name to string, excluding "
                          "from geophysical variables")
    else:
        standard_name = standard_name_test

    # Is the standard name associated with coordinates
    if standard_name in {'time', 'latitude', 'longitude',
                         'height', 'depth', 'altitude'}:
        return False

    if variable in get_coordinate_variables(ds):
        return False

    if variable in get_auxiliary_coordinate_variables(ds):
        return False

    # Is it dimensionless and unitless?
    if len(ncvar.shape) == 0 and unitless:
        return False

    # Is it a QC Flag?
    if 'status_flag' in standard_name or hasattr(ncvar, 'flag_meanings'):
        return False

    # Is it a §7.1 Cell Boundaries variable
    if variable in get_cell_boundary_variables(ds):
        return False

    if variable == get_climatology_variable(ds):
        return False

    # Is it a string but with no defined units?
    if ncvar.dtype.char == 'S':
        return False

    # Is it an instrument descriptor?
    if variable in get_instrument_variables(ds):
        return False

    # What about a platform descriptor?
    if variable in get_platform_variables(ds):
        return False

    # Skip count variables too
    if hasattr(ncvar, 'sample_dimension'):
        return False

    return True


def get_coordinate_variables(ds):
    '''
    Returns a list of variable names that identify as coordinate variables.

    A coordinate variable is a netCDF variable with exactly one dimension. The
    name of this dimension must be equivalent to the variable name.

    From CF §1.2 Terminology

    It is a one-dimensional variable with the same name as its dimension [e.g.,
    time(time) ], and it is defined as a numeric data type with values that are
    ordered monotonically. Missing values are not allowed in coordinate
    variables.

    :param netCDF4.Dataset ds: An open netCDF dataset
    '''
    coord_vars = []
    for dimension in ds.dimensions:
        if dimension in ds.variables:
            if ds.variables[dimension].dimensions == (dimension,):
                coord_vars.append(dimension)
    return coord_vars


def get_auxiliary_coordinate_variables(ds):
    '''
    Returns a list of auxiliary coordinate variables

    An auxiliary coordinate variable is any netCDF variable that contains
    coordinate data, but is not a coordinate variable (in the sense of the term
    defined by CF).

    :param netCDf4.Dataset ds: An open netCDF dataset
    '''
    aux_vars = []
    # get any variables referecned by the coordinates attribute
    for ncvar in ds.get_variables_by_attributes(coordinates=lambda x: isinstance(x, basestring)):
        # split the coordinates into individual variable names
        referenced_variables = ncvar.coordinates.split(' ')
        # if the variable names exist, add them
        for referenced_variable in referenced_variables:
            if referenced_variable in ds.variables and referenced_variable not in aux_vars:
                aux_vars.append(referenced_variable)

    # axis variables are automatically in
    for variable in get_axis_variables(ds):
        if variable not in aux_vars:
            aux_vars.append(variable)

    # Last are any variables that define the common coordinate standard names
    coordinate_standard_names = ['time', 'longitude', 'latitude', 'height', 'depth', 'altitude']
    coordinate_standard_names += DIMENSIONLESS_VERTICAL_COORDINATES

    # Some datasets like ROMS use multiple variables to define coordinates
    for ncvar in ds.get_variables_by_attributes(standard_name=lambda x: x in coordinate_standard_names):
        if ncvar.name not in aux_vars:
            aux_vars.append(ncvar.name)

    # Remove any that are purely coordinate variables
    ret_val = []
    for aux_var in aux_vars:
        if ds.variables[aux_var].dimensions == (aux_var,):
            continue
        ret_val.append(aux_var)

    return ret_val


def get_cell_boundary_map(ds):
    '''
    Returns a dictionary mapping a variable to its boundary variable. The
    returned dictionary maps a string variable name to the name of the boundary
    variable.

    :param netCDF4.Dataset nc: netCDF dataset
    '''
    boundary_map = {}
    for variable in ds.get_variables_by_attributes(bounds=lambda x: x is not None):
        if variable.bounds in ds.variables:
            boundary_map[variable.name] = variable.bounds
    return boundary_map


def get_cell_boundary_variables(ds):
    '''
    Returns a list of variable names for variables that represent cell
    boundaries through the `bounds` attribute

    :param netCDF4.Dataset nc: netCDF dataset
    '''
    boundary_variables = []
    has_bounds = ds.get_variables_by_attributes(bounds=lambda x: x is not None)
    for var in has_bounds:
        if var.bounds in ds.variables:
            boundary_variables.append(var.bounds)
    return boundary_variables

@lru_cache(128)
def get_geophysical_variables(ds):
    '''
    Returns a list of variable names for the variables detected as geophysical
    variables.

    :param netCDF4.Dataset nc: An open netCDF dataset
    '''

    parameters = []
    for variable in ds.variables:
        if is_geophysical(ds, variable):
            parameters.append(variable)
    return parameters


@lru_cache(128)
def get_z_variable(nc):
    '''
    Returns the name of the variable that defines the Z axis or height/depth

    :param netCDF4.Dataset nc: netCDF dataset
    '''
    z_variables = get_z_variables(nc)
    if not z_variables:
        return None

    # Priority is standard_name, units
    for var in z_variables:
        ncvar = nc.variables[var]
        if getattr(ncvar, 'standard_name', None) in ('depth', 'height', 'altitude'):
            return var

    for var in z_variables:
        ncvar = nc.variables[var]
        units = getattr(ncvar, 'units', None)
        if isinstance(units, basestring):
            if units_convertible(units, 'bar'):
                return var
            if units_convertible(units, 'm'):
                return var

    return z_variables[0]


def get_z_variables(nc):
    '''
    Returns a list of all variables matching definitions for Z

    :param netcdf4.dataset nc: an open netcdf dataset object
    '''
    z_variables = []
    # Vertical coordinates will be identifiable by units of pressure or the
    # presence of the positive attribute with a value of up/down

    # optionally, the vertical type may be indicated by providing the
    # standard_name attribute or axis='Z'

    total_coords = get_coordinate_variables(nc) + get_auxiliary_coordinate_variables(nc)
    for coord_name in total_coords:
        if coord_name in z_variables:
            continue
        coord_var = nc.variables[coord_name]
        units = getattr(coord_var, 'units', None)
        positive = getattr(coord_var, 'positive', None)
        standard_name = getattr(coord_var, 'standard_name', None)
        axis = getattr(coord_var, 'axis', None)
        # If there are no units, we can't identify it as a vertical coordinate
        # by checking pressure or positive
        if units is not None:
            if units_convertible(units, 'bar'):
                z_variables.append(coord_name)
            elif isinstance(positive, basestring):
                if positive.lower() in ['up', 'down']:
                    z_variables.append(coord_name)
        # if axis='Z' we're good
        if coord_name not in z_variables and axis == 'Z':
            z_variables.append(coord_name)
        if coord_name not in z_variables and standard_name in ('depth', 'height', 'altitude'):
            z_variables.append(coord_name)
        if coord_name not in z_variables and standard_name in DIMENSIONLESS_VERTICAL_COORDINATES:
            z_variables.append(coord_name)

    return z_variables


@lru_cache(128)
def get_lat_variable(nc):
    '''
    Returns the first variable matching latitude

    :param netcdf4.dataset nc: an open netcdf dataset object
    '''
    latitudes = get_latitude_variables(nc)
    if latitudes:
        return latitudes[0]
    return None


def get_latitude_variables(nc):
    '''
    Returns a list of all variables matching definitions for latitude

    :param netcdf4.dataset nc: an open netcdf dataset object
    '''
    latitude_variables = []
    # standard_name takes precedence
    for variable in nc.get_variables_by_attributes(standard_name="latitude"):
        latitude_variables.append(variable.name)

    # Then axis
    for variable in nc.get_variables_by_attributes(axis='Y'):
        if variable.name not in latitude_variables:
            latitude_variables.append(variable.name)

    check_fn = partial(attr_membership, value_set=VALID_LAT_UNITS,
                           modifier_fn=lambda s: s.lower())
    for variable in nc.get_variables_by_attributes(units=check_fn):
        if variable.name not in latitude_variables:
            latitude_variables.append(variable.name)

    return latitude_variables


def get_true_latitude_variables(nc):
    '''
    Returns a list of variables defining true latitude.

    CF Chapter 4 refers to latitude as a coordinate variable that can also be
    used in non-standard coordinate systems like rotated pole and other
    projections. Chapter 5 refers to a concept of true latitude where the
    variabe defines latitude in a standard projection.

    True latitude, for lack of a better definition, is simply latitude where
    the standard_name is latitude or the units are degrees_north.

    :param netCDF4.Dataset nc: An open netCDF dataset
    '''
    lats = get_latitude_variables(nc)
    true_lats = []
    for lat in lats:
        standard_name = getattr(nc.variables[lat], "standard_name", None)
        units = getattr(nc.variables[lat], "units", None)
        if standard_name == 'latitude':
            true_lats.append(lat)
        elif isinstance(units, basestring) and units.lower() in VALID_LAT_UNITS:
            true_lats.append(lat)
    return true_lats


@lru_cache(128)
def get_lon_variable(nc):
    '''
    Returns the variable for longitude

    :param netCDF4.Dataset nc: netCDF dataset
    '''
    longitudes = get_longitude_variables(nc)
    if longitudes:
        return longitudes[0]
    return None


def get_longitude_variables(nc):
    '''
    Returns a list of all variables matching definitions for longitude

    :param netcdf4.dataset nc: an open netcdf dataset object
    '''
    longitude_variables = []
    # standard_name takes precedence
    for variable in nc.get_variables_by_attributes(standard_name="longitude"):
        longitude_variables.append(variable.name)

    # Then axis
    for variable in nc.get_variables_by_attributes(axis='X'):
        if variable.name not in longitude_variables:
            longitude_variables.append(variable.name)

    check_fn = partial(attr_membership, value_set=VALID_LON_UNITS,
                       modifier_fn=lambda s: s.lower())
    for variable in nc.get_variables_by_attributes(units=check_fn):
        if variable.name not in longitude_variables:
            longitude_variables.append(variable.name)

    return longitude_variables


def get_true_longitude_variables(nc):
    '''
    Returns a list of variables defining true longitude.

    CF Chapter 4 refers to longitude as a coordinate variable that can also be
    used in non-standard coordinate systems like rotated pole and other
    projections. Chapter 5 refers to a concept of true longitude where the
    variabe defines longitude in a standard projection.

    True longitude, for lack of a better definition, is simply longitude where
    the standard_name is longitude or the units are degrees_north.

    :param netCDF4.Dataset nc: An open netCDF dataset
    '''
    lons = get_longitude_variables(nc)
    true_lons = []
    for lon in lons:
        standard_name = getattr(nc.variables[lon], "standard_name", None)
        units = getattr(nc.variables[lon], "units", None)
        if standard_name == 'longitude':
            true_lons.append(lon)
        elif isinstance(units, basestring) and units.lower() in VALID_LON_UNITS:
            true_lons.append(lon)
    return true_lons


def get_platform_variables(ds):
    '''
    Returns a list of platform variable NAMES

    :param netCDF4.Dataset ds: An open netCDF4 Dataset
    '''
    candidates = []
    for variable in ds.variables:
        platform = getattr(ds.variables[variable], 'platform', '')
        if platform and platform in ds.variables:
            if platform not in candidates:
                candidates.append(platform)

    platform = getattr(ds, 'platform', '')
    if platform and platform in ds.variables:
        if platform not in candidates:
            candidates.append(platform)
    return candidates


def get_instrument_variables(ds):
    '''
    Returns a list of instrument variables

    :param netCDF4.Dataset ds: An open netCDF4 Dataset
    '''
    candidates = []
    for variable in ds.variables:
        instrument = getattr(ds.variables[variable], 'instrument', '')
        if instrument and instrument in ds.variables:
            if instrument not in candidates:
                candidates.append(instrument)

    instrument = getattr(ds, 'instrument', '')
    if instrument and instrument in ds.variables:
        if instrument not in candidates:
            candidates.append(instrument)
    return candidates


def get_time_variable(ds):
    '''
    Returns the likeliest variable to be the time coordiante variable

    :param netCDF4.Dataset ds: An open netCDF4 Dataset
    '''
    for var in ds.variables:
        if getattr(ds.variables[var], 'axis', '') == 'T':
            return var
    else:
        candidates = ds.get_variables_by_attributes(standard_name='time')
        if len(candidates) == 1:
            return candidates[0].name
        else:  # Look for a coordinate variable time
            for candidate in candidates:
                if candidate.dimensions == (candidate.name,):
                    return candidate.name

    # If we still haven't found the candidate
    time_variables = set(get_time_variables(ds))
    coordinate_variables = set(get_coordinate_variables(ds))
    if len(time_variables.intersection(coordinate_variables)) == 1:
        return list(time_variables.intersection(coordinate_variables))[0]

    auxiliary_coordinates = set(get_auxiliary_coordinate_variables(ds))
    if len(time_variables.intersection(auxiliary_coordinates)) == 1:
        return list(time_variables.intersection(auxiliary_coordinates))[0]
    return None


@lru_cache(128)
def get_time_variables(ds):
    '''
    Returns a list of variables describing the time coordinate

    :param netCDF4.Dataset ds: An open netCDF4 Dataset
    '''
    time_variables = set()
    for variable in ds.get_variables_by_attributes(standard_name='time'):
        time_variables.add(variable.name)

    for variable in ds.get_variables_by_attributes(axis='T'):
        if variable.name not in time_variables:
            time_variables.add(variable.name)

    regx = r'^(?:day|d|hour|hr|h|minute|min|second|s)s? since .*$'
    for variable in ds.get_variables_by_attributes(units=lambda x: isinstance(x, basestring)):
        if re.match(regx, variable.units) and variable.name not in time_variables:
            time_variables.add(variable.name)

    return time_variables


def get_axis_variables(ds):
    '''
    Returns a list of variables that define an axis of the dataset

    :param netCDF4.Dataset ds: An open netCDF4 Dataset
    '''
    axis_variables = []
    for ncvar in ds.get_variables_by_attributes(axis=lambda x: x is not None):
        axis_variables.append(ncvar.name)
    return axis_variables


def get_climatology_variable(ds):
    '''
    Returns the variable describing climatology bounds if it exists.

    Climatology variables are similar to cell boundary variables that describe
    the climatology bounds.

    See Example 7.8 in CF 1.6

    :param netCDF4.Dataset ds: An open netCDF4 Dataset
    :rtype: str or None
    '''
    time = get_time_variable(ds)
    # If there's no time dimension there's no climatology bounds
    if not time:
        return None
    # Climatology variable is simply whatever time points to under the
    # `climatology` attribute.
    if hasattr(ds.variables[time], 'climatology'):
        if ds.variables[time].climatology in ds.variables:
            return ds.variables[time].climatology
    return None


def get_flag_variables(ds):
    '''
    Returns a list of variables that are defined as flag variables

    :param netCDF4.Dataset ds: An open netCDF4 Dataset
    '''
    flag_variables = []
    for name, ncvar in ds.variables.items():
        standard_name = getattr(ncvar, 'standard_name', None)
        if isinstance(standard_name, basestring) and 'status_flag' in standard_name:
            flag_variables.append(name)
        elif hasattr(ncvar, 'flag_meanings'):
            flag_variables.append(name)
    return flag_variables


def get_grid_mapping_variables(ds):
    '''
    Returns a list of grid mapping variables

    :param netCDF4.Dataset ds: An open netCDF4 Dataset
    '''
    grid_mapping_variables = set()
    for ncvar in ds.get_variables_by_attributes(grid_mapping=lambda x: x is not None):
        if ncvar.grid_mapping in ds.variables:
            grid_mapping_variables.add(ncvar.grid_mapping)
    return grid_mapping_variables


@lru_cache(128)
def get_axis_map(ds, variable):
    '''
    Returns an axis_map dictionary that contains an axis key and the coordinate
    names as values.

    For example::

        {'X': ['longitude'], 'Y': ['latitude'], 'T': ['time']}

    The axis C is for compressed coordinates like a reduced grid, and U is for
    unknown axis. This can sometimes be physical quantities representing a
    continuous discrete axis, like temperature or density.

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: Variable name
    '''
    all_coords = get_coordinate_variables(ds) + get_auxiliary_coordinate_variables(ds)

    latitudes = get_latitude_variables(ds)
    longitudes = get_longitude_variables(ds)
    times = get_time_variables(ds)
    heights = get_z_variables(ds)

    coordinates = getattr(ds.variables[variable], "coordinates", None)
    if not isinstance(coordinates, basestring):
        coordinates = []
    else:
        coordinates = coordinates.split(' ')

    # For example
    # {'x': ['longitude'], 'y': ['latitude'], 't': ['time']}
    axis_map = defaultdict(list)
    for coord_name in all_coords:
        axis = getattr(ds.variables[coord_name], 'axis', None)
        if not axis or axis not in ('X', 'Y', 'Z', 'T'):
            if is_compression_coordinate(ds, coord_name):
                axis = 'C'
            elif coord_name in times:
                axis = 'T'
            elif coord_name in longitudes:
                axis = 'X'
            elif coord_name in latitudes:
                axis = 'Y'
            elif coord_name in heights:
                axis = 'Z'
            else:
                axis = 'U'

        if coord_name in ds.variables[variable].dimensions:
            if coord_name not in axis_map[axis]:
                axis_map[axis].append(coord_name)

        elif coord_name in coordinates:
            if coord_name not in axis_map[axis]:
                axis_map[axis].append(coord_name)
    return axis_map


def is_coordinate_variable(ds, variable):
    '''
    Returns True if the variable is a coordinate variable

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: Variable name
    '''
    if variable not in ds.variables:
        return False
    return ds.variables[variable].dimensions == (variable,)


def is_compression_coordinate(ds, variable):
    '''
    Returns True if the variable is a coordinate variable that defines a
    compression scheme.

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: Variable name
    '''
    # Must be a coordinate variable
    if not is_coordinate_variable(ds, variable):
        return False
    # must have a string attribute compress
    compress = getattr(ds.variables[variable], 'compress', None)
    if not isinstance(compress, basestring):
        return False
    if not compress:
        return False
    # This should never happen or be allowed
    if variable in compress:
        return False
    # Must point to dimensions
    for dim in compress.split():
        if dim not in ds.dimensions:
            return False
    return True


def coordinate_dimension_matrix(nc):
    '''
    Returns a dictionary of coordinates mapped to their dimensions

    :param netCDF4.Dataset nc: An open netCDF dataset
    '''
    retval = {}
    x = get_lon_variable(nc)
    if x:
        retval['x'] = nc.variables[x].dimensions
    y = get_lat_variable(nc)
    if y:
        retval['y'] = nc.variables[y].dimensions

    z = get_z_variable(nc)
    if z:
        retval['z'] = nc.variables[z].dimensions

    t = get_time_variable(nc)
    if t:
        retval['t'] = nc.variables[t].dimensions
    return retval


def is_point(nc, variable):
    '''
    Returns true if the variable is a point feature type

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    # x(o), y(o), z(o), t(o)
    # X(o)

    dims = nc.variables[variable].dimensions

    cmatrix = coordinate_dimension_matrix(nc)
    first_coord = None
    if 't' in cmatrix:
        first_coord = cmatrix['t']
        if len(cmatrix['t']) > 1:
            return False
    if 'x' in cmatrix:
        if first_coord is None:
            first_coord = cmatrix['x']
        if first_coord != cmatrix['x']:
            return False
        if len(cmatrix['x']) > 1:
            return False
    if 'y' in cmatrix:
        if first_coord is None:
            first_coord = cmatrix['y']
        if first_coord != cmatrix['y']:
            return False
        if len(cmatrix['y']) > 1:
            return False
    if 'z' in cmatrix:
        if first_coord is None:
            first_coord = cmatrix['z']
        if first_coord != cmatrix['z']:
            return False
        if len(cmatrix['z']) > 1:
            return False
    if first_coord and dims != first_coord:
        return False
    # Point is indistinguishable from trajectories where the instance dimension
    # is implied (scalar)
    traj_ids = nc.get_variables_by_attributes(cf_role="trajectory_id")
    if traj_ids:
        return False

    return True


def is_timeseries(nc, variable):
    '''
    Returns true if the variable is a time series feature type.

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''

    # x, y, z, t(t)
    # X(t)
    dims = nc.variables[variable].dimensions
    cmatrix = coordinate_dimension_matrix(nc)
    time_variables = get_time_variables(nc)

    if len(dims) != 1:
        return False
    dim = dims[0]
    if dim not in time_variables:
        return False
    # No other coordinates can vary with time
    if 'x' in cmatrix:
        if len(cmatrix['x']) != 0:
            return False
    if 'y' in cmatrix:
        if len(cmatrix['y']) != 0:
            return False
    if 'z' in cmatrix:
        if len(cmatrix['z']) != 0:
            return False

    return True


def is_multi_timeseries_orthogonal(nc, variable):
    '''
    Returns true if the variable is a orthogonal multidimensional array
    representation of time series. For more information on what this means see
    CF 1.6 §H.2.1

    http://cfconventions.org/cf-conventions/v1.6.0/cf-conventions.html#_orthogonal_multidimensional_array_representation_of_time_series

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    # x(i), y(i), z(i), t(o)
    # X(i, o)
    dims = nc.variables[variable].dimensions

    cmatrix = coordinate_dimension_matrix(nc)

    for req in ('x', 'y', 't'):
        if req not in cmatrix:
            return False
    if len(cmatrix['x']) != 1 or cmatrix['x'] != cmatrix['y']:
        return False
    if 'z' in cmatrix and cmatrix['x'] != cmatrix['z']:
        return False

    timevar = get_time_variable(nc)
    if cmatrix['t'] != (timevar,):
        return False

    i = cmatrix['x'][0]
    o = cmatrix['t'][0]
    if dims == (i, o):
        return True
    return False


def is_multi_timeseries_incomplete(nc, variable):
    '''
    Returns true if the variable is an incomplete multidimensional array
    representation of time series. For more information on what this means see
    CF 1.6 §H.2.2

    http://cfconventions.org/cf-conventions/v1.6.0/cf-conventions.html#_incomplete_multidimensional_array_representation_of_time_series

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''

    # x(i), y(i), z(i), t(i, o)
    # X(i, o)
    dims = nc.variables[variable].dimensions
    cmatrix = coordinate_dimension_matrix(nc)

    for req in ('x', 'y', 't'):
        if req not in cmatrix:
            return False
    if len(cmatrix['x']) != 1:
        return False
    if cmatrix['x'] != cmatrix['y']:
        return False
    if len(cmatrix['t']) != 2:
        return False
    if cmatrix['x'][0] != cmatrix['t'][0]:
        return False

    i = cmatrix['x'][0]
    o = cmatrix['t'][1]

    if dims == (i, o):
        return True
    return False


def is_cf_trajectory(nc, variable):
    '''
    Returns true if the variable is a CF trajectory feature type

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    # x(i, o), y(i, o), z(i, o), t(i, o)
    # X(i, o)
    dims = nc.variables[variable].dimensions
    cmatrix = coordinate_dimension_matrix(nc)

    for req in ('x', 'y', 't'):
        if req not in cmatrix:
            return False
    if len(cmatrix['x']) != 2:
        return False
    if cmatrix['x'] != cmatrix['y']:
        return False
    if cmatrix['x'] != cmatrix['t']:
        return False
    if 'z' in cmatrix and cmatrix['x'] != cmatrix['z']:
        return False
    if dims == cmatrix['x']:
        return True
    return False


def is_single_trajectory(nc, variable):
    '''
    Returns true if the variable is a single trajectory feature

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    # x(o), y(o), z(o), t(o)
    # X(o)
    # cf_role must be trajectory
    dims = nc.variables[variable].dimensions

    cmatrix = coordinate_dimension_matrix(nc)
    # Time is required for trajectories
    if 't' not in cmatrix:
        return False
    # Coordinates changing with time are optional
    if 'x' in cmatrix:
        if cmatrix['x'] != cmatrix['t']:
            return False
    if 'y' in cmatrix:
        if cmatrix['y'] != cmatrix['t']:
            return False
    if 'z' in cmatrix:
        if cmatrix['z'] != cmatrix['t']:
            return False
    if dims != cmatrix['t']:
        return False
    traj_ids = nc.get_variables_by_attributes(cf_role="trajectory_id")
    if len(traj_ids) != 1:
        return False
    return True


def is_profile_orthogonal(nc, variable):
    '''
    Returns true if the variable is a orthogonal profile feature type

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    # Every profile has the exact same depths, think thermister or ADCP
    # x(i), y(i), z(j), t(i)
    # X(i, j)
    dims = nc.variables[variable].dimensions
    cmatrix = coordinate_dimension_matrix(nc)

    for req in ('x', 'y', 'z', 't'):
        if req not in cmatrix:
            return False
    if len(cmatrix['x']) != 1:
        return False
    if cmatrix['x'] != cmatrix['y']:
        return False
    if cmatrix['x'] != cmatrix['t']:
        return False
    if len(cmatrix['z']) != 1:
        return False

    i = cmatrix['x'][0]
    j = cmatrix['z'][0]

    if dims == (i, j):
        return True
    return False


def is_profile_incomplete(nc, variable):
    '''
    Returns true if the variable is a incomplete profile feature type

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    # Every profile may have different depths
    # x(i), y(i), z(i, j), t(i)
    # X(i, j)
    dims = nc.variables[variable].dimensions
    cmatrix = coordinate_dimension_matrix(nc)

    for req in ('x', 'y', 'z', 't'):
        if req not in cmatrix:
            return False
    if len(cmatrix['x']) != 1:
        return False
    if cmatrix['x'] != cmatrix['y']:
        return False
    if cmatrix['x'] != cmatrix['t']:
        return False
    if len(cmatrix['z']) != 2:
        return False
    if cmatrix['z'][0] != cmatrix['x'][0]:
        return False

    i = cmatrix['x'][0]
    j = cmatrix['z'][1]

    if dims == (i, j):
        return True
    return False


def is_timeseries_profile_single_station(nc, variable):
    '''
    Returns true if the variable is a time-series profile that represents a
    single station and each profile is the same length.

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''

    # x, y, z(z), t(t)
    # X(t, z)
    dims = nc.variables[variable].dimensions
    cmatrix = coordinate_dimension_matrix(nc)

    for req in ('x', 'y', 'z', 't'):
        if req not in cmatrix:
            return False
    if len(cmatrix['x']) != 0:
        return False
    if cmatrix['x'] != cmatrix['y']:
        return False

    z = get_z_variable(nc)
    if cmatrix['z'] != (z,):
        return False
    t = get_time_variable(nc)
    if cmatrix['t'] != (t,):
        return False

    if dims == (t, z):
        return True
    return False


def is_timeseries_profile_multi_station(nc, variable):
    '''
    Returns true if the variable is a time-series profile that represents multiple stations with orthogonal time and depth

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    # x(i), y(i), z(z), t(t)
    # X(i, t, z)
    dims = nc.variables[variable].dimensions
    cmatrix = coordinate_dimension_matrix(nc)

    for req in ('x', 'y', 'z', 't'):
        if req not in cmatrix:
            return False
    if len(cmatrix['x']) != 1:
        return False
    if cmatrix['x'] != cmatrix['y']:
        return False
    i = cmatrix['x'][0]

    z = get_z_variable(nc)
    if cmatrix['z'] != (z,):
        return False
    t = get_time_variable(nc)
    if cmatrix['t'] != (t,):
        return False

    if dims == (i, t, z):
        return True
    return False


def is_timeseries_profile_single_ortho_time(nc, variable):
    '''
    Returns true if the variable is a time-series profile that represents a
    single station with orthogonal time only.

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    # x, y, z(t, j), t(t)
    # X(t, j)
    dims = nc.variables[variable].dimensions
    cmatrix = coordinate_dimension_matrix(nc)

    for req in ('x', 'y', 'z', 't'):
        if req not in cmatrix:
            return False

    if len(cmatrix['x']) != 0:
        return False
    if cmatrix['x'] != cmatrix['y']:
        return False

    t = get_time_variable(nc)
    if cmatrix['t'] != (t,):
        return False

    if len(cmatrix['z']) != 2:
        return False

    if cmatrix['z'][0] != t:
        return False

    j = cmatrix['z'][1]

    if dims == (t, j):
        return True
    return False


def is_timeseries_profile_multi_ortho_time(nc, variable):
    '''
    Returns true if the variable is a time-series profile that represents a
    multi station with orthogonal time only.

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    # x(i), y(i), z(i, t, j), t(t)
    # X(i, t, j)
    dims = nc.variables[variable].dimensions
    cmatrix = coordinate_dimension_matrix(nc)

    for req in ('x', 'y', 'z', 't'):
        if req not in cmatrix:
            return False

    if len(cmatrix['x']) != 1:
        return False
    if cmatrix['x'] != cmatrix['y']:
        return False

    t = get_time_variable(nc)
    if cmatrix['t'] != (t,):
        return False

    if len(cmatrix['z']) != 3:
        return False

    if cmatrix['z'][1] != t:
        return False
    if cmatrix['z'][0] != cmatrix['x'][0]:
        return False

    i = cmatrix['x'][0]
    j = cmatrix['z'][2]

    if dims == (i, t, j):
        return True
    return False


def is_timeseries_profile_ortho_depth(nc, variable):
    '''
    Returns true if the variable is a time-series profile with orthogonal depth
    only.

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    # x(i), y(i), z(z), t(i, j)
    # X(i, j, z)
    dims = nc.variables[variable].dimensions
    cmatrix = coordinate_dimension_matrix(nc)

    for req in ('x', 'y', 'z', 't'):
        if req not in cmatrix:
            return False

    if len(cmatrix['x']) != 1:
        return False
    if cmatrix['x'] != cmatrix['y']:
        return False

    z = get_z_variable(nc)
    if cmatrix['z'] != (z,):
        return False

    i = cmatrix['x'][0]

    if len(cmatrix['t']) != 2:
        return False
    if cmatrix['t'][0] != i:
        return False

    j = cmatrix['t'][1]

    if dims == (i, j, z):
        return True
    return False


def is_timeseries_profile_incomplete(nc, variable):
    '''
    Returns true if the variable is a time-series profile incomplete depth and
    incomplete time.

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    # x(i), y(i), z(i, j, k), t(i, j)
    # X(i, j, k)
    dims = nc.variables[variable].dimensions
    cmatrix = coordinate_dimension_matrix(nc)

    for req in ('x', 'y', 'z', 't'):
        if req not in cmatrix:
            return False

    if len(cmatrix['x']) != 1:
        return False
    if cmatrix['x'] != cmatrix['y']:
        return False
    i = cmatrix['x'][0]

    if len(cmatrix['t']) != 2:
        return False
    if cmatrix['t'][0] != i:
        return False
    j = cmatrix['t'][1]

    if len(cmatrix['z']) != 3:
        return False
    if cmatrix['z'][0] != i:
        return False
    if cmatrix['z'][1] != j:
        return False
    k = cmatrix['z'][2]

    if dims == (i, j, k):
        return True
    return False


def is_trajectory_profile_orthogonal(nc, variable):
    '''
    Returns true if the variable is a trajectory profile with orthogonal
    depths.

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    # x(i, o), y(i, o), z(z), t(i, o)
    # X(i, o, z)
    dims = nc.variables[variable].dimensions
    cmatrix = coordinate_dimension_matrix(nc)

    for req in ('x', 'y', 'z', 't'):
        if req not in cmatrix:
            return False

    if len(cmatrix['x']) != 2:
        return False
    if cmatrix['x'] != cmatrix['y']:
        return False
    if cmatrix['x'] != cmatrix['t']:
        return False

    i, o = cmatrix['x']

    z = get_z_variable(nc)
    if cmatrix['z'] != (z,):
        return False

    if dims == (i, o, z):
        return True
    return False


def is_trajectory_profile_incomplete(nc, variable):
    '''
    Returns true if the variable is a trajectory profile with incomplete
    depths.

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    # x(i, o), y(i, o), z(i, o, j), t(i, o)
    # X(i, o, j)
    dims = nc.variables[variable].dimensions
    cmatrix = coordinate_dimension_matrix(nc)

    for req in ('x', 'y', 'z', 't'):
        if req not in cmatrix:
            return False

    if len(cmatrix['x']) != 2:
        return False
    if cmatrix['x'] != cmatrix['y']:
        return False
    if cmatrix['x'] != cmatrix['t']:
        return False

    i, o = cmatrix['x']

    if len(cmatrix['z']) != 3:
        return False

    if cmatrix['z'][0] != i:
        return False
    if cmatrix['z'][1] != o:
        return False

    j = cmatrix['z'][2]

    if dims == (i, o, j):
        return True
    return False


def is_2d_regular_grid(nc, variable):
    '''
    Returns True if the variable is a 2D Regular grid.

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    # x(x), y(y), t(t)
    # X(t, y, x)

    if is_mapped_grid(nc, variable):
        return False

    dims = nc.variables[variable].dimensions

    cmatrix = coordinate_dimension_matrix(nc)

    for req in ('x', 'y', 't'):
        if req not in cmatrix:
            return False

    x = get_lon_variable(nc)
    y = get_lat_variable(nc)
    t = get_time_variable(nc)

    if cmatrix['x'] != (x,):
        return False
    if cmatrix['y'] != (y,):
        return False
    if cmatrix['t'] != (t,):
        return False

    # Relaxed dimension ordering
    if len(dims) == 3 and x in dims and y in dims and t in dims:
        return True
    return False


def is_2d_static_grid(nc, variable):
    '''
    Returns True if the variable is a 2D Regular grid that does not vary with
    time.

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    # x(x), y(y)
    # X(y, x)

    if is_mapped_grid(nc, variable):
        return False

    dims = nc.variables[variable].dimensions
    cmatrix = coordinate_dimension_matrix(nc)

    for req in ('x', 'y'):
        if req not in cmatrix:
            return False

    x = get_lon_variable(nc)
    y = get_lat_variable(nc)

    if cmatrix['x'] != (x,):
        return False

    if cmatrix['y'] != (y,):
        return False

    if len(dims) != 2 or x not in dims or y not in dims:
        return False

    return True


def is_3d_regular_grid(nc, variable):
    '''
    Returns True if the variable is a 3D Regular grid.

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    # x(x), y(y), z(z), t(t)
    # X(t, z, y, x)

    if is_mapped_grid(nc, variable):
        return False

    dims = nc.variables[variable].dimensions

    cmatrix = coordinate_dimension_matrix(nc)

    for req in ('x', 'y', 'z', 't'):
        if req not in cmatrix:
            return False

    x = get_lon_variable(nc)
    y = get_lat_variable(nc)
    z = get_z_variable(nc)
    t = get_time_variable(nc)

    if cmatrix['x'] != (x,):
        return False
    if cmatrix['y'] != (y,):
        return False
    if cmatrix['z'] != (z,):
        return False
    if cmatrix['t'] != (t,):
        return False

    # Relaxed dimension ordering
    if len(dims) == 4 and x in dims and y in dims and t in dims and z in dims:
        return True
    return False


def is_3d_static_grid(nc, variable):
    '''
    Returns True if the variable is a 2D Regular grid that does not vary with
    time.

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    # x(x), y(y), z(z)
    # X(z, y, x)

    if is_mapped_grid(nc, variable):
        return False

    dims = nc.variables[variable].dimensions
    cmatrix = coordinate_dimension_matrix(nc)

    for req in ('x', 'y', 'z'):
        if req not in cmatrix:
            return False

    x = get_lon_variable(nc)
    y = get_lat_variable(nc)
    z = get_z_variable(nc)

    if cmatrix['x'] != (x,):
        return False

    if cmatrix['y'] != (y,):
        return False

    if cmatrix['z'] != (z,):
        return False

    if len(dims) != 3 or x not in dims or y not in dims or z not in dims:
        return False

    return True


def is_mapped_grid(nc, variable):
    '''
    Returns true if the feature-type of variable corresponds to a mapped grid
    type. Characterized by Appedix F of CF-1.6

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    # x(j, i), y(j, i), z?, t?
    # F(t?, z?, j, i)

    # The important and really defining characteristic of mapped grids is that
    # the true latitude and longitude coordinates are functions of (j,i) and
    # that the geophysical variables are also functions of (j,i) in their
    # dimensions.
    dims = nc.variables[variable].dimensions
    # For cases like ROMS, the coordinates are mapped using the coordinates attribute
    variable_coordinates = getattr(nc.variables[variable], 'coordinates', '').split()

    lons = get_longitude_variables(nc)
    for lon in lons:
        if lon in variable_coordinates:
            break
    else:
        lon = get_lon_variable(nc)

    if lon is None:
        return False

    lats = get_latitude_variables(nc)
    for lat in lats:
        if lat in variable_coordinates:
            break
    else:
        lat = get_lat_variable(nc)

    if lat is None:
        return False

    x = nc.variables[lon].dimensions
    y = nc.variables[lat].dimensions

    if len(x) != 2:
        return False
    if x != y:
        return False

    comma_dimension = ','.join(dims)
    # Dimensions must be in the same order and the mapping coordinates i and j
    # must be in the same order
    if ','.join(x) not in comma_dimension:
        return False

    return True


def is_reduced_grid(nc, variable):
    '''
    Returns True if the feature-type of the variable corresponds to a reduced
    horizontal grid.

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    axis_map = get_axis_map(nc, variable)

    if 'X' not in axis_map:
        return False
    if 'Y' not in axis_map:
        return False
    if 'C' not in axis_map:
        return False

    compressed_coordinates = axis_map['C']
    if len(compressed_coordinates) > 1:
        return False
    compressed_coordinate = axis_map['C'][0]
    for dim in nc.variables[compressed_coordinate].compress.split():
        if dim not in nc.dimensions:
            return False
    return True


def guess_feature_type(nc, variable):
    '''
    Returns a string describing the feature type for this variable

    :param netCDF4.Dataset nc: An open netCDF dataset
    :param str variable: name of the variable to check
    '''
    if is_point(nc, variable):
        return 'point'
    if is_timeseries(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 is_2d_regular_grid(nc, variable):
        return '2d-regular-grid'
    if is_2d_static_grid(nc, variable):
        return '2d-static-grid'
    if is_3d_regular_grid(nc, variable):
        return '3d-regular-grid'
    if is_3d_static_grid(nc, variable):
        return '3d-static-grid'
    if is_mapped_grid(nc, variable):
        return 'mapped-grid'
    if is_reduced_grid(nc, variable):
        return 'reduced-grid'


def units_convertible(units1, units2, reftimeistime=True):
    """
    Return True if a Unit representing the string units1 can be converted
    to a Unit representing the string units2, else False.

    :param str units1: A string representing the units
    :param str units2: A string representing the units
    """
    try:
        u1 = Unit(units1)
        u2 = Unit(units2)
    except ValueError:
        return False
    return u1.is_convertible(u2)
