Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
325e97a
Functionality to load meshes independent of cubes.
trexfeathers Jul 28, 2021
f9ece8a
Make meshes_from_cf private.
trexfeathers Jul 28, 2021
4f4101f
Added tests.
trexfeathers Jul 28, 2021
6f49fd2
Mesh loading docstrings.
trexfeathers Jul 29, 2021
6c966ed
load_mesh integration test.
trexfeathers Jul 29, 2021
df01b3f
Testing improvements.
trexfeathers Jul 30, 2021
48c6d14
Mesh bad cf_role tolerance.
trexfeathers Jul 30, 2021
5a29d44
load_mesh raise ValueError.
trexfeathers Jul 30, 2021
8a981a7
Better var_name docstring for load_meshes.
trexfeathers Jul 30, 2021
fa5937f
Mesh load testing tidy-up.
trexfeathers Jul 30, 2021
6da332d
load_meshes docstring pluralisation fix.
trexfeathers Jul 30, 2021
e6649c1
load_meshes http test py37 compatibility.
trexfeathers Jul 30, 2021
7316ec6
Correct Sphinx domain pluralisation.
trexfeathers Aug 20, 2021
5ffc3d6
Clearer load_meshes Returns docstring.
trexfeathers Aug 20, 2021
a14317b
Add no_mesh integration tests.
trexfeathers Aug 20, 2021
ff73cb0
Clearer test_CFUGridMeshVariable comments.
trexfeathers Aug 20, 2021
436f5f7
Mesh load unit testing use IrisTest.patch().
trexfeathers Aug 20, 2021
b28fbd8
Mesh loading clearer docstring.
trexfeathers Aug 20, 2021
bb30d2e
Enhance test_var_name in test_load_meshes.
trexfeathers Aug 20, 2021
2a4237f
Added test_no_mesh to test_load_meshes.
trexfeathers Aug 20, 2021
ec55e07
Docstring load clarification for mesh loading.
trexfeathers Aug 20, 2021
b900f44
load_mesh better duplicate handling.
trexfeathers Aug 20, 2021
d8809da
Removed face coordinates/data from test_load_meshes.
trexfeathers Aug 20, 2021
b16a4cd
Allow Meshes to be hashed.
pp-mo Aug 20, 2021
ef1c217
Fix for set usage.
pp-mo Aug 23, 2021
b81cfe7
Merge pull request #5 from pp-mo/mesh_hashes
trexfeathers Aug 23, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 148 additions & 1 deletion lib/iris/experimental/ugrid/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
from collections.abc import Iterable
from contextlib import contextmanager
from functools import wraps
from itertools import groupby
import logging
from pathlib import Path
import re
import threading

Expand All @@ -36,15 +38,22 @@
from ...common.mixin import CFVariableMixin
from ...config import get_logger
from ...coords import AuxCoord, _DimensionalMetadata
from ...exceptions import ConnectivityNotFoundError, CoordinateNotFoundError
from ...exceptions import (
ConnectivityNotFoundError,
ConstraintMismatchError,
CoordinateNotFoundError,
)
from ...fileformats import cf, netcdf
from ...fileformats._nc_load_rules.helpers import get_attr_units, get_names
from ...io import decode_uri, expand_filespecs
from ...util import guess_coord_axis

__all__ = [
"CFUGridReader",
"Connectivity",
"ConnectivityMetadata",
"load_mesh",
"load_meshes",
"Mesh",
"Mesh1DConnectivities",
"Mesh1DCoords",
Expand Down Expand Up @@ -3274,6 +3283,134 @@ def context(self):
PARSE_UGRID_ON_LOAD = ParseUGridOnLoad()


def _meshes_from_cf(cf_reader):
"""
Common behaviour for extracting meshes from a CFReader.

Simple now, but expected to increase in complexity as Mesh sharing develops.

"""
# Mesh instances are shared between file phenomena.
# TODO: more sophisticated Mesh sharing between files.
# TODO: access external Mesh cache?
mesh_vars = cf_reader.cf_group.meshes
meshes = {
name: _build_mesh(cf_reader, var, cf_reader.filename)
for name, var in mesh_vars.items()
}
return meshes


def load_mesh(uris, var_name=None):
"""
Create a single :class:`Mesh` object from one or more NetCDF files.

Raises an error if more/less than one :class:`Mesh` is found.

Parameters
----------
uris : str or iterable of str
One or more filenames/URI's. Any URI's must support OpenDAP.
var_name : str, optional
Only return a :class:`Mesh` if its var_name matches this value.

Returns
-------
:class:`Mesh`

"""
meshes_result = load_meshes(uris, var_name)
result = [mesh for file in meshes_result.values() for mesh in file]
mesh_count = len(result)
if mesh_count != 1:
message = (
f"Expecting 1 mesh, but input file(s) produced: {mesh_count} ."
)
raise ConstraintMismatchError(message)
return result[0]


def load_meshes(uris, var_name=None):
"""
Create :class:`Mesh` objects from one or more NetCDF files.

Parameters
----------
uris : str or iterable of str
One or more filenames/URI's. Any URI's must support OpenDAP.
var_name : str, optional
Only return a :class:`Mesh` if its var_name matches this value.

Returns
-------
dict
A dictionary of file paths/URL's and lists of the :class:`Mesh`es
returned from each.

"""
# No constraints or callbacks supported - these assume they are operating
# on a Cube.

from iris.fileformats import FORMAT_AGENT

# TODO: rationalise UGRID/mesh handling once experimental.ugrid is folded
# into standard behaviour.

if not PARSE_UGRID_ON_LOAD:
# Explicit behaviour, consistent with netcdf.load_cubes(), rather than
# an invisible assumption.
message = (
f"PARSE_UGRID_ON_LOAD is {bool(PARSE_UGRID_ON_LOAD)}. Must be "
f"True to enable mesh loading."
)
raise ValueError(message)

if isinstance(uris, str):
uris = [uris]

# Group collections of uris by their iris handler
# Create list of tuples relating schemes to part names.
uri_tuples = sorted(decode_uri(uri) for uri in uris)

valid_sources = []
for scheme, groups in groupby(uri_tuples, key=lambda x: x[0]):
# Call each scheme handler with the appropriate URIs
if scheme == "file":
filenames = [x[1] for x in groups]
sources = expand_filespecs(filenames)
elif scheme in ["http", "https"]:
sources = [":".join(x) for x in groups]
else:
message = f"Iris cannot handle the URI scheme: {scheme}"
raise ValueError(message)

for source in sources:
if scheme == "file":
with open(source, "rb") as fh:
handling_format_spec = FORMAT_AGENT.get_spec(
Path(source).name, fh
)
else:
handling_format_spec = FORMAT_AGENT.get_spec(source, None)

if handling_format_spec.handler == netcdf.load_cubes:
valid_sources.append(source)
else:
message = f"Ignoring non-NetCDF file: {source}"
logger.info(msg=message, extra=dict(cls=None))

result = {}
for source in valid_sources:
meshes_dict = _meshes_from_cf(CFUGridReader(source))
meshes = list(meshes_dict.values())
if var_name is not None:
meshes = list(filter(lambda m: m.var_name == var_name, meshes))
if meshes:
result[source] = meshes

return result


############
# CF Overrides.
# These are not included in __all__ since they are not [currently] needed
Expand Down Expand Up @@ -3469,7 +3606,17 @@ def identify(cls, variables, ignore=None, target=None, warn=True):
log_level = logging.WARNING if warn else logging.DEBUG

# Identify all CF-UGRID mesh variables.
all_vars = target == variables
for nc_var_name, nc_var in target.items():
if all_vars:
# SPECIAL BEHAVIOUR FOR MESH VARIABLES.
# We are looking for all mesh variables. Check if THIS variable
# is a mesh using its own attributes.
if getattr(nc_var, "cf_role", "") == "mesh_topology":
result[nc_var_name] = CFUGridMeshVariable(
nc_var_name, nc_var
)

# Check for mesh variable references.
nc_var_att = getattr(nc_var, cls.cf_identity, None)

Expand Down
5 changes: 5 additions & 0 deletions lib/iris/fileformats/cf.py
Original file line number Diff line number Diff line change
Expand Up @@ -1067,6 +1067,11 @@ def __init__(self, filename, warn=False, monotonic=False):
self._build_cf_groups()
self._reset()

@property
def filename(self):
"""The file that the CFReader is reading."""
return self._filename

def __repr__(self):
return "%s(%r)" % (self.__class__.__name__, self._filename)

Expand Down
12 changes: 2 additions & 10 deletions lib/iris/fileformats/netcdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -792,8 +792,8 @@ def load_cubes(filenames, callback=None):
from iris.experimental.ugrid import (
PARSE_UGRID_ON_LOAD,
CFUGridReader,
_build_mesh,
_build_mesh_coords,
_meshes_from_cf,
)
from iris.io import run_callback

Expand All @@ -808,15 +808,7 @@ def load_cubes(filenames, callback=None):
meshes = {}
if PARSE_UGRID_ON_LOAD:
cf = CFUGridReader(filename)

# Mesh instances are shared between file phenomena.
# TODO: more sophisticated Mesh sharing between files.
# TODO: access external Mesh cache?
mesh_vars = cf.cf_group.meshes
meshes = {
name: _build_mesh(cf, var, filename)
for name, var in mesh_vars.items()
}
meshes = _meshes_from_cf(cf)
else:
cf = iris.fileformats.cf.CFReader(filename)

Expand Down
41 changes: 41 additions & 0 deletions lib/iris/tests/unit/experimental/ugrid/test_CFUGridMeshVariable.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@ class TestIdentify(tests.IrisTest):
def setUp(self):
self.cf_identity = "mesh"

def test_cf_role(self):
match_name = "match"
match = named_variable(match_name)
setattr(match, "cf_role", "mesh_topology")

not_match_name = f"not_{match_name}"
not_match = named_variable(not_match_name)
setattr(not_match, "cf_role", "foo")

vars_all = {match_name: match, not_match_name: not_match}

# ONLY expecting match, excluding not_match.
expected = {match_name: CFUGridMeshVariable(match_name, match)}
result = CFUGridMeshVariable.identify(vars_all)
self.assertDictEqual(expected, result)

def test_cf_identity(self):
subject_name = "ref_subject"
ref_subject = named_variable(subject_name)
Expand All @@ -49,6 +65,31 @@ def test_cf_identity(self):
result = CFUGridMeshVariable.identify(vars_all)
self.assertDictEqual(expected, result)

def test_cf_role_and_identity(self):
role_match_name = "match"
role_match = named_variable(role_match_name)
setattr(role_match, "cf_role", "mesh_topology")

subject_name = "ref_subject"
ref_subject = named_variable(subject_name)
ref_source = named_variable("ref_source")
setattr(ref_source, self.cf_identity, subject_name)

vars_all = {
role_match_name: role_match,
subject_name: ref_subject,
"ref_not_subject": named_variable("ref_not_subject"),
"ref_source": ref_source,
}

# Expecting role_match and ref_subject but excluding other variables.
expected = {
role_match_name: CFUGridMeshVariable(role_match_name, role_match),
subject_name: CFUGridMeshVariable(subject_name, ref_subject),
}
result = CFUGridMeshVariable.identify(vars_all)
self.assertDictEqual(expected, result)

def test_duplicate_refs(self):
subject_name = "ref_subject"
ref_subject = named_variable(subject_name)
Expand Down
57 changes: 57 additions & 0 deletions lib/iris/tests/unit/experimental/ugrid/test_load_mesh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Copyright Iris contributors
#
# This file is part of Iris and is released under the LGPL license.
# See COPYING and COPYING.LESSER in the root of the repository for full
# licensing details.
"""
Unit tests for the :func:`iris.experimental.ugrid.load_mesh` function.

"""

# Import iris.tests first so that some things can be initialised before
# importing anything else.
import iris.tests as tests # isort:skip

from unittest import mock

from iris.exceptions import ConstraintMismatchError
from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD, load_mesh


class Tests(tests.IrisTest):
# All 'real' tests have been done for load_meshes(). Here we just check
# that load_mesh() works with load_meshes() correctly, using mocking.
def setUp(self):
patcher = mock.patch("iris.experimental.ugrid.load_meshes")
self.addCleanup(patcher.stop)

self.load_meshes_mock = patcher.start()
# The expected return from load_meshes - a dict of files, each with
# a list of meshes.
self.load_meshes_mock.return_value = {"file": ["mesh"]}

def test_calls_load_meshes(self):
args = [("file_1", "file_2"), "my_var_name"]
with PARSE_UGRID_ON_LOAD.context():
_ = load_mesh(args)
self.assertTrue(self.load_meshes_mock.called_with(args))

def test_returns_mesh(self):
with PARSE_UGRID_ON_LOAD.context():
mesh = load_mesh([])
self.assertEqual(mesh, "mesh")

def test_single_mesh(self):
# Override the load_meshes_mock return values to provoke errors.
def common(ret_val):
self.load_meshes_mock.return_value = ret_val
with self.assertRaisesRegex(
ConstraintMismatchError, "Expecting 1 mesh.*"
):
with PARSE_UGRID_ON_LOAD.context():
_ = load_mesh([])

# Too many.
common({"file": ["mesh1", "mesh2"]})
# Too few.
common({"file": []})
Loading