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

113 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-01 14:49 +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 warnings 

18 

19import iris 

20import iris.coord_systems 

21import iris.cube 

22import numpy as np 

23 

24from CSET._common import iter_maybe 

25from CSET.operators._utils import get_cube_yxcoordname 

26 

27 

28class BoundaryWarning(UserWarning): 

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

30 

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

32 values, so caution is advised when interpreting them. 

33 """ 

34 

35 

36def regrid_onto_cube( 

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

38 target: iris.cube.Cube, 

39 method: str, 

40 **kwargs, 

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

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

43 

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

45 

46 Arguments 

47 ---------- 

48 toregrid: iris.cube | iris.cube.CubeList 

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

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

51 latitude, longitude coordinates. 

52 target: Cube 

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

54 latitude, longitude coordinate. 

55 method: str 

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

57 

58 Returns 

59 ------- 

60 iris.cube | iris.cube.CubeList 

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

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

63 toregrid. 

64 

65 Raises 

66 ------ 

67 ValueError 

68 If a unique x/y coordinate cannot be found 

69 NotImplementedError 

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

71 

72 Notes 

73 ----- 

74 Currently rectlinear grids (uniform) are supported. 

75 """ 

76 # To store regridded cubes. 

77 regridded_cubes = iris.cube.CubeList() 

78 

79 # Iterate over all cubes and regrid. 

80 for cube in iter_maybe(toregrid): 

81 # Get y,x coord names 

82 y_coord, x_coord = get_cube_yxcoordname(cube) 

83 

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

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

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

87 raise NotImplementedError( 

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

89 ) 

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

91 raise NotImplementedError( 

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

93 ) 

94 

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

96 if callable(regrid_method): 

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

98 else: 

99 raise NotImplementedError( 

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

101 ) 

102 

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

104 if len(regridded_cubes) == 1: 

105 return regridded_cubes[0] 

106 else: 

107 return regridded_cubes 

108 

109 

110def regrid_onto_xyspacing( 

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

112 xspacing: float, 

113 yspacing: float, 

114 method: str, 

115 **kwargs, 

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

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

118 

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

120 

121 Parameters 

122 ---------- 

123 toregrid: iris.cube | iris.cube.CubeList 

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

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

126 latitude, longitude coordinates. 

127 xspacing: float 

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

129 yspacing: float 

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

131 method: str 

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

133 

134 Returns 

135 ------- 

136 iris.cube | iris.cube.CubeList 

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

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

139 toregrid. 

140 

141 Raises 

142 ------ 

143 ValueError 

144 If a unique x/y coordinate cannot be found 

145 NotImplementedError 

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

147 

148 Notes 

149 ----- 

150 Currently rectlinear grids (uniform) are supported. 

151 

152 """ 

153 # To store regridded cubes. 

154 regridded_cubes = iris.cube.CubeList() 

155 

156 # Iterate over all cubes and regrid. 

157 for cube in iter_maybe(toregrid): 

158 # Get x,y coord names 

159 y_coord, x_coord = get_cube_yxcoordname(cube) 

160 

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

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

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

164 raise NotImplementedError( 

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

166 ) 

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

168 raise NotImplementedError( 

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

170 ) 

171 

172 # Get axis 

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

174 

175 # Get bounds 

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

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

178 

179 # Generate new mesh 

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

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

182 

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

184 if callable(regrid_method): 

185 regridded_cubes.append( 

186 cube.interpolate( 

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

188 ) 

189 ) 

190 else: 

191 raise NotImplementedError( 

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

193 ) 

194 

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

196 if len(regridded_cubes) == 1: 

197 return regridded_cubes[0] 

198 else: 

199 return regridded_cubes 

200 

201 

202def regrid_to_single_point( 

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

204 lat_pt: float, 

205 lon_pt: float, 

206 latlon_in_type: str = "rotated", 

207 method: str = "Nearest", 

208 boundary_margin: int = 8, 

209 **kwargs, 

210) -> iris.cube.Cube: 

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

212 

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

214 selecting the nearest gridpoint to the selected longitude and latitude 

215 values or using linear interpolation across the surrounding points. 

216 

217 Parameters 

218 ---------- 

219 cubes: Cube | CubeList 

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

221 to be 2D with latitude, longitude coordinates. 

222 lon_pt: float 

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

224 180 degrees. 

225 lat_pt: float 

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

227 90 degrees. 

228 latlon_in_type: str, optional 

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

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

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

232 method: str 

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

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

235 which selects the nearest gridpoint. An alternative is 

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

237 longitude and latitude by linear interpolation. 

238 boundary_margin: int, optional 

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

240 Defaults to 8. 

241 

242 Returns 

243 ------- 

244 regridded_cubes: Cube | CubeList 

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

246 have time and/or height dimensions). 

247 

248 Raises 

249 ------ 

250 ValueError 

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

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

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

254 points exceed 0.1 degrees. 

255 NotImplementedError 

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

257 

258 Notes 

259 ----- 

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

261 described in X_COORD_NAMES and Y_COORD_NAMES. These cover commonly used 

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

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

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

265 is potentially unreliable. 

266 """ 

267 # To store regridded cubes. 

268 regridded_cubes = iris.cube.CubeList() 

269 

270 # Iterate over all cubes and regrid. 

271 for cube in iter_maybe(cubes): 

272 # Get x and y coordinate names. 

273 y_coord, x_coord = get_cube_yxcoordname(cube) 

274 

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

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

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

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

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

280 raise NotImplementedError( 

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

282 ) 

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

284 raise NotImplementedError( 

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

286 ) 

287 

288 # Transform input coordinates onto rotated grid if requested 

289 if latlon_in_type == "realworld": 

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

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

292 lon_tr, lat_tr = lon_pt, lat_pt 

293 

294 # Get axis 

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

296 

297 # Get bounds 

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

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

300 

301 # Use different logic for single point obs data. 

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

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

304 lat_min_bound, lon_min_bound = ( 

305 lat.points[boundary_margin - 1], 

306 lon.points[boundary_margin - 1], 

307 ) 

308 lat_max_bound, lon_max_bound = ( 

309 lat.points[-boundary_margin], 

310 lon.points[-boundary_margin], 

311 ) 

312 

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

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

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

316 else: 

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

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

319 lon_tr += 360.0 

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

321 lon_tr -= 360.0 

322 else: 

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

324 

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

326 if ( 

327 (lat_tr < lat_min_bound) 

328 or (lat_tr > lat_max_bound) 

329 or (lon_tr < lon_min_bound) 

330 or (lon_tr > lon_max_bound) 

331 ): 

332 warnings.warn( 

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

334 category=BoundaryWarning, 

335 stacklevel=2, 

336 ) 

337 

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

339 if not callable(regrid_method): 

340 raise NotImplementedError( 

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

342 ) 

343 

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

345 regridded_cubes.append(cube_rgd) 

346 else: 

347 if ( 

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

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

350 ): 

351 raise ValueError( 

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

353 ) 

354 else: 

355 print( 

356 "*** lat/long diffs", 

357 np.abs(lat_tr - lat_pt), 

358 np.abs(lon_tr - lon_pt), 

359 ) 

360 regridded_cubes.append(cube) 

361 

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

363 if len(regridded_cubes) == 1: 

364 return regridded_cubes[0] 

365 else: 

366 return regridded_cubes 

367 

368 

369def transform_lat_long_points(lon, lat, cube): 

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

371 

372 Transform the coordinates of a point from the real world 

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

374 

375 Parameters 

376 ---------- 

377 cube: Cube 

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

379 the longitude-latitude transformation. 

380 lon: float 

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

382 180 degrees. 

383 lat: float 

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

385 90 degrees. 

386 

387 Returns 

388 ------- 

389 lon_rot, lat_rot: float 

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

391 the selected cube. 

392 

393 """ 

394 import cartopy.crs as ccrs 

395 

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

397 true_grid = ccrs.Geodetic() 

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

399 lon_rot = rot_coords[0] 

400 lat_rot = rot_coords[1] 

401 

402 return lon_rot, lat_rot 

403 

404 

405def interpolate_to_point_cube( 

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

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

408 """Interpolate from a 2D field to a set of points. 

409 

410 Interpolate the 2D field in fld to the set of points 

411 specified in point_cube. 

412 

413 Parameters 

414 ---------- 

415 fld: Cube 

416 An iris cube containing a two-dimensional field. 

417 point_cube: Cube 

418 An iris cube specifying the point to which the data 

419 will be interpolated. 

420 

421 Returns 

422 ------- 

423 fld_point_cube: Cube 

424 An iris cube containing interpolated values at the points 

425 specified by the point cube. 

426 

427 """ 

428 # 

429 # As a basis, create a copy of the point cube. 

430 fld_point_cube = point_cube.copy() 

431 # Get indices of positional coordinates. We assume that the 

432 # point cube is unrotated. 

433 klon = None 

434 klat = None 

435 for kc in range(len(fld_point_cube.aux_coords)): 

436 if fld_point_cube.aux_coords[kc].standard_name == "latitude": 

437 klat = kc 

438 elif fld_point_cube.aux_coords[kc].standard_name == "longitude": 

439 klon = kc 

440 # 

441 # The input may have a rotated coordinate system. 

442 if len(fld.coords("grid_latitude")) > 0: 

443 # Interpolate in rotated coordinates. 

444 rot_csyst = fld.coords("grid_latitude")[0].coord_system 

445 rotpt = iris.analysis.cartography.rotate_pole( 

446 fld_point_cube.aux_coords[klon].points, 

447 fld_point_cube.aux_coords[klat].points, 

448 rot_csyst.grid_north_pole_longitude, 

449 rot_csyst.grid_north_pole_latitude, 

450 ) 

451 # Add other interpolation options later. 

452 fld_interpolator = iris.analysis.Linear(extrapolation_mode="mask").interpolator( 

453 fld, ["time", "grid_latitude", "grid_longitude"] 

454 ) 

455 for jt in range(len(fld_point_cube.coords("time")[0].points)): 

456 fld_point_cube.data[jt, :] = np.ma.masked_invalid( 

457 [ 

458 fld_interpolator( 

459 [ 

460 fld_point_cube.coord("time").points[jt], 

461 rotpt[1][k], 

462 rotpt[0][k], 

463 ] 

464 ).data 

465 if ~point_cube.data.mask[jt][k] 

466 else np.nan 

467 for k in range(len(rotpt[0])) 

468 ] 

469 ) 

470 else: 

471 # Add other interpolation options later. 

472 fld_interpolator = iris.analysis.Linear(extrapolation_mode="mask").interpolator( 

473 fld, ["time", "latitude", "longitude"] 

474 ) 

475 for jt in range(len(fld_point_cube.coords("time")[0].points)): 

476 fld_point_cube.data[jt, :] = np.ma.masked_invalid( 

477 [ 

478 fld_interpolator( 

479 [ 

480 fld_point_cube.coords("time")[0].points[jt], 

481 fld_point_cube.coord("latitude").points[k], 

482 fld_point_cube.coord("longitude").points[k], 

483 ] 

484 ).data 

485 if ~point_cube.data.mask[jt][k] 

486 else np.nan 

487 for k in range(fld_point_cube.coord("latitude").points) 

488 ] 

489 ) 

490 return fld_point_cube