Coverage for src/CSET/operators/read.py: 94%
439 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-20 15:26 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-20 15:26 +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"""Operators for reading various types of files from disk."""
17import ast
18import datetime
19import functools
20import glob
21import itertools
22import logging
23from pathlib import Path
24from typing import Literal
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
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)
44logger = logging.getLogger(__name__)
47class NoDataError(FileNotFoundError):
48 """Error that no data has been loaded."""
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.
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.
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.
70 Deterministic data will be loaded with a realization of 0, allowing it to be
71 processed in the same way as ensemble data.
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.
88 Returns
89 -------
90 cubes: iris.cube.Cube
91 Cube loaded
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 )
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.
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.
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.
135 Deterministic data will be loaded with a realization of 0, allowing it to be
136 processed in the same way as ensemble data.
138 Data output by XIOS (such as LFRic) has its per-file metadata removed so
139 that the cubes merge across files.
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.
156 Returns
157 -------
158 cubes: iris.cube.CubeList
159 Cubes loaded after being merged and concatenated.
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)
170 # flattens model_names if needed into one dimensional list.
171 if model_names != (None,):
172 flat = []
173 for item in model_names:
174 if isinstance(item, list): 174 ↛ 175line 174 didn't jump to line 175 because the condition on line 174 was never true
175 flat.extend(item)
176 else:
177 flat.append(item)
178 model_names = flat
180 # Check we have appropriate number of model names.
181 if model_names != (None,) and len(model_names) != len(paths):
182 raise ValueError(
183 f"The number of model names ({len(model_names)}) should equal "
184 f"the number of paths given ({len(paths)})."
185 )
187 # Load the data for each model into a CubeList per model.
188 model_cubes = (
189 _load_model(path, name, constraint)
190 for path, name in itertools.zip_longest(paths, model_names, fillvalue=None)
191 )
193 # Split out first model's cubes and mark it as the base for comparisons.
194 cubes = next(model_cubes)
195 for cube in cubes:
196 # Use 1 to indicate True, as booleans can't be saved in NetCDF attributes.
197 cube.attributes["cset_comparison_base"] = 1
199 # Load the rest of the models.
200 cubes.extend(itertools.chain.from_iterable(model_cubes))
202 # Enable different point-based observation sources to be concatenated.
203 cubes = _check_combine_point_observations(cubes)
205 # Unify time units so different case studies can merge.
206 iris.util.unify_time_units(cubes)
208 # Select sub region.
209 cubes = _cutout_cubes(cubes, subarea_type, subarea_extent)
211 # Merge and concatenate cubes now metadata has been fixed.
212 cubes = _merge_cubes_check_ensemble(cubes)
213 cubes = cubes.concatenate()
215 # Squeeze single valued coordinates into scalar coordinates.
216 cubes = iris.cube.CubeList(iris.util.squeeze(cube) for cube in cubes)
218 # Ensure dimension coordinates are bounded.
219 for cube in cubes:
220 for dim_coord in cube.coords(dim_coords=True):
221 if (dim_coord.standard_name == "time") and (
222 dim_coord.name()
223 not in itertools.chain.from_iterable(
224 m.coord_names for m in cube.cell_methods if m.method != "point"
225 )
226 ):
227 # Instantaneous time coordinate
228 continue
229 # Iris can't guess the bounds of a scalar coordinate.
230 if not dim_coord.has_bounds() and dim_coord.shape[0] > 1:
231 dim_coord.guess_bounds()
233 logger.info("Loaded cubes: %s", cubes)
234 if len(cubes) == 0:
235 raise NoDataError("No cubes loaded, check your constraints!")
236 return cubes
239def _load_model(
240 paths: str | list[str],
241 model_name: str | None,
242 constraint: iris.Constraint | None,
243) -> iris.cube.CubeList:
244 """Load a single model's data into a CubeList."""
245 input_files = _check_input_files(paths)
246 # If unset, a constraint of None lets everything be loaded.
247 logger.debug("Constraint: %s", constraint)
248 cubes = iris.load(input_files, constraint, callback=_loading_callback)
249 # If required, compute wind_speed from components.
250 cubes = _compute_winds(cubes)
252 # Add model_name attribute to each cube to make it available at any further
253 # step without needing to pass it as function parameter.
254 if model_name is not None:
255 for cube in cubes:
256 cube.attributes["model_name"] = model_name
257 return cubes
260def _check_input_files(input_paths: str | list[str]) -> list[Path]:
261 """Get an iterable of files to load, and check that they all exist.
263 Arguments
264 ---------
265 input_paths: list[str]
266 List of paths to input files or directories. The path may itself contain
267 glob patterns, but unlike in shells it will match directly first.
269 Returns
270 -------
271 list[Path]
272 A list of files to load.
274 Raises
275 ------
276 FileNotFoundError:
277 If the provided arguments don't resolve to at least one existing file.
278 """
279 files = []
280 for raw_filename in iter_maybe(input_paths):
281 # Match glob-like files first, if they exist.
282 raw_path = Path(raw_filename)
283 if raw_path.is_file():
284 files.append(raw_path)
285 else:
286 for input_path in glob.glob(raw_filename):
287 # Convert string paths into Path objects.
288 input_path = Path(input_path)
289 # Get the list of files in the directory, or use it directly.
290 if input_path.is_dir():
291 logger.debug("Checking directory '%s' for files", input_path)
292 files.extend(p for p in input_path.iterdir() if p.is_file())
293 else:
294 files.append(input_path)
296 files.sort()
297 logger.info("Loading files:\n%s", "\n".join(str(path) for path in files))
298 if len(files) == 0:
299 raise FileNotFoundError(f"No files found for {input_paths}")
300 return files
303def _merge_cubes_check_ensemble(cubes: iris.cube.CubeList):
304 """Merge CubeList, renumbering realizations of 0 if required.
306 An unsuccessful merge indicates common input cube attributes, most
307 commonly from ensemble members missing an explicit realization
308 coordinate. Therefore the members are renumbered before being merged
309 again.
310 """
311 try:
312 cubes = cubes.merge()
313 except iris.exceptions.MergeError:
314 _log_once(
315 "Attempt to merge input CubeList failed. Attempting to iterate realization coords to enable merge.",
316 level=logging.WARNING,
317 )
318 for ir, cube in enumerate(cubes):
319 if cube.coord("realization").points == 0: 319 ↛ 318line 319 didn't jump to line 318 because the condition on line 319 was always true
320 cube.coord("realization").points = ir + 1
321 cubes = cubes.merge()
322 return cubes
325def _cutout_cubes(
326 cubes: iris.cube.CubeList,
327 subarea_type: Literal["gridcells", "realworld", "modelrelative"] | None,
328 subarea_extent: list[float],
329):
330 """Cut out a subarea from a CubeList."""
331 if subarea_type is None:
332 logger.debug("Subarea selection is disabled.")
333 return cubes
335 # If selected, cutout according to number of grid cells to trim from each edge.
336 cutout_cubes = iris.cube.CubeList()
337 # Find spatial coordinates
338 for cube in cubes:
339 # Find dimension coordinates.
340 lat_name, lon_name = get_cube_yxcoordname(cube)
342 # Compute cutout based on number of cells to trim from edges.
343 if subarea_type == "gridcells":
344 logger.debug(
345 "User requested LowerTrim: %s LeftTrim: %s UpperTrim: %s RightTrim: %s",
346 subarea_extent[0],
347 subarea_extent[1],
348 subarea_extent[2],
349 subarea_extent[3],
350 )
351 lat_points = np.sort(cube.coord(lat_name).points)
352 lon_points = np.sort(cube.coord(lon_name).points)
353 # Define cutout region using user provided cell points.
354 lats = [lat_points[subarea_extent[0]], lat_points[-subarea_extent[2] - 1]]
355 lons = [lon_points[subarea_extent[1]], lon_points[-subarea_extent[3] - 1]]
357 # Compute cutout based on specified coordinate values.
358 elif subarea_type == "realworld" or subarea_type == "modelrelative":
359 # If not gridcells, cutout by requested geographic area,
360 logger.debug(
361 "User requested LLat: %s ULat: %s LLon: %s ULon: %s",
362 subarea_extent[0],
363 subarea_extent[1],
364 subarea_extent[2],
365 subarea_extent[3],
366 )
367 # Define cutout region using user provided coordinates.
368 lats = np.array(subarea_extent[0:2])
369 lons = np.array(subarea_extent[2:4])
370 # Ensure cutout longitudes are within +/- 180.0 bounds.
371 while lons[0] < -180.0:
372 lons += 360.0
373 while lons[1] > 180.0:
374 lons -= 360.0
375 # If the coordinate system is rotated we convert coordinates into
376 # model-relative coordinates to extract the appropriate cutout.
377 coord_system = cube.coord(lat_name).coord_system
378 if subarea_type == "realworld" and isinstance(
379 coord_system, iris.coord_systems.RotatedGeogCS
380 ):
381 lons, lats = rotate_pole(
382 lons,
383 lats,
384 pole_lon=coord_system.grid_north_pole_longitude,
385 pole_lat=coord_system.grid_north_pole_latitude,
386 )
387 else:
388 raise ValueError("Unknown subarea_type:", subarea_type)
390 # Do cutout and add to cutout_cubes.
391 intersection_args = {lat_name: lats, lon_name: lons}
392 logger.debug("Cutting out coords: %s", intersection_args)
393 try:
394 cutout_cubes.append(cube.intersection(**intersection_args))
395 except IndexError as err:
396 raise ValueError(
397 "Region cutout error. Check and update SUBAREA_EXTENT."
398 "Cutout region requested should be contained within data area. "
399 "Also check if cutout region requested is smaller than input grid spacing."
400 ) from err
402 return cutout_cubes
405def _loading_callback(cube: iris.cube.Cube, field, filename: str) -> iris.cube.Cube:
406 """Compose together the needed callbacks into a single function."""
407 # Most callbacks operate in-place, but save the cube when returned!
408 _realization_callback(cube)
409 _um_normalise_callback(cube)
410 _lfric_normalise_callback(cube)
411 _nimrod_normalise_callback(cube)
412 cube = _lfric_time_coord_fix_callback(cube)
413 _normalise_var0_varname(cube)
414 cube = _fix_no_spatial_coords_callback(cube)
415 _fix_spatial_coords_callback(cube)
416 _fix_pressure_coord_callback(cube)
417 _fix_um_radtime(cube)
418 _fix_cell_methods(cube)
419 cube = _convert_cube_units_callback(cube)
420 cube = _grid_longitude_fix_callback(cube)
421 _fix_lfric_cloud_base_altitude(cube)
422 _proleptic_gregorian_fix(cube)
423 _lfric_time_callback(cube)
424 _lfric_forecast_period_callback(cube)
425 cube = _fix_no_time_coords_callback(cube)
426 _normalise_longname(cube)
427 return cube
430def _realization_callback(cube):
431 """Add a realization coordinate initialised to 0 if missing.
433 This means deterministic and ensemble cubes can assume realization coordinate through the rest
434 of the code.
435 """
436 # Only add if realization coordinate does not exist.
437 if not cube.coords("realization"):
438 cube.add_aux_coord(
439 iris.coords.DimCoord(0, standard_name="realization", units="1")
440 )
443@functools.lru_cache(None)
444def _log_once(msg, level=logging.WARNING):
445 """Print a warning message, skipping recent duplicates."""
446 logger.log(level, msg)
449def _um_normalise_callback(cube: iris.cube.Cube):
450 """Normalise UM STASH variable long names to LFRic variable names.
452 Note standard names will remain associated with cubes where different.
453 Long name will be used consistently in output filename and titles.
454 """
455 # Convert STASH to LFRic variable name
456 if "STASH" in cube.attributes:
457 stash = cube.attributes["STASH"]
458 try:
459 (name, grid) = STASH_TO_LFRIC[str(stash)]
460 cube.long_name = name
461 except KeyError:
462 # Don't change cubes with unknown stash codes.
463 _log_once(
464 f"Unknown STASH code: {stash}. Please check file stash_to_lfric.py to update.",
465 level=logging.WARNING,
466 )
469def _lfric_normalise_callback(cube: iris.cube.Cube):
470 """Normalise attributes that prevents LFRic cube from merging.
472 The uuid and timeStamp relate to the output file, as saved by XIOS, and has
473 no relation to the data contained. These attributes are removed.
475 The um_stash_source is a list of STASH codes for when an LFRic field maps to
476 multiple UM fields, however it can be encoded in any order. This attribute
477 is sorted to prevent this. This attribute is only present in LFRic data that
478 has been converted to look like UM data.
479 """
480 # Remove unwanted attributes.
481 cube.attributes.pop("timeStamp", None)
482 cube.attributes.pop("uuid", None)
483 cube.attributes.pop("name", None)
484 cube.attributes.pop("source", None)
485 cube.attributes.pop("analysis_source", None)
486 cube.attributes.pop("history", None)
488 # Sort STASH code list.
489 stash_list = cube.attributes.get("um_stash_source")
490 if stash_list:
491 # Parse the string as a list, sort, then re-encode as a string.
492 cube.attributes["um_stash_source"] = str(sorted(ast.literal_eval(stash_list)))
495def _nimrod_normalise_callback(cube: iris.cube.Cube):
496 """Normalise attributes that prevents NIMROD radar cubes from merging."""
497 # Remove unwanted attributes.
498 cube.attributes.pop("radar_sites", None)
499 cube.attributes.pop("additional_radar_sites", None)
500 cube.attributes.pop("recursive_filter_iterations", None)
501 cube.attributes.pop("Probability methods", None)
504def _lfric_time_coord_fix_callback(cube: iris.cube.Cube) -> iris.cube.Cube:
505 """Ensure the time coordinate is a DimCoord rather than an AuxCoord.
507 The coordinate is converted and replaced if not. SLAMed LFRic data has this
508 issue, though the coordinate satisfies all the properties for a DimCoord.
509 Scalar time values are left as AuxCoords.
510 """
511 # This issue seems to come from iris's handling of NetCDF files where time
512 # always ends up as an AuxCoord.
513 if cube.coords("time"):
514 time_coord = cube.coord("time")
515 if (
516 not isinstance(time_coord, iris.coords.DimCoord)
517 and len(cube.coord_dims(time_coord)) == 1
518 ):
519 # Fudge the bounds to foil checking for strict monotonicity.
520 if ( 520 ↛ 524line 520 didn't jump to line 524 because the condition on line 520 was never true
521 time_coord.has_bounds()
522 and (time_coord.bounds[-1][0] - time_coord.bounds[0][0]) < 1.0e-8
523 ):
524 time_coord.bounds = [
525 [
526 time_coord.bounds[i][0] + 1.0e-8 * float(i),
527 time_coord.bounds[i][1],
528 ]
529 for i in range(len(time_coord.bounds))
530 ]
531 iris.util.promote_aux_coord_to_dim_coord(cube, time_coord)
532 return cube
535def _grid_longitude_fix_callback(cube: iris.cube.Cube) -> iris.cube.Cube:
536 """Check grid_longitude coordinates are in the range -180 deg to 180 deg.
538 This is necessary if comparing two models with different conventions --
539 for example, models where the prime meridian is defined as 0 deg or
540 360 deg. If not in the range -180 deg to 180 deg, we wrap the grid_longitude
541 so that it falls in this range. Checks are for near-180 bounds given
542 model data bounds may not extend exactly to 0. or 360.
543 Input cubes on non-rotated grid coordinates are not impacted.
544 """
545 try:
546 y, x = get_cube_yxcoordname(cube)
547 except ValueError:
548 # Don't modify non-spatial cubes.
549 return cube
551 long_coord = cube.coord(x)
552 # Wrap longitudes if rotated pole coordinates
553 coord_system = long_coord.coord_system
554 if x == "grid_longitude" and isinstance(
555 coord_system, iris.coord_systems.RotatedGeogCS
556 ):
557 long_points = long_coord.points.copy()
558 long_centre = np.median(long_points)
559 while long_centre < -175.0:
560 long_centre += 360.0
561 long_points += 360.0
562 while long_centre >= 175.0:
563 long_centre -= 360.0
564 long_points -= 360.0
565 long_coord.points = long_points
567 # Update coord bounds to be consistent with wrapping.
568 if long_coord.has_bounds():
569 long_coord.bounds = None
570 long_coord.guess_bounds()
572 return cube
575def _fix_no_spatial_coords_callback(cube: iris.cube.Cube):
576 import CSET.operators._utils as utils
578 # Don't modify spatial cubes that already have spatial dimensions
579 if utils.is_spatialdim(cube):
580 return cube
582 else:
583 # attempt to get lat/long from cube attributes
584 try:
585 lat_min = cube.attributes.get("geospatial_lat_min")
586 lat_max = cube.attributes.get("geospatial_lat_max")
587 lon_min = cube.attributes.get("geospatial_lon_min")
588 lon_max = cube.attributes.get("geospatial_lon_max")
590 lon_val = (lon_min + lon_max) / 2.0
591 lat_val = (lat_min + lat_max) / 2.0
593 lat_coord = iris.coords.DimCoord(
594 lat_val,
595 standard_name="latitude",
596 units="degrees_north",
597 var_name="latitude",
598 coord_system=iris.coord_systems.GeogCS(6371229.0),
599 circular=True,
600 )
602 lon_coord = iris.coords.DimCoord(
603 lon_val,
604 standard_name="longitude",
605 units="degrees_east",
606 var_name="longitude",
607 coord_system=iris.coord_systems.GeogCS(6371229.0),
608 circular=True,
609 )
611 cube.add_aux_coord(lat_coord)
612 cube.add_aux_coord(lon_coord)
613 return cube
615 # if lat/long are not in attributes, then return cube unchanged:
616 except TypeError:
617 return cube
620def _fix_spatial_coords_callback(cube: iris.cube.Cube):
621 """Check latitude and longitude coordinates name.
623 This is necessary as some models define their grid as on rotated
624 'grid_latitude' and 'grid_longitude' coordinates while others define
625 the grid on non-rotated 'latitude' and 'longitude'.
626 Cube dimensions need to be made consistent to avoid recipe failures,
627 particularly where comparing multiple input models with differing spatial
628 coordinates.
629 """
630 # Check if cube is spatial.
631 if not is_spatialdim(cube):
632 # Don't modify non-spatial cubes.
633 return
635 # Get spatial coords and dimension index.
636 y_name, x_name = get_cube_yxcoordname(cube)
637 ny = get_cube_coordindex(cube, y_name)
638 nx = get_cube_coordindex(cube, x_name)
640 # Remove spatial coords bounds if erroneous values detected.
641 # Aims to catch some errors in input coord bounds by setting
642 # invalid threshold of 10000.0
643 if cube.coord(x_name).has_bounds() and cube.coord(y_name).has_bounds():
644 bx_max = np.max(np.abs(cube.coord(x_name).bounds))
645 by_max = np.max(np.abs(cube.coord(y_name).bounds))
646 if bx_max > 10000.0 or by_max > 10000.0:
647 cube.coord(x_name).bounds = None
648 cube.coord(y_name).bounds = None
650 # Translate [grid_latitude, grid_longitude] to an unrotated 1-d DimCoord
651 # [latitude, longitude] for instances where rotated_pole=90.0
652 if "grid_latitude" in [coord.name() for coord in cube.coords(dim_coords=True)]:
653 coord_system = cube.coord("grid_latitude").coord_system
654 pole_lat = getattr(coord_system, "grid_north_pole_latitude", None)
655 if pole_lat == 90.0: 655 ↛ 656line 655 didn't jump to line 656 because the condition on line 655 was never true
656 lats = cube.coord("grid_latitude").points
657 lons = cube.coord("grid_longitude").points
659 cube.remove_coord("grid_latitude")
660 cube.add_dim_coord(
661 iris.coords.DimCoord(
662 lats,
663 standard_name="latitude",
664 var_name="latitude",
665 units="degrees",
666 coord_system=iris.coord_systems.GeogCS(6371229.0),
667 circular=True,
668 ),
669 ny,
670 )
671 y_name = "latitude"
672 cube.remove_coord("grid_longitude")
673 cube.add_dim_coord(
674 iris.coords.DimCoord(
675 lons,
676 standard_name="longitude",
677 var_name="longitude",
678 units="degrees",
679 coord_system=iris.coord_systems.GeogCS(6371229.0),
680 circular=True,
681 ),
682 nx,
683 )
684 x_name = "longitude"
686 # Create additional AuxCoord [grid_latitude, grid_longitude] with
687 # rotated pole attributes for cases with [lat, lon] inputs
688 if y_name in ["latitude"] and cube.coord(y_name).units in [
689 "degrees",
690 "degrees_north",
691 "degrees_south",
692 ]:
693 # Add grid_latitude AuxCoord
694 if "grid_latitude" not in [
695 coord.name() for coord in cube.coords(dim_coords=False)
696 ]:
697 cube.add_aux_coord(
698 iris.coords.AuxCoord(
699 cube.coord(y_name).points,
700 var_name="grid_latitude",
701 units="degrees",
702 ),
703 ny,
704 )
705 # Ensure input latitude DimCoord has CoordSystem
706 # This attribute is sometimes lost on iris.save
707 if not cube.coord(y_name).coord_system:
708 cube.coord(y_name).coord_system = iris.coord_systems.GeogCS(6371229.0)
710 if x_name in ["longitude"] and cube.coord(x_name).units in [
711 "degrees",
712 "degrees_west",
713 "degrees_east",
714 ]:
715 # Add grid_longitude AuxCoord
716 if "grid_longitude" not in [
717 coord.name() for coord in cube.coords(dim_coords=False)
718 ]:
719 cube.add_aux_coord(
720 iris.coords.AuxCoord(
721 cube.coord(x_name).points,
722 var_name="grid_longitude",
723 units="degrees",
724 ),
725 nx,
726 )
728 # Ensure input longitude DimCoord has CoordSystem
729 # This attribute is sometimes lost on iris.save
730 if not cube.coord(x_name).coord_system:
731 cube.coord(x_name).coord_system = iris.coord_systems.GeogCS(6371229.0)
734def _fix_pressure_coord_callback(cube: iris.cube.Cube):
735 """Rename pressure coordinate to "pressure" if it exists and ensure hPa units.
737 This problem was raised because the AIFS model data from ECMWF
738 defines the pressure coordinate with the name "pressure_level" rather
739 than compliant CF coordinate names.
741 Additionally, set the units of pressure to be hPa to be consistent with the UM,
742 and approach the coordinates in a unified way.
743 """
744 for coord in cube.dim_coords:
745 if coord.name() in ["pressure_level", "pressure_levels"]:
746 coord.rename("pressure")
748 if coord.name() == "pressure" and str(cube.coord("pressure").units) != "hPa":
749 cube.coord("pressure").convert_units("hPa")
752def _fix_um_radtime(cube: iris.cube.Cube):
753 """Move radiation diagnostics from timestamps which are output N minutes or seconds past every hour.
755 This callback does not have any effect for output diagnostics with
756 timestamps exactly 00 or 30 minutes past the hour. Only radiation
757 diagnostics are checked.
758 Note this callback does not interpolate the data in time, only adjust
759 timestamps to sit on the hour to enable time-to-time difference plotting
760 with models which may output radiation data on the hour.
761 """
762 try:
763 if cube.attributes["STASH"] in [
764 "m01s01i207",
765 "m01s01i208",
766 "m01s02i205",
767 "m01s02i201",
768 "m01s01i207",
769 "m01s02i207",
770 "m01s01i235",
771 ]:
772 time_coord = cube.coord("time")
774 # Convert time points to datetime objects
775 time_unit = time_coord.units
776 time_points = time_unit.num2date(time_coord.points)
777 # Skip if times don't need fixing.
778 if time_points[0].minute == 0 and time_points[0].second == 0:
779 return
780 if time_points[0].minute == 30 and time_points[0].second == 0: 780 ↛ 781line 780 didn't jump to line 781 because the condition on line 780 was never true
781 return
783 # Subtract time difference from the hour from each time point
784 n_minute = time_points[0].minute
785 n_second = time_points[0].second
786 # If times closer to next hour, compute difference to add on to following hour
787 if n_minute > 30:
788 n_minute = n_minute - 60
789 # Compute new diagnostic time stamp
790 new_time_points = (
791 time_points
792 - datetime.timedelta(minutes=n_minute)
793 - datetime.timedelta(seconds=n_second)
794 )
796 # Convert back to numeric values using the original time unit.
797 new_time_values = time_unit.date2num(new_time_points)
799 # Replace the time coordinate with updated values.
800 time_coord.points = new_time_values
802 # Recompute forecast_period with corrected values.
803 if cube.coord("forecast_period"): 803 ↛ exitline 803 didn't return from function '_fix_um_radtime' because the condition on line 803 was always true
804 fcst_prd_points = cube.coord("forecast_period").points
805 new_fcst_points = (
806 time_unit.num2date(fcst_prd_points)
807 - datetime.timedelta(minutes=n_minute)
808 - datetime.timedelta(seconds=n_second)
809 )
810 cube.coord("forecast_period").points = time_unit.date2num(
811 new_fcst_points
812 )
813 except KeyError:
814 pass
817def _fix_cell_methods(cube: iris.cube.Cube):
818 """To fix the assumed cell_methods in accumulation STASH from UM.
820 Lightning (m01s21i104), rainfall amount (m01s04i201, m01s05i201) and snowfall amount
821 (m01s04i202, m01s05i202) in UM is being output as a time accumulation,
822 over each hour (TAcc1hr), but input cubes show cell_methods as "mean".
823 For UM and LFRic inputs to be compatible, we assume accumulated cell_methods are
824 "sum". This callback changes "mean" cube attribute cell_method to "sum",
825 enabling the cell_method constraint on reading to select correct input.
826 """
827 # Shift "mean" cell_method to "sum" for selected UM inputs.
828 if cube.attributes.get("STASH") in [
829 "m01s21i104",
830 "m01s04i201",
831 "m01s04i202",
832 "m01s05i201",
833 "m01s05i202",
834 ] and {cm.method for cm in cube.cell_methods} == {"mean"}:
835 # Retrieve interval and any comment information.
836 for cell_method in cube.cell_methods:
837 interval_str = cell_method.intervals
838 comment_str = cell_method.comments
840 # Remove input aggregation method.
841 cube.cell_methods = ()
843 # Replace "mean" with "sum" cell_method to indicate aggregation.
844 cube.add_cell_method(
845 iris.coords.CellMethod(
846 method="sum",
847 coords="time",
848 intervals=interval_str,
849 comments=comment_str,
850 )
851 )
854def _convert_cube_units_callback(cube: iris.cube.Cube):
855 """Adjust diagnostic units for specific variables.
857 Some precipitation diagnostics are output with unit kg m-2 s-1 and are
858 converted here to mm hr-1.
860 Visibility diagnostics are converted here from m to km to improve output
861 formatting.
862 """
863 # Convert precipitation diagnostic units if required.
864 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name])
865 if any("surface_microphysical" in name for name in varnames):
866 if cube.units == "kg m-2 s-1":
867 _log_once(
868 "Converting precipitation rate units from kg m-2 s-1 to mm hr-1",
869 level=logging.DEBUG,
870 )
871 # Convert from kg m-2 s-1 to mm s-1 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 s-1"
874 # Convert the units to per hour.
875 cube.convert_units("mm hr-1")
876 elif cube.units == "kg m-2": 876 ↛ 886line 876 didn't jump to line 886 because the condition on line 876 was always true
877 _log_once(
878 "Converting precipitation amount units from kg m-2 to mm",
879 level=logging.DEBUG,
880 )
881 # Convert from kg m-2 to mm assuming 1kg water = 1l water = 1dm^3 water.
882 # This is a 1:1 conversion, so we just change the units.
883 cube.units = "mm"
885 # Convert visibility diagnostic units if required.
886 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name])
887 if any("visibility" in name for name in varnames) and cube.units == "m":
888 _log_once("Converting visibility units m to km.", level=logging.DEBUG)
889 # Convert the units to km.
890 cube.convert_units("km")
892 return cube
895def _fix_lfric_cloud_base_altitude(cube: iris.cube.Cube):
896 """Mask cloud_base_altitude diagnostic in regions with no cloud."""
897 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name])
898 if any("cloud_base_altitude" in name for name in varnames):
899 # Mask cube where set > 144kft to catch default 144.35695538058164
900 cube.data = dask.array.ma.masked_greater(cube.core_data(), 144.0)
903def _compute_winds(cubes: iris.cube.CubeList):
904 """To compute wind_speed from vector components if not available as diagnostic.
906 Diagnostics of wind are also not always consistent between the UM
907 and LFRic. Here, winds from the UM are adjusted to make them
908 consistent with LFRic.
909 """
910 # Check whether we have components of the wind identified by varname
911 # but not the wind speed and calculate it if it is missing. Note that
912 # this will be biased low in general because the components will mostly
913 # be time averages. For simplicity, we do this only if there is just one
914 # cube of a component. A more complicated approach would be to consider
915 # the cell methods, but it may not be warranted.
916 #
917 # A check on UM STASH attributes is also conducted to adjust directions.
918 u_constr = iris.Constraint("eastward_wind_at_10m")
919 v_constr = iris.Constraint("northward_wind_at_10m")
920 speed_constr = iris.Constraint("wind_speed_at_10m")
921 try:
922 if cubes.extract(u_constr) and cubes.extract(v_constr):
923 if len(cubes) == 2:
924 wind_only = True
925 else:
926 wind_only = False
927 if len(cubes.extract(u_constr)) == 1 and not cubes.extract(speed_constr): 927 ↛ 930line 927 didn't jump to line 930 because the condition on line 927 was always true
928 _add_wind_speed_um(cubes)
929 # Convert winds in the UM to be relative to true east and true north.
930 if cubes.extract(u_constr) and cubes.extract(v_constr): 930 ↛ 933line 930 didn't jump to line 933 because the condition on line 930 was always true
931 _convert_wind_true_dirn_um(cubes)
932 # Return only wind_speed cube
933 if wind_only:
934 cubes = cubes.extract(speed_constr)
935 except (KeyError, AttributeError):
936 pass
938 return cubes
941def _add_wind_speed_um(cubes: iris.cube.CubeList):
942 """Add windspeeds to cubes from components."""
943 u_wind = cubes.extract_cube(iris.Constraint("eastward_wind_at_10m"))
944 v_wind = cubes.extract_cube(iris.Constraint("northward_wind_at_10m"))
945 wspd10 = (u_wind**2 + v_wind**2) ** 0.5
946 wspd10.attributes["STASH"] = "m01s03i227"
947 wspd10.standard_name = "wind_speed"
948 wspd10.long_name = "wind_speed_at_10m"
949 wspd10.units = "ms-1"
950 cubes.append(wspd10)
953def _convert_wind_true_dirn_um(cubes: iris.cube.CubeList):
954 """To convert winds to true directions.
956 Convert from the components relative to the grid to true directions.
957 This functionality only handles the simplest case.
958 Constrains using STASH code only to ensure applied to UM outputs only.
959 """
960 u_grids = cubes.extract(iris.AttributeConstraint(STASH="m01s03i225"))
961 v_grids = cubes.extract(iris.AttributeConstraint(STASH="m01s03i226"))
962 for u, v in zip(u_grids, v_grids, strict=True): 962 ↛ 963line 962 didn't jump to line 963 because the loop on line 962 never started
963 true_u, true_v = rotate_winds(u, v, iris.coord_systems.GeogCS(6371229.0))
964 u.data = true_u.core_data()
965 v.data = true_v.core_data()
968def _normalise_var0_varname(cube: iris.cube.Cube):
969 """Fix varnames for consistency to allow merging.
971 Some model data netCDF sometimes have a coordinate name end in
972 "_0" etc, where duplicate coordinates of same name are defined but
973 with different attributes. This can be inconsistently managed in
974 different model inputs and can cause cubes to fail to merge.
975 """
976 for coord in cube.coords():
977 if coord.var_name and coord.var_name.endswith("_0"):
978 coord.var_name = coord.var_name.removesuffix("_0")
979 if coord.var_name and coord.var_name.endswith("_1"):
980 coord.var_name = coord.var_name.removesuffix("_1")
981 if coord.var_name and coord.var_name.endswith("_2"): 981 ↛ 982line 981 didn't jump to line 982 because the condition on line 981 was never true
982 coord.var_name = coord.var_name.removesuffix("_2")
983 if coord.var_name and coord.var_name.endswith("_3"): 983 ↛ 984line 983 didn't jump to line 984 because the condition on line 983 was never true
984 coord.var_name = coord.var_name.removesuffix("_3")
986 if cube.var_name and cube.var_name.endswith("_0"):
987 cube.var_name = cube.var_name.removesuffix("_0")
990def _proleptic_gregorian_fix(cube: iris.cube.Cube):
991 """Convert the calendars of time units to use a standard calendar."""
992 try:
993 time_coord = cube.coord("time")
994 if time_coord.units.calendar == "proleptic_gregorian":
995 logger.debug(
996 "Changing proleptic Gregorian calendar to standard calendar for %s",
997 repr(time_coord.units),
998 )
999 time_coord.units = time_coord.units.change_calendar("standard")
1000 except iris.exceptions.CoordinateNotFoundError:
1001 pass
1004def _lfric_time_callback(cube: iris.cube.Cube):
1005 """Fix time coordinate metadata if missing dimensions.
1007 Some model data does not contain forecast_reference_time or forecast_period as
1008 expected coordinates, and so we cannot aggregate over case studies without this
1009 metadata. This callback fixes these issues.
1011 This callback also ensures all time coordinates are referenced as hours since
1012 1970-01-01 00:00:00 for consistency across different model inputs.
1014 Notes
1015 -----
1016 Some parts of the code have been adapted from Paul Earnshaw's scripts.
1017 """
1018 # Construct forecast_reference time if it doesn't exist.
1019 try:
1020 tcoord = cube.coord("time")
1021 # Set time coordinate to common basis "hours since 1970"
1022 try:
1023 tcoord.convert_units("hours since 1970-01-01 00:00:00")
1024 except ValueError:
1025 logger.warning("Unrecognised base time unit: %s", tcoord.units)
1027 if not cube.coords("forecast_reference_time"):
1028 try:
1029 init_time = datetime.datetime.fromisoformat(
1030 tcoord.attributes["time_origin"]
1031 )
1032 frt_point = tcoord.units.date2num(init_time)
1033 frt_coord = iris.coords.AuxCoord(
1034 frt_point,
1035 units=tcoord.units,
1036 standard_name="forecast_reference_time",
1037 long_name="forecast_reference_time",
1038 )
1039 cube.add_aux_coord(frt_coord)
1040 except KeyError:
1041 logger.warning(
1042 "Cannot find forecast_reference_time, but no `time_origin` attribute to construct it from."
1043 )
1045 # Remove time_origin to allow multiple case studies to merge.
1046 tcoord.attributes.pop("time_origin", None)
1048 # Construct forecast_period axis (forecast lead time) if it doesn't exist.
1049 if not cube.coords("forecast_period"):
1050 try:
1051 # Create array of forecast lead times.
1052 init_coord = cube.coord("forecast_reference_time")
1053 init_time_points_in_tcoord_units = tcoord.units.date2num(
1054 init_coord.units.num2date(init_coord.points)
1055 )
1056 lead_times = tcoord.points - init_time_points_in_tcoord_units
1058 # Get unit for lead time from time coordinate's unit.
1059 # Convert all lead time to hours for consistency between models.
1060 if "seconds" in str(tcoord.units): 1060 ↛ 1061line 1060 didn't jump to line 1061 because the condition on line 1060 was never true
1061 lead_times = lead_times / 3600.0
1062 units = "hours"
1063 elif "hours" in str(tcoord.units): 1063 ↛ 1066line 1063 didn't jump to line 1066 because the condition on line 1063 was always true
1064 units = "hours"
1065 else:
1066 raise ValueError(f"Unrecognised base time unit: {tcoord.units}")
1068 # Create lead time coordinate.
1069 lead_time_coord = iris.coords.AuxCoord(
1070 lead_times,
1071 standard_name="forecast_period",
1072 long_name="forecast_period",
1073 units=units,
1074 )
1076 # Associate lead time coordinate with time dimension.
1077 cube.add_aux_coord(lead_time_coord, cube.coord_dims("time"))
1078 except iris.exceptions.CoordinateNotFoundError:
1079 logger.warning(
1080 "Cube does not have both time and forecast_reference_time coordinate, so cannot construct forecast_period"
1081 )
1082 except iris.exceptions.CoordinateNotFoundError:
1083 logger.warning("No time coordinate on cube.")
1086def _lfric_forecast_period_callback(cube: iris.cube.Cube):
1087 """Check forecast_period name and units."""
1088 try:
1089 coord = cube.coord("forecast_period")
1090 if coord.units != "hours":
1091 cube.coord("forecast_period").convert_units("hours")
1092 if not coord.standard_name:
1093 coord.standard_name = "forecast_period"
1094 except iris.exceptions.CoordinateNotFoundError:
1095 pass
1098def _fix_no_time_coords_callback(cube: iris.cube.Cube):
1099 """Add dummy time coord to process cubes that don't have sequence coord."""
1100 # Only add if time coordinate does not exist.
1101 if not cube.coords("time"):
1102 cube.add_aux_coord(
1103 iris.coords.DimCoord(
1104 0, standard_name="time", units="hours since 0001-01-01 00:00:00"
1105 )
1106 )
1108 return cube
1111def _normalise_longname(cube: iris.cube.Cube):
1112 """Normalise long_name to the LFRic standard list."""
1113 if cube.coords("pressure"):
1114 if cube.name() == "x_wind":
1115 cube.long_name = "zonal_wind_at_pressure_levels"
1116 if cube.name() == "y_wind":
1117 cube.long_name = "meridional_wind_at_pressure_levels"
1118 if cube.name() == "air_temperature":
1119 cube.long_name = "temperature_at_pressure_levels"
1120 if cube.name() == "specific_humidity": 1120 ↛ 1121line 1120 didn't jump to line 1121 because the condition on line 1120 was never true
1121 cube.long_name = (
1122 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging"
1123 )
1124 else:
1125 if cube.name() == "x_wind" and cube.var_name == "u_wind_at_10m": 1125 ↛ 1126line 1125 didn't jump to line 1126 because the condition on line 1125 was never true
1126 cube.long_name = "eastward_wind_at_10m"
1127 if cube.name() == "y_wind" and cube.var_name == "v_wind_at_10m": 1127 ↛ 1128line 1127 didn't jump to line 1128 because the condition on line 1127 was never true
1128 cube.long_name = "northward_wind_at_10m"
1129 if cube.name() == "air_pressure_at_sea_level":
1130 cube.long_name = "air_pressure_at_mean_sea_level"
1133def _check_combine_point_observations(cubes: iris.cube.CubeList):
1134 """Enable cubes containing different point observation sources to be concatenated."""
1135 nstation = 0
1136 for cube in cubes:
1137 if "station" in [coord.name() for coord in cube.coords(dim_coords=True)]:
1138 if "obs_source" in [coord.name() for coord in cube.coords()]:
1139 cube.remove_coord("obs_source")
1140 cube.coord("station").points = cube.coord("station").points + nstation
1141 nstation = nstation + len(cube.coord("station").points)
1143 return cubes