cf-xarray 0.10.7__tar.gz → 0.10.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/.github/workflows/ci.yaml +4 -4
  2. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/.github/workflows/pypi.yaml +5 -5
  3. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/.github/workflows/testpypi-release.yaml +2 -2
  4. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/.github/workflows/upstream-dev-ci.yaml +1 -1
  5. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/PKG-INFO +1 -1
  6. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/__init__.py +1 -1
  7. cf_xarray-0.10.8/cf_xarray/_version.py +1 -0
  8. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/accessor.py +345 -22
  9. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/datasets.py +38 -0
  10. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/helpers.py +16 -3
  11. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/tests/__init__.py +1 -0
  12. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/tests/test_accessor.py +201 -3
  13. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/tests/test_helpers.py +35 -0
  14. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray.egg-info/PKG-INFO +1 -1
  15. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/ci/doc.yml +2 -0
  16. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/ci/environment-all-min-deps.yml +1 -0
  17. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/ci/environment.yml +1 -0
  18. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/ci/upstream-dev-env.yml +1 -0
  19. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/api.rst +3 -0
  20. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/grid_mappings.md +83 -18
  21. cf_xarray-0.10.7/cf_xarray/_version.py +0 -1
  22. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/.binder/environment.yml +0 -0
  23. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/.deepsource.toml +0 -0
  24. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/.github/dependabot.yml +0 -0
  25. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/.github/release.yml +0 -0
  26. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/.github/workflows/parse_logs.py +0 -0
  27. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/.gitignore +0 -0
  28. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/.pre-commit-config.yaml +0 -0
  29. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/.readthedocs.yml +0 -0
  30. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/.tributors +0 -0
  31. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/CITATION.cff +0 -0
  32. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/LICENSE +0 -0
  33. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/README.rst +0 -0
  34. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/coding.py +0 -0
  35. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/criteria.py +0 -0
  36. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/formatting.py +0 -0
  37. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/geometry.py +0 -0
  38. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/groupers.py +0 -0
  39. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/options.py +0 -0
  40. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/parametric.py +0 -0
  41. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/py.typed +0 -0
  42. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/scripts/make_doc.py +0 -0
  43. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/scripts/print_versions.py +0 -0
  44. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/sgrid.py +0 -0
  45. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/tests/conftest.py +0 -0
  46. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/tests/test_coding.py +0 -0
  47. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/tests/test_geometry.py +0 -0
  48. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/tests/test_groupers.py +0 -0
  49. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/tests/test_options.py +0 -0
  50. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/tests/test_parametric.py +0 -0
  51. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/tests/test_scripts.py +0 -0
  52. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/tests/test_units.py +0 -0
  53. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/units.py +0 -0
  54. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray/utils.py +0 -0
  55. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray.egg-info/SOURCES.txt +0 -0
  56. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray.egg-info/dependency_links.txt +0 -0
  57. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray.egg-info/requires.txt +0 -0
  58. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/cf_xarray.egg-info/top_level.txt +0 -0
  59. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/ci/environment-no-optional-deps.yml +0 -0
  60. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/codecov.yml +0 -0
  61. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/2D_bounds_averaged.png +0 -0
  62. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/2D_bounds_error.png +0 -0
  63. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/2D_bounds_nonunique.png +0 -0
  64. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/Makefile +0 -0
  65. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/_static/dataset-diagram-logo.tex +0 -0
  66. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/_static/full-logo.png +0 -0
  67. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/_static/logo.png +0 -0
  68. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/_static/logo.svg +0 -0
  69. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/_static/rich-repr-example.png +0 -0
  70. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/_static/style.css +0 -0
  71. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/bounds.md +0 -0
  72. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/cartopy_rotated_pole.png +0 -0
  73. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/coding.md +0 -0
  74. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/conf.py +0 -0
  75. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/contributing.rst +0 -0
  76. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/coord_axes.md +0 -0
  77. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/custom-criteria.md +0 -0
  78. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/dsg.md +0 -0
  79. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/examples/introduction.ipynb +0 -0
  80. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/faq.md +0 -0
  81. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/flags.md +0 -0
  82. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/geometry.md +0 -0
  83. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/howtouse.md +0 -0
  84. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/index.rst +0 -0
  85. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/make.bat +0 -0
  86. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/parametricz.md +0 -0
  87. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/plotting.md +0 -0
  88. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/provenance.md +0 -0
  89. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/quickstart.md +0 -0
  90. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/roadmap.rst +0 -0
  91. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/selecting.md +0 -0
  92. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/sgrid_ugrid.md +0 -0
  93. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/units.md +0 -0
  94. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/doc/whats-new.rst +0 -0
  95. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/pyproject.toml +0 -0
  96. {cf_xarray-0.10.7 → cf_xarray-0.10.8}/setup.cfg +0 -0
@@ -37,7 +37,7 @@ jobs:
37
37
  python-version: "3.13"
38
38
  os: ubuntu-latest
39
39
  steps:
40
- - uses: actions/checkout@v4
40
+ - uses: actions/checkout@v5
41
41
  with:
42
42
  fetch-depth: 0 # Fetch all history for all branches and tags.
43
43
  - name: Set environment variables
@@ -70,7 +70,7 @@ jobs:
70
70
  run: |
71
71
  pytest -n auto --cov=./ --cov-report=xml
72
72
  - name: Upload code coverage to Codecov
73
- uses: codecov/codecov-action@v5.4.3
73
+ uses: codecov/codecov-action@v5.5.0
74
74
  with:
75
75
  file: ./coverage.xml
76
76
  flags: unittests
@@ -88,7 +88,7 @@ jobs:
88
88
  matrix:
89
89
  python-version: ["3.11", "3.13"]
90
90
  steps:
91
- - uses: actions/checkout@v4
91
+ - uses: actions/checkout@v5
92
92
  with:
93
93
  fetch-depth: 0 # Fetch all history for all branches and tags.
94
94
  - name: Set up conda environment
@@ -109,7 +109,7 @@ jobs:
109
109
  run: |
110
110
  python -m mypy --install-types --non-interactive --cobertura-xml-report mypy_report cf_xarray/
111
111
  - name: Upload mypy coverage to Codecov
112
- uses: codecov/codecov-action@v5.4.3
112
+ uses: codecov/codecov-action@v5.5.0
113
113
  with:
114
114
  file: mypy_report/cobertura.xml
115
115
  flags: mypy
@@ -12,7 +12,7 @@ jobs:
12
12
  runs-on: ubuntu-latest
13
13
  if: github.repository == 'xarray-contrib/cf-xarray'
14
14
  steps:
15
- - uses: actions/checkout@v4
15
+ - uses: actions/checkout@v5
16
16
  with:
17
17
  fetch-depth: 0
18
18
  - uses: actions/setup-python@v5
@@ -54,7 +54,7 @@ jobs:
54
54
  name: Install Python
55
55
  with:
56
56
  python-version: "3.10"
57
- - uses: actions/download-artifact@v4
57
+ - uses: actions/download-artifact@v5
58
58
  with:
59
59
  name: releases
60
60
  path: dist
@@ -72,7 +72,7 @@ jobs:
72
72
 
73
73
  - name: Publish package to TestPyPI
74
74
  if: github.event_name == 'push'
75
- uses: pypa/gh-action-pypi-publish@v1.12.4
75
+ uses: pypa/gh-action-pypi-publish@v1.13.0
76
76
  with:
77
77
  password: ${{ secrets.TESTPYPI_TOKEN }}
78
78
  repository_url: https://test.pypi.org/legacy/
@@ -91,11 +91,11 @@ jobs:
91
91
  id-token: write
92
92
 
93
93
  steps:
94
- - uses: actions/download-artifact@v4
94
+ - uses: actions/download-artifact@v5
95
95
  with:
96
96
  name: releases
97
97
  path: dist
98
98
  - name: Publish package to PyPI
99
- uses: pypa/gh-action-pypi-publish@v1.12.4
99
+ uses: pypa/gh-action-pypi-publish@v1.13.0
100
100
  with:
101
101
  verbose: true
@@ -17,7 +17,7 @@ jobs:
17
17
  if: ${{ contains( github.event.pull_request.labels.*.name, 'test-build') && github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }}
18
18
  runs-on: ubuntu-latest
19
19
  steps:
20
- - uses: actions/checkout@v4
20
+ - uses: actions/checkout@v5
21
21
  with:
22
22
  fetch-depth: 0
23
23
 
@@ -66,7 +66,7 @@ jobs:
66
66
  name: Install Python
67
67
  with:
68
68
  python-version: "3.10"
69
- - uses: actions/download-artifact@v4
69
+ - uses: actions/download-artifact@v5
70
70
  with:
71
71
  name: releases
72
72
  path: dist
@@ -31,7 +31,7 @@ jobs:
31
31
  matrix:
32
32
  python-version: ["3.13"]
33
33
  steps:
34
- - uses: actions/checkout@v4
34
+ - uses: actions/checkout@v5
35
35
  with:
36
36
  fetch-depth: 0 # Fetch all history for all branches and tags.
37
37
  - name: Set environment variables
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cf_xarray
3
- Version: 0.10.7
3
+ Version: 0.10.8
4
4
  Summary: A convenience wrapper for using CF attributes on xarray objects
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -3,7 +3,7 @@ from packaging.version import Version
3
3
 
4
4
  from . import geometry as geometry
5
5
  from . import sgrid # noqa
6
- from .accessor import CFAccessor # noqa
6
+ from .accessor import CFAccessor, GridMapping # noqa
7
7
  from .coding import ( # noqa
8
8
  decode_compress_to_multi_index,
9
9
  encode_multi_index_as_compress,
@@ -0,0 +1 @@
1
+ __version__ = "0.10.8"
@@ -4,7 +4,7 @@ import functools
4
4
  import inspect
5
5
  import itertools
6
6
  import re
7
- from collections import ChainMap, namedtuple
7
+ from collections import ChainMap, defaultdict, namedtuple
8
8
  from collections.abc import (
9
9
  Callable,
10
10
  Hashable,
@@ -13,6 +13,7 @@ from collections.abc import (
13
13
  MutableMapping,
14
14
  Sequence,
15
15
  )
16
+ from dataclasses import dataclass
16
17
  from datetime import datetime
17
18
  from typing import (
18
19
  Any,
@@ -82,6 +83,61 @@ from .utils import (
82
83
 
83
84
  FlagParam = namedtuple("FlagParam", ["flag_mask", "flag_value"])
84
85
 
86
+
87
+ @dataclass(frozen=True, kw_only=True)
88
+ class GridMapping:
89
+ """
90
+ Represents a CF grid mapping with its properties and associated coordinate variables.
91
+
92
+ Attributes
93
+ ----------
94
+ name : str
95
+ The CF grid mapping name (e.g., ``'latitude_longitude'``, ``'transverse_mercator'``)
96
+ crs : pyproj.CRS
97
+ The coordinate reference system object
98
+ array : xarray.DataArray
99
+ The grid mapping variable as a DataArray containing the CRS parameters
100
+ coordinates : tuple[Hashable, ...]
101
+ Names of coordinate variables associated with this grid mapping. For grid mappings
102
+ that are explicitly listed with coordinates in the grid_mapping attribute
103
+ (e.g., ``'spatial_ref: crs_4326: latitude longitude'``), this contains those coordinates.
104
+ For grid mappings (e.g. ``spatial_ref``) that don't explicitly specify coordinates,
105
+ this falls back to the dimension names of the data variable that references
106
+ this grid mapping.
107
+ """
108
+
109
+ name: str
110
+ crs: Any # really pyproj.CRS
111
+ array: xr.DataArray
112
+ coordinates: tuple[Hashable, ...]
113
+
114
+ def __repr__(self) -> str:
115
+ # Try to get EPSG code first, fallback to shorter description
116
+ try:
117
+ if hasattr(self.crs, "to_epsg") and self.crs.to_epsg():
118
+ crs_repr = f"<CRS: EPSG:{self.crs.to_epsg()}>"
119
+ else:
120
+ # Use the name if available, otherwise authority:code
121
+ crs_name = getattr(self.crs, "name", str(self.crs)[:50] + "...")
122
+ crs_repr = f"<CRS: {crs_name}>"
123
+ except Exception:
124
+ # Fallback to generic representation
125
+ crs_repr = "<CRS>"
126
+
127
+ # Short array representation - name and shape
128
+ array_repr = f"<DataArray '{self.array.name}' {self.array.shape}>"
129
+
130
+ # Format coordinates nicely
131
+ coords_repr = f"({', '.join(repr(c) for c in self.coordinates)})"
132
+
133
+ return (
134
+ f"GridMapping(name={self.name!r}, "
135
+ f"crs={crs_repr}, "
136
+ f"array={array_repr}, "
137
+ f"coordinates={coords_repr})"
138
+ )
139
+
140
+
85
141
  #: Classes wrapped by cf_xarray.
86
142
  _WRAPPED_CLASSES = (Resample, GroupBy, Rolling, Coarsen, Weighted)
87
143
 
@@ -343,7 +399,7 @@ def _get_axis_coord(obj: DataArray | Dataset, key: str) -> list[str]:
343
399
  results.update((coord,))
344
400
  if criterion == "units":
345
401
  # deal with pint-backed objects
346
- units = getattr(var.data, "units", None)
402
+ units = getattr(var.variable._data, "units", None)
347
403
  if units in expected:
348
404
  results.update((coord,))
349
405
 
@@ -440,6 +496,124 @@ def _get_bounds(obj: DataArray | Dataset, key: Hashable) -> list[Hashable]:
440
496
  return list(results)
441
497
 
442
498
 
499
+ def _parse_grid_mapping_attribute(grid_mapping_attr: str) -> dict[str, list[Hashable]]:
500
+ """
501
+ Parse a grid_mapping attribute that may contain multiple grid mappings.
502
+
503
+ The attribute has the format: "grid_mapping_variable_name: optional_coordinate_names_space_separated"
504
+ Multiple sections are separated by colons.
505
+
506
+ Examples:
507
+ - Single: "spatial_ref" -> {"spatial_ref": []}
508
+ - Multiple: "spatial_ref: crs_4326: latitude longitude crs_27700: x27700 y27700"
509
+ -> {"spatial_ref": [], "crs_4326": ["latitude", "longitude"], "crs_27700": ["x27700", "y27700"]}
510
+
511
+ Returns a dictionary mapping grid mapping variable names to their associated coordinate variables.
512
+ """
513
+ # Check if there are colons indicating multiple mappings
514
+ if ":" not in grid_mapping_attr:
515
+ return {grid_mapping_attr.strip(): []}
516
+
517
+ # Use regex to parse the format
518
+ # First, find all grid mapping variables (words before colons)
519
+ grid_pattern = r"(?:^|\s)([a-zA-Z_][a-zA-Z0-9_]*)(?=\s*:)"
520
+ grid_mappings = re.findall(grid_pattern, grid_mapping_attr)
521
+
522
+ if not grid_mappings:
523
+ return {grid_mapping_attr.strip(): []}
524
+
525
+ result: dict[str, list[Hashable]] = {}
526
+
527
+ # Now extract coordinates for each grid mapping
528
+ # Split the string to find what comes after each grid mapping variable
529
+ for i, gm in enumerate(grid_mappings):
530
+ # Pattern to capture everything after this grid mapping until the next one or end
531
+ if i < len(grid_mappings) - 1:
532
+ next_gm = grid_mappings[i + 1]
533
+ # Capture everything between current grid mapping and next one
534
+ coord_pattern = (
535
+ rf"{re.escape(gm)}\s*:\s*([^:]*?)(?=\s+{re.escape(next_gm)}\s*:)"
536
+ )
537
+ else:
538
+ # Last grid mapping - capture everything after it
539
+ coord_pattern = rf"{re.escape(gm)}\s*:\s*(.*)$"
540
+
541
+ coord_match = re.search(coord_pattern, grid_mapping_attr)
542
+ if coord_match:
543
+ coord_text = coord_match.group(1).strip()
544
+ # Split coordinates and filter out any grid mapping names that might have been captured
545
+ coords = coord_text.split() if coord_text else []
546
+ # Filter out the next grid mapping variable if it got captured
547
+ coords = [c for c in coords if c not in grid_mappings]
548
+ result[gm] = coords # type: ignore[assignment]
549
+ else:
550
+ result[gm] = []
551
+
552
+ return result
553
+
554
+
555
+ def _create_grid_mapping(
556
+ var_name: str,
557
+ ds: Dataset,
558
+ grid_mapping_dict: dict[str, list[Hashable]],
559
+ ) -> GridMapping:
560
+ """
561
+ Create a GridMapping dataclass instance from a grid mapping variable.
562
+
563
+ Parameters
564
+ ----------
565
+ var_name : str
566
+ Name of the grid mapping variable
567
+ obj_dataset : Dataset
568
+ Dataset containing the grid mapping variable
569
+ grid_mapping_dict : dict[str, list[Hashable]]
570
+ Dictionary mapping grid mapping variable names to their coordinate variables
571
+
572
+ Returns
573
+ -------
574
+ GridMapping
575
+ GridMapping dataclass instance
576
+
577
+ Notes
578
+ -----
579
+ Assumes pyproj is available.
580
+ """
581
+ import pyproj
582
+
583
+ var = ds._variables[var_name]
584
+
585
+ # Create DataArray from Variable, preserving the name
586
+ da = xr.DataArray(ds._variables[var_name], name=var_name)
587
+
588
+ # Get the CF grid mapping name from the variable's attributes
589
+ cf_name = var.attrs.get("grid_mapping_name", var_name)
590
+
591
+ # Create CRS from the grid mapping variable
592
+ crs = pyproj.CRS.from_cf(var.attrs)
593
+
594
+ # Get associated coordinate variables, fallback to dimension names
595
+ coordinates: list[Hashable] = grid_mapping_dict.get(var_name, [])
596
+ # """
597
+ # In order to make use of a grid mapping to directly calculate latitude and longitude values
598
+ # it is necessary to associate the coordinate variables with the independent variables of the mapping.
599
+ # This is done by assigning a standard_name to the coordinate variable.
600
+ # The appropriate values of the standard_name depend on the grid mapping and are given in Appendix F, Grid Mappings.
601
+ # """
602
+ if not coordinates and len(grid_mapping_dict) == 1:
603
+ if crs.to_cf().get("grid_mapping_name") == "rotated_latitude_longitude":
604
+ xname, yname = "grid_longitude", "grid_latitude"
605
+ elif crs.is_geographic:
606
+ xname, yname = "longitude", "latitude"
607
+ elif crs.is_projected:
608
+ xname, yname = "projection_x_coordinate", "projection_y_coordinate"
609
+
610
+ x = apply_mapper(_get_with_standard_name, ds, xname, error=False, default=[[]])
611
+ y = apply_mapper(_get_with_standard_name, ds, yname, error=False, default=[[]])
612
+ coordinates = list(itertools.chain(x, y))
613
+
614
+ return GridMapping(name=cf_name, crs=crs, array=da, coordinates=tuple(coordinates))
615
+
616
+
443
617
  def _get_grid_mapping_name(obj: DataArray | Dataset, key: str) -> list[str]:
444
618
  """
445
619
  Translate from grid mapping name attribute to appropriate variable name.
@@ -466,14 +640,16 @@ def _get_grid_mapping_name(obj: DataArray | Dataset, key: str) -> list[str]:
466
640
  results = set()
467
641
  for var in variables.values():
468
642
  attrs_or_encoding = ChainMap(var.attrs, var.encoding)
469
- if "grid_mapping" in attrs_or_encoding:
470
- grid_mapping_var_name = attrs_or_encoding["grid_mapping"]
471
- if grid_mapping_var_name not in variables:
472
- raise ValueError(
473
- f"{var} defines non-existing grid_mapping variable {grid_mapping_var_name}."
474
- )
475
- if key == variables[grid_mapping_var_name].attrs["grid_mapping_name"]:
476
- results.update([grid_mapping_var_name])
643
+ if grid_mapping_attr := attrs_or_encoding.get("grid_mapping"):
644
+ # Parse potentially multiple grid mappings
645
+ grid_mapping_dict = _parse_grid_mapping_attribute(grid_mapping_attr)
646
+ for grid_mapping_var_name in grid_mapping_dict.keys():
647
+ if grid_mapping_var_name not in variables:
648
+ raise ValueError(
649
+ f"{var} defines non-existing grid_mapping variable {grid_mapping_var_name}."
650
+ )
651
+ if key == variables[grid_mapping_var_name].attrs["grid_mapping_name"]:
652
+ results.update([grid_mapping_var_name])
477
653
  return list(results)
478
654
 
479
655
 
@@ -1943,9 +2119,20 @@ class CFAccessor:
1943
2119
  if dbounds := self._obj[dim].attrs.get("bounds", None):
1944
2120
  coords["bounds"].append(dbounds)
1945
2121
 
1946
- for attrname in ["grid", "grid_mapping"]:
1947
- if maybe := attrs_or_encoding.get(attrname, None):
1948
- coords[attrname] = [maybe]
2122
+ if grid := attrs_or_encoding.get("grid", None):
2123
+ coords["grid"] = [grid]
2124
+
2125
+ if grid_mapping_attr := attrs_or_encoding.get("grid_mapping", None):
2126
+ # Parse grid mapping variables and their coordinates
2127
+ grid_mapping_dict = _parse_grid_mapping_attribute(grid_mapping_attr)
2128
+ coords["grid_mapping"] = cast(
2129
+ list[Hashable], list(grid_mapping_dict.keys())
2130
+ )
2131
+
2132
+ # Add coordinate variables from the grid mapping
2133
+ for coord_vars in grid_mapping_dict.values():
2134
+ if coord_vars:
2135
+ coords["coordinates"].extend(coord_vars)
1949
2136
 
1950
2137
  more: Sequence[Hashable] = ()
1951
2138
  if geometry_var := attrs_or_encoding.get("geometry", None):
@@ -2337,6 +2524,95 @@ class CFAccessor:
2337
2524
 
2338
2525
  return obj
2339
2526
 
2527
+ @property
2528
+ def grid_mappings(self) -> tuple[GridMapping, ...]:
2529
+ """
2530
+ Return a tuple of GridMapping objects for all grid mappings in this object.
2531
+
2532
+ For DataArrays, the order in the tuple matches the order that grid mappings appear
2533
+ in the grid_mapping attribute string.
2534
+
2535
+ Parameters
2536
+ ----------
2537
+ None
2538
+
2539
+ Returns
2540
+ -------
2541
+ tuple[GridMapping, ...]
2542
+ Tuple of GridMapping dataclass instances, each containing:
2543
+ - name: CF grid mapping name
2544
+ - crs: pyproj.CRS object
2545
+ - array: xarray.DataArray containing the grid mapping variable
2546
+ - coordinates: tuple of coordinate variable names
2547
+
2548
+ Raises
2549
+ ------
2550
+ ImportError
2551
+ If pyproj is not available. This property requires pyproj for CRS creation.
2552
+
2553
+ Examples
2554
+ --------
2555
+ >>> ds.cf.grid_mappings
2556
+ (GridMapping(name='latitude_longitude', crs=<CRS: EPSG:4326>, ...),)
2557
+
2558
+ Notes
2559
+ -----
2560
+ This property requires pyproj to be installed for creating CRS objects from
2561
+ CF grid mapping parameters. Install with: ``conda install pyproj`` or
2562
+ ``pip install pyproj``.
2563
+ """
2564
+ # Check pyproj availability upfront
2565
+ try:
2566
+ import pyproj # noqa: F401
2567
+ except ImportError:
2568
+ raise ImportError(
2569
+ "pyproj is required for .cf.grid_mappings property. "
2570
+ "Install with: conda install pyproj or pip install pyproj"
2571
+ ) from None
2572
+ # For DataArrays, preserve order from grid_mapping attribute
2573
+ if isinstance(self._obj, DataArray) and "grid_mapping" in self._obj.attrs:
2574
+ grid_mapping_dict = _parse_grid_mapping_attribute(
2575
+ self._obj.attrs["grid_mapping"]
2576
+ )
2577
+ # Get grid mappings in the order they appear in the string
2578
+ ordered_var_names = list(grid_mapping_dict.keys())
2579
+ else:
2580
+ # For Datasets, look for grid_mapping attributes in data variables
2581
+ grid_mapping_dict = {}
2582
+ ordered_var_names = []
2583
+
2584
+ # Search all data variables for grid_mapping attributes
2585
+ for _var_name, var in self._obj.data_vars.items():
2586
+ if "grid_mapping" in var.attrs:
2587
+ parsed = _parse_grid_mapping_attribute(var.attrs["grid_mapping"])
2588
+ grid_mapping_dict.update(parsed)
2589
+ # Add variables in order they appear in this grid_mapping string
2590
+ for gm_var in parsed.keys():
2591
+ if gm_var not in ordered_var_names:
2592
+ ordered_var_names.append(gm_var)
2593
+
2594
+ # If no grid_mapping attributes found in data vars, try grid_mapping_names property
2595
+ if not ordered_var_names and hasattr(self, "grid_mapping_names"):
2596
+ grid_mapping_names = self.grid_mapping_names
2597
+ for var_names in grid_mapping_names.values():
2598
+ ordered_var_names.extend(var_names)
2599
+
2600
+ if not ordered_var_names:
2601
+ return ()
2602
+
2603
+ grid_mappings = []
2604
+ obj_dataset = self._maybe_to_dataset()
2605
+
2606
+ for var_name in ordered_var_names:
2607
+ if var_name not in obj_dataset._variables:
2608
+ continue
2609
+
2610
+ grid_mappings.append(
2611
+ _create_grid_mapping(var_name, obj_dataset, grid_mapping_dict)
2612
+ )
2613
+
2614
+ return tuple(grid_mappings)
2615
+
2340
2616
 
2341
2617
  @xr.register_dataset_accessor("cf")
2342
2618
  class CFDatasetAccessor(CFAccessor):
@@ -2899,6 +3175,49 @@ class CFDataArrayAccessor(CFAccessor):
2899
3175
  terms[key] = value
2900
3176
  return terms
2901
3177
 
3178
+ @property
3179
+ def grid_mapping_names(self) -> dict[str, list[str]]:
3180
+ """
3181
+ Mapping the CF grid mapping name to the grid mapping variable name.
3182
+
3183
+ Returns
3184
+ -------
3185
+ dict
3186
+ Dictionary mapping the CF grid mapping name to the variable name containing
3187
+ the grid mapping attributes.
3188
+
3189
+ See Also
3190
+ --------
3191
+ DataArray.cf.grid_mapping_name
3192
+ Dataset.cf.grid_mapping_names
3193
+
3194
+ References
3195
+ ----------
3196
+ https://cfconventions.org/Data/cf-conventions/cf-conventions-1.10/cf-conventions.html#appendix-grid-mappings
3197
+
3198
+ Examples
3199
+ --------
3200
+ >>> from cf_xarray.datasets import hrrrds
3201
+ >>> hrrrds.foo.cf.grid_mapping_names
3202
+ {'latitude_longitude': ['crs_4326'], 'lambert_azimuthal_equal_area': ['spatial_ref']}
3203
+ """
3204
+ da = self._obj
3205
+ attrs_or_encoding = ChainMap(da.attrs, da.encoding)
3206
+ grid_mapping_attr = attrs_or_encoding.get("grid_mapping", None)
3207
+
3208
+ if not grid_mapping_attr:
3209
+ return {}
3210
+
3211
+ # Parse potentially multiple grid mappings
3212
+ grid_mapping_dict = _parse_grid_mapping_attribute(grid_mapping_attr)
3213
+
3214
+ results = defaultdict(list)
3215
+ for grid_mapping_var_name in grid_mapping_dict.keys() & set(da.coords):
3216
+ grid_mapping_var = da.coords[grid_mapping_var_name]
3217
+ if gmn := grid_mapping_var.attrs.get("grid_mapping_name"):
3218
+ results[gmn].append(grid_mapping_var_name)
3219
+ return dict(results)
3220
+
2902
3221
  @property
2903
3222
  def grid_mapping_name(self) -> str:
2904
3223
  """
@@ -2911,6 +3230,7 @@ class CFDataArrayAccessor(CFAccessor):
2911
3230
 
2912
3231
  See Also
2913
3232
  --------
3233
+ DataArray.cf.grid_mapping_names
2914
3234
  Dataset.cf.grid_mapping_names
2915
3235
 
2916
3236
  Examples
@@ -2920,19 +3240,22 @@ class CFDataArrayAccessor(CFAccessor):
2920
3240
  'rotated_latitude_longitude'
2921
3241
  """
2922
3242
 
2923
- da = self._obj
3243
+ # Use grid_mapping_names under the hood
3244
+ grid_mapping_names = self.grid_mapping_names
2924
3245
 
2925
- attrs_or_encoding = ChainMap(da.attrs, da.encoding)
2926
- grid_mapping = attrs_or_encoding.get("grid_mapping", None)
2927
- if not grid_mapping:
3246
+ if not grid_mapping_names:
2928
3247
  raise ValueError("No 'grid_mapping' attribute present.")
2929
3248
 
2930
- if grid_mapping not in da._coords:
2931
- raise ValueError(f"Grid Mapping variable {grid_mapping} not present.")
2932
-
2933
- grid_mapping_var = da[grid_mapping]
3249
+ if len(grid_mapping_names) > 1:
3250
+ # Get the variable names for error message
3251
+ all_vars = list(itertools.chain.from_iterable(grid_mapping_names.values()))
3252
+ raise ValueError(
3253
+ f"Multiple grid mappings found: {all_vars}. "
3254
+ "Please use DataArray.cf.grid_mapping_names instead."
3255
+ )
2934
3256
 
2935
- return grid_mapping_var.attrs["grid_mapping_name"]
3257
+ # Return the single grid mapping name
3258
+ return next(iter(grid_mapping_names.keys()))
2936
3259
 
2937
3260
  def __getitem__(self, key: Hashable | Iterable[Hashable]) -> DataArray:
2938
3261
  """
@@ -750,6 +750,44 @@ sgrid_delft3["grid"] = xr.DataArray(
750
750
  )
751
751
 
752
752
 
753
+ try:
754
+ from pyproj import CRS
755
+
756
+ hrrrds = xr.Dataset()
757
+ hrrrds["foo"] = (
758
+ ("x", "y"),
759
+ np.arange(200).reshape((10, 20)),
760
+ {
761
+ "grid_mapping": "spatial_ref: x y crs_4326: latitude longitude crs_27700: x27700 y27700"
762
+ },
763
+ )
764
+ hrrrds.coords["spatial_ref"] = ((), 0, CRS.from_epsg(3035).to_cf())
765
+ hrrrds.coords["crs_4326"] = ((), 0, CRS.from_epsg(4326).to_cf())
766
+ hrrrds.coords["crs_27700"] = ((), 0, CRS.from_epsg(27700).to_cf())
767
+ hrrrds.coords["latitude"] = (
768
+ ("x", "y"),
769
+ np.ones((10, 20)),
770
+ {"standard_name": "latitude"},
771
+ )
772
+ hrrrds.coords["longitude"] = (
773
+ ("x", "y"),
774
+ np.zeros((10, 20)),
775
+ {"standard_name": "longitude"},
776
+ )
777
+ hrrrds.coords["y27700"] = (
778
+ ("x", "y"),
779
+ np.ones((10, 20)),
780
+ {"standard_name": "projected_x_coordinate"},
781
+ )
782
+ hrrrds.coords["x27700"] = (
783
+ ("x", "y"),
784
+ np.zeros((10, 20)),
785
+ {"standard_name": "projected_y_coordinate"},
786
+ )
787
+ except ImportError:
788
+ pass
789
+
790
+
753
791
  def point_dataset():
754
792
  from shapely.geometry import MultiPoint, Point
755
793
 
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import datetime
3
4
  from collections.abc import Hashable, Sequence
4
5
 
5
6
  import numpy as np
@@ -230,6 +231,10 @@ def _get_core_dim_orders(core_dim_coords: dict[str, np.ndarray]) -> dict[str, st
230
231
  # Cast to float64 for safe comparison
231
232
  diffs_float = diffs.astype("float64")
232
233
  nonzero_diffs = diffs_float[diffs_float != 0]
234
+ elif isinstance(diffs[0], datetime.timedelta):
235
+ # For datetime timedelta, we use the total_seconds method
236
+ diffs_float = np.array([x.total_seconds() for x in diffs])
237
+ nonzero_diffs = diffs_float[diffs_float != 0]
233
238
  else:
234
239
  zero = 0
235
240
  nonzero_diffs = diffs[diffs != zero]
@@ -360,9 +365,17 @@ def _is_bounds_monotonic(bounds: np.ndarray) -> bool:
360
365
  # Cannot cast ufunc 'greater' input 0 from dtype('<m8[ns]') to dtype('<m8')
361
366
  # with casting rule 'same_kind' To avoid this, always cast to float64 before
362
367
  # np.diff.
363
- arr_numeric = bounds.astype("float64").flatten()
364
- diffs = np.diff(arr_numeric)
365
- nonzero_diffs = diffs[diffs != 0]
368
+
369
+ diffs = np.diff(bounds.flatten())
370
+
371
+ if isinstance(diffs[0], datetime.timedelta):
372
+ # For datetime timedelta, we use the total_seconds method
373
+ diffs_float = np.array([x.total_seconds() for x in diffs])
374
+
375
+ else:
376
+ diffs_float = diffs.astype("float64")
377
+
378
+ nonzero_diffs = diffs_float[diffs_float != 0]
366
379
 
367
380
  # All values are equal, treat as monotonic
368
381
  if nonzero_diffs.size == 0:
@@ -69,3 +69,4 @@ has_pint, requires_pint = _importorskip("pint")
69
69
  has_pooch, requires_pooch = _importorskip("pooch")
70
70
  _, requires_rich = _importorskip("rich")
71
71
  has_regex, requires_regex = _importorskip("regex")
72
+ has_pyproj, requires_pyproj = _importorskip("pyproj")