Skip to content
Merged
Show file tree
Hide file tree
Changes from 46 commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
219d4a4
Added ability to load multiple coordinate systems for a cube.
ukmo-ccbunney Jun 26, 2025
b71beaf
Check for existence of grid_mapping attr
ukmo-ccbunney Jun 26, 2025
07a2d93
Merge remote-tracking branch 'upstream/main' into mutli_coord_systems
ukmo-ccbunney Jun 26, 2025
94585fa
Fix for failing tests for case where no coord systems found
ukmo-ccbunney Jun 27, 2025
b57e268
Some new tests for extended grid mapping `in test__grid_mappings.py`
ukmo-ccbunney Jun 27, 2025
03a5654
Added some validators to the grid_mapping parser
ukmo-ccbunney Jun 27, 2025
fcf6be8
Merge branch 'FEATURE_wkt' into mutli_coord_systems
trexfeathers Jun 27, 2025
92ed35a
Save cubes with multiple coord_systems using extended grid mapping sy…
ukmo-ccbunney Jun 27, 2025
a6c8f48
Merge remote-tracking branch 'origin/mutli_coord_systems' into mutli_…
ukmo-ccbunney Jun 27, 2025
9a50380
Typo in function name
ukmo-ccbunney Jun 27, 2025
2ac5c96
Ignore for ruff fmt
ukmo-ccbunney Jun 27, 2025
6beb20d
More idiomatic list unpacking syntax
ukmo-ccbunney Jun 27, 2025
08b8985
Made return value of _parse_grid_mapping a dict. Added type hints.
ukmo-ccbunney Jul 1, 2025
001b0b4
Added link to regex101 playground
ukmo-ccbunney Jul 1, 2025
fd35592
Refactored to store grid_mapping parsing results in CFReader.
ukmo-ccbunney Jul 2, 2025
baa91ef
Small update to keep tests working with Mocks
ukmo-ccbunney Jul 2, 2025
bd401c9
Changed cs_mapping to return dict of `{coord: cs}` rather than `{cs: …
ukmo-ccbunney Jul 3, 2025
8fd4f0c
Added sorting of coordinates in grid mapping to saver. Also addes Future
ukmo-ccbunney Jul 4, 2025
46edf1a
Fixed typo
ukmo-ccbunney Jul 4, 2025
ed0e703
Removed redundant line
ukmo-ccbunney Jul 4, 2025
7af31ed
Inverted conditional for clarity.
ukmo-ccbunney Jul 4, 2025
2a54669
MpPy type hinting.
ukmo-ccbunney Jul 4, 2025
e80a6b4
Use .get rather than indexing on engine.cube_parts
ukmo-ccbunney Jul 4, 2025
a7b8817
Fix broken function signature
ukmo-ccbunney Jul 4, 2025
09952c8
Fix Mocks for Saver.py
ukmo-ccbunney Jul 4, 2025
fea5d70
Changed grid_mappings to dict in saver.py
ukmo-ccbunney Jul 4, 2025
4791e84
Missing category keyword on warning.
ukmo-ccbunney Jul 4, 2025
564c37c
Removed Future flag and added Cube.ordered_axes property instead.
ukmo-ccbunney Jul 4, 2025
0499eec
Prefer use of `_get_coord_variable_name` over
ukmo-ccbunney Jul 4, 2025
3bdb100
Updated URL to CF Conventions document
ukmo-ccbunney Jul 4, 2025
113aaf3
Fixed spurious match group in extended grid mapping regex
ukmo-ccbunney Jul 4, 2025
cd5c9b6
Ensure WKT is only written out if cube.ordered_axes=True
ukmo-ccbunney Jul 4, 2025
d501f13
Updated `Test_create_cf_grid_mapping` to test grid_mapping generation
ukmo-ccbunney Jul 4, 2025
b9e64ec
Revert Future tests.
ukmo-ccbunney Jul 4, 2025
bd361ec
Use `_name_coord_map` to get coord var name
ukmo-ccbunney Jul 4, 2025
2c59010
Update _name_coord_map if no cfvar name found for coord
ukmo-ccbunney Jul 5, 2025
c37d1fb
Added some tests for multi-coordinate system saving
ukmo-ccbunney Jul 5, 2025
ff9041d
Added assert on existence of coord system in test__grid_mappings.py
ukmo-ccbunney Jul 5, 2025
54c4393
Added multi coord system loading tests
ukmo-ccbunney Jul 5, 2025
4c693c0
Only create CFGridMappingVariable if at least one referenced coord ex…
ukmo-ccbunney Jul 8, 2025
bf6dbd6
New CFParseError exception
ukmo-ccbunney Jul 8, 2025
3839b01
Move setting of ordered_axes property to loader.py + added tests.
ukmo-ccbunney Jul 8, 2025
418549f
Removed the WKT attr from the CDL of some tests
ukmo-ccbunney Jul 8, 2025
8776d2c
Remove expected WKT output from CDL of most tests.
ukmo-ccbunney Jul 8, 2025
fd1ffcd
What's New
ukmo-ccbunney Jul 8, 2025
c60eb34
Update Docs
ukmo-ccbunney Jul 8, 2025
bec7613
Renamed ordered_coords -> extended_grid_mapping and store as an attri…
ukmo-ccbunney Jul 16, 2025
6d36cb8
Fixed some typos and references to ordered_coords in latest.rst
ukmo-ccbunney Jul 16, 2025
3e3a758
Expanded section in docs on mutliple coord systems
ukmo-ccbunney Jul 17, 2025
e4d07fb
Fixed emphasis split over two lines (sphinx doesn't like this)
ukmo-ccbunney Jul 17, 2025
7a8ab8f
Small typo and emphasis fix
ukmo-ccbunney Jul 17, 2025
9f3fb48
Typo iris.coord.Coord => iris.coords.Coord
ukmo-ccbunney Jul 17, 2025
dda23fc
Extended grid_mapping integration tests to test multi coord systems
ukmo-ccbunney Jul 17, 2025
5cd4da4
Revert unnecessary change to attr saving.
ukmo-ccbunney Jul 17, 2025
44d171d
Removed confusing coordinates entry in example CDL.
ukmo-ccbunney Jul 17, 2025
3e8dd24
Fixed rendering of latest.rst entry.
ukmo-ccbunney Jul 17, 2025
8132357
Converted test_coord_systes.py::TestCoordSystem to pytest
ukmo-ccbunney Jul 17, 2025
af5d104
Updated remainder of test_coord_systems.py to pytest
ukmo-ccbunney Jul 17, 2025
12e575b
Removed context manager for tmp_path filename - deprecated in python3.13
ukmo-ccbunney Jul 17, 2025
4379b41
Fixed x and y variable data in multi_cs_osgb_wkt CDL.
ukmo-ccbunney Jul 17, 2025
8478bf5
Modernised TestLoadMinimalGeostationary tests
ukmo-ccbunney Jul 18, 2025
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
67 changes: 67 additions & 0 deletions docs/src/further_topics/netcdf_io.rst
Original file line number Diff line number Diff line change
Expand Up @@ -235,3 +235,70 @@ Worked example:
>>> my_coord.ignore_axis = True
>>> print(guess_coord_axis(my_coord))
None

Multiple Coordinate Systems and Ordered Axes
--------------------------------------------

Traditionally, the coordinate system for coordinate of a data variable
is specified by variable a single name in the ``grid_mapping`` attribute,
e.g.:

::

grid_mapping = 'latitude_longitude`
Comment thread
trexfeathers marked this conversation as resolved.
Outdated
Comment thread
trexfeathers marked this conversation as resolved.
Outdated

Since version `1.8 of the CF Conventions
<https://cfconventions.org/Data/cf-conventions/cf-conventions-1.8/cf-conventions.html#grid-mappings-and-projections>`_
, there has been support for the concept of **ordered axes** in the definition
of a coordinate system which allows for more explicit specification of the
associated coordinate variables.

This is achieved via use of an extended syntax in the ``grid_mapping``
attribute of a data variable:

::

<gridMappingVariable>: <coordinatesVariable> [<coordinatesVariable> …] [<gridMappingVariable>: <coordinatesVariable>…]
Comment thread
trexfeathers marked this conversation as resolved.
Outdated

where each ``gridMappingVariable`` identifies a grid mapping variable
followed by the list of associated coordinate variables. Note that with
this syntax it is possible to specify multiple coordinate systems for a
data variable.

The order of the axes in the extended grid mapping specification is
significant, but only when used in conjunction with a "well known text"
(WKT) representation of the coordinate system _REF_ where it should be
Comment thread
trexfeathers marked this conversation as resolved.
Outdated
consistent with the ``AXES ORDER`` specified in the ``crs_wkt`` attribute.


Effect on loading
^^^^^^^^^^^^^^^^^

When Iris loads a NetCDF file that uses the extended grid mapping syntax
it will generate an :class:`iris.coord_systems.CoordSystem` for each
coordinate system listed and attempt to attach it to the associated
:class:`iris.coord.Coord` instances on the cube. Currently, Iris considers
Comment thread
trexfeathers marked this conversation as resolved.
Outdated
the ``crs_wkt`` supplementary and builds coordinate systems exclusively
from the ``grid_mapping`` attribute.

The :attr:`iris.cube.Cube.ordered_axes` property will be set to ``True``
for cubes loaded from NetCDF data variables utilising the extended
``grid_mapping`` syntax.

Effect on saving
^^^^^^^^^^^^^^^^

To maintain existing behaviour, saving an :class:`iris.cube.Cube` to
a netCDF file will default to the "simple" grid mapping syntax. If
the cube contains multiple coordinate systems, only the coordinate
system of the dimension coordinate(s) will be specified.

To enable saving of multiple coordinate systems with ordered axes,
set the :attr:`iris.cube.Cube.ordered_axes` to ``True``. This will
generate a ``grid_mapping`` attribute using the extended syntax to
specify all coordinate systems on the cube. The axes ordering of the
associated coordinate variables will be consistent with that of the
generated ``crs_wkt`` attribute.

Note, the ``crs_wkt`` attribute will only be generated when the
extended grid mapping is also written, i.e. when ``Cube.ordered_axes=True``.
13 changes: 13 additions & 0 deletions docs/src/whatsnew/latest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ This document explains the changes made to Iris for this release
needs by using the `Ncdata`_ package.
See `CRS WKT in the CF Conventions`_ for more. (:issue:`3796`, :pull:`6519`)

#. `@ukmo-ccbunney` and `@trexfeathers` added support for **ordered coordinates**
when loading and saving NetCDF files. This allows for coordinates to be
explicitly associated with a coordinate system via an extended syntax in
the ``grid_mapping`` attribute of a NetCDF data variable. This extended
syntax also supports specification of multiple coordinate systems per
data variable. Setting the property ``cube.ordered_coords = True`` will
enable extended grid mapping syntax when saving a NetCDF file and also
enerated an associated well known text attribute (``crs_wks``; as
described in :issue:`3796`).
See `CRS Grid Mappings and Projections`_ for more information.
(:issue:3388:, :pull:6536:)
Comment thread
trexfeathers marked this conversation as resolved.
Outdated


🐛 Bugs Fixed
=============
Expand Down Expand Up @@ -146,4 +158,5 @@ This document explains the changes made to Iris for this release
Whatsnew resources in alphabetical order:

.. _CRS WKT in the CF Conventions: https://cfconventions.org/Data/cf-conventions/cf-conventions-1.12/cf-conventions.html#use-of-the-crs-well-known-text-format
.. _CRS Grid Mappings and Projections: https://cfconventions.org/Data/cf-conventions/cf-conventions-1.12/cf-conventions.html#grid-mappings-and-projections
.. _Ncdata: https://github.com/pp-mo/ncdata
29 changes: 29 additions & 0 deletions lib/iris/cube.py
Original file line number Diff line number Diff line change
Expand Up @@ -1291,6 +1291,9 @@ def __init__(
] = []
self._aux_factories: list[AuxCoordFactory] = []

# Default ordered_axes property to False; requires explicit opt-in
self._ordered_axes = False

# Cell Measures
self._cell_measures_and_dims: list[tuple[CellMeasure, tuple[int, ...]]] = []

Expand Down Expand Up @@ -2451,6 +2454,32 @@ def coord_system(

return result

def coord_systems(self) -> list[iris.coord_systems.CoordSystem]:
"""Return a list of all coordinate systems used in cube coordinates."""
# Gather list of our unique CoordSystems on cube:
coord_systems = ClassDict(iris.coord_systems.CoordSystem)
for coord in self.coords():
if coord.coord_system:
coord_systems.add(coord.coord_system, replace=True)

return list(coord_systems.values())

@property
def ordered_axes(self) -> bool:
"""Return True if a cube will use extended grid mapping syntax to write axes order in grid_mapping.

Only relevant when saving a cube to NetCDF file format.

For more details see "Grid Mappings and Projections" in the CF Conventions document:
https://cfconventions.org/cf-conventions/conformance.html
"""
return self._ordered_axes

@ordered_axes.setter
def ordered_axes(self, ordered: bool) -> None:
"""Set to True to enable extended grid mapping syntax."""
self._ordered_axes = ordered

def _any_meshcoord(self) -> MeshCoord | None:
"""Return a MeshCoord if there are any, else None."""
mesh_coords = self.coords(mesh_coords=True)
Expand Down
6 changes: 6 additions & 0 deletions lib/iris/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,9 @@ def __str__(self):
"operations are not currently supported."
)
return msg.format(super().__str__())


class CFParseError(IrisError):
"""Raised when a string associated with a CF defined syntax could not be parsed."""

pass
78 changes: 65 additions & 13 deletions lib/iris/fileformats/_nc_load_rules/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,10 @@ def action_provides_grid_mapping(engine, gridmapping_fact):

def build_outer(engine_, cf_var_):
coordinate_system = builder(engine_, cf_var_)
engine_.cube_parts["coordinate_system"] = coordinate_system
# We can now handle more than one coordinate_system, so store as dictionary:
engine_.cube_parts["coordinate_systems"][cf_var_.cf_name] = (
coordinate_system
)
Comment thread
trexfeathers marked this conversation as resolved.

# Part 1 - only building - adding takes place downstream in
# helpers.build_and_add_dimension/auxiliary_coordinate().
Expand All @@ -218,11 +221,8 @@ def build_outer(engine_, cf_var_):
),
)

# Check there is not an existing one.
# ATM this is guaranteed by the caller, "run_actions".
assert engine.fact_list("grid-type") == []
Comment thread
trexfeathers marked this conversation as resolved.

engine.add_fact("grid-type", (grid_mapping_type,))
# Store grid-mapping name along with grid-type to match them later on
engine.add_fact("grid-type", (var_name, grid_mapping_type))

else:
message = "Coordinate system not created. Debug info:\n"
Expand Down Expand Up @@ -343,7 +343,35 @@ def action_build_dimension_coordinate(engine, providescoord_fact):
# Non-conforming lon/lat/projection coords will be classed as
# dim-coords by cf.py, but 'action_provides_coordinate' will give them
# a coord-type of 'miscellaneous' : hence, they have no coord-system.
coord_system = engine.cube_parts.get("coordinate_system")
#
# At this point, we need to match any "coordinate_system" entries in
# the engine to the coord we are building. There are a couple of cases here:
# 1. Simple `grid_mapping = crs` is used, in which case
# we should just apply that mapping to all dim coords.
# 2. Extended `grid_mapping = crs: coord1 coord2 crs: coord3 coord4`
# is used in which case we need to match the crs to the coord here.

# We can have multiple coordinate_system, so now stored as a list (note plural key)
coord_systems = engine.cube_parts.get("coordinate_systems")
coord_system = None

if len(coord_systems):
# Find which coord system applies to this coordinate.
cs_mappings = engine.cube_parts.get("coordinate_system_mappings")
if cs_mappings and coord_systems:
if len(coord_systems) == 1 and None in cs_mappings:
# Simple grid mapping (a single coord_system with no explicit coords)
# Applies to spatial DimCoord(s) only. In this case only one
# coordinate_system will have been built, so just use it.
(coord_system,) = coord_systems.values()
(cs_name,) = cs_mappings.values()
else:
# Extended grid mapping, e.g.
# `grid_mapping = "crs: coord1 coord2 crs: coord3 coord4"`
# We need to search for coord system that references our coordinate.
if cs_name := cs_mappings.get(cf_var.cf_name):
coord_system = coord_systems.get(cs_name, None)

# Translate the specific grid-mapping type to a grid-class
if coord_system is None:
succeed = True
Expand All @@ -352,8 +380,13 @@ def action_build_dimension_coordinate(engine, providescoord_fact):
# Get a grid-class from the grid-type
# i.e. one of latlon/rotated/projected, as for coord_grid_class.
gridtypes_factlist = engine.fact_list("grid-type")
(gridtypes_fact,) = gridtypes_factlist # only 1 fact
(cs_gridtype,) = gridtypes_fact # fact contains 1 term

# potentially multiple grid-type facts; find one for CRS varname
cs_gridtype = None
for fact_cs_name, fact_cs_type in gridtypes_factlist:
if fact_cs_name == cs_name:
cs_gridtype = fact_cs_type
Comment thread
ukmo-ccbunney marked this conversation as resolved.

if cs_gridtype == "latitude_longitude":
cs_gridclass = "latlon"
elif cs_gridtype == "rotated_latitude_longitude":
Expand Down Expand Up @@ -446,6 +479,7 @@ def action_build_auxiliary_coordinate(engine, auxcoord_fact):
"""Convert a CFAuxiliaryCoordinateVariable into a cube aux-coord."""
(var_name,) = auxcoord_fact
rule_name = "fc_build_auxiliary_coordinate"
cf_var = engine.cf_var.cf_group[var_name]

# Identify any known coord "type" : latitude/longitude/time/time_period
# If latitude/longitude, this sets the standard_name of the built AuxCoord
Expand Down Expand Up @@ -473,8 +507,27 @@ def action_build_auxiliary_coordinate(engine, auxcoord_fact):
if coord_type:
rule_name += f"_{coord_type}"

# Check if we have a coord_system specified for this coordinate.
# (Only possible via extended grid_mapping attribute)
coord_systems = engine.cube_parts.get("coordinate_systems")
coord_system = None

cs_mappings = engine.cube_parts.get("coordinate_system_mappings", None)
if cs_mappings and coord_systems:
if len(coord_systems) == 1 and None in cs_mappings:
# Simple grid_mapping - doesn't apply to AuxCoords (we need an explicit mapping)
pass
else:
# Extended grid mapping, e.g.
# `grid_mapping = "crs: coord1 coord2 crs: coord3 coord4"`
# We need to search for coord system that references our coordinate.
if cs_name := cs_mappings.get(cf_var.cf_name):
coord_system = coord_systems.get(cs_name, None)

cf_var = engine.cf_var.cf_group.auxiliary_coordinates[var_name]
hh.build_and_add_auxiliary_coordinate(engine, cf_var, coord_name=coord_name)
hh.build_and_add_auxiliary_coordinate(
engine, cf_var, coord_name=coord_name, coord_system=coord_system
)

return rule_name

Expand Down Expand Up @@ -615,10 +668,9 @@ def run_actions(engine):
# default (all cubes) action, always runs
action_default(engine) # This should run the default rules.

# deal with grid-mappings
# deal with grid-mappings; potentially multiple mappings if extended grid_mapping used.
grid_mapping_facts = engine.fact_list("grid_mapping")
# For now, there should be at most *one* of these.
assert len(grid_mapping_facts) in (0, 1)

for grid_mapping_fact in grid_mapping_facts:
action_provides_grid_mapping(engine, grid_mapping_fact)

Expand Down
75 changes: 75 additions & 0 deletions lib/iris/fileformats/_nc_load_rules/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,46 @@
CF_GRID_MAPPING_OBLIQUE = "oblique_mercator"
CF_GRID_MAPPING_ROTATED_MERCATOR = "rotated_mercator"

#
# Regex for parsing grid_mapping (extended format)
Comment thread
trexfeathers marked this conversation as resolved.
# Link to online regex101 playground: https://regex101.com/r/NcKzkQ/1
#
# (\w+): # Matches '<word>:' and stores in CAPTURE GROUP 1
# ( # CAPTURE GROUP 2 for capturing multiple coords
# (?: # Non-capturing group for composing match
# \s+ # Matches one or more blank characters
# (?!\w+:) # Negative look-ahead: don't match <word> followed by colon
# \w+ # Matches a <word>
# )+ # Repeats non-capturing group at least once.
# ) # End of CAPTURE GROUP 2
_GRID_MAPPING_PARSE_EXTENDED = re.compile(
r"""
(\w+):
(
(?:
\s+
(?!\w+:)
\w+
)+
)+
""",
re.VERBOSE,
)
_GRID_MAPPING_PARSE_SIMPLE = re.compile(r"^\w+$")
Comment thread
trexfeathers marked this conversation as resolved.
_GRID_MAPPING_VALIDATORS = ( # fmt: skip
(
re.compile(r"\w+: +\w+:"),
"`<coord_system>:` identifier followed immediately by another `<coord_system>:` identifier",
),
(
re.compile(r"\w+: *$"),
"`<coord_system>:` is empty - missing coordinate list",
),
(
re.compile(r"^\w+ +\w+"),
"Multiple coordinates found without `<coord_system>:` identifier",
),
)
#
# CF Attribute Names.
#
Expand Down Expand Up @@ -1936,6 +1976,41 @@ def is_grid_mapping(engine, cf_name, grid_mapping):
return is_valid


################################################################################
def _parse_extended_grid_mapping(grid_mapping: str) -> dict[None | str, str]:
Comment thread
trexfeathers marked this conversation as resolved.
"""Parse `grid_mapping` attribute and return list of coordinate system variables and associated coords."""
# Handles extended grid_mapping too. Possibilities:
# grid_mapping = "crs" : simple mapping; a single variable name with no coords
# grid_mapping = "crs: lat lon" : extended mapping; a variable name and list of coords
# grid_mapping = "crs: lat lon other: var1 var2" : multiple extended mappings

mappings: dict[None | str, str]

# try simple mapping first
if _GRID_MAPPING_PARSE_SIMPLE.match(grid_mapping):
mappings = {None: grid_mapping} # simple single grid mapping variable
else:
# Try extended mapping:
# 1. Run validators to check for invalid expressions:
for v_re, v_msg in _GRID_MAPPING_VALIDATORS:
if len(match := v_re.findall(grid_mapping)):
msg = f"Invalid syntax in extended grid_mapping: {grid_mapping!r}\n{v_msg} : {match}"
raise iris.exceptions.CFParseError(msg)

# 2. Parse grid_mapping into list of [cs, (coords, ...)]:
result = _GRID_MAPPING_PARSE_EXTENDED.findall(grid_mapping)
if len(result) == 0:
msg = f"Failed to parse grid_mapping: {grid_mapping!r}"
raise iris.exceptions.CFParseError(msg)

# split second match group into list of coordinates:
mappings = {}
for cs, coords in result:
mappings.update({coord: cs for coord in coords.split()})

return mappings


################################################################################
def _is_rotated(engine, cf_name, cf_attr_value):
"""Determine whether the CF coordinate variable is rotated."""
Expand Down
Loading
Loading