Coverage for src / CSET / operators / plot.py: 81%
1072 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-01 14:49 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-01 14:49 +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 to produce various kinds of plots."""
17import fcntl
18import functools
19import importlib.resources
20import itertools
21import json
22import logging
23import math
24import os
25from typing import Literal
27import cartopy.crs as ccrs
28import cartopy.feature as cfeature
29import iris
30import iris.coords
31import iris.cube
32import iris.exceptions
33import iris.plot as iplt
34import matplotlib as mpl
35import matplotlib.colors as mcolors
36import matplotlib.pyplot as plt
37import numpy as np
38from iris.cube import Cube
39from markdown_it import MarkdownIt
41from CSET._common import (
42 combine_dicts,
43 filename_slugify,
44 get_recipe_metadata,
45 iter_maybe,
46 render_file,
47 slugify,
48)
49from CSET.operators._utils import (
50 fully_equalise_attributes,
51 get_cube_yxcoordname,
52 is_transect,
53 slice_over_maybe,
54)
55from CSET.operators.collapse import collapse
56from CSET.operators.misc import _extract_common_time_points
57from CSET.operators.regrid import regrid_onto_cube
59# Suppress matplotlib debug output
60# logging.getLogger('matplotlib.ticker').setLevel(logging.WARNING)
62# Use a non-interactive plotting backend.
63mpl.use("agg")
65DEFAULT_DISCRETE_COLORS = mpl.colormaps["tab10"].colors + mpl.colormaps["Accent"].colors
67############################
68# Private helper functions #
69############################
72def _append_to_plot_index(plot_index: list) -> list:
73 """Add plots into the plot index, returning the complete plot index."""
74 with open("meta.json", "r+t", encoding="UTF-8") as fp:
75 fcntl.flock(fp, fcntl.LOCK_EX)
76 fp.seek(0)
77 meta = json.load(fp)
78 complete_plot_index = meta.get("plots", [])
79 complete_plot_index = complete_plot_index + plot_index
80 meta["plots"] = complete_plot_index
81 if os.getenv("CYLC_TASK_CYCLE_POINT") and not bool(
82 os.getenv("DO_CASE_AGGREGATION")
83 ):
84 meta["case_date"] = os.getenv("CYLC_TASK_CYCLE_POINT", "")
85 fp.seek(0)
86 fp.truncate()
87 json.dump(meta, fp, indent=2)
88 return complete_plot_index
91def _check_single_cube(cube: iris.cube.Cube | iris.cube.CubeList) -> iris.cube.Cube:
92 """Ensure a single cube is given.
94 If a CubeList of length one is given that the contained cube is returned,
95 otherwise an error is raised.
97 Parameters
98 ----------
99 cube: Cube | CubeList
100 The cube to check.
102 Returns
103 -------
104 cube: Cube
105 The checked cube.
107 Raises
108 ------
109 TypeError
110 If the input cube is not a Cube or CubeList of a single Cube.
111 """
112 if isinstance(cube, iris.cube.Cube):
113 return cube
114 if isinstance(cube, iris.cube.CubeList):
115 if len(cube) == 1:
116 return cube[0]
117 raise TypeError("Must have a single cube", cube)
120def _make_plot_html_page(plots: list):
121 """Create a HTML page to display a plot image."""
122 # Debug check that plots actually contains some strings.
123 assert isinstance(plots[0], str)
125 # Load HTML template file.
126 operator_files = importlib.resources.files()
127 template_file = operator_files.joinpath("_plot_page_template.html")
129 # Get some metadata.
130 meta = get_recipe_metadata()
131 title = meta.get("title", "Untitled")
132 description = MarkdownIt().render(meta.get("description", "*No description.*"))
134 # Prepare template variables.
135 variables = {
136 "title": title,
137 "description": description,
138 "initial_plot": plots[0],
139 "plots": plots,
140 "title_slug": slugify(title),
141 }
143 # Render template.
144 html = render_file(template_file, **variables)
146 # Save completed HTML.
147 with open("index.html", "wt", encoding="UTF-8") as fp:
148 fp.write(html)
151@functools.cache
152def _load_colorbar_map(user_colorbar_file: str = None) -> dict:
153 """Load the colorbar definitions from a file.
155 This is a separate function to make it cacheable.
156 """
157 colorbar_file = importlib.resources.files().joinpath("_colorbar_definition.json")
158 with open(colorbar_file, "rt", encoding="UTF-8") as fp:
159 colorbar = json.load(fp)
161 logging.debug("User colour bar file: %s", user_colorbar_file)
162 override_colorbar = {}
163 if user_colorbar_file:
164 try:
165 with open(user_colorbar_file, "rt", encoding="UTF-8") as fp:
166 override_colorbar = json.load(fp)
167 except FileNotFoundError:
168 logging.warning("Colorbar file does not exist. Using default values.")
170 # Overwrite values with the user supplied colorbar definition.
171 colorbar = combine_dicts(colorbar, override_colorbar)
172 return colorbar
175def _get_model_colors_map(cubes: iris.cube.CubeList | iris.cube.Cube) -> dict:
176 """Get an appropriate colors for model lines in line plots.
178 For each model in the list of cubes colors either from user provided
179 color definition file (so-called style file) or from default colors are mapped
180 to model_name attribute.
182 Parameters
183 ----------
184 cubes: CubeList or Cube
185 Cubes with model_name attribute
187 Returns
188 -------
189 model_colors_map:
190 Dictionary mapping model_name attribute to colors
191 """
192 user_colorbar_file = get_recipe_metadata().get("style_file_path", None)
193 colorbar = _load_colorbar_map(user_colorbar_file)
194 model_names = sorted(
195 filter(
196 lambda x: x is not None,
197 (cube.attributes.get("model_name", None) for cube in iter_maybe(cubes)),
198 )
199 )
200 if not model_names:
201 return {}
202 use_user_colors = all(mname in colorbar.keys() for mname in model_names)
203 if use_user_colors: 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true
204 return {mname: colorbar[mname] for mname in model_names}
206 color_list = itertools.cycle(DEFAULT_DISCRETE_COLORS)
207 return {mname: color for mname, color in zip(model_names, color_list, strict=False)}
210def _colorbar_map_levels(cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None):
211 """Get an appropriate colorbar for the given cube.
213 For the given variable the appropriate colorbar is looked up from a
214 combination of the built-in CSET colorbar definitions, and any user supplied
215 definitions. As well as varying on variables, these definitions may also
216 exist for specific pressure levels to account for variables with
217 significantly different ranges at different heights. The colorbars also exist
218 for masks and mask differences for considering variable presence diagnostics.
219 Specific variable ranges can be separately set in user-supplied definition
220 for x- or y-axis limits, or indicate where automated range preferred.
222 Parameters
223 ----------
224 cube: Cube
225 Cube of variable for which the colorbar information is desired.
226 axis: "x", "y", optional
227 Select the levels for just this axis of a line plot. The min and max
228 can be set by xmin/xmax or ymin/ymax respectively. For variables where
229 setting a universal range is not desirable (e.g. temperature), users
230 can set ymin/ymax values to "auto" in the colorbar definitions file.
231 Where no additional xmin/xmax or ymin/ymax values are provided, the
232 axis bounds default to use the vmin/vmax values provided.
234 Returns
235 -------
236 cmap:
237 Matplotlib colormap.
238 levels:
239 List of levels to use for plotting. For continuous plots the min and max
240 should be taken as the range.
241 norm:
242 BoundaryNorm information.
243 """
244 # Grab the colorbar file from the recipe global metadata.
245 user_colorbar_file = get_recipe_metadata().get("style_file_path", None)
246 colorbar = _load_colorbar_map(user_colorbar_file)
247 cmap = None
249 try:
250 # We assume that pressure is a scalar coordinate here.
251 pressure_level_raw = cube.coord("pressure").points[0]
252 # Ensure pressure_level is a string, as it is used as a JSON key.
253 pressure_level = str(int(pressure_level_raw))
254 except iris.exceptions.CoordinateNotFoundError:
255 pressure_level = None
257 # First try long name, then standard name, then var name. This order is used
258 # as long name is the one we correct between models, so it most likely to be
259 # consistent.
260 varnames = list(filter(None, [cube.long_name, cube.standard_name, cube.var_name]))
261 for varname in varnames:
262 # Get the colormap for this variable.
263 try:
264 var_colorbar = colorbar[varname]
265 cmap = plt.get_cmap(colorbar[varname]["cmap"], 51)
266 varname_key = varname
267 break
268 except KeyError:
269 logging.debug("Cube name %s has no colorbar definition.", varname)
271 # Get colormap if it is a mask.
272 if any("mask_for_" in name for name in varnames):
273 cmap, levels, norm = _custom_colormap_mask(cube, axis=axis)
274 return cmap, levels, norm
275 # If winds on Beaufort Scale use custom colorbar and levels
276 if any("Beaufort_Scale" in name for name in varnames):
277 cmap, levels, norm = _custom_beaufort_scale(cube, axis=axis)
278 return cmap, levels, norm
279 # If probability is plotted use custom colorbar and levels
280 if any("probability_of_" in name for name in varnames):
281 cmap, levels, norm = _custom_colormap_probability(cube, axis=axis)
282 return cmap, levels, norm
283 # If aviation colour state use custom colorbar and levels
284 if any("aviation_colour_state" in name for name in varnames):
285 cmap, levels, norm = _custom_colormap_aviation_colour_state(cube)
286 return cmap, levels, norm
288 # If no valid colormap has been defined, use defaults and return.
289 if not cmap:
290 logging.warning("No colorbar definition exists for %s.", cube.name())
291 cmap, levels, norm = mpl.colormaps["viridis"], None, None
292 return cmap, levels, norm
294 # Test if pressure-level specific settings are provided for cube.
295 if pressure_level:
296 try:
297 var_colorbar = colorbar[varname_key]["pressure_levels"][pressure_level]
298 except KeyError:
299 logging.debug(
300 "%s has no colorbar definition for pressure level %s.",
301 varname,
302 pressure_level,
303 )
305 # Check for availability of x-axis or y-axis user-specific overrides
306 # for setting level bounds for line plot types and return just levels.
307 # Line plots do not need a colormap, and just use the data range.
308 if axis:
309 if axis == "x":
310 try:
311 vmin, vmax = var_colorbar["xmin"], var_colorbar["xmax"]
312 except KeyError:
313 vmin, vmax = var_colorbar["min"], var_colorbar["max"]
314 if axis == "y":
315 try:
316 vmin, vmax = var_colorbar["ymin"], var_colorbar["ymax"]
317 except KeyError:
318 vmin, vmax = var_colorbar["min"], var_colorbar["max"]
319 # Check if user-specified auto-scaling for this variable
320 if vmin == "auto" or vmax == "auto":
321 levels = None
322 else:
323 levels = [vmin, vmax]
324 return None, levels, None
325 # Get and use the colorbar levels for this variable if spatial or histogram.
326 else:
327 try:
328 levels = var_colorbar["levels"]
329 # Use discrete bins when levels are specified, rather
330 # than a smooth range.
331 norm = mpl.colors.BoundaryNorm(levels, ncolors=cmap.N)
332 logging.debug("Using levels for %s colorbar.", varname)
333 logging.info("Using levels: %s", levels)
334 except KeyError:
335 # Get the range for this variable.
336 vmin, vmax = var_colorbar["min"], var_colorbar["max"]
337 logging.debug("Using min and max for %s colorbar.", varname)
338 # Calculate levels from range.
339 levels = np.linspace(vmin, vmax, 101)
340 norm = None
342 # Overwrite cmap, levels and norm for specific variables that
343 # require custom colorbar_map as these can not be defined in the
344 # JSON file.
345 cmap, levels, norm = _custom_colourmap_precipitation(cube, cmap, levels, norm)
346 cmap, levels, norm = _custom_colourmap_visibility_in_air(
347 cube, cmap, levels, norm
348 )
349 cmap, levels, norm = _custom_colormap_celsius(cube, cmap, levels, norm)
350 return cmap, levels, norm
353def _setup_spatial_map(
354 cube: iris.cube.Cube,
355 figure,
356 cmap,
357 grid_size: int | None = None,
358 subplot: int | None = None,
359):
360 """Define map projections, extent and add coastlines and borderlines for spatial plots.
362 For spatial map plots, a relevant map projection for rotated or non-rotated inputs
363 is specified, and map extent defined based on the input data.
365 Parameters
366 ----------
367 cube: Cube
368 2 dimensional (lat and lon) Cube of the data to plot.
369 figure:
370 Matplotlib Figure object holding all plot elements.
371 cmap:
372 Matplotlib colormap.
373 grid_size: int, optional
374 Size of grid for subplots if multiple spatial subplots in figure.
375 subplot: int, optional
376 Subplot index if multiple spatial subplots in figure.
378 Returns
379 -------
380 axes:
381 Matplotlib GeoAxes definition.
382 """
383 # Identify min/max plot bounds.
384 try:
385 lat_axis, lon_axis = get_cube_yxcoordname(cube)
386 x1 = np.min(cube.coord(lon_axis).points)
387 x2 = np.max(cube.coord(lon_axis).points)
388 y1 = np.min(cube.coord(lat_axis).points)
389 y2 = np.max(cube.coord(lat_axis).points)
391 # Adjust bounds within +/- 180.0 if x dimension extends beyond half-globe.
392 if np.abs(x2 - x1) > 180.0:
393 x1 = x1 - 180.0
394 x2 = x2 - 180.0
395 logging.debug("Adjusting plot bounds to fit global extent.")
397 # Consider map projection orientation.
398 # Adapting orientation enables plotting across international dateline.
399 # Users can adapt the default central_longitude if alternative projections views.
400 if x2 > 180.0 or x1 < -180.0:
401 central_longitude = 180.0
402 else:
403 central_longitude = 0.0
405 # Define spatial map projection.
406 coord_system = cube.coord(lat_axis).coord_system
407 if isinstance(coord_system, iris.coord_systems.RotatedGeogCS):
408 # Define rotated pole map projection for rotated pole inputs.
409 projection = ccrs.RotatedPole(
410 pole_longitude=coord_system.grid_north_pole_longitude,
411 pole_latitude=coord_system.grid_north_pole_latitude,
412 central_rotated_longitude=central_longitude,
413 )
414 crs = projection
415 elif isinstance(coord_system, iris.coord_systems.TransverseMercator): 415 ↛ 417line 415 didn't jump to line 417 because the condition on line 415 was never true
416 # Define Transverse Mercator projection for TM inputs.
417 projection = ccrs.TransverseMercator(
418 central_longitude=coord_system.longitude_of_central_meridian,
419 central_latitude=coord_system.latitude_of_projection_origin,
420 false_easting=coord_system.false_easting,
421 false_northing=coord_system.false_northing,
422 scale_factor=coord_system.scale_factor_at_central_meridian,
423 )
424 crs = projection
425 else:
426 # Define regular map projection for non-rotated pole inputs.
427 # Alternatives might include e.g. for global model outputs:
428 # projection=ccrs.Robinson(central_longitude=X.y, globe=None)
429 # See also https://scitools.org.uk/cartopy/docs/v0.15/crs/projections.html.
430 projection = ccrs.PlateCarree(central_longitude=central_longitude)
431 crs = ccrs.PlateCarree()
433 # Define axes for plot (or subplot) with required map projection.
434 if subplot is not None:
435 axes = figure.add_subplot(
436 grid_size, grid_size, subplot, projection=projection
437 )
438 else:
439 axes = figure.add_subplot(projection=projection)
441 # Add coastlines and borderlines if cube contains x and y map coordinates.
442 if cmap.name in ["viridis", "Greys"]:
443 coastcol = "magenta"
444 else:
445 coastcol = "black"
446 logging.debug("Plotting coastlines and borderlines in colour %s.", coastcol)
447 axes.coastlines(resolution="10m", color=coastcol)
448 axes.add_feature(cfeature.BORDERS, edgecolor=coastcol)
450 # If is lat/lon spatial map, fix extent to keep plot tight.
451 # Specifying crs within set_extent helps ensure only data region is shown.
452 if isinstance(coord_system, iris.coord_systems.GeogCS):
453 axes.set_extent([x1, x2, y1, y2], crs=crs)
455 except ValueError:
456 # Skip if not both x and y map coordinates.
457 axes = figure.gca()
458 pass
460 return axes
463def _get_plot_resolution() -> int:
464 """Get resolution of rasterised plots in pixels per inch."""
465 return get_recipe_metadata().get("plot_resolution", 100)
468def _set_title_and_filename(
469 seq_coord: iris.coords.Coord,
470 nplot: int,
471 recipe_title: str,
472 filename: str,
473):
474 """Set plot title and filename based on cube coordinate.
476 Parameters
477 ----------
478 sequence_coordinate: iris.coords.Coord
479 Coordinate about which to make a plot sequence.
480 nplot: int
481 Number of output plots to generate - controls title/naming.
482 recipe_title: str
483 Default plot title, potentially to update.
484 filename: str
485 Input plot filename, potentially to update.
487 Returns
488 -------
489 plot_title: str
490 Output formatted plot title string, based on plotted data.
491 plot_filename: str
492 Output formatted plot filename string.
493 """
494 ndim = seq_coord.ndim
495 npoints = np.size(seq_coord.points)
496 sequence_title = ""
497 sequence_fname = ""
499 # Account for case with multi-dimension sequence input (e.g. aggregation)
500 if ndim > 1:
501 sequence_title = f"\n [{ndim} cases]"
502 sequence_fname = f"_{ndim}cases"
504 else:
505 if npoints == 1:
506 if nplot > 1:
507 # Set default labels for sequence inputs
508 sequence_value = seq_coord.units.title(seq_coord.points[0])
509 sequence_title = f"\n [{sequence_value}]"
510 sequence_fname = f"_{filename_slugify(sequence_value)}"
511 elif seq_coord.has_bounds():
512 ncase = np.size(seq_coord.bounds)
513 sequence_title = f"\n [{ncase} cases]"
514 sequence_fname = f"_{ncase}cases"
515 # Use sequence (e.g. time) bounds if plotting single non-sequence outputs
516 # Take title endpoints from coord points where series input (e.g. timeseries)
517 if npoints > 1:
518 if not seq_coord.has_bounds():
519 startstring = seq_coord.units.title(seq_coord.points[0])
520 endstring = seq_coord.units.title(seq_coord.points[-1])
521 else:
522 # Take title endpoint from coord bounds where single input (e.g. map)
523 startstring = seq_coord.units.title(seq_coord.bounds.flatten()[0])
524 endstring = seq_coord.units.title(seq_coord.bounds.flatten()[-1])
525 sequence_title = f"\n [{startstring} to {endstring}]"
526 sequence_fname = (
527 f"_{filename_slugify(startstring)}_{filename_slugify(endstring)}"
528 )
530 # Set plot title and filename
531 plot_title = f"{recipe_title}{sequence_title}"
533 # Set plot filename, defaulting to user input if provided.
534 if filename is None:
535 filename = slugify(recipe_title)
536 plot_filename = f"{filename.rsplit('.', 1)[0]}{sequence_fname}.png"
537 else:
538 if nplot > 1:
539 plot_filename = f"{filename.rsplit('.', 1)[0]}{sequence_fname}.png"
540 else:
541 plot_filename = f"{filename.rsplit('.', 1)[0]}.png"
543 return plot_title, plot_filename
546def select_series_coord(cube, series_coordinate):
547 """Determine the grid coordinates to use to calculate grid spacing."""
548 try:
549 # Try the requested coordinate first
550 return cube.coord(series_coordinate)
552 except iris.exceptions.CoordinateNotFoundError:
553 # Fallback logic
554 if series_coordinate == "frequency":
555 fallbacks = ("physical_wavenumber", "wavelength")
557 elif series_coordinate == "physical_wavenumber":
558 fallbacks = ("frequency", "wavelength")
560 elif series_coordinate == "wavelength": 560 ↛ 565line 560 didn't jump to line 565 because the condition on line 560 was always true
561 fallbacks = ("frequency", "physical_wavenumber")
563 else:
564 # Unknown coordinate type -> re-raise the original exception
565 raise
567 # Try the fallbacks in order
568 for fb in fallbacks: 568 ↛ 576line 568 didn't jump to line 576 because the loop on line 568 didn't complete
569 try:
570 return cube.coord(fb)
571 except iris.exceptions.CoordinateNotFoundError:
572 continue
574 # If we get here, none of the fallback options were found
576 raise iris.exceptions.CoordinateNotFoundError(
577 f"No valid coordinate found for '{series_coordinate}' "
578 f"or fallback options {fallbacks}"
579 ) from None
582def _plot_and_save_spatial_plot(
583 cube: iris.cube.Cube,
584 filename: str,
585 title: str,
586 method: Literal["contourf", "pcolormesh"],
587 overlay_cube: iris.cube.Cube | None = None,
588 contour_cube: iris.cube.Cube | None = None,
589 **kwargs,
590):
591 """Plot and save a spatial plot.
593 Parameters
594 ----------
595 cube: Cube
596 2 dimensional (lat and lon) Cube of the data to plot.
597 filename: str
598 Filename of the plot to write.
599 title: str
600 Plot title.
601 method: "contourf" | "pcolormesh"
602 The plotting method to use.
603 overlay_cube: Cube, optional
604 Optional 2 dimensional (lat and lon) Cube of data to overplot on top of base cube
605 contour_cube: Cube, optional
606 Optional 2 dimensional (lat and lon) Cube of data to overplot as contours over base cube
607 """
608 # Setup plot details, size, resolution, etc.
609 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
611 # Specify the color bar
612 cmap, levels, norm = _colorbar_map_levels(cube)
614 # If overplotting, set required colorbars
615 if overlay_cube:
616 over_cmap, over_levels, over_norm = _colorbar_map_levels(overlay_cube)
617 if contour_cube:
618 cntr_cmap, cntr_levels, cntr_norm = _colorbar_map_levels(contour_cube)
620 # Setup plot map projection, extent and coastlines and borderlines.
621 axes = _setup_spatial_map(cube, fig, cmap)
623 # Plot the field.
624 if method == "contourf":
625 # Filled contour plot of the field.
626 plot = iplt.contourf(cube, cmap=cmap, levels=levels, norm=norm)
627 elif method == "pcolormesh":
628 try:
629 vmin = min(levels)
630 vmax = max(levels)
631 except TypeError:
632 vmin, vmax = None, None
633 # pcolormesh plot of the field and ensure to use norm and not vmin/vmax
634 # if levels are defined.
635 if norm is not None:
636 vmin = None
637 vmax = None
638 logging.debug("Plotting using defined levels.")
639 plot = iplt.pcolormesh(cube, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax)
640 else:
641 raise ValueError(f"Unknown plotting method: {method}")
643 # Overplot overlay field, if required
644 if overlay_cube:
645 try:
646 over_vmin = min(over_levels)
647 over_vmax = max(over_levels)
648 except TypeError:
649 over_vmin, over_vmax = None, None
650 if over_norm is not None: 650 ↛ 651line 650 didn't jump to line 651 because the condition on line 650 was never true
651 over_vmin = None
652 over_vmax = None
653 overlay = iplt.pcolormesh(
654 overlay_cube,
655 cmap=over_cmap,
656 norm=over_norm,
657 alpha=0.8,
658 vmin=over_vmin,
659 vmax=over_vmax,
660 )
661 # Overplot contour field, if required, with contour labelling.
662 if contour_cube:
663 contour = iplt.contour(
664 contour_cube,
665 colors="darkgray",
666 levels=cntr_levels,
667 norm=cntr_norm,
668 alpha=0.5,
669 linestyles="--",
670 linewidths=1,
671 )
672 plt.clabel(contour)
674 # Check to see if transect, and if so, adjust y axis.
675 if is_transect(cube):
676 if "pressure" in [coord.name() for coord in cube.coords()]:
677 axes.invert_yaxis()
678 axes.set_yscale("log")
679 axes.set_ylim(1100, 100)
680 # If both model_level_number and level_height exists, iplt can construct
681 # plot as a function of height above orography (NOT sea level).
682 elif {"model_level_number", "level_height"}.issubset( 682 ↛ 687line 682 didn't jump to line 687 because the condition on line 682 was always true
683 {coord.name() for coord in cube.coords()}
684 ):
685 axes.set_yscale("log")
687 axes.set_title(
688 f"{title}\n"
689 f"Start Lat: {cube.attributes['transect_coords'].split('_')[0]}"
690 f" Start Lon: {cube.attributes['transect_coords'].split('_')[1]}"
691 f" End Lat: {cube.attributes['transect_coords'].split('_')[2]}"
692 f" End Lon: {cube.attributes['transect_coords'].split('_')[3]}",
693 fontsize=16,
694 )
696 else:
697 # Add title.
698 axes.set_title(title, fontsize=16)
700 # Add watermark with min/max/mean. Currently not user togglable.
701 # In the bbox dictionary, fc and ec are hex colour codes for grey shade.
702 axes.annotate(
703 f"Min: {np.min(cube.data):.3g} Max: {np.max(cube.data):.3g} Mean: {np.mean(cube.data):.3g}",
704 xy=(1, -0.05),
705 xycoords="axes fraction",
706 xytext=(-5, 5),
707 textcoords="offset points",
708 ha="right",
709 va="bottom",
710 size=11,
711 bbox=dict(boxstyle="round", fc="#cccccc", ec="#808080", alpha=0.9),
712 )
714 # Add secondary colour bar for overlay_cube field if required.
715 if overlay_cube:
716 cbarB = fig.colorbar(
717 overlay, orientation="horizontal", location="bottom", pad=0.0, shrink=0.7
718 )
719 cbarB.set_label(label=f"{overlay_cube.name()} ({overlay_cube.units})", size=14)
720 # add ticks and tick_labels for every levels if less than 20 levels exist
721 if over_levels is not None and len(over_levels) < 20: 721 ↛ 722line 721 didn't jump to line 722 because the condition on line 721 was never true
722 cbarB.set_ticks(over_levels)
723 cbarB.set_ticklabels([f"{level:.2f}" for level in over_levels])
724 if "rainfall" or "snowfall" or "visibility" in overlay_cube.name():
725 cbarB.set_ticklabels([f"{level:.3g}" for level in over_levels])
726 logging.debug("Set secondary colorbar ticks and labels.")
728 # Add main colour bar.
729 cbar = fig.colorbar(
730 plot, orientation="horizontal", location="bottom", pad=0.042, shrink=0.7
731 )
732 cbar.set_label(label=f"{cube.name()} ({cube.units})", size=14)
733 # add ticks and tick_labels for every levels if less than 20 levels exist
734 if levels is not None and len(levels) < 20:
735 cbar.set_ticks(levels)
736 cbar.set_ticklabels([f"{level:.2f}" for level in levels])
737 if "rainfall" or "snowfall" or "visibility" in cube.name(): 737 ↛ 739line 737 didn't jump to line 739 because the condition on line 737 was always true
738 cbar.set_ticklabels([f"{level:.3g}" for level in levels])
739 logging.debug("Set colorbar ticks and labels.")
741 # Save plot.
742 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
743 logging.info("Saved spatial plot to %s", filename)
744 plt.close(fig)
747def _plot_and_save_postage_stamp_spatial_plot(
748 cube: iris.cube.Cube,
749 filename: str,
750 stamp_coordinate: str,
751 title: str,
752 method: Literal["contourf", "pcolormesh"],
753 overlay_cube: iris.cube.Cube | None = None,
754 contour_cube: iris.cube.Cube | None = None,
755 **kwargs,
756):
757 """Plot postage stamp spatial plots from an ensemble.
759 Parameters
760 ----------
761 cube: Cube
762 Iris cube of data to be plotted. It must have the stamp coordinate.
763 filename: str
764 Filename of the plot to write.
765 stamp_coordinate: str
766 Coordinate that becomes different plots.
767 method: "contourf" | "pcolormesh"
768 The plotting method to use.
769 overlay_cube: Cube, optional
770 Optional 2 dimensional (lat and lon) Cube of data to overplot on top of base cube
771 contour_cube: Cube, optional
772 Optional 2 dimensional (lat and lon) Cube of data to overplot as contours over base cube
774 Raises
775 ------
776 ValueError
777 If the cube doesn't have the right dimensions.
778 """
779 # Use the smallest square grid that will fit the members.
780 grid_size = int(math.ceil(math.sqrt(len(cube.coord(stamp_coordinate).points))))
782 fig = plt.figure(figsize=(10, 10))
784 # Specify the color bar
785 cmap, levels, norm = _colorbar_map_levels(cube)
786 # If overplotting, set required colorbars
787 if overlay_cube: 787 ↛ 788line 787 didn't jump to line 788 because the condition on line 787 was never true
788 over_cmap, over_levels, over_norm = _colorbar_map_levels(overlay_cube)
789 if contour_cube: 789 ↛ 790line 789 didn't jump to line 790 because the condition on line 789 was never true
790 cntr_cmap, cntr_levels, cntr_norm = _colorbar_map_levels(contour_cube)
792 # Make a subplot for each member.
793 for member, subplot in zip(
794 cube.slices_over(stamp_coordinate), range(1, grid_size**2 + 1), strict=False
795 ):
796 # Setup subplot map projection, extent and coastlines and borderlines.
797 axes = _setup_spatial_map(
798 member, fig, cmap, grid_size=grid_size, subplot=subplot
799 )
800 if method == "contourf":
801 # Filled contour plot of the field.
802 plot = iplt.contourf(member, cmap=cmap, levels=levels, norm=norm)
803 elif method == "pcolormesh":
804 if levels is not None:
805 vmin = min(levels)
806 vmax = max(levels)
807 else:
808 raise TypeError("Unknown vmin and vmax range.")
809 vmin, vmax = None, None
810 # pcolormesh plot of the field and ensure to use norm and not vmin/vmax
811 # if levels are defined.
812 if norm is not None: 812 ↛ 813line 812 didn't jump to line 813 because the condition on line 812 was never true
813 vmin = None
814 vmax = None
815 # pcolormesh plot of the field.
816 plot = iplt.pcolormesh(member, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax)
817 else:
818 raise ValueError(f"Unknown plotting method: {method}")
820 # Overplot overlay field, if required
821 if overlay_cube: 821 ↛ 822line 821 didn't jump to line 822 because the condition on line 821 was never true
822 try:
823 over_vmin = min(over_levels)
824 over_vmax = max(over_levels)
825 except TypeError:
826 over_vmin, over_vmax = None, None
827 if over_norm is not None:
828 over_vmin = None
829 over_vmax = None
830 iplt.pcolormesh(
831 overlay_cube[member.coord(stamp_coordinate).points[0]],
832 cmap=over_cmap,
833 norm=over_norm,
834 alpha=0.6,
835 vmin=over_vmin,
836 vmax=over_vmax,
837 )
838 # Overplot contour field, if required
839 if contour_cube: 839 ↛ 840line 839 didn't jump to line 840 because the condition on line 839 was never true
840 iplt.contour(
841 contour_cube[member.coord(stamp_coordinate).points[0]],
842 colors="darkgray",
843 levels=cntr_levels,
844 norm=cntr_norm,
845 alpha=0.6,
846 linestyles="--",
847 linewidths=1,
848 )
849 axes.set_title(f"Member #{member.coord(stamp_coordinate).points[0]}")
850 axes.set_axis_off()
852 # Put the shared colorbar in its own axes.
853 colorbar_axes = fig.add_axes([0.15, 0.07, 0.7, 0.03])
854 colorbar = fig.colorbar(
855 plot, colorbar_axes, orientation="horizontal", pad=0.042, shrink=0.7
856 )
857 colorbar.set_label(f"{cube.name()} ({cube.units})", size=14)
859 # Overall figure title.
860 fig.suptitle(title, fontsize=16)
862 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
863 logging.info("Saved contour postage stamp plot to %s", filename)
864 plt.close(fig)
867def _plot_and_save_line_series(
868 cubes: iris.cube.CubeList,
869 coords: list[iris.coords.Coord],
870 ensemble_coord: str,
871 filename: str,
872 title: str,
873 **kwargs,
874):
875 """Plot and save a 1D line series.
877 Parameters
878 ----------
879 cubes: Cube or CubeList
880 Cube or CubeList containing the cubes to plot on the y-axis.
881 coords: list[Coord]
882 Coordinates to plot on the x-axis, one per cube.
883 ensemble_coord: str
884 Ensemble coordinate in the cube.
885 filename: str
886 Filename of the plot to write.
887 title: str
888 Plot title.
889 """
890 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
892 model_colors_map = _get_model_colors_map(cubes)
894 # Store min/max ranges.
895 y_levels = []
897 # Check match-up across sequence coords gives consistent sizes
898 _validate_cubes_coords(cubes, coords)
900 for cube, coord in zip(cubes, coords, strict=True):
901 label = None
902 color = "black"
903 if model_colors_map:
904 label = cube.attributes.get("model_name")
905 color = model_colors_map.get(label)
906 for cube_slice in cube.slices_over(ensemble_coord):
907 # Label with (control) if part of an ensemble or not otherwise.
908 if cube_slice.coord(ensemble_coord).points == [0]:
909 iplt.plot(
910 coord,
911 cube_slice,
912 color=color,
913 marker="o",
914 ls="-",
915 lw=3,
916 label=f"{label} (control)"
917 if len(cube.coord(ensemble_coord).points) > 1
918 else label,
919 )
920 # Label with (perturbed) if part of an ensemble and not the control.
921 else:
922 iplt.plot(
923 coord,
924 cube_slice,
925 color=color,
926 ls="-",
927 lw=1.5,
928 alpha=0.75,
929 label=f"{label} (member)",
930 )
932 # Calculate the global min/max if multiple cubes are given.
933 _, levels, _ = _colorbar_map_levels(cube, axis="y")
934 if levels is not None: 934 ↛ 935line 934 didn't jump to line 935 because the condition on line 934 was never true
935 y_levels.append(min(levels))
936 y_levels.append(max(levels))
938 # Get the current axes.
939 ax = plt.gca()
941 # Add some labels and tweak the style.
942 # check if cubes[0] works for single cube if not CubeList
943 if coords[0].name() == "time":
944 ax.set_xlabel(f"{coords[0].name()}", fontsize=14)
945 else:
946 ax.set_xlabel(f"{coords[0].name()} / {coords[0].units}", fontsize=14)
947 ax.set_ylabel(f"{cubes[0].name()} / {cubes[0].units}", fontsize=14)
948 ax.set_title(title, fontsize=16)
950 ax.ticklabel_format(axis="y", useOffset=False)
951 ax.tick_params(axis="x", labelrotation=15)
952 ax.tick_params(axis="both", labelsize=12)
954 # Set y limits to global min and max, autoscale if colorbar doesn't exist.
955 if y_levels: 955 ↛ 956line 955 didn't jump to line 956 because the condition on line 955 was never true
956 ax.set_ylim(min(y_levels), max(y_levels))
957 # Add zero line.
958 if min(y_levels) < 0.0 and max(y_levels) > 0.0:
959 ax.axhline(y=0, xmin=0, xmax=1, ls="-", color="grey", lw=2)
960 logging.debug(
961 "Line plot with y-axis limits %s-%s", min(y_levels), max(y_levels)
962 )
963 else:
964 ax.autoscale()
966 # Add gridlines
967 ax.grid(linestyle="--", color="grey", linewidth=1)
968 # Ientify unique labels for legend
969 handles = list(
970 {
971 label: handle
972 for (handle, label) in zip(*ax.get_legend_handles_labels(), strict=True)
973 }.values()
974 )
975 ax.legend(handles=handles, loc="best", ncol=1, frameon=False, fontsize=16)
977 # Save plot.
978 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
979 logging.info("Saved line plot to %s", filename)
980 plt.close(fig)
983def _plot_and_save_line_power_spectrum_series(
984 cubes: iris.cube.CubeList,
985 coords: list[iris.coords.Coord],
986 ensemble_coord: str,
987 filename: str,
988 title: str,
989 series_coordinate: str = None,
990 **kwargs,
991):
992 """Plot and save a 1D line series.
994 Parameters
995 ----------
996 cubes: Cube or CubeList
997 Cube or CubeList containing the cubes to plot on the y-axis.
998 coords: list[Coord]
999 Coordinates to plot on the x-axis, one per cube.
1000 ensemble_coord: str
1001 Ensemble coordinate in the cube.
1002 filename: str
1003 Filename of the plot to write.
1004 title: str
1005 Plot title.
1006 series_coordinate: str, optional
1007 Coordinate being plotted on x-axis. In case of spectra frequency, physical_wavenumber, or wavelength.
1008 """
1009 # xn = coords[0].name() # x-axis (e.g. frequency)
1011 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
1012 model_colors_map = _get_model_colors_map(cubes)
1013 ax = plt.gca()
1015 # Store min/max ranges.
1016 y_levels = []
1018 line_marker = None
1019 line_width = 1
1021 # for cube, coord in zip(cubes, coords, strict=True):
1022 for cube in iter_maybe(cubes):
1023 # next 2 lines replace chunk of code.
1024 xcoord = select_series_coord(cube, series_coordinate)
1025 xname = xcoord.points
1027 yfield = cube.data # power spectrum
1028 label = None
1029 color = "black"
1030 if model_colors_map: 1030 ↛ 1033line 1030 didn't jump to line 1033 because the condition on line 1030 was always true
1031 label = cube.attributes.get("model_name")
1032 color = model_colors_map.get(label)
1033 for cube_slice in cube.slices_over(ensemble_coord):
1034 # Label with (control) if part of an ensemble or not otherwise.
1035 if cube_slice.coord(ensemble_coord).points == [0]: 1035 ↛ 1049line 1035 didn't jump to line 1049 because the condition on line 1035 was always true
1036 ax.plot(
1037 xname,
1038 yfield,
1039 color=color,
1040 marker=line_marker,
1041 ls="-",
1042 lw=line_width,
1043 label=f"{label} (control)"
1044 if len(cube.coord(ensemble_coord).points) > 1
1045 else label,
1046 )
1047 # Label with (perturbed) if part of an ensemble and not the control.
1048 else:
1049 ax.plot(
1050 xname,
1051 yfield,
1052 color=color,
1053 ls="-",
1054 lw=1.5,
1055 alpha=0.75,
1056 label=f"{label} (member)",
1057 )
1059 # Calculate the global min/max if multiple cubes are given.
1060 _, levels, _ = _colorbar_map_levels(cube, axis="y")
1061 if levels is not None: 1061 ↛ 1062line 1061 didn't jump to line 1062 because the condition on line 1061 was never true
1062 y_levels.append(min(levels))
1063 y_levels.append(max(levels))
1065 # Add some labels and tweak the style.
1066 # check if cubes[0] works for single cube if not CubeList
1068 # title = f"{title}\n [{coords.units.title(coords.points[0])}]"
1069 title = f"{title}"
1070 ax.set_title(title, fontsize=16)
1072 # Set appropriate x-axis label based on coordinate
1073 if series_coordinate == "wavelength" or ( 1073 ↛ 1076line 1073 didn't jump to line 1076 because the condition on line 1073 was never true
1074 hasattr(xcoord, "long_name") and xcoord.long_name == "wavelength"
1075 ):
1076 ax.set_xlabel("Wavelength (km)", fontsize=14)
1077 elif series_coordinate == "physical_wavenumber" or ( 1077 ↛ 1080line 1077 didn't jump to line 1080 because the condition on line 1077 was never true
1078 hasattr(xcoord, "long_name") and xcoord.long_name == "physical_wavenumber"
1079 ):
1080 ax.set_xlabel("Wavenumber (km⁻¹)", fontsize=14)
1081 else: # frequency or check units
1082 if hasattr(xcoord, "units") and str(xcoord.units) == "km-1": 1082 ↛ 1083line 1082 didn't jump to line 1083 because the condition on line 1082 was never true
1083 ax.set_xlabel("Wavenumber (km⁻¹)", fontsize=14)
1084 else:
1085 ax.set_xlabel("Wavenumber", fontsize=14)
1087 ax.set_ylabel("Power Spectral Density", fontsize=14)
1088 ax.tick_params(axis="both", labelsize=12)
1090 # Set y limits to global min and max, autoscale if colorbar doesn't exist.
1092 # Set log-log scale
1093 ax.set_xscale("log")
1094 ax.set_yscale("log")
1096 # Add gridlines
1097 ax.grid(linestyle="--", color="grey", linewidth=1)
1098 # Ientify unique labels for legend
1099 handles = list(
1100 {
1101 label: handle
1102 for (handle, label) in zip(*ax.get_legend_handles_labels(), strict=True)
1103 }.values()
1104 )
1105 ax.legend(handles=handles, loc="best", ncol=1, frameon=False, fontsize=16)
1107 # Save plot.
1108 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1109 logging.info("Saved line plot to %s", filename)
1110 plt.close(fig)
1113def _plot_and_save_vertical_line_series(
1114 cubes: iris.cube.CubeList,
1115 coords: list[iris.coords.Coord],
1116 ensemble_coord: str,
1117 filename: str,
1118 series_coordinate: str,
1119 title: str,
1120 vmin: float,
1121 vmax: float,
1122 **kwargs,
1123):
1124 """Plot and save a 1D line series in vertical.
1126 Parameters
1127 ----------
1128 cubes: CubeList
1129 1 dimensional Cube or CubeList of the data to plot on x-axis.
1130 coord: list[Coord]
1131 Coordinates to plot on the y-axis, one per cube.
1132 ensemble_coord: str
1133 Ensemble coordinate in the cube.
1134 filename: str
1135 Filename of the plot to write.
1136 series_coordinate: str
1137 Coordinate to use as vertical axis.
1138 title: str
1139 Plot title.
1140 vmin: float
1141 Minimum value for the x-axis.
1142 vmax: float
1143 Maximum value for the x-axis.
1144 """
1145 # plot the vertical pressure axis using log scale
1146 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
1148 model_colors_map = _get_model_colors_map(cubes)
1150 # Check match-up across sequence coords gives consistent sizes
1151 _validate_cubes_coords(cubes, coords)
1153 for cube, coord in zip(cubes, coords, strict=True):
1154 label = None
1155 color = "black"
1156 if model_colors_map: 1156 ↛ 1157line 1156 didn't jump to line 1157 because the condition on line 1156 was never true
1157 label = cube.attributes.get("model_name")
1158 color = model_colors_map.get(label)
1160 for cube_slice in cube.slices_over(ensemble_coord):
1161 # If ensemble data given plot control member with (control)
1162 # unless single forecast.
1163 if cube_slice.coord(ensemble_coord).points == [0]:
1164 iplt.plot(
1165 cube_slice,
1166 coord,
1167 color=color,
1168 marker="o",
1169 ls="-",
1170 lw=3,
1171 label=f"{label} (control)"
1172 if len(cube.coord(ensemble_coord).points) > 1
1173 else label,
1174 )
1175 # If ensemble data given plot perturbed members with (perturbed).
1176 else:
1177 iplt.plot(
1178 cube_slice,
1179 coord,
1180 color=color,
1181 ls="-",
1182 lw=1.5,
1183 alpha=0.75,
1184 label=f"{label} (member)",
1185 )
1187 # Get the current axis
1188 ax = plt.gca()
1190 # Special handling for pressure level data.
1191 if series_coordinate == "pressure": 1191 ↛ 1213line 1191 didn't jump to line 1213 because the condition on line 1191 was always true
1192 # Invert y-axis and set to log scale.
1193 ax.invert_yaxis()
1194 ax.set_yscale("log")
1196 # Define y-ticks and labels for pressure log axis.
1197 y_tick_labels = [
1198 "1000",
1199 "850",
1200 "700",
1201 "500",
1202 "300",
1203 "200",
1204 "100",
1205 ]
1206 y_ticks = [1000, 850, 700, 500, 300, 200, 100]
1208 # Set y-axis limits and ticks.
1209 ax.set_ylim(1100, 100)
1211 # Test if series_coordinate is model level data. The UM data uses
1212 # model_level_number and lfric uses full_levels as coordinate.
1213 elif series_coordinate in ("model_level_number", "full_levels", "half_levels"):
1214 # Define y-ticks and labels for vertical axis.
1215 y_ticks = iter_maybe(cubes)[0].coord(series_coordinate).points
1216 y_tick_labels = [str(int(i)) for i in y_ticks]
1217 ax.set_ylim(min(y_ticks), max(y_ticks))
1219 ax.set_yticks(y_ticks)
1220 ax.set_yticklabels(y_tick_labels)
1222 # Set x-axis limits.
1223 ax.set_xlim(vmin, vmax)
1224 # Mark y=0 if present in plot.
1225 if vmin < 0.0 and vmax > 0.0: 1225 ↛ 1226line 1225 didn't jump to line 1226 because the condition on line 1225 was never true
1226 ax.axvline(x=0, ymin=0, ymax=1, ls="-", color="grey", lw=2)
1228 # Add some labels and tweak the style.
1229 ax.set_ylabel(f"{coord.name()} / {coord.units}", fontsize=14)
1230 ax.set_xlabel(
1231 f"{iter_maybe(cubes)[0].name()} / {iter_maybe(cubes)[0].units}", fontsize=14
1232 )
1233 ax.set_title(title, fontsize=16)
1234 ax.ticklabel_format(axis="x")
1235 ax.tick_params(axis="y")
1236 ax.tick_params(axis="both", labelsize=12)
1238 # Add gridlines
1239 ax.grid(linestyle="--", color="grey", linewidth=1)
1240 # Ientify unique labels for legend
1241 handles = list(
1242 {
1243 label: handle
1244 for (handle, label) in zip(*ax.get_legend_handles_labels(), strict=True)
1245 }.values()
1246 )
1247 ax.legend(handles=handles, loc="best", ncol=1, frameon=False, fontsize=16)
1249 # Save plot.
1250 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1251 logging.info("Saved line plot to %s", filename)
1252 plt.close(fig)
1255def _plot_and_save_scatter_plot(
1256 cube_x: iris.cube.Cube | iris.cube.CubeList,
1257 cube_y: iris.cube.Cube | iris.cube.CubeList,
1258 filename: str,
1259 title: str,
1260 one_to_one: bool,
1261 model_names: list[str] = None,
1262 **kwargs,
1263):
1264 """Plot and save a 2D scatter plot.
1266 Parameters
1267 ----------
1268 cube_x: Cube | CubeList
1269 1 dimensional Cube or CubeList of the data to plot on x-axis.
1270 cube_y: Cube | CubeList
1271 1 dimensional Cube or CubeList of the data to plot on y-axis.
1272 filename: str
1273 Filename of the plot to write.
1274 title: str
1275 Plot title.
1276 one_to_one: bool
1277 Whether a 1:1 line is plotted.
1278 """
1279 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
1280 # plot the cube_x and cube_y 1D fields as a scatter plot. If they are CubeLists this ensures
1281 # to pair each cube from cube_x with the corresponding cube from cube_y, allowing to iterate
1282 # over the pairs simultaneously.
1284 # Ensure cube_x and cube_y are iterable
1285 cube_x_iterable = iter_maybe(cube_x)
1286 cube_y_iterable = iter_maybe(cube_y)
1288 for cube_x_iter, cube_y_iter in zip(cube_x_iterable, cube_y_iterable, strict=True):
1289 iplt.scatter(cube_x_iter, cube_y_iter)
1290 if one_to_one is True:
1291 plt.plot(
1292 [
1293 np.nanmin([np.nanmin(cube_y.data), np.nanmin(cube_x.data)]),
1294 np.nanmax([np.nanmax(cube_y.data), np.nanmax(cube_x.data)]),
1295 ],
1296 [
1297 np.nanmin([np.nanmin(cube_y.data), np.nanmin(cube_x.data)]),
1298 np.nanmax([np.nanmax(cube_y.data), np.nanmax(cube_x.data)]),
1299 ],
1300 "k",
1301 linestyle="--",
1302 )
1303 ax = plt.gca()
1305 # Add some labels and tweak the style.
1306 if model_names is None:
1307 ax.set_xlabel(f"{cube_x[0].name()} / {cube_x[0].units}", fontsize=14)
1308 ax.set_ylabel(f"{cube_y[0].name()} / {cube_y[0].units}", fontsize=14)
1309 else:
1310 # Add the model names, these should be order of base (x) and other (y).
1311 ax.set_xlabel(
1312 f"{model_names[0]}_{cube_x[0].name()} / {cube_x[0].units}", fontsize=14
1313 )
1314 ax.set_ylabel(
1315 f"{model_names[1]}_{cube_y[0].name()} / {cube_y[0].units}", fontsize=14
1316 )
1317 ax.set_title(title, fontsize=16)
1318 ax.ticklabel_format(axis="y", useOffset=False)
1319 ax.tick_params(axis="x", labelrotation=15)
1320 ax.tick_params(axis="both", labelsize=12)
1321 ax.autoscale()
1323 # Save plot.
1324 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1325 logging.info("Saved scatter plot to %s", filename)
1326 plt.close(fig)
1329def _plot_and_save_vector_plot(
1330 cube_u: iris.cube.Cube,
1331 cube_v: iris.cube.Cube,
1332 filename: str,
1333 title: str,
1334 method: Literal["contourf", "pcolormesh"],
1335 **kwargs,
1336):
1337 """Plot and save a 2D vector plot.
1339 Parameters
1340 ----------
1341 cube_u: Cube
1342 2 dimensional Cube of u component of the data.
1343 cube_v: Cube
1344 2 dimensional Cube of v component of the data.
1345 filename: str
1346 Filename of the plot to write.
1347 title: str
1348 Plot title.
1349 """
1350 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
1352 # Create a cube containing the magnitude of the vector field.
1353 cube_vec_mag = (cube_u**2 + cube_v**2) ** 0.5
1354 cube_vec_mag.rename(f"{cube_u.name()}_{cube_v.name()}_magnitude")
1356 # Specify the color bar
1357 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag)
1359 # Setup plot map projection, extent and coastlines and borderlines.
1360 axes = _setup_spatial_map(cube_vec_mag, fig, cmap)
1362 if method == "contourf":
1363 # Filled contour plot of the field.
1364 plot = iplt.contourf(cube_vec_mag, cmap=cmap, levels=levels, norm=norm)
1365 elif method == "pcolormesh":
1366 try:
1367 vmin = min(levels)
1368 vmax = max(levels)
1369 except TypeError:
1370 vmin, vmax = None, None
1371 # pcolormesh plot of the field and ensure to use norm and not vmin/vmax
1372 # if levels are defined.
1373 if norm is not None:
1374 vmin = None
1375 vmax = None
1376 plot = iplt.pcolormesh(cube_vec_mag, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax)
1377 else:
1378 raise ValueError(f"Unknown plotting method: {method}")
1380 # Check to see if transect, and if so, adjust y axis.
1381 if is_transect(cube_vec_mag):
1382 if "pressure" in [coord.name() for coord in cube_vec_mag.coords()]:
1383 axes.invert_yaxis()
1384 axes.set_yscale("log")
1385 axes.set_ylim(1100, 100)
1386 # If both model_level_number and level_height exists, iplt can construct
1387 # plot as a function of height above orography (NOT sea level).
1388 elif {"model_level_number", "level_height"}.issubset(
1389 {coord.name() for coord in cube_vec_mag.coords()}
1390 ):
1391 axes.set_yscale("log")
1393 axes.set_title(
1394 f"{title}\n"
1395 f"Start Lat: {cube_vec_mag.attributes['transect_coords'].split('_')[0]}"
1396 f" Start Lon: {cube_vec_mag.attributes['transect_coords'].split('_')[1]}"
1397 f" End Lat: {cube_vec_mag.attributes['transect_coords'].split('_')[2]}"
1398 f" End Lon: {cube_vec_mag.attributes['transect_coords'].split('_')[3]}",
1399 fontsize=16,
1400 )
1402 else:
1403 # Add title.
1404 axes.set_title(title, fontsize=16)
1406 # Add watermark with min/max/mean. Currently not user togglable.
1407 # In the bbox dictionary, fc and ec are hex colour codes for grey shade.
1408 axes.annotate(
1409 f"Min: {np.min(cube_vec_mag.data):.3g} Max: {np.max(cube_vec_mag.data):.3g} Mean: {np.mean(cube_vec_mag.data):.3g}",
1410 xy=(1, -0.05),
1411 xycoords="axes fraction",
1412 xytext=(-5, 5),
1413 textcoords="offset points",
1414 ha="right",
1415 va="bottom",
1416 size=11,
1417 bbox=dict(boxstyle="round", fc="#cccccc", ec="#808080", alpha=0.9),
1418 )
1420 # Add colour bar.
1421 cbar = fig.colorbar(plot, orientation="horizontal", pad=0.042, shrink=0.7)
1422 cbar.set_label(label=f"{cube_vec_mag.name()} ({cube_vec_mag.units})", size=14)
1423 # add ticks and tick_labels for every levels if less than 20 levels exist
1424 if levels is not None and len(levels) < 20:
1425 cbar.set_ticks(levels)
1426 cbar.set_ticklabels([f"{level:.1f}" for level in levels])
1428 # 30 barbs along the longest axis of the plot, or a barb per point for data
1429 # with less than 30 points.
1430 step = max(max(cube_u.shape) // 30, 1)
1431 iplt.quiver(cube_u[::step, ::step], cube_v[::step, ::step], pivot="middle")
1433 # Save plot.
1434 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1435 logging.info("Saved vector plot to %s", filename)
1436 plt.close(fig)
1439def _plot_and_save_histogram_series(
1440 cubes: iris.cube.Cube | iris.cube.CubeList,
1441 filename: str,
1442 title: str,
1443 vmin: float,
1444 vmax: float,
1445 **kwargs,
1446):
1447 """Plot and save a histogram series.
1449 Parameters
1450 ----------
1451 cubes: Cube or CubeList
1452 2 dimensional Cube or CubeList of the data to plot as histogram.
1453 filename: str
1454 Filename of the plot to write.
1455 title: str
1456 Plot title.
1457 vmin: float
1458 minimum for colorbar
1459 vmax: float
1460 maximum for colorbar
1461 """
1462 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
1463 ax = plt.gca()
1465 model_colors_map = _get_model_colors_map(cubes)
1467 # Set default that histograms will produce probability density function
1468 # at each bin (integral over range sums to 1).
1469 density = True
1471 for cube in iter_maybe(cubes):
1472 # Easier to check title (where var name originates)
1473 # than seeing if long names exist etc.
1474 # Exception case, where distribution better fits log scales/bins.
1475 if "surface_microphysical" in title:
1476 if "amount" in title: 1476 ↛ 1478line 1476 didn't jump to line 1478 because the condition on line 1476 was never true
1477 # Compute histogram following Klingaman et al. (2017): ASoP
1478 bin2 = np.exp(np.log(0.02) + 0.1 * np.linspace(0, 99, 100))
1479 bins = np.pad(bin2, (1, 0), "constant", constant_values=0)
1480 density = False
1481 else:
1482 bins = 10.0 ** (
1483 np.arange(-10, 27, 1) / 10.0
1484 ) # Suggestion from RMED toolbox.
1485 bins = np.insert(bins, 0, 0)
1486 ax.set_yscale("log")
1487 vmin = bins[1]
1488 vmax = bins[-1] # Manually set vmin/vmax to override json derived value.
1489 ax.set_xscale("log")
1490 elif "lightning" in title:
1491 bins = [0, 1, 2, 3, 4, 5]
1492 else:
1493 bins = np.linspace(vmin, vmax, 51)
1494 logging.debug(
1495 "Plotting histogram with %s bins %s - %s.",
1496 np.size(bins),
1497 np.min(bins),
1498 np.max(bins),
1499 )
1501 # Reshape cube data into a single array to allow for a single histogram.
1502 # Otherwise we plot xdim histograms stacked.
1503 cube_data_1d = (cube.data).flatten()
1505 label = None
1506 color = "black"
1507 if model_colors_map: 1507 ↛ 1508line 1507 didn't jump to line 1508 because the condition on line 1507 was never true
1508 label = cube.attributes.get("model_name")
1509 color = model_colors_map[label]
1510 x, y = np.histogram(cube_data_1d, bins=bins, density=density)
1512 # Compute area under curve.
1513 if "surface_microphysical" in title and "amount" in title: 1513 ↛ 1514line 1513 didn't jump to line 1514 because the condition on line 1513 was never true
1514 bin_mean = (bins[:-1] + bins[1:]) / 2.0
1515 x = x * bin_mean / x.sum()
1516 x = x[1:]
1517 y = y[1:]
1519 ax.plot(
1520 y[:-1], x, color=color, linewidth=3, marker="o", markersize=6, label=label
1521 )
1523 # Add some labels and tweak the style.
1524 ax.set_title(title, fontsize=16)
1525 ax.set_xlabel(
1526 f"{iter_maybe(cubes)[0].name()} / {iter_maybe(cubes)[0].units}", fontsize=14
1527 )
1528 ax.set_ylabel("Normalised probability density", fontsize=14)
1529 if "surface_microphysical" in title and "amount" in title: 1529 ↛ 1530line 1529 didn't jump to line 1530 because the condition on line 1529 was never true
1530 ax.set_ylabel(
1531 f"Contribution to mean ({iter_maybe(cubes)[0].units})", fontsize=14
1532 )
1533 ax.set_xlim(vmin, vmax)
1534 ax.tick_params(axis="both", labelsize=12)
1536 # Overlay grid-lines onto histogram plot.
1537 ax.grid(linestyle="--", color="grey", linewidth=1)
1538 if model_colors_map: 1538 ↛ 1539line 1538 didn't jump to line 1539 because the condition on line 1538 was never true
1539 ax.legend(loc="best", ncol=1, frameon=False, fontsize=16)
1541 # Save plot.
1542 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1543 logging.info("Saved histogram plot to %s", filename)
1544 plt.close(fig)
1547def _plot_and_save_postage_stamp_histogram_series(
1548 cube: iris.cube.Cube,
1549 filename: str,
1550 title: str,
1551 stamp_coordinate: str,
1552 vmin: float,
1553 vmax: float,
1554 **kwargs,
1555):
1556 """Plot and save postage (ensemble members) stamps for a histogram series.
1558 Parameters
1559 ----------
1560 cube: Cube
1561 2 dimensional Cube of the data to plot as histogram.
1562 filename: str
1563 Filename of the plot to write.
1564 title: str
1565 Plot title.
1566 stamp_coordinate: str
1567 Coordinate that becomes different plots.
1568 vmin: float
1569 minimum for pdf x-axis
1570 vmax: float
1571 maximum for pdf x-axis
1572 """
1573 # Use the smallest square grid that will fit the members.
1574 grid_size = int(math.ceil(math.sqrt(len(cube.coord(stamp_coordinate).points))))
1576 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
1577 # Make a subplot for each member.
1578 for member, subplot in zip(
1579 cube.slices_over(stamp_coordinate), range(1, grid_size**2 + 1), strict=False
1580 ):
1581 # Implicit interface is much easier here, due to needing to have the
1582 # cartopy GeoAxes generated.
1583 plt.subplot(grid_size, grid_size, subplot)
1584 # Reshape cube data into a single array to allow for a single histogram.
1585 # Otherwise we plot xdim histograms stacked.
1586 member_data_1d = (member.data).flatten()
1587 plt.hist(member_data_1d, density=True, stacked=True)
1588 ax = plt.gca()
1589 ax.set_title(f"Member #{member.coord(stamp_coordinate).points[0]}")
1590 ax.set_xlim(vmin, vmax)
1592 # Overall figure title.
1593 fig.suptitle(title, fontsize=16)
1595 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1596 logging.info("Saved histogram postage stamp plot to %s", filename)
1597 plt.close(fig)
1600def _plot_and_save_postage_stamps_in_single_plot_histogram_series(
1601 cube: iris.cube.Cube,
1602 filename: str,
1603 title: str,
1604 stamp_coordinate: str,
1605 vmin: float,
1606 vmax: float,
1607 **kwargs,
1608):
1609 fig, ax = plt.subplots(figsize=(10, 10), facecolor="w", edgecolor="k")
1610 ax.set_title(title, fontsize=16)
1611 ax.set_xlim(vmin, vmax)
1612 ax.set_xlabel(f"{cube.name()} / {cube.units}", fontsize=14)
1613 ax.set_ylabel("normalised probability density", fontsize=14)
1614 # Loop over all slices along the stamp_coordinate
1615 for member in cube.slices_over(stamp_coordinate):
1616 # Flatten the member data to 1D
1617 member_data_1d = member.data.flatten()
1618 # Plot the histogram using plt.hist
1619 plt.hist(
1620 member_data_1d,
1621 density=True,
1622 stacked=True,
1623 label=f"Member #{member.coord(stamp_coordinate).points[0]}",
1624 )
1626 # Add a legend
1627 ax.legend(fontsize=16)
1629 # Save the figure to a file
1630 plt.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1631 logging.info("Saved histogram postage stamp plot to %s", filename)
1633 # Close the figure
1634 plt.close(fig)
1637def _plot_and_save_scattermap_plot(
1638 cube: iris.cube.Cube, filename: str, title: str, projection=None, **kwargs
1639):
1640 """Plot and save a geographical scatter plot.
1642 Parameters
1643 ----------
1644 cube: Cube
1645 1 dimensional Cube of the data points with auxiliary latitude and
1646 longitude coordinates,
1647 filename: str
1648 Filename of the plot to write.
1649 title: str
1650 Plot title.
1651 projection: str
1652 Mapping projection to be used by cartopy.
1653 """
1654 # Setup plot details, size, resolution, etc.
1655 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
1656 if projection is not None:
1657 # Apart from the default, the only projection we currently support is
1658 # a stereographic projection over the North Pole.
1659 if projection == "NP_Stereo":
1660 axes = plt.axes(projection=ccrs.NorthPolarStereo(central_longitude=0.0))
1661 else:
1662 raise ValueError(f"Unknown projection: {projection}")
1663 else:
1664 axes = plt.axes(projection=ccrs.PlateCarree())
1666 # Scatter plot of the field. The marker size is chosen to give
1667 # symbols that decrease in size as the number of observations
1668 # increases, although the fraction of the figure covered by
1669 # symbols increases roughly as N^(1/2), disregarding overlaps,
1670 # and has been selected for the default figure size of (10, 10).
1671 # Should this be changed, the marker size should be adjusted in
1672 # proportion to the area of the figure.
1673 mrk_size = int(np.sqrt(2500000.0 / len(cube.data)))
1674 klon = None
1675 klat = None
1676 for kc in range(len(cube.aux_coords)):
1677 if cube.aux_coords[kc].standard_name == "latitude":
1678 klat = kc
1679 elif cube.aux_coords[kc].standard_name == "longitude":
1680 klon = kc
1681 scatter_map = iplt.scatter(
1682 cube.aux_coords[klon],
1683 cube.aux_coords[klat],
1684 c=cube.data[:],
1685 s=mrk_size,
1686 cmap="jet",
1687 edgecolors="k",
1688 )
1690 # Add coastlines and borderlines.
1691 try:
1692 axes.coastlines(resolution="10m")
1693 axes.add_feature(cfeature.BORDERS)
1694 except AttributeError:
1695 pass
1697 # Add title.
1698 axes.set_title(title, fontsize=16)
1700 # Add colour bar.
1701 cbar = fig.colorbar(scatter_map)
1702 cbar.set_label(label=f"{cube.name()} ({cube.units})", size=20)
1704 # Save plot.
1705 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1706 logging.info("Saved geographical scatter plot to %s", filename)
1707 plt.close(fig)
1710def _spatial_plot(
1711 method: Literal["contourf", "pcolormesh"],
1712 cube: iris.cube.Cube,
1713 filename: str | None,
1714 sequence_coordinate: str,
1715 stamp_coordinate: str,
1716 overlay_cube: iris.cube.Cube | None = None,
1717 contour_cube: iris.cube.Cube | None = None,
1718 **kwargs,
1719):
1720 """Plot a spatial variable onto a map from a 2D, 3D, or 4D cube.
1722 A 2D spatial field can be plotted, but if the sequence_coordinate is present
1723 then a sequence of plots will be produced. Similarly if the stamp_coordinate
1724 is present then postage stamp plots will be produced.
1726 If an overlay_cube and/or contour_cube are specified, multiple variables can
1727 be overplotted on the same figure.
1729 Parameters
1730 ----------
1731 method: "contourf" | "pcolormesh"
1732 The plotting method to use.
1733 cube: Cube
1734 Iris cube of the data to plot. It should have two spatial dimensions,
1735 such as lat and lon, and may also have a another two dimension to be
1736 plotted sequentially and/or as postage stamp plots.
1737 filename: str | None
1738 Name of the plot to write, used as a prefix for plot sequences. If None
1739 uses the recipe name.
1740 sequence_coordinate: str
1741 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
1742 This coordinate must exist in the cube.
1743 stamp_coordinate: str
1744 Coordinate about which to plot postage stamp plots. Defaults to
1745 ``"realization"``.
1746 overlay_cube: Cube | None, optional
1747 Optional 2 dimensional (lat and lon) Cube of data to overplot on top of base cube
1748 contour_cube: Cube | None, optional
1749 Optional 2 dimensional (lat and lon) Cube of data to overplot as contours over base cube
1751 Raises
1752 ------
1753 ValueError
1754 If the cube doesn't have the right dimensions.
1755 TypeError
1756 If the cube isn't a single cube.
1757 """
1758 recipe_title = get_recipe_metadata().get("title", "Untitled")
1760 # Ensure we've got a single cube.
1761 cube = _check_single_cube(cube)
1763 # Make postage stamp plots if stamp_coordinate exists and has more than a
1764 # single point.
1765 plotting_func = _plot_and_save_spatial_plot
1766 try:
1767 if cube.coord(stamp_coordinate).shape[0] > 1:
1768 plotting_func = _plot_and_save_postage_stamp_spatial_plot
1769 except iris.exceptions.CoordinateNotFoundError:
1770 pass
1772 # Produce a geographical scatter plot if the data have a
1773 # dimension called observation or model_obs_error
1774 if any( 1774 ↛ 1778line 1774 didn't jump to line 1778 because the condition on line 1774 was never true
1775 crd.var_name == "station" or crd.var_name == "model_obs_error"
1776 for crd in cube.coords()
1777 ):
1778 plotting_func = _plot_and_save_scattermap_plot
1780 # Must have a sequence coordinate.
1781 try:
1782 cube.coord(sequence_coordinate)
1783 except iris.exceptions.CoordinateNotFoundError as err:
1784 raise ValueError(f"Cube must have a {sequence_coordinate} coordinate.") from err
1786 # Create a plot for each value of the sequence coordinate.
1787 plot_index = []
1788 nplot = np.size(cube.coord(sequence_coordinate).points)
1790 for iseq, cube_slice in enumerate(cube.slices_over(sequence_coordinate)):
1791 # Set plot titles and filename
1792 seq_coord = cube_slice.coord(sequence_coordinate)
1793 plot_title, plot_filename = _set_title_and_filename(
1794 seq_coord, nplot, recipe_title, filename
1795 )
1797 # Extract sequence slice for overlay_cube and contour_cube if required.
1798 overlay_slice = slice_over_maybe(overlay_cube, sequence_coordinate, iseq)
1799 contour_slice = slice_over_maybe(contour_cube, sequence_coordinate, iseq)
1801 # Do the actual plotting.
1802 plotting_func(
1803 cube_slice,
1804 filename=plot_filename,
1805 stamp_coordinate=stamp_coordinate,
1806 title=plot_title,
1807 method=method,
1808 overlay_cube=overlay_slice,
1809 contour_cube=contour_slice,
1810 **kwargs,
1811 )
1812 plot_index.append(plot_filename)
1814 # Add list of plots to plot metadata.
1815 complete_plot_index = _append_to_plot_index(plot_index)
1817 # Make a page to display the plots.
1818 _make_plot_html_page(complete_plot_index)
1821def _custom_colormap_mask(cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None):
1822 """Get colourmap for mask.
1824 If "mask_for_" appears anywhere in the name of a cube this function will be called
1825 regardless of the name of the variable to ensure a consistent plot.
1827 Parameters
1828 ----------
1829 cube: Cube
1830 Cube of variable for which the colorbar information is desired.
1831 axis: "x", "y", optional
1832 Select the levels for just this axis of a line plot. The min and max
1833 can be set by xmin/xmax or ymin/ymax respectively. For variables where
1834 setting a universal range is not desirable (e.g. temperature), users
1835 can set ymin/ymax values to "auto" in the colorbar definitions file.
1836 Where no additional xmin/xmax or ymin/ymax values are provided, the
1837 axis bounds default to use the vmin/vmax values provided.
1839 Returns
1840 -------
1841 cmap:
1842 Matplotlib colormap.
1843 levels:
1844 List of levels to use for plotting. For continuous plots the min and max
1845 should be taken as the range.
1846 norm:
1847 BoundaryNorm information.
1848 """
1849 if "difference" not in cube.long_name:
1850 if axis:
1851 levels = [0, 1]
1852 # Complete settings based on levels.
1853 return None, levels, None
1854 else:
1855 # Define the levels and colors.
1856 levels = [0, 1, 2]
1857 colors = ["white", "dodgerblue"]
1858 # Create a custom color map.
1859 cmap = mcolors.ListedColormap(colors)
1860 # Normalize the levels.
1861 norm = mcolors.BoundaryNorm(levels, cmap.N)
1862 logging.debug("Colourmap for %s.", cube.long_name)
1863 return cmap, levels, norm
1864 else:
1865 if axis:
1866 levels = [-1, 1]
1867 return None, levels, None
1868 else:
1869 # Search for if mask difference, set to +/- 0.5 as values plotted <
1870 # not <=.
1871 levels = [-2, -0.5, 0.5, 2]
1872 colors = ["goldenrod", "white", "teal"]
1873 cmap = mcolors.ListedColormap(colors)
1874 norm = mcolors.BoundaryNorm(levels, cmap.N)
1875 logging.debug("Colourmap for %s.", cube.long_name)
1876 return cmap, levels, norm
1879def _custom_beaufort_scale(cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None):
1880 """Get a custom colorbar for a cube in the Beaufort Scale.
1882 Specific variable ranges can be separately set in user-supplied definition
1883 for x- or y-axis limits, or indicate where automated range preferred.
1885 Parameters
1886 ----------
1887 cube: Cube
1888 Cube of variable with Beaufort Scale in name.
1889 axis: "x", "y", optional
1890 Select the levels for just this axis of a line plot. The min and max
1891 can be set by xmin/xmax or ymin/ymax respectively. For variables where
1892 setting a universal range is not desirable (e.g. temperature), users
1893 can set ymin/ymax values to "auto" in the colorbar definitions file.
1894 Where no additional xmin/xmax or ymin/ymax values are provided, the
1895 axis bounds default to use the vmin/vmax values provided.
1897 Returns
1898 -------
1899 cmap:
1900 Matplotlib colormap.
1901 levels:
1902 List of levels to use for plotting. For continuous plots the min and max
1903 should be taken as the range.
1904 norm:
1905 BoundaryNorm information.
1906 """
1907 if "difference" not in cube.long_name:
1908 if axis:
1909 levels = [0, 12]
1910 return None, levels, None
1911 else:
1912 levels = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]
1913 colors = [
1914 "black",
1915 (0, 0, 0.6),
1916 "blue",
1917 "cyan",
1918 "green",
1919 "yellow",
1920 (1, 0.5, 0),
1921 "red",
1922 "pink",
1923 "magenta",
1924 "purple",
1925 "maroon",
1926 "white",
1927 ]
1928 cmap = mcolors.ListedColormap(colors)
1929 norm = mcolors.BoundaryNorm(levels, cmap.N)
1930 logging.info("change colormap for Beaufort Scale colorbar.")
1931 return cmap, levels, norm
1932 else:
1933 if axis:
1934 levels = [-4, 4]
1935 return None, levels, None
1936 else:
1937 levels = [
1938 -3.5,
1939 -2.5,
1940 -1.5,
1941 -0.5,
1942 0.5,
1943 1.5,
1944 2.5,
1945 3.5,
1946 ]
1947 cmap = plt.get_cmap("bwr", 8)
1948 norm = mcolors.BoundaryNorm(levels, cmap.N)
1949 return cmap, levels, norm
1952def _custom_colormap_celsius(cube: iris.cube.Cube, cmap, levels, norm):
1953 """Return altered colourmap for temperature with change in units to Celsius.
1955 If "Celsius" appears anywhere in the name of a cube this function will be called.
1957 Parameters
1958 ----------
1959 cube: Cube
1960 Cube of variable for which the colorbar information is desired.
1961 cmap: Matplotlib colormap.
1962 levels: List
1963 List of levels to use for plotting. For continuous plots the min and max
1964 should be taken as the range.
1965 norm: BoundaryNorm.
1967 Returns
1968 -------
1969 cmap: Matplotlib colormap.
1970 levels: List
1971 List of levels to use for plotting. For continuous plots the min and max
1972 should be taken as the range.
1973 norm: BoundaryNorm.
1974 """
1975 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name])
1976 if any("temperature" in name for name in varnames) and "Celsius" == cube.units:
1977 levels = np.array(levels)
1978 levels -= 273
1979 levels = levels.tolist()
1980 else:
1981 # Do nothing keep the existing colourbar attributes
1982 levels = levels
1983 cmap = cmap
1984 norm = norm
1985 return cmap, levels, norm
1988def _custom_colormap_probability(
1989 cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None
1990):
1991 """Get a custom colorbar for a probability cube.
1993 Specific variable ranges can be separately set in user-supplied definition
1994 for x- or y-axis limits, or indicate where automated range preferred.
1996 Parameters
1997 ----------
1998 cube: Cube
1999 Cube of variable with probability in name.
2000 axis: "x", "y", optional
2001 Select the levels for just this axis of a line plot. The min and max
2002 can be set by xmin/xmax or ymin/ymax respectively. For variables where
2003 setting a universal range is not desirable (e.g. temperature), users
2004 can set ymin/ymax values to "auto" in the colorbar definitions file.
2005 Where no additional xmin/xmax or ymin/ymax values are provided, the
2006 axis bounds default to use the vmin/vmax values provided.
2008 Returns
2009 -------
2010 cmap:
2011 Matplotlib colormap.
2012 levels:
2013 List of levels to use for plotting. For continuous plots the min and max
2014 should be taken as the range.
2015 norm:
2016 BoundaryNorm information.
2017 """
2018 if axis:
2019 levels = [0, 1]
2020 return None, levels, None
2021 else:
2022 cmap = mcolors.ListedColormap(
2023 [
2024 "#FFFFFF",
2025 "#636363",
2026 "#e1dada",
2027 "#B5CAFF",
2028 "#8FB3FF",
2029 "#7F97FF",
2030 "#ABCF63",
2031 "#E8F59E",
2032 "#FFFA14",
2033 "#FFD121",
2034 "#FFA30A",
2035 ]
2036 )
2037 levels = [0.0, 0.01, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
2038 norm = mcolors.BoundaryNorm(levels, cmap.N)
2039 return cmap, levels, norm
2042def _custom_colourmap_precipitation(cube: iris.cube.Cube, cmap, levels, norm):
2043 """Return a custom colourmap for the current recipe."""
2044 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name])
2045 if (
2046 any("surface_microphysical" in name for name in varnames)
2047 and "difference" not in cube.long_name
2048 and "mask" not in cube.long_name
2049 ):
2050 # Define the levels and colors
2051 levels = [0, 0.125, 0.25, 0.5, 1, 2, 4, 8, 16, 32, 64, 128, 256]
2052 colors = [
2053 "w",
2054 (0, 0, 0.6),
2055 "b",
2056 "c",
2057 "g",
2058 "y",
2059 (1, 0.5, 0),
2060 "r",
2061 "pink",
2062 "m",
2063 "purple",
2064 "maroon",
2065 "gray",
2066 ]
2067 # Create a custom colormap
2068 cmap = mcolors.ListedColormap(colors)
2069 # Normalize the levels
2070 norm = mcolors.BoundaryNorm(levels, cmap.N)
2071 logging.info("change colormap for surface_microphysical variable colorbar.")
2072 else:
2073 # do nothing and keep existing colorbar attributes
2074 cmap = cmap
2075 levels = levels
2076 norm = norm
2077 return cmap, levels, norm
2080def _custom_colormap_aviation_colour_state(cube: iris.cube.Cube):
2081 """Return custom colourmap for aviation colour state.
2083 If "aviation_colour_state" appears anywhere in the name of a cube
2084 this function will be called.
2086 Parameters
2087 ----------
2088 cube: Cube
2089 Cube of variable for which the colorbar information is desired.
2091 Returns
2092 -------
2093 cmap: Matplotlib colormap.
2094 levels: List
2095 List of levels to use for plotting. For continuous plots the min and max
2096 should be taken as the range.
2097 norm: BoundaryNorm.
2098 """
2099 levels = [-0.5, 0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5]
2100 colors = [
2101 "#87ceeb",
2102 "#ffffff",
2103 "#8ced69",
2104 "#ffff00",
2105 "#ffd700",
2106 "#ffa500",
2107 "#fe3620",
2108 ]
2109 # Create a custom colormap
2110 cmap = mcolors.ListedColormap(colors)
2111 # Normalise the levels
2112 norm = mcolors.BoundaryNorm(levels, cmap.N)
2113 return cmap, levels, norm
2116def _custom_colourmap_visibility_in_air(cube: iris.cube.Cube, cmap, levels, norm):
2117 """Return a custom colourmap for the current recipe."""
2118 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name])
2119 if (
2120 any("visibility_in_air" in name for name in varnames)
2121 and "difference" not in cube.long_name
2122 and "mask" not in cube.long_name
2123 ):
2124 # Define the levels and colors (in km)
2125 levels = [0, 0.05, 0.1, 0.2, 1.0, 2.0, 5.0, 10.0, 20.0, 30.0, 50.0, 70.0, 100.0]
2126 norm = mcolors.BoundaryNorm(levels, cmap.N)
2127 colours = [
2128 "#8f00d6",
2129 "#d10000",
2130 "#ff9700",
2131 "#ffff00",
2132 "#00007f",
2133 "#6c9ccd",
2134 "#aae8ff",
2135 "#37a648",
2136 "#8edc64",
2137 "#c5ffc5",
2138 "#dcdcdc",
2139 "#ffffff",
2140 ]
2141 # Create a custom colormap
2142 cmap = mcolors.ListedColormap(colours)
2143 # Normalize the levels
2144 norm = mcolors.BoundaryNorm(levels, cmap.N)
2145 logging.info("change colormap for visibility_in_air variable colorbar.")
2146 else:
2147 # do nothing and keep existing colorbar attributes
2148 cmap = cmap
2149 levels = levels
2150 norm = norm
2151 return cmap, levels, norm
2154def _get_num_models(cube: iris.cube.Cube | iris.cube.CubeList) -> int:
2155 """Return number of models based on cube attributes."""
2156 model_names = list(
2157 filter(
2158 lambda x: x is not None,
2159 {cb.attributes.get("model_name", None) for cb in iter_maybe(cube)},
2160 )
2161 )
2162 if not model_names:
2163 logging.debug("Missing model names. Will assume single model.")
2164 return 1
2165 else:
2166 return len(model_names)
2169def _validate_cube_shape(
2170 cube: iris.cube.Cube | iris.cube.CubeList, num_models: int
2171) -> None:
2172 """Check all cubes have a model name."""
2173 if isinstance(cube, iris.cube.CubeList) and len(cube) != num_models: 2173 ↛ 2174line 2173 didn't jump to line 2174 because the condition on line 2173 was never true
2174 raise ValueError(
2175 f"The number of model names ({num_models}) should equal the number "
2176 f"of cubes ({len(cube)})."
2177 )
2180def _validate_cubes_coords(
2181 cubes: iris.cube.CubeList, coords: list[iris.coords.Coord]
2182) -> None:
2183 """Check same number of cubes as sequence coordinate for zip functions."""
2184 if len(cubes) != len(coords): 2184 ↛ 2185line 2184 didn't jump to line 2185 because the condition on line 2184 was never true
2185 raise ValueError(
2186 f"The number of CubeList entries ({len(cubes)}) should equal the number "
2187 f"of sequence coordinates ({len(coords)})."
2188 f"Check that number of time entries in input data are consistent if "
2189 f"performing time-averaging steps prior to plotting outputs."
2190 )
2193####################
2194# Public functions #
2195####################
2198def spatial_contour_plot(
2199 cube: iris.cube.Cube,
2200 filename: str = None,
2201 sequence_coordinate: str = "time",
2202 stamp_coordinate: str = "realization",
2203 **kwargs,
2204) -> iris.cube.Cube:
2205 """Plot a spatial variable onto a map from a 2D, 3D, or 4D cube.
2207 A 2D spatial field can be plotted, but if the sequence_coordinate is present
2208 then a sequence of plots will be produced. Similarly if the stamp_coordinate
2209 is present then postage stamp plots will be produced.
2211 Parameters
2212 ----------
2213 cube: Cube
2214 Iris cube of the data to plot. It should have two spatial dimensions,
2215 such as lat and lon, and may also have a another two dimension to be
2216 plotted sequentially and/or as postage stamp plots.
2217 filename: str, optional
2218 Name of the plot to write, used as a prefix for plot sequences. Defaults
2219 to the recipe name.
2220 sequence_coordinate: str, optional
2221 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
2222 This coordinate must exist in the cube.
2223 stamp_coordinate: str, optional
2224 Coordinate about which to plot postage stamp plots. Defaults to
2225 ``"realization"``.
2227 Returns
2228 -------
2229 Cube
2230 The original cube (so further operations can be applied).
2232 Raises
2233 ------
2234 ValueError
2235 If the cube doesn't have the right dimensions.
2236 TypeError
2237 If the cube isn't a single cube.
2238 """
2239 _spatial_plot(
2240 "contourf", cube, filename, sequence_coordinate, stamp_coordinate, **kwargs
2241 )
2242 return cube
2245def spatial_pcolormesh_plot(
2246 cube: iris.cube.Cube,
2247 filename: str = None,
2248 sequence_coordinate: str = "time",
2249 stamp_coordinate: str = "realization",
2250 **kwargs,
2251) -> iris.cube.Cube:
2252 """Plot a spatial variable onto a map from a 2D, 3D, or 4D cube.
2254 A 2D spatial field can be plotted, but if the sequence_coordinate is present
2255 then a sequence of plots will be produced. Similarly if the stamp_coordinate
2256 is present then postage stamp plots will be produced.
2258 This function is significantly faster than ``spatial_contour_plot``,
2259 especially at high resolutions, and should be preferred unless contiguous
2260 contour areas are important.
2262 Parameters
2263 ----------
2264 cube: Cube
2265 Iris cube of the data to plot. It should have two spatial dimensions,
2266 such as lat and lon, and may also have a another two dimension to be
2267 plotted sequentially and/or as postage stamp plots.
2268 filename: str, optional
2269 Name of the plot to write, used as a prefix for plot sequences. Defaults
2270 to the recipe name.
2271 sequence_coordinate: str, optional
2272 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
2273 This coordinate must exist in the cube.
2274 stamp_coordinate: str, optional
2275 Coordinate about which to plot postage stamp plots. Defaults to
2276 ``"realization"``.
2278 Returns
2279 -------
2280 Cube
2281 The original cube (so further operations can be applied).
2283 Raises
2284 ------
2285 ValueError
2286 If the cube doesn't have the right dimensions.
2287 TypeError
2288 If the cube isn't a single cube.
2289 """
2290 _spatial_plot(
2291 "pcolormesh", cube, filename, sequence_coordinate, stamp_coordinate, **kwargs
2292 )
2293 return cube
2296def spatial_multi_pcolormesh_plot(
2297 cube: iris.cube.Cube,
2298 overlay_cube: iris.cube.Cube,
2299 contour_cube: iris.cube.Cube,
2300 filename: str = None,
2301 sequence_coordinate: str = "time",
2302 stamp_coordinate: str = "realization",
2303 **kwargs,
2304) -> iris.cube.Cube:
2305 """Plot a set of spatial variables onto a map from a 2D, 3D, or 4D cube.
2307 A 2D basis cube spatial field can be plotted, but if the sequence_coordinate is present
2308 then a sequence of plots will be produced. Similarly if the stamp_coordinate
2309 is present then postage stamp plots will be produced.
2311 If specified, a masked overlay_cube can be overplotted on top of the base cube.
2313 If specified, contours of a contour_cube can be overplotted on top of those.
2315 For single-variable equivalent of this routine, use spatial_pcolormesh_plot.
2317 This function is significantly faster than ``spatial_contour_plot``,
2318 especially at high resolutions, and should be preferred unless contiguous
2319 contour areas are important.
2321 Parameters
2322 ----------
2323 cube: Cube
2324 Iris cube of the data to plot. It should have two spatial dimensions,
2325 such as lat and lon, and may also have a another two dimension to be
2326 plotted sequentially and/or as postage stamp plots.
2327 overlay_cube: Cube
2328 Iris cube of the data to plot as an overlay on top of basis cube. It should have two spatial dimensions,
2329 such as lat and lon, and may also have a another two dimension to be
2330 plotted sequentially and/or as postage stamp plots. This is likely to be a masked cube in order not to hide the underlying basis cube.
2331 contour_cube: Cube
2332 Iris cube of the data to plot as a contour overlay on top of basis cube and overlay_cube. It should have two spatial dimensions,
2333 such as lat and lon, and may also have a another two dimension to be
2334 plotted sequentially and/or as postage stamp plots.
2335 filename: str, optional
2336 Name of the plot to write, used as a prefix for plot sequences. Defaults
2337 to the recipe name.
2338 sequence_coordinate: str, optional
2339 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
2340 This coordinate must exist in the cube.
2341 stamp_coordinate: str, optional
2342 Coordinate about which to plot postage stamp plots. Defaults to
2343 ``"realization"``.
2345 Returns
2346 -------
2347 Cube
2348 The original cube (so further operations can be applied).
2350 Raises
2351 ------
2352 ValueError
2353 If the cube doesn't have the right dimensions.
2354 TypeError
2355 If the cube isn't a single cube.
2356 """
2357 _spatial_plot(
2358 "pcolormesh",
2359 cube,
2360 filename,
2361 sequence_coordinate,
2362 stamp_coordinate,
2363 overlay_cube=overlay_cube,
2364 contour_cube=contour_cube,
2365 )
2366 return cube, overlay_cube, contour_cube
2369# TODO: Expand function to handle ensemble data.
2370# line_coordinate: str, optional
2371# Coordinate about which to plot multiple lines. Defaults to
2372# ``"realization"``.
2373def plot_line_series(
2374 cube: iris.cube.Cube | iris.cube.CubeList,
2375 filename: str = None,
2376 series_coordinate: str = "time",
2377 sequence_coordinate: str = "time",
2378 # add the following for ensembles
2379 stamp_coordinate: str = "realization",
2380 single_plot: bool = False,
2381 **kwargs,
2382) -> iris.cube.Cube | iris.cube.CubeList:
2383 """Plot a line plot for the specified coordinate.
2385 The Cube or CubeList must be 1D.
2387 Parameters
2388 ----------
2389 iris.cube | iris.cube.CubeList
2390 Cube or CubeList of the data to plot. The individual cubes should have a single dimension.
2391 The cubes should cover the same phenomenon i.e. all cubes contain temperature data.
2392 We do not support different data such as temperature and humidity in the same CubeList for plotting.
2393 filename: str, optional
2394 Name of the plot to write, used as a prefix for plot sequences. Defaults
2395 to the recipe name.
2396 series_coordinate: str, optional
2397 Coordinate about which to make a series. Defaults to ``"time"``. This
2398 coordinate must exist in the cube.
2400 Returns
2401 -------
2402 iris.cube.Cube | iris.cube.CubeList
2403 The original Cube or CubeList (so further operations can be applied).
2404 plotted data.
2406 Raises
2407 ------
2408 ValueError
2409 If the cubes don't have the right dimensions.
2410 TypeError
2411 If the cube isn't a Cube or CubeList.
2412 """
2413 stamp_coordinate = "realization"
2414 # Ensure we have a name for the plot file.
2415 recipe_title = get_recipe_metadata().get("title", "Untitled")
2417 num_models = _get_num_models(cube)
2419 _validate_cube_shape(cube, num_models)
2421 # Iterate over all cubes and extract coordinate to plot.
2422 cubes = iter_maybe(cube)
2424 coords = []
2425 for cube in cubes:
2426 try:
2427 coords.append(cube.coord(series_coordinate))
2428 except iris.exceptions.CoordinateNotFoundError as err:
2429 raise ValueError(
2430 f"Cube must have a {series_coordinate} coordinate."
2431 ) from err
2432 if cube.coords("realization"): 2432 ↛ 2436line 2432 didn't jump to line 2436 because the condition on line 2432 was always true
2433 if cube.ndim > 3: 2433 ↛ 2434line 2433 didn't jump to line 2434 because the condition on line 2433 was never true
2434 raise ValueError("Cube must be 1D or 2D with a realization coordinate.")
2435 else:
2436 raise ValueError("Cube must have a realization coordinate.")
2438 plot_index = []
2440 # Check if this is a spectral plot by looking for spectral coordinates
2441 is_spectral_plot = series_coordinate in [
2442 "frequency",
2443 "physical_wavenumber",
2444 "wavelength",
2445 ]
2447 if is_spectral_plot:
2448 # If series coordinate is frequency, physical_wavenumber or wavelength, for example power spectra with series
2449 # coordinate frequency/wavenumber.
2450 # If several power spectra are plotted with time as sequence_coordinate for the
2451 # time slider option.
2453 # Internal plotting function.
2454 plotting_func = _plot_and_save_line_power_spectrum_series
2456 for cube in cubes:
2457 try:
2458 cube.coord(sequence_coordinate)
2459 except iris.exceptions.CoordinateNotFoundError as err:
2460 raise ValueError(
2461 f"Cube must have a {sequence_coordinate} coordinate."
2462 ) from err
2464 if num_models == 1: 2464 ↛ 2478line 2464 didn't jump to line 2478 because the condition on line 2464 was always true
2465 # check for ensembles
2466 if ( 2466 ↛ 2470line 2466 didn't jump to line 2470 because the condition on line 2466 was never true
2467 stamp_coordinate in [c.name() for c in cubes[0].coords()]
2468 and cubes[0].coord(stamp_coordinate).shape[0] > 1
2469 ):
2470 if single_plot:
2471 # Plot spectra, mean and ensemble spread on 1 plot
2472 plotting_func = _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series
2473 else:
2474 # Plot postage stamps
2475 plotting_func = _plot_and_save_postage_stamp_power_spectrum_series
2476 cube_iterables = cubes[0].slices_over(sequence_coordinate)
2477 else:
2478 all_points = sorted(
2479 set(
2480 itertools.chain.from_iterable(
2481 cb.coord(sequence_coordinate).points for cb in cubes
2482 )
2483 )
2484 )
2485 all_slices = list(
2486 itertools.chain.from_iterable(
2487 cb.slices_over(sequence_coordinate) for cb in cubes
2488 )
2489 )
2490 # Matched slices (matched by seq coord point; it may happen that
2491 # evaluated models do not cover the same seq coord range, hence matching
2492 # necessary)
2493 cube_iterables = [
2494 iris.cube.CubeList(
2495 s
2496 for s in all_slices
2497 if s.coord(sequence_coordinate).points[0] == point
2498 )
2499 for point in all_points
2500 ]
2502 nplot = np.size(cube.coord(sequence_coordinate).points)
2504 # Create a plot for each value of the sequence coordinate. Allowing for
2505 # multiple cubes in a CubeList to be plotted in the same plot for similar
2506 # sequence values. Passing a CubeList into the internal plotting function
2507 # for similar values of the sequence coordinate. cube_slice can be an
2508 # iris.cube.Cube or an iris.cube.CubeList.
2510 for cube_slice in cube_iterables:
2511 # Normalize cube_slice to a list of cubes
2512 if isinstance(cube_slice, iris.cube.CubeList): 2512 ↛ 2513line 2512 didn't jump to line 2513 because the condition on line 2512 was never true
2513 cubes = list(cube_slice)
2514 elif isinstance(cube_slice, iris.cube.Cube): 2514 ↛ 2517line 2514 didn't jump to line 2517 because the condition on line 2514 was always true
2515 cubes = [cube_slice]
2516 else:
2517 raise TypeError(f"Expected Cube or CubeList, got {type(cube_slice)}")
2519 # Use sequence value so multiple sequences can merge.
2520 seq_coord = cube_slice[0].coord(sequence_coordinate)
2521 plot_title, plot_filename = _set_title_and_filename(
2522 seq_coord, nplot, recipe_title, filename
2523 )
2525 # Format the coordinate value in a unit appropriate way.
2526 title = f"{recipe_title}\n [{seq_coord.units.title(seq_coord.points[0])}]"
2528 # Use sequence (e.g. time) bounds if plotting single non-sequence outputs
2529 if nplot == 1 and seq_coord.has_bounds: 2529 ↛ 2534line 2529 didn't jump to line 2534 because the condition on line 2529 was always true
2530 if np.size(seq_coord.bounds) > 1: 2530 ↛ 2531line 2530 didn't jump to line 2531 because the condition on line 2530 was never true
2531 title = f"{recipe_title}\n [{seq_coord.units.title(seq_coord.bounds[0][0])} to {seq_coord.units.title(seq_coord.bounds[0][1])}]"
2533 # Do the actual plotting.
2534 plotting_func(
2535 cube_slice,
2536 coords,
2537 stamp_coordinate,
2538 plot_filename,
2539 title,
2540 series_coordinate,
2541 )
2543 plot_index.append(plot_filename)
2544 else:
2545 # Format the title and filename using plotted series coordinate
2546 nplot = 1
2547 seq_coord = coords[0]
2548 plot_title, plot_filename = _set_title_and_filename(
2549 seq_coord, nplot, recipe_title, filename
2550 )
2551 # Do the actual plotting for all other series coordinate options.
2552 _plot_and_save_line_series(
2553 cubes, coords, stamp_coordinate, plot_filename, plot_title
2554 )
2556 plot_index.append(plot_filename)
2558 # append plot to list of plots
2559 complete_plot_index = _append_to_plot_index(plot_index)
2561 # Make a page to display the plots.
2562 _make_plot_html_page(complete_plot_index)
2564 return cube
2567def plot_vertical_line_series(
2568 cubes: iris.cube.Cube | iris.cube.CubeList,
2569 filename: str = None,
2570 series_coordinate: str = "model_level_number",
2571 sequence_coordinate: str = "time",
2572 # line_coordinate: str = "realization",
2573 **kwargs,
2574) -> iris.cube.Cube | iris.cube.CubeList:
2575 """Plot a line plot against a type of vertical coordinate.
2577 The Cube or CubeList must be 1D.
2579 A 1D line plot with y-axis as pressure coordinate can be plotted, but if the sequence_coordinate is present
2580 then a sequence of plots will be produced.
2582 Parameters
2583 ----------
2584 iris.cube | iris.cube.CubeList
2585 Cube or CubeList of the data to plot. The individual cubes should have a single dimension.
2586 The cubes should cover the same phenomenon i.e. all cubes contain temperature data.
2587 We do not support different data such as temperature and humidity in the same CubeList for plotting.
2588 filename: str, optional
2589 Name of the plot to write, used as a prefix for plot sequences. Defaults
2590 to the recipe name.
2591 series_coordinate: str, optional
2592 Coordinate to plot on the y-axis. Can be ``pressure`` or
2593 ``model_level_number`` for UM, or ``full_levels`` or ``half_levels``
2594 for LFRic. Defaults to ``model_level_number``.
2595 This coordinate must exist in the cube.
2596 sequence_coordinate: str, optional
2597 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
2598 This coordinate must exist in the cube.
2600 Returns
2601 -------
2602 iris.cube.Cube | iris.cube.CubeList
2603 The original Cube or CubeList (so further operations can be applied).
2604 Plotted data.
2606 Raises
2607 ------
2608 ValueError
2609 If the cubes doesn't have the right dimensions.
2610 TypeError
2611 If the cube isn't a Cube or CubeList.
2612 """
2613 # Ensure we have a name for the plot file.
2614 recipe_title = get_recipe_metadata().get("title", "Untitled")
2616 cubes = iter_maybe(cubes)
2617 # Initialise empty list to hold all data from all cubes in a CubeList
2618 all_data = []
2620 # Store min/max ranges for x range.
2621 x_levels = []
2623 num_models = _get_num_models(cubes)
2625 _validate_cube_shape(cubes, num_models)
2627 # Iterate over all cubes in cube or CubeList and plot.
2628 coords = []
2629 for cube in cubes:
2630 # Test if series coordinate i.e. pressure level exist for any cube with cube.ndim >=1.
2631 try:
2632 coords.append(cube.coord(series_coordinate))
2633 except iris.exceptions.CoordinateNotFoundError as err:
2634 raise ValueError(
2635 f"Cube must have a {series_coordinate} coordinate."
2636 ) from err
2638 try:
2639 if cube.ndim > 1 or not cube.coords("realization"): 2639 ↛ 2647line 2639 didn't jump to line 2647 because the condition on line 2639 was always true
2640 cube.coord(sequence_coordinate)
2641 except iris.exceptions.CoordinateNotFoundError as err:
2642 raise ValueError(
2643 f"Cube must have a {sequence_coordinate} coordinate or be 1D, or 2D with a realization coordinate."
2644 ) from err
2646 # Get minimum and maximum from levels information.
2647 _, levels, _ = _colorbar_map_levels(cube, axis="x")
2648 if levels is not None: 2648 ↛ 2652line 2648 didn't jump to line 2652 because the condition on line 2648 was always true
2649 x_levels.append(min(levels))
2650 x_levels.append(max(levels))
2651 else:
2652 all_data.append(cube.data)
2654 if len(x_levels) == 0: 2654 ↛ 2656line 2654 didn't jump to line 2656 because the condition on line 2654 was never true
2655 # Combine all data into a single NumPy array
2656 combined_data = np.concatenate(all_data)
2658 # Set the lower and upper limit for the x-axis to ensure all plots have
2659 # same range. This needs to read the whole cube over the range of the
2660 # sequence and if applicable postage stamp coordinate.
2661 vmin = np.floor(combined_data.min())
2662 vmax = np.ceil(combined_data.max())
2663 else:
2664 vmin = min(x_levels)
2665 vmax = max(x_levels)
2667 # Matching the slices (matching by seq coord point; it may happen that
2668 # evaluated models do not cover the same seq coord range, hence matching
2669 # necessary)
2670 def filter_cube_iterables(cube_iterables) -> bool:
2671 return len(cube_iterables) == len(coords)
2673 cube_iterables = filter(
2674 filter_cube_iterables,
2675 (
2676 iris.cube.CubeList(
2677 s
2678 for s in itertools.chain.from_iterable(
2679 cb.slices_over(sequence_coordinate) for cb in cubes
2680 )
2681 if s.coord(sequence_coordinate).points[0] == point
2682 )
2683 for point in sorted(
2684 set(
2685 itertools.chain.from_iterable(
2686 cb.coord(sequence_coordinate).points for cb in cubes
2687 )
2688 )
2689 )
2690 ),
2691 )
2693 # Create a plot for each value of the sequence coordinate.
2694 # Allowing for multiple cubes in a CubeList to be plotted in the same plot for
2695 # similar sequence values. Passing a CubeList into the internal plotting function
2696 # for similar values of the sequence coordinate. cube_slice can be an iris.cube.Cube
2697 # or an iris.cube.CubeList.
2698 plot_index = []
2699 nplot = np.size(cubes[0].coord(sequence_coordinate).points)
2700 for cubes_slice in cube_iterables:
2701 # Format the coordinate value in a unit appropriate way.
2702 seq_coord = cubes_slice[0].coord(sequence_coordinate)
2703 plot_title, plot_filename = _set_title_and_filename(
2704 seq_coord, nplot, recipe_title, filename
2705 )
2707 # Do the actual plotting.
2708 _plot_and_save_vertical_line_series(
2709 cubes_slice,
2710 coords,
2711 "realization",
2712 plot_filename,
2713 series_coordinate,
2714 title=plot_title,
2715 vmin=vmin,
2716 vmax=vmax,
2717 )
2718 plot_index.append(plot_filename)
2720 # Add list of plots to plot metadata.
2721 complete_plot_index = _append_to_plot_index(plot_index)
2723 # Make a page to display the plots.
2724 _make_plot_html_page(complete_plot_index)
2726 return cubes
2729def qq_plot(
2730 cubes: iris.cube.CubeList,
2731 coordinates: list[str],
2732 percentiles: list[float],
2733 model_names: list[str],
2734 filename: str = None,
2735 one_to_one: bool = True,
2736 **kwargs,
2737) -> iris.cube.CubeList:
2738 """Plot a Quantile-Quantile plot between two models for common time points.
2740 The cubes will be normalised by collapsing each cube to its percentiles. Cubes are
2741 collapsed within the operator over all specified coordinates such as
2742 grid_latitude, grid_longitude, vertical levels, but also realisation representing
2743 ensemble members to ensure a 1D cube (array).
2745 Parameters
2746 ----------
2747 cubes: iris.cube.CubeList
2748 Two cubes of the same variable with different models.
2749 coordinate: list[str]
2750 The list of coordinates to collapse over. This list should be
2751 every coordinate within the cube to result in a 1D cube around
2752 the percentile coordinate.
2753 percent: list[float]
2754 A list of percentiles to appear in the plot.
2755 model_names: list[str]
2756 A list of model names to appear on the axis of the plot.
2757 filename: str, optional
2758 Filename of the plot to write.
2759 one_to_one: bool, optional
2760 If True a 1:1 line is plotted; if False it is not. Default is True.
2762 Raises
2763 ------
2764 ValueError
2765 When the cubes are not compatible.
2767 Notes
2768 -----
2769 The quantile-quantile plot is a variant on the scatter plot representing
2770 two datasets by their quantiles (percentiles) for common time points.
2771 This plot does not use a theoretical distribution to compare against, but
2772 compares percentiles of two datasets. This plot does
2773 not use all raw data points, but plots the selected percentiles (quantiles) of
2774 each variable instead for the two datasets, thereby normalising the data for a
2775 direct comparison between the selected percentiles of the two dataset distributions.
2777 Quantile-quantile plots are valuable for comparing against
2778 observations and other models. Identical percentiles between the variables
2779 will lie on the one-to-one line implying the values correspond well to each
2780 other. Where there is a deviation from the one-to-one line a range of
2781 possibilities exist depending on how and where the data is shifted (e.g.,
2782 Wilks 2011 [Wilks2011]_).
2784 For distributions above the one-to-one line the distribution is left-skewed;
2785 below is right-skewed. A distinct break implies a bimodal distribution, and
2786 closer values/values further apart at the tails imply poor representation of
2787 the extremes.
2789 References
2790 ----------
2791 .. [Wilks2011] Wilks, D.S., (2011) "Statistical Methods in the Atmospheric
2792 Sciences" Third Edition, vol. 100, Academic Press, Oxford, UK, 676 pp.
2793 """
2794 # Check cubes using same functionality as the difference operator.
2795 if len(cubes) != 2:
2796 raise ValueError("cubes should contain exactly 2 cubes.")
2797 base: Cube = cubes.extract_cube(iris.AttributeConstraint(cset_comparison_base=1))
2798 other: Cube = cubes.extract_cube(
2799 iris.Constraint(
2800 cube_func=lambda cube: "cset_comparison_base" not in cube.attributes
2801 )
2802 )
2804 # Get spatial coord names.
2805 base_lat_name, base_lon_name = get_cube_yxcoordname(base)
2806 other_lat_name, other_lon_name = get_cube_yxcoordname(other)
2808 # Ensure cubes to compare are on common differencing grid.
2809 # This is triggered if either
2810 # i) latitude and longitude shapes are not the same. Note grid points
2811 # are not compared directly as these can differ through rounding
2812 # errors.
2813 # ii) or variables are known to often sit on different grid staggering
2814 # in different models (e.g. cell center vs cell edge), as is the case
2815 # for UM and LFRic comparisons.
2816 # In future greater choice of regridding method might be applied depending
2817 # on variable type. Linear regridding can in general be appropriate for smooth
2818 # variables. Care should be taken with interpretation of differences
2819 # given this dependency on regridding.
2820 if (
2821 base.coord(base_lat_name).shape != other.coord(other_lat_name).shape
2822 or base.coord(base_lon_name).shape != other.coord(other_lon_name).shape
2823 ) or (
2824 base.long_name
2825 in [
2826 "eastward_wind_at_10m",
2827 "northward_wind_at_10m",
2828 "northward_wind_at_cell_centres",
2829 "eastward_wind_at_cell_centres",
2830 "zonal_wind_at_pressure_levels",
2831 "meridional_wind_at_pressure_levels",
2832 "potential_vorticity_at_pressure_levels",
2833 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging",
2834 ]
2835 ):
2836 logging.debug(
2837 "Linear regridding base cube to other grid to compute differences"
2838 )
2839 base = regrid_onto_cube(base, other, method="Linear")
2841 # Extract just common time points.
2842 base, other = _extract_common_time_points(base, other)
2844 # Equalise attributes so we can merge.
2845 fully_equalise_attributes([base, other])
2846 logging.debug("Base: %s\nOther: %s", base, other)
2848 # Collapse cubes.
2849 base = collapse(
2850 base,
2851 coordinate=coordinates,
2852 method="PERCENTILE",
2853 additional_percent=percentiles,
2854 )
2855 other = collapse(
2856 other,
2857 coordinate=coordinates,
2858 method="PERCENTILE",
2859 additional_percent=percentiles,
2860 )
2862 # Ensure we have a name for the plot file.
2863 recipe_title = get_recipe_metadata().get("title", "Untitled")
2864 title = f"{recipe_title}"
2866 if filename is None:
2867 filename = slugify(recipe_title)
2869 # Add file extension.
2870 plot_filename = f"{filename.rsplit('.', 1)[0]}.png"
2872 # Do the actual plotting on a scatter plot
2873 _plot_and_save_scatter_plot(
2874 base, other, plot_filename, title, one_to_one, model_names
2875 )
2877 # Add list of plots to plot metadata.
2878 plot_index = _append_to_plot_index([plot_filename])
2880 # Make a page to display the plots.
2881 _make_plot_html_page(plot_index)
2883 return iris.cube.CubeList([base, other])
2886def scatter_plot(
2887 cube_x: iris.cube.Cube | iris.cube.CubeList,
2888 cube_y: iris.cube.Cube | iris.cube.CubeList,
2889 filename: str = None,
2890 one_to_one: bool = True,
2891 **kwargs,
2892) -> iris.cube.CubeList:
2893 """Plot a scatter plot between two variables.
2895 Both cubes must be 1D.
2897 Parameters
2898 ----------
2899 cube_x: Cube | CubeList
2900 1 dimensional Cube of the data to plot on y-axis.
2901 cube_y: Cube | CubeList
2902 1 dimensional Cube of the data to plot on x-axis.
2903 filename: str, optional
2904 Filename of the plot to write.
2905 one_to_one: bool, optional
2906 If True a 1:1 line is plotted; if False it is not. Default is True.
2908 Returns
2909 -------
2910 cubes: CubeList
2911 CubeList of the original x and y cubes for further processing.
2913 Raises
2914 ------
2915 ValueError
2916 If the cube doesn't have the right dimensions and cubes not the same
2917 size.
2918 TypeError
2919 If the cube isn't a single cube.
2921 Notes
2922 -----
2923 Scatter plots are used for determining if there is a relationship between
2924 two variables. Positive relations have a slope going from bottom left to top
2925 right; Negative relations have a slope going from top left to bottom right.
2926 """
2927 # Iterate over all cubes in cube or CubeList and plot.
2928 for cube_iter in iter_maybe(cube_x):
2929 # Check cubes are correct shape.
2930 cube_iter = _check_single_cube(cube_iter)
2931 if cube_iter.ndim > 1:
2932 raise ValueError("cube_x must be 1D.")
2934 # Iterate over all cubes in cube or CubeList and plot.
2935 for cube_iter in iter_maybe(cube_y):
2936 # Check cubes are correct shape.
2937 cube_iter = _check_single_cube(cube_iter)
2938 if cube_iter.ndim > 1:
2939 raise ValueError("cube_y must be 1D.")
2941 # Ensure we have a name for the plot file.
2942 recipe_title = get_recipe_metadata().get("title", "Untitled")
2943 title = f"{recipe_title}"
2945 if filename is None:
2946 filename = slugify(recipe_title)
2948 # Add file extension.
2949 plot_filename = f"{filename.rsplit('.', 1)[0]}.png"
2951 # Do the actual plotting.
2952 _plot_and_save_scatter_plot(cube_x, cube_y, plot_filename, title, one_to_one)
2954 # Add list of plots to plot metadata.
2955 plot_index = _append_to_plot_index([plot_filename])
2957 # Make a page to display the plots.
2958 _make_plot_html_page(plot_index)
2960 return iris.cube.CubeList([cube_x, cube_y])
2963def vector_plot(
2964 cube_u: iris.cube.Cube,
2965 cube_v: iris.cube.Cube,
2966 filename: str = None,
2967 sequence_coordinate: str = "time",
2968 **kwargs,
2969) -> iris.cube.CubeList:
2970 """Plot a vector plot based on the input u and v components."""
2971 recipe_title = get_recipe_metadata().get("title", "Untitled")
2973 # Cubes must have a matching sequence coordinate.
2974 try:
2975 # Check that the u and v cubes have the same sequence coordinate.
2976 if cube_u.coord(sequence_coordinate) != cube_v.coord(sequence_coordinate): 2976 ↛ anywhereline 2976 didn't jump anywhere: it always raised an exception.
2977 raise ValueError("Coordinates do not match.")
2978 except (iris.exceptions.CoordinateNotFoundError, ValueError) as err:
2979 raise ValueError(
2980 f"Cubes should have matching {sequence_coordinate} coordinate:\n{cube_u}\n{cube_v}"
2981 ) from err
2983 # Create a plot for each value of the sequence coordinate.
2984 plot_index = []
2985 nplot = np.size(cube_u[0].coord(sequence_coordinate).points)
2986 for cube_u_slice, cube_v_slice in zip(
2987 cube_u.slices_over(sequence_coordinate),
2988 cube_v.slices_over(sequence_coordinate),
2989 strict=True,
2990 ):
2991 # Format the coordinate value in a unit appropriate way.
2992 seq_coord = cube_u_slice.coord(sequence_coordinate)
2993 plot_title, plot_filename = _set_title_and_filename(
2994 seq_coord, nplot, recipe_title, filename
2995 )
2997 # Do the actual plotting.
2998 _plot_and_save_vector_plot(
2999 cube_u_slice,
3000 cube_v_slice,
3001 filename=plot_filename,
3002 title=plot_title,
3003 method="contourf",
3004 )
3005 plot_index.append(plot_filename)
3007 # Add list of plots to plot metadata.
3008 complete_plot_index = _append_to_plot_index(plot_index)
3010 # Make a page to display the plots.
3011 _make_plot_html_page(complete_plot_index)
3013 return iris.cube.CubeList([cube_u, cube_v])
3016def plot_histogram_series(
3017 cubes: iris.cube.Cube | iris.cube.CubeList,
3018 filename: str = None,
3019 sequence_coordinate: str = "time",
3020 stamp_coordinate: str = "realization",
3021 single_plot: bool = False,
3022 **kwargs,
3023) -> iris.cube.Cube | iris.cube.CubeList:
3024 """Plot a histogram plot for each vertical level provided.
3026 A histogram plot can be plotted, but if the sequence_coordinate (i.e. time)
3027 is present then a sequence of plots will be produced using the time slider
3028 functionality to scroll through histograms against time. If a
3029 stamp_coordinate is present then postage stamp plots will be produced. If
3030 stamp_coordinate and single_plot is True, all postage stamp plots will be
3031 plotted in a single plot instead of separate postage stamp plots.
3033 Parameters
3034 ----------
3035 cubes: Cube | iris.cube.CubeList
3036 Iris cube or CubeList of the data to plot. It should have a single dimension other
3037 than the stamp coordinate.
3038 The cubes should cover the same phenomenon i.e. all cubes contain temperature data.
3039 We do not support different data such as temperature and humidity in the same CubeList for plotting.
3040 filename: str, optional
3041 Name of the plot to write, used as a prefix for plot sequences. Defaults
3042 to the recipe name.
3043 sequence_coordinate: str, optional
3044 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
3045 This coordinate must exist in the cube and will be used for the time
3046 slider.
3047 stamp_coordinate: str, optional
3048 Coordinate about which to plot postage stamp plots. Defaults to
3049 ``"realization"``.
3050 single_plot: bool, optional
3051 If True, all postage stamp plots will be plotted in a single plot. If
3052 False, each postage stamp plot will be plotted separately. Is only valid
3053 if stamp_coordinate exists and has more than a single point.
3055 Returns
3056 -------
3057 iris.cube.Cube | iris.cube.CubeList
3058 The original Cube or CubeList (so further operations can be applied).
3059 Plotted data.
3061 Raises
3062 ------
3063 ValueError
3064 If the cube doesn't have the right dimensions.
3065 TypeError
3066 If the cube isn't a Cube or CubeList.
3067 """
3068 recipe_title = get_recipe_metadata().get("title", "Untitled")
3070 cubes = iter_maybe(cubes)
3071 # Ensure we have a name for the plot file.
3072 if filename is None:
3073 filename = slugify(recipe_title)
3075 # Internal plotting function.
3076 plotting_func = _plot_and_save_histogram_series
3078 num_models = _get_num_models(cubes)
3080 _validate_cube_shape(cubes, num_models)
3082 # If several histograms are plotted with time as sequence_coordinate for the
3083 # time slider option.
3084 for cube in cubes:
3085 try:
3086 cube.coord(sequence_coordinate)
3087 except iris.exceptions.CoordinateNotFoundError as err:
3088 raise ValueError(
3089 f"Cube must have a {sequence_coordinate} coordinate."
3090 ) from err
3092 # Get minimum and maximum from levels information.
3093 levels = None
3094 for cube in cubes: 3094 ↛ 3110line 3094 didn't jump to line 3110 because the loop on line 3094 didn't complete
3095 # First check if user-specified "auto" range variable.
3096 # This maintains the value of levels as None, so proceed.
3097 _, levels, _ = _colorbar_map_levels(cube, axis="y")
3098 if levels is None:
3099 break
3100 # If levels is changed, recheck to use the vmin,vmax or
3101 # levels-based ranges for histogram plots.
3102 _, levels, _ = _colorbar_map_levels(cube)
3103 logging.debug("levels: %s", levels)
3104 if levels is not None: 3104 ↛ 3094line 3104 didn't jump to line 3094 because the condition on line 3104 was always true
3105 vmin = min(levels)
3106 vmax = max(levels)
3107 logging.debug("Updated vmin, vmax: %s, %s", vmin, vmax)
3108 break
3110 if levels is None:
3111 vmin = min(cb.data.min() for cb in cubes)
3112 vmax = max(cb.data.max() for cb in cubes)
3114 # Make postage stamp plots if stamp_coordinate exists and has more than a
3115 # single point. If single_plot is True:
3116 # -- all postage stamp plots will be plotted in a single plot instead of
3117 # separate postage stamp plots.
3118 # -- model names (hidden in cube attrs) are ignored, that is stamp plots are
3119 # produced per single model only
3121 if num_models == 1: 3121 ↛ 3134line 3121 didn't jump to line 3134 because the condition on line 3121 was always true
3122 if ( 3122 ↛ 3126line 3122 didn't jump to line 3126 because the condition on line 3122 was never true
3123 stamp_coordinate in [c.name() for c in cubes[0].coords()]
3124 and cubes[0].coord(stamp_coordinate).shape[0] > 1
3125 ):
3126 if single_plot:
3127 plotting_func = (
3128 _plot_and_save_postage_stamps_in_single_plot_histogram_series
3129 )
3130 else:
3131 plotting_func = _plot_and_save_postage_stamp_histogram_series
3132 cube_iterables = cubes[0].slices_over(sequence_coordinate)
3133 else:
3134 all_points = sorted(
3135 set(
3136 itertools.chain.from_iterable(
3137 cb.coord(sequence_coordinate).points for cb in cubes
3138 )
3139 )
3140 )
3141 all_slices = list(
3142 itertools.chain.from_iterable(
3143 cb.slices_over(sequence_coordinate) for cb in cubes
3144 )
3145 )
3146 # Matched slices (matched by seq coord point; it may happen that
3147 # evaluated models do not cover the same seq coord range, hence matching
3148 # necessary)
3149 cube_iterables = [
3150 iris.cube.CubeList(
3151 s for s in all_slices if s.coord(sequence_coordinate).points[0] == point
3152 )
3153 for point in all_points
3154 ]
3156 plot_index = []
3157 nplot = np.size(cube.coord(sequence_coordinate).points)
3158 # Create a plot for each value of the sequence coordinate. Allowing for
3159 # multiple cubes in a CubeList to be plotted in the same plot for similar
3160 # sequence values. Passing a CubeList into the internal plotting function
3161 # for similar values of the sequence coordinate. cube_slice can be an
3162 # iris.cube.Cube or an iris.cube.CubeList.
3163 for cube_slice in cube_iterables:
3164 single_cube = cube_slice
3165 if isinstance(cube_slice, iris.cube.CubeList): 3165 ↛ 3166line 3165 didn't jump to line 3166 because the condition on line 3165 was never true
3166 single_cube = cube_slice[0]
3168 # Set plot titles and filename, based on sequence coordinate
3169 seq_coord = single_cube.coord(sequence_coordinate)
3170 # Use time coordinate in title and filename if single histogram output.
3171 if sequence_coordinate == "realization" and nplot == 1: 3171 ↛ 3172line 3171 didn't jump to line 3172 because the condition on line 3171 was never true
3172 seq_coord = single_cube.coord("time")
3173 plot_title, plot_filename = _set_title_and_filename(
3174 seq_coord, nplot, recipe_title, filename
3175 )
3177 # Do the actual plotting.
3179 plotting_func(
3180 cube_slice,
3181 filename=plot_filename,
3182 stamp_coordinate=stamp_coordinate,
3183 title=plot_title,
3184 vmin=vmin,
3185 vmax=vmax,
3186 )
3187 plot_index.append(plot_filename)
3189 # Add list of plots to plot metadata.
3190 complete_plot_index = _append_to_plot_index(plot_index)
3192 # Make a page to display the plots.
3193 _make_plot_html_page(complete_plot_index)
3195 return cubes
3198def _plot_and_save_postage_stamp_power_spectrum_series(
3199 cubes: iris.cube.Cube,
3200 coords: list[iris.coords.Coord],
3201 stamp_coordinate: str,
3202 filename: str,
3203 title: str,
3204 series_coordinate: str = None,
3205 **kwargs,
3206):
3207 """Plot and save postage (ensemble members) stamps for a power spectrum series.
3209 Parameters
3210 ----------
3211 cubes: Cube or CubeList
3212 Cube or Cubelist of the power spectrum data.
3213 coords: list[Coord]
3214 Coordinates to plot on the x-axis, one per cube.
3215 stamp_coordinate: str
3216 Coordinate that becomes different plots.
3217 filename: str
3218 Filename of the plot to write.
3219 title: str
3220 Plot title.
3221 series_coordinate: str, optional
3222 Coordinate being plotted on x-axis. In case of spectra frequency, physical_wavenumber, or wavelength.
3224 """
3225 # Use the smallest square grid that will fit the members.
3226 grid_size = int(math.ceil(math.sqrt(len(cubes.coord(stamp_coordinate).points))))
3228 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
3229 model_colors_map = _get_model_colors_map(cubes)
3230 # ax = plt.gca()
3231 # Make a subplot for each member.
3232 for member, subplot in zip(
3233 cubes.slices_over(stamp_coordinate), range(1, grid_size**2 + 1), strict=False
3234 ):
3235 ax = plt.subplot(grid_size, grid_size, subplot)
3237 # Store min/max ranges.
3238 y_levels = []
3240 line_marker = None
3241 line_width = 1
3243 for cube in iter_maybe(member):
3244 xcoord = select_series_coord(cube, series_coordinate)
3245 xname = xcoord.points
3247 yfield = cube.data # power spectrum
3248 label = None
3249 color = "black"
3250 if model_colors_map: 3250 ↛ 3251line 3250 didn't jump to line 3251 because the condition on line 3250 was never true
3251 label = cube.attributes.get("model_name")
3252 color = model_colors_map.get(label)
3254 if member.coord(stamp_coordinate).points == [0]:
3255 ax.plot(
3256 xname,
3257 yfield,
3258 color=color,
3259 marker=line_marker,
3260 ls="-",
3261 lw=line_width,
3262 label=f"{label} (control)"
3263 if len(cube.coord(stamp_coordinate).points) > 1
3264 else label,
3265 )
3266 # Label with member if part of an ensemble and not the control.
3267 else:
3268 ax.plot(
3269 xname,
3270 yfield,
3271 color=color,
3272 ls="-",
3273 lw=1.5,
3274 alpha=0.75,
3275 label=f"{label} (member)",
3276 )
3278 # Calculate the global min/max if multiple cubes are given.
3279 _, levels, _ = _colorbar_map_levels(cube, axis="y")
3280 if levels is not None: 3280 ↛ 3281line 3280 didn't jump to line 3281 because the condition on line 3280 was never true
3281 y_levels.append(min(levels))
3282 y_levels.append(max(levels))
3284 # Add some labels and tweak the style.
3285 title = f"{title}"
3286 ax.set_title(title, fontsize=16)
3288 # Set appropriate x-axis label based on coordinate
3289 if series_coordinate == "wavelength" or ( 3289 ↛ 3292line 3289 didn't jump to line 3292 because the condition on line 3289 was never true
3290 hasattr(xcoord, "long_name") and xcoord.long_name == "wavelength"
3291 ):
3292 ax.set_xlabel("Wavelength (km)", fontsize=14)
3293 elif series_coordinate == "physical_wavenumber" or ( 3293 ↛ 3298line 3293 didn't jump to line 3298 because the condition on line 3293 was always true
3294 hasattr(xcoord, "long_name") and xcoord.long_name == "physical_wavenumber"
3295 ):
3296 ax.set_xlabel("Wavenumber (km⁻¹)", fontsize=14)
3297 else: # frequency or check units
3298 if hasattr(xcoord, "units") and str(xcoord.units) == "km-1":
3299 ax.set_xlabel("Wavenumber (km⁻¹)", fontsize=14)
3300 else:
3301 ax.set_xlabel("Wavenumber", fontsize=14)
3303 ax.set_ylabel("Power Spectral Density", fontsize=14)
3304 ax.tick_params(axis="both", labelsize=12)
3306 # Set log-log scale
3307 ax.set_xscale("log")
3308 ax.set_yscale("log")
3310 # Add gridlines
3311 ax.grid(linestyle="--", color="grey", linewidth=1)
3312 # Ientify unique labels for legend
3313 handles = list(
3314 {
3315 label: handle
3316 for (handle, label) in zip(*ax.get_legend_handles_labels(), strict=True)
3317 }.values()
3318 )
3319 ax.legend(handles=handles, loc="best", ncol=1, frameon=False, fontsize=16)
3321 ax = plt.gca()
3322 ax.set_title(f"Member #{member.coord(stamp_coordinate).points[0]}")
3324 # Overall figure title.
3325 fig.suptitle(title, fontsize=16)
3327 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
3328 logging.info("Saved histogram postage stamp plot to %s", filename)
3329 plt.close(fig)
3332def _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series(
3333 cubes: iris.cube.Cube,
3334 coords: list[iris.coords.Coord],
3335 stamp_coordinate: str,
3336 filename: str,
3337 title: str,
3338 series_coordinate: str = None,
3339 **kwargs,
3340):
3341 """Plot and save power spectra for ensemble members in single plot.
3343 Parameters
3344 ----------
3345 cubes: Cube or CubeList
3346 Cube or Cubelist of the power spectrum data.
3347 coords: list[Coord]
3348 Coordinates to plot on the x-axis, one per cube.
3349 stamp_coordinate: str
3350 Coordinate that becomes different plots.
3351 filename: str
3352 Filename of the plot to write.
3353 title: str
3354 Plot title.
3355 series_coordinate: str, optional
3356 Coordinate being plotted on x-axis. In case of spectra frequency, physical_wavenumber, or wavelength.
3358 """
3359 fig, ax = plt.subplots(figsize=(10, 10), facecolor="w", edgecolor="k")
3360 model_colors_map = _get_model_colors_map(cubes)
3362 line_marker = None
3363 line_width = 1
3365 # Compute ensemble statistics to show spread
3366 mean_cube = cubes.collapsed(stamp_coordinate, iris.analysis.MEAN)
3367 min_cube = cubes.collapsed(stamp_coordinate, iris.analysis.MIN)
3368 max_cube = cubes.collapsed(stamp_coordinate, iris.analysis.MAX)
3370 xcoord_global = mean_cube.coord(series_coordinate)
3371 x_global = xcoord_global.points
3373 for i, member in enumerate(cubes.slices_over(stamp_coordinate)):
3374 xcoord = select_series_coord(member, series_coordinate)
3375 xname = xcoord.points
3377 yfield = member.data # power spectrum
3378 color = "black"
3379 if model_colors_map: 3379 ↛ 3383line 3379 didn't jump to line 3383 because the condition on line 3379 was always true
3380 label = member.attributes.get("model_name") if i == 0 else None
3381 color = model_colors_map.get(label)
3383 if member.coord(stamp_coordinate).points == [0]:
3384 ax.plot(
3385 xname,
3386 yfield,
3387 color=color,
3388 marker=line_marker,
3389 ls="-",
3390 lw=line_width,
3391 label=f"{label} (control)"
3392 if len(member.coord(stamp_coordinate).points) > 1
3393 else label,
3394 )
3395 # Label with member number if part of an ensemble and not the control.
3396 else:
3397 ax.plot(
3398 xname,
3399 yfield,
3400 color=color,
3401 ls="-",
3402 lw=1.5,
3403 alpha=0.75,
3404 label=label,
3405 )
3407 # Set appropriate x-axis label based on coordinate
3408 if series_coordinate == "wavelength" or ( 3408 ↛ 3411line 3408 didn't jump to line 3411 because the condition on line 3408 was never true
3409 hasattr(xcoord, "long_name") and xcoord.long_name == "wavelength"
3410 ):
3411 ax.set_xlabel("Wavelength (km)", fontsize=14)
3412 elif series_coordinate == "physical_wavenumber" or ( 3412 ↛ 3417line 3412 didn't jump to line 3417 because the condition on line 3412 was always true
3413 hasattr(xcoord, "long_name") and xcoord.long_name == "physical_wavenumber"
3414 ):
3415 ax.set_xlabel("Wavenumber (km⁻¹)", fontsize=14)
3416 else: # frequency or check units
3417 if hasattr(xcoord, "units") and str(xcoord.units) == "km-1":
3418 ax.set_xlabel("Wavenumber (km⁻¹)", fontsize=14)
3419 else:
3420 ax.set_xlabel("Wavenumber", fontsize=14)
3422 # Add ensemble spread shading
3423 ax.fill_between(
3424 x_global,
3425 min_cube.data,
3426 max_cube.data,
3427 color="grey",
3428 alpha=0.3,
3429 label="Ensemble spread",
3430 )
3432 # Add ensemble mean line
3433 ax.plot(x_global, mean_cube.data, color="black", lw=1, label="Ensemble mean")
3435 ax.set_ylabel("Power Spectral Density", fontsize=14)
3436 ax.tick_params(axis="both", labelsize=12)
3438 # Set y limits to global min and max, autoscale if colorbar doesn't exist.
3439 # Set log-log scale
3440 ax.set_xscale("log")
3441 ax.set_yscale("log")
3443 # Add gridlines
3444 ax.grid(linestyle="--", color="grey", linewidth=1)
3445 # Identify unique labels for legend
3446 handles = list(
3447 {
3448 label: handle
3449 for (handle, label) in zip(*ax.get_legend_handles_labels(), strict=True)
3450 }.values()
3451 )
3452 ax.legend(handles=handles, loc="best", ncol=1, frameon=False, fontsize=16)
3454 # Add a legend
3455 ax.legend(fontsize=16)
3457 # Figure title.
3458 ax.set_title(title, fontsize=16)
3460 # Save the figure to a file
3461 plt.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
3463 # Close the figure
3464 plt.close(fig)