#!/usr/bin/env python
# -*- coding: utf-8 -*-
# -----------------------------------------------------------------------------
# (C) British Crown Copyright 2017-2019 Met Office.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# * Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
"""
Compare netcdf files using python-netCDF4 library.
This comparison is done using netCDF4 rather than iris so that it is not
coupled to changes in netCDF representation across iris versions. It is also
able to compare non-CF conventions compliant netCDF files that iris has
difficulty loading.
Many functions in this module take an argument called 'reporter' which is a
function to be called to report comparison differences. This provides
flexibility regarding what action should be taken when differences are found.
For example, the action to take could be to print the message, log the message,
or raise an appropriate exception.
"""
import netCDF4
import numpy as np
TIGHT_TOLERANCE = 1e-5
DEFAULT_TOLERANCE = 1e-4
LOOSE_TOLERANCE = 1e-3
[docs]def compare_netcdfs(actual_path, desired_path, rtol, atol,
exclude_vars=None, reporter=None):
"""
Compare two netCDF files.
Args:
actual_path (os.Pathlike): data file produced by test run
desired_path (os.Pathlike): data file considered good eg. KGO
rtol (float): relative tolerance
atol (float): absolute tolerance
exclude_vars (Iterable[str]): variable names to exclude from comparison
reporter (Callable[[str], None]): callback function for
reporting differences
Returns:
None
"""
def raise_reporter(message):
raise ValueError(message)
if exclude_vars is None:
exclude_vars = []
if reporter is None:
reporter = raise_reporter
actual_ds = netCDF4.Dataset(str(actual_path), mode='r')
actual_ds.set_auto_maskandscale(False)
desired_ds = netCDF4.Dataset(str(desired_path), mode='r')
desired_ds.set_auto_maskandscale(False)
compare_datasets("", actual_ds, desired_ds, rtol, atol,
exclude_vars, reporter)
[docs]def compare_datasets(name, actual_ds, desired_ds, rtol, atol,
exclude_vars, reporter):
"""
Compare netCDF datasets.
This function can call itself recursively to handle nested groups in
netCDF4 files which are represented using the same Dataset class by
python-netCDF4.
Args:
name (str): group name
actual_ds (netCDF.Dataset): dataset produced by test run
desired_ds (netCDF.Dataset): dataset considered good
rtol (float): relative tolerance
atol (float): absolute tolerance
reporter (Callable[[str], None]): callback function for
reporting differences
Returns:
None
"""
compare_attributes("root", actual_ds, desired_ds, reporter)
actual_groups = sorted(actual_ds.groups.keys())
desired_groups = sorted(desired_ds.groups.keys())
if actual_groups != desired_groups:
msg = f"different groups {name}: {actual_groups} {desired_groups}"
reporter(msg)
for group in desired_groups:
compare_attributes(group, actual_ds.groups[group],
desired_ds.groups[group], reporter)
compare_datasets(group,
actual_ds.groups[group], desired_ds.groups[group],
rtol, atol, exclude_vars, reporter)
compare_dims(name, actual_ds, desired_ds, exclude_vars, reporter)
compare_vars(name, actual_ds, desired_ds, rtol, atol,
exclude_vars, reporter)
[docs]def compare_dims(name, actual_ds, desired_ds, exclude_vars, reporter):
"""
Compare dimensions in a netCDF dataset/group.
Args:
name (str): group name
actual_ds (netCDF.Dataset): dataset produced by test run
desired_ds (netCDF.Dataset): dataset considered good
reporter (Callable[[str], None]): callback function for
reporting differences
Returns:
None
"""
if exclude_vars is None:
exclude_vars = []
actual_dims = sorted(set(actual_ds.dimensions.keys()) - set(exclude_vars))
desired_dims = sorted(
set(desired_ds.dimensions.keys()) - set(exclude_vars))
if actual_dims != desired_dims:
msg = ("different dimensions - "
f"{name} {actual_dims} {desired_dims}")
reporter(msg)
for dim in desired_dims:
try:
actual_len = actual_ds.dimensions[dim].size
desired_len = desired_ds.dimensions[dim].size
if actual_len != desired_len:
msg = ("different dimension size - "
f"{name}/{dim} {actual_len} {desired_len}")
reporter(msg)
except KeyError:
pass
[docs]def compare_vars(name, actual_ds, desired_ds, rtol, atol,
exclude_vars, reporter):
"""
Compare variables in a netCDF dataset/group.
Args:
name (str): group name
actual_ds (netCDF.Dataset): dataset produced by test run
desired_ds (netCDF.Dataset): dataset considered good
reporter (Callable[[str], None]): callback function for
reporting differences
Returns:
None
"""
if exclude_vars is None:
exclude_vars = []
actual_vars = sorted(set(actual_ds.variables.keys()) - set(exclude_vars))
desired_vars = sorted(set(desired_ds.variables.keys()) - set(exclude_vars))
if actual_vars != desired_vars:
msg = ("different variables - "
f"{name} {actual_vars} {desired_vars}")
reporter(msg)
coords = set()
for var in desired_vars:
coords = coords.union(desired_ds.variables[var].dimensions)
metadata_vars = coords.intersection(desired_vars)
for var in desired_vars:
var_path = f"{name}/{var}"
try:
actual_var = actual_ds.variables[var]
desired_var = desired_ds.variables[var]
except KeyError:
continue
compare_attributes(var_path, actual_var, desired_var, reporter)
if var in exclude_vars:
pass
elif var in metadata_vars:
compare_data(var_path, actual_var, desired_var,
0.0, 0.0, reporter)
else:
compare_data(var_path, actual_var, desired_var,
rtol, atol, reporter)
[docs]def compare_attributes(name, actual_ds, desired_ds, reporter):
"""
Compare attributes in a netCDF dataset/group.
Args:
name (str): group name
actual_ds (netCDF.Dataset): dataset produced by test run
desired_ds (netCDF.Dataset): dataset considered good
reporter (Callable[[str], None]): callback function for
reporting differences
Returns:
None
"""
actual_attrs = sorted(actual_ds.ncattrs())
desired_attrs = sorted(desired_ds.ncattrs())
# ignore history attribute - this often contain datestamps and other
# overly specific details
if "history" in actual_attrs:
actual_attrs.remove("history")
if "history" in desired_attrs:
desired_attrs.remove("history")
if actual_attrs != desired_attrs:
msg = (f"different attributes of {name} -"
f" {actual_attrs} {desired_attrs}")
reporter(msg)
for key in desired_attrs:
try:
actual_attr = actual_ds.getncattr(key)
desired_attr = desired_ds.getncattr(key)
assert isinstance(desired_attr, type(actual_attr))
if isinstance(desired_attr, np.ndarray):
if not np.array_equal(actual_attr, desired_attr):
msg = (f"different attribute value {name}/{key} - "
f"{actual_attr} {desired_attr}")
reporter(msg)
elif actual_attr != desired_attr:
msg = (f"different attribute value {name}/{key} - "
f"{actual_attr} {desired_attr}")
reporter(msg)
except KeyError:
pass
[docs]def compare_data(name, actual_var, desired_var, rtol, atol, reporter):
"""
Compare attributes in a netCDF variable.
Args:
name (str): variable name
actual_var (netCDF.Variable): variable produced by test run
desired_var (netCDF.Variable): variable considered good
reporter (Callable[[str], None]): callback function for
reporting differences
Returns:
None
"""
if actual_var.dtype != desired_var.dtype:
msg = (f"different type {name} - {actual_var.type} {desired_var.type}")
reporter(msg)
actual_data = actual_var[:]
desired_data = desired_var[:]
difference_found = False
numpy_err_message = ''
try:
if actual_data.dtype.kind in ['b', 'O', 'S', 'U', 'V']:
# numpy boolean, object, bytestring, unicode and void types don't
# have numerical "closeneess" so use exact equality for these
np.testing.assert_equal(actual_data, desired_data, verbose=True)
else:
np.testing.assert_allclose(actual_data, desired_data, rtol, atol,
equal_nan=True, verbose=True)
except AssertionError as exc:
difference_found = True
numpy_err_message = str(exc).strip()
# call the reporter function outside the except block to avoid nested
# exceptions if the reporter function is raising an exception
if difference_found:
reporter(f"different data {name} - {numpy_err_message}")