cf-xarray 0.11.0__tar.gz → 0.11.2__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 (92) hide show
  1. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/PKG-INFO +1 -1
  2. cf_xarray-0.11.2/cf_xarray/_version.py +1 -0
  3. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/accessor.py +183 -82
  4. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/sgrid.py +25 -0
  5. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/tests/test_accessor.py +75 -0
  6. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/utils.py +8 -5
  7. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray.egg-info/PKG-INFO +1 -1
  8. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/sgrid_ugrid.md +17 -0
  9. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/uv.lock +3 -3
  10. cf_xarray-0.11.0/cf_xarray/_version.py +0 -1
  11. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/.binder/environment.yml +0 -0
  12. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/.deepsource.toml +0 -0
  13. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/.github/dependabot.yml +0 -0
  14. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/.github/release.yml +0 -0
  15. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/.github/workflows/ci.yaml +0 -0
  16. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/.github/workflows/parse_logs.py +0 -0
  17. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/.github/workflows/pypi.yaml +0 -0
  18. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/.github/workflows/testpypi-release.yaml +0 -0
  19. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/.github/workflows/upstream-dev-ci.yaml +0 -0
  20. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/.gitignore +0 -0
  21. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/.pre-commit-config.yaml +0 -0
  22. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/.readthedocs.yml +0 -0
  23. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/.tributors +0 -0
  24. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/CITATION.cff +0 -0
  25. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/LICENSE +0 -0
  26. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/README.rst +0 -0
  27. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/__init__.py +0 -0
  28. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/coding.py +0 -0
  29. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/criteria.py +0 -0
  30. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/datasets.py +0 -0
  31. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/formatting.py +0 -0
  32. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/geometry.py +0 -0
  33. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/groupers.py +0 -0
  34. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/helpers.py +0 -0
  35. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/options.py +0 -0
  36. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/parametric.py +0 -0
  37. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/py.typed +0 -0
  38. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/scripts/make_doc.py +0 -0
  39. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/scripts/print_versions.py +0 -0
  40. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/tests/__init__.py +0 -0
  41. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/tests/conftest.py +0 -0
  42. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/tests/test_coding.py +0 -0
  43. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/tests/test_geometry.py +0 -0
  44. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/tests/test_groupers.py +0 -0
  45. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/tests/test_helpers.py +0 -0
  46. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/tests/test_options.py +0 -0
  47. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/tests/test_parametric.py +0 -0
  48. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/tests/test_scripts.py +0 -0
  49. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/tests/test_units.py +0 -0
  50. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray/units.py +0 -0
  51. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray.egg-info/SOURCES.txt +0 -0
  52. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray.egg-info/dependency_links.txt +0 -0
  53. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray.egg-info/requires.txt +0 -0
  54. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/cf_xarray.egg-info/top_level.txt +0 -0
  55. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/codecov.yml +0 -0
  56. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/2D_bounds_averaged.png +0 -0
  57. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/2D_bounds_error.png +0 -0
  58. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/2D_bounds_nonunique.png +0 -0
  59. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/Makefile +0 -0
  60. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/_static/dataset-diagram-logo.tex +0 -0
  61. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/_static/full-logo.png +0 -0
  62. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/_static/logo.png +0 -0
  63. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/_static/logo.svg +0 -0
  64. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/_static/rich-repr-example.png +0 -0
  65. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/_static/style.css +0 -0
  66. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/api.rst +0 -0
  67. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/bounds.md +0 -0
  68. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/cartopy_rotated_pole.png +0 -0
  69. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/coding.md +0 -0
  70. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/conf.py +0 -0
  71. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/contributing.rst +0 -0
  72. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/coord_axes.md +0 -0
  73. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/custom-criteria.md +0 -0
  74. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/dsg.md +0 -0
  75. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/examples/introduction.ipynb +0 -0
  76. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/faq.md +0 -0
  77. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/flags.md +0 -0
  78. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/geometry.md +0 -0
  79. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/grid_mappings.md +0 -0
  80. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/howtouse.md +0 -0
  81. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/index.rst +0 -0
  82. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/make.bat +0 -0
  83. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/parametricz.md +0 -0
  84. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/plotting.md +0 -0
  85. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/provenance.md +0 -0
  86. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/quickstart.md +0 -0
  87. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/roadmap.rst +0 -0
  88. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/selecting.md +0 -0
  89. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/units.md +0 -0
  90. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/doc/whats-new.rst +0 -0
  91. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/pyproject.toml +0 -0
  92. {cf_xarray-0.11.0 → cf_xarray-0.11.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cf_xarray
3
- Version: 0.11.0
3
+ Version: 0.11.2
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
@@ -0,0 +1 @@
1
+ __version__ = "0.11.2"
@@ -27,6 +27,7 @@ import xarray as xr
27
27
  from xarray import DataArray, Dataset
28
28
  from xarray.core.groupby import GroupBy
29
29
  from xarray.core.resample import Resample
30
+ from xarray.core.utils import Frozen
30
31
 
31
32
  try:
32
33
  from xarray.core.rolling import ( # type:ignore[import-not-found,no-redef,unused-ignore]
@@ -495,7 +496,10 @@ def _get_bounds(obj: DataArray | Dataset, key: Hashable) -> list[Hashable]:
495
496
  return list(results)
496
497
 
497
498
 
498
- def _parse_grid_mapping_attribute(grid_mapping_attr: str) -> dict[str, list[Hashable]]:
499
+ @functools.lru_cache(maxsize=256)
500
+ def _parse_grid_mapping_attribute(
501
+ grid_mapping_attr: str,
502
+ ) -> Mapping[str, list[Hashable]]:
499
503
  """
500
504
  Parse a grid_mapping attribute that may contain multiple grid mappings.
501
505
 
@@ -507,11 +511,12 @@ def _parse_grid_mapping_attribute(grid_mapping_attr: str) -> dict[str, list[Hash
507
511
  - Multiple: "spatial_ref: crs_4326: latitude longitude crs_27700: x27700 y27700"
508
512
  -> {"spatial_ref": [], "crs_4326": ["latitude", "longitude"], "crs_27700": ["x27700", "y27700"]}
509
513
 
510
- Returns a dictionary mapping grid mapping variable names to their associated coordinate variables.
514
+ Returns a read-only mapping from grid mapping variable name to its associated
515
+ coordinate variables. The result is memoized, so callers must not mutate it.
511
516
  """
512
517
  # Check if there are colons indicating multiple mappings
513
518
  if ":" not in grid_mapping_attr:
514
- return {grid_mapping_attr.strip(): []}
519
+ return Frozen({grid_mapping_attr.strip(): []})
515
520
 
516
521
  # Use regex to parse the format
517
522
  # First, find all grid mapping variables (words before colons)
@@ -519,7 +524,7 @@ def _parse_grid_mapping_attribute(grid_mapping_attr: str) -> dict[str, list[Hash
519
524
  grid_mappings = re.findall(grid_pattern, grid_mapping_attr)
520
525
 
521
526
  if not grid_mappings:
522
- return {grid_mapping_attr.strip(): []}
527
+ return Frozen({grid_mapping_attr.strip(): []})
523
528
 
524
529
  result: dict[str, list[Hashable]] = {}
525
530
 
@@ -548,13 +553,44 @@ def _parse_grid_mapping_attribute(grid_mapping_attr: str) -> dict[str, list[Hash
548
553
  else:
549
554
  result[gm] = []
550
555
 
551
- return result
556
+ return Frozen(result)
557
+
558
+
559
+ def _hashable_attrs(attrs: Mapping[Any, Any]) -> tuple:
560
+ """Return a hashable, order-independent representation of an attrs mapping.
561
+
562
+ List- and array-valued attributes (e.g. ``standard_parallel``) are coerced
563
+ to tuples so the result can be used as an ``lru_cache`` key.
564
+ """
565
+ frozen = []
566
+ for key, value in attrs.items():
567
+ if hasattr(value, "tolist"): # numpy scalars/arrays
568
+ value = value.tolist()
569
+ if isinstance(value, list | tuple):
570
+ value = tuple(value)
571
+ frozen.append((key, value))
572
+ frozen.sort(key=lambda kv: repr(kv[0]))
573
+ return tuple(frozen)
574
+
575
+
576
+ @functools.lru_cache(maxsize=256)
577
+ def _crs_from_cf_attrs(attrs_items: tuple) -> Any:
578
+ """Build a ``pyproj.CRS`` from frozen CF grid-mapping attrs (memoized).
579
+
580
+ ``pyproj.CRS.from_cf`` re-parses the datum/ellipsoid on every call, which is
581
+ expensive for grid mappings carrying explicit ellipsoid parameters (e.g.
582
+ geostationary). A dataset routinely references the same grid mapping from
583
+ many variables, so cache on the attribute items.
584
+ """
585
+ import pyproj
586
+
587
+ return pyproj.CRS.from_cf(dict(attrs_items))
552
588
 
553
589
 
554
590
  def _create_grid_mapping(
555
591
  var_name: str,
556
592
  ds: Dataset,
557
- grid_mapping_dict: dict[str, list[Hashable]],
593
+ grid_mapping_dict: Mapping[str, list[Hashable]],
558
594
  ) -> GridMapping:
559
595
  """
560
596
  Create a GridMapping dataclass instance from a grid mapping variable.
@@ -664,7 +700,7 @@ def _create_grid_mapping(
664
700
  }
665
701
  )
666
702
  else:
667
- crs = pyproj.CRS.from_cf(var.attrs)
703
+ crs = _crs_from_cf_attrs(_hashable_attrs(var.attrs))
668
704
 
669
705
  # Get associated coordinate variables, fallback to dimension names
670
706
  coordinates: list[Hashable] = grid_mapping_dict.get(var_name, [])
@@ -1007,7 +1043,11 @@ def _getattr(
1007
1043
  newmap.update(dict.fromkeys(inverted[key], value))
1008
1044
  newmap.update({key: attribute[key] for key in unused_keys})
1009
1045
 
1010
- skip: dict[str, list[Literal["coords", "measures"]] | None] = {
1046
+ skip: dict[
1047
+ str,
1048
+ list[Literal["coords", "measures", "grid_mapping_names", "geometries"]]
1049
+ | None,
1050
+ ] = {
1011
1051
  "data_vars": ["coords"],
1012
1052
  "coords": None,
1013
1053
  }
@@ -1048,7 +1088,8 @@ def _getattr(
1048
1088
  def _getitem(
1049
1089
  accessor: CFAccessor,
1050
1090
  key: Hashable,
1051
- skip: list[Literal["coords", "measures"]] | None = None,
1091
+ skip: list[Literal["coords", "measures", "grid_mapping_names", "geometries"]]
1092
+ | None = None,
1052
1093
  ) -> DataArray: ...
1053
1094
 
1054
1095
 
@@ -1056,14 +1097,16 @@ def _getitem(
1056
1097
  def _getitem(
1057
1098
  accessor: CFAccessor,
1058
1099
  key: Iterable[Hashable],
1059
- skip: list[Literal["coords", "measures"]] | None = None,
1100
+ skip: list[Literal["coords", "measures", "grid_mapping_names", "geometries"]]
1101
+ | None = None,
1060
1102
  ) -> Dataset: ...
1061
1103
 
1062
1104
 
1063
1105
  def _getitem(
1064
1106
  accessor: CFAccessor,
1065
1107
  key: Hashable | Iterable[Hashable],
1066
- skip: list[Literal["coords", "measures"]] | None = None,
1108
+ skip: list[Literal["coords", "measures", "grid_mapping_names", "geometries"]]
1109
+ | None = None,
1067
1110
  ):
1068
1111
  """
1069
1112
  Index into obj using key. Attaches CF associated variables.
@@ -1077,9 +1120,14 @@ def _getitem(
1077
1120
  """
1078
1121
 
1079
1122
  obj = accessor._obj
1080
- all_bounds = obj.cf.bounds if isinstance(obj, Dataset) else {}
1081
1123
  kind = str(type(obj).__name__)
1082
1124
  scalar_key = isinstance(key, Hashable)
1125
+ # obj.cf.bounds is expensive; only compute it when scalar lookup on a
1126
+ # Dataset actually needs to drop bounds variables.
1127
+ if not isinstance(obj, DataArray) and scalar_key:
1128
+ all_bounds = obj.cf.bounds
1129
+ else:
1130
+ all_bounds = {}
1083
1131
 
1084
1132
  key_iter: Iterable[Hashable]
1085
1133
  if isinstance(key, Hashable): # using scalar_key breaks mypy type narrowing
@@ -1127,60 +1175,90 @@ def _getitem(
1127
1175
 
1128
1176
  custom_criteria = ChainMap(*OPTIONS["custom_criteria"])
1129
1177
 
1130
- varnames: list[Hashable] = []
1131
- coords: list[Hashable] = []
1132
- successful = dict.fromkeys(key_iter, False)
1133
- for k in key_iter:
1134
- if "coords" not in skip and k in _AXIS_NAMES + _COORD_NAMES:
1135
- names = _get_all(obj, k)
1136
- names = drop_bounds(names)
1137
- check_results(names, k)
1138
- successful[k] = bool(names)
1139
- coords.extend(names)
1140
- elif "measures" not in skip and k in measures:
1141
- measure = _get_all(obj, k)
1142
- check_results(measure, k)
1143
- successful[k] = bool(measure)
1144
- if measure:
1145
- varnames.extend(measure)
1146
- elif "grid_mapping_names" not in skip and k in grid_mapping_names:
1147
- grid_mapping = _get_all(obj, k)
1148
- check_results(grid_mapping, k)
1149
- successful[k] = bool(grid_mapping)
1150
- if grid_mapping:
1151
- varnames.extend(grid_mapping)
1152
- elif "geometries" not in skip and (k == "geometry" or k in _GEOMETRY_TYPES):
1153
- geometries = _get_all(obj, k)
1154
- if geometries and k in _GEOMETRY_TYPES:
1155
- new = itertools.chain(
1156
- _parse_related_geometry_vars(
1157
- ChainMap(obj[g].attrs, obj[g].encoding)
1178
+ # Fast path: when every key is just a plain variable in the Dataset (no CF
1179
+ # special meaning), skip the per-key classification loop below. Test the
1180
+ # cheap predicates first and only build the reserved-name set / consult
1181
+ # accessor.standard_names (a full attrs scan) if those could pass — the
1182
+ # common ds.cf["X"] / ds.cf["longitude"] paths must not pay that cost.
1183
+ fast_path = (
1184
+ isinstance(obj, Dataset)
1185
+ and not skip
1186
+ and all(k in obj._variables for k in key_iter)
1187
+ )
1188
+ if fast_path:
1189
+ reserved: set[Hashable] = set(_AXIS_NAMES).union(
1190
+ _COORD_NAMES,
1191
+ _GEOMETRY_TYPES,
1192
+ ("geometry",),
1193
+ measures,
1194
+ grid_mapping_names,
1195
+ custom_criteria,
1196
+ cf_role_criteria,
1197
+ )
1198
+ standard_names = accessor.standard_names
1199
+ fast_path = all(k not in reserved and k not in standard_names for k in key_iter)
1200
+
1201
+ varnames: list[Hashable]
1202
+ coords: list[Hashable]
1203
+ if fast_path:
1204
+ varnames = list(key_iter)
1205
+ coords = []
1206
+ successful = dict.fromkeys(key_iter, True)
1207
+ else:
1208
+ varnames = []
1209
+ coords = []
1210
+ successful = dict.fromkeys(key_iter, False)
1211
+ for k in key_iter:
1212
+ if "coords" not in skip and k in _AXIS_NAMES + _COORD_NAMES:
1213
+ names = _get_all(obj, k)
1214
+ names = drop_bounds(names)
1215
+ check_results(names, k)
1216
+ successful[k] = bool(names)
1217
+ coords.extend(names)
1218
+ elif "measures" not in skip and k in measures:
1219
+ measure = _get_all(obj, k)
1220
+ check_results(measure, k)
1221
+ successful[k] = bool(measure)
1222
+ if measure:
1223
+ varnames.extend(measure)
1224
+ elif "grid_mapping_names" not in skip and k in grid_mapping_names:
1225
+ grid_mapping = _get_all(obj, k)
1226
+ check_results(grid_mapping, k)
1227
+ successful[k] = bool(grid_mapping)
1228
+ if grid_mapping:
1229
+ varnames.extend(grid_mapping)
1230
+ elif "geometries" not in skip and (k == "geometry" or k in _GEOMETRY_TYPES):
1231
+ geometries = _get_all(obj, k)
1232
+ if geometries and k in _GEOMETRY_TYPES:
1233
+ new = itertools.chain(
1234
+ _parse_related_geometry_vars(
1235
+ ChainMap(obj[g].attrs, obj[g].encoding)
1236
+ )
1237
+ for g in geometries
1158
1238
  )
1159
- for g in geometries
1160
- )
1161
- geometries.extend(*new)
1162
- if len(geometries) > 1 and scalar_key:
1163
- raise ValueError(
1164
- f"CF geometries must be represented by an Xarray Dataset. To request a Dataset in return please pass `[{k!r}]` instead."
1165
- )
1166
- successful[k] = bool(geometries)
1167
- if geometries:
1168
- varnames.extend(geometries)
1169
- elif k in custom_criteria or k in cf_role_criteria:
1170
- names = _get_all(obj, k)
1171
- check_results(names, k)
1172
- successful[k] = bool(names)
1173
- varnames.extend(names)
1174
- else:
1175
- stdnames = set(_get_with_standard_name(obj, k))
1176
- objcoords = set(obj.coords)
1177
- stdnames = drop_bounds(stdnames)
1178
- if "coords" in skip:
1179
- stdnames -= objcoords
1180
- check_results(stdnames, k)
1181
- successful[k] = bool(stdnames)
1182
- varnames.extend(stdnames - objcoords)
1183
- coords.extend(stdnames & objcoords)
1239
+ geometries.extend(*new)
1240
+ if len(geometries) > 1 and scalar_key:
1241
+ raise ValueError(
1242
+ f"CF geometries must be represented by an Xarray Dataset. To request a Dataset in return please pass `[{k!r}]` instead."
1243
+ )
1244
+ successful[k] = bool(geometries)
1245
+ if geometries:
1246
+ varnames.extend(geometries)
1247
+ elif k in custom_criteria or k in cf_role_criteria:
1248
+ names = _get_all(obj, k)
1249
+ check_results(names, k)
1250
+ successful[k] = bool(names)
1251
+ varnames.extend(names)
1252
+ else:
1253
+ stdnames = set(_get_with_standard_name(obj, k))
1254
+ objcoords = set(obj.coords)
1255
+ stdnames = drop_bounds(stdnames)
1256
+ if "coords" in skip:
1257
+ stdnames -= objcoords
1258
+ check_results(stdnames, k)
1259
+ successful[k] = bool(stdnames)
1260
+ varnames.extend(stdnames - objcoords)
1261
+ coords.extend(stdnames & objcoords)
1184
1262
 
1185
1263
  # these are not special names but could be variable names in underlying object
1186
1264
  # we allow this so that we can return variables with appropriate CF auxiliary variables
@@ -2029,20 +2107,17 @@ class CFAccessor:
2029
2107
  """
2030
2108
 
2031
2109
  obj = self._obj
2110
+ if isinstance(obj, DataArray):
2111
+ variables = [*obj.coords.variables.values(), obj.variable]
2112
+ else:
2113
+ variables = list(obj.variables.values())
2032
2114
  all_attrs = [
2033
- ChainMap(da.attrs, da.encoding).get("cell_measures", "")
2034
- for da in obj.coords.values()
2115
+ ChainMap(var.attrs, var.encoding).get("cell_measures", "")
2116
+ for var in variables
2035
2117
  ]
2036
- if isinstance(obj, DataArray):
2037
- all_attrs += [ChainMap(obj.attrs, obj.encoding).get("cell_measures", "")]
2038
- elif isinstance(obj, Dataset):
2039
- all_attrs += [
2040
- ChainMap(da.attrs, da.encoding).get("cell_measures", "")
2041
- for da in obj.data_vars.values()
2042
- ]
2043
2118
  as_dataset = self._maybe_to_dataset().reset_coords()
2044
2119
 
2045
- keys = {}
2120
+ keys: dict[str, str] = {}
2046
2121
  for attr in set(all_attrs):
2047
2122
  try:
2048
2123
  keys.update(parse_cell_methods_attr(attr))
@@ -2222,6 +2297,8 @@ class CFAccessor:
2222
2297
 
2223
2298
  if grid := attrs_or_encoding.get("grid", None):
2224
2299
  coords["grid"] = [grid]
2300
+ if isinstance(self._obj, Dataset):
2301
+ coords["coordinates"].extend(sgrid.get_topology_coords(self._obj, grid))
2225
2302
 
2226
2303
  if grid_mapping_attr := attrs_or_encoding.get("grid_mapping", None):
2227
2304
  # Parse grid mapping variables and their coordinates
@@ -2835,14 +2912,38 @@ class CFDatasetAccessor(CFAccessor):
2835
2912
  """
2836
2913
 
2837
2914
  obj = self._obj
2838
- keys = self.keys() | set(obj.variables)
2915
+ variables = obj._variables
2916
+
2917
+ # Single linear scan to discover which variables in the dataset have
2918
+ # a ``bounds`` attribute pointing at another existing variable. The
2919
+ # naive implementation called ``apply_mapper(_get_bounds, ...)`` for
2920
+ # every key in ``self.keys() | set(variables)``, which itself fans
2921
+ # out through ``_get_all`` and runs the regex criteria against every
2922
+ # variable's attrs. That is O(num_keys × num_vars × num_criteria)
2923
+ # for an answer that is structurally a single attribute lookup.
2924
+ var_to_bounds: dict[Hashable, Hashable] = {}
2925
+ for name, var in variables.items():
2926
+ attrs_or_encoding = ChainMap(var.attrs, var.encoding)
2927
+ bounds_name = attrs_or_encoding.get("bounds")
2928
+ if bounds_name is not None and bounds_name in variables:
2929
+ var_to_bounds[name] = bounds_name
2930
+
2931
+ if not var_to_bounds:
2932
+ return {}
2839
2933
 
2840
- vardict = {
2841
- key: self._drop_missing_variables(
2842
- apply_mapper(_get_bounds, obj, key, error=False)
2843
- )
2844
- for key in keys
2845
- }
2934
+ vardict: dict[Hashable, set[Hashable]] = {}
2935
+ # Direct variable-name keys: O(1) lookup, no mapper machinery.
2936
+ for name, bounds_name in var_to_bounds.items():
2937
+ vardict[name] = {bounds_name}
2938
+
2939
+ # CF keys (axes, coordinate names, custom criteria, ...): resolve to
2940
+ # their target variable names once, then read the precomputed
2941
+ # ``var_to_bounds`` map. ``_get_bounds`` is no longer invoked.
2942
+ for key in self.keys() - set(variables):
2943
+ target_vars = apply_mapper(_get_all, obj, key, error=False, default=[])
2944
+ bounds_vars = {var_to_bounds[v] for v in target_vars if v in var_to_bounds}
2945
+ if bounds_vars:
2946
+ vardict[key] = bounds_vars
2846
2947
 
2847
2948
  return {k: sort_maybe_hashable(v) for k, v in vardict.items() if v}
2848
2949
 
@@ -11,6 +11,31 @@ SGRID_DIM_ATTRS = [
11
11
  # "edge3_dimensions",
12
12
  ]
13
13
 
14
+ SGRID_COORD_ATTRS = [
15
+ "node_coordinates",
16
+ "face_coordinates",
17
+ "edge1_coordinates",
18
+ "edge2_coordinates",
19
+ "volume_coordinates",
20
+ ]
21
+
22
+
23
+ def get_topology_coords(ds, grid_var_name):
24
+ """Return coordinate variable names referenced by an SGRID topology variable.
25
+
26
+ Reads ``node_coordinates``, ``face_coordinates``, ``edge{1,2}_coordinates``,
27
+ and ``volume_coordinates`` from the topology variable's attrs and filters
28
+ to names that are actually present in ``ds``.
29
+ """
30
+ if grid_var_name not in ds.variables:
31
+ return []
32
+ grid_attrs = ds[grid_var_name].attrs
33
+ names: list[str] = []
34
+ for attr_name in SGRID_COORD_ATTRS:
35
+ if coord_str := grid_attrs.get(attr_name):
36
+ names.extend(n for n in coord_str.split() if n in ds.variables)
37
+ return names
38
+
14
39
 
15
40
  def parse_axes(ds):
16
41
  import re
@@ -1254,6 +1254,44 @@ def test_grid_mappings_property():
1254
1254
  assert gm.coordinates == ("x", "y")
1255
1255
 
1256
1256
 
1257
+ @requires_pyproj
1258
+ def test_grid_mappings_crs_construction_is_cached(monkeypatch):
1259
+ """``pyproj.CRS.from_cf`` is memoized per grid-mapping attrs.
1260
+
1261
+ Building the CRS re-parses the datum/ellipsoid on every call. A dataset
1262
+ references the same grid mapping from many variables and the property may
1263
+ be accessed repeatedly, so each distinct grid mapping should be built once.
1264
+ """
1265
+ import pyproj
1266
+
1267
+ from ..accessor import _crs_from_cf_attrs
1268
+
1269
+ _crs_from_cf_attrs.cache_clear()
1270
+
1271
+ from ..datasets import hrrrds
1272
+
1273
+ ds = hrrrds
1274
+
1275
+ calls = {"n": 0}
1276
+ orig = pyproj.CRS.from_cf
1277
+
1278
+ def counting_from_cf(*args, **kwargs):
1279
+ calls["n"] += 1
1280
+ return orig(*args, **kwargs)
1281
+
1282
+ monkeypatch.setattr(pyproj.CRS, "from_cf", staticmethod(counting_from_cf))
1283
+
1284
+ # Repeated property accesses, including via a DataArray, must not rebuild
1285
+ # the same CRS: hrrrds has 3 distinct grid mappings, each built once.
1286
+ ds.cf.grid_mappings
1287
+ ds.cf.grid_mappings
1288
+ ds.foo.cf.grid_mappings
1289
+
1290
+ assert calls["n"] == 3
1291
+
1292
+ _crs_from_cf_attrs.cache_clear()
1293
+
1294
+
1257
1295
  @requires_pyproj
1258
1296
  def test_grid_mappings_coordinates_attribute():
1259
1297
  """Test that coordinates attribute is always populated correctly for DataArray grid mappings."""
@@ -2484,6 +2522,43 @@ def test_sgrid():
2484
2522
  }
2485
2523
 
2486
2524
 
2525
+ def test_sgrid_includes_topology_coordinates():
2526
+ """Variables referenced in node/face/edge/volume_coordinates of the
2527
+ grid_topology variable should be pulled in by ds.cf[[var]]."""
2528
+ roms = sgrid_roms.copy()
2529
+ for pos in ("psi", "rho", "u", "v"):
2530
+ roms[f"lon_{pos}"] = ((f"xi_{pos}", f"eta_{pos}"), np.zeros((2, 2)))
2531
+ roms[f"lat_{pos}"] = ((f"xi_{pos}", f"eta_{pos}"), np.zeros((2, 2)))
2532
+
2533
+ expected_coord_vars = {
2534
+ "lon_psi",
2535
+ "lat_psi",
2536
+ "lon_rho",
2537
+ "lat_rho",
2538
+ "lon_u",
2539
+ "lat_u",
2540
+ "lon_v",
2541
+ "lat_v",
2542
+ }
2543
+ assoc = roms.cf.get_associated_variable_names("u")
2544
+ assert expected_coord_vars.issubset(set(assoc["coordinates"]))
2545
+
2546
+ subset = roms.cf[["u"]]
2547
+ assert "grid" in subset.variables
2548
+ assert expected_coord_vars.issubset(set(subset.variables))
2549
+
2550
+ # only dim-compatible coords attach to the DataArray form
2551
+ u_da = roms.cf["u"]
2552
+ assert {"lon_u", "lat_u"}.issubset(set(u_da.coords))
2553
+
2554
+ delft = sgrid_delft.copy()
2555
+ delft["node_lon"] = (("inode", "jnode"), np.zeros((2, 2)))
2556
+ delft["node_lat"] = (("inode", "jnode"), np.zeros((2, 2)))
2557
+ delft["foo"] = (("icell", "jcell"), np.ones((2, 2)), {"grid": "grid"})
2558
+ delft_subset = delft.cf[["foo"]]
2559
+ assert {"grid", "node_lon", "node_lat"}.issubset(set(delft_subset.variables))
2560
+
2561
+
2487
2562
  def test_ancillary_variables_extra_dim():
2488
2563
  ds = xr.Dataset(
2489
2564
  {
@@ -1,13 +1,15 @@
1
+ import functools
1
2
  import inspect
2
3
  import os
3
4
  import warnings
4
5
  from collections import defaultdict
5
- from collections.abc import Iterable
6
+ from collections.abc import Iterable, Mapping
6
7
  from typing import Any
7
8
  from xml.etree import ElementTree
8
9
 
9
10
  import numpy as np
10
11
  from xarray import DataArray
12
+ from xarray.core.utils import Frozen
11
13
 
12
14
  try:
13
15
  import cftime
@@ -64,7 +66,8 @@ def _is_datetime_like(da: DataArray) -> bool:
64
66
  return False
65
67
 
66
68
 
67
- def parse_cell_methods_attr(attr: str) -> dict[str, str]:
69
+ @functools.lru_cache(maxsize=256)
70
+ def parse_cell_methods_attr(attr: str) -> Mapping[str, str]:
68
71
  """
69
72
  Parse cell_methods attributes (format is 'measure: name').
70
73
 
@@ -75,14 +78,14 @@ def parse_cell_methods_attr(attr: str) -> dict[str, str]:
75
78
 
76
79
  Returns
77
80
  -------
78
- Dictionary mapping measure to name
81
+ Read-only mapping from measure to name.
79
82
  """
80
83
  strings = [s for scolons in attr.split(":") for s in scolons.split()]
81
84
  if len(strings) % 2 != 0:
82
85
  raise ValueError(f"attrs['cell_measures'] = {attr!r} is malformed.")
83
86
 
84
- return dict(
85
- zip(strings[slice(0, None, 2)], strings[slice(1, None, 2)], strict=False)
87
+ return Frozen(
88
+ dict(zip(strings[slice(0, None, 2)], strings[slice(1, None, 2)], strict=False))
86
89
  )
87
90
 
88
91
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cf_xarray
3
- Version: 0.11.0
3
+ Version: 0.11.2
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
@@ -61,6 +61,23 @@ only `xi_u`, `eta_u` are listed in the repr even though the attributes on the `g
61
61
  variable `grid` list many more dimension names.
62
62
  ```
63
63
 
64
+ ### Coordinate variables
65
+
66
+ `cf_xarray` also follows the `node_coordinates`, `face_coordinates`,
67
+ `edge1_coordinates`, `edge2_coordinates`, and `volume_coordinates` attributes
68
+ on the `grid_topology` variable. When you select a data variable that
69
+ references a `grid_topology` via its `grid` attribute, the referenced
70
+ coordinate variables are pulled in alongside it:
71
+
72
+ ```python
73
+ ds.cf[["u"]] # includes `grid`, lon_psi/lat_psi, lon_rho/lat_rho, ...
74
+ ```
75
+
76
+ Only names actually present in the dataset are propagated. For the
77
+ `DataArray` form (`ds.cf["u"]`) xarray only attaches coordinates whose
78
+ dimensions are compatible with the variable, so e.g. only `lon_u`/`lat_u`
79
+ appear as coords on `u`.
80
+
64
81
  ## UGRID
65
82
 
66
83
  ### Topology variable
@@ -2983,11 +2983,11 @@ wheels = [
2983
2983
 
2984
2984
  [[package]]
2985
2985
  name = "urllib3"
2986
- version = "2.6.3"
2986
+ version = "2.7.0"
2987
2987
  source = { registry = "https://pypi.org/simple" }
2988
- sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
2988
+ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
2989
2989
  wheels = [
2990
- { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
2990
+ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
2991
2991
  ]
2992
2992
 
2993
2993
  [[package]]
@@ -1 +0,0 @@
1
- __version__ = "0.11.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes