Coverage for src / CSET / operators / regrid.py: 71%

135 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-08 17:20 +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. 

14 

15"""Operators to regrid cubes.""" 

16 

17import logging 

18import warnings 

19 

20import iris 

21import iris.coord_systems 

22import iris.cube 

23import numpy as np 

24 

25from CSET._common import iter_maybe 

26from CSET.operators._utils import get_cube_yxcoordname 

27 

28 

29class BoundaryWarning(UserWarning): 

30 """Selected gridpoint is close to the domain edge. 

31 

32 In many cases gridpoints near the domain boundary contain non-physical 

33 values, so caution is advised when interpreting them. 

34 """ 

35 

36 

37def regrid_onto_cube( 

38 toregrid: iris.cube.Cube | iris.cube.CubeList, 

39 target: iris.cube.Cube, 

40 method: str, 

41 **kwargs, 

42) -> iris.cube.Cube | iris.cube.CubeList: 

43 """Regrid a cube or CubeList, projecting onto a target cube. 

44 

45 All cubes must have at least 2 spatial (map) dimensions. 

46 

47 Arguments 

48 ---------- 

49 toregrid: iris.cube | iris.cube.CubeList 

50 An iris Cube of data to regrid, or multiple cubes to regrid in a 

51 CubeList. A minimum requirement is that the cube(s) need to be 2D with a 

52 latitude, longitude coordinates. 

53 target: Cube 

54 An iris cube of the data to regrid onto. It needs to be 2D with a 

55 latitude, longitude coordinate. 

56 method: str 

57 Method used to regrid onto, etc. Linear will use iris.analysis.Linear() 

58 

59 Returns 

60 ------- 

61 iris.cube | iris.cube.CubeList 

62 An iris cube of the data that has been regridded, or a CubeList of the 

63 cubes that have been regridded in the same order they were passed in 

64 toregrid. 

65 

66 Raises 

67 ------ 

68 ValueError 

69 If a unique x/y coordinate cannot be found 

70 NotImplementedError 

71 If the cubes grid, or the method for regridding, is not yet supported. 

72 

73 Notes 

74 ----- 

75 Currently rectlinear grids (uniform) are supported. 

76 """ 

77 # To store regridded cubes. 

78 regridded_cubes = iris.cube.CubeList() 

79 

80 # Iterate over all cubes and regrid. 

81 for cube in iter_maybe(toregrid): 

82 # Get y,x coord names 

83 y_coord, x_coord = get_cube_yxcoordname(cube) 

84 

85 # List of supported grids - check if it is compatible 

86 supported_grids = (iris.coord_systems.GeogCS, iris.coord_systems.RotatedGeogCS) 

87 if not isinstance(cube.coord(x_coord).coord_system, supported_grids): 

88 raise NotImplementedError( 

89 f"Does not currently support {cube.coord(x_coord).coord_system} coordinate system" 

90 ) 

91 if not isinstance(cube.coord(y_coord).coord_system, supported_grids): 

92 raise NotImplementedError( 

93 f"Does not currently support {cube.coord(y_coord).coord_system} coordinate system" 

94 ) 

95 

96 regrid_method = getattr(iris.analysis, method, None) 

97 if callable(regrid_method): 

98 regridded_cubes.append(cube.regrid(target, regrid_method())) 

99 else: 

100 raise NotImplementedError( 

101 f"Does not currently support {method} regrid method" 

102 ) 

103 

104 # Preserve returning a cube if only a cube has been supplied to regrid. 

105 if len(regridded_cubes) == 1: 

106 return regridded_cubes[0] 

107 else: 

108 return regridded_cubes 

109 

110 

111def regrid_onto_xyspacing( 

112 toregrid: iris.cube.Cube | iris.cube.CubeList, 

113 xspacing: float, 

114 yspacing: float, 

115 method: str, 

116 **kwargs, 

117) -> iris.cube.Cube | iris.cube.CubeList: 

118 """Regrid cube or cubelist onto a set x,y spacing. 

119 

120 Regrid cube(s) using specified x,y spacing, which is performed linearly. 

121 

122 Parameters 

123 ---------- 

124 toregrid: iris.cube | iris.cube.CubeList 

125 An iris cube of the data to regrid, or multiple cubes to regrid in a 

126 cubelist. A minimum requirement is that the cube(s) need to be 2D with a 

127 latitude, longitude coordinates. 

128 xspacing: float 

129 Spacing of points in longitude direction (could be degrees, meters etc.) 

130 yspacing: float 

131 Spacing of points in latitude direction (could be degrees, meters etc.) 

132 method: str 

133 Method used to regrid onto, etc. Linear will use iris.analysis.Linear() 

134 

135 Returns 

136 ------- 

137 iris.cube | iris.cube.CubeList 

138 An iris cube of the data that has been regridded, or a cubelist of the 

139 cubes that have been regridded in the same order they were passed in 

140 toregrid. 

141 

142 Raises 

143 ------ 

144 ValueError 

145 If a unique x/y coordinate cannot be found 

146 NotImplementedError 

147 If the cubes grid, or the method for regridding, is not yet supported. 

148 

149 Notes 

150 ----- 

151 Currently rectlinear grids (uniform) are supported. 

152 

153 """ 

154 # To store regridded cubes. 

155 regridded_cubes = iris.cube.CubeList() 

156 

157 # Iterate over all cubes and regrid. 

158 for cube in iter_maybe(toregrid): 

159 # Get x,y coord names 

160 y_coord, x_coord = get_cube_yxcoordname(cube) 

161 

162 # List of supported grids - check if it is compatible 

163 supported_grids = (iris.coord_systems.GeogCS, iris.coord_systems.RotatedGeogCS) 

164 if not isinstance(cube.coord(x_coord).coord_system, supported_grids): 

165 raise NotImplementedError( 

166 f"Does not currently support {cube.coord(x_coord).coord_system} regrid method" 

167 ) 

168 if not isinstance(cube.coord(y_coord).coord_system, supported_grids): 

169 raise NotImplementedError( 

170 f"Does not currently support {cube.coord(y_coord).coord_system} regrid method" 

171 ) 

172 

173 # Get axis 

174 lat, lon = cube.coord(y_coord), cube.coord(x_coord) 

175 

176 # Get bounds 

177 lat_min, lon_min = lat.points.min(), lon.points.min() 

178 lat_max, lon_max = lat.points.max(), lon.points.max() 

179 

180 # Generate new mesh 

181 latout = np.arange(lat_min, lat_max, yspacing) 

182 lonout = np.arange(lon_min, lon_max, xspacing) 

183 

184 regrid_method = getattr(iris.analysis, method, None) 

185 if callable(regrid_method): 

186 regridded_cubes.append( 

187 cube.interpolate( 

188 [(y_coord, latout), (x_coord, lonout)], regrid_method() 

189 ) 

190 ) 

191 else: 

192 raise NotImplementedError( 

193 f"Does not currently support {method} regrid method" 

194 ) 

195 

196 # Preserve returning a cube if only a cube has been supplied to regrid. 

197 if len(regridded_cubes) == 1: 

198 return regridded_cubes[0] 

199 else: 

200 return regridded_cubes 

201 

202 

203def regrid_to_single_point( 

204 cubes: iris.cube.Cube | iris.cube.CubeList, 

205 lat_pt: float, 

206 lon_pt: float, 

207 latlon_in_type: str = "rotated", 

208 method: str = "Nearest", 

209 boundary_margin: int = 8, 

210 **kwargs, 

211) -> iris.cube.Cube: 

212 """Select data at a single point by longitude and latitude. 

213 

214 Selection of model grid point is performed by a regrid function, either 

215 selecting the nearest gridpoint to the selected longitude and latitude 

216 values or using linear interpolation across the surrounding points. 

217 

218 Parameters 

219 ---------- 

220 cubes: Cube | CubeList 

221 An iris cube or CubeList of the data to regrid. As a minimum, it needs 

222 to be 2D with latitude, longitude coordinates. 

223 lon_pt: float 

224 Selected value of longitude: this should be in the range -180 degrees to 

225 180 degrees. 

226 lat_pt: float 

227 Selected value of latitude: this should be in the range -90 degrees to 

228 90 degrees. 

229 latlon_in_type: str, optional 

230 Specify whether the input longitude and latitude point is in standard 

231 geographic realworld coordinates ("realworld") or on the rotated grid 

232 of the cube ("rotated"). Default is "rotated". 

233 method: str 

234 Method used to determine the values at the selected longitude and 

235 latitude. The recommended approach is to use iris.analysis.Nearest(), 

236 which selects the nearest gridpoint. An alternative is 

237 iris.analysis.Linear(), which obtains the values at the selected 

238 longitude and latitude by linear interpolation. 

239 boundary_margin: int, optional 

240 Number of grid points from the domain boundary considered "unreliable". 

241 Defaults to 8. 

242 

243 Returns 

244 ------- 

245 regridded_cubes: Cube | CubeList 

246 An iris cube or CubeList of the data at the specified point (this may 

247 have time and/or height dimensions). 

248 

249 Raises 

250 ------ 

251 ValueError 

252 If a unique x/y coordinate cannot be found; also if, for selecting a 

253 single gridpoint, the chosen longitude and latitude point is outside the 

254 domain; also (currently) if the difference between the actual and target 

255 points exceed 0.1 degrees. 

256 NotImplementedError 

257 If the cubes grid, or the method for regridding, is not yet supported. 

258 

259 Notes 

260 ----- 

261 The acceptable coordinate names for X and Y coordinates are currently 

262 described in X_COORD_NAMES and Y_COORD_NAMES. These cover commonly used 

263 coordinate types, though a user can append new ones. Currently rectilinear 

264 grids (uniform) are supported. Warnings are raised if the selected gridpoint 

265 is within boundary_margin grid lengths of the domain boundary as data here 

266 is potentially unreliable. 

267 """ 

268 # To store regridded cubes. 

269 regridded_cubes = iris.cube.CubeList() 

270 

271 # Iterate over all cubes and regrid. 

272 for cube in iter_maybe(cubes): 

273 # Get x and y coordinate names. 

274 y_coord, x_coord = get_cube_yxcoordname(cube) 

275 

276 # List of supported grids - check if it is compatible 

277 # NOTE: The "RotatedGeogCS" option below seems to be required for rotated grids -- 

278 # this may need to be added in other places in these Operators. 

279 supported_grids = (iris.coord_systems.GeogCS, iris.coord_systems.RotatedGeogCS) 

280 if not isinstance(cube.coord(x_coord).coord_system, supported_grids): 

281 raise NotImplementedError( 

282 f"Does not currently support {cube.coord(x_coord).coord_system} regrid method" 

283 ) 

284 if not isinstance(cube.coord(y_coord).coord_system, supported_grids): 

285 raise NotImplementedError( 

286 f"Does not currently support {cube.coord(y_coord).coord_system} regrid method" 

287 ) 

288 

289 # Transform input coordinates onto rotated grid if requested 

290 if latlon_in_type == "realworld": 

291 lon_tr, lat_tr = transform_lat_long_points(lon_pt, lat_pt, cube) 

292 elif latlon_in_type == "rotated": 292 ↛ 296line 292 didn't jump to line 296 because the condition on line 292 was always true

293 lon_tr, lat_tr = lon_pt, lat_pt 

294 

295 # Get axis 

296 lat, lon = cube.coord(y_coord), cube.coord(x_coord) 

297 

298 # Get bounds 

299 lat_min, lon_min = lat.points.min(), lon.points.min() 

300 lat_max, lon_max = lat.points.max(), lon.points.max() 

301 

302 # Use different logic for single point obs data. 

303 if len(cube.coord(x_coord).points) > 1: 

304 # Get boundaries of frame to avoid selecting gridpoint close to domain edge 

305 lat_min_bound, lon_min_bound = ( 

306 lat.points[boundary_margin - 1], 

307 lon.points[boundary_margin - 1], 

308 ) 

309 lat_max_bound, lon_max_bound = ( 

310 lat.points[-boundary_margin], 

311 lon.points[-boundary_margin], 

312 ) 

313 

314 # Check to see if selected point is outside the domain 

315 if (lat_tr < lat_min) or (lat_tr > lat_max): 

316 raise ValueError("Selected point is outside the domain.") 

317 else: 

318 if (lon_tr < lon_min) or (lon_tr > lon_max): 

319 if (lon_tr + 360.0 >= lon_min) and (lon_tr + 360.0 <= lon_max): 

320 lon_tr += 360.0 

321 elif (lon_tr - 360.0 >= lon_min) and (lon_tr - 360.0 <= lon_max): 

322 lon_tr -= 360.0 

323 else: 

324 raise ValueError("Selected point is outside the domain.") 

325 

326 # Check to see if selected point is near the domain boundaries 

327 if ( 

328 (lat_tr < lat_min_bound) 

329 or (lat_tr > lat_max_bound) 

330 or (lon_tr < lon_min_bound) 

331 or (lon_tr > lon_max_bound) 

332 ): 

333 warnings.warn( 

334 f"Selected point is within {boundary_margin} gridlengths of the domain edge, data may be unreliable.", 

335 category=BoundaryWarning, 

336 stacklevel=2, 

337 ) 

338 

339 regrid_method = getattr(iris.analysis, method, None) 

340 if not callable(regrid_method): 

341 raise NotImplementedError( 

342 f"Does not currently support {method} regrid method" 

343 ) 

344 

345 cube_rgd = cube.interpolate(((lat, lat_tr), (lon, lon_tr)), regrid_method()) 

346 regridded_cubes.append(cube_rgd) 

347 else: 

348 if ( 

349 np.abs((lat_tr - lat.points[0])) > 0.1 

350 or np.abs((lon_tr - lon.points[0])) > 0.1 

351 ): 

352 raise ValueError( 

353 "Selected point is too far from the specified coordinates. It should be within 0.1 degrees." 

354 ) 

355 else: 

356 print( 

357 "*** lat/long diffs", 

358 np.abs(lat_tr - lat_pt), 

359 np.abs(lon_tr - lon_pt), 

360 ) 

361 regridded_cubes.append(cube) 

362 

363 # Preserve returning a cube if only a cube has been supplied to regrid. 

364 if len(regridded_cubes) == 1: 

365 return regridded_cubes[0] 

366 else: 

367 return regridded_cubes 

368 

369 

370def transform_lat_long_points(lon, lat, cube): 

371 """Transform a selected point in longitude and latitude. 

372 

373 Transform the coordinates of a point from the real world 

374 grid to the corresponding point on the rotated grid of a cube. 

375 

376 Parameters 

377 ---------- 

378 cube: Cube 

379 An iris cube of data defining the rotated grid to be used in 

380 the longitude-latitude transformation. 

381 lon: float 

382 Selected value of longitude: this should be in the range -180 degrees to 

383 180 degrees. 

384 lat: float 

385 Selected value of latitude: this should be in the range -90 degrees to 

386 90 degrees. 

387 

388 Returns 

389 ------- 

390 lon_rot, lat_rot: float 

391 Coordinates of the selected point on the rotated grid specified within 

392 the selected cube. 

393 

394 """ 

395 import cartopy.crs as ccrs 

396 

397 rot_pole = cube.coord_system().as_cartopy_crs() 

398 true_grid = ccrs.Geodetic() 

399 rot_coords = rot_pole.transform_point(lon, lat, true_grid) 

400 lon_rot = rot_coords[0] 

401 lat_rot = rot_coords[1] 

402 

403 return lon_rot, lat_rot 

404 

405 

406def interpolate_to_point_cube( 

407 fld: iris.cube.Cube | iris.cube.CubeList, point_cube: iris.cube.Cube, **kwargs 

408) -> iris.cube.Cube | iris.cube.CubeList: 

409 """Interpolate a 2D field in cube or CubeList to a set of points. 

410 

411 Regrid cube(s) to set of sample points specified by point_cube. 

412 Ensures only matching times between input and point_cube are included. 

413 

414 Parameters 

415 ---------- 

416 fld: Cube | CubeList 

417 An iris cube or CubeList containing a two-dimensional field(s). 

418 point_cube: Cube 

419 An iris cube specifying the coord point(s) to which the data 

420 will be interpolated. 

421 

422 Returns 

423 ------- 

424 fld_point_cube: Cube | CubeList 

425 An iris cube or CubeList containing interpolated values at the 

426 points specified by the point cube for matching times common to 

427 both fld and point_cube. 

428 """ 

429 # Empty CubeList To store regridded cubes. 

430 regridded_cubes = iris.cube.CubeList() 

431 

432 # Iterate over all cubes and regrid. 

433 for cube in iter_maybe(fld): 

434 # Ensure matching times in fld cube and point_cube 

435 base_time_coord = point_cube.coord("time") 

436 other_time_coord = cube.coord("time") 

437 base_times = base_time_coord.units.num2date(base_time_coord.points) 

438 other_times = other_time_coord.units.num2date(other_time_coord.points) 

439 shared_times = set.intersection(set(base_times), set(other_times)) 

440 logging.debug("Shared times: %s", shared_times) 

441 time_constraint = iris.Constraint( 

442 coord_values={ 

443 "time": lambda cell, shared_times=shared_times: ( 

444 cell.point in shared_times 

445 ) 

446 } 

447 ) 

448 

449 # Extract points matching the shared times. 

450 cube = cube.extract(time_constraint) 

451 point_cube = point_cube.extract(time_constraint) 

452 if cube is None or point_cube is None: 

453 raise ValueError("No common time points found!") 

454 

455 # Generate array of point cube lat and lon points. 

456 lat_name, lon_name = get_cube_yxcoordname(point_cube) 

457 lats = point_cube.coord(lat_name).points 

458 lons = point_cube.coord(lon_name).points 

459 if lat_name == "latitude": 

460 sample_points = [ 

461 ("latitude", np.array(lats)), 

462 ("longitude", np.array(lons)), 

463 ] 

464 else: 

465 sample_points = [("grid_latitude", lats), ("grid_longitude", lons)] 

466 

467 # Interpolate fld cube to required sample points 

468 fld_point_cube = cube.interpolate( 

469 sample_points, iris.analysis.Linear(extrapolation_mode="mask") 

470 ) 

471 

472 # Retain only diagonal elements of 2D interpolated cube to vector points 

473 od_index = point_cube.coord_dims("station")[0] 

474 fv_cube = iris.cube.Cube( 

475 fld_point_cube.data.diagonal(axis1=od_index, axis2=od_index + 1), 

476 standard_name=cube.standard_name, 

477 long_name=cube.long_name, 

478 units=cube.units, 

479 ) 

480 # Copy all non-lat/lon coordinates and cube attributes 

481 if "time" in [coord.name() for coord in fld_point_cube.coords(dim_coords=True)]: 

482 fv_cube.add_dim_coord(point_cube.coord("time"), 0) 

483 else: 

484 fv_cube.add_aux_coord(point_cube.coord("time"), od_index) 

485 for coord in cube.coords(): 

486 if coord.name() not in [ 

487 "time", 

488 "latitude", 

489 "longitude", 

490 "grid_latitude", 

491 "grid_longitude", 

492 ]: 

493 fv_cube.add_aux_coord(coord.copy(), cube.coord_dims(coord)) 

494 for coord in point_cube.coords(): 

495 if coord.name() not in ["time", "realization", "station"]: 

496 fv_cube.add_aux_coord(coord.copy(), point_cube.coord_dims(coord)) 

497 fv_cube.add_dim_coord(point_cube.coord("station"), od_index) 

498 fv_cube.attributes = cube.attributes.copy() 

499 fv_cube.cell_methods = cube.cell_methods 

500 fv_cube.units = cube.units 

501 regridded_cubes.append(fv_cube) 

502 

503 # Preserve returning a cube if only a cube has been supplied to regrid. 

504 if len(regridded_cubes) == 1: 

505 return regridded_cubes[0] 

506 else: 

507 return regridded_cubes