Coverage for src/CSET/operators/regrid.py: 99%
153 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 14:01 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 14:01 +0000
1# © Crown copyright, Met Office (2022-2024) and CSET contributors.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
15"""Operators to regrid cubes."""
17import logging
18import warnings
20import iris
21import iris.coord_systems
22import iris.cube
23import numpy as np
24from iris.analysis.cartography import rotate_pole
26from CSET._common import iter_maybe
27from CSET.operators._utils import get_cube_yxcoordname
29logger = logging.getLogger(__name__)
32class BoundaryWarning(UserWarning):
33 """Selected gridpoint is close to the domain edge.
35 In many cases gridpoints near the domain boundary contain non-physical
36 values, so caution is advised when interpreting them.
37 """
40def regrid_onto_cube(
41 toregrid: iris.cube.Cube | iris.cube.CubeList,
42 target: iris.cube.Cube,
43 method: str,
44 **kwargs,
45) -> iris.cube.Cube | iris.cube.CubeList:
46 """Regrid a cube or CubeList, projecting onto a target cube.
48 All cubes must have at least 2 spatial (map) dimensions.
50 Arguments
51 ----------
52 toregrid: iris.cube | iris.cube.CubeList
53 An iris Cube of data to regrid, or multiple cubes to regrid in a
54 CubeList. A minimum requirement is that the cube(s) need to be 2D with a
55 latitude, longitude coordinates.
56 target: Cube
57 An iris cube of the data to regrid onto. It needs to be 2D with a
58 latitude, longitude coordinate.
59 method: str
60 Method used to regrid onto, etc. Linear will use iris.analysis.Linear()
62 Returns
63 -------
64 iris.cube | iris.cube.CubeList
65 An iris cube of the data that has been regridded, or a CubeList of the
66 cubes that have been regridded in the same order they were passed in
67 toregrid.
69 Raises
70 ------
71 ValueError
72 If a unique x/y coordinate cannot be found
73 NotImplementedError
74 If the cubes grid, or the method for regridding, is not yet supported.
76 Notes
77 -----
78 Currently rectlinear grids (uniform) are supported.
79 """
80 # To store regridded cubes.
81 regridded_cubes = iris.cube.CubeList()
83 # Iterate over all cubes and regrid.
84 for cube in iter_maybe(toregrid):
85 # Get y,x coord names
86 y_coord, x_coord = get_cube_yxcoordname(cube)
88 # List of supported grids - check if it is compatible
89 supported_grids = (iris.coord_systems.GeogCS, iris.coord_systems.RotatedGeogCS)
90 if not isinstance(cube.coord(x_coord).coord_system, supported_grids):
91 raise NotImplementedError(
92 f"Does not currently support {cube.coord(x_coord).coord_system} coordinate system"
93 )
94 if not isinstance(cube.coord(y_coord).coord_system, supported_grids):
95 raise NotImplementedError(
96 f"Does not currently support {cube.coord(y_coord).coord_system} coordinate system"
97 )
99 regrid_method = getattr(iris.analysis, method, None)
100 if callable(regrid_method):
101 regridded_cubes.append(cube.regrid(target, regrid_method()))
102 else:
103 raise NotImplementedError(
104 f"Does not currently support {method} regrid method"
105 )
107 # Preserve returning a cube if only a cube has been supplied to regrid.
108 if len(regridded_cubes) == 1:
109 return regridded_cubes[0]
110 else:
111 return regridded_cubes
114def regrid_onto_xyspacing(
115 toregrid: iris.cube.Cube | iris.cube.CubeList,
116 xspacing: float,
117 yspacing: float,
118 method: str,
119 **kwargs,
120) -> iris.cube.Cube | iris.cube.CubeList:
121 """Regrid cube or cubelist onto a set x,y spacing.
123 Regrid cube(s) using specified x,y spacing, which is performed linearly.
125 Parameters
126 ----------
127 toregrid: iris.cube | iris.cube.CubeList
128 An iris cube of the data to regrid, or multiple cubes to regrid in a
129 cubelist. A minimum requirement is that the cube(s) need to be 2D with a
130 latitude, longitude coordinates.
131 xspacing: float
132 Spacing of points in longitude direction (could be degrees, meters etc.)
133 yspacing: float
134 Spacing of points in latitude direction (could be degrees, meters etc.)
135 method: str
136 Method used to regrid onto, etc. Linear will use iris.analysis.Linear()
138 Returns
139 -------
140 iris.cube | iris.cube.CubeList
141 An iris cube of the data that has been regridded, or a cubelist of the
142 cubes that have been regridded in the same order they were passed in
143 toregrid.
145 Raises
146 ------
147 ValueError
148 If a unique x/y coordinate cannot be found
149 NotImplementedError
150 If the cubes grid, or the method for regridding, is not yet supported.
152 Notes
153 -----
154 Currently rectlinear grids (uniform) are supported.
156 """
157 # To store regridded cubes.
158 regridded_cubes = iris.cube.CubeList()
160 # Iterate over all cubes and regrid.
161 for cube in iter_maybe(toregrid):
162 # Get x,y coord names
163 y_coord, x_coord = get_cube_yxcoordname(cube)
165 # List of supported grids - check if it is compatible
166 supported_grids = (iris.coord_systems.GeogCS, iris.coord_systems.RotatedGeogCS)
167 if not isinstance(cube.coord(x_coord).coord_system, supported_grids):
168 raise NotImplementedError(
169 f"Does not currently support {cube.coord(x_coord).coord_system} regrid method"
170 )
171 if not isinstance(cube.coord(y_coord).coord_system, supported_grids):
172 raise NotImplementedError(
173 f"Does not currently support {cube.coord(y_coord).coord_system} regrid method"
174 )
176 # Get axis
177 lat, lon = cube.coord(y_coord), cube.coord(x_coord)
179 # Get bounds
180 lat_min, lon_min = lat.points.min(), lon.points.min()
181 lat_max, lon_max = lat.points.max(), lon.points.max()
183 # Generate new mesh
184 latout = np.arange(lat_min, lat_max, yspacing)
185 lonout = np.arange(lon_min, lon_max, xspacing)
187 regrid_method = getattr(iris.analysis, method, None)
188 if callable(regrid_method):
189 regridded_cubes.append(
190 cube.interpolate(
191 [(y_coord, latout), (x_coord, lonout)], regrid_method()
192 )
193 )
194 else:
195 raise NotImplementedError(
196 f"Does not currently support {method} regrid method"
197 )
199 # Preserve returning a cube if only a cube has been supplied to regrid.
200 if len(regridded_cubes) == 1:
201 return regridded_cubes[0]
202 else:
203 return regridded_cubes
206def regrid_to_single_point(
207 cubes: iris.cube.Cube | iris.cube.CubeList,
208 lat_pt: float,
209 lon_pt: float,
210 latlon_in_type: str = "rotated",
211 method: str = "Nearest",
212 boundary_margin: int = 8,
213 **kwargs,
214) -> iris.cube.Cube:
215 """Select data at a single point by longitude and latitude.
217 Selection of model grid point is performed by a regrid function, either
218 selecting the nearest gridpoint to the selected longitude and latitude
219 values or using linear interpolation across the surrounding points.
221 Parameters
222 ----------
223 cubes: Cube | CubeList
224 An iris cube or CubeList of the data to regrid. As a minimum, it needs
225 to be 2D with latitude, longitude coordinates.
226 lon_pt: float
227 Selected value of longitude: this should be in the range -180 degrees to
228 180 degrees.
229 lat_pt: float
230 Selected value of latitude: this should be in the range -90 degrees to
231 90 degrees.
232 latlon_in_type: str, optional
233 Specify whether the input longitude and latitude point is in standard
234 geographic realworld coordinates ("realworld") or on the rotated grid
235 of the cube ("rotated"). Default is "rotated".
236 method: str
237 Method used to determine the values at the selected longitude and
238 latitude. The recommended approach is to use iris.analysis.Nearest(),
239 which selects the nearest gridpoint. An alternative is
240 iris.analysis.Linear(), which obtains the values at the selected
241 longitude and latitude by linear interpolation.
242 boundary_margin: int, optional
243 Number of grid points from the domain boundary considered "unreliable".
244 Defaults to 8.
246 Returns
247 -------
248 regridded_cubes: Cube | CubeList
249 An iris cube or CubeList of the data at the specified point (this may
250 have time and/or height dimensions).
252 Raises
253 ------
254 ValueError
255 If a unique x/y coordinate cannot be found; also if, for selecting a
256 single gridpoint, the chosen longitude and latitude point is outside the
257 domain; also (currently) if the difference between the actual and target
258 points exceed 0.1 degrees.
259 NotImplementedError
260 If the cubes grid, or the method for regridding, is not yet supported.
262 Notes
263 -----
264 The acceptable coordinate names for X and Y coordinates are currently
265 described in X_COORD_NAMES and Y_COORD_NAMES. These cover commonly used
266 coordinate types, though a user can append new ones. Currently rectilinear
267 grids (uniform) are supported. Warnings are raised if the selected gridpoint
268 is within boundary_margin grid lengths of the domain boundary as data here
269 is potentially unreliable.
270 """
271 # To store regridded cubes.
272 regridded_cubes = iris.cube.CubeList()
274 # Iterate over all cubes and regrid.
275 for cube in iter_maybe(cubes):
276 # Get x and y coordinate names.
277 y_coord, x_coord = get_cube_yxcoordname(cube)
279 # List of supported grids - check if it is compatible
280 # NOTE: The "RotatedGeogCS" option below seems to be required for rotated grids --
281 # this may need to be added in other places in these Operators.
282 supported_grids = (iris.coord_systems.GeogCS, iris.coord_systems.RotatedGeogCS)
283 if not isinstance(cube.coord(x_coord).coord_system, supported_grids):
284 raise NotImplementedError(
285 f"Does not currently support {cube.coord(x_coord).coord_system} regrid method"
286 )
287 if not isinstance(cube.coord(y_coord).coord_system, supported_grids):
288 raise NotImplementedError(
289 f"Does not currently support {cube.coord(y_coord).coord_system} regrid method"
290 )
292 # Transform input coordinates onto rotated grid if requested
293 if latlon_in_type == "realworld":
294 lon_tr, lat_tr = transform_lat_long_points(lon_pt, lat_pt, cube)
295 elif latlon_in_type == "rotated": 295 ↛ 299line 295 didn't jump to line 299 because the condition on line 295 was always true
296 lon_tr, lat_tr = lon_pt, lat_pt
298 # Get axis
299 lat, lon = cube.coord(y_coord), cube.coord(x_coord)
301 # Get bounds
302 lat_min, lon_min = lat.points.min(), lon.points.min()
303 lat_max, lon_max = lat.points.max(), lon.points.max()
305 # Use different logic for single point obs data.
306 if len(cube.coord(x_coord).points) > 1:
307 # Get boundaries of frame to avoid selecting gridpoint close to domain edge
308 lat_min_bound, lon_min_bound = (
309 lat.points[boundary_margin - 1],
310 lon.points[boundary_margin - 1],
311 )
312 lat_max_bound, lon_max_bound = (
313 lat.points[-boundary_margin],
314 lon.points[-boundary_margin],
315 )
317 # Check to see if selected point is outside the domain
318 if (lat_tr < lat_min) or (lat_tr > lat_max):
319 raise ValueError("Selected point is outside the domain.")
320 else:
321 if (lon_tr < lon_min) or (lon_tr > lon_max):
322 if (lon_tr + 360.0 >= lon_min) and (lon_tr + 360.0 <= lon_max):
323 lon_tr += 360.0
324 elif (lon_tr - 360.0 >= lon_min) and (lon_tr - 360.0 <= lon_max):
325 lon_tr -= 360.0
326 else:
327 raise ValueError("Selected point is outside the domain.")
329 # Check to see if selected point is near the domain boundaries
330 if (
331 (lat_tr < lat_min_bound)
332 or (lat_tr > lat_max_bound)
333 or (lon_tr < lon_min_bound)
334 or (lon_tr > lon_max_bound)
335 ):
336 warnings.warn(
337 f"Selected point is within {boundary_margin} gridlengths of the domain edge, data may be unreliable.",
338 category=BoundaryWarning,
339 stacklevel=2,
340 )
342 regrid_method = getattr(iris.analysis, method, None)
343 if not callable(regrid_method):
344 raise NotImplementedError(
345 f"Does not currently support {method} regrid method"
346 )
348 cube_rgd = cube.interpolate(((lat, lat_tr), (lon, lon_tr)), regrid_method())
349 regridded_cubes.append(cube_rgd)
350 else:
351 if (
352 np.abs(lat_tr - lat.points[0]) > 0.1
353 or np.abs(lon_tr - lon.points[0]) > 0.1
354 ):
355 raise ValueError(
356 "Selected point is too far from the specified coordinates. It should be within 0.1 degrees."
357 )
358 else:
359 print(
360 "*** lat/long diffs",
361 np.abs(lat_tr - lat_pt),
362 np.abs(lon_tr - lon_pt),
363 )
364 regridded_cubes.append(cube)
366 # Preserve returning a cube if only a cube has been supplied to regrid.
367 if len(regridded_cubes) == 1:
368 return regridded_cubes[0]
369 else:
370 return regridded_cubes
373def transform_lat_long_points(lon, lat, cube):
374 """Transform a selected point in longitude and latitude.
376 Transform the coordinates of a point from the real world
377 grid to the corresponding point on the rotated grid of a cube.
379 Parameters
380 ----------
381 cube: Cube
382 An iris cube of data defining the rotated grid to be used in
383 the longitude-latitude transformation.
384 lon: float
385 Selected value of longitude: this should be in the range -180 degrees to
386 180 degrees.
387 lat: float
388 Selected value of latitude: this should be in the range -90 degrees to
389 90 degrees.
391 Returns
392 -------
393 lon_rot, lat_rot: float
394 Coordinates of the selected point on the rotated grid specified within
395 the selected cube.
397 """
398 import cartopy.crs as ccrs
400 rot_pole = cube.coord_system().as_cartopy_crs()
401 true_grid = ccrs.Geodetic()
402 rot_coords = rot_pole.transform_point(lon, lat, true_grid)
403 lon_rot = rot_coords[0]
404 lat_rot = rot_coords[1]
406 return lon_rot, lat_rot
409def interpolate_to_point_cube(
410 fld: iris.cube.Cube | iris.cube.CubeList,
411 point_cube: iris.cube.Cube | iris.cube.CubeList,
412 **kwargs,
413) -> iris.cube.Cube | iris.cube.CubeList:
414 """Interpolate a 2D field in cube or CubeList to a set of points.
416 Regrid cube(s) to set of sample points specified by point_cube.
417 Ensures only matching times between input and point_cube are included.
419 Parameters
420 ----------
421 fld: Cube | CubeList
422 An iris cube or CubeList containing a two-dimensional field(s).
423 point_cube: Cube
424 An iris cube specifying the coord point(s) to which the data
425 will be interpolated.
427 Returns
428 -------
429 fld_point_cube: Cube | CubeList
430 An iris cube or CubeList containing interpolated values at the
431 points specified by the point cube for matching times common to
432 both fld and point_cube.
433 """
434 # Empty CubeList To store regridded cubes.
435 regridded_cubes = iris.cube.CubeList()
437 # Generate array of point cube lat and lon points.
438 point_lat_name, point_lon_name = get_cube_yxcoordname(point_cube)
439 point_lats = point_cube.coord(point_lat_name).points
440 point_lons = point_cube.coord(point_lon_name).points
442 # Iterate over all cubes and regrid.
443 for cube in iter_maybe(fld):
444 # Get forecast field cube spatial names.
445 y_coord, x_coord = get_cube_yxcoordname(cube)
447 # Rotate point_cube coords if required to match model coord rotation.
448 if (
449 isinstance(
450 cube.coord(x_coord).coord_system, iris.coord_systems.RotatedGeogCS
451 )
452 and point_lat_name == "latitude"
453 and point_lon_name == "longitude"
454 ):
455 point_lons_rp, point_lats_rp = rotate_pole(
456 point_lons,
457 point_lats,
458 pole_lon=cube.coord(x_coord).coord_system.grid_north_pole_longitude,
459 pole_lat=cube.coord(x_coord).coord_system.grid_north_pole_latitude,
460 )
461 sample_points = [
462 ("grid_latitude", point_lats_rp),
463 ("grid_longitude", point_lons_rp),
464 ]
465 # Default, sample points based on point cube dimensions
466 else:
467 sample_points = [
468 (point_lat_name, np.array(point_lats)),
469 (point_lon_name, np.array(point_lons)),
470 ]
472 # Interpolate fld cube to required sample points
473 fld_point_cube = cube.interpolate(
474 sample_points, iris.analysis.Linear(extrapolation_mode="mask")
475 )
477 diag_data = np.diagonal(
478 fld_point_cube.data,
479 axis1=-2,
480 axis2=-1,
481 )
483 fv_cube = iris.cube.Cube(
484 diag_data,
485 standard_name=cube.standard_name,
486 long_name=cube.long_name,
487 units=cube.units,
488 )
490 # Add all non-horizontal dimension coordinates
491 # from the forecast cube.
492 out_dim = 0
494 for coord in cube.coords(dim_coords=True):
495 if coord.name() in (
496 "latitude",
497 "longitude",
498 "grid_latitude",
499 "grid_longitude",
500 ):
501 continue
503 fv_cube.add_dim_coord(
504 coord.copy(),
505 out_dim,
506 )
508 out_dim += 1
510 # Station dimension is always last.
511 station_dim = out_dim
513 fv_cube.add_dim_coord(
514 point_cube.coord("station").copy(),
515 station_dim,
516 )
518 # Copy forecast auxiliary coordinates.
519 for coord in cube.coords():
520 if coord.name() in (
521 "latitude",
522 "longitude",
523 "grid_latitude",
524 "grid_longitude",
525 ):
526 continue
528 if coord.name() in [c.name() for c in fv_cube.coords()]:
529 continue
531 dims = cube.coord_dims(coord)
533 if dims:
534 fv_cube.add_aux_coord(
535 coord.copy(),
536 dims,
537 )
538 else:
539 fv_cube.add_aux_coord(
540 coord.copy(),
541 )
543 # Copy observation auxiliary coordinates.
544 for coord in point_cube.coords():
545 if coord.name() in (
546 "station",
547 "forecast_period",
548 "forecast_reference_time",
549 ):
550 continue
552 if coord.name() in [c.name() for c in fv_cube.coords()]:
553 continue
555 dims = point_cube.coord_dims(coord)
557 if dims: 557 ↛ 563line 557 didn't jump to line 563 because the condition on line 557 was always true
558 fv_cube.add_aux_coord(
559 coord.copy(),
560 dims,
561 )
562 else:
563 fv_cube.add_aux_coord(
564 coord.copy(),
565 )
567 fv_cube.attributes = cube.attributes.copy()
568 fv_cube.cell_methods = cube.cell_methods
569 fv_cube.units = cube.units
571 regridded_cubes.append(fv_cube)
573 # Preserve returning a cube if only a cube supplied.
574 if len(regridded_cubes) == 1:
575 return regridded_cubes[0]
577 return regridded_cubes
580def vertical_interpolation(
581 cubes: iris.cube.Cube | iris.cube.CubeList,
582 coordinate: str,
583 target: iris.cube.Cube | iris.cube.CubeList,
584) -> iris.cube.Cube | iris.cube.CubeList:
585 """Vertical interpolation of a cube to match that off a different cube.
587 Acts as a wrapper around the `cube.interpolate` functionality and uses
588 linear interpolation as the method.
590 Parameters
591 ----------
592 cubes: iris.cube.Cube | iris.cube.CubeList
593 An iris cube or cubelist of data defining field that should be
594 vertically interpolated.
595 coordinate: str
596 The coordinate the interpolation occurs over.
597 target: iris.cube.Cube | iris.cube.CubeList
598 The target cube or cubelist that provides the vertical coordinate
599 information. It will use `cube.coord(coordinate).points` to provide
600 the vertical target. The number of target cubes should match the number
601 of cubes used as input.
603 Returns
604 -------
605 interpolated_cubes: iris.cube.Cube | iris.cube.CubeList
606 Coordinates of the selected point on the rotated grid specified within
607 the selected cube.
608 """
609 interpolated_cubes = iris.cube.CubeList([])
610 for cube, cube_t in zip(iter_maybe(cubes), iter_maybe(target), strict=True):
611 target_levels = cube_t.coord(coordinate).points
612 new_cube = cube.interpolate(
613 [(coordinate, target_levels)], iris.analysis.Linear()
614 )
615 interpolated_cubes.append(new_cube)
616 if len(interpolated_cubes) == 1:
617 return interpolated_cubes[0]
618 else:
619 return interpolated_cubes