Coverage for src/CSET/operators/misc.py: 86%

158 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-10 13:27 +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"""Miscellaneous operators.""" 

16 

17import itertools 

18import logging 

19from collections.abc import Iterable 

20 

21import iris 

22import iris.analysis.calculus 

23import numpy as np 

24from iris.cube import Cube, CubeList 

25 

26from CSET._common import is_increasing, iter_maybe 

27from CSET.operators._utils import fully_equalise_attributes, get_cube_yxcoordname 

28from CSET.operators.regrid import regrid_onto_cube 

29 

30 

31def noop(x, **kwargs): 

32 """Return its input without doing anything to it. 

33 

34 Useful for constructing diagnostic chains. 

35 

36 Arguments 

37 --------- 

38 x: Any 

39 Input to return. 

40 

41 Returns 

42 ------- 

43 x: Any 

44 The input that was given. 

45 """ 

46 return x 

47 

48 

49def remove_attribute( 

50 cubes: Cube | CubeList, attribute: str | Iterable, **kwargs 

51) -> CubeList: 

52 """Remove a cube attribute. 

53 

54 If the attribute is not on the cube, the cube is passed through unchanged. 

55 

56 Arguments 

57 --------- 

58 cubes: Cube | CubeList 

59 One or more cubes to remove the attribute from. 

60 attribute: str | Iterable 

61 Name of attribute (or Iterable of names) to remove. 

62 

63 Returns 

64 ------- 

65 cubes: CubeList 

66 CubeList of cube(s) with the attribute removed. 

67 """ 

68 # Ensure cubes is a CubeList. 

69 if not isinstance(cubes, CubeList): 

70 cubes = CubeList(iter_maybe(cubes)) 

71 

72 for cube in cubes: 

73 for attr in iter_maybe(attribute): 

74 cube.attributes.pop(attr, None) 

75 

76 # Combine things that can be merged due to remove removing the 

77 # attributes. 

78 cubes = cubes.merge() 

79 # combine items that can be merged after removing unwanted attributes 

80 cubes = cubes.concatenate() 

81 return cubes 

82 

83 

84def remove_scalar_coords( 

85 cubes: Cube | CubeList, coords: str | Iterable[str] 

86) -> CubeList: 

87 """Remove scalar coordinates from one or more cubes. 

88 

89 Coordinates are only removed if they exist on the cube and are 

90 scalar coordinates (i.e. have no associated dimensions). Examples 

91 include ``realization`` and ``forecast_reference_time`` on model 

92 data cubes. Dimensional and non-scalar auxiliary coordinates are 

93 left unchanged. 

94 

95 Arguments 

96 --------- 

97 cubes: Cube | CubeList 

98 One or more cubes from which scalar coordinates will be removed. 

99 coords: str | Iterable 

100 Name of a coordinate (or Iterable of coordinate names) to remove. 

101 

102 Returns 

103 ------- 

104 cubes: CubeList 

105 CubeList of cube(s) with the requested scalar coordinates 

106 removed where present. 

107 """ 

108 if not isinstance(cubes, CubeList): 108 ↛ 111line 108 didn't jump to line 111 because the condition on line 108 was always true

109 cubes = CubeList(iter_maybe(cubes)) 

110 

111 if isinstance(coords, str): 111 ↛ 112line 111 didn't jump to line 112 because the condition on line 111 was never true

112 coords = [coords] 

113 

114 for cube in cubes: 

115 for coord_name in iter_maybe(coords): 

116 if cube.coords(coord_name): 116 ↛ 115line 116 didn't jump to line 115 because the condition on line 116 was always true

117 coord = cube.coord(coord_name) 

118 # only remove if scalar 

119 if cube.coord_dims(coord) == (): 

120 cube.remove_coord(coord) 

121 

122 return cubes 

123 

124 

125def addition(addend_1, addend_2): 

126 """Addition of two fields. 

127 

128 Parameters 

129 ---------- 

130 addend_1: Cube 

131 Any field to have another field added to it. 

132 addend_2: Cube 

133 Any field to be added to another field. 

134 

135 Returns 

136 ------- 

137 Cube 

138 

139 Raises 

140 ------ 

141 ValueError, iris.exceptions.NotYetImplementedError 

142 When the cubes are not compatible. 

143 

144 Notes 

145 ----- 

146 This is a simple operator designed for combination of diagnostics or 

147 creating new diagnostics by using recipes. 

148 

149 Examples 

150 -------- 

151 >>> field_addition = misc.addition(kinetic_energy_u, kinetic_energy_v) 

152 

153 """ 

154 return addend_1 + addend_2 

155 

156 

157def subtraction(minuend, subtrahend): 

158 """Subtraction of two fields. 

159 

160 Parameters 

161 ---------- 

162 minuend: Cube 

163 Any field to have another field subtracted from it. 

164 subtrahend: Cube 

165 Any field to be subtracted from to another field. 

166 

167 Returns 

168 ------- 

169 Cube 

170 

171 Raises 

172 ------ 

173 ValueError, iris.exceptions.NotYetImplementedError 

174 When the cubes are not compatible. 

175 

176 Notes 

177 ----- 

178 This is a simple operator designed for combination of diagnostics or 

179 creating new diagnostics by using recipes. It can be used for model 

180 differences to allow for comparisons between the same field in different 

181 models or model configurations. 

182 

183 Examples 

184 -------- 

185 >>> model_diff = misc.subtraction(temperature_model_A, temperature_model_B) 

186 

187 """ 

188 return minuend - subtrahend 

189 

190 

191def division(numerator, denominator): 

192 """Division of two fields. 

193 

194 Parameters 

195 ---------- 

196 numerator: Cube 

197 Any field to have the ratio taken with respect to another field. 

198 denominator: Cube 

199 Any field used to divide another field or provide the reference 

200 value in a ratio. 

201 

202 Returns 

203 ------- 

204 Cube 

205 

206 Raises 

207 ------ 

208 ValueError 

209 When the cubes are not compatible. 

210 

211 Notes 

212 ----- 

213 This is a simple operator designed for combination of diagnostics or 

214 creating new diagnostics by using recipes. 

215 

216 Examples 

217 -------- 

218 >>> bowen_ratio = misc.division(sensible_heat_flux, latent_heat_flux) 

219 

220 """ 

221 return numerator / denominator 

222 

223 

224def multiplication( 

225 multiplicand: Cube | CubeList, multiplier: Cube | CubeList 

226) -> Cube | CubeList: 

227 """Multiplication of two fields. 

228 

229 Parameters 

230 ---------- 

231 multiplicand: Cube | CubeList 

232 Any field to be multiplied by another field. 

233 multiplier: Cube | CubeList 

234 Any field to be multiplied to another field. 

235 

236 Returns 

237 ------- 

238 Cube | CubeList 

239 The result of multiplicand x multiplier. 

240 

241 Raises 

242 ------ 

243 ValueError 

244 When the cubes are not compatible. 

245 

246 Notes 

247 ----- 

248 This is a simple operator designed for combination of diagnostics or 

249 creating new diagnostics by using recipes. CubeLists are multiplied 

250 on a strict ordering (e.g. first cube with first cube). 

251 

252 Examples 

253 -------- 

254 >>> filtered_CAPE_ratio = misc.multiplication(CAPE_ratio, inflow_layer_properties) 

255 

256 """ 

257 new_cubelist = iris.cube.CubeList([]) 

258 for cube_a, cube_b in zip( 

259 iter_maybe(multiplicand), iter_maybe(multiplier), strict=True 

260 ): 

261 multiplied_cube = cube_a * cube_b 

262 multiplied_cube.rename(f"{cube_a.name()}_x_{cube_b.name()}") 

263 new_cubelist.append(multiplied_cube) 

264 if len(new_cubelist) == 1: 

265 return new_cubelist[0] 

266 else: 

267 return new_cubelist 

268 

269 

270def combine_cubes_into_cubelist(first: Cube | CubeList, **kwargs) -> CubeList: 

271 """Operator that combines multiple cubes or CubeLists into one. 

272 

273 Arguments 

274 --------- 

275 first: Cube | CubeList 

276 First cube or CubeList to merge into CubeList. 

277 second: Cube | CubeList 

278 Second cube or CubeList to merge into CubeList. This must be a named 

279 argument. 

280 third: Cube | CubeList 

281 There can be any number of additional arguments, they just need unique 

282 names. 

283 ... 

284 

285 Returns 

286 ------- 

287 combined_cubelist: CubeList 

288 Combined CubeList containing all cubes/CubeLists. 

289 

290 Raises 

291 ------ 

292 TypeError: 

293 If the provided arguments are not either a Cube or CubeList. 

294 """ 

295 # Create empty CubeList to store cubes/CubeList. 

296 all_cubes = CubeList() 

297 # Combine all CubeLists into a single flat iterable. 

298 for item in itertools.chain(iter_maybe(first), *map(iter_maybe, kwargs.values())): 

299 # Check each item is a Cube, erroring if not. 

300 if isinstance(item, Cube): 

301 # Add cube to CubeList. 

302 all_cubes.append(item) 

303 else: 

304 raise TypeError("Not a Cube or CubeList!", item) 

305 return all_cubes 

306 

307 

308def difference(cubes: CubeList): 

309 """Difference of two fields. 

310 

311 Parameters 

312 ---------- 

313 cubes: CubeList 

314 A list of exactly two cubes. One must have the cset_comparison_base 

315 attribute set to 1, and will be used as the base of the comparison. 

316 

317 Returns 

318 ------- 

319 Cube 

320 

321 Raises 

322 ------ 

323 ValueError 

324 When the cubes are not compatible. 

325 

326 Notes 

327 ----- 

328 This is a simple operator designed for combination of diagnostics or 

329 creating new diagnostics by using recipes. It can be used for model 

330 differences to allow for comparisons between the same field in different 

331 models or model configurations. 

332 

333 Examples 

334 -------- 

335 >>> model_diff = misc.difference(temperature_model_A, temperature_model_B) 

336 

337 """ 

338 if len(cubes) != 2: 

339 raise ValueError("cubes should contain exactly 2 cubes.") 

340 base: Cube = cubes.extract_cube(iris.AttributeConstraint(cset_comparison_base=1)) 

341 other: Cube = cubes.extract_cube( 

342 iris.Constraint( 

343 cube_func=lambda cube: "cset_comparison_base" not in cube.attributes 

344 ) 

345 ) 

346 

347 # If cubes contain a pressure coordinate, ensure it is increasing. 

348 for cube in cubes: 

349 try: 

350 if len(cube.coord("pressure").points) > 2: 350 ↛ 348line 350 didn't jump to line 348 because the condition on line 350 was always true

351 if not is_increasing(cube.coord("pressure").points): 

352 cube.data = np.flip(cube.data, axis=cube.coord_dims("pressure")[0]) 

353 

354 except iris.exceptions.CoordinateNotFoundError: 

355 pass 

356 

357 # Get spatial coord names. 

358 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

359 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

360 

361 # Ensure cubes to compare are on common differencing grid. 

362 # This is triggered if either 

363 # i) latitude and longitude shapes are not the same. Note grid points 

364 # are not compared directly as these can differ through rounding 

365 # errors. 

366 # ii) or variables are known to often sit on different grid staggering 

367 # in different models (e.g. cell center vs cell edge), as is the case 

368 # for UM and LFRic comparisons. 

369 # In future greater choice of regridding method might be applied depending 

370 # on variable type. Linear regridding can in general be appropriate for smooth 

371 # variables. Care should be taken with interpretation of differences 

372 # given this dependency on regridding. 

373 if ( 

374 base.coord(base_lat_name).shape != other.coord(other_lat_name).shape 

375 or base.coord(base_lon_name).shape != other.coord(other_lon_name).shape 

376 ) or ( 

377 base.long_name 

378 in [ 

379 "eastward_wind_at_10m", 

380 "northward_wind_at_10m", 

381 "northward_wind_at_cell_centres", 

382 "eastward_wind_at_cell_centres", 

383 "zonal_wind_at_pressure_levels", 

384 "meridional_wind_at_pressure_levels", 

385 "potential_vorticity_at_pressure_levels", 

386 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

387 ] 

388 ): 

389 logging.debug( 

390 "Linear regridding base cube to other grid to compute differences" 

391 ) 

392 base = regrid_onto_cube(base, other, method="Linear") 

393 

394 # Figure out if we are comparing between UM and LFRic; flip array if so. 

395 base_lat_direction = is_increasing(base.coord(base_lat_name).points) 

396 other_lat_direction = is_increasing(other.coord(other_lat_name).points) 

397 if base_lat_direction != other_lat_direction: 

398 other.data = np.flip(other.data, other.coord(other_lat_name).cube_dims(other)) 

399 

400 # Extract just common time points. 

401 base, other = _extract_common_time_points(base, other) 

402 

403 # Equalise attributes so we can merge. 

404 fully_equalise_attributes([base, other]) 

405 logging.debug("Base: %s\nOther: %s", base, other) 

406 

407 # This currently relies on the cubes having the same underlying data layout. 

408 difference = base.copy() 

409 

410 # Differences don't have a standard name; long name gets a suffix. We are 

411 # assuming we can rely on cubes having a long name, so we don't check for 

412 # its presents. 

413 difference.standard_name = None 

414 difference.long_name = ( 

415 base.long_name if base.long_name else base.name() 

416 ) + "_difference" 

417 if base.var_name: 

418 difference.var_name = base.var_name + "_difference" 

419 elif base.standard_name: 

420 difference.var_name = base.standard_name + "_difference" 

421 

422 difference.data = other.data - base.data 

423 return difference 

424 

425 

426def _extract_common_time_points(base: Cube, other: Cube) -> tuple[Cube, Cube]: 

427 """Extract common time points from cubes to allow comparison.""" 

428 # Get the name of the first non-scalar time coordinate. 

429 time_coord = next( 

430 map( 

431 lambda coord: coord.name(), 

432 filter( 

433 lambda coord: coord.shape > (1,) and coord.name() in ["time", "hour"], 

434 base.coords(), 

435 ), 

436 ), 

437 None, 

438 ) 

439 if not time_coord: 

440 logging.debug("No time coord, skipping equalisation.") 

441 return (base, other) 

442 base_time_coord = base.coord(time_coord) 

443 other_time_coord = other.coord(time_coord) 

444 logging.debug("Base: %s\nOther: %s", base_time_coord, other_time_coord) 

445 if time_coord == "hour": 

446 # We directly compare points when comparing coordinates with 

447 # non-absolute units, such as hour. We can't just check the units are 

448 # equal as iris automatically converts to datetime objects in the 

449 # comparison for certain coordinate names. 

450 base_times = base_time_coord.points 

451 other_times = other_time_coord.points 

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

453 else: 

454 # Units don't match, so converting to datetimes for comparison. 

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

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

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

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

459 time_constraint = iris.Constraint( 

460 coord_values={time_coord: lambda cell: cell.point in shared_times} 

461 ) 

462 # Extract points matching the shared times. 

463 base = base.extract(time_constraint) 

464 other = other.extract(time_constraint) 

465 if base is None or other is None: 

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

467 return (base, other) 

468 

469 

470def convert_units(cubes: iris.cube.Cube | iris.cube.CubeList, units: str): 

471 """Convert the units of a cube. 

472 

473 Arguments 

474 --------- 

475 cubes: iris.cube.Cube | iris.cube.CubeList 

476 A Cube or CubeList of a field for its units to be converted. 

477 

478 units: str 

479 The unit that the original field is to be converted to. It takes 

480 CF compliant units. 

481 

482 Returns 

483 ------- 

484 iris.cube.Cube | iris.cube.CubeList 

485 The field converted into the specified units. 

486 

487 Examples 

488 -------- 

489 >>> T_in_F = misc.convert_units(temperature_in_K, "Fahrenheit") 

490 

491 """ 

492 new_cubelist = iris.cube.CubeList([]) 

493 for cube in iter_maybe(cubes): 

494 # Copy cube to keep original data. 

495 cube_a = cube.copy() 

496 # Convert cube units. 

497 cube_a.convert_units(units) 

498 new_cubelist.append(cube_a) 

499 if len(new_cubelist) == 1: 

500 return new_cubelist[0] 

501 else: 

502 return new_cubelist 

503 

504 

505def rename_cube(cubes: iris.cube.Cube | iris.cube.CubeList, name: str): 

506 """Rename a cube. 

507 

508 Arguments 

509 --------- 

510 cubes: iris.cube.Cube | iris.cube.CubeList 

511 A Cube or CubeList of a field to be renamed. 

512 

513 name: str 

514 The new name of the cube. It should be CF compliant. 

515 

516 Returns 

517 ------- 

518 iris.cube.Cube | iris.cube.CubeList 

519 The renamed field. 

520 

521 Notes 

522 ----- 

523 This operator is designed to be used when the output field name does not 

524 match expectations or needs to be different to defaults in standard_name, var_name or 

525 long_name. For example, if combining masks 

526 to create light rain you would like the field to be named "mask_for_light_rain" 

527 rather than "mask_for_microphysical_precip_gt_0.0_x_mask_for_microphysical_precip_lt_2.0". 

528 

529 Examples 

530 -------- 

531 >>> light_rain_mask = misc.rename_cube(light_rain_mask,"mask_for_light_rainfall" 

532 """ 

533 new_cubelist = iris.cube.CubeList([]) 

534 for cube in iter_maybe(cubes): 

535 cube.rename(name) 

536 new_cubelist.append(cube) 

537 if len(new_cubelist) == 1: 

538 return new_cubelist[0] 

539 else: 

540 return new_cubelist 

541 

542 

543def _slice_cube_on_levels(cube: iris.cube.Cube, coord_name: str, levels: list): 

544 """ 

545 Extract levels from a cube for a given coordinate. 

546 

547 Arguments 

548 --------- 

549 cube: iris.cube.Cube 

550 A Cube to be sliced. 

551 

552 coord_name: str 

553 The coordinate name to be sliced 

554 

555 levels: list 

556 A list containing points to be extracted from the cube. 

557 

558 Returns 

559 ------- 

560 iris.cube.Cube 

561 The sliced cube. 

562 """ 

563 coord = cube.coord(coord_name) 

564 (dim_index,) = cube.coord_dims(coord) 

565 

566 mask = np.isin(coord.points, levels) 

567 

568 slicer = [slice(None)] * cube.ndim 

569 slicer[dim_index] = mask 

570 

571 return cube[tuple(slicer)] 

572 

573 

574def extract_common_points(cubes: iris.cube.CubeList, coordinate: str): 

575 """ 

576 Extract common points for a given coordinate between two cubes. 

577 

578 Parameters 

579 ---------- 

580 cubes: iris.cube.CubeList 

581 CubeList containing exactly two cubes. 

582 

583 coordinate: str 

584 The coordinate name to be checked for common levels. 

585 

586 Returns 

587 ------- 

588 iris.cube.CubeList 

589 CubeList containing the two cubes sliced to common levels 

590 for the given coordinate. 

591 """ 

592 # Check type of input 

593 if type(cubes) is not iris.cube.CubeList: 

594 raise TypeError(f"Not a CubeList, got type {type(cubes)}") 

595 

596 # Check that only two cubes are passed into function. 

597 if len(cubes) != 2: 

598 raise ValueError(f"Maximum of two cubes allowed, received {len(cubes)}") 

599 

600 # Extract coordinate 

601 try: 

602 p1 = cubes[0].coord(coordinate) 

603 p2 = cubes[1].coord(coordinate) 

604 except iris.exceptions.CoordinateNotFoundError as err: 

605 raise ValueError(f"Both cubes must have an {coordinate} coordinate") from err 

606 

607 # Find common points 

608 common_points = np.intersect1d(p1.points, p2.points) 

609 

610 # Check that common points is more than zero. 

611 if common_points.size == 0: 

612 raise ValueError("No common levels found") 

613 

614 # Extract common points 

615 cube0_common = _slice_cube_on_levels(cubes[0], coordinate, common_points) 

616 cube1_common = _slice_cube_on_levels(cubes[1], coordinate, common_points) 

617 

618 return iris.cube.CubeList([cube0_common, cube1_common]) 

619 

620 

621def differentiate( 

622 cubes: iris.cube.Cube | iris.cube.CubeList, coordinate: str, **kwargs 

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

624 """Differentiate a cube on a specified coordinate. 

625 

626 Arguments 

627 --------- 

628 cubes: iris.cube.Cube | iris.cube.CubeList 

629 A Cube or CubeList of a field that is to be differentiated. 

630 

631 coordinate: str 

632 The coordinate that is to be differentiated over. 

633 

634 Returns 

635 ------- 

636 iris.cube.Cube | iris.cube.CubeList 

637 The differential of the cube along the specified coordinate. 

638 

639 Notes 

640 ----- 

641 The differential is calculated based on a carteisan grid. This calculation 

642 is then suitable for vertical and temporal derivatives. It is not sensible 

643 for horizontal derivatives if they are based on spherical coordinates (e.g. 

644 latitude and longitude). In essence this operator is a CSET wrapper around 

645 `iris.analysis.calculus.differentiate <https://scitools-iris.readthedocs.io/en/stable/generated/api/iris.analysis.calculus.html#iris.analysis.calculus.differentiate>`_. 

646 

647 Examples 

648 -------- 

649 >>> dT_dz = misc.differentiate(temperature, "altitude") 

650 """ 

651 new_cubelist = iris.cube.CubeList([]) 

652 for cube in iter_maybe(cubes): 

653 dcube = iris.analysis.calculus.differentiate(cube, coordinate) 

654 new_cubelist.append(dcube) 

655 if len(new_cubelist) == 1: 

656 return new_cubelist[0] 

657 else: 

658 return new_cubelist