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

457 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-11 15:11 +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 collections.abc import Iterator 

24from pathlib import Path 

25from typing import Literal 

26 

27import dask 

28import iris 

29import iris.coord_systems 

30import iris.coords 

31import iris.cube 

32import iris.exceptions 

33import iris.util 

34import numpy as np 

35from iris._constraints import ConstraintCombination 

36from iris.analysis.cartography import rotate_pole, rotate_winds 

37 

38from CSET._common import iter_maybe 

39from CSET.operators._stash_to_lfric import STASH_TO_LFRIC 

40from CSET.operators._utils import ( 

41 get_cube_coordindex, 

42 get_cube_yxcoordname, 

43 is_spatialdim, 

44) 

45 

46logger = logging.getLogger(__name__) 

47 

48 

49class NoDataError(FileNotFoundError): 

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

51 

52 

53def _probe_variables(file_paths: list[str] | str): 

54 """Return variables in input data using CSET-normalised long names. 

55 

56 Files are loaded using the standard CSET loading callbacks, so UM STASH 

57 codes are mapped to their corresponding LFRic names where possible. 

58 """ 

59 # Use _load_model to make use of normalisation callbacks. 

60 cubes = _load_model(file_paths, model_name=None, constraint=None) 

61 

62 variables = {cube.long_name for cube in cubes if cube.long_name is not None} 

63 

64 # Add 10m wind, if eastward and northward wind exist. 

65 if "eastward_wind_at_10m" in variables and "northward_wind_at_10m" in variables: 

66 variables.add("wind_speed_at_10m") 

67 

68 return sorted(variables) 

69 

70 

71def read_cube( 

72 file_paths: list[str] | str, 

73 constraint: iris.Constraint | None = None, 

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

75 subarea_type: str | None = None, 

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

77 **kwargs, 

78) -> iris.cube.Cube: 

79 """Read a single cube from files. 

80 

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

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

83 directory, all the files contained within are loaded. 

84 

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

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

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

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

89 

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

91 processed in the same way as ensemble data. 

92 

93 Arguments 

94 --------- 

95 file_paths: str | list[str] 

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

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

98 Constraints to filter data by. Defaults to unconstrained. 

99 model_names: str | list[str], optional 

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

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

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

103 coordinates. 

104 subarea_extent: list, optional 

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

106 upper latitude, lower longitude, upper longitude. 

107 

108 Returns 

109 ------- 

110 cubes: iris.cube.Cube 

111 Cube loaded 

112 

113 Raises 

114 ------ 

115 FileNotFoundError 

116 If the provided path does not exist 

117 ValueError 

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

119 """ 

120 cubes = read_cubes( 

121 file_paths=file_paths, 

122 constraint=constraint, 

123 model_names=model_names, 

124 subarea_type=subarea_type, 

125 subarea_extent=subarea_extent, 

126 ) 

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

128 if len(cubes) == 1: 

129 return cubes[0] 

130 else: 

131 raise ValueError( 

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

133 ) 

134 

135 

136def read_cubes( 

137 file_paths: list[str] | str, 

138 constraint: iris.Constraint | None = None, 

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

140 subarea_type: str | None = None, 

141 subarea_extent: list | None = None, 

142 **kwargs, 

143) -> iris.cube.CubeList: 

144 """Read cubes from files. 

145 

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

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

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

149 

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

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

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

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

154 

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

156 processed in the same way as ensemble data. 

157 

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

159 that the cubes merge across files. 

160 

161 Arguments 

162 --------- 

163 file_paths: str | list[str] 

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

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

166 Constraints to filter data by. Defaults to unconstrained. 

167 model_names: str | list[str], optional 

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

169 subarea_type: str, optional 

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

171 coordinates. 

172 subarea_extent: list[float], optional 

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

174 upper latitude, lower longitude, upper longitude. 

175 

176 Returns 

177 ------- 

178 cubes: iris.cube.CubeList 

179 Cubes loaded after being merged and concatenated. 

180 

181 Raises 

182 ------ 

183 FileNotFoundError 

184 If the provided path does not exist 

185 """ 

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

187 paths = iter_maybe(file_paths) 

188 model_names = iter_maybe(model_names) 

189 

190 # flattens model_names if needed into one dimensional list. 

191 if model_names != (None,): 

192 flat = [] 

193 for item in model_names: 

194 if isinstance(item, list): 194 ↛ 195line 194 didn't jump to line 195 because the condition on line 194 was never true

195 flat.extend(item) 

196 else: 

197 flat.append(item) 

198 model_names = flat 

199 

200 # Check we have appropriate number of model names. 

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

202 raise ValueError( 

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

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

205 ) 

206 

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

208 model_cubes = ( 

209 _load_model(path, name, constraint) 

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

211 ) 

212 

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

214 cubes = next(model_cubes) 

215 for cube in cubes: 

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

217 cube.attributes["cset_comparison_base"] = 1 

218 # Load the rest of the models. 

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

220 

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

222 cubes = _check_combine_point_observations(cubes) 

223 

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

225 iris.util.unify_time_units(cubes) 

226 

227 # Select sub region. 

228 cubes = _cutout_cubes(cubes, subarea_type, subarea_extent) 

229 

230 # Clean unwanted cube attributes 

231 cubes = _clean_unwanted_cube_attributes(cubes) 

232 

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

234 cubes = _merge_cubes_check_ensemble(cubes) 

235 cubes = cubes.concatenate() 

236 

237 # Squeeze single valued coordinates into scalar coordinates. 

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

239 

240 # Ensure dimension coordinates are bounded. 

241 for cube in cubes: 

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

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

244 dim_coord.name() 

245 not in itertools.chain.from_iterable( 

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

247 ) 

248 ): 

249 # Instantaneous time coordinate 

250 continue 

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

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

253 dim_coord.guess_bounds() 

254 

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

256 if len(cubes) == 0: 

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

258 return cubes 

259 

260 

261def _load_model( 

262 paths: str | list[str], 

263 model_name: str | None, 

264 constraint: iris.Constraint | None, 

265) -> iris.cube.CubeList: 

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

267 input_files = _check_input_files(paths) 

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

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

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

271 # If required, compute wind_speed from components. 

272 cubes = _compute_winds(cubes, constraint) 

273 

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

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

276 if model_name is not None: 

277 for cube in cubes: 

278 cube.attributes["model_name"] = model_name 

279 return cubes 

280 

281 

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

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

284 

285 Arguments 

286 --------- 

287 input_paths: list[str] 

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

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

290 

291 Returns 

292 ------- 

293 list[Path] 

294 A list of files to load. 

295 

296 Raises 

297 ------ 

298 FileNotFoundError: 

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

300 """ 

301 files = [] 

302 for raw_filename in iter_maybe(input_paths): 

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

304 raw_path = Path(raw_filename) 

305 if raw_path.is_file(): 

306 files.append(raw_path) 

307 else: 

308 for input_path in glob.glob(raw_filename): 

309 # Convert string paths into Path objects. 

310 input_path = Path(input_path) 

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

312 if input_path.is_dir(): 

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

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

315 else: 

316 files.append(input_path) 

317 

318 files.sort() 

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

320 if len(files) == 0: 

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

322 return files 

323 

324 

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

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

327 

328 An unsuccessful merge indicates common input cube attributes, most 

329 commonly from ensemble members missing an explicit realization 

330 coordinate. Therefore the members are renumbered before being merged 

331 again. 

332 """ 

333 try: 

334 cubes = cubes.merge() 

335 except iris.exceptions.MergeError: 

336 _log_once( 

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

338 level=logging.WARNING, 

339 ) 

340 for ir, cube in enumerate(cubes): 

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

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

343 cubes = cubes.merge() 

344 return cubes 

345 

346 

347def _cutout_cubes( 

348 cubes: iris.cube.CubeList, 

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

350 subarea_extent: list[float], 

351): 

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

353 if subarea_type is None: 

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

355 return cubes 

356 

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

358 cutout_cubes = iris.cube.CubeList() 

359 # Find spatial coordinates 

360 for cube in cubes: 

361 # Find dimension coordinates. 

362 lat_name, lon_name = get_cube_yxcoordname(cube) 

363 

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

365 if subarea_type == "gridcells": 

366 logger.debug( 

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

368 subarea_extent[0], 

369 subarea_extent[1], 

370 subarea_extent[2], 

371 subarea_extent[3], 

372 ) 

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

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

375 # Define cutout region using user provided cell points. 

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

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

378 

379 # Compute cutout based on specified coordinate values. 

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

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

382 logger.debug( 

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

384 subarea_extent[0], 

385 subarea_extent[1], 

386 subarea_extent[2], 

387 subarea_extent[3], 

388 ) 

389 # Define cutout region using user provided coordinates. 

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

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

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

393 while lons[0] < -180.0: 

394 lons += 360.0 

395 while lons[1] > 180.0: 

396 lons -= 360.0 

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

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

399 coord_system = cube.coord(lat_name).coord_system 

400 if subarea_type == "realworld" and isinstance( 

401 coord_system, iris.coord_systems.RotatedGeogCS 

402 ): 

403 lons, lats = rotate_pole( 

404 lons, 

405 lats, 

406 pole_lon=coord_system.grid_north_pole_longitude, 

407 pole_lat=coord_system.grid_north_pole_latitude, 

408 ) 

409 else: 

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

411 

412 # Do cutout and add to cutout_cubes. 

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

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

415 try: 

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

417 except IndexError as err: 

418 raise ValueError( 

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

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

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

422 ) from err 

423 

424 return cutout_cubes 

425 

426 

427def _clean_unwanted_cube_attributes( 

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

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

430 """Remove unwanted cube attributes, apart from those in an exception list. 

431 

432 This is run as a final step in read, so cube metadata fixing has already been done. 

433 """ 

434 

435 def _strip_attributes( 

436 obj: iris.cube.Cube | iris.coords.Coord, keep: list[str] 

437 ) -> None: 

438 """Remove all attributes not in keep, in place.""" 

439 for attr in list(obj.attributes): 

440 if attr not in keep: 

441 obj.attributes.pop(attr, None) 

442 

443 # List of cube attributes to preserve 

444 attrs_to_preserve = [ 

445 "model_name", 

446 "cset_comparison_base", 

447 "STASH", 

448 "um_stash_source", 

449 ] 

450 

451 # Iterate through each cube and check each attribute. 

452 for cube in iter_maybe(cubes): 

453 # remove unwanted cube attributes 

454 _strip_attributes(cube, attrs_to_preserve) 

455 for coord in cube.dim_coords + cube.aux_coords: 

456 # remove unwanted coord attributes 

457 _strip_attributes(coord, attrs_to_preserve) 

458 

459 return cubes 

460 

461 

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

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

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

465 _remove_cset_comparison_base_attribute_callback(cube) 

466 _realization_callback(cube) 

467 _um_normalise_callback(cube) 

468 _lfric_stash_source_callback(cube) 

469 cube = _lfric_time_coord_fix_callback(cube) 

470 _normalise_var0_varname(cube) 

471 cube = _fix_no_spatial_coords_callback(cube) 

472 _fix_spatial_coords_callback(cube) 

473 _fix_pressure_coord_callback(cube) 

474 _fix_um_radtime(cube) 

475 _fix_cell_methods(cube) 

476 cube = _convert_cube_units_callback(cube) 

477 cube = _grid_longitude_fix_callback(cube) 

478 _fix_lfric_cloud_base_altitude(cube) 

479 _proleptic_gregorian_fix(cube) 

480 _lfric_time_callback(cube) 

481 _lfric_forecast_period_callback(cube) 

482 cube = _fix_no_time_coords_callback(cube) 

483 _normalise_longname(cube) 

484 return cube 

485 

486 

487def _remove_cset_comparison_base_attribute_callback(cube): 

488 """Remove ``cset_comparison_base`` attribute if present. 

489 

490 This allows for reprocessing output previously saved by CSET. 

491 """ 

492 cube.attributes.pop("cset_comparison_base", None) 

493 

494 

495def _realization_callback(cube): 

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

497 

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

499 of the code. 

500 """ 

501 # Only add if realization coordinate does not exist. 

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

503 cube.add_aux_coord( 

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

505 ) 

506 

507 

508@functools.lru_cache(None) 

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

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

511 logger.log(level, msg) 

512 

513 

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

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

516 

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

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

519 """ 

520 # Convert STASH to LFRic variable name 

521 if "STASH" in cube.attributes: 

522 stash = cube.attributes["STASH"] 

523 try: 

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

525 cube.long_name = name 

526 except KeyError: 

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

528 _log_once( 

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

530 level=logging.WARNING, 

531 ) 

532 

533 

534def _lfric_stash_source_callback(cube: iris.cube.Cube): 

535 """Add um stash source to LFRic data. 

536 

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

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

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

540 has been converted to look like UM data. 

541 """ 

542 # Sort STASH code list. 

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

544 if stash_list: 

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

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

547 

548 

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

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

551 

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

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

554 Scalar time values are left as AuxCoords. 

555 """ 

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

557 # always ends up as an AuxCoord. 

558 if cube.coords("time"): 

559 time_coord = cube.coord("time") 

560 if ( 

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

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

563 ): 

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

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

566 time_coord.has_bounds() 

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

568 ): 

569 time_coord.bounds = [ 

570 [ 

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

572 time_coord.bounds[i][1], 

573 ] 

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

575 ] 

576 iris.util.promote_aux_coord_to_dim_coord(cube, time_coord) 

577 return cube 

578 

579 

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

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

582 

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

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

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

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

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

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

589 """ 

590 try: 

591 y, x = get_cube_yxcoordname(cube) 

592 except ValueError: 

593 # Don't modify non-spatial cubes. 

594 return cube 

595 

596 long_coord = cube.coord(x) 

597 # Wrap longitudes if rotated pole coordinates 

598 coord_system = long_coord.coord_system 

599 if x == "grid_longitude" and isinstance( 

600 coord_system, iris.coord_systems.RotatedGeogCS 

601 ): 

602 long_points = long_coord.points.copy() 

603 long_centre = np.median(long_points) 

604 while long_centre < -175.0: 

605 long_centre += 360.0 

606 long_points += 360.0 

607 while long_centre >= 175.0: 

608 long_centre -= 360.0 

609 long_points -= 360.0 

610 long_coord.points = long_points 

611 

612 # Update coord bounds to be consistent with wrapping. 

613 if long_coord.has_bounds(): 

614 long_coord.bounds = None 

615 long_coord.guess_bounds() 

616 

617 return cube 

618 

619 

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

621 import CSET.operators._utils as utils 

622 

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

624 if utils.is_spatialdim(cube): 

625 return cube 

626 

627 else: 

628 # attempt to get lat/long from cube attributes 

629 try: 

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

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

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

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

634 

635 lon_val = (lon_min + lon_max) / 2.0 

636 lat_val = (lat_min + lat_max) / 2.0 

637 

638 lat_coord = iris.coords.DimCoord( 

639 lat_val, 

640 standard_name="latitude", 

641 units="degrees_north", 

642 var_name="latitude", 

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

644 circular=True, 

645 ) 

646 

647 lon_coord = iris.coords.DimCoord( 

648 lon_val, 

649 standard_name="longitude", 

650 units="degrees_east", 

651 var_name="longitude", 

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

653 circular=True, 

654 ) 

655 

656 cube.add_aux_coord(lat_coord) 

657 cube.add_aux_coord(lon_coord) 

658 return cube 

659 

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

661 except TypeError: 

662 return cube 

663 

664 

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

666 """Check latitude and longitude coordinates name. 

667 

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

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

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

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

672 particularly where comparing multiple input models with differing spatial 

673 coordinates. 

674 """ 

675 # Check if cube is spatial. 

676 if not is_spatialdim(cube): 

677 # Don't modify non-spatial cubes. 

678 return 

679 

680 # Get spatial coords and dimension index. 

681 y_name, x_name = get_cube_yxcoordname(cube) 

682 ny = get_cube_coordindex(cube, y_name) 

683 nx = get_cube_coordindex(cube, x_name) 

684 

685 # Remove spatial coords bounds if erroneous values detected. 

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

687 # invalid threshold of 10000.0 

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

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

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

691 if bx_max > 10000.0 or by_max > 10000.0: 

692 cube.coord(x_name).bounds = None 

693 cube.coord(y_name).bounds = None 

694 

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

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

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

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

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

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

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

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

703 

704 cube.remove_coord("grid_latitude") 

705 cube.add_dim_coord( 

706 iris.coords.DimCoord( 

707 lats, 

708 standard_name="latitude", 

709 var_name="latitude", 

710 units="degrees", 

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

712 ), 

713 ny, 

714 ) 

715 y_name = "latitude" 

716 cube.remove_coord("grid_longitude") 

717 cube.add_dim_coord( 

718 iris.coords.DimCoord( 

719 lons, 

720 standard_name="longitude", 

721 var_name="longitude", 

722 units="degrees", 

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

724 ), 

725 nx, 

726 ) 

727 x_name = "longitude" 

728 

729 # Create additional AuxCoord [grid_latitude, grid_longitude] with 

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

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

732 "degrees", 

733 "degrees_north", 

734 "degrees_south", 

735 ]: 

736 # Add grid_latitude AuxCoord 

737 if "grid_latitude" not in [ 

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

739 ]: 

740 cube.add_aux_coord( 

741 iris.coords.AuxCoord( 

742 cube.coord(y_name).points, 

743 var_name="grid_latitude", 

744 units="degrees", 

745 ), 

746 ny, 

747 ) 

748 # Ensure input latitude DimCoord has CoordSystem 

749 # This attribute is sometimes lost on iris.save 

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

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

752 

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

754 "degrees", 

755 "degrees_west", 

756 "degrees_east", 

757 ]: 

758 # Add grid_longitude AuxCoord 

759 if "grid_longitude" not in [ 

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

761 ]: 

762 cube.add_aux_coord( 

763 iris.coords.AuxCoord( 

764 cube.coord(x_name).points, 

765 var_name="grid_longitude", 

766 units="degrees", 

767 ), 

768 nx, 

769 ) 

770 

771 # Ensure input longitude DimCoord has CoordSystem 

772 # This attribute is sometimes lost on iris.save 

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

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

775 

776 

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

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

779 

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

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

782 than compliant CF coordinate names. 

783 

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

785 and approach the coordinates in a unified way. 

786 """ 

787 for coord in cube.dim_coords: 

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

789 coord.rename("pressure") 

790 

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

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

793 

794 

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

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

797 

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

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

800 diagnostics are checked. 

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

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

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

804 """ 

805 try: 

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

807 "m01s01i207", 

808 "m01s01i208", 

809 "m01s02i205", 

810 "m01s02i201", 

811 "m01s01i207", 

812 "m01s02i207", 

813 "m01s01i235", 

814 ]: 

815 time_coord = cube.coord("time") 

816 

817 # Convert time points to datetime objects 

818 time_unit = time_coord.units 

819 time_points = time_unit.num2date(time_coord.points) 

820 # Skip if times don't need fixing. 

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

822 return 

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

824 return 

825 

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

827 n_minute = time_points[0].minute 

828 n_second = time_points[0].second 

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

830 if n_minute > 30: 

831 n_minute = n_minute - 60 

832 # Compute new diagnostic time stamp 

833 new_time_points = ( 

834 time_points 

835 - datetime.timedelta(minutes=n_minute) 

836 - datetime.timedelta(seconds=n_second) 

837 ) 

838 

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

840 new_time_values = time_unit.date2num(new_time_points) 

841 

842 # Replace the time coordinate with updated values. 

843 time_coord.points = new_time_values 

844 

845 # Recompute forecast_period with corrected values. 

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

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

848 new_fcst_points = ( 

849 time_unit.num2date(fcst_prd_points) 

850 - datetime.timedelta(minutes=n_minute) 

851 - datetime.timedelta(seconds=n_second) 

852 ) 

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

854 new_fcst_points 

855 ) 

856 except KeyError: 

857 pass 

858 

859 

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

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

862 

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

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

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

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

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

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

869 """ 

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

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

872 "m01s21i104", 

873 "m01s04i201", 

874 "m01s04i202", 

875 "m01s05i201", 

876 "m01s05i202", 

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

878 # Retrieve interval and any comment information. 

879 for cell_method in cube.cell_methods: 

880 interval_str = cell_method.intervals 

881 comment_str = cell_method.comments 

882 

883 # Remove input aggregation method. 

884 cube.cell_methods = () 

885 

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

887 cube.add_cell_method( 

888 iris.coords.CellMethod( 

889 method="sum", 

890 coords="time", 

891 intervals=interval_str, 

892 comments=comment_str, 

893 ) 

894 ) 

895 

896 

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

898 """Adjust diagnostic units for specific variables. 

899 

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

901 converted here to mm hr-1. 

902 

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

904 formatting. 

905 """ 

906 # Convert precipitation diagnostic units if required. 

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

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

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

910 _log_once( 

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

912 level=logging.DEBUG, 

913 ) 

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

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

916 cube.units = "mm s-1" 

917 # Convert the units to per hour. 

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

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

920 _log_once( 

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

922 level=logging.DEBUG, 

923 ) 

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

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

926 cube.units = "mm" 

927 

928 # Convert visibility diagnostic units if required. 

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

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

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

932 # Convert the units to km. 

933 cube.convert_units("km") 

934 

935 return cube 

936 

937 

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

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

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

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

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

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

944 

945 

946def _compute_winds( 

947 cubes: iris.cube.CubeList, 

948 constraint: iris.Constraint | ConstraintCombination | None = None, 

949): 

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

951 

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

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

954 consistent with LFRic. 

955 """ 

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

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

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

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

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

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

962 # 

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

964 

965 if constraint is None: 

966 return cubes 

967 filter_windspeed = None 

968 for constr in _flatten_combined_constraint(constraint): 

969 filter_windspeed = getattr(constr, "varname", None) 

970 if filter_windspeed: 

971 constraint = constr 

972 break 

973 

974 u_constr = iris.Constraint("eastward_wind_at_10m") 

975 v_constr = iris.Constraint("northward_wind_at_10m") 

976 sp_constr = iris.Constraint("wind_speed_at_10m") 

977 

978 try: 

979 if ( 

980 cubes.extract(u_constr) 

981 and cubes.extract(v_constr) 

982 and not cubes.extract(sp_constr) 

983 ): 

984 if "wind_speed_at_10m" in constraint.varname: 

985 _add_wind_speed_um(cubes) 

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

987 _convert_wind_true_dirn_um(cubes) 

988 except (KeyError, AttributeError): 

989 pass 

990 

991 if filter_windspeed: 

992 filter_windspeed_constraint = iris.Constraint( 

993 cube_func=lambda cube: ( 

994 cube.long_name in filter_windspeed 

995 or cube.standard_name in filter_windspeed 

996 or cube.var_name in filter_windspeed 

997 ) 

998 ) 

999 cubes = cubes.extract(filter_windspeed_constraint) 

1000 return cubes 

1001 

1002 

1003def _flatten_combined_constraint( 

1004 con: iris.Constraint | ConstraintCombination, 

1005) -> Iterator[iris.Constraint]: 

1006 # yields constraints of a possibly nested constraint combination 

1007 if isinstance(con, ConstraintCombination): 

1008 yield from _flatten_combined_constraint(con.lhs) 

1009 yield from _flatten_combined_constraint(con.rhs) 

1010 else: 

1011 yield con 

1012 

1013 

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

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

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

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

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

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

1020 wspd10.standard_name = "wind_speed" 

1021 wspd10.long_name = "wind_speed_at_10m" 

1022 wspd10.units = "m s-1" 

1023 cubes.append(wspd10) 

1024 

1025 

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

1027 """To convert winds to true directions. 

1028 

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

1030 This functionality only handles the simplest case. 

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

1032 """ 

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

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

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

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

1037 u.data = true_u.core_data() 

1038 v.data = true_v.core_data() 

1039 

1040 

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

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

1043 

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

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

1046 with different attributes. This can be inconsistently managed in 

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

1048 """ 

1049 for coord in cube.coords(): 

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

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

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

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

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

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

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

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

1058 

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

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

1061 

1062 

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

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

1065 try: 

1066 time_coord = cube.coord("time") 

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

1068 logger.debug( 

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

1070 repr(time_coord.units), 

1071 ) 

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

1073 except iris.exceptions.CoordinateNotFoundError: 

1074 pass 

1075 

1076 

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

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

1079 

1080 Some model data does not contain forecast_reference_time or forecast_period as 

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

1082 metadata. This callback fixes these issues. 

1083 

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

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

1086 

1087 Notes 

1088 ----- 

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

1090 """ 

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

1092 try: 

1093 tcoord = cube.coord("time") 

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

1095 try: 

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

1097 except ValueError: 

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

1099 

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

1101 try: 

1102 init_time = datetime.datetime.fromisoformat( 

1103 tcoord.attributes["time_origin"] 

1104 ) 

1105 frt_point = tcoord.units.date2num(init_time) 

1106 frt_coord = iris.coords.AuxCoord( 

1107 frt_point, 

1108 units=tcoord.units, 

1109 standard_name="forecast_reference_time", 

1110 long_name="forecast_reference_time", 

1111 ) 

1112 cube.add_aux_coord(frt_coord) 

1113 except KeyError: 

1114 logger.warning( 

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

1116 ) 

1117 

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

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

1120 try: 

1121 # Create array of forecast lead times. 

1122 init_coord = cube.coord("forecast_reference_time") 

1123 init_time_points_in_tcoord_units = tcoord.units.date2num( 

1124 init_coord.units.num2date(init_coord.points) 

1125 ) 

1126 lead_times = tcoord.points - init_time_points_in_tcoord_units 

1127 

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

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

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

1131 lead_times = lead_times / 3600.0 

1132 units = "hours" 

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

1134 units = "hours" 

1135 else: 

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

1137 

1138 # Create lead time coordinate. 

1139 lead_time_coord = iris.coords.AuxCoord( 

1140 lead_times, 

1141 standard_name="forecast_period", 

1142 long_name="forecast_period", 

1143 units=units, 

1144 ) 

1145 

1146 # Associate lead time coordinate with time dimension. 

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

1148 except iris.exceptions.CoordinateNotFoundError: 

1149 logger.warning( 

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

1151 ) 

1152 except iris.exceptions.CoordinateNotFoundError: 

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

1154 

1155 

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

1157 """Check forecast_period name and units.""" 

1158 try: 

1159 coord = cube.coord("forecast_period") 

1160 if coord.units != "hours": 

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

1162 if not coord.standard_name: 

1163 coord.standard_name = "forecast_period" 

1164 except iris.exceptions.CoordinateNotFoundError: 

1165 pass 

1166 

1167 

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

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

1170 # Only add if time coordinate does not exist. 

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

1172 cube.add_aux_coord( 

1173 iris.coords.DimCoord( 

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

1175 ) 

1176 ) 

1177 

1178 return cube 

1179 

1180 

1181def _normalise_longname(cube: iris.cube.Cube): 

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

1183 if cube.coords("pressure"): 

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

1185 cube.long_name = "zonal_wind_at_pressure_levels" 

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

1187 cube.long_name = "meridional_wind_at_pressure_levels" 

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

1189 cube.long_name = "temperature_at_pressure_levels" 

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

1191 cube.long_name = ( 

1192 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging" 

1193 ) 

1194 else: 

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

1196 cube.long_name = "eastward_wind_at_10m" 

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

1198 cube.long_name = "northward_wind_at_10m" 

1199 

1200 

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

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

1203 nstation = 0 

1204 for cube in cubes: 

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

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

1207 cube.remove_coord("obs_source") 

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

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

1210 

1211 return cubes