Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
157 changes: 157 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,160 @@ Worked example:
>>> my_coord.ignore_axis = True
>>> print(guess_coord_axis(my_coord))
None

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

In a CF compliant NetCDF file, the coordinate variables associated with a
data variable can specify a specific *coordinate system* that defines how
the coordinate values relate to physical locations on the globe. For example,
a coordinate might have values with units of metres that should be referenced
against a *Transverse Mercator* projection with a specific origin. This
information is not stored on the coordinate itself, but in a separate
*grid mapping* variable. Furthermore, the grid mapping for a set of
coordinates is associated with the data variable (not the coordinates
variables) via the ``grid_mapping`` attribute.

For example, a temperature variable defined on a *rotated pole* grid might
look like this in a NetCDF file (extract of relevant variables):

.. code-block:: text

float T(rlat,rlon) ;
T:long_name = "temperature" ;
T:units = "K" ;
T:grid_mapping = "rotated_pole" ;

char rotated_pole ;
rotated_pole:grid_mapping_name = "rotated_latitude_longitude" ;
rotated_pole:grid_north_pole_latitude = 32.5 ;
rotated_pole:grid_north_pole_longitude = 170. ;

float rlon(rlon) ;
rlon:long_name = "longitude in rotated pole grid" ;
rlon:units = "degrees" ;
rlon:standard_name = "grid_longitude";

float rlat(rlat) ;
rlat:long_name = "latitude in rotated pole grid" ;
rlat:units = "degrees" ;
rlat:standard_name = "grid_latitude";


Note how the ``rotated pole`` grid mapping (coordinate system) is referenced
from the data variable ``T:grid_mapping = "rotated_pole"`` and is implicitly
associated with the dimension coordinate variables ``rlat`` and ``rlon``.


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 a more explicit version of the ``grid_mapping``
attribute. This allows for **multiple coordinate systems** to be defined for
a data variable and individual coordinates to be explicitly associated with
a coordinate system. This is achieved by use of an **extended syntax** in the
``grid_mapping`` variable of a data variable:


.. code-block:: text

<grid_mapping_var>: <coord_var> [<coord_var>] [<grid_mapping_var>: <coord_var> ...]

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

For example, consider the following *air pressure* variable that is
defined on an *OSGB Transverse Mercator grid*:

.. code-block:: text

float pres(y, x) ;
pres:standard_name = "air_pressure" ;
pres:units = "Pa" ;
pres:coordinates = "lat lon" ;
pres:grid_mapping = "crsOSGB: x y crsWGS84: lat lon" ;

double x(x) ;
x:standard_name = "projection_x_coordinate" ;
x:units = "m" ;

double y(y) ;
y:standard_name = "projection_y_coordinate" ;
y:units = "m" ;

double lat(y, x) ;
lat:standard_name = "latitude" ;
lat:units = "degrees_north" ;

double lon(y, x) ;
lon:standard_name = "longitude" ;
lon:units = "degrees_east" ;

int crsOSGB ;
crsOSGB:grid_mapping_name = "transverse_mercator" ;
crsOSGB:semi_major_axis = 6377563.396 ;
crsOSGB:inverse_flattening = 299.3249646 ;
<snip>

int crsWGS84 ;
crsWGS84:grid_mapping_name = "latitude_longitude" ;
crsWGS84:longitude_of_prime_meridian = 0. ;
<snip>


The dimension coordinates ``x`` and ``y`` are explicitly defined on
an a *transverse mercator* grid via the ``crsOSGB`` variable.

However, with the extended grid syntax, it is also possible to define
a second coordinate system on a standard **latitude_longitude** grid
and associate it with the auxiliary ``lat`` and ``lon`` coordinates:

::

pres:grid_mapping = "crsOSGB: x y crsWGS84: lat lon" ;


Note, the *order* of the axes in the extended grid mapping specification is
significant, but only when used in conjunction with a
`CRS Well Known Text (WKT)`_ representation of the coordinate system where it
should be 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.coords.Coord` instances on the cube. Currently, Iris considers
the ``crs_wkt`` supplementary and builds coordinate systems exclusively
from the ``grid_mapping`` attribute.

The :attr:`iris.cube.Cube.extended_grid_mapping` 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, unless
the cube was loaded from a file using the extended 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.extended_grid_mapping` 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.extended_grid_mapping=True``.


.. _CRS Well Known Text (WKT): https://cfconventions.org/Data/cf-conventions/cf-conventions-1.12/cf-conventions.html#use-of-the-crs-well-known-text-format
14 changes: 14 additions & 0 deletions docs/src/whatsnew/latest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ 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
**multiple coordinate systems** and **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.extended_grid_mapping = True`` will enable extended grid mapping
syntax when saving a NetCDF file and also generate an associated **well known
text** attribute (``crs_wkt``; as described in :issue:`3796`).
See `CRS Grid Mappings and Projections`_ for more information.
(:issue:`3388`:, :pull:`6536`:)


🐛 Bugs Fixed
=============
Expand Down Expand Up @@ -146,4 +159,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
26 changes: 26 additions & 0 deletions lib/iris/cube.py
Original file line number Diff line number Diff line change
Expand Up @@ -2451,6 +2451,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 extended_grid_mapping(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.attributes.get("iris_extended_grid_mapping", False)

@extended_grid_mapping.setter
def extended_grid_mapping(self, ordered: bool) -> None:
"""Set to True to enable extended grid mapping syntax."""
self.attributes["iris_extended_grid_mapping"] = 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
)

# 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") == []

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

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
Loading
Loading