Coverage for src/CSET/operators/misc.py: 98%
172 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 08:32 +0000
« 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.
15"""Miscellaneous operators."""
17import itertools
18import logging
19from collections.abc import Iterable
20from functools import reduce
22import iris
23import iris.analysis.calculus
24import numpy as np
25from iris.cube import Cube, CubeList
27from CSET._common import is_increasing, iter_maybe
28from CSET.operators._utils import fully_equalise_attributes, get_cube_yxcoordname
29from CSET.operators.regrid import regrid_onto_cube
31logger = logging.getLogger(__name__)
34def noop(x, **kwargs):
35 """Return its input without doing anything to it.
37 Useful for constructing diagnostic chains.
39 Arguments
40 ---------
41 x: Any
42 Input to return.
44 Returns
45 -------
46 x: Any
47 The input that was given.
48 """
49 return x
52def remove_attribute(
53 cubes: Cube | CubeList, attribute: str | Iterable, **kwargs
54) -> CubeList:
55 """Remove a cube attribute.
57 If the attribute is not on the cube, the cube is passed through unchanged.
59 Arguments
60 ---------
61 cubes: Cube | CubeList
62 One or more cubes to remove the attribute from.
63 attribute: str | Iterable
64 Name of attribute (or Iterable of names) to remove.
66 Returns
67 -------
68 cubes: CubeList
69 CubeList of cube(s) with the attribute removed.
70 """
71 # Ensure cubes is a CubeList.
72 if not isinstance(cubes, CubeList):
73 cubes = CubeList(iter_maybe(cubes))
75 for cube in cubes:
76 for attr in iter_maybe(attribute):
77 cube.attributes.pop(attr, None)
79 # Combine things that can be merged due to remove removing the
80 # attributes.
81 cubes = cubes.merge()
82 # combine items that can be merged after removing unwanted attributes
83 cubes = cubes.concatenate()
84 return cubes
87def remove_scalar_coords(
88 cubes: Cube | CubeList, coords: str | Iterable[str]
89) -> CubeList:
90 """Remove scalar coordinates from one or more cubes.
92 Coordinates are only removed if they exist on the cube and are
93 scalar coordinates (i.e. have no associated dimensions). Examples
94 include ``realization`` and ``forecast_reference_time`` on model
95 data cubes. Dimensional and non-scalar auxiliary coordinates are
96 left unchanged.
98 Arguments
99 ---------
100 cubes: Cube | CubeList
101 One or more cubes from which scalar coordinates will be removed.
102 coords: str | Iterable
103 Name of a coordinate (or Iterable of coordinate names) to remove.
105 Returns
106 -------
107 cubes: CubeList
108 CubeList of cube(s) with the requested scalar coordinates
109 removed where present.
110 """
111 if not isinstance(cubes, CubeList): 111 ↛ 114line 111 didn't jump to line 114 because the condition on line 111 was always true
112 cubes = CubeList(iter_maybe(cubes))
114 if isinstance(coords, str): 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true
115 coords = [coords]
117 for cube in cubes:
118 for coord_name in iter_maybe(coords):
119 if cube.coords(coord_name): 119 ↛ 118line 119 didn't jump to line 118 because the condition on line 119 was always true
120 coord = cube.coord(coord_name)
121 # only remove if scalar
122 if cube.coord_dims(coord) == ():
123 cube.remove_coord(coord)
125 return cubes
128def addition(addend_1, addend_2):
129 """Addition of two fields.
131 Parameters
132 ----------
133 addend_1: Cube
134 Any field to have another field added to it.
135 addend_2: Cube
136 Any field to be added to another field.
138 Returns
139 -------
140 Cube
142 Raises
143 ------
144 ValueError, iris.exceptions.NotYetImplementedError
145 When the cubes are not compatible.
147 Notes
148 -----
149 This is a simple operator designed for combination of diagnostics or
150 creating new diagnostics by using recipes.
152 Examples
153 --------
154 >>> field_addition = misc.addition(kinetic_energy_u, kinetic_energy_v)
156 """
157 return addend_1 + addend_2
160def subtraction(
161 minuend: Cube | CubeList, subtrahend: Cube | CubeList
162) -> Cube | CubeList:
163 """Subtraction of two fields.
165 Parameters
166 ----------
167 minuend: Cube | CubeList
168 Any field(s) to have another field subtracted from it.
169 subtrahend: Cube | CubeList
170 Any field(s) to be subtracted from to another field.
172 Returns
173 -------
174 Cube | CubeList
176 Raises
177 ------
178 ValueError, iris.exceptions.NotYetImplementedError
179 When the cubes are not compatible.
181 Notes
182 -----
183 This is a simple operator designed for combination of diagnostics or
184 creating new diagnostics by using recipes. It can be used for model
185 differences to allow for comparisons between the same field in different
186 models or model configurations.
188 If called with 2 Cubes as input, will return a difference cube.
189 If called with 2 CubeLists as input, with return a CubeList of differences.
190 If called with CubeList as minuend and Cube as subtrahend, will return CubeList of differences subtracting Cube from each element of input CubeList.
191 If called with Cube as minuend and CubeList as subtrahend, will return CubeList of differences subtracting each element on CubeList from input Cube.
193 Examples
194 --------
195 >>> model_diff = misc.subtraction(temperature_model_A, temperature_model_B)
197 """
199 def subtract_preserve_attributes(cube_a: Cube, cube_b: Cube) -> Cube:
200 result = cube_a - cube_b
201 result.attributes.update(cube_a.attributes)
202 return result
204 # Case where both inputs are single cubes
205 if isinstance(minuend, iris.cube.Cube) and isinstance(subtrahend, iris.cube.Cube):
206 return subtract_preserve_attributes(minuend, subtrahend)
208 # Check if minuend is iterable
209 cubes_a = iter_maybe(minuend)
211 # Case: subtrahend also iterable
212 if isinstance(subtrahend, iris.cube.CubeList):
213 cubes_b = iter_maybe(subtrahend)
214 if isinstance(minuend, iris.cube.CubeList):
215 # Case: subtract cubelist from cubelist - assume both same sizes
216 result = iris.cube.CubeList(
217 [
218 subtract_preserve_attributes(cube_a, cube_b)
219 for cube_a, cube_b in zip(cubes_a, cubes_b, strict=True)
220 ]
221 )
222 else:
223 # Case: subtract each element of subtrahend from single cube
224 result = iris.cube.CubeList(
225 [subtract_preserve_attributes(minuend, cube_b) for cube_b in cubes_b]
226 )
227 else:
228 # Case: subtract single cube from each minuend
229 result = iris.cube.CubeList(
230 [subtract_preserve_attributes(cube_a, subtrahend) for cube_a in cubes_a]
231 )
233 # Return single cube if only one result, else return CubeList
234 return result[0] if len(result) == 1 else result
237def division(numerator, denominator):
238 """Division of two fields.
240 Parameters
241 ----------
242 numerator: Cube
243 Any field to have the ratio taken with respect to another field.
244 denominator: Cube
245 Any field used to divide another field or provide the reference
246 value in a ratio.
248 Returns
249 -------
250 Cube
252 Raises
253 ------
254 ValueError
255 When the cubes are not compatible.
257 Notes
258 -----
259 This is a simple operator designed for combination of diagnostics or
260 creating new diagnostics by using recipes.
262 Examples
263 --------
264 >>> bowen_ratio = misc.division(sensible_heat_flux, latent_heat_flux)
266 """
267 return numerator / denominator
270def multiplication(
271 multiplicand: Cube | CubeList, multiplier: Cube | CubeList
272) -> Cube | CubeList:
273 """Multiplication of two fields.
275 Parameters
276 ----------
277 multiplicand: Cube | CubeList
278 Any field to be multiplied by another field.
279 multiplier: Cube | CubeList
280 Any field to be multiplied to another field.
282 Returns
283 -------
284 Cube | CubeList
285 The result of multiplicand x multiplier.
287 Raises
288 ------
289 ValueError
290 When the cubes are not compatible.
292 Notes
293 -----
294 This is a simple operator designed for combination of diagnostics or
295 creating new diagnostics by using recipes. CubeLists are multiplied
296 on a strict ordering (e.g. first cube with first cube).
298 Examples
299 --------
300 >>> filtered_CAPE_ratio = misc.multiplication(CAPE_ratio, inflow_layer_properties)
302 """
303 new_cubelist = iris.cube.CubeList([])
304 for cube_a, cube_b in zip(
305 iter_maybe(multiplicand), iter_maybe(multiplier), strict=True
306 ):
307 multiplied_cube = cube_a * cube_b
308 multiplied_cube.rename(f"{cube_a.name()}_x_{cube_b.name()}")
309 new_cubelist.append(multiplied_cube)
310 if len(new_cubelist) == 1:
311 return new_cubelist[0]
312 else:
313 return new_cubelist
316def combine_cubes_into_cubelist(first: Cube | CubeList, **kwargs) -> CubeList:
317 """Operator that combines multiple cubes or CubeLists into one.
319 Arguments
320 ---------
321 first: Cube | CubeList
322 First cube or CubeList to merge into CubeList.
323 second: Cube | CubeList
324 Second cube or CubeList to merge into CubeList. This must be a named
325 argument.
326 third: Cube | CubeList
327 There can be any number of additional arguments, they just need unique
328 names.
329 ...
331 Returns
332 -------
333 combined_cubelist: CubeList
334 Combined CubeList containing all cubes/CubeLists.
336 Raises
337 ------
338 TypeError:
339 If the provided arguments are not either a Cube or CubeList.
340 """
341 # Create empty CubeList to store cubes/CubeList.
342 all_cubes = CubeList()
343 # Combine all CubeLists into a single flat iterable.
344 for item in itertools.chain(iter_maybe(first), *map(iter_maybe, kwargs.values())):
345 # Check each item is a Cube, erroring if not.
346 if isinstance(item, Cube):
347 # Add cube to CubeList.
348 all_cubes.append(item)
349 else:
350 raise TypeError("Not a Cube or CubeList!", item)
351 return all_cubes
354def difference(cubes: CubeList):
355 """Difference of two fields.
357 Parameters
358 ----------
359 cubes: CubeList
360 A list of exactly two cubes. One must have the cset_comparison_base
361 attribute set to 1, and will be used as the base of the comparison.
363 Returns
364 -------
365 Cube
367 Raises
368 ------
369 ValueError
370 When the cubes are not compatible.
372 Notes
373 -----
374 This is a simple operator designed for combination of diagnostics or
375 creating new diagnostics by using recipes. It can be used for model
376 differences to allow for comparisons between the same field in different
377 models or model configurations.
379 Examples
380 --------
381 >>> model_diff = misc.difference(temperature_model_A, temperature_model_B)
383 """
384 if len(cubes) != 2:
385 raise ValueError("cubes should contain exactly 2 cubes.")
386 base: Cube = cubes.extract_cube(iris.AttributeConstraint(cset_comparison_base=1))
387 other: Cube = cubes.extract_cube(
388 iris.Constraint(
389 cube_func=lambda cube: "cset_comparison_base" not in cube.attributes
390 )
391 )
393 # If cubes contain a pressure coordinate, ensure it is increasing.
394 for cube in cubes:
395 try:
396 if len(cube.coord("pressure").points) > 2 and not is_increasing(
397 cube.coord("pressure").points
398 ):
399 cube.data = np.flip(cube.data, axis=cube.coord_dims("pressure")[0])
401 except iris.exceptions.CoordinateNotFoundError:
402 pass
404 # Get spatial coord names.
405 base_lat_name, base_lon_name = get_cube_yxcoordname(base)
406 other_lat_name, other_lon_name = get_cube_yxcoordname(other)
408 # Ensure cubes to compare are on common differencing grid.
409 # This is triggered if either
410 # i) latitude and longitude shapes are not the same. Note grid points
411 # are not compared directly as these can differ through rounding
412 # errors.
413 # ii) or variables are known to often sit on different grid staggering
414 # in different models (e.g. cell center vs cell edge), as is the case
415 # for UM and LFRic comparisons.
416 # In future greater choice of regridding method might be applied depending
417 # on variable type. Linear regridding can in general be appropriate for smooth
418 # variables. Care should be taken with interpretation of differences
419 # given this dependency on regridding.
420 if (
421 base.coord(base_lat_name).shape != other.coord(other_lat_name).shape
422 or base.coord(base_lon_name).shape != other.coord(other_lon_name).shape
423 ) or (
424 base.long_name
425 in [
426 "eastward_wind_at_10m",
427 "northward_wind_at_10m",
428 "northward_wind_at_cell_centres",
429 "eastward_wind_at_cell_centres",
430 "zonal_wind_at_pressure_levels",
431 "meridional_wind_at_pressure_levels",
432 "potential_vorticity_at_pressure_levels",
433 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging",
434 ]
435 ):
436 logger.debug("Linear regridding base cube to other grid to compute differences")
437 base = regrid_onto_cube(base, other, method="Linear")
439 # Figure out if we are comparing between UM and LFRic; flip array if so.
440 base_lat_direction = is_increasing(base.coord(base_lat_name).points)
441 other_lat_direction = is_increasing(other.coord(other_lat_name).points)
442 if base_lat_direction != other_lat_direction:
443 other.data = np.flip(other.data, other.coord(other_lat_name).cube_dims(other))
445 # Extract just common time points.
446 base, other = _extract_common_time_points(base, other)
448 # Equalise attributes so we can merge.
449 fully_equalise_attributes([base, other])
450 logger.debug("Base: %s\nOther: %s", base, other)
452 # This currently relies on the cubes having the same underlying data layout.
453 difference = base.copy()
455 # Differences don't have a standard name; long name gets a suffix. We are
456 # assuming we can rely on cubes having a long name, so we don't check for
457 # its presents.
458 difference.standard_name = None
459 difference.long_name = (
460 base.long_name if base.long_name else base.name()
461 ) + "_difference"
462 if base.var_name:
463 difference.var_name = base.var_name + "_difference"
464 elif base.standard_name:
465 difference.var_name = base.standard_name + "_difference"
467 difference.data = other.data - base.data
468 return difference
471def _extract_common_time_points(base: Cube, other: Cube) -> tuple[Cube, Cube]:
472 """Extract common time points from cubes to allow comparison."""
473 # Get the name of the first non-scalar time coordinate.
474 time_coord = next(
475 (
476 coord.name()
477 for coord in filter(
478 lambda coord: coord.shape > (1,) and coord.name() in ["time", "hour"],
479 base.coords(),
480 )
481 ),
482 None,
483 )
484 if not time_coord:
485 logger.debug("No time coord, skipping equalisation.")
486 return (base, other)
487 base_time_coord = base.coord(time_coord)
488 other_time_coord = other.coord(time_coord)
489 logger.debug("Base: %s\nOther: %s", base_time_coord, other_time_coord)
490 if time_coord == "hour":
491 # We directly compare points when comparing coordinates with
492 # non-absolute units, such as hour. We can't just check the units are
493 # equal as iris automatically converts to datetime objects in the
494 # comparison for certain coordinate names.
495 base_times = base_time_coord.points
496 other_times = other_time_coord.points
497 shared_times = set.intersection(set(base_times), set(other_times))
498 else:
499 # Units don't match, so converting to datetimes for comparison.
500 base_times = base_time_coord.units.num2date(base_time_coord.points)
501 other_times = other_time_coord.units.num2date(other_time_coord.points)
502 shared_times = set.intersection(set(base_times), set(other_times))
503 logger.debug("Shared times: %s", shared_times)
504 time_constraint = iris.Constraint(
505 coord_values={
506 time_coord: lambda cell, shared_times=shared_times: (
507 cell.point in shared_times
508 )
509 }
510 )
511 # Extract points matching the shared times.
512 base = base.extract(time_constraint)
513 other = other.extract(time_constraint)
514 if base is None or other is None:
515 raise ValueError("No common time points found!")
516 return (base, other)
519def convert_units(cubes: iris.cube.Cube | iris.cube.CubeList, units: str):
520 """Convert the units of a cube.
522 Arguments
523 ---------
524 cubes: iris.cube.Cube | iris.cube.CubeList
525 A Cube or CubeList of a field for its units to be converted.
527 units: str
528 The unit that the original field is to be converted to. It takes
529 CF compliant units.
531 Returns
532 -------
533 iris.cube.Cube | iris.cube.CubeList
534 The field converted into the specified units.
536 Examples
537 --------
538 >>> T_in_F = misc.convert_units(temperature_in_K, "Fahrenheit")
540 """
541 new_cubelist = iris.cube.CubeList([])
542 for cube in iter_maybe(cubes):
543 # Copy cube to keep original data.
544 cube_a = cube.copy()
545 # Convert cube units.
546 cube_a.convert_units(units)
547 new_cubelist.append(cube_a)
548 if len(new_cubelist) == 1:
549 return new_cubelist[0]
550 else:
551 return new_cubelist
554def rename_cube(cubes: iris.cube.Cube | iris.cube.CubeList, name: str):
555 """Rename a cube.
557 Arguments
558 ---------
559 cubes: iris.cube.Cube | iris.cube.CubeList
560 A Cube or CubeList of a field to be renamed.
562 name: str
563 The new name of the cube. It should be CF compliant.
565 Returns
566 -------
567 iris.cube.Cube | iris.cube.CubeList
568 The renamed field.
570 Notes
571 -----
572 This operator is designed to be used when the output field name does not
573 match expectations or needs to be different to defaults in standard_name, var_name or
574 long_name. For example, if combining masks
575 to create light rain you would like the field to be named "mask_for_light_rain"
576 rather than "mask_for_microphysical_precip_gt_0.0_x_mask_for_microphysical_precip_lt_2.0".
578 Examples
579 --------
580 >>> light_rain_mask = misc.rename_cube(light_rain_mask,"mask_for_light_rainfall"
581 """
582 new_cubelist = iris.cube.CubeList([])
583 for cube in iter_maybe(cubes):
584 cube.rename(name)
585 new_cubelist.append(cube)
586 if len(new_cubelist) == 1:
587 return new_cubelist[0]
588 else:
589 return new_cubelist
592def _slice_cube_on_levels(cube: iris.cube.Cube, coord_name: str, levels: list):
593 """
594 Extract levels from a cube for a given coordinate.
596 Arguments
597 ---------
598 cube: iris.cube.Cube
599 A Cube to be sliced.
601 coord_name: str
602 The coordinate name to be sliced
604 levels: list
605 A list containing points to be extracted from the cube.
607 Returns
608 -------
609 iris.cube.Cube
610 The sliced cube.
611 """
612 coord = cube.coord(coord_name)
613 (dim_index,) = cube.coord_dims(coord)
615 mask = np.isin(coord.points, levels)
617 slicer = [slice(None)] * cube.ndim
618 slicer[dim_index] = mask
620 return cube[tuple(slicer)]
623def extract_common_points(cubes: iris.cube.CubeList, coordinate: str):
624 """
625 Extract common points for a given coordinate between cubes in a CubeList.
627 Parameters
628 ----------
629 cubes: iris.cube.CubeList
630 CubeList containing cubes for which to extract common points.
632 coordinate: str
633 The coordinate name to be checked for common points.
635 Returns
636 -------
637 iris.cube.CubeList
638 CubeList containing the two cubes sliced to common points
639 for the given coordinate.
640 """
641 # Check type of input
642 if type(cubes) is not iris.cube.CubeList:
643 raise TypeError(f"Not a CubeList, got type {type(cubes)}")
645 # Extract coordinate
646 try:
647 points_list = []
648 for cube in cubes:
649 points_list.append(cube.coord(coordinate).points)
650 except iris.exceptions.CoordinateNotFoundError as err:
651 raise ValueError(f"Both cubes must have an {coordinate} coordinate") from err
653 # Find common points
654 common_points = reduce(np.intersect1d, points_list)
656 # Check that common points is more than zero.
657 if common_points.size == 0:
658 raise ValueError("No common levels found")
660 # Extract common points
661 common_cubes = iris.cube.CubeList()
662 for cube in cubes:
663 common_cubes.append(_slice_cube_on_levels(cube, coordinate, common_points))
665 return common_cubes
668def differentiate(
669 cubes: iris.cube.Cube | iris.cube.CubeList, coordinate: str, **kwargs
670) -> iris.cube.Cube | iris.cube.CubeList:
671 """Differentiate a cube on a specified coordinate.
673 Arguments
674 ---------
675 cubes: iris.cube.Cube | iris.cube.CubeList
676 A Cube or CubeList of a field that is to be differentiated.
678 coordinate: str
679 The coordinate that is to be differentiated over.
681 Returns
682 -------
683 iris.cube.Cube | iris.cube.CubeList
684 The differential of the cube along the specified coordinate.
686 Notes
687 -----
688 The differential is calculated based on a carteisan grid. This calculation
689 is then suitable for vertical and temporal derivatives. It is not sensible
690 for horizontal derivatives if they are based on spherical coordinates (e.g.
691 latitude and longitude). In essence this operator is a CSET wrapper around
692 `iris.analysis.calculus.differentiate <https://scitools-iris.readthedocs.io/en/stable/generated/api/iris.analysis.calculus.html#iris.analysis.calculus.differentiate>`_.
694 Examples
695 --------
696 >>> dT_dz = misc.differentiate(temperature, "altitude")
697 """
698 new_cubelist = iris.cube.CubeList([])
699 for cube in iter_maybe(cubes):
700 dcube = iris.analysis.calculus.differentiate(cube, coordinate)
701 new_cubelist.append(dcube)
702 if len(new_cubelist) == 1:
703 return new_cubelist[0]
704 else:
705 return new_cubelist