From 0fc41255e1f41064a4e398f0f30f46f5db077cf9 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Mon, 27 Oct 2025 17:12:06 +0000 Subject: [PATCH 01/12] dev --- cf/test/test_UGRID.py | 140 ++++++++++++++++++++++++++++++++----- cf/test/test_read_write.py | 7 +- 2 files changed, 124 insertions(+), 23 deletions(-) diff --git a/cf/test/test_UGRID.py b/cf/test/test_UGRID.py index 7d80ba3166..103faa78e8 100644 --- a/cf/test/test_UGRID.py +++ b/cf/test/test_UGRID.py @@ -1,10 +1,12 @@ import atexit import datetime import faulthandler +import itertools import os import tempfile import unittest +import netCDF4 import numpy as np faulthandler.enable() # to debug seg faults and timeouts @@ -14,12 +16,12 @@ warnings = False # Set up temporary files -n_tmpfiles = 1 +n_tmpfiles = 2 tmpfiles = [ - tempfile.mkstemp("_test_read_write.nc", dir=os.getcwd())[1] + tempfile.mkstemp("_test_ugrid.nc", dir=os.getcwd())[1] for i in range(n_tmpfiles) ] -[tmpfile1] = tmpfiles +[tmpfile, tmpfile1] = tmpfiles def _remove_tmpfiles(): @@ -34,6 +36,22 @@ def _remove_tmpfiles(): atexit.register(_remove_tmpfiles) +def n_mesh_variables(filename): + """Return the number of mesh variables in the file.""" + nc = netCDF4.Dataset(filename, "r") + n = 0 + for v in nc.variables.values(): + try: + v.getncattr("topology_dimension") + except AttributeError: + pass + else: + n += 1 + + nc.close() + return n + + class UGRIDTest(unittest.TestCase): """Test UGRID field constructs.""" @@ -76,10 +94,6 @@ def test_UGRID_read(self): g.cell_connectivity().get_connectivity(), "edge" ) - # Check that all fields have the same mesh id - mesh_ids1 = set(g.get_mesh_id() for g in f1) - self.assertEqual(len(mesh_ids1), 1) - f2 = cf.read(self.filename2) self.assertEqual(len(f2), 3) for g in f2: @@ -98,13 +112,6 @@ def test_UGRID_read(self): g.cell_connectivity().get_connectivity(), "edge" ) - # Check that all fields have the same mesh id - mesh_ids2 = set(g.get_mesh_id() for g in f2) - self.assertEqual(len(mesh_ids2), 1) - - # Check that the different files have different mesh ids - self.assertNotEqual(mesh_ids1, mesh_ids2) - def test_UGRID_data(self): """Test reading of UGRID data.""" node1, face1, edge1 = cf.read(self.filename1) @@ -177,9 +184,108 @@ def test_read_UGRID_domain(self): g.cell_connectivity().get_connectivity(), "edge" ) - # Check that all domains have the same mesh id - mesh_ids1 = set(g.get_mesh_id() for g in d1) - self.assertEqual(len(mesh_ids1), 1) + def test_read_write_UGRID_field(self): + """Test the cf.read and cf.write with UGRID fields.""" + # Face, edge, and point fields that are all part of the same + # UGRID mesh + ugrid = cf.example_fields(8, 9, 10) + + face, edge, point = (0, 1, 2) + + tmpfile = "tmpfileu.nc" + # Test for equality with the fields defined in memory. Only + # works for face and edge fields. + for cell in (face, edge): + f = ugrid[cell] + cf.write(f, tmpfile) + g = cf.read(tmpfile) + self.assertEqual(len(g), 1) + self.assertTrue(g[0].equals(f)) + + # Test round-tripping fields with multiple fields + # + # Get the indices of 'ugrid' for all possible combinations of + # fields: + # + # combinations = [(0,), (1,), ..., (2, 0, 1), (2, 1, 0)] + combinations = [ + i + for n in range(1, 4) + for i in itertools.permutations([face, edge, point], n) + ] + + for cells in combinations: + f = [] + for cell in cells: + f.append(ugrid[cell]) + + cf.write(f, tmpfile) + + # Check that there's only one mesh variable in the file + self.assertEqual(n_mesh_variables(tmpfile), 1) + + g = cf.read(tmpfile) + self.assertEqual(len(g), len(f)) + + cf.write(g, tmpfile1) + + # Check that there's only one mesh variable in the file + self.assertEqual(n_mesh_variables(tmpfile1), 1) + + h = cf.read(tmpfile1) + self.assertEqual(len(h), len(g)) + self.assertTrue(h[0].equals(g[0])) + + def test_read_write_UGRID_domain(self): + """Test the cf.read and cf.write with UGRID domains.""" + # Face, edge, and point fields/domains that are all part of + # the same UGRID mesh + ugrid = [f.domain for f in cf.example_fields(8, 9, 10)] + + face, edge, point = (0, 1, 2) + + # Test for equality with the fields defined in memory. Only + # works for face and edge domains. + for cell in (face, edge): + d = ugrid[cell] + cf.write(d, tmpfile) + e = cf.read(tmpfile, domain=True) + self.assertEqual(len(e), 2) + self.assertTrue(e[0].equals(d)) + self.assertEqual(e[1].domain_topology().get_cell(), "point") + + # Test round-tripping fields with all three domains + # + # combinations = [(0, 1, 2), (0, 2, 1), ..., (2, 0, 1), (2, 1, 0)] + combinations = list(itertools.permutations([face, edge, point], 3)) + for cells in combinations: + d = [] + for cell in cells: + d.append(ugrid[cell]) + + cf.write(d, tmpfile) + + # Check that there's only one mesh variable in the file + self.assertEqual(n_mesh_variables(tmpfile), 1) + + e = cf.read(tmpfile, domain=True) + + self.assertEqual(len(e), len(d)) + + cf.write(e, tmpfile1) + + # Check that there's only one mesh variable in the file + self.assertEqual(n_mesh_variables(tmpfile1), 1) + + f = cf.read(tmpfile1, domain=True) + self.assertEqual(len(f), len(e)) + for i, j in zip(f, e): + self.assertTrue(i.equals(j)) + + # Note: Other combintations of domain read/write are tricky, + # because the mesh variable *and* the domain variable in + # the dataset *both* define domains. Let's not worry + # about that now! if __name__ == "__main__": diff --git a/cf/test/test_read_write.py b/cf/test/test_read_write.py index 93d58c1d2e..b7bae5fd1f 100644 --- a/cf/test/test_read_write.py +++ b/cf/test/test_read_write.py @@ -346,9 +346,6 @@ def test_write_netcdf_mode(self): if fmt == "NETCDF4_CLASSIC" and ex_field_n in (6, 7): continue - print( - "TODOUGRID: excluding example fields 8, 9, 10 until writing UGRID is enabled" - ) if ex_field_n in (8, 9, 10): continue @@ -420,9 +417,7 @@ def test_write_netcdf_mode(self): # Now do the same test, but appending all of the example fields in # one operation rather than one at a time, to check that it works. cf.write(g, tmpfile, fmt=fmt, mode="w") # 1. overwrite to wipe - print( - "TODOUGRID: excluding example fields 8, 9, 10 until writing UGRID is enabled" - ) + append_ex_fields = cf.example_fields(0, 1, 2, 3, 4, 5, 6, 7) del append_ex_fields[1] # note: can remove after Issue #141 closed if fmt in "NETCDF4_CLASSIC": From c05bc58404bde1e1136646e9de1612ededa24a19 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Wed, 29 Oct 2025 12:45:23 +0000 Subject: [PATCH 02/12] ugrid_3.nc --- cf/test/create_test_files.py | 145 +++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/cf/test/create_test_files.py b/cf/test/create_test_files.py index e2b6cf6f48..604f38c787 100644 --- a/cf/test/create_test_files.py +++ b/cf/test/create_test_files.py @@ -2228,6 +2228,150 @@ def _make_ugrid_2(filename): return filename +def _make_ugrid_3(filename): + """Create a UGRID mesh topology and no fields/domains.""" + n = netCDF4.Dataset(filename, "w") + + n.Conventions = f"CF-{VN}" + + n.createDimension("nMesh3_node", 7) + n.createDimension("nMesh3_edge", 9) + n.createDimension("nMesh3_face", 3) + n.createDimension("connectivity2", 2) + n.createDimension("connectivity4", 4) + n.createDimension("connectivity5", 5) + + Mesh3 = n.createVariable("Mesh3", "i4", ()) + Mesh3.cf_role = "mesh_topology" + Mesh3.topology_dimension = 2 + Mesh3.node_coordinates = "Mesh3_node_x Mesh3_node_y" + Mesh3.face_node_connectivity = "Mesh3_face_nodes" + Mesh3.edge_node_connectivity = "Mesh3_edge_nodes" + Mesh3.face_dimension = "nMesh3_face" + Mesh3.edge_dimension = "nMesh3_edge" + Mesh3.face_face_connectivity = "Mesh3_face_links" + Mesh3.edge_edge_connectivity = "Mesh3_edge_links" + + # Node + Mesh3_node_x = n.createVariable("Mesh3_node_x", "f4", ("nMesh3_node",)) + Mesh3_node_x.standard_name = "longitude" + Mesh3_node_x.units = "degrees_east" + Mesh3_node_x[...] = [-45, -43, -45, -43, -45, -43, -40] + + Mesh3_node_y = n.createVariable("Mesh3_node_y", "f4", ("nMesh3_node",)) + Mesh3_node_y.standard_name = "latitude" + Mesh3_node_y.units = "degrees_north" + Mesh3_node_y[...] = [35, 35, 33, 33, 31, 31, 34] + + Mesh3_edge_nodes = n.createVariable( + "Mesh3_edge_nodes", "i4", ("nMesh3_edge", "connectivity2") + ) + Mesh3_edge_nodes.long_name = "Maps every edge to its two nodes" + Mesh3_edge_nodes[...] = [ + [1, 6], + [3, 6], + [3, 1], + [0, 1], + [2, 0], + [2, 3], + [2, 4], + [5, 4], + [3, 5], + ] + + # Face + Mesh3_face_x = n.createVariable( + "Mesh3_face_x", "f8", ("nMesh3_face",), fill_value=-99 + ) + Mesh3_face_x.standard_name = "longitude" + Mesh3_face_x.units = "degrees_east" + Mesh3_face_x[...] = [-44, -44, -42] + + Mesh3_face_y = n.createVariable( + "Mesh3_face_y", "f8", ("nMesh3_face",), fill_value=-99 + ) + Mesh3_face_y.standard_name = "latitude" + Mesh3_face_y.units = "degrees_north" + Mesh3_face_y[...] = [34, 32, 34] + + Mesh3_face_nodes = n.createVariable( + "Mesh3_face_nodes", + "i4", + ("nMesh3_face", "connectivity4"), + fill_value=-99, + ) + Mesh3_face_nodes.long_name = "Maps every face to its corner nodes" + Mesh3_face_nodes[...] = [[2, 3, 1, 0], [4, 5, 3, 2], [6, 1, 3, -99]] + + Mesh3_face_links = n.createVariable( + "Mesh3_face_links", + "i4", + ("nMesh3_face", "connectivity4"), + fill_value=-99, + ) + Mesh3_face_links.long_name = "neighbour faces for faces" + Mesh3_face_links[...] = [ + [1, 2, -99, -99], + [0, -99, -99, -99], + [0, -99, -99, -99], + ] + + # Edge + Mesh3_edge_x = n.createVariable( + "Mesh3_edge_x", "f8", ("nMesh3_edge",), fill_value=-99 + ) + Mesh3_edge_x.standard_name = "longitude" + Mesh3_edge_x.units = "degrees_east" + Mesh3_edge_x[...] = [-41.5, -41.5, -43, -44, -45, -44, -45, -44, -43] + + Mesh3_edge_y = n.createVariable( + "Mesh3_edge_y", "f8", ("nMesh3_edge",), fill_value=-99 + ) + Mesh3_edge_y.standard_name = "latitude" + Mesh3_edge_y.units = "degrees_north" + Mesh3_edge_y[...] = [34.5, 33.5, 34, 35, 34, 33, 32, 31, 32] + + Mesh3_edge_links = n.createVariable( + "Mesh3_edge_links", + "i4", + ("nMesh3_edge", "connectivity5"), + fill_value=-99, + ) + Mesh3_edge_links.long_name = "neighbour edges for edges" + Mesh3_edge_links[...] = [ + [1, 2, 3, -99, -99], + [0, 2, 5, 8, -99], + [3, 0, 1, 5, 8], + [4, 2, 0, -99, -99], + [ + 3, + 5, + 6, + -99, + -99, + ], + [4, 6, 2, 1, 8], + [ + 4, + 5, + 7, + -99, + -99, + ], + [ + 6, + 8, + -99, + -99, + -99, + ], + [7, 5, 2, 1, -99], + ] + + n.close() + return filename + + def _make_aggregation_value(filename): """Create an aggregation variable with 'unique_values'.""" n = netCDF4.Dataset(filename, "w") @@ -2341,6 +2485,7 @@ def _make_aggregation_value(filename): ugrid_1 = _make_ugrid_1("ugrid_1.nc") ugrid_2 = _make_ugrid_2("ugrid_2.nc") +ugrid_3 = _make_ugrid_3("ugrid_3.nc") aggregation_value = _make_aggregation_value("aggregation_value.nc") From ed45ae8af8a1a1015537eabdf8ef25d00617427d Mon Sep 17 00:00:00 2001 From: David Hassell Date: Wed, 29 Oct 2025 13:36:48 +0000 Subject: [PATCH 03/12] dev --- cf/test/test_UGRID.py | 86 +++++++++++++++++++++++-------------------- 1 file changed, 47 insertions(+), 39 deletions(-) diff --git a/cf/test/test_UGRID.py b/cf/test/test_UGRID.py index 103faa78e8..848da10593 100644 --- a/cf/test/test_UGRID.py +++ b/cf/test/test_UGRID.py @@ -52,6 +52,15 @@ def n_mesh_variables(filename): return n +def combinations(face, edge, point): + """Return combinations for field/domain indexing.""" + return [ + i + for n in range(1, 4) + for i in itertools.permutations([face, edge, point], n) + ] + + class UGRIDTest(unittest.TestCase): """Test UGRID field constructs.""" @@ -63,6 +72,10 @@ class UGRIDTest(unittest.TestCase): os.path.dirname(os.path.abspath(__file__)), "ugrid_2.nc" ) + filename3 = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "ugrid_3.nc" + ) + def setUp(self): """Preparations called immediately before each test method.""" # Disable log messages to silence expected warnings @@ -189,7 +202,6 @@ def test_read_write_UGRID_field(self): # Face, edge, and point fields that are all part of the same # UGRID mesh ugrid = cf.example_fields(8, 9, 10) - face, edge, point = (0, 1, 2) tmpfile = "tmpfileu.nc" @@ -202,19 +214,8 @@ def test_read_write_UGRID_field(self): self.assertEqual(len(g), 1) self.assertTrue(g[0].equals(f)) - # Test round-tripping fields with multiple fields - # - # Get the indices of 'ugrid' for all possible combinations of - # fields: - # - # combinations = [(0,), (1,), ..., (2, 0, 1), (2, 1, 0)] - combinations = [ - i - for n in range(1, 4) - for i in itertools.permutations([face, edge, point], n) - ] - - for cells in combinations: + # Test round-tripping of field combinations + for cells in combinations(face, edge, point): f = [] for cell in cells: f.append(ugrid[cell]) @@ -241,7 +242,6 @@ def test_read_write_UGRID_domain(self): # Face, edge, and point fields/domains that are all part of # the same UGRID mesh ugrid = [f.domain for f in cf.example_fields(8, 9, 10)] - face, edge, point = (0, 1, 2) # Test for equality with the fields defined in memory. Only @@ -254,38 +254,46 @@ def test_read_write_UGRID_domain(self): self.assertTrue(e[0].equals(d)) self.assertEqual(e[1].domain_topology().get_cell(), "point") - # Test round-tripping fields with all three domains - # - # combinations = [(0, 1, 2), (0, 2, 1), ..., (2, 0, 1), (2, 1, 0)] - combinations = list(itertools.permutations([face, edge, point], 3)) - for cells in combinations: - d = [] - for cell in cells: - d.append(ugrid[cell]) + # Test round-tripping of domain combinations for the + # example_field domains, and also the domain read from + # 'ugrid_3.nc'. + for iteration in ("memory", "file"): + for cells in combinations(face, edge, point): + d = [] + for cell in cells: + d.append(ugrid[cell]) - cf.write(d, tmpfile) + if point not in cells: + # When we write a non-point domains, we also get + # the point locations. + d.append(ugrid[point]) + elif cells == (point,): + # When we write a point domain on its own, we also + # get the edge location. + d.append(ugrid[edge]) - # Check that there's only one mesh variable in the file - self.assertEqual(n_mesh_variables(tmpfile), 1) + cf.write(d, tmpfile) - e = cf.read(tmpfile, domain=True) + # Check that there's only one mesh variable in the file + self.assertEqual(n_mesh_variables(tmpfile), 1) - self.assertEqual(len(e), len(d)) + e = cf.read(tmpfile, domain=True) - cf.write(e, tmpfile1) + self.assertEqual(len(e), len(d)) - # Check that there's only one mesh variable in the file - self.assertEqual(n_mesh_variables(tmpfile1), 1) + cf.write(e, tmpfile1) + + # Check that there's only one mesh variable in the file + self.assertEqual(n_mesh_variables(tmpfile1), 1) - f = cf.read(tmpfile1, domain=True) - self.assertEqual(len(f), len(e)) - for i, j in zip(f, e): - self.assertTrue(i.equals(j)) + f = cf.read(tmpfile1, domain=True) + self.assertEqual(len(f), len(e)) + for i, j in zip(f, e): + self.assertTrue(i.equals(j)) - # Note: Other combintations of domain read/write are tricky, - # because the mesh variable *and* the domain variable in - # the dataset *both* define domains. Let's not worry - # about that now! + # Set up for the 'file' iteration + ugrid = cf.read(self.filename3, domain=True) + face, edge, point = (2, 1, 0) if __name__ == "__main__": From 020c2ddbce56dc4aa87b4e9a752db22374a6a1ba Mon Sep 17 00:00:00 2001 From: David Hassell Date: Mon, 17 Nov 2025 15:06:54 +0000 Subject: [PATCH 04/12] dev --- cf/test/test_read_write.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cf/test/test_read_write.py b/cf/test/test_read_write.py index b7bae5fd1f..23ae7956e4 100644 --- a/cf/test/test_read_write.py +++ b/cf/test/test_read_write.py @@ -346,6 +346,8 @@ def test_write_netcdf_mode(self): if fmt == "NETCDF4_CLASSIC" and ex_field_n in (6, 7): continue + # Exclude UGRID fields, as we deal with them in + # test_UGRID.py if ex_field_n in (8, 9, 10): continue @@ -418,6 +420,8 @@ def test_write_netcdf_mode(self): # one operation rather than one at a time, to check that it works. cf.write(g, tmpfile, fmt=fmt, mode="w") # 1. overwrite to wipe + # Exclude UGRID fields, as we deal with them in + # test_UGRID.py append_ex_fields = cf.example_fields(0, 1, 2, 3, 4, 5, 6, 7) del append_ex_fields[1] # note: can remove after Issue #141 closed if fmt in "NETCDF4_CLASSIC": From 3f0e703afbbc6d5a6d171f216ee93656ba6d34e1 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Sat, 14 Mar 2026 15:57:00 +0000 Subject: [PATCH 05/12] dev --- cf/mixin/fielddomain.py | 27 ++++++++++++++++ cf/mixin/fielddomainlist.py | 26 +++++++++++++++ cf/test/test_xarray.py | 64 +++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 cf/test/test_xarray.py diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index 60098d76cd..b94739a7d6 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -3014,6 +3014,33 @@ def set_coordinate_reference( return self.set_construct(ref, key=key, copy=False) + def to_xarray(self): + """Convert the {{class}} to an `xarray` Dataset. + + If the `cf_xarray` package (https://cf-xarray.readthedocs.io) + is installed then the `cf_xarray` accessors will be present on + the returned `xarray` objects (`xarray.DataArray.cf` and + `xarray.Dataset.cf`) that allow some interpretation of CF + attributes. + + Note that multiple fields and domains may be written to the + same `xarray` dataset with `cf.write`, e.g. `ds = cf.write([x, + y], fmt='XARRAY')` + + .. versionadded:: NEXTVERSION + + .. seealso:: `cf.write` + + :Returns: + + `xarray.Dataset` + The equivalent `xarray` Dataset. + + """ + from cf.read_write import write + + return write(self, fmt="XARRAY") + # ---------------------------------------------------------------- # Aliases # ---------------------------------------------------------------- diff --git a/cf/mixin/fielddomainlist.py b/cf/mixin/fielddomainlist.py index 1e754ed357..fa376367c4 100644 --- a/cf/mixin/fielddomainlist.py +++ b/cf/mixin/fielddomainlist.py @@ -331,3 +331,29 @@ def select_by_rank(self, *ranks): """ return type(self)(f for f in self if f.match_by_rank(*ranks)) + + def to_xarray(self): + """Convert the list elements to an `xarray` Dataset. + + If the `cf_xarray` package (https://cf-xarray.readthedocs.io) + is installed then the `cf_xarray` accessors will be present on + the returned `xarray` objects (`xarray.DataArray.cf` and + `xarray.Dataset.cf`) that allow some interpretation of CF + attributes. + + Note that ``ds = fl.to_xarray()`` is identical to ``ds = + cf.write(fl, fmt='XARRAY')`` + + .. versionadded:: NEXTVERSION + + .. seealso:: `cf.write` + + :Returns: + + `xarray.Dataset` + The equivalent `xarray` Dataset. + + """ + from cf.read_write import write + + return write(self, fmt="XARRAY") diff --git a/cf/test/test_xarray.py b/cf/test/test_xarray.py new file mode 100644 index 0000000000..66be79c639 --- /dev/null +++ b/cf/test/test_xarray.py @@ -0,0 +1,64 @@ +import datetime +import faulthandler +import unittest + +faulthandler.enable() # to debug seg faults and timeouts + +import xarray as xr + +import cf + + +class xarrayTest(unittest.TestCase): + """Unit test for converting to xarray.""" + + def setUp(self): + """Preparations called immediately before each test method.""" + # Disable log messages to silence expected warnings + cf.log_level("DISABLE") + # Note: to enable all messages for given methods, lines or + # calls (those without a 'verbose' option to do the same) + # e.g. to debug them, wrap them (for methods, start-to-end + # internally) as follows: + # + # cf.LOG_LEVEL('DEBUG') + # < ... test code ... > + # cf.log_level('DISABLE') + + def test_Field_to_xarray(self): + """Test Field.to_xarray.""" + fields = cf.example_fields() + + # Write each field to a different xarray dataset + for f in fields: + ds = f.to_xarray() + self.assertIsInstance(ds, xr.Dataset) + str(ds) + self.assertIn("Conventions", ds.attrs) + + # Write all fields to one xarray dataset + ds = cf.write(fields, fmt="XARRAY") + self.assertIsInstance(ds, xr.Dataset) + str(ds) + + def test_Domain_to_xarray(self): + """Test Domain.to_xarray.""" + domains = [f.domain for f in cf.example_fields()] + + # Write each domain to a different xarray dataset + for d in domains: + ds = d.to_xarray() + self.assertIsInstance(ds, xr.Dataset) + str(ds) + + # Write all domains to one xarray dataset + ds = cf.write(domains, fmt="XARRAY") + self.assertIsInstance(ds, xr.Dataset) + str(ds) + + +if __name__ == "__main__": + print("Run date:", datetime.datetime.now()) + cf.environment() + print("") + unittest.main(verbosity=2) From 00364872643716e0b2f028bcee7b7be565fb6a35 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Sun, 15 Mar 2026 12:14:46 +0000 Subject: [PATCH 06/12] dev --- cf/mixin/fielddomain.py | 15 +++--- cf/mixin/fielddomainlist.py | 4 +- cf/mixin/propertiesdatabounds.py | 4 +- cf/test/test_xarray.py | 50 +++++++++++++++++++ .../source/recipes-source/plot_08_recipe.py | 3 +- .../source/recipes-source/plot_12_recipe.py | 2 +- .../source/recipes-source/plot_13_recipe.py | 4 +- .../source/recipes-source/plot_17_recipe.py | 2 +- .../source/recipes-source/plot_18_recipe.py | 4 +- .../source/recipes-source/plot_19_recipe.py | 3 +- .../source/recipes-source/plot_23_recipe.py | 6 +-- setup.py | 47 +++++++---------- 12 files changed, 93 insertions(+), 51 deletions(-) diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index b94739a7d6..44ff078565 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -3020,16 +3020,19 @@ def to_xarray(self): If the `cf_xarray` package (https://cf-xarray.readthedocs.io) is installed then the `cf_xarray` accessors will be present on the returned `xarray` objects (`xarray.DataArray.cf` and - `xarray.Dataset.cf`) that allow some interpretation of CF - attributes. + `xarray.Dataset.cf`) that allow some extra interpretation of + CF attributes. - Note that multiple fields and domains may be written to the - same `xarray` dataset with `cf.write`, e.g. `ds = cf.write([x, - y], fmt='XARRAY')` + Note that `f.to_xarray()` is identical to ``ds = cf.write(f, + 'fmt='XARRAY')``; and multiple fields and domains may be + written to the same `xarray` dataset from a `cf.{{class}}List` + (e.g. ``ds = fl.to_xarray()``) or with `cf.write` (e.g.``ds = + cf.write([f, g], fmt='XARRAY')`` or ``ds = cf.write(fl, + fmt='XARRAY')``). .. versionadded:: NEXTVERSION - .. seealso:: `cf.write` + .. seealso:: `cf.{{class}}List.to_xarray`, `cf.write` :Returns: diff --git a/cf/mixin/fielddomainlist.py b/cf/mixin/fielddomainlist.py index fa376367c4..7f4b33fc5e 100644 --- a/cf/mixin/fielddomainlist.py +++ b/cf/mixin/fielddomainlist.py @@ -338,8 +338,8 @@ def to_xarray(self): If the `cf_xarray` package (https://cf-xarray.readthedocs.io) is installed then the `cf_xarray` accessors will be present on the returned `xarray` objects (`xarray.DataArray.cf` and - `xarray.Dataset.cf`) that allow some interpretation of CF - attributes. + `xarray.Dataset.cf`) that allow some extra interpretation of + CF attributes. Note that ``ds = fl.to_xarray()`` is identical to ``ds = cf.write(fl, fmt='XARRAY')`` diff --git a/cf/mixin/propertiesdatabounds.py b/cf/mixin/propertiesdatabounds.py index 1150449a56..720e2f7c3c 100644 --- a/cf/mixin/propertiesdatabounds.py +++ b/cf/mixin/propertiesdatabounds.py @@ -18,7 +18,9 @@ ) from ..functions import equivalent as cf_equivalent from ..functions import inspect as cf_inspect -from ..functions import parse_indices +from ..functions import ( + parse_indices, +) from ..functions import size as cf_size from ..query import Query from ..units import Units diff --git a/cf/test/test_xarray.py b/cf/test/test_xarray.py index 66be79c639..2243f9c555 100644 --- a/cf/test/test_xarray.py +++ b/cf/test/test_xarray.py @@ -56,6 +56,56 @@ def test_Domain_to_xarray(self): self.assertIsInstance(ds, xr.Dataset) str(ds) + def test_FieldList_to_xarray(self): + """Test Field.to_xarray.""" + fields = cf.example_fields() + ds = fields.to_xarray() + self.assertIsInstance(ds, xr.Dataset) + str(ds) + + def test_DomainList_to_xarray(self): + """Test DomainList.to_xarray.""" + domains = cf.DomainList([f.domain for f in cf.example_fields()]) + ds = domains.to_xarray() + self.assertIsInstance(ds, xr.Dataset) + str(ds) + + def test_FieldList_to_xarray_from_dataset(self): + """Test FieldList.to_xarray from datasets read from disk.""" + for dataset in ( + "example_field_0.nc", + "example_field_0.zarr2", + "example_field_0.zarr3", + "gathered.nc", + "DSG_timeSeries_contiguous.nc", + "DSG_timeSeries_indexed.nc", + "DSG_timeSeriesProfile_indexed_contiguous.nc", + "parent.nc", + "external.nc", + "external_missing.nc", + "combined.nc", + "geometry_1.nc", + "geometry_2.nc", + "geometry_3.nc", + "geometry_4.nc", + "geometry_interior_ring.nc", + "geometry_interior_ring_2.nc", + "string_char.nc", + "subsampled_2.nc", + "ugrid_1.nc", + "ugrid_2.nc", + "ugrid_3.nc", + "test_file.nc", + "extra_data.pp", + "file1.pp", + "umfile.pp", + "wgdos_packed.pp", + ): + f = cf.read(dataset, netcdf_backend="netCDF4") + ds = f.to_xarray() + self.assertIsInstance(ds, xr.Dataset) + str(ds) + if __name__ == "__main__": print("Run date:", datetime.datetime.now()) diff --git a/recipes-docs/source/recipes-source/plot_08_recipe.py b/recipes-docs/source/recipes-source/plot_08_recipe.py index 63427f62a7..6045f51448 100644 --- a/recipes-docs/source/recipes-source/plot_08_recipe.py +++ b/recipes-docs/source/recipes-source/plot_08_recipe.py @@ -9,11 +9,10 @@ # 1. Import cf-python, cf-plot, numpy and scipy.stats: import cfplot as cfp -import cf - import numpy as np import scipy.stats as stats +import cf # %% # 2. Three functions are defined: diff --git a/recipes-docs/source/recipes-source/plot_12_recipe.py b/recipes-docs/source/recipes-source/plot_12_recipe.py index b09db0b29f..5304194b19 100644 --- a/recipes-docs/source/recipes-source/plot_12_recipe.py +++ b/recipes-docs/source/recipes-source/plot_12_recipe.py @@ -13,8 +13,8 @@ # %% # 1. Import cf-python, cf-plot and matplotlib.pyplot: -import matplotlib.pyplot as plt import cfplot as cfp +import matplotlib.pyplot as plt import cf diff --git a/recipes-docs/source/recipes-source/plot_13_recipe.py b/recipes-docs/source/recipes-source/plot_13_recipe.py index bf0398713e..9b658597d8 100644 --- a/recipes-docs/source/recipes-source/plot_13_recipe.py +++ b/recipes-docs/source/recipes-source/plot_13_recipe.py @@ -18,13 +18,11 @@ # in next steps. import cartopy.crs as ccrs -import matplotlib.patches as mpatches - import cfplot as cfp +import matplotlib.patches as mpatches import cf - # %% # 2. Read and select the SST by index and look at its contents: sst = cf.read("~/recipes/ERA5_monthly_averaged_SST.nc")[0] diff --git a/recipes-docs/source/recipes-source/plot_17_recipe.py b/recipes-docs/source/recipes-source/plot_17_recipe.py index c94769e2ba..a66c90b518 100644 --- a/recipes-docs/source/recipes-source/plot_17_recipe.py +++ b/recipes-docs/source/recipes-source/plot_17_recipe.py @@ -11,8 +11,8 @@ # %% # 1. Import cf-python and cf-plot: -import matplotlib.pyplot as plt import cfplot as cfp +import matplotlib.pyplot as plt import cf diff --git a/recipes-docs/source/recipes-source/plot_18_recipe.py b/recipes-docs/source/recipes-source/plot_18_recipe.py index f0eae36e35..3beb9d0db9 100644 --- a/recipes-docs/source/recipes-source/plot_18_recipe.py +++ b/recipes-docs/source/recipes-source/plot_18_recipe.py @@ -10,15 +10,15 @@ """ +import cfplot as cfp + # %% # 1. Import cf-python, cf-plot and other required packages: import matplotlib.pyplot as plt import scipy.stats.mstats as mstats -import cfplot as cfp import cf - # %% # 2. Read the data in and unpack the Fields from FieldLists using indexing. # In our example We are investigating the influence of the land height on diff --git a/recipes-docs/source/recipes-source/plot_19_recipe.py b/recipes-docs/source/recipes-source/plot_19_recipe.py index 02d493dc21..ceb9db1c5c 100644 --- a/recipes-docs/source/recipes-source/plot_19_recipe.py +++ b/recipes-docs/source/recipes-source/plot_19_recipe.py @@ -9,10 +9,11 @@ maxima. """ +import cfplot as cfp + # %% # 1. Import cf-python, cf-plot and other required packages: import matplotlib.pyplot as plt -import cfplot as cfp import cf diff --git a/recipes-docs/source/recipes-source/plot_23_recipe.py b/recipes-docs/source/recipes-source/plot_23_recipe.py index 4ae11c3863..29537803af 100644 --- a/recipes-docs/source/recipes-source/plot_23_recipe.py +++ b/recipes-docs/source/recipes-source/plot_23_recipe.py @@ -18,14 +18,13 @@ # sphinx_gallery_thumbnail_number = 2 # sphinx_gallery_end_ignore +import cfplot as cfp +import dask.array as da import matplotlib.pyplot as plt import numpy as np -import dask.array as da -import cfplot as cfp import cf - # %% # 2. Read example data field constructs, and set region for our plots: @@ -171,4 +170,3 @@ # create your figure with cf-plot with placeholders for your other plots, # then add subplots by accessing the ``cfp.plotvars.master_plot`` object, # and finally redraw the figure containing the new plots. - diff --git a/setup.py b/setup.py index 4620e64a4a..c1ed974391 100755 --- a/setup.py +++ b/setup.py @@ -178,58 +178,46 @@ def compile(): The ``cf`` package can: -* read field and domain constructs from netCDF, CDL, Zarr, PP and UM datasets, - +* read field and domain constructs from netCDF, CDL, Zarr, UM fields file, and PP datasets with a choice of netCDF backends, * be fully flexible with respect to dataset storage chunking, - -* create new field constructs in memory, - -* write and append field and domain constructs to netCDF and Zarr v3 datasets on disk, - +* create new field and domain constructs in memory, +* write and append field and domain constructs to netCDF and Zarr v3 datasets on disk, with control over HDF5 internal file metadata, +* read, write, and manipulate UGRID mesh topologies, +* read, write, and manipulate HEALPix grids, * read, write, and create coordinates defined by geometry cells, - -* read netCDF and CDL datasets containing hierarchical groups, - +* read and write netCDF4 string data-type variables, +* read, write, and create netCDF and CDL datasets containing hierarchical groups, +* read, write, and create data that have been compressed by convention + (i.e. ragged or gathered arrays, or coordinate arrays compressed by + subsampling), whilst presenting a view of the data in its + uncompressed form, +* read and write data that are quantized to eliminate false + precision, +* Convert field and domain constructs to `xarray` datasets in memory, * inspect field constructs, - * test whether two field constructs are the same, - * modify field construct metadata and data, - * create subspaces of field constructs, - * incorporate, and create, metadata stored in external files, - * read, write, and create data that have been compressed by convention (i.e. ragged or gathered arrays, or coordinate arrays compressed by subsampling), whilst presenting a view of the data in its uncompressed form, - * combine field constructs arithmetically, - * manipulate field construct data by arithmetical and trigonometrical operations, - * perform statistical collapses on field constructs, - * perform histogram, percentile and binning operations on field constructs, - * regrid structured grid, mesh and DSG field constructs with (multi-)linear, nearest neighbour, first- and second-order conservative and higher order patch recovery methods, including 3-d regridding, and large-grid support, - * apply convolution filters to field constructs, - * create running means from field constructs, +* apply differential operators to field constructs, and +* create derived quantities (such as relative vorticity). -* apply differential operators to field constructs, - -* create derived quantities (such as relative vorticity), - -* read and write data that are quantized to eliminate false - precision. """ @@ -264,6 +252,9 @@ def compile(): "zarr": [ "zarr>=3.1.3", ], + "xarray": [ + "xarray>=2026.2.0", + ], } setup( From 38bd4cac7da391044a37a2f95974f6aeba4f7c5f Mon Sep 17 00:00:00 2001 From: David Hassell Date: Sun, 15 Mar 2026 12:18:28 +0000 Subject: [PATCH 07/12] dev --- docs/source/class/cf.Domain.rst | 14 ++++++++++++++ docs/source/class/cf.Field.rst | 14 ++++++++++++++ docs/source/class/cf.FieldList.rst | 14 ++++++++++++++ docs/source/installation.rst | 6 ++++++ 4 files changed, 48 insertions(+) diff --git a/docs/source/class/cf.Domain.rst b/docs/source/class/cf.Domain.rst index 7f75c0b3ac..f946e620b8 100644 --- a/docs/source/class/cf.Domain.rst +++ b/docs/source/class/cf.Domain.rst @@ -240,6 +240,20 @@ NetCDF ~cf.Domain.nc_set_global_attribute ~cf.Domain.nc_set_global_attributes +.. _Domain-xarray: + +xarray +------ + +.. rubric:: Methods + +.. autosummary:: + :nosignatures: + :toctree: ../method/ + :template: method.rst + + ~cf.Domain.to_xarray + Groups ^^^^^^ diff --git a/docs/source/class/cf.Field.rst b/docs/source/class/cf.Field.rst index 1c307f362e..fb3c0f3764 100644 --- a/docs/source/class/cf.Field.rst +++ b/docs/source/class/cf.Field.rst @@ -439,6 +439,20 @@ NetCDF ~cf.Field.nc_dataset_chunksizes ~cf.Field.nc_set_dataset_chunksizes +.. _Field-xarray: + +xarray +------ + +.. rubric:: Methods + +.. autosummary:: + :nosignatures: + :toctree: ../method/ + :template: method.rst + + ~cf.Field.to_xarray + Groups ^^^^^^ diff --git a/docs/source/class/cf.FieldList.rst b/docs/source/class/cf.FieldList.rst index dfc462f727..bb75b59535 100644 --- a/docs/source/class/cf.FieldList.rst +++ b/docs/source/class/cf.FieldList.rst @@ -41,6 +41,20 @@ Comparison ~cf.FieldList.equals +.. _FieldList-xarray: + +xarray +------ + +.. rubric:: Methods + +.. autosummary:: + :nosignatures: + :toctree: ../method/ + :template: method.rst + + ~cf.FieldList.to_xarray + Miscellaneous ------------- diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 13c9f0ef88..c444d1bf53 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -271,6 +271,12 @@ environments for which these features are not required. For reading and writing Zarr datasets. +.. rubric:: xarray + +* `xarray `_, version 2026.2.0 or newer. + + For converting fields and domains to `xarray` datasets in memory. + .. rubric:: Regridding * `esmpy `_, previously From a8de6af0f73fa7ffc021568815e20927e35ff114 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Mon, 16 Mar 2026 17:31:01 +0000 Subject: [PATCH 08/12] remove UGRID constructs after collapse --- cf/field.py | 15 +++++++++++---- cf/test/test_collapse.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/cf/field.py b/cf/field.py index b571158ea9..0f6b400c68 100644 --- a/cf/field.py +++ b/cf/field.py @@ -6903,7 +6903,8 @@ def collapse( # --------------------------------------------------------- # Update dimension coordinates, auxiliary coordinates, - # cell measures and domain ancillaries + # cell measures, domain ancillaries, domain_topologies, + # and cell connectivities. # --------------------------------------------------------- for axis, domain_axis in collapse_axes.items(): # Ignore axes which are already size 1 @@ -6911,10 +6912,16 @@ def collapse( if size == 1: continue - # REMOVE all cell measures and domain ancillaries - # which span this axis + # REMOVE all cell measures, domain ancillaries, + # domain_topologies, and cell connectivities which + # span this axis c = f.constructs.filter( - filter_by_type=("cell_measure", "domain_ancillary"), + filter_by_type=( + "cell_measure", + "domain_ancillary", + "domain_topology", + "cell_connectivity", + ), filter_by_axis=(axis,), axis_mode="or", todict=True, diff --git a/cf/test/test_collapse.py b/cf/test/test_collapse.py index c7c866c8b6..0f987fb969 100644 --- a/cf/test/test_collapse.py +++ b/cf/test/test_collapse.py @@ -1,6 +1,8 @@ +import atexit import datetime import faulthandler import os +import tempfile import unittest import numpy @@ -9,6 +11,25 @@ import cf +n_tmpfiles = 1 +tmpfiles = [ + tempfile.mkstemp("_test_collapse.nc", dir=os.getcwd())[1] + for i in range(n_tmpfiles) +] +[tmpfile] = tmpfiles + + +def _remove_tmpfiles(): + """Try to remove defined temporary files by deleting their paths.""" + for f in tmpfiles: + try: + os.remove(f) + except OSError: + pass + + +atexit.register(_remove_tmpfiles) + class Field_collapseTest(unittest.TestCase): def setUp(self): @@ -791,6 +812,19 @@ def test_Field_collapse_non_positive_weights(self): # compute time g.array + def test_Field_collapse_ugrid(self): + """Check that UGRID constructs are removed after collapsing.""" + f = cf.example_field(8) + self.assertTrue(f.domain_topologies()) + self.assertTrue(f.cell_connectivities()) + + f = f.collapse("area: mean") + self.assertFalse(f.domain_topologies()) + self.assertFalse(f.cell_connectivities()) + + # Check the collpsed fields writes + cf.write(f, tmpfile) + if __name__ == "__main__": print("Run date:", datetime.datetime.now()) From afc67c0bc01d0e3d11562c9f3bad66fdbcc0a767 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Mon, 16 Mar 2026 17:59:16 +0000 Subject: [PATCH 09/12] dev --- Changelog.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Changelog.rst b/Changelog.rst index fcc3fbb2de..1bcae724cc 100644 --- a/Changelog.rst +++ b/Changelog.rst @@ -3,6 +3,13 @@ Version NEXTVERSION **2026-??-??** +* New methods to convert to `xarray`: `cf.Field.to_xarray`, + `cf.FieldList.to_xarray`, `cf.Domain.to_xarray`, and + `cf.DomainList.to_xarray` + (https://github.com/NCAS-CMS/cf-python/issues/???) +* New output format for `cf.write` that creates an `xarray` dataset in + memory: ``'XARRAY'`` + (https://github.com/NCAS-CMS/cf-python/issues/???) * New keyword parameter to `cf.Data.compute`: ``persist`` (https://github.com/NCAS-CMS/cf-python/issues/929) * New function to control the persistence of computed data: @@ -22,8 +29,11 @@ Version NEXTVERSION (https://github.com/NCAS-CMS/cfdm/issues/391) * Fix for subspacing with cyclic `cf.wi` and `cf.wo` arguments (https://github.com/NCAS-CMS/cf-python/issues/887) +* New optional dependency: ``xarray>=2026.2.0`` * Changed dependency: ``cfdm>=1.13.1.0, <1.13.2.0`` +---- + Version 3.19.0 -------------- From 00437c7f08e8b3f44f7cbdcb92be5dd2b666114a Mon Sep 17 00:00:00 2001 From: David Hassell Date: Tue, 17 Mar 2026 00:47:26 +0000 Subject: [PATCH 10/12] dev --- cf/mixin/fielddomain.py | 43 +++++++++++++++++++------------------ cf/mixin/fielddomainlist.py | 28 +++++++++++++----------- cf/test/test_xarray.py | 33 ++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 34 deletions(-) diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index 339a91d532..91b7a25342 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -3044,38 +3044,39 @@ def set_coordinate_reference( return self.set_construct(ref, key=key, copy=False) - def to_xarray(self): - """Convert the {{class}} to an `xarray` Dataset. - - If the `cf_xarray` package (https://cf-xarray.readthedocs.io) - is installed then the `cf_xarray` accessors that allow some - interpretation of CF attributes will bxe present on the - returned `xarray` objects (`xarray.DataArray.cf` and - `xarray.Dataset.cf`, but not `xarray.DataTree`). - - Note that ``f.to_xarray()`` is identical to ``ds = cf.write(f, - fmt='XARRAY')``; and multiple fields and domains may be - written to the same `xarray` dataset from a `cf.{{class}}List` - (e.g. ``ds = fl.to_xarray()``) or with `cf.write` (e.g.``ds = - cf.write([f, g], fmt='XARRAY')`` or ``ds = cf.write(fl, - fmt='XARRAY')``). + def to_xarray(self, group=True): + """Convert the {{class}} to an `xarray` dataset. + + {{cf_xarray description}} + + Note that ``ds = f.to_xarray()`` is identical to ``ds = + cf.write(f, fmt='XARRAY')``; and multiple fields and domains + may be written to the same `xarray` dataset from a + `cf.{{class}}List` (e.g. ``ds = fl.to_xarray()``) or with + `cf.write`, e.g. ``ds = cf.write([f, g], fmt='XARRAY')``. .. versionadded:: NEXTVERSION .. seealso:: `cf.{{class}}List.to_xarray`, `cf.write` + :Parameter: + + group: `bool`, optional + If False then create a "flat" dataset, i.e. one with + only the root group, regardless of any group structure + specified by the field constructs. If True (the + default) then any sub-groups defined by the netCDF + interface of the {{class}} constructs and its + components will be created and populated. + :Returns: - `xarray.Dataset` or `xarray.DataTree` - The equivalent `xarray` dataset. If there are no - sub-groups of the root group then an `xarray.Dataset` - is returned, oterwise an `xarray.DataTree` is - returned. + {{Returns xarray}} """ from cf.read_write import write - return write(self, fmt="XARRAY") + return write(self, fmt="XARRAY", group=group) # ---------------------------------------------------------------- # Aliases diff --git a/cf/mixin/fielddomainlist.py b/cf/mixin/fielddomainlist.py index da8dd6b52b..a8d8fe130d 100644 --- a/cf/mixin/fielddomainlist.py +++ b/cf/mixin/fielddomainlist.py @@ -332,31 +332,33 @@ def select_by_rank(self, *ranks): return type(self)(f for f in self if f.match_by_rank(*ranks)) - def to_xarray(self): + def to_xarray(self, group=True): """Convert the list elements to an `xarray` Dataset. - If the `cf_xarray` package (https://cf-xarray.readthedocs.io) - is installed then the `cf_xarray` accessors that allow some - interpretation of CF attributes will bxe present on the - returned `xarray` objects (`xarray.DataArray.cf` and - `xarray.Dataset.cf`, but not `xarray.DataTree`). + {{cf_xarray description}} Note that ``ds = fl.to_xarray()`` is identical to ``ds = - cf.write(fl, fmt='XARRAY')`` + cf.write(fl, fmt='XARRAY')``. .. versionadded:: NEXTVERSION .. seealso:: `cf.write` + :Parameter: + + group: `bool`, optional + If False then create a "flat" dataset, i.e. one with + only the root group, regardless of any group structure + specified by the field constructs. If True (the + default) then any sub-groups defined by the netCDF + interface of the constructs and their components will + be created and populated. + :Returns: - `xarray.Dataset` or `xarray.DataTree` - The equivalent `xarray` dataset. If there are no - sub-groups of the root group then an `xarray.Dataset` - is returned, oterwise an `xarray.DataTree` is - returned. + {{Returns xarray}} """ from cf.read_write import write - return write(self, fmt="XARRAY") + return write(self, fmt="XARRAY", group=group) diff --git a/cf/test/test_xarray.py b/cf/test/test_xarray.py index 2e80364c47..619f453c5e 100644 --- a/cf/test/test_xarray.py +++ b/cf/test/test_xarray.py @@ -117,8 +117,10 @@ def test_Field_to_xarray_groups(self): f.nc_set_variable("/forecast/model/q2") ds = f.to_xarray() self.assertIsInstance(ds, xr.DataTree) + self.assertIn("q2", ds["/forecast/model"]) str(ds) + # group=True ds = cf.write([f, g], fmt="XARRAY") self.assertIsInstance(ds, xr.DataTree) str(ds) @@ -126,6 +128,37 @@ def test_Field_to_xarray_groups(self): self.assertIn("q", ds) self.assertIn("q2", ds["/forecast/model"]) + # group=False + ds = f.to_xarray(group=False) + self.assertIsInstance(ds, xr.Dataset) + self.assertIn("q2", ds) + str(ds) + + ds = cf.write([f, g], fmt="XARRAY", group=False) + self.assertIsInstance(ds, xr.Dataset) + str(ds) + + self.assertIn("q", ds) + self.assertIn("q2", ds) + + def test_FieldList_to_xarray_groups(self): + """Test Field.to_xarray with groups.""" + f = cf.example_fields(0) + + ds = f.to_xarray() + self.assertIsInstance(ds, xr.Dataset) + + f[0].nc_set_variable("/forecast/model/q2") + ds = f.to_xarray() + self.assertIsInstance(ds, xr.DataTree) + self.assertIn("q2", ds["/forecast/model"]) + str(ds) + + ds = f.to_xarray(group=False) + self.assertIsInstance(ds, xr.Dataset) + self.assertIn("q2", ds) + str(ds) + def test_Field_to_xarray_aggregation(self): """Test Field.to_xarray with aggregated data.""" f = cf.read("example_field_0.nc")[0] From 222a614814cb1a31e1f717717d0dc28c3d8a5a0e Mon Sep 17 00:00:00 2001 From: David Hassell Date: Tue, 17 Mar 2026 13:32:22 +0000 Subject: [PATCH 11/12] dev --- cf/mixin/fielddomain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index 91b7a25342..6f73a5e224 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -3050,7 +3050,7 @@ def to_xarray(self, group=True): {{cf_xarray description}} Note that ``ds = f.to_xarray()`` is identical to ``ds = - cf.write(f, fmt='XARRAY')``; and multiple fields and domains + cf.write(f, fmt='XARRAY')``; and multiple {{clasfields and domains may be written to the same `xarray` dataset from a `cf.{{class}}List` (e.g. ``ds = fl.to_xarray()``) or with `cf.write`, e.g. ``ds = cf.write([f, g], fmt='XARRAY')``. From 9b1d5603b8a7314c533dbcdc2d5fdc5977c2cd55 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Tue, 17 Mar 2026 14:49:11 +0000 Subject: [PATCH 12/12] dev --- Changelog.rst | 4 ++-- cf/mixin/fielddomain.py | 19 +++++++++++-------- cf/mixin/fielddomainlist.py | 11 ++++++----- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/Changelog.rst b/Changelog.rst index 1bcae724cc..3db9936f2e 100644 --- a/Changelog.rst +++ b/Changelog.rst @@ -6,10 +6,10 @@ Version NEXTVERSION * New methods to convert to `xarray`: `cf.Field.to_xarray`, `cf.FieldList.to_xarray`, `cf.Domain.to_xarray`, and `cf.DomainList.to_xarray` - (https://github.com/NCAS-CMS/cf-python/issues/???) + (https://github.com/NCAS-CMS/cf-python/issues/933) * New output format for `cf.write` that creates an `xarray` dataset in memory: ``'XARRAY'`` - (https://github.com/NCAS-CMS/cf-python/issues/???) + (https://github.com/NCAS-CMS/cf-python/issues/933) * New keyword parameter to `cf.Data.compute`: ``persist`` (https://github.com/NCAS-CMS/cf-python/issues/929) * New function to control the persistence of computed data: diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index 6f73a5e224..f5137b5d4a 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -3050,10 +3050,12 @@ def to_xarray(self, group=True): {{cf_xarray description}} Note that ``ds = f.to_xarray()`` is identical to ``ds = - cf.write(f, fmt='XARRAY')``; and multiple {{clasfields and domains - may be written to the same `xarray` dataset from a - `cf.{{class}}List` (e.g. ``ds = fl.to_xarray()``) or with - `cf.write`, e.g. ``ds = cf.write([f, g], fmt='XARRAY')``. + cf.write(f, fmt='XARRAY')``; and multiple {{class_lower}}s may + be written to the same `xarray` dataset with + `cf.{{class}}List.to_xarray`, or with `cf.write` (e.g. ``ds = + cf.write([f, g], fmt='XARRAY')``). Also, `cf.write` allows a + mixture a mixture of fields and domains to be written to the + same `xarray` dataset. .. versionadded:: NEXTVERSION @@ -3062,12 +3064,13 @@ def to_xarray(self, group=True): :Parameter: group: `bool`, optional + If False then create a "flat" dataset, i.e. one with only the root group, regardless of any group structure - specified by the field constructs. If True (the - default) then any sub-groups defined by the netCDF - interface of the {{class}} constructs and its - components will be created and populated. + specified by the netCDF interfaces of the + {{class_lower}} and its components. If True (the + default) then any sub-groups will be created and + populated. :Returns: diff --git a/cf/mixin/fielddomainlist.py b/cf/mixin/fielddomainlist.py index a8d8fe130d..8d529c274f 100644 --- a/cf/mixin/fielddomainlist.py +++ b/cf/mixin/fielddomainlist.py @@ -338,7 +338,9 @@ def to_xarray(self, group=True): {{cf_xarray description}} Note that ``ds = fl.to_xarray()`` is identical to ``ds = - cf.write(fl, fmt='XARRAY')``. + cf.write(fl, fmt='XARRAY')``. Also, `cfdm.write` allows a + mixture a mixture of fields and domains to be written to the + same `xarray` dataset. .. versionadded:: NEXTVERSION @@ -349,10 +351,9 @@ def to_xarray(self, group=True): group: `bool`, optional If False then create a "flat" dataset, i.e. one with only the root group, regardless of any group structure - specified by the field constructs. If True (the - default) then any sub-groups defined by the netCDF - interface of the constructs and their components will - be created and populated. + specified by the netCDF interfaces of the list + elements and their components. If True (the default) + then any sub-groups will be created and populated. :Returns: