grid-process 1.0.0__tar.gz → 1.1.0__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 (27) hide show
  1. grid_process-1.1.0/CHANGELOG.rst +96 -0
  2. {grid_process-1.0.0 → grid_process-1.1.0}/LICENSE +0 -1
  3. {grid_process-1.0.0 → grid_process-1.1.0}/PKG-INFO +2 -2
  4. {grid_process-1.0.0 → grid_process-1.1.0}/README.rst +1 -1
  5. {grid_process-1.0.0 → grid_process-1.1.0}/grid_process/config/grid_process.yaml +6 -0
  6. {grid_process-1.0.0 → grid_process-1.1.0}/grid_process/config/library_settings.py +1 -0
  7. {grid_process-1.0.0 → grid_process-1.1.0}/grid_process/data_model.py +132 -12
  8. {grid_process-1.0.0 → grid_process-1.1.0}/grid_process/grid_interp.py +542 -200
  9. {grid_process-1.0.0 → grid_process-1.1.0}/grid_process/grid_interp_utils.py +20 -11
  10. {grid_process-1.0.0 → grid_process-1.1.0}/grid_process/helpers.py +71 -1
  11. grid_process-1.1.0/grid_process/optimization.py +114 -0
  12. grid_process-1.1.0/grid_process/py.typed +0 -0
  13. {grid_process-1.0.0 → grid_process-1.1.0}/pyproject.toml +17 -2
  14. grid_process-1.0.0/CHANGELOG.rst +0 -15
  15. grid_process-1.0.0/grid_process/optimization.py +0 -50
  16. {grid_process-1.0.0 → grid_process-1.1.0}/AUTHORS +0 -0
  17. {grid_process-1.0.0 → grid_process-1.1.0}/grid_process/__init__.py +0 -0
  18. {grid_process-1.0.0 → grid_process-1.1.0}/grid_process/aggreg.py +0 -0
  19. {grid_process-1.0.0 → grid_process-1.1.0}/grid_process/blending.py +0 -0
  20. {grid_process-1.0.0 → grid_process-1.1.0}/grid_process/config/__init__.py +0 -0
  21. {grid_process-1.0.0 → grid_process-1.1.0}/grid_process/da_utils.py +0 -0
  22. {grid_process-1.0.0 → grid_process-1.1.0}/grid_process/ds_utils.py +0 -0
  23. {grid_process-1.0.0 → grid_process-1.1.0}/grid_process/ens_stats.py +0 -0
  24. {grid_process-1.0.0 → grid_process-1.1.0}/grid_process/sun.py +0 -0
  25. {grid_process-1.0.0 → grid_process-1.1.0}/grid_process/time_utils.py +0 -0
  26. {grid_process-1.0.0 → grid_process-1.1.0}/grid_process/timeseries.py +0 -0
  27. {grid_process-1.0.0 → grid_process-1.1.0}/grid_process/units_conv.py +0 -0
@@ -0,0 +1,96 @@
1
+ =========
2
+ Changelog
3
+ =========
4
+
5
+ 1.1.0 (2026-11-12)
6
+ ------------------
7
+
8
+ **Changes of results** (compared to 1.0.0, see the entries below for details)
9
+
10
+ * ``interp2poi`` and ``interp2grid`` (outside of the regular grid interpolation) search nearest neighbors and
11
+ triangulate in meters instead of latitude/longitude degrees: neighbors, IDW weights and ``linear``/``cubic``
12
+ results change slightly for grids that were searched in degrees, including native LV95 grids with
13
+ latitude/longitude coordinates
14
+ * grids in rotated pole or latitude/longitude on a sphere without datum (e.g. a CF grid mapping with
15
+ ``earth_radius``) now get the datum shift to Swiss coordinates (about 180 m)
16
+ * wrong values or NaN are fixed for grids with integer coordinates and for latitude/longitude grids whose longitude
17
+ convention (0..360 or -180..180) differs from the one of the POIs or destination grid
18
+ * ``create_dst_grid`` (with ``topright`` and ``resolution``) and ``create_dst_grid_from_src`` return different
19
+ destination grids: exact resolution, and the extent of the source domain reprojected in the destination CRS
20
+ * ``grid_interp._get_buffer`` is renamed to ``get_buffer`` (now public)
21
+
22
+ **Changes**
23
+
24
+ * added a ``py.typed`` marker so that type checkers use the inline type hints of ``grid-process`` (PEP 561)
25
+ * added example notebooks to the documentation illustrating ``interp2poi`` and ``interp2grid`` with open data, with
26
+ their dependencies in the optional ``example`` dependency group. Two of them are global: GFS 2 m temperature to
27
+ stations worldwide (compared with GHCN-hourly observations) and ERA5 2 m temperature to the IFS 0.1° grid
28
+ * fixed ``interp2grid`` and ``interp2poi`` truncating reprojected coordinates when the grid coordinates are
29
+ integers (e.g. an LV95 destination grid built with ``np.arange``), which gave wrong values or NaN
30
+ * fixed ``create_dst_grid`` with ``topright`` and ``resolution``: the resolution is now exact and ``topright``
31
+ a closed upper bound, instead of the grid spacing being stretched to end exactly on ``topright``
32
+ * fixed ``interp2poi`` and ``interp2grid`` (outside of the regular grid interpolation) searching nearest neighbors
33
+ and triangulating (``linear``, ``cubic``) in latitude/longitude degrees, where a degree of longitude is shorter
34
+ than a degree of latitude (about 0.68 times in Switzerland). Distances are now always in meters: native
35
+ coordinates projected in meters are used even if latitude/longitude coordinates are present (e.g. LV95 grids),
36
+ and latitude/longitude grids are converted to Swiss LV95, or to earth-centered earth-fixed coordinates (ECEF) far
37
+ from Switzerland. Neighbors, IDW weights and ``linear``/``cubic`` results change slightly for grids that were
38
+ searched in latitude/longitude, including native LV95 grids with latitude/longitude coordinates
39
+ * fixed the reprojection of grids in rotated pole (or latitude/longitude) on a sphere without datum, e.g. from a CF
40
+ grid mapping with ``earth_radius``: their latitudes and longitudes are now considered as WGS84 ones, as their
41
+ latitude/longitude coordinates usually are. The datum shift to Swiss coordinates (about 180 m) was left out.
42
+ ``parse_grid_mapping`` (and ``get_grid_crs``) now return such CF mappings directly on WGS84, without
43
+ ``pyproj.CRS.from_cf``, which took about 0.3 s for a rotated pole; other mappings parsed by ``from_cf`` are cached
44
+ * added the support of global and other non-Swiss latitude/longitude grids by ``interp2poi`` and ``interp2grid``
45
+ (except ``linear``, ``linear_3D`` and ``cubic``, which raise an error), with nearest neighbor searches in ECEF
46
+ coordinates. The maximum distance of the destination to the LV95 central northing for using LV95 is set by the
47
+ new setting ``lv95_max_offset`` (environment variable ``GRID_PROCESS__lv95_max_offset``, default 700 km)
48
+ * changed the methods with altitude, which no longer reproject to Swiss coordinates when the source grid or the
49
+ destination has another CRS projected in meters (e.g. UTM)
50
+ * added an explicit error when the destination grid of ``interp2grid`` is unstructured, which is not supported
51
+ * fixed ``interp2poi`` and ``interp2grid`` with latitude/longitude source grids whose longitude convention
52
+ (0..360 or -180..180) differs from the one of the POIs or destination grid: the source was sliced to the
53
+ wrong longitudes (wrong values in the generic path, e.g. for a global destination grid) and the regular grid
54
+ interpolation (``try_reg=True``) returned NaN. Global latitude/longitude grids are also interpolated across
55
+ their 360 degree seam. When slicing a latitude/longitude source grid, the longitude buffer is widened by
56
+ 1 / cos(latitude) (as nearest neighbors are now searched in meters) and all longitudes are kept close to the
57
+ poles
58
+ * changed the ``temp`` method of ``interp2poi``: POIs whose nearest grid points all have the same altitude (e.g.
59
+ close to the poles of a latitude/longitude grid, whose pole rows repeat the same point, or on flat orography)
60
+ use the fixed lapse rate fallback (as POIs far above or below their neighborhood) instead of raising an error
61
+ * fixed ``create_dst_grid_from_src``: the destination grid now spans exactly the bounding box of the source domain
62
+ reprojected in the destination CRS (from native coordinates projected in meters when available), instead of the
63
+ reprojected corners of its latitude/longitude bounding box, which could be much larger (e.g. +80 km for ICON-CH2
64
+ in LV95) or miss parts of the domain (e.g. 28 km for IFS in LV95)
65
+ * fixed ``set_grid_mapping`` with CF grid mappings holding numpy arrays or non-float64 numpy values (e.g. a
66
+ ``standard_parallel`` array read from a netCDF file), which raised a ``TypeError``
67
+ * changed ``memoize``: the least recently used result is evicted (instead of the oldest one), the cache key can be
68
+ computed by an optional ``key`` function (only when caching is active), a cache size of 0 deactivates caching
69
+ without overhead, and the cache is safe to use from several threads, also when the process forks (the fork waits
70
+ until no thread is using the cache). The ``hkey`` keyword argument still works
71
+ * fixed the in-memory caches of the POI and grid reprojections, whose keys left out the source CRS, and the cache
72
+ of the regular grid nearest interpolation, which failed with a POI dimension name other than ``poi``
73
+ (``GRID_PROCESS__data_model__poi__dim``)
74
+ * documented the in-memory caches of ``interp2poi`` and ``interp2grid`` and how to set their sizes or deactivate
75
+ them with the environment variables ``GRID_PROCESS__cache_size__<name>`` (Usage page)
76
+ * changed ``interp2poi`` and ``interp2grid`` outside of the regular grid interpolation to flatten structured
77
+ grids with numpy instead of ``xarray.stack``, and ``interp2poi`` to look for the grid points with NaN in all
78
+ fields (excluded, as before) only among those with NaN in the middle field. Later calls with cached neighbors are
79
+ faster on large grids (e.g. 0.13 s to 0.05 s for a global 0.25 degree grid to 33 POIs, 1.6 s to 0.5 s from this
80
+ grid to a global 0.1 degree grid), with identical results
81
+ * fixed ``interp2poi`` raising an ``IndexError`` when all POIs are outside an unstructured grid (no grid point left
82
+ after slicing): they now get NaN, as outside of the regular grid interpolation
83
+ * made ``grid_interp.get_buffer`` public (renamed from ``_get_buffer``): the slicing buffer in grid points of an
84
+ interpolation method and its arguments, e.g. to slice the input data with ``slice_boundingbox`` beforehand
85
+
86
+ 1.0.0
87
+ -----
88
+
89
+ First open-source release of ``grid-process``, the continuation of the internal MeteoSwiss library
90
+ ``gridefix-process`` (last internal version: 6.1.0).
91
+
92
+ **Breaking Change** (compared to ``gridefix-process`` 6.1.0)
93
+
94
+ * renamed the package from ``gridefix-process`` to ``grid-process`` (import name ``grid_process``)
95
+ * renamed the environment variable prefix from ``GRIDEFIX_PROCESS__`` to ``GRID_PROCESS__``
96
+ * versioning restarts at ``1.0.0``
@@ -1,7 +1,6 @@
1
1
  BSD 3-Clause License
2
2
 
3
3
  Copyright (c) 2026, MeteoSwiss
4
- Authors: Mathieu Schaer <mathieu.schaer@meteoswiss.ch>
5
4
 
6
5
  Redistribution and use in source and binary forms, with or without
7
6
  modification, are permitted provided that the following conditions are met:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: grid-process
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Weather forecast oriented Python library to process xarray Datasets
5
5
  License-Expression: BSD-3-Clause
6
6
  License-File: AUTHORS
@@ -138,7 +138,7 @@ Migrating from gridefix-process
138
138
  * Package and import name: ``gridefix-process`` / ``gridefix_process`` became ``grid-process`` / ``grid_process``.
139
139
  * Environment variable prefix: ``GRIDEFIX_PROCESS__`` became ``GRID_PROCESS__``
140
140
  (e.g. ``GRID_PROCESS__data_model__validtime``).
141
- * Versioning restarts at ``1.0.0``; the last internal release was ``gridefix-process`` 6.0.1.
141
+ * Versioning restarts at ``1.0.0``; the last internal release was ``gridefix-process`` 6.1.0.
142
142
 
143
143
  Release the Project
144
144
  -------------------
@@ -106,7 +106,7 @@ Migrating from gridefix-process
106
106
  * Package and import name: ``gridefix-process`` / ``gridefix_process`` became ``grid-process`` / ``grid_process``.
107
107
  * Environment variable prefix: ``GRIDEFIX_PROCESS__`` became ``GRID_PROCESS__``
108
108
  (e.g. ``GRID_PROCESS__data_model__validtime``).
109
- * Versioning restarts at ``1.0.0``; the last internal release was ``gridefix-process`` 6.0.1.
109
+ * Versioning restarts at ``1.0.0``; the last internal release was ``gridefix-process`` 6.1.0.
110
110
 
111
111
  Release the Project
112
112
  -------------------
@@ -1,9 +1,15 @@
1
+ # number of results kept by each in-memory cache (least recently used evicted), 0 to deactivate it. Override with
2
+ # the environment variables GRID_PROCESS__cache_size__<name>, set before importing grid_process
1
3
  cache_size:
2
4
  nn_lookup: 10
3
5
  nn_lookup_regular: 10
4
6
  reproject_poi: 6
5
7
  reproject_grid: 6
6
8
 
9
+ # [m] max |N_LV95 - 1 200 000| of the destination for interpolating in LV95 when both grids are
10
+ # in lat/lon (otherwise ECEF). 700 km ≈ 0.6 % scale error, covers ICON-CH1/CH2 (523 km)
11
+ lv95_max_offset: 700000
12
+
7
13
  data_model: # supported names
8
14
  grid:
9
15
  unstructured_dim: ["cell", "points"]
@@ -55,6 +55,7 @@ class LibrarySettings(BaseSettings):
55
55
  )
56
56
  cache_size: CacheSizeSettings
57
57
  data_model: DataModelSettings
58
+ lv95_max_offset: float = Field(gt=0)
58
59
 
59
60
  @classmethod
60
61
  def settings_customise_sources(
@@ -10,12 +10,15 @@ Module with utility functions to deal with the numerous supported data models
10
10
 
11
11
  import logging
12
12
  import json
13
+ from functools import lru_cache
13
14
  from typing import Any, TypeVar, Literal, overload
14
15
  from collections.abc import Callable
15
16
 
16
17
  import numpy as np
17
18
  from numpy.typing import NDArray
18
19
  from pyproj import CRS
20
+ from pyproj.crs import DerivedGeographicCRS
21
+ from pyproj.crs.coordinate_operation import PoleRotationNetCDFCFConversion
19
22
  import geopandas as gpd
20
23
  import xarray as xr
21
24
 
@@ -203,12 +206,6 @@ def set_grid_mapping(ds: xr.Dataset) -> xr.Dataset:
203
206
  data variable encoding the grid mapping is removed.
204
207
  """
205
208
 
206
- def serializable_mapping() -> dict[str, Any]:
207
- return {
208
- key: float(value) if isinstance(value, np.floating) else value
209
- for key, value in ds_out[mapname].attrs.items()
210
- }
211
-
212
209
  ds_out = ds.copy(deep=False)
213
210
  grid_map = set()
214
211
  for varname, da in ds_out.items():
@@ -224,7 +221,7 @@ def set_grid_mapping(ds: xr.Dataset) -> xr.Dataset:
224
221
 
225
222
  mapname = grid_map.pop()
226
223
  if {"grid_mapping", "crs"}.isdisjoint(ds_out.attrs) and mapname in ds_out:
227
- ds_out.attrs["grid_mapping"] = json.dumps(serializable_mapping())
224
+ ds_out.attrs["grid_mapping"] = _grid_mapping_json(ds_out[mapname].attrs)
228
225
 
229
226
  return ds_out.drop_vars(mapname, errors="ignore")
230
227
 
@@ -452,23 +449,106 @@ def parse_grid_mapping(grid_mapping: str | dict[str, Any]) -> CRS:
452
449
  See also
453
450
  --------
454
451
  :func:`get_grid_crs`
452
+
453
+ Note
454
+ ----
455
+ CF ``latitude_longitude`` and ``rotated_latitude_longitude`` mappings without ellipsoid parameters other than
456
+ ``earth_radius`` (as NWP models on a sphere) are built directly on WGS84, which is much faster than
457
+ pyproj.CRS.from_cf(). The result is the same as ``sphere_as_wgs84(pyproj.CRS.from_cf(grid_mapping))``, see
458
+ :func:`~grid_process.helpers.sphere_as_wgs84`.
455
459
  """
456
460
 
461
+ # pyproj.CRS.from_cf() takes ~0.3 s for mappings with ellipsoid parameters (custom datum): cached on the JSON
457
462
  if isinstance(grid_mapping, str):
458
- grid_mapping = json.loads(grid_mapping)
463
+ return _parse_grid_mapping_str(grid_mapping)
464
+ try:
465
+ return _parse_grid_mapping_str(_grid_mapping_json(grid_mapping))
466
+ except TypeError: # not serializable: parse without cache
467
+ return _parse_grid_mapping_dict(grid_mapping)
468
+
469
+
470
+ def _grid_mapping_json(grid_mapping: dict[str, Any]) -> str:
471
+ """
472
+ JSON string of a grid mapping dictionary, with sorted keys and numpy scalars and arrays (e.g. attributes read
473
+ from a netCDF file such as "standard_parallel") converted to numbers and lists.
474
+ """
475
+
476
+ return json.dumps(grid_mapping, sort_keys=True, default=lambda value: value.tolist())
477
+
478
+
479
+ @lru_cache(maxsize=32)
480
+ def _parse_grid_mapping_str(grid_mapping: str) -> CRS:
481
+ """
482
+ Cached parsing of a JSON "grid_mapping" attribute, see :func:`parse_grid_mapping`.
483
+ """
484
+
485
+ return _parse_grid_mapping_dict(json.loads(grid_mapping))
486
+
487
+
488
+ def _parse_grid_mapping_dict(grid_mapping: dict[str, Any]) -> CRS:
489
+ """
490
+ Parsing of a "grid_mapping" dictionary, see :func:`parse_grid_mapping`.
491
+ """
492
+
459
493
  if "epsg_code" in grid_mapping:
460
- return CRS.from_string(grid_mapping["epsg_code"]) # type: ignore[index]
494
+ return CRS.from_string(grid_mapping["epsg_code"])
495
+
496
+ if _ELLIPSOID_KEYS.isdisjoint(grid_mapping):
497
+ mapping_name = grid_mapping.get("grid_mapping_name")
498
+ if mapping_name == "latitude_longitude":
499
+ return CRS.from_epsg(4326)
500
+ if mapping_name == "rotated_latitude_longitude":
501
+ return DerivedGeographicCRS(
502
+ base_crs=CRS.from_epsg(4326),
503
+ conversion=PoleRotationNetCDFCFConversion(
504
+ grid_north_pole_latitude=grid_mapping["grid_north_pole_latitude"],
505
+ grid_north_pole_longitude=grid_mapping["grid_north_pole_longitude"],
506
+ north_pole_grid_longitude=grid_mapping.get("north_pole_grid_longitude", 0.0),
507
+ ),
508
+ )
509
+
510
+ return CRS.from_cf(grid_mapping)
511
+
512
+
513
+ # CF grid mapping attributes defining the ellipsoid or datum (other than earth_radius for a sphere)
514
+ _ELLIPSOID_KEYS = frozenset(
515
+ (
516
+ "semi_major_axis",
517
+ "semi_minor_axis",
518
+ "inverse_flattening",
519
+ "reference_ellipsoid_name",
520
+ "horizontal_datum_name",
521
+ "geographic_crs_name",
522
+ "prime_meridian_name",
523
+ "longitude_of_prime_meridian",
524
+ "towgs84",
525
+ "crs_wkt",
526
+ "spatial_ref", # WKT written by GDAL
527
+ )
528
+ )
529
+
530
+
531
+ def is_metric_crs(crs: CRS) -> bool:
532
+ """
533
+ Whether ``crs`` is a projected CRS with all axes in meters (e.g. LV95, LV03 or UTM), i.e. in which euclidean
534
+ distances can be computed directly from the coordinates. The axis units are compared by their conversion factor
535
+ to the meter, so that units written with another name (e.g. "m" or "Meter" in a WKT without authority) are
536
+ recognized, while kilometers or feet are not.
537
+ """
461
538
 
462
- return CRS.from_cf(grid_mapping) # type: ignore[arg-type]
539
+ return bool(crs.is_projected) and all(axis.unit_conversion_factor == 1.0 for axis in crs.axis_info)
463
540
 
464
541
 
465
542
  def get_coords_name_crs(
466
- grid: xr.Dataset | xr.DataArray, spatial_dims: tuple[str, ...] | None = None
543
+ grid: xr.Dataset | xr.DataArray,
544
+ spatial_dims: tuple[str, ...] | None = None,
545
+ prefer_projected: bool = False,
467
546
  ) -> tuple[str, str, CRS]:
468
547
  """
469
548
  Get correct coordinate names and CRS according to supported conventions. When
470
549
  non-dimensional longitude and latitude coordinates arrays are present, these
471
- are used instead of the native spatial dimension coordinates.
550
+ are used instead of the native spatial dimension coordinates, unless ``prefer_projected``
551
+ is True and the native CRS is projected with axes in meters.
472
552
 
473
553
  Parameters
474
554
  ----------
@@ -480,6 +560,10 @@ def get_coords_name_crs(
480
560
  name of the ``grid`` spatial dimension(s). Has size one in case of
481
561
  unstructured grid otherwise contains dimension names in the order (y, x).
482
562
  If not provided, calls :func:`~grid_process.helpers.get_spatial_dims`
563
+ prefer_projected : bool, optional
564
+ If True, return the native spatial coordinates when their CRS is projected with axes in meters (e.g. LV95),
565
+ even if longitude and latitude coordinates are also present. Native coordinates in degrees (e.g. rotated
566
+ pole) still give the longitude and latitude coordinates when present. Default is False.
483
567
 
484
568
  Returns
485
569
  -------
@@ -491,6 +575,13 @@ def get_coords_name_crs(
491
575
  pyproj representation of input grid coordinate reference system
492
576
  """
493
577
 
578
+ if prefer_projected:
579
+ if spatial_dims is None:
580
+ spatial_dims = get_spatial_dims(grid)
581
+ native = _get_native_metric_coords(grid, spatial_dims)
582
+ if native is not None:
583
+ return native
584
+
494
585
  latlon = get_latlon_coords(grid)
495
586
  if latlon is not None:
496
587
  grid_y, grid_x = latlon
@@ -510,6 +601,35 @@ def get_coords_name_crs(
510
601
  raise ValueError("Missing spatial coordinates")
511
602
 
512
603
 
604
+ def _get_native_metric_coords(
605
+ grid: xr.Dataset | xr.DataArray, spatial_dims: tuple[str, ...]
606
+ ) -> tuple[str, str, CRS] | None:
607
+ """
608
+ Names of the native (y, x) spatial coordinates and their CRS if this CRS is projected with axes in meters,
609
+ otherwise None. Used by :func:`get_coords_name_crs` with ``prefer_projected=True``.
610
+ """
611
+
612
+ if len(spatial_dims) == 1:
613
+ try:
614
+ grid_y, grid_x = get_spatial_coords(grid)
615
+ except ValueError:
616
+ return None
617
+ else:
618
+ grid_y, grid_x = spatial_dims
619
+ if grid_y not in grid.coords or grid_x not in grid.coords:
620
+ return None
621
+
622
+ if (grid_y, grid_x) == get_latlon_coords(grid, nondim=False):
623
+ return None
624
+
625
+ try:
626
+ crs = get_grid_crs(grid)
627
+ except ValueError: # no CRS attribute: only longitude and latitude can be used
628
+ return None
629
+
630
+ return (grid_y, grid_x, crs) if is_metric_crs(crs) else None
631
+
632
+
513
633
  def is_decreasing1d(coords: xr.DataArray) -> bool:
514
634
  """
515
635
  Whether 1 dimensional ``coords`` are in decreasing order.