Coverage for src/CSET/operators/read.py: 94%

430 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 08:32 +0000

1# © Crown copyright, Met Office (2022-2025) 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 for reading various types of files from disk.""" 

16 

17import ast 

18import datetime 

19import functools 

20import glob 

21import itertools 

22import logging 

23from pathlib import Path 

24from typing import Literal 

25 

26import dask 

27import iris 

28import iris.coord_systems 

29import iris.coords 

30import iris.cube 

31import iris.exceptions 

32import iris.util 

33import numpy as np 

34from iris.analysis.cartography import rotate_pole, rotate_winds 

35 

36from CSET._common import iter_maybe 

37from CSET.operators._stash_to_lfric import STASH_TO_LFRIC 

38from CSET.operators._utils import ( 

39 get_cube_coordindex, 

40 get_cube_yxcoordname, 

41 is_spatialdim, 

42) 

43 

44logger = logging.getLogger(__name__) 

45 

46 

47class NoDataError(FileNotFoundError): 

48 """Error that no data has been loaded.""" 

49 

50 

51def read_cube( 

52 file_paths: list[str] | str, 

53 constraint: iris.Constraint = None, 

54 model_names: list[str] | str | None = None, 

55 subarea_type: str | None = None, 

56 subarea_extent: list[float] | None = None, 

57 **kwargs, 

58) -> iris.cube.Cube: 

59 """Read a single cube from files. 

60 

61 Read operator that takes a path string (can include shell-style glob 

62 patterns), and loads the cube matching the constraint. If any paths point to 

63 directory, all the files contained within are loaded. 

64 

65 Ensemble data can also be loaded. If it has a realization coordinate 

66 already, it will be directly used. If not, it will have its member number 

67 guessed from the filename, based on one of several common patterns. For 

68 example the pattern *emXX*, where XX is the realization. 

69 

70 Deterministic data will be loaded with a realization of 0, allowing it to be 

71 processed in the same way as ensemble data. 

72 

73 Arguments 

74 --------- 

75 file_paths: str | list[str] 

76 Path or paths to where .pp/.nc files are located 

77 constraint: iris.Constraint | iris.ConstraintCombination, optional 

78 Constraints to filter data by. Defaults to unconstrained. 

79 model_names: str | list[str], optional 

80 Names of the models that correspond to respective paths in file_paths. 

81 subarea_type: "gridcells" | "modelrelative" | "realworld", optional 

82 Whether to constrain data by model relative coordinates or real world 

83 coordinates. 

84 subarea_extent: list, optional 

85 List of coordinates to constraint data by, in order lower latitude, 

86 upper latitude, lower longitude, upper longitude. 

87 

88 Returns 

89 ------- 

90 cubes: iris.cube.Cube 

91 Cube loaded 

92 

93 Raises 

94 ------ 

95 FileNotFoundError 

96 If the provided path does not exist 

97 ValueError 

98 If the constraint doesn't produce a single cube. 

99 """ 

100 cubes = read_cubes( 

101 file_paths=file_paths, 

102 constraint=constraint, 

103 model_names=model_names, 

104 subarea_type=subarea_type, 

105 subarea_extent=subarea_extent, 

106 ) 

107 # Check filtered cubes is a CubeList containing one cube. 

108 if len(cubes) == 1: 

109 return cubes[0] 

110 else: 

111 raise ValueError( 

112 f"Constraint doesn't produce single cube: {constraint}\n{cubes}" 

113 ) 

114 

115 

116def read_cubes( 

117 file_paths: list[str] | str, 

118 constraint: iris.Constraint | None = None, 

119 model_names: str | list[str] | None = None, 

120 subarea_type: str | None = None, 

121 subarea_extent: list | None = None, 

122 **kwargs, 

123) -> iris.cube.CubeList: 

124 """Read cubes from files. 

125 

126 Read operator that takes a path string (can include shell-style glob 

127 patterns), and loads the cubes matching the constraint. If any paths point 

128 to directory, all the files contained within are loaded. 

129 

130 Ensemble data can also be loaded. If it has a realization coordinate 

131 already, it will be directly used. If not, it will have its member number 

132 guessed from the filename, based on one of several common patterns. For 

133 example the pattern *emXX*, where XX is the realization. 

134 

135 Deterministic data will be loaded with a realization of 0, allowing it to be 

136 processed in the same way as ensemble data. 

137 

138 Data output by XIOS (such as LFRic) has its per-file metadata removed so 

139 that the cubes merge across files. 

140 

141 Arguments 

142 --------- 

143 file_paths: str | list[str] 

144 Path or paths to where .pp/.nc files are located. Can include globs. 

145 constraint: iris.Constraint | iris.ConstraintCombination, optional 

146 Constraints to filter data by. Defaults to unconstrained. 

147 model_names: str | list[str], optional 

148 Names of the models that correspond to respective paths in file_paths. 

149 subarea_type: str, optional 

150 Whether to constrain data by model relative coordinates or real world 

151 coordinates. 

152 subarea_extent: list[float], optional 

153 List of coordinates to constraint data by, in order lower latitude, 

154 upper latitude, lower longitude, upper longitude. 

155 

156 Returns 

157 ------- 

158 cubes: iris.cube.CubeList 

159 Cubes loaded after being merged and concatenated. 

160 

161 Raises 

162 ------ 

163 FileNotFoundError 

164 If the provided path does not exist 

165 """ 

166 # Get iterable of paths. Each path corresponds to 1 model. 

167 paths = iter_maybe(file_paths) 

168 model_names = iter_maybe(model_names) 

169 

170 # Check we have appropriate number of model names. 

171 if model_names != (None,) and len(model_names) != len(paths): 

172 raise ValueError( 

173 f"The number of model names ({len(model_names)}) should equal " 

174 f"the number of paths given ({len(paths)})." 

175 ) 

176 

177 # Load the data for each model into a CubeList per model. 

178 model_cubes = ( 

179 _load_model(path, name, constraint) 

180 for path, name in itertools.zip_longest(paths, model_names, fillvalue=None) 

181 ) 

182 

183 # Split out first model's cubes and mark it as the base for comparisons. 

184 cubes = next(model_cubes) 

185 for cube in cubes: 

186 # Use 1 to indicate True, as booleans can't be saved in NetCDF attributes. 

187 cube.attributes["cset_comparison_base"] = 1 

188 

189 # Load the rest of the models. 

190 cubes.extend(itertools.chain.from_iterable(model_cubes)) 

191 

192 # Enable different point-based observation sources to be concatenated. 

193 cubes = _check_combine_point_observations(cubes) 

194 

195 # Unify time units so different case studies can merge. 

196 iris.util.unify_time_units(cubes) 

197 

198 # Select sub region. 

199 cubes = _cutout_cubes(cubes, subarea_type, subarea_extent) 

200 

201 # Merge and concatenate cubes now metadata has been fixed. 

202 cubes = _merge_cubes_check_ensemble(cubes) 

203 cubes = cubes.concatenate() 

204 

205 # Squeeze single valued coordinates into scalar coordinates. 

206 cubes = iris.cube.CubeList(iris.util.squeeze(cube) for cube in cubes) 

207 

208 # Ensure dimension coordinates are bounded. 

209 for cube in cubes: 

210 for dim_coord in cube.coords(dim_coords=True): 

211 if (dim_coord.standard_name == "time") and ( 

212 dim_coord.name() 

213 not in itertools.chain.from_iterable( 

214 m.coord_names for m in cube.cell_methods if m.method != "point" 

215 ) 

216 ): 

217 # Instantaneous time coordinate 

218 continue 

219 # Iris can't guess the bounds of a scalar coordinate. 

220 if not dim_coord.has_bounds() and dim_coord.shape[0] > 1: 

221 dim_coord.guess_bounds() 

222 

223 logger.info("Loaded cubes: %s", cubes) 

224 if len(cubes) == 0: 

225 raise NoDataError("No cubes loaded, check your constraints!") 

226 return cubes 

227 

228 

229def _load_model( 

230 paths: str | list[str], 

231 model_name: str | None, 

232 constraint: iris.Constraint | None, 

233) -> iris.cube.CubeList: 

234 """Load a single model's data into a CubeList.""" 

235 input_files = _check_input_files(paths) 

236 # If unset, a constraint of None lets everything be loaded. 

237 logger.debug("Constraint: %s", constraint) 

238 cubes = iris.load(input_files, constraint, callback=_loading_callback) 

239 # If required, compute wind_speed from components. 

240 cubes = _compute_winds(cubes) 

241 

242 # Add model_name attribute to each cube to make it available at any further 

243 # step without needing to pass it as function parameter. 

244 if model_name is not None: 

245 for cube in cubes: 

246 cube.attributes["model_name"] = model_name 

247 return cubes 

248 

249 

250def _check_input_files(input_paths: str | list[str]) -> list[Path]: 

251 """Get an iterable of files to load, and check that they all exist. 

252 

253 Arguments 

254 --------- 

255 input_paths: list[str] 

256 List of paths to input files or directories. The path may itself contain 

257 glob patterns, but unlike in shells it will match directly first. 

258 

259 Returns 

260 ------- 

261 list[Path] 

262 A list of files to load. 

263 

264 Raises 

265 ------ 

266 FileNotFoundError: 

267 If the provided arguments don't resolve to at least one existing file. 

268 """ 

269 files = [] 

270 for raw_filename in iter_maybe(input_paths): 

271 # Match glob-like files first, if they exist. 

272 raw_path = Path(raw_filename) 

273 if raw_path.is_file(): 

274 files.append(raw_path) 

275 else: 

276 for input_path in glob.glob(raw_filename): 

277 # Convert string paths into Path objects. 

278 input_path = Path(input_path) 

279 # Get the list of files in the directory, or use it directly. 

280 if input_path.is_dir(): 

281 logger.debug("Checking directory '%s' for files", input_path) 

282 files.extend(p for p in input_path.iterdir() if p.is_file()) 

283 else: 

284 files.append(input_path) 

285 

286 files.sort() 

287 logger.info("Loading files:\n%s", "\n".join(str(path) for path in files)) 

288 if len(files) == 0: 

289 raise FileNotFoundError(f"No files found for {input_paths}") 

290 return files 

291 

292 

293def _merge_cubes_check_ensemble(cubes: iris.cube.CubeList): 

294 """Merge CubeList, renumbering realizations of 0 if required. 

295 

296 An unsuccessful merge indicates common input cube attributes, most 

297 commonly from ensemble members missing an explicit realization 

298 coordinate. Therefore the members are renumbered before being merged 

299 again. 

300 """ 

301 try: 

302 cubes = cubes.merge() 

303 except iris.exceptions.MergeError: 

304 _log_once( 

305 "Attempt to merge input CubeList failed. Attempting to iterate realization coords to enable merge.", 

306 level=logging.WARNING, 

307 ) 

308 for ir, cube in enumerate(cubes): 

309 if cube.coord("realization").points == 0: 309 ↛ 308line 309 didn't jump to line 308 because the condition on line 309 was always true

310 cube.coord("realization").points = ir + 1 

311 cubes = cubes.merge() 

312 return cubes 

313 

314 

315def _cutout_cubes( 

316 cubes: iris.cube.CubeList, 

317 subarea_type: Literal["gridcells", "realworld", "modelrelative"] | None, 

318 subarea_extent: list[float], 

319): 

320 """Cut out a subarea from a CubeList.""" 

321 if subarea_type is None: 

322 logger.debug("Subarea selection is disabled.") 

323 return cubes 

324 

325 # If selected, cutout according to number of grid cells to trim from each edge. 

326 cutout_cubes = iris.cube.CubeList() 

327 # Find spatial coordinates 

328 for cube in cubes: 

329 # Find dimension coordinates. 

330 lat_name, lon_name = get_cube_yxcoordname(cube) 

331 

332 # Compute cutout based on number of cells to trim from edges. 

333 if subarea_type == "gridcells": 

334 logger.debug( 

335 "User requested LowerTrim: %s LeftTrim: %s UpperTrim: %s RightTrim: %s", 

336 subarea_extent[0], 

337 subarea_extent[1], 

338 subarea_extent[2], 

339 subarea_extent[3], 

340 ) 

341 lat_points = np.sort(cube.coord(lat_name).points) 

342 lon_points = np.sort(cube.coord(lon_name).points) 

343 # Define cutout region using user provided cell points. 

344 lats = [lat_points[subarea_extent[0]], lat_points[-subarea_extent[2] - 1]] 

345 lons = [lon_points[subarea_extent[1]], lon_points[-subarea_extent[3] - 1]] 

346 

347 # Compute cutout based on specified coordinate values. 

348 elif subarea_type == "realworld" or subarea_type == "modelrelative": 

349 # If not gridcells, cutout by requested geographic area, 

350 logger.debug( 

351 "User requested LLat: %s ULat: %s LLon: %s ULon: %s", 

352 subarea_extent[0], 

353 subarea_extent[1], 

354 subarea_extent[2], 

355 subarea_extent[3], 

356 ) 

357 # Define cutout region using user provided coordinates. 

358 lats = np.array(subarea_extent[0:2]) 

359 lons = np.array(subarea_extent[2:4]) 

360 # Ensure cutout longitudes are within +/- 180.0 bounds. 

361 while lons[0] < -180.0: 

362 lons += 360.0 

363 while lons[1] > 180.0: 

364 lons -= 360.0 

365 # If the coordinate system is rotated we convert coordinates into 

366 # model-relative coordinates to extract the appropriate cutout. 

367 coord_system = cube.coord(lat_name).coord_system 

368 if subarea_type == "realworld" and isinstance( 

369 coord_system, iris.coord_systems.RotatedGeogCS 

370 ): 

371 lons, lats = rotate_pole( 

372 lons, 

373 lats, 

374 pole_lon=coord_system.grid_north_pole_longitude, 

375 pole_lat=coord_system.grid_north_pole_latitude, 

376 ) 

377 else: 

378 raise ValueError("Unknown subarea_type:", subarea_type) 

379 

380 # Do cutout and add to cutout_cubes. 

381 intersection_args = {lat_name: lats, lon_name: lons} 

382 logger.debug("Cutting out coords: %s", intersection_args) 

383 try: 

384 cutout_cubes.append(cube.intersection(**intersection_args)) 

385 except IndexError as err: 

386 raise ValueError( 

387 "Region cutout error. Check and update SUBAREA_EXTENT." 

388 "Cutout region requested should be contained within data area. " 

389 "Also check if cutout region requested is smaller than input grid spacing." 

390 ) from err 

391 

392 return cutout_cubes 

393 

394 

395def _loading_callback(cube: iris.cube.Cube, field, filename: str) -> iris.cube.Cube: 

396 """Compose together the needed callbacks into a single function.""" 

397 # Most callbacks operate in-place, but save the cube when returned! 

398 _realization_callback(cube) 

399 _um_normalise_callback(cube) 

400 _lfric_normalise_callback(cube) 

401 _nimrod_normalise_callback(cube) 

402 cube = _lfric_time_coord_fix_callback(cube) 

403 _normalise_var0_varname(cube) 

404 cube = _fix_no_spatial_coords_callback(cube) 

405 _fix_spatial_coords_callback(cube) 

406 _fix_pressure_coord_callback(cube) 

407 _fix_um_radtime(cube) 

408 _fix_cell_methods(cube) 

409 cube = _convert_cube_units_callback(cube) 

410 cube = _grid_longitude_fix_callback(cube) 

411 _fix_lfric_cloud_base_altitude(cube) 

412 _proleptic_gregorian_fix(cube) 

413 _lfric_time_callback(cube) 

414 _lfric_forecast_period_callback(cube) 

415 cube = _fix_no_time_coords_callback(cube) 

416 _normalise_ML_varname(cube) 

417 return cube 

418 

419 

420def _realization_callback(cube): 

421 """Add a realization coordinate initialised to 0 if missing. 

422 

423 This means deterministic and ensemble cubes can assume realization coordinate through the rest 

424 of the code. 

425 """ 

426 # Only add if realization coordinate does not exist. 

427 if not cube.coords("realization"): 

428 cube.add_aux_coord( 

429 iris.coords.DimCoord(0, standard_name="realization", units="1") 

430 ) 

431 

432 

433@functools.lru_cache(None) 

434def _log_once(msg, level=logging.WARNING): 

435 """Print a warning message, skipping recent duplicates.""" 

436 logger.log(level, msg) 

437 

438 

439def _um_normalise_callback(cube: iris.cube.Cube): 

440 """Normalise UM STASH variable long names to LFRic variable names. 

441 

442 Note standard names will remain associated with cubes where different. 

443 Long name will be used consistently in output filename and titles. 

444 """ 

445 # Convert STASH to LFRic variable name 

446 if "STASH" in cube.attributes: 

447 stash = cube.attributes["STASH"] 

448 try: 

449 (name, grid) = STASH_TO_LFRIC[str(stash)] 

450 cube.long_name = name 

451 except KeyError: 

452 # Don't change cubes with unknown stash codes. 

453 _log_once( 

454 f"Unknown STASH code: {stash}. Please check file stash_to_lfric.py to update.", 

455 level=logging.WARNING, 

456 ) 

457 

458 

459def _lfric_normalise_callback(cube: iris.cube.Cube): 

460 """Normalise attributes that prevents LFRic cube from merging. 

461 

462 The uuid and timeStamp relate to the output file, as saved by XIOS, and has 

463 no relation to the data contained. These attributes are removed. 

464 

465 The um_stash_source is a list of STASH codes for when an LFRic field maps to 

466 multiple UM fields, however it can be encoded in any order. This attribute 

467 is sorted to prevent this. This attribute is only present in LFRic data that 

468 has been converted to look like UM data. 

469 """ 

470 # Remove unwanted attributes. 

471 cube.attributes.pop("timeStamp", None) 

472 cube.attributes.pop("uuid", None) 

473 cube.attributes.pop("name", None) 

474 cube.attributes.pop("source", None) 

475 cube.attributes.pop("analysis_source", None) 

476 cube.attributes.pop("history", None) 

477 

478 # Sort STASH code list. 

479 stash_list = cube.attributes.get("um_stash_source") 

480 if stash_list: 

481 # Parse the string as a list, sort, then re-encode as a string. 

482 cube.attributes["um_stash_source"] = str(sorted(ast.literal_eval(stash_list))) 

483 

484 

485def _nimrod_normalise_callback(cube: iris.cube.Cube): 

486 """Normalise attributes that prevents NIMROD radar cubes from merging.""" 

487 # Remove unwanted attributes. 

488 cube.attributes.pop("radar_sites", None) 

489 cube.attributes.pop("additional_radar_sites", None) 

490 cube.attributes.pop("recursive_filter_iterations", None) 

491 cube.attributes.pop("Probability methods", None) 

492 

493 

494def _lfric_time_coord_fix_callback(cube: iris.cube.Cube) -> iris.cube.Cube: 

495 """Ensure the time coordinate is a DimCoord rather than an AuxCoord. 

496 

497 The coordinate is converted and replaced if not. SLAMed LFRic data has this 

498 issue, though the coordinate satisfies all the properties for a DimCoord. 

499 Scalar time values are left as AuxCoords. 

500 """ 

501 # This issue seems to come from iris's handling of NetCDF files where time 

502 # always ends up as an AuxCoord. 

503 if cube.coords("time"): 

504 time_coord = cube.coord("time") 

505 if ( 

506 not isinstance(time_coord, iris.coords.DimCoord) 

507 and len(cube.coord_dims(time_coord)) == 1 

508 ): 

509 # Fudge the bounds to foil checking for strict monotonicity. 

510 if ( 510 ↛ 514line 510 didn't jump to line 514 because the condition on line 510 was never true

511 time_coord.has_bounds() 

512 and (time_coord.bounds[-1][0] - time_coord.bounds[0][0]) < 1.0e-8 

513 ): 

514 time_coord.bounds = [ 

515 [ 

516 time_coord.bounds[i][0] + 1.0e-8 * float(i), 

517 time_coord.bounds[i][1], 

518 ] 

519 for i in range(len(time_coord.bounds)) 

520 ] 

521 iris.util.promote_aux_coord_to_dim_coord(cube, time_coord) 

522 return cube 

523 

524 

525def _grid_longitude_fix_callback(cube: iris.cube.Cube) -> iris.cube.Cube: 

526 """Check grid_longitude coordinates are in the range -180 deg to 180 deg. 

527 

528 This is necessary if comparing two models with different conventions -- 

529 for example, models where the prime meridian is defined as 0 deg or 

530 360 deg. If not in the range -180 deg to 180 deg, we wrap the grid_longitude 

531 so that it falls in this range. Checks are for near-180 bounds given 

532 model data bounds may not extend exactly to 0. or 360. 

533 Input cubes on non-rotated grid coordinates are not impacted. 

534 """ 

535 try: 

536 y, x = get_cube_yxcoordname(cube) 

537 except ValueError: 

538 # Don't modify non-spatial cubes. 

539 return cube 

540 

541 long_coord = cube.coord(x) 

542 # Wrap longitudes if rotated pole coordinates 

543 coord_system = long_coord.coord_system 

544 if x == "grid_longitude" and isinstance( 

545 coord_system, iris.coord_systems.RotatedGeogCS 

546 ): 

547 long_points = long_coord.points.copy() 

548 long_centre = np.median(long_points) 

549 while long_centre < -175.0: 

550 long_centre += 360.0 

551 long_points += 360.0 

552 while long_centre >= 175.0: 

553 long_centre -= 360.0 

554 long_points -= 360.0 

555 long_coord.points = long_points 

556 

557 # Update coord bounds to be consistent with wrapping. 

558 if long_coord.has_bounds(): 

559 long_coord.bounds = None 

560 long_coord.guess_bounds() 

561 

562 return cube 

563 

564 

565def _fix_no_spatial_coords_callback(cube: iris.cube.Cube): 

566 import CSET.operators._utils as utils 

567 

568 # Don't modify spatial cubes that already have spatial dimensions 

569 if utils.is_spatialdim(cube): 

570 return cube 

571 

572 else: 

573 # attempt to get lat/long from cube attributes 

574 try: 

575 lat_min = cube.attributes.get("geospatial_lat_min") 

576 lat_max = cube.attributes.get("geospatial_lat_max") 

577 lon_min = cube.attributes.get("geospatial_lon_min") 

578 lon_max = cube.attributes.get("geospatial_lon_max") 

579 

580 lon_val = (lon_min + lon_max) / 2.0 

581 lat_val = (lat_min + lat_max) / 2.0 

582 

583 lat_coord = iris.coords.DimCoord( 

584 lat_val, 

585 standard_name="latitude", 

586 units="degrees_north", 

587 var_name="latitude", 

588 coord_system=iris.coord_systems.GeogCS(6371229.0), 

589 circular=True, 

590 ) 

591 

592 lon_coord = iris.coords.DimCoord( 

593 lon_val, 

594 standard_name="longitude", 

595 units="degrees_east", 

596 var_name="longitude", 

597 coord_system=iris.coord_systems.GeogCS(6371229.0), 

598 circular=True, 

599 ) 

600 

601 cube.add_aux_coord(lat_coord) 

602 cube.add_aux_coord(lon_coord) 

603 return cube 

604 

605 # if lat/long are not in attributes, then return cube unchanged: 

606 except TypeError: 

607 return cube 

608 

609 

610def _fix_spatial_coords_callback(cube: iris.cube.Cube): 

611 """Check latitude and longitude coordinates name. 

612 

613 This is necessary as some models define their grid as on rotated 

614 'grid_latitude' and 'grid_longitude' coordinates while others define 

615 the grid on non-rotated 'latitude' and 'longitude'. 

616 Cube dimensions need to be made consistent to avoid recipe failures, 

617 particularly where comparing multiple input models with differing spatial 

618 coordinates. 

619 """ 

620 # Check if cube is spatial. 

621 if not is_spatialdim(cube): 

622 # Don't modify non-spatial cubes. 

623 return 

624 

625 # Get spatial coords and dimension index. 

626 y_name, x_name = get_cube_yxcoordname(cube) 

627 ny = get_cube_coordindex(cube, y_name) 

628 nx = get_cube_coordindex(cube, x_name) 

629 

630 # Remove spatial coords bounds if erroneous values detected. 

631 # Aims to catch some errors in input coord bounds by setting 

632 # invalid threshold of 10000.0 

633 if cube.coord(x_name).has_bounds() and cube.coord(y_name).has_bounds(): 

634 bx_max = np.max(np.abs(cube.coord(x_name).bounds)) 

635 by_max = np.max(np.abs(cube.coord(y_name).bounds)) 

636 if bx_max > 10000.0 or by_max > 10000.0: 

637 cube.coord(x_name).bounds = None 

638 cube.coord(y_name).bounds = None 

639 

640 # Translate [grid_latitude, grid_longitude] to an unrotated 1-d DimCoord 

641 # [latitude, longitude] for instances where rotated_pole=90.0 

642 if "grid_latitude" in [coord.name() for coord in cube.coords(dim_coords=True)]: 

643 coord_system = cube.coord("grid_latitude").coord_system 

644 pole_lat = getattr(coord_system, "grid_north_pole_latitude", None) 

645 if pole_lat == 90.0: 645 ↛ 646line 645 didn't jump to line 646 because the condition on line 645 was never true

646 lats = cube.coord("grid_latitude").points 

647 lons = cube.coord("grid_longitude").points 

648 

649 cube.remove_coord("grid_latitude") 

650 cube.add_dim_coord( 

651 iris.coords.DimCoord( 

652 lats, 

653 standard_name="latitude", 

654 var_name="latitude", 

655 units="degrees", 

656 coord_system=iris.coord_systems.GeogCS(6371229.0), 

657 circular=True, 

658 ), 

659 ny, 

660 ) 

661 y_name = "latitude" 

662 cube.remove_coord("grid_longitude") 

663 cube.add_dim_coord( 

664 iris.coords.DimCoord( 

665 lons, 

666 standard_name="longitude", 

667 var_name="longitude", 

668 units="degrees", 

669 coord_system=iris.coord_systems.GeogCS(6371229.0), 

670 circular=True, 

671 ), 

672 nx, 

673 ) 

674 x_name = "longitude" 

675 

676 # Create additional AuxCoord [grid_latitude, grid_longitude] with 

677 # rotated pole attributes for cases with [lat, lon] inputs 

678 if y_name in ["latitude"] and cube.coord(y_name).units in [ 

679 "degrees", 

680 "degrees_north", 

681 "degrees_south", 

682 ]: 

683 # Add grid_latitude AuxCoord 

684 if "grid_latitude" not in [ 

685 coord.name() for coord in cube.coords(dim_coords=False) 

686 ]: 

687 cube.add_aux_coord( 

688 iris.coords.AuxCoord( 

689 cube.coord(y_name).points, 

690 var_name="grid_latitude", 

691 units="degrees", 

692 ), 

693 ny, 

694 ) 

695 # Ensure input latitude DimCoord has CoordSystem 

696 # This attribute is sometimes lost on iris.save 

697 if not cube.coord(y_name).coord_system: 

698 cube.coord(y_name).coord_system = iris.coord_systems.GeogCS(6371229.0) 

699 

700 if x_name in ["longitude"] and cube.coord(x_name).units in [ 

701 "degrees", 

702 "degrees_west", 

703 "degrees_east", 

704 ]: 

705 # Add grid_longitude AuxCoord 

706 if "grid_longitude" not in [ 

707 coord.name() for coord in cube.coords(dim_coords=False) 

708 ]: 

709 cube.add_aux_coord( 

710 iris.coords.AuxCoord( 

711 cube.coord(x_name).points, 

712 var_name="grid_longitude", 

713 units="degrees", 

714 ), 

715 nx, 

716 ) 

717 

718 # Ensure input longitude DimCoord has CoordSystem 

719 # This attribute is sometimes lost on iris.save 

720 if not cube.coord(x_name).coord_system: 

721 cube.coord(x_name).coord_system = iris.coord_systems.GeogCS(6371229.0) 

722 

723 

724def _fix_pressure_coord_callback(cube: iris.cube.Cube): 

725 """Rename pressure coordinate to "pressure" if it exists and ensure hPa units. 

726 

727 This problem was raised because the AIFS model data from ECMWF 

728 defines the pressure coordinate with the name "pressure_level" rather 

729 than compliant CF coordinate names. 

730 

731 Additionally, set the units of pressure to be hPa to be consistent with the UM, 

732 and approach the coordinates in a unified way. 

733 """ 

734 for coord in cube.dim_coords: 

735 if coord.name() in ["pressure_level", "pressure_levels"]: 

736 coord.rename("pressure") 

737 

738 if coord.name() == "pressure" and str(cube.coord("pressure").units) != "hPa": 

739 cube.coord("pressure").convert_units("hPa") 

740 

741 

742def _fix_um_radtime(cube: iris.cube.Cube): 

743 """Move radiation diagnostics from timestamps which are output N minutes or seconds past every hour. 

744 

745 This callback does not have any effect for output diagnostics with 

746 timestamps exactly 00 or 30 minutes past the hour. Only radiation 

747 diagnostics are checked. 

748 Note this callback does not interpolate the data in time, only adjust 

749 timestamps to sit on the hour to enable time-to-time difference plotting 

750 with models which may output radiation data on the hour. 

751 """ 

752 try: 

753 if cube.attributes["STASH"] in [ 

754 "m01s01i207", 

755 "m01s01i208", 

756 "m01s02i205", 

757 "m01s02i201", 

758 "m01s01i207", 

759 "m01s02i207", 

760 "m01s01i235", 

761 ]: 

762 time_coord = cube.coord("time") 

763 

764 # Convert time points to datetime objects 

765 time_unit = time_coord.units 

766 time_points = time_unit.num2date(time_coord.points) 

767 # Skip if times don't need fixing. 

768 if time_points[0].minute == 0 and time_points[0].second == 0: 

769 return 

770 if time_points[0].minute == 30 and time_points[0].second == 0: 770 ↛ 771line 770 didn't jump to line 771 because the condition on line 770 was never true

771 return 

772 

773 # Subtract time difference from the hour from each time point 

774 n_minute = time_points[0].minute 

775 n_second = time_points[0].second 

776 # If times closer to next hour, compute difference to add on to following hour 

777 if n_minute > 30: 

778 n_minute = n_minute - 60 

779 # Compute new diagnostic time stamp 

780 new_time_points = ( 

781 time_points 

782 - datetime.timedelta(minutes=n_minute) 

783 - datetime.timedelta(seconds=n_second) 

784 ) 

785 

786 # Convert back to numeric values using the original time unit. 

787 new_time_values = time_unit.date2num(new_time_points) 

788 

789 # Replace the time coordinate with updated values. 

790 time_coord.points = new_time_values 

791 

792 # Recompute forecast_period with corrected values. 

793 if cube.coord("forecast_period"): 793 ↛ exitline 793 didn't return from function '_fix_um_radtime' because the condition on line 793 was always true

794 fcst_prd_points = cube.coord("forecast_period").points 

795 new_fcst_points = ( 

796 time_unit.num2date(fcst_prd_points) 

797 - datetime.timedelta(minutes=n_minute) 

798 - datetime.timedelta(seconds=n_second) 

799 ) 

800 cube.coord("forecast_period").points = time_unit.date2num( 

801 new_fcst_points 

802 ) 

803 except KeyError: 

804 pass 

805 

806 

807def _fix_cell_methods(cube: iris.cube.Cube): 

808 """To fix the assumed cell_methods in accumulation STASH from UM. 

809 

810 Lightning (m01s21i104), rainfall amount (m01s04i201, m01s05i201) and snowfall amount 

811 (m01s04i202, m01s05i202) in UM is being output as a time accumulation, 

812 over each hour (TAcc1hr), but input cubes show cell_methods as "mean". 

813 For UM and LFRic inputs to be compatible, we assume accumulated cell_methods are 

814 "sum". This callback changes "mean" cube attribute cell_method to "sum", 

815 enabling the cell_method constraint on reading to select correct input. 

816 """ 

817 # Shift "mean" cell_method to "sum" for selected UM inputs. 

818 if cube.attributes.get("STASH") in [ 

819 "m01s21i104", 

820 "m01s04i201", 

821 "m01s04i202", 

822 "m01s05i201", 

823 "m01s05i202", 

824 ] and {cm.method for cm in cube.cell_methods} == {"mean"}: 

825 # Retrieve interval and any comment information. 

826 for cell_method in cube.cell_methods: 

827 interval_str = cell_method.intervals 

828 comment_str = cell_method.comments 

829 

830 # Remove input aggregation method. 

831 cube.cell_methods = () 

832 

833 # Replace "mean" with "sum" cell_method to indicate aggregation. 

834 cube.add_cell_method( 

835 iris.coords.CellMethod( 

836 method="sum", 

837 coords="time", 

838 intervals=interval_str, 

839 comments=comment_str, 

840 ) 

841 ) 

842 

843 

844def _convert_cube_units_callback(cube: iris.cube.Cube): 

845 """Adjust diagnostic units for specific variables. 

846 

847 Some precipitation diagnostics are output with unit kg m-2 s-1 and are 

848 converted here to mm hr-1. 

849 

850 Visibility diagnostics are converted here from m to km to improve output 

851 formatting. 

852 """ 

853 # Convert precipitation diagnostic units if required. 

854 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name]) 

855 if any("surface_microphysical" in name for name in varnames): 

856 if cube.units == "kg m-2 s-1": 

857 _log_once( 

858 "Converting precipitation rate units from kg m-2 s-1 to mm hr-1", 

859 level=logging.DEBUG, 

860 ) 

861 # Convert from kg m-2 s-1 to mm s-1 assuming 1kg water = 1l water = 1dm^3 water. 

862 # This is a 1:1 conversion, so we just change the units. 

863 cube.units = "mm s-1" 

864 # Convert the units to per hour. 

865 cube.convert_units("mm hr-1") 

866 elif cube.units == "kg m-2": 866 ↛ 876line 866 didn't jump to line 876 because the condition on line 866 was always true

867 _log_once( 

868 "Converting precipitation amount units from kg m-2 to mm", 

869 level=logging.DEBUG, 

870 ) 

871 # Convert from kg m-2 to mm assuming 1kg water = 1l water = 1dm^3 water. 

872 # This is a 1:1 conversion, so we just change the units. 

873 cube.units = "mm" 

874 

875 # Convert visibility diagnostic units if required. 

876 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name]) 

877 if any("visibility" in name for name in varnames) and cube.units == "m": 

878 _log_once("Converting visibility units m to km.", level=logging.DEBUG) 

879 # Convert the units to km. 

880 cube.convert_units("km") 

881 

882 return cube 

883 

884 

885def _fix_lfric_cloud_base_altitude(cube: iris.cube.Cube): 

886 """Mask cloud_base_altitude diagnostic in regions with no cloud.""" 

887 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name]) 

888 if any("cloud_base_altitude" in name for name in varnames): 

889 # Mask cube where set > 144kft to catch default 144.35695538058164 

890 cube.data = dask.array.ma.masked_greater(cube.core_data(), 144.0) 

891 

892 

893def _compute_winds(cubes: iris.cube.CubeList): 

894 """To compute wind_speed from vector components if not available as diagnostic. 

895 

896 Diagnostics of wind are also not always consistent between the UM 

897 and LFRic. Here, winds from the UM are adjusted to make them 

898 consistent with LFRic. 

899 """ 

900 # Check whether we have components of the wind identified by varname 

901 # but not the wind speed and calculate it if it is missing. Note that 

902 # this will be biased low in general because the components will mostly 

903 # be time averages. For simplicity, we do this only if there is just one 

904 # cube of a component. A more complicated approach would be to consider 

905 # the cell methods, but it may not be warranted. 

906 # 

907 # A check on UM STASH attributes is also conducted to adjust directions. 

908 u_constr = iris.Constraint("eastward_wind_at_10m") 

909 v_constr = iris.Constraint("northward_wind_at_10m") 

910 speed_constr = iris.Constraint("wind_speed_at_10m") 

911 try: 

912 if cubes.extract(u_constr) and cubes.extract(v_constr): 

913 if len(cubes) == 2: 

914 wind_only = True 

915 else: 

916 wind_only = False 

917 if len(cubes.extract(u_constr)) == 1 and not cubes.extract(speed_constr): 917 ↛ 920line 917 didn't jump to line 920 because the condition on line 917 was always true

918 _add_wind_speed_um(cubes) 

919 # Convert winds in the UM to be relative to true east and true north. 

920 if cubes.extract(u_constr) and cubes.extract(v_constr): 920 ↛ 923line 920 didn't jump to line 923 because the condition on line 920 was always true

921 _convert_wind_true_dirn_um(cubes) 

922 # Return only wind_speed cube 

923 if wind_only: 

924 cubes = cubes.extract(speed_constr) 

925 except (KeyError, AttributeError): 

926 pass 

927 

928 return cubes 

929 

930 

931def _add_wind_speed_um(cubes: iris.cube.CubeList): 

932 """Add windspeeds to cubes from components.""" 

933 u_wind = cubes.extract_cube(iris.Constraint("eastward_wind_at_10m")) 

934 v_wind = cubes.extract_cube(iris.Constraint("northward_wind_at_10m")) 

935 wspd10 = (u_wind**2 + v_wind**2) ** 0.5 

936 wspd10.attributes["STASH"] = "m01s03i227" 

937 wspd10.standard_name = "wind_speed" 

938 wspd10.long_name = "wind_speed_at_10m" 

939 wspd10.units = "ms-1" 

940 cubes.append(wspd10) 

941 

942 

943def _convert_wind_true_dirn_um(cubes: iris.cube.CubeList): 

944 """To convert winds to true directions. 

945 

946 Convert from the components relative to the grid to true directions. 

947 This functionality only handles the simplest case. 

948 Constrains using STASH code only to ensure applied to UM outputs only. 

949 """ 

950 u_grids = cubes.extract(iris.AttributeConstraint(STASH="m01s03i225")) 

951 v_grids = cubes.extract(iris.AttributeConstraint(STASH="m01s03i226")) 

952 for u, v in zip(u_grids, v_grids, strict=True): 952 ↛ 953line 952 didn't jump to line 953 because the loop on line 952 never started

953 true_u, true_v = rotate_winds(u, v, iris.coord_systems.GeogCS(6371229.0)) 

954 u.data = true_u.core_data() 

955 v.data = true_v.core_data() 

956 

957 

958def _normalise_var0_varname(cube: iris.cube.Cube): 

959 """Fix varnames for consistency to allow merging. 

960 

961 Some model data netCDF sometimes have a coordinate name end in 

962 "_0" etc, where duplicate coordinates of same name are defined but 

963 with different attributes. This can be inconsistently managed in 

964 different model inputs and can cause cubes to fail to merge. 

965 """ 

966 for coord in cube.coords(): 

967 if coord.var_name and coord.var_name.endswith("_0"): 

968 coord.var_name = coord.var_name.removesuffix("_0") 

969 if coord.var_name and coord.var_name.endswith("_1"): 

970 coord.var_name = coord.var_name.removesuffix("_1") 

971 if coord.var_name and coord.var_name.endswith("_2"): 971 ↛ 972line 971 didn't jump to line 972 because the condition on line 971 was never true

972 coord.var_name = coord.var_name.removesuffix("_2") 

973 if coord.var_name and coord.var_name.endswith("_3"): 973 ↛ 974line 973 didn't jump to line 974 because the condition on line 973 was never true

974 coord.var_name = coord.var_name.removesuffix("_3") 

975 

976 if cube.var_name and cube.var_name.endswith("_0"): 

977 cube.var_name = cube.var_name.removesuffix("_0") 

978 

979 

980def _proleptic_gregorian_fix(cube: iris.cube.Cube): 

981 """Convert the calendars of time units to use a standard calendar.""" 

982 try: 

983 time_coord = cube.coord("time") 

984 if time_coord.units.calendar == "proleptic_gregorian": 

985 logger.debug( 

986 "Changing proleptic Gregorian calendar to standard calendar for %s", 

987 repr(time_coord.units), 

988 ) 

989 time_coord.units = time_coord.units.change_calendar("standard") 

990 except iris.exceptions.CoordinateNotFoundError: 

991 pass 

992 

993 

994def _lfric_time_callback(cube: iris.cube.Cube): 

995 """Fix time coordinate metadata if missing dimensions. 

996 

997 Some model data does not contain forecast_reference_time or forecast_period as 

998 expected coordinates, and so we cannot aggregate over case studies without this 

999 metadata. This callback fixes these issues. 

1000 

1001 This callback also ensures all time coordinates are referenced as hours since 

1002 1970-01-01 00:00:00 for consistency across different model inputs. 

1003 

1004 Notes 

1005 ----- 

1006 Some parts of the code have been adapted from Paul Earnshaw's scripts. 

1007 """ 

1008 # Construct forecast_reference time if it doesn't exist. 

1009 try: 

1010 tcoord = cube.coord("time") 

1011 # Set time coordinate to common basis "hours since 1970" 

1012 try: 

1013 tcoord.convert_units("hours since 1970-01-01 00:00:00") 

1014 except ValueError: 

1015 logger.warning("Unrecognised base time unit: %s", tcoord.units) 

1016 

1017 if not cube.coords("forecast_reference_time"): 

1018 try: 

1019 init_time = datetime.datetime.fromisoformat( 

1020 tcoord.attributes["time_origin"] 

1021 ) 

1022 frt_point = tcoord.units.date2num(init_time) 

1023 frt_coord = iris.coords.AuxCoord( 

1024 frt_point, 

1025 units=tcoord.units, 

1026 standard_name="forecast_reference_time", 

1027 long_name="forecast_reference_time", 

1028 ) 

1029 cube.add_aux_coord(frt_coord) 

1030 except KeyError: 

1031 logger.warning( 

1032 "Cannot find forecast_reference_time, but no `time_origin` attribute to construct it from." 

1033 ) 

1034 

1035 # Remove time_origin to allow multiple case studies to merge. 

1036 tcoord.attributes.pop("time_origin", None) 

1037 

1038 # Construct forecast_period axis (forecast lead time) if it doesn't exist. 

1039 if not cube.coords("forecast_period"): 

1040 try: 

1041 # Create array of forecast lead times. 

1042 init_coord = cube.coord("forecast_reference_time") 

1043 init_time_points_in_tcoord_units = tcoord.units.date2num( 

1044 init_coord.units.num2date(init_coord.points) 

1045 ) 

1046 lead_times = tcoord.points - init_time_points_in_tcoord_units 

1047 

1048 # Get unit for lead time from time coordinate's unit. 

1049 # Convert all lead time to hours for consistency between models. 

1050 if "seconds" in str(tcoord.units): 1050 ↛ 1051line 1050 didn't jump to line 1051 because the condition on line 1050 was never true

1051 lead_times = lead_times / 3600.0 

1052 units = "hours" 

1053 elif "hours" in str(tcoord.units): 1053 ↛ 1056line 1053 didn't jump to line 1056 because the condition on line 1053 was always true

1054 units = "hours" 

1055 else: 

1056 raise ValueError(f"Unrecognised base time unit: {tcoord.units}") 

1057 

1058 # Create lead time coordinate. 

1059 lead_time_coord = iris.coords.AuxCoord( 

1060 lead_times, 

1061 standard_name="forecast_period", 

1062 long_name="forecast_period", 

1063 units=units, 

1064 ) 

1065 

1066 # Associate lead time coordinate with time dimension. 

1067 cube.add_aux_coord(lead_time_coord, cube.coord_dims("time")) 

1068 except iris.exceptions.CoordinateNotFoundError: 

1069 logger.warning( 

1070 "Cube does not have both time and forecast_reference_time coordinate, so cannot construct forecast_period" 

1071 ) 

1072 except iris.exceptions.CoordinateNotFoundError: 

1073 logger.warning("No time coordinate on cube.") 

1074 

1075 

1076def _lfric_forecast_period_callback(cube: iris.cube.Cube): 

1077 """Check forecast_period name and units.""" 

1078 try: 

1079 coord = cube.coord("forecast_period") 

1080 if coord.units != "hours": 

1081 cube.coord("forecast_period").convert_units("hours") 

1082 if not coord.standard_name: 

1083 coord.standard_name = "forecast_period" 

1084 except iris.exceptions.CoordinateNotFoundError: 

1085 pass 

1086 

1087 

1088def _fix_no_time_coords_callback(cube: iris.cube.Cube): 

1089 """Add dummy time coord to process cubes that don't have sequence coord.""" 

1090 # Only add if time coordinate does not exist. 

1091 if not cube.coords("time"): 

1092 cube.add_aux_coord( 

1093 iris.coords.DimCoord( 

1094 0, standard_name="time", units="hours since 0001-01-01 00:00:00" 

1095 ) 

1096 ) 

1097 

1098 return cube 

1099 

1100 

1101def _normalise_ML_varname(cube: iris.cube.Cube): 

1102 """Fix plev variable names to standard names.""" 

1103 if cube.coords("pressure"): 

1104 if cube.name() == "x_wind": 

1105 cube.long_name = "zonal_wind_at_pressure_levels" 

1106 if cube.name() == "y_wind": 

1107 cube.long_name = "meridional_wind_at_pressure_levels" 

1108 if cube.name() == "air_temperature": 

1109 cube.long_name = "temperature_at_pressure_levels" 

1110 if cube.name() == "specific_humidity": 1110 ↛ 1111line 1110 didn't jump to line 1111 because the condition on line 1110 was never true

1111 cube.long_name = ( 

1112 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging" 

1113 ) 

1114 else: 

1115 if cube.name() == "x_wind" and cube.var_name == "u_wind_at_10m": 1115 ↛ 1116line 1115 didn't jump to line 1116 because the condition on line 1115 was never true

1116 cube.long_name = "eastward_wind_at_10m" 

1117 if cube.name() == "y_wind" and cube.var_name == "v_wind_at_10m": 1117 ↛ 1118line 1117 didn't jump to line 1118 because the condition on line 1117 was never true

1118 cube.long_name = "northward_wind_at_10m" 

1119 

1120 

1121def _check_combine_point_observations(cubes: iris.cube.CubeList): 

1122 """Enable cubes containing different point observation sources to be concatenated.""" 

1123 nstation = 0 

1124 for cube in cubes: 

1125 if "station" in [coord.name() for coord in cube.coords(dim_coords=True)]: 

1126 if "obs_source" in [coord.name() for coord in cube.coords()]: 

1127 cube.remove_coord("obs_source") 

1128 cube.coord("station").points = cube.coord("station").points + nstation 

1129 nstation = nstation + len(cube.coord("station").points) 

1130 

1131 return cubes