grid-process 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,208 @@
1
+ # Copyright (c) 2026 MeteoSwiss, contributors listed in AUTHORS
2
+ #
3
+ # Distributed under the terms of the BSD 3-Clause License.
4
+ #
5
+ # SPDX-License-Identifier: BSD-3-Clause
6
+
7
+ """
8
+ Module with utilities for spatial interpolation of gridded datasets
9
+ """
10
+
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+ from pyproj import CRS
15
+ import xarray as xr
16
+
17
+ from grid_process.helpers import reproject
18
+ from grid_process import data_model as dm
19
+
20
+
21
+ Y_DIM, X_DIM = dm.YX[0]
22
+ LAT, LON = dm.LATLON[0]
23
+
24
+
25
+ def create_dst_grid( # pylint: disable=too-many-locals
26
+ dst_grid_crs: Any,
27
+ bottomleft: tuple[float, float],
28
+ topright: tuple[float, float] | None = None,
29
+ nrowcol: tuple[int, int] | None = None,
30
+ resolution: float | None = None,
31
+ add_latlon: bool = False,
32
+ dtype: np.dtype = np.dtype("float32"),
33
+ ) -> xr.Dataset:
34
+ """
35
+ Create a destination grid on which data can be interpolated. The output
36
+ can be directly used with :func:`interp2grid`. Two out of the three optional
37
+ input arguments ``topright``, ``nrowcol``, ``resolution`` must be provided.
38
+
39
+ Parameters
40
+ ----------
41
+ dst_grid_crs : string, int, dict or a CRS object
42
+ Anything accepted by pyproj.crs.CRS.from_user_input:
43
+ https://pyproj4.github.io/pyproj/dev/api/crs/crs.html#pyproj.crs.CRS.from_user_input
44
+ (e.g. EPSG:4326)
45
+ bottomleft : tuple of floats
46
+ (y, x) coordinates of the center of the bottom left corner pixel
47
+ topright : tuple of floats, optional
48
+ (y, x) coordinates of the center of the top right corner pixel.
49
+ If provided with ``nrowcol``, this is the exact coordinates. If provided
50
+ with ``resolution`` this is a closed upper bound. The exact coordinates of
51
+ the top right corner pixel will then be a multiple of the resolution with
52
+ respect to the bottom left corner.
53
+ nrowcol : tuple of int, optional
54
+ number of rows (y) and columns (x) respectively
55
+ resolution : float, optional
56
+ resolution of the destination grid in the units of the ``grid_mapping``
57
+ add_latlon : bool
58
+ If True, add latitude and longitude 2d arrays as coordinates
59
+ dtype : numpy.dtype
60
+ The dtype in which spatial coordinates are returned
61
+
62
+ Returns
63
+ -------
64
+ dst_grid : xarray.Dataset
65
+ dataset with spatial horizontal coordinates and an associated crs attribute
66
+
67
+ See also
68
+ --------
69
+ :func:`interp2grid`
70
+ """
71
+
72
+ start_y, start_x = bottomleft
73
+
74
+ if topright is not None and nrowcol is not None:
75
+ end_y, end_x = topright
76
+ nrows, ncols = nrowcol
77
+ y_coords = np.linspace(start_y, end_y, nrows, dtype=dtype)
78
+ x_coords = np.linspace(start_x, end_x, ncols, dtype=dtype)
79
+ elif topright is not None and resolution is not None:
80
+ end_y, end_x = topright
81
+ y_coords = np.linspace(start_y, end_y, num=int((end_y - start_y) / resolution) + 1, dtype=dtype)
82
+ x_coords = np.linspace(start_x, end_x, num=int((end_x - start_x) / resolution) + 1, dtype=dtype)
83
+ elif nrowcol is not None and resolution is not None:
84
+ nrows, ncols = nrowcol
85
+ end_y = start_y + (nrows - 1) * resolution
86
+ end_x = start_x + (ncols - 1) * resolution
87
+ y_coords = np.linspace(start_y, end_y, nrows, dtype=dtype)
88
+ x_coords = np.linspace(start_x, end_x, ncols, dtype=dtype)
89
+ else:
90
+ raise ValueError("At least two of 'topright', 'nrowcol', 'resolution' arguments must be provided")
91
+
92
+ dst_grid = xr.Dataset(
93
+ coords={X_DIM: x_coords, Y_DIM: y_coords},
94
+ attrs={"crs": dst_grid_crs},
95
+ )
96
+
97
+ if add_latlon:
98
+ x_2d, y_2d = np.meshgrid(x_coords, y_coords)
99
+ dst_crs = CRS.from_user_input(dst_grid_crs)
100
+ longitude, latitude = reproject(x_2d, y_2d, dst_crs, "EPSG:4326")
101
+ dst_grid = dst_grid.assign_coords(
102
+ {
103
+ LON: ((Y_DIM, X_DIM), longitude.astype(dtype, copy=False)),
104
+ LAT: ((Y_DIM, X_DIM), latitude.astype(dtype, copy=False)),
105
+ }
106
+ )
107
+
108
+ return dst_grid
109
+
110
+
111
+ def create_dst_grid_from_src( # pylint: disable=too-many-locals
112
+ src_grid: xr.Dataset,
113
+ dst_grid_crs: Any,
114
+ add_latlon: bool = False,
115
+ ) -> xr.Dataset:
116
+ """
117
+ Creates a destination grid on which data can be interpolated from a source grid
118
+ in a different Coordinate Reference System. Domain and sizes are preserved.
119
+ The output can be directly used with :func:`interp2grid`.
120
+
121
+ Parameters
122
+ ----------
123
+ src_grid : xarray Dataset
124
+ The source grid from which a new destination grid with same sizes and domain
125
+ is derived. Supported data models include:
126
+
127
+ - CF-convention grids (see https://cfconventions.org/)
128
+ - internal MCH models
129
+ - Unstructured grids
130
+
131
+ Typical spatial dimension and coordinate names are supported, and can be extended via environment variables:
132
+
133
+ - Y coordinate: export GRID_PROCESS__data_model__grid__y (e.g., '["y", "latitude", "lat", "dummy_y"]')
134
+ - X coordinate: export GRID_PROCESS__data_model__grid__x (e.g., '["x", "longitude", "lon", "dummy_x"]')
135
+ - Unstructured dimension: export GRID_PROCESS__data_model__grid__unstructured_dim (e.g., '["dummy_cell"]')
136
+ - When the spatial coordinates variables have a CF compliant `standard_name` attribute, arbitrary names can be
137
+ used directly.
138
+
139
+ Spatial CRS must be referenced via one of:
140
+
141
+ - Global attribute 'crs' (e.g., "EPSG:2056" or anything accepted by :meth:`pyproj.CRS.from_user_input`)
142
+ - Global attribute 'grid_mapping' (internal grid database convention)
143
+ - CF-convention grid mapping variable
144
+ - If coordinates are named 'latitude'/'longitude' or 'lat'/'lon', WGS84 (EPSG:4326) is assumed.
145
+ dst_grid_crs : string, int, dict or a CRS object
146
+ Anything accepted by pyproj.crs.CRS.from_user_input:
147
+ https://pyproj4.github.io/pyproj/dev/api/crs/crs.html#pyproj.crs.CRS.from_user_input
148
+ (e.g. EPSG:4326)
149
+ add_latlon : bool
150
+ If True, add latitude and longitude 2d arrays as coordinates
151
+
152
+ Returns
153
+ -------
154
+ dst_grid : xarray.Dataset
155
+ dataset with spatial horizontal coordinates and an associated crs attribute
156
+
157
+ See also
158
+ --------
159
+ :func:`interp2grid`
160
+ """
161
+
162
+ spatial_dims = dm.get_spatial_dims(src_grid)
163
+ src_grid = dm.set_grid_mapping(src_grid)
164
+ src_y, src_x, src_crs = dm.get_coords_name_crs(src_grid, spatial_dims)
165
+ dst_crs = CRS.from_user_input(dst_grid_crs)
166
+ x_min, x_max = src_grid[src_x].values.min(), src_grid[src_x].values.max()
167
+ y_min, y_max = src_grid[src_y].values.min(), src_grid[src_y].values.max()
168
+ x_bnds = (x_min, x_min, x_max, x_max)
169
+ y_bnds = (y_min, y_max, y_min, y_max)
170
+ x_bnds, y_bnds = reproject(x_bnds, y_bnds, src_crs, dst_crs)
171
+ x_bnds, y_bnds = (min(x_bnds), max(x_bnds)), (min(y_bnds), max(y_bnds))
172
+
173
+ if len(spatial_dims) == 1: # need to infer expected sizes for x and y dims
174
+ xy_ratio = (x_bnds[1] - x_bnds[0]) / (y_bnds[1] - y_bnds[0])
175
+ dst_x_size = round(np.sqrt(src_grid.sizes[spatial_dims[0]] * xy_ratio))
176
+ dst_y_size = round(np.sqrt(src_grid.sizes[spatial_dims[0]] / xy_ratio))
177
+ bool_decrease = False
178
+ else:
179
+ dst_x_size, dst_y_size = (
180
+ src_grid.sizes[spatial_dims[1]],
181
+ src_grid.sizes[spatial_dims[0]],
182
+ )
183
+ bool_decrease = dm.is_decreasing(src_grid[src_y], spatial_dims[0])
184
+
185
+ src_x_dtype, src_y_dtype = src_grid[src_x].dtype, src_grid[src_y].dtype
186
+ dst_x_dtype = src_x_dtype if src_x_dtype == np.float64 else np.float32
187
+ dst_y_dtype = src_y_dtype if src_y_dtype == np.float64 else np.float32
188
+ dst_x_coords = np.linspace(x_bnds[0], x_bnds[1], dst_x_size, dtype=dst_x_dtype)
189
+ dst_y_coords = np.linspace(y_bnds[0], y_bnds[1], dst_y_size, dtype=dst_y_dtype)
190
+ if bool_decrease:
191
+ dst_y_coords = dst_y_coords[::-1]
192
+
193
+ dst_grid = xr.Dataset(
194
+ coords={X_DIM: dst_x_coords, Y_DIM: dst_y_coords},
195
+ attrs={"crs": dst_grid_crs},
196
+ )
197
+
198
+ if add_latlon:
199
+ x_2d, y_2d = np.meshgrid(dst_x_coords, dst_y_coords)
200
+ longitude, latitude = reproject(x_2d, y_2d, dst_crs, "EPSG:4326")
201
+ dst_grid = dst_grid.assign_coords(
202
+ {
203
+ LON: ((Y_DIM, X_DIM), longitude.astype(dst_x_dtype, copy=False)),
204
+ LAT: ((Y_DIM, X_DIM), latitude.astype(dst_y_dtype, copy=False)),
205
+ }
206
+ )
207
+
208
+ return dst_grid
@@ -0,0 +1,174 @@
1
+ # Copyright (c) 2026 MeteoSwiss, contributors listed in AUTHORS
2
+ #
3
+ # Distributed under the terms of the BSD 3-Clause License.
4
+ #
5
+ # SPDX-License-Identifier: BSD-3-Clause
6
+
7
+ """
8
+ Module for general helper functions and decorators
9
+ """
10
+
11
+ import logging
12
+ import time
13
+ from functools import wraps
14
+ from typing import Any, overload
15
+ from collections.abc import Callable, Hashable
16
+
17
+ import xarray as xr
18
+ import numpy as np
19
+ from numpy.typing import NDArray
20
+ from pyproj import Transformer, CRS
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def timefn(func: Callable[..., Any]) -> Callable[..., Any]:
26
+ """
27
+ Generic decorator to log function processing times
28
+ """
29
+
30
+ @wraps(func)
31
+ def measure_time(*args: Any, **kwargs: Any) -> Any:
32
+ t0 = time.monotonic()
33
+ result = func(*args, **kwargs)
34
+ logger.debug("@timefn: %s took %.3f sec", func.__name__, time.monotonic() - t0)
35
+ return result
36
+
37
+ return measure_time
38
+
39
+
40
+ @overload
41
+ def make_accessor(func: Callable[..., xr.Dataset], accessor_class: type) -> Callable[..., xr.Dataset]: ...
42
+ @overload
43
+ def make_accessor(func: Callable[..., xr.DataArray], accessor_class: type) -> Callable[..., xr.DataArray]: ...
44
+
45
+
46
+ def make_accessor(
47
+ func: Callable[..., xr.Dataset | xr.DataArray], accessor_class: type
48
+ ) -> Callable[..., xr.Dataset | xr.DataArray]:
49
+ """
50
+ Generic decorator to turn functions operating on xarray objects into
51
+ accessors based on a registered dataset or dataarray ``accessor_class``
52
+ """
53
+
54
+ @wraps(func)
55
+ def method(accessor: xr.Dataset | xr.DataArray, *args: Any, **kwargs: Any) -> xr.Dataset | xr.DataArray:
56
+ return func(accessor._data, *args, **kwargs) # pylint: disable=protected-access # own accessor classes
57
+
58
+ setattr(accessor_class, func.__name__, method)
59
+ return func
60
+
61
+
62
+ def keepattrs(func: Callable[..., xr.Dataset]) -> Callable[..., xr.Dataset]:
63
+ """
64
+ Decorator to keep dataset and data variables attributes for functions
65
+ operating on xarray.Datasets
66
+ """
67
+
68
+ @wraps(func)
69
+ def wrapper(*args: xr.Dataset, **kwargs: Any) -> xr.Dataset:
70
+ vars_attrs = {name: da.attrs for name, da in args[0].data_vars.items()}
71
+ ds_attrs = args[0].attrs
72
+ ds_out = func(*args, **kwargs)
73
+ update_vars_attrs(ds_out, vars_attrs)
74
+ ds_out.attrs.update(ds_attrs)
75
+ return ds_out
76
+
77
+ return wrapper
78
+
79
+
80
+ def update_vars_attrs(ds: xr.Dataset, vars_attrs: dict[Hashable, dict[Hashable, Any]]) -> None:
81
+ """
82
+ Updates data variables attributes in a xarray.Dataset. The operation is
83
+ done "in-place".
84
+
85
+ Parameters
86
+ ----------
87
+ ds : xarray.Dataset
88
+ can contain one or several data variables
89
+ vars_attrs : dict
90
+ contains the attributes of each data variables in the input dataset
91
+ """
92
+
93
+ for varname, attributes in vars_attrs.items():
94
+ ds[varname].attrs.update(attributes)
95
+
96
+
97
+ def replace_vars_attrs(ds: xr.Dataset, vars_attrs: dict[str, dict]) -> None:
98
+ """
99
+ Replaces data variables attributes in a xarray.Dataset. The operation is
100
+ done "in-place".
101
+
102
+ Parameters
103
+ ----------
104
+ ds : xarray.Dataset
105
+ can contain one or several data variables
106
+ vars_attrs : dict
107
+ contains the new attributes of each data variables in the input dataset
108
+ to be replaced
109
+ """
110
+
111
+ for varname, attributes in vars_attrs.items():
112
+ ds[varname].attrs = attributes
113
+
114
+
115
+ @overload
116
+ def reproject(
117
+ x_coords: NDArray[np.floating],
118
+ y_coords: NDArray[np.floating],
119
+ src_crs: CRS | str,
120
+ dst_crs: CRS | str,
121
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ...
122
+ @overload
123
+ def reproject(
124
+ x_coords: list[float], y_coords: list[float], src_crs: CRS | str, dst_crs: CRS | str
125
+ ) -> tuple[list[float], list[float]]: ...
126
+ @overload
127
+ def reproject(
128
+ x_coords: tuple[float, ...],
129
+ y_coords: tuple[float, ...],
130
+ src_crs: CRS | str,
131
+ dst_crs: CRS | str,
132
+ ) -> tuple[tuple[float, ...], tuple[float, ...]]: ...
133
+
134
+
135
+ def reproject(
136
+ x_coords: NDArray[np.floating] | list[float] | tuple[float, ...],
137
+ y_coords: NDArray[np.floating] | list[float] | tuple[float, ...],
138
+ src_crs: CRS | str,
139
+ dst_crs: CRS | str,
140
+ ) -> (
141
+ tuple[NDArray[np.float64], NDArray[np.float64]]
142
+ | tuple[list[float], list[float]]
143
+ | tuple[tuple[float, ...], tuple[float, ...]]
144
+ ):
145
+ """
146
+ Reprojects `x` and `y` coordinates from a source CRS to a new specified
147
+ one.
148
+
149
+ Parameters
150
+ ----------
151
+ x_coords : numpy.ndarray, list or tuple
152
+ contains `x` coordinates.
153
+ y_coords : numpy.ndarray, list or tuple
154
+ contains `y` coordinates
155
+ src_crs : pyproj.CRS of the source or equivalent string
156
+ The value can be anything accepted by
157
+ pyproj.CRS.from_user_input, such as an authority string
158
+ (e.g. "EPSG:4326") or a WKT string
159
+ dst_crs : pyproj.CRS of the destination or equivalent string
160
+ The value can be anything accepted by
161
+ pyproj.CRS.from_user_input, such as an authority string
162
+ (eg "EPSG:4326") or a WKT string
163
+
164
+ Returns
165
+ -------
166
+ x_coords_new : numpy.ndarray, list or tuple of float64
167
+ same as input ``x_coords`` in the new CRS and cast as float64
168
+ y_coords_new : numpy.ndarray, list or tuple of float64
169
+ same as input ``y_coords`` in the new CRS and cast as float64
170
+ """
171
+
172
+ transformer = Transformer.from_crs(src_crs, dst_crs, always_xy=True)
173
+
174
+ return transformer.transform(x_coords, y_coords)
@@ -0,0 +1,50 @@
1
+ # Copyright (c) 2026 MeteoSwiss, contributors listed in AUTHORS
2
+ #
3
+ # Distributed under the terms of the BSD 3-Clause License.
4
+ #
5
+ # SPDX-License-Identifier: BSD-3-Clause
6
+
7
+ """
8
+ Module for optimization of computations
9
+ """
10
+
11
+ import logging
12
+ from functools import wraps
13
+ from typing import Any, TypeVar, cast
14
+ from collections.abc import Callable, Hashable
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ F = TypeVar("F", bound=Callable[..., Any])
19
+
20
+
21
+ def memoize(maxsize: int = 10) -> Callable[[F], F]:
22
+ """
23
+ LRU cache decorator implementation for any arbitrary input since the
24
+ caching is purely based on the optional keyword argument ``hkey``.
25
+ """
26
+
27
+ def real_memoize(func: F) -> F:
28
+ cache: dict[Hashable, Any] = {}
29
+ hkeys = []
30
+
31
+ @wraps(func)
32
+ def _memoize(*args: Any, **kwargs: Any) -> Any:
33
+ hkey = kwargs.pop("hkey", None)
34
+ if hkey in cache:
35
+ logger.debug("@memoize: %s cached!", func.__name__)
36
+ return cache[hkey]
37
+ logger.debug("@memoize: %s computing ...", func.__name__)
38
+ result = func(*args, **kwargs)
39
+ if hkey is not None:
40
+ cache[hkey] = result
41
+ hkeys.append(hkey)
42
+ if len(hkeys) > maxsize:
43
+ cache.pop(hkeys.pop(0))
44
+ logger.debug("@memoize: %s cache size is %s}", func.__name__, len(cache))
45
+
46
+ return result
47
+
48
+ return cast(F, _memoize)
49
+
50
+ return real_memoize