Coverage for src / CSET / operators / plot.py: 84%
903 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-02 17:30 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-02 17:30 +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 iris
29import iris.coords
30import iris.cube
31import iris.exceptions
32import iris.plot as iplt
33import matplotlib as mpl
34import matplotlib.colors as mcolors
35import matplotlib.pyplot as plt
36import numpy as np
37from iris.cube import Cube
38from markdown_it import MarkdownIt
40from CSET._common import (
41 combine_dicts,
42 get_recipe_metadata,
43 iter_maybe,
44 render_file,
45 slugify,
46)
47from CSET.operators._utils import (
48 fully_equalise_attributes,
49 get_cube_yxcoordname,
50 is_transect,
51)
52from CSET.operators.collapse import collapse
53from CSET.operators.misc import _extract_common_time_points
54from CSET.operators.regrid import regrid_onto_cube
56# Use a non-interactive plotting backend.
57mpl.use("agg")
59DEFAULT_DISCRETE_COLORS = mpl.colormaps["tab10"].colors + mpl.colormaps["Accent"].colors
61############################
62# Private helper functions #
63############################
66def _append_to_plot_index(plot_index: list) -> list:
67 """Add plots into the plot index, returning the complete plot index."""
68 with open("meta.json", "r+t", encoding="UTF-8") as fp:
69 fcntl.flock(fp, fcntl.LOCK_EX)
70 fp.seek(0)
71 meta = json.load(fp)
72 complete_plot_index = meta.get("plots", [])
73 complete_plot_index = complete_plot_index + plot_index
74 meta["plots"] = complete_plot_index
75 if os.getenv("CYLC_TASK_CYCLE_POINT") and not bool(
76 os.getenv("DO_CASE_AGGREGATION")
77 ):
78 meta["case_date"] = os.getenv("CYLC_TASK_CYCLE_POINT", "")
79 fp.seek(0)
80 fp.truncate()
81 json.dump(meta, fp, indent=2)
82 return complete_plot_index
85def _check_single_cube(cube: iris.cube.Cube | iris.cube.CubeList) -> iris.cube.Cube:
86 """Ensure a single cube is given.
88 If a CubeList of length one is given that the contained cube is returned,
89 otherwise an error is raised.
91 Parameters
92 ----------
93 cube: Cube | CubeList
94 The cube to check.
96 Returns
97 -------
98 cube: Cube
99 The checked cube.
101 Raises
102 ------
103 TypeError
104 If the input cube is not a Cube or CubeList of a single Cube.
105 """
106 if isinstance(cube, iris.cube.Cube):
107 return cube
108 if isinstance(cube, iris.cube.CubeList):
109 if len(cube) == 1:
110 return cube[0]
111 raise TypeError("Must have a single cube", cube)
114def _make_plot_html_page(plots: list):
115 """Create a HTML page to display a plot image."""
116 # Debug check that plots actually contains some strings.
117 assert isinstance(plots[0], str)
119 # Load HTML template file.
120 operator_files = importlib.resources.files()
121 template_file = operator_files.joinpath("_plot_page_template.html")
123 # Get some metadata.
124 meta = get_recipe_metadata()
125 title = meta.get("title", "Untitled")
126 description = MarkdownIt().render(meta.get("description", "*No description.*"))
128 # Prepare template variables.
129 variables = {
130 "title": title,
131 "description": description,
132 "initial_plot": plots[0],
133 "plots": plots,
134 "title_slug": slugify(title),
135 }
137 # Render template.
138 html = render_file(template_file, **variables)
140 # Save completed HTML.
141 with open("index.html", "wt", encoding="UTF-8") as fp:
142 fp.write(html)
145@functools.cache
146def _load_colorbar_map(user_colorbar_file: str = None) -> dict:
147 """Load the colorbar definitions from a file.
149 This is a separate function to make it cacheable.
150 """
151 colorbar_file = importlib.resources.files().joinpath("_colorbar_definition.json")
152 with open(colorbar_file, "rt", encoding="UTF-8") as fp:
153 colorbar = json.load(fp)
155 logging.debug("User colour bar file: %s", user_colorbar_file)
156 override_colorbar = {}
157 if user_colorbar_file:
158 try:
159 with open(user_colorbar_file, "rt", encoding="UTF-8") as fp:
160 override_colorbar = json.load(fp)
161 except FileNotFoundError:
162 logging.warning("Colorbar file does not exist. Using default values.")
164 # Overwrite values with the user supplied colorbar definition.
165 colorbar = combine_dicts(colorbar, override_colorbar)
166 return colorbar
169def _get_model_colors_map(cubes: iris.cube.CubeList | iris.cube.Cube) -> dict:
170 """Get an appropriate colors for model lines in line plots.
172 For each model in the list of cubes colors either from user provided
173 color definition file (so-called style file) or from default colors are mapped
174 to model_name attribute.
176 Parameters
177 ----------
178 cubes: CubeList or Cube
179 Cubes with model_name attribute
181 Returns
182 -------
183 model_colors_map:
184 Dictionary mapping model_name attribute to colors
185 """
186 user_colorbar_file = get_recipe_metadata().get("style_file_path", None)
187 colorbar = _load_colorbar_map(user_colorbar_file)
188 model_names = sorted(
189 filter(
190 lambda x: x is not None,
191 (cube.attributes.get("model_name", None) for cube in iter_maybe(cubes)),
192 )
193 )
194 if not model_names:
195 return {}
196 use_user_colors = all(mname in colorbar.keys() for mname in model_names)
197 if use_user_colors: 197 ↛ 198line 197 didn't jump to line 198 because the condition on line 197 was never true
198 return {mname: colorbar[mname] for mname in model_names}
200 color_list = itertools.cycle(DEFAULT_DISCRETE_COLORS)
201 return {mname: color for mname, color in zip(model_names, color_list, strict=False)}
204def _colorbar_map_levels(cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None):
205 """Get an appropriate colorbar for the given cube.
207 For the given variable the appropriate colorbar is looked up from a
208 combination of the built-in CSET colorbar definitions, and any user supplied
209 definitions. As well as varying on variables, these definitions may also
210 exist for specific pressure levels to account for variables with
211 significantly different ranges at different heights. The colorbars also exist
212 for masks and mask differences for considering variable presence diagnostics.
213 Specific variable ranges can be separately set in user-supplied definition
214 for x- or y-axis limits, or indicate where automated range preferred.
216 Parameters
217 ----------
218 cube: Cube
219 Cube of variable for which the colorbar information is desired.
220 axis: "x", "y", optional
221 Select the levels for just this axis of a line plot. The min and max
222 can be set by xmin/xmax or ymin/ymax respectively. For variables where
223 setting a universal range is not desirable (e.g. temperature), users
224 can set ymin/ymax values to "auto" in the colorbar definitions file.
225 Where no additional xmin/xmax or ymin/ymax values are provided, the
226 axis bounds default to use the vmin/vmax values provided.
228 Returns
229 -------
230 cmap:
231 Matplotlib colormap.
232 levels:
233 List of levels to use for plotting. For continuous plots the min and max
234 should be taken as the range.
235 norm:
236 BoundaryNorm information.
237 """
238 # Grab the colorbar file from the recipe global metadata.
239 user_colorbar_file = get_recipe_metadata().get("style_file_path", None)
240 colorbar = _load_colorbar_map(user_colorbar_file)
241 cmap = None
243 try:
244 # We assume that pressure is a scalar coordinate here.
245 pressure_level_raw = cube.coord("pressure").points[0]
246 # Ensure pressure_level is a string, as it is used as a JSON key.
247 pressure_level = str(int(pressure_level_raw))
248 except iris.exceptions.CoordinateNotFoundError:
249 pressure_level = None
251 # First try long name, then standard name, then var name. This order is used
252 # as long name is the one we correct between models, so it most likely to be
253 # consistent.
254 varnames = list(filter(None, [cube.long_name, cube.standard_name, cube.var_name]))
255 for varname in varnames:
256 # Get the colormap for this variable.
257 try:
258 var_colorbar = colorbar[varname]
259 cmap = plt.get_cmap(colorbar[varname]["cmap"], 51)
260 varname_key = varname
261 break
262 except KeyError:
263 logging.debug("Cube name %s has no colorbar definition.", varname)
265 # Get colormap if it is a mask.
266 if any("mask_for_" in name for name in varnames):
267 cmap, levels, norm = _custom_colormap_mask(cube, axis=axis)
268 return cmap, levels, norm
269 # If winds on Beaufort Scale use custom colorbar and levels
270 if any("Beaufort_Scale" in name for name in varnames):
271 cmap, levels, norm = _custom_beaufort_scale(cube, axis=axis)
272 return cmap, levels, norm
273 # If probability is plotted use custom colorbar and levels
274 if any("probability_of_" in name for name in varnames):
275 cmap, levels, norm = _custom_colormap_probability(cube, axis=axis)
276 return cmap, levels, norm
277 # If aviation colour state use custom colorbar and levels
278 if any("aviation_colour_state" in name for name in varnames): 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true
279 cmap, levels, norm = _custom_colormap_aviation_colour_state(cube)
280 return cmap, levels, norm
282 # If no valid colormap has been defined, use defaults and return.
283 if not cmap:
284 logging.warning("No colorbar definition exists for %s.", cube.name())
285 cmap, levels, norm = mpl.colormaps["viridis"], None, None
286 return cmap, levels, norm
288 # Test if pressure-level specific settings are provided for cube.
289 if pressure_level:
290 try:
291 var_colorbar = colorbar[varname_key]["pressure_levels"][pressure_level]
292 except KeyError:
293 logging.debug(
294 "%s has no colorbar definition for pressure level %s.",
295 varname,
296 pressure_level,
297 )
299 # Check for availability of x-axis or y-axis user-specific overrides
300 # for setting level bounds for line plot types and return just levels.
301 # Line plots do not need a colormap, and just use the data range.
302 if axis:
303 if axis == "x":
304 try:
305 vmin, vmax = var_colorbar["xmin"], var_colorbar["xmax"]
306 except KeyError:
307 vmin, vmax = var_colorbar["min"], var_colorbar["max"]
308 if axis == "y":
309 try:
310 vmin, vmax = var_colorbar["ymin"], var_colorbar["ymax"]
311 except KeyError:
312 vmin, vmax = var_colorbar["min"], var_colorbar["max"]
313 # Check if user-specified auto-scaling for this variable
314 if vmin == "auto" or vmax == "auto":
315 levels = None
316 else:
317 levels = [vmin, vmax]
318 return None, levels, None
319 # Get and use the colorbar levels for this variable if spatial or histogram.
320 else:
321 try:
322 levels = var_colorbar["levels"]
323 # Use discrete bins when levels are specified, rather
324 # than a smooth range.
325 norm = mpl.colors.BoundaryNorm(levels, ncolors=cmap.N)
326 logging.debug("Using levels for %s colorbar.", varname)
327 logging.info("Using levels: %s", levels)
328 except KeyError:
329 # Get the range for this variable.
330 vmin, vmax = var_colorbar["min"], var_colorbar["max"]
331 logging.debug("Using min and max for %s colorbar.", varname)
332 # Calculate levels from range.
333 levels = np.linspace(vmin, vmax, 101)
334 norm = None
336 # Overwrite cmap, levels and norm for specific variables that
337 # require custom colorbar_map as these can not be defined in the
338 # JSON file.
339 cmap, levels, norm = _custom_colourmap_precipitation(cube, cmap, levels, norm)
340 cmap, levels, norm = _custom_colourmap_visibility_in_air(
341 cube, cmap, levels, norm
342 )
343 cmap, levels, norm = _custom_colormap_celsius(cube, cmap, levels, norm)
344 return cmap, levels, norm
347def _setup_spatial_map(
348 cube: iris.cube.Cube,
349 figure,
350 cmap,
351 grid_size: int | None = None,
352 subplot: int | None = None,
353):
354 """Define map projections, extent and add coastlines for spatial plots.
356 For spatial map plots, a relevant map projection for rotated or non-rotated inputs
357 is specified, and map extent defined based on the input data.
359 Parameters
360 ----------
361 cube: Cube
362 2 dimensional (lat and lon) Cube of the data to plot.
363 figure:
364 Matplotlib Figure object holding all plot elements.
365 cmap:
366 Matplotlib colormap.
367 grid_size: int, optional
368 Size of grid for subplots if multiple spatial subplots in figure.
369 subplot: int, optional
370 Subplot index if multiple spatial subplots in figure.
372 Returns
373 -------
374 axes:
375 Matplotlib GeoAxes definition.
376 """
377 # Identify min/max plot bounds.
378 try:
379 lat_axis, lon_axis = get_cube_yxcoordname(cube)
380 x1 = np.min(cube.coord(lon_axis).points)
381 x2 = np.max(cube.coord(lon_axis).points)
382 y1 = np.min(cube.coord(lat_axis).points)
383 y2 = np.max(cube.coord(lat_axis).points)
385 # Adjust bounds within +/- 180.0 if x dimension extends beyond half-globe.
386 if np.abs(x2 - x1) > 180.0:
387 x1 = x1 - 180.0
388 x2 = x2 - 180.0
389 logging.debug("Adjusting plot bounds to fit global extent.")
391 # Consider map projection orientation.
392 # Adapting orientation enables plotting across international dateline.
393 # Users can adapt the default central_longitude if alternative projections views.
394 if x2 > 180.0 or x1 < -180.0:
395 central_longitude = 180.0
396 else:
397 central_longitude = 0.0
399 # Define spatial map projection.
400 coord_system = cube.coord(lat_axis).coord_system
401 if isinstance(coord_system, iris.coord_systems.RotatedGeogCS):
402 # Define rotated pole map projection for rotated pole inputs.
403 projection = ccrs.RotatedPole(
404 pole_longitude=coord_system.grid_north_pole_longitude,
405 pole_latitude=coord_system.grid_north_pole_latitude,
406 central_rotated_longitude=central_longitude,
407 )
408 crs = projection
409 elif isinstance(coord_system, iris.coord_systems.TransverseMercator): 409 ↛ 411line 409 didn't jump to line 411 because the condition on line 409 was never true
410 # Define Transverse Mercator projection for TM inputs.
411 projection = ccrs.TransverseMercator(
412 central_longitude=coord_system.longitude_of_central_meridian,
413 central_latitude=coord_system.latitude_of_projection_origin,
414 false_easting=coord_system.false_easting,
415 false_northing=coord_system.false_northing,
416 scale_factor=coord_system.scale_factor_at_central_meridian,
417 )
418 crs = projection
419 else:
420 # Define regular map projection for non-rotated pole inputs.
421 # Alternatives might include e.g. for global model outputs:
422 # projection=ccrs.Robinson(central_longitude=X.y, globe=None)
423 # See also https://scitools.org.uk/cartopy/docs/v0.15/crs/projections.html.
424 projection = ccrs.PlateCarree(central_longitude=central_longitude)
425 crs = ccrs.PlateCarree()
427 # Define axes for plot (or subplot) with required map projection.
428 if subplot is not None:
429 axes = figure.add_subplot(
430 grid_size, grid_size, subplot, projection=projection
431 )
432 else:
433 axes = figure.add_subplot(projection=projection)
435 # Add coastlines if cube contains x and y map coordinates.
436 if cmap.name in ["viridis", "Greys"]:
437 coastcol = "magenta"
438 else:
439 coastcol = "black"
440 logging.debug("Plotting coastlines in colour %s.", coastcol)
441 axes.coastlines(resolution="10m", color=coastcol)
443 # If is lat/lon spatial map, fix extent to keep plot tight.
444 # Specifying crs within set_extent helps ensure only data region is shown.
445 if isinstance(coord_system, iris.coord_systems.GeogCS):
446 axes.set_extent([x1, x2, y1, y2], crs=crs)
448 except ValueError:
449 # Skip if not both x and y map coordinates.
450 axes = figure.gca()
451 pass
453 return axes
456def _get_plot_resolution() -> int:
457 """Get resolution of rasterised plots in pixels per inch."""
458 return get_recipe_metadata().get("plot_resolution", 100)
461def _plot_and_save_spatial_plot(
462 cube: iris.cube.Cube,
463 filename: str,
464 title: str,
465 method: Literal["contourf", "pcolormesh"],
466 **kwargs,
467):
468 """Plot and save a spatial plot.
470 Parameters
471 ----------
472 cube: Cube
473 2 dimensional (lat and lon) Cube of the data to plot.
474 filename: str
475 Filename of the plot to write.
476 title: str
477 Plot title.
478 method: "contourf" | "pcolormesh"
479 The plotting method to use.
480 """
481 # Setup plot details, size, resolution, etc.
482 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
484 # Specify the color bar
485 cmap, levels, norm = _colorbar_map_levels(cube)
487 # Setup plot map projection, extent and coastlines.
488 axes = _setup_spatial_map(cube, fig, cmap)
490 # Plot the field.
491 if method == "contourf":
492 # Filled contour plot of the field.
493 plot = iplt.contourf(cube, cmap=cmap, levels=levels, norm=norm)
494 elif method == "pcolormesh":
495 try:
496 vmin = min(levels)
497 vmax = max(levels)
498 except TypeError:
499 vmin, vmax = None, None
500 # pcolormesh plot of the field and ensure to use norm and not vmin/vmax
501 # if levels are defined.
502 if norm is not None:
503 vmin = None
504 vmax = None
505 logging.debug("Plotting using defined levels.")
506 plot = iplt.pcolormesh(cube, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax)
507 else:
508 raise ValueError(f"Unknown plotting method: {method}")
510 # Check to see if transect, and if so, adjust y axis.
511 if is_transect(cube):
512 if "pressure" in [coord.name() for coord in cube.coords()]:
513 axes.invert_yaxis()
514 axes.set_yscale("log")
515 axes.set_ylim(1100, 100)
516 # If both model_level_number and level_height exists, iplt can construct
517 # plot as a function of height above orography (NOT sea level).
518 elif {"model_level_number", "level_height"}.issubset( 518 ↛ 523line 518 didn't jump to line 523 because the condition on line 518 was always true
519 {coord.name() for coord in cube.coords()}
520 ):
521 axes.set_yscale("log")
523 axes.set_title(
524 f"{title}\n"
525 f"Start Lat: {cube.attributes['transect_coords'].split('_')[0]}"
526 f" Start Lon: {cube.attributes['transect_coords'].split('_')[1]}"
527 f" End Lat: {cube.attributes['transect_coords'].split('_')[2]}"
528 f" End Lon: {cube.attributes['transect_coords'].split('_')[3]}",
529 fontsize=16,
530 )
532 else:
533 # Add title.
534 axes.set_title(title, fontsize=16)
536 # Add watermark with min/max/mean. Currently not user togglable.
537 # In the bbox dictionary, fc and ec are hex colour codes for grey shade.
538 axes.annotate(
539 f"Min: {np.min(cube.data):.3g} Max: {np.max(cube.data):.3g} Mean: {np.mean(cube.data):.3g}",
540 xy=(1, -0.05),
541 xycoords="axes fraction",
542 xytext=(-5, 5),
543 textcoords="offset points",
544 ha="right",
545 va="bottom",
546 size=11,
547 bbox=dict(boxstyle="round", fc="#cccccc", ec="#808080", alpha=0.9),
548 )
550 # Add colour bar.
551 cbar = fig.colorbar(plot, orientation="horizontal", pad=0.042, shrink=0.7)
552 cbar.set_label(label=f"{cube.name()} ({cube.units})", size=14)
553 # add ticks and tick_labels for every levels if less than 20 levels exist
554 if levels is not None and len(levels) < 20:
555 cbar.set_ticks(levels)
556 cbar.set_ticklabels([f"{level:.2f}" for level in levels])
557 if "visibility" in cube.name(): 557 ↛ 558line 557 didn't jump to line 558 because the condition on line 557 was never true
558 cbar.set_ticklabels([f"{level:.3g}" for level in levels])
559 logging.debug("Set colorbar ticks and labels.")
561 # Save plot.
562 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
563 logging.info("Saved spatial plot to %s", filename)
564 plt.close(fig)
567def _plot_and_save_postage_stamp_spatial_plot(
568 cube: iris.cube.Cube,
569 filename: str,
570 stamp_coordinate: str,
571 title: str,
572 method: Literal["contourf", "pcolormesh"],
573 **kwargs,
574):
575 """Plot postage stamp spatial plots from an ensemble.
577 Parameters
578 ----------
579 cube: Cube
580 Iris cube of data to be plotted. It must have the stamp coordinate.
581 filename: str
582 Filename of the plot to write.
583 stamp_coordinate: str
584 Coordinate that becomes different plots.
585 method: "contourf" | "pcolormesh"
586 The plotting method to use.
588 Raises
589 ------
590 ValueError
591 If the cube doesn't have the right dimensions.
592 """
593 # Use the smallest square grid that will fit the members.
594 grid_size = int(math.ceil(math.sqrt(len(cube.coord(stamp_coordinate).points))))
596 fig = plt.figure(figsize=(10, 10))
598 # Specify the color bar
599 cmap, levels, norm = _colorbar_map_levels(cube)
601 # Make a subplot for each member.
602 for member, subplot in zip(
603 cube.slices_over(stamp_coordinate), range(1, grid_size**2 + 1), strict=False
604 ):
605 # Setup subplot map projection, extent and coastlines.
606 axes = _setup_spatial_map(
607 member, fig, cmap, grid_size=grid_size, subplot=subplot
608 )
609 if method == "contourf":
610 # Filled contour plot of the field.
611 plot = iplt.contourf(member, cmap=cmap, levels=levels, norm=norm)
612 elif method == "pcolormesh":
613 if levels is not None:
614 vmin = min(levels)
615 vmax = max(levels)
616 else:
617 raise TypeError("Unknown vmin and vmax range.")
618 vmin, vmax = None, None
619 # pcolormesh plot of the field and ensure to use norm and not vmin/vmax
620 # if levels are defined.
621 if norm is not None: 621 ↛ 622line 621 didn't jump to line 622 because the condition on line 621 was never true
622 vmin = None
623 vmax = None
624 # pcolormesh plot of the field.
625 plot = iplt.pcolormesh(member, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax)
626 else:
627 raise ValueError(f"Unknown plotting method: {method}")
628 axes.set_title(f"Member #{member.coord(stamp_coordinate).points[0]}")
629 axes.set_axis_off()
631 # Put the shared colorbar in its own axes.
632 colorbar_axes = fig.add_axes([0.15, 0.07, 0.7, 0.03])
633 colorbar = fig.colorbar(
634 plot, colorbar_axes, orientation="horizontal", pad=0.042, shrink=0.7
635 )
636 colorbar.set_label(f"{cube.name()} ({cube.units})", size=14)
638 # Overall figure title.
639 fig.suptitle(title, fontsize=16)
641 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
642 logging.info("Saved contour postage stamp plot to %s", filename)
643 plt.close(fig)
646def _plot_and_save_line_series(
647 cubes: iris.cube.CubeList,
648 coords: list[iris.coords.Coord],
649 ensemble_coord: str,
650 filename: str,
651 title: str,
652 **kwargs,
653):
654 """Plot and save a 1D line series.
656 Parameters
657 ----------
658 cubes: Cube or CubeList
659 Cube or CubeList containing the cubes to plot on the y-axis.
660 coords: list[Coord]
661 Coordinates to plot on the x-axis, one per cube.
662 ensemble_coord: str
663 Ensemble coordinate in the cube.
664 filename: str
665 Filename of the plot to write.
666 title: str
667 Plot title.
668 """
669 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
671 model_colors_map = _get_model_colors_map(cubes)
673 # Store min/max ranges.
674 y_levels = []
676 # Check match-up across sequence coords gives consistent sizes
677 _validate_cubes_coords(cubes, coords)
679 for cube, coord in zip(cubes, coords, strict=True):
680 label = None
681 color = "black"
682 if model_colors_map:
683 label = cube.attributes.get("model_name")
684 color = model_colors_map.get(label)
685 for cube_slice in cube.slices_over(ensemble_coord):
686 # Label with (control) if part of an ensemble or not otherwise.
687 if cube_slice.coord(ensemble_coord).points == [0]:
688 iplt.plot(
689 coord,
690 cube_slice,
691 color=color,
692 marker="o",
693 ls="-",
694 lw=3,
695 label=f"{label} (control)"
696 if len(cube.coord(ensemble_coord).points) > 1
697 else label,
698 )
699 # Label with (perturbed) if part of an ensemble and not the control.
700 else:
701 iplt.plot(
702 coord,
703 cube_slice,
704 color=color,
705 ls="-",
706 lw=1.5,
707 alpha=0.75,
708 label=f"{label} (member)",
709 )
711 # Calculate the global min/max if multiple cubes are given.
712 _, levels, _ = _colorbar_map_levels(cube, axis="y")
713 if levels is not None: 713 ↛ 714line 713 didn't jump to line 714 because the condition on line 713 was never true
714 y_levels.append(min(levels))
715 y_levels.append(max(levels))
717 # Get the current axes.
718 ax = plt.gca()
720 # Add some labels and tweak the style.
721 # check if cubes[0] works for single cube if not CubeList
722 ax.set_xlabel(f"{coords[0].name()} / {coords[0].units}", fontsize=14)
723 ax.set_ylabel(f"{cubes[0].name()} / {cubes[0].units}", fontsize=14)
724 ax.set_title(title, fontsize=16)
726 ax.ticklabel_format(axis="y", useOffset=False)
727 ax.tick_params(axis="x", labelrotation=15)
728 ax.tick_params(axis="both", labelsize=12)
730 # Set y limits to global min and max, autoscale if colorbar doesn't exist.
731 if y_levels: 731 ↛ 732line 731 didn't jump to line 732 because the condition on line 731 was never true
732 ax.set_ylim(min(y_levels), max(y_levels))
733 # Add zero line.
734 if min(y_levels) < 0.0 and max(y_levels) > 0.0:
735 ax.axhline(y=0, xmin=0, xmax=1, ls="-", color="grey", lw=2)
736 logging.debug(
737 "Line plot with y-axis limits %s-%s", min(y_levels), max(y_levels)
738 )
739 else:
740 ax.autoscale()
742 # Add gridlines
743 ax.grid(linestyle="--", color="grey", linewidth=1)
744 # Ientify unique labels for legend
745 handles = list(
746 {
747 label: handle
748 for (handle, label) in zip(*ax.get_legend_handles_labels(), strict=True)
749 }.values()
750 )
751 ax.legend(handles=handles, loc="best", ncol=1, frameon=False, fontsize=16)
753 # Save plot.
754 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
755 logging.info("Saved line plot to %s", filename)
756 plt.close(fig)
759def _plot_and_save_line_1D(
760 cubes: iris.cube.CubeList,
761 coords: list[iris.coords.Coord],
762 ensemble_coord: str,
763 filename: str,
764 title: str,
765 **kwargs,
766):
767 """Plot and save a 1D line series.
769 Parameters
770 ----------
771 cubes: Cube or CubeList
772 Cube or CubeList containing the cubes to plot on the y-axis.
773 coords: list[Coord]
774 Coordinates to plot on the x-axis, one per cube.
775 ensemble_coord: str
776 Ensemble coordinate in the cube.
777 filename: str
778 Filename of the plot to write.
779 title: str
780 Plot title.
781 """
782 xn = coords[0].name() # x-axis (e.g. frequency)
784 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
785 model_colors_map = _get_model_colors_map(cubes)
786 ax = plt.gca()
788 # Store min/max ranges.
789 y_levels = []
791 if "Spectrum" in title: 791 ↛ 792line 791 didn't jump to line 792 because the condition on line 791 was never true
792 line_marker = None
793 line_width = 1
794 else:
795 line_marker = "o"
796 line_width = 3
798 # for cube, coord in zip(cubes, coords, strict=True):
799 for cube in iter_maybe(cubes):
800 xname = cube.coord(xn).points # frequency
801 yfield = cube.data # power spectrum
802 label = None
803 color = "black"
804 if model_colors_map:
805 label = cube.attributes.get("model_name")
806 color = model_colors_map.get(label)
807 for cube_slice in cube.slices_over(ensemble_coord):
808 # Label with (control) if part of an ensemble or not otherwise.
809 if cube_slice.coord(ensemble_coord).points == [0]: 809 ↛ 823line 809 didn't jump to line 823 because the condition on line 809 was always true
810 ax.plot(
811 xname,
812 yfield,
813 color=color,
814 marker=line_marker,
815 ls="-",
816 lw=line_width,
817 label=f"{label} (control)"
818 if len(cube.coord(ensemble_coord).points) > 1
819 else label,
820 )
821 # Label with (perturbed) if part of an ensemble and not the control.
822 else:
823 ax.plot(
824 xname,
825 yfield,
826 color=color,
827 ls="-",
828 lw=1.5,
829 alpha=0.75,
830 label=f"{label} (member)",
831 )
833 # Calculate the global min/max if multiple cubes are given.
834 _, levels, _ = _colorbar_map_levels(cube, axis="y")
835 if levels is not None: 835 ↛ 836line 835 didn't jump to line 836 because the condition on line 835 was never true
836 y_levels.append(min(levels))
837 y_levels.append(max(levels))
839 # Add some labels and tweak the style.
840 # check if cubes[0] works for single cube if not CubeList
841 if "Spectrum" in title: 841 ↛ 843line 841 didn't jump to line 843 because the condition on line 841 was never true
842 # title = f"{title}\n [{coords.units.title(coords.points[0])}]"
843 title = f"{title}"
844 ax.set_title(title, fontsize=16)
845 ax.set_xlabel("Wavenumber", fontsize=14)
846 ax.set_ylabel("Power", fontsize=14)
847 ax.tick_params(axis="both", labelsize=12)
848 else:
849 ax.set_xlabel(f"{coords[0].name()} / {coords[0].units}", fontsize=14)
850 ax.set_ylabel(f"{cubes[0].name()} / {cubes[0].units}", fontsize=14)
851 ax.set_title(title, fontsize=16)
853 ax.ticklabel_format(axis="y", useOffset=False)
854 ax.tick_params(axis="x", labelrotation=15)
855 ax.tick_params(axis="both", labelsize=12)
857 # Set y limits to global min and max, autoscale if colorbar doesn't exist.
858 if "Spectrum" in title: 858 ↛ 860line 858 didn't jump to line 860 because the condition on line 858 was never true
859 # Set log-log scale
860 ax.set_xscale("log")
861 ax.set_yscale("log")
863 elif y_levels: 863 ↛ 864line 863 didn't jump to line 864 because the condition on line 863 was never true
864 ax.set_ylim(min(y_levels), max(y_levels))
865 # Add zero line.
866 if min(y_levels) < 0.0 and max(y_levels) > 0.0:
867 ax.axhline(y=0, xmin=0, xmax=1, ls="-", color="grey", lw=2)
868 logging.debug(
869 "Line plot with y-axis limits %s-%s", min(y_levels), max(y_levels)
870 )
871 else:
872 ax.autoscale()
874 # Add gridlines
875 ax.grid(linestyle="--", color="grey", linewidth=1)
876 # Ientify unique labels for legend
877 handles = list(
878 {
879 label: handle
880 for (handle, label) in zip(*ax.get_legend_handles_labels(), strict=True)
881 }.values()
882 )
883 ax.legend(handles=handles, loc="best", ncol=1, frameon=False, fontsize=16)
885 # Save plot.
886 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
887 logging.info("Saved line plot to %s", filename)
888 plt.close(fig)
891def _plot_and_save_vertical_line_series(
892 cubes: iris.cube.CubeList,
893 coords: list[iris.coords.Coord],
894 ensemble_coord: str,
895 filename: str,
896 series_coordinate: str,
897 title: str,
898 vmin: float,
899 vmax: float,
900 **kwargs,
901):
902 """Plot and save a 1D line series in vertical.
904 Parameters
905 ----------
906 cubes: CubeList
907 1 dimensional Cube or CubeList of the data to plot on x-axis.
908 coord: list[Coord]
909 Coordinates to plot on the y-axis, one per cube.
910 ensemble_coord: str
911 Ensemble coordinate in the cube.
912 filename: str
913 Filename of the plot to write.
914 series_coordinate: str
915 Coordinate to use as vertical axis.
916 title: str
917 Plot title.
918 vmin: float
919 Minimum value for the x-axis.
920 vmax: float
921 Maximum value for the x-axis.
922 """
923 # plot the vertical pressure axis using log scale
924 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
926 model_colors_map = _get_model_colors_map(cubes)
928 # Check match-up across sequence coords gives consistent sizes
929 _validate_cubes_coords(cubes, coords)
931 for cube, coord in zip(cubes, coords, strict=True):
932 label = None
933 color = "black"
934 if model_colors_map: 934 ↛ 935line 934 didn't jump to line 935 because the condition on line 934 was never true
935 label = cube.attributes.get("model_name")
936 color = model_colors_map.get(label)
938 for cube_slice in cube.slices_over(ensemble_coord):
939 # If ensemble data given plot control member with (control)
940 # unless single forecast.
941 if cube_slice.coord(ensemble_coord).points == [0]:
942 iplt.plot(
943 cube_slice,
944 coord,
945 color=color,
946 marker="o",
947 ls="-",
948 lw=3,
949 label=f"{label} (control)"
950 if len(cube.coord(ensemble_coord).points) > 1
951 else label,
952 )
953 # If ensemble data given plot perturbed members with (perturbed).
954 else:
955 iplt.plot(
956 cube_slice,
957 coord,
958 color=color,
959 ls="-",
960 lw=1.5,
961 alpha=0.75,
962 label=f"{label} (member)",
963 )
965 # Get the current axis
966 ax = plt.gca()
968 # Special handling for pressure level data.
969 if series_coordinate == "pressure": 969 ↛ 991line 969 didn't jump to line 991 because the condition on line 969 was always true
970 # Invert y-axis and set to log scale.
971 ax.invert_yaxis()
972 ax.set_yscale("log")
974 # Define y-ticks and labels for pressure log axis.
975 y_tick_labels = [
976 "1000",
977 "850",
978 "700",
979 "500",
980 "300",
981 "200",
982 "100",
983 ]
984 y_ticks = [1000, 850, 700, 500, 300, 200, 100]
986 # Set y-axis limits and ticks.
987 ax.set_ylim(1100, 100)
989 # Test if series_coordinate is model level data. The UM data uses
990 # model_level_number and lfric uses full_levels as coordinate.
991 elif series_coordinate in ("model_level_number", "full_levels", "half_levels"):
992 # Define y-ticks and labels for vertical axis.
993 y_ticks = iter_maybe(cubes)[0].coord(series_coordinate).points
994 y_tick_labels = [str(int(i)) for i in y_ticks]
995 ax.set_ylim(min(y_ticks), max(y_ticks))
997 ax.set_yticks(y_ticks)
998 ax.set_yticklabels(y_tick_labels)
1000 # Set x-axis limits.
1001 ax.set_xlim(vmin, vmax)
1002 # Mark y=0 if present in plot.
1003 if vmin < 0.0 and vmax > 0.0: 1003 ↛ 1004line 1003 didn't jump to line 1004 because the condition on line 1003 was never true
1004 ax.axvline(x=0, ymin=0, ymax=1, ls="-", color="grey", lw=2)
1006 # Add some labels and tweak the style.
1007 ax.set_ylabel(f"{coord.name()} / {coord.units}", fontsize=14)
1008 ax.set_xlabel(
1009 f"{iter_maybe(cubes)[0].name()} / {iter_maybe(cubes)[0].units}", fontsize=14
1010 )
1011 ax.set_title(title, fontsize=16)
1012 ax.ticklabel_format(axis="x")
1013 ax.tick_params(axis="y")
1014 ax.tick_params(axis="both", labelsize=12)
1016 # Add gridlines
1017 ax.grid(linestyle="--", color="grey", linewidth=1)
1018 # Ientify unique labels for legend
1019 handles = list(
1020 {
1021 label: handle
1022 for (handle, label) in zip(*ax.get_legend_handles_labels(), strict=True)
1023 }.values()
1024 )
1025 ax.legend(handles=handles, loc="best", ncol=1, frameon=False, fontsize=16)
1027 # Save plot.
1028 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1029 logging.info("Saved line plot to %s", filename)
1030 plt.close(fig)
1033def _plot_and_save_scatter_plot(
1034 cube_x: iris.cube.Cube | iris.cube.CubeList,
1035 cube_y: iris.cube.Cube | iris.cube.CubeList,
1036 filename: str,
1037 title: str,
1038 one_to_one: bool,
1039 model_names: list[str] = None,
1040 **kwargs,
1041):
1042 """Plot and save a 2D scatter plot.
1044 Parameters
1045 ----------
1046 cube_x: Cube | CubeList
1047 1 dimensional Cube or CubeList of the data to plot on x-axis.
1048 cube_y: Cube | CubeList
1049 1 dimensional Cube or CubeList of the data to plot on y-axis.
1050 filename: str
1051 Filename of the plot to write.
1052 title: str
1053 Plot title.
1054 one_to_one: bool
1055 Whether a 1:1 line is plotted.
1056 """
1057 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
1058 # plot the cube_x and cube_y 1D fields as a scatter plot. If they are CubeLists this ensures
1059 # to pair each cube from cube_x with the corresponding cube from cube_y, allowing to iterate
1060 # over the pairs simultaneously.
1062 # Ensure cube_x and cube_y are iterable
1063 cube_x_iterable = iter_maybe(cube_x)
1064 cube_y_iterable = iter_maybe(cube_y)
1066 for cube_x_iter, cube_y_iter in zip(cube_x_iterable, cube_y_iterable, strict=True):
1067 iplt.scatter(cube_x_iter, cube_y_iter)
1068 if one_to_one is True:
1069 plt.plot(
1070 [
1071 np.nanmin([np.nanmin(cube_y.data), np.nanmin(cube_x.data)]),
1072 np.nanmax([np.nanmax(cube_y.data), np.nanmax(cube_x.data)]),
1073 ],
1074 [
1075 np.nanmin([np.nanmin(cube_y.data), np.nanmin(cube_x.data)]),
1076 np.nanmax([np.nanmax(cube_y.data), np.nanmax(cube_x.data)]),
1077 ],
1078 "k",
1079 linestyle="--",
1080 )
1081 ax = plt.gca()
1083 # Add some labels and tweak the style.
1084 if model_names is None:
1085 ax.set_xlabel(f"{cube_x[0].name()} / {cube_x[0].units}", fontsize=14)
1086 ax.set_ylabel(f"{cube_y[0].name()} / {cube_y[0].units}", fontsize=14)
1087 else:
1088 # Add the model names, these should be order of base (x) and other (y).
1089 ax.set_xlabel(
1090 f"{model_names[0]}_{cube_x[0].name()} / {cube_x[0].units}", fontsize=14
1091 )
1092 ax.set_ylabel(
1093 f"{model_names[1]}_{cube_y[0].name()} / {cube_y[0].units}", fontsize=14
1094 )
1095 ax.set_title(title, fontsize=16)
1096 ax.ticklabel_format(axis="y", useOffset=False)
1097 ax.tick_params(axis="x", labelrotation=15)
1098 ax.tick_params(axis="both", labelsize=12)
1099 ax.autoscale()
1101 # Save plot.
1102 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1103 logging.info("Saved scatter plot to %s", filename)
1104 plt.close(fig)
1107def _plot_and_save_vector_plot(
1108 cube_u: iris.cube.Cube,
1109 cube_v: iris.cube.Cube,
1110 filename: str,
1111 title: str,
1112 method: Literal["contourf", "pcolormesh"],
1113 **kwargs,
1114):
1115 """Plot and save a 2D vector plot.
1117 Parameters
1118 ----------
1119 cube_u: Cube
1120 2 dimensional Cube of u component of the data.
1121 cube_v: Cube
1122 2 dimensional Cube of v component of the data.
1123 filename: str
1124 Filename of the plot to write.
1125 title: str
1126 Plot title.
1127 """
1128 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
1130 # Create a cube containing the magnitude of the vector field.
1131 cube_vec_mag = (cube_u**2 + cube_v**2) ** 0.5
1132 cube_vec_mag.rename(f"{cube_u.name()}_{cube_v.name()}_magnitude")
1134 # Specify the color bar
1135 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag)
1137 # Setup plot map projection, extent and coastlines.
1138 axes = _setup_spatial_map(cube_vec_mag, fig, cmap)
1140 if method == "contourf": 1140 ↛ 1143line 1140 didn't jump to line 1143 because the condition on line 1140 was always true
1141 # Filled contour plot of the field.
1142 plot = iplt.contourf(cube_vec_mag, cmap=cmap, levels=levels, norm=norm)
1143 elif method == "pcolormesh":
1144 try:
1145 vmin = min(levels)
1146 vmax = max(levels)
1147 except TypeError:
1148 vmin, vmax = None, None
1149 # pcolormesh plot of the field and ensure to use norm and not vmin/vmax
1150 # if levels are defined.
1151 if norm is not None:
1152 vmin = None
1153 vmax = None
1154 plot = iplt.pcolormesh(cube_vec_mag, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax)
1155 else:
1156 raise ValueError(f"Unknown plotting method: {method}")
1158 # Check to see if transect, and if so, adjust y axis.
1159 if is_transect(cube_vec_mag): 1159 ↛ 1160line 1159 didn't jump to line 1160 because the condition on line 1159 was never true
1160 if "pressure" in [coord.name() for coord in cube_vec_mag.coords()]:
1161 axes.invert_yaxis()
1162 axes.set_yscale("log")
1163 axes.set_ylim(1100, 100)
1164 # If both model_level_number and level_height exists, iplt can construct
1165 # plot as a function of height above orography (NOT sea level).
1166 elif {"model_level_number", "level_height"}.issubset(
1167 {coord.name() for coord in cube_vec_mag.coords()}
1168 ):
1169 axes.set_yscale("log")
1171 axes.set_title(
1172 f"{title}\n"
1173 f"Start Lat: {cube_vec_mag.attributes['transect_coords'].split('_')[0]}"
1174 f" Start Lon: {cube_vec_mag.attributes['transect_coords'].split('_')[1]}"
1175 f" End Lat: {cube_vec_mag.attributes['transect_coords'].split('_')[2]}"
1176 f" End Lon: {cube_vec_mag.attributes['transect_coords'].split('_')[3]}",
1177 fontsize=16,
1178 )
1180 else:
1181 # Add title.
1182 axes.set_title(title, fontsize=16)
1184 # Add watermark with min/max/mean. Currently not user togglable.
1185 # In the bbox dictionary, fc and ec are hex colour codes for grey shade.
1186 axes.annotate(
1187 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}",
1188 xy=(1, -0.05),
1189 xycoords="axes fraction",
1190 xytext=(-5, 5),
1191 textcoords="offset points",
1192 ha="right",
1193 va="bottom",
1194 size=11,
1195 bbox=dict(boxstyle="round", fc="#cccccc", ec="#808080", alpha=0.9),
1196 )
1198 # Add colour bar.
1199 cbar = fig.colorbar(plot, orientation="horizontal", pad=0.042, shrink=0.7)
1200 cbar.set_label(label=f"{cube_vec_mag.name()} ({cube_vec_mag.units})", size=14)
1201 # add ticks and tick_labels for every levels if less than 20 levels exist
1202 if levels is not None and len(levels) < 20: 1202 ↛ 1203line 1202 didn't jump to line 1203 because the condition on line 1202 was never true
1203 cbar.set_ticks(levels)
1204 cbar.set_ticklabels([f"{level:.1f}" for level in levels])
1206 # 30 barbs along the longest axis of the plot, or a barb per point for data
1207 # with less than 30 points.
1208 step = max(max(cube_u.shape) // 30, 1)
1209 iplt.quiver(cube_u[::step, ::step], cube_v[::step, ::step], pivot="middle")
1211 # Save plot.
1212 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1213 logging.info("Saved vector plot to %s", filename)
1214 plt.close(fig)
1217def _plot_and_save_histogram_series(
1218 cubes: iris.cube.Cube | iris.cube.CubeList,
1219 filename: str,
1220 title: str,
1221 vmin: float,
1222 vmax: float,
1223 **kwargs,
1224):
1225 """Plot and save a histogram series.
1227 Parameters
1228 ----------
1229 cubes: Cube or CubeList
1230 2 dimensional Cube or CubeList of the data to plot as histogram.
1231 filename: str
1232 Filename of the plot to write.
1233 title: str
1234 Plot title.
1235 vmin: float
1236 minimum for colorbar
1237 vmax: float
1238 maximum for colorbar
1239 """
1240 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
1241 ax = plt.gca()
1243 model_colors_map = _get_model_colors_map(cubes)
1245 # Set default that histograms will produce probability density function
1246 # at each bin (integral over range sums to 1).
1247 density = True
1249 for cube in iter_maybe(cubes):
1250 # Easier to check title (where var name originates)
1251 # than seeing if long names exist etc.
1252 # Exception case, where distribution better fits log scales/bins.
1253 if "surface_microphysical" in title:
1254 if "amount" in title: 1254 ↛ 1256line 1254 didn't jump to line 1256 because the condition on line 1254 was never true
1255 # Compute histogram following Klingaman et al. (2017): ASoP
1256 bin2 = np.exp(np.log(0.02) + 0.1 * np.linspace(0, 99, 100))
1257 bins = np.pad(bin2, (1, 0), "constant", constant_values=0)
1258 density = False
1259 else:
1260 bins = 10.0 ** (
1261 np.arange(-10, 27, 1) / 10.0
1262 ) # Suggestion from RMED toolbox.
1263 bins = np.insert(bins, 0, 0)
1264 ax.set_yscale("log")
1265 vmin = bins[1]
1266 vmax = bins[-1] # Manually set vmin/vmax to override json derived value.
1267 ax.set_xscale("log")
1268 elif "lightning" in title:
1269 bins = [0, 1, 2, 3, 4, 5]
1270 else:
1271 bins = np.linspace(vmin, vmax, 51)
1272 logging.debug(
1273 "Plotting histogram with %s bins %s - %s.",
1274 np.size(bins),
1275 np.min(bins),
1276 np.max(bins),
1277 )
1279 # Reshape cube data into a single array to allow for a single histogram.
1280 # Otherwise we plot xdim histograms stacked.
1281 cube_data_1d = (cube.data).flatten()
1283 label = None
1284 color = "black"
1285 if model_colors_map: 1285 ↛ 1286line 1285 didn't jump to line 1286 because the condition on line 1285 was never true
1286 label = cube.attributes.get("model_name")
1287 color = model_colors_map[label]
1288 x, y = np.histogram(cube_data_1d, bins=bins, density=density)
1290 # Compute area under curve.
1291 if "surface_microphysical" in title and "amount" in title: 1291 ↛ 1292line 1291 didn't jump to line 1292 because the condition on line 1291 was never true
1292 bin_mean = (bins[:-1] + bins[1:]) / 2.0
1293 x = x * bin_mean / x.sum()
1294 x = x[1:]
1295 y = y[1:]
1297 ax.plot(
1298 y[:-1], x, color=color, linewidth=3, marker="o", markersize=6, label=label
1299 )
1301 # Add some labels and tweak the style.
1302 ax.set_title(title, fontsize=16)
1303 ax.set_xlabel(
1304 f"{iter_maybe(cubes)[0].name()} / {iter_maybe(cubes)[0].units}", fontsize=14
1305 )
1306 ax.set_ylabel("Normalised probability density", fontsize=14)
1307 if "surface_microphysical" in title and "amount" in title: 1307 ↛ 1308line 1307 didn't jump to line 1308 because the condition on line 1307 was never true
1308 ax.set_ylabel(
1309 f"Contribution to mean ({iter_maybe(cubes)[0].units})", fontsize=14
1310 )
1311 ax.set_xlim(vmin, vmax)
1312 ax.tick_params(axis="both", labelsize=12)
1314 # Overlay grid-lines onto histogram plot.
1315 ax.grid(linestyle="--", color="grey", linewidth=1)
1316 if model_colors_map: 1316 ↛ 1317line 1316 didn't jump to line 1317 because the condition on line 1316 was never true
1317 ax.legend(loc="best", ncol=1, frameon=False, fontsize=16)
1319 # Save plot.
1320 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1321 logging.info("Saved line plot to %s", filename)
1322 plt.close(fig)
1325def _plot_and_save_postage_stamp_histogram_series(
1326 cube: iris.cube.Cube,
1327 filename: str,
1328 title: str,
1329 stamp_coordinate: str,
1330 vmin: float,
1331 vmax: float,
1332 **kwargs,
1333):
1334 """Plot and save postage (ensemble members) stamps for a histogram series.
1336 Parameters
1337 ----------
1338 cube: Cube
1339 2 dimensional Cube of the data to plot as histogram.
1340 filename: str
1341 Filename of the plot to write.
1342 title: str
1343 Plot title.
1344 stamp_coordinate: str
1345 Coordinate that becomes different plots.
1346 vmin: float
1347 minimum for pdf x-axis
1348 vmax: float
1349 maximum for pdf x-axis
1350 """
1351 # Use the smallest square grid that will fit the members.
1352 grid_size = int(math.ceil(math.sqrt(len(cube.coord(stamp_coordinate).points))))
1354 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
1355 # Make a subplot for each member.
1356 for member, subplot in zip(
1357 cube.slices_over(stamp_coordinate), range(1, grid_size**2 + 1), strict=False
1358 ):
1359 # Implicit interface is much easier here, due to needing to have the
1360 # cartopy GeoAxes generated.
1361 plt.subplot(grid_size, grid_size, subplot)
1362 # Reshape cube data into a single array to allow for a single histogram.
1363 # Otherwise we plot xdim histograms stacked.
1364 member_data_1d = (member.data).flatten()
1365 plt.hist(member_data_1d, density=True, stacked=True)
1366 ax = plt.gca()
1367 ax.set_title(f"Member #{member.coord(stamp_coordinate).points[0]}")
1368 ax.set_xlim(vmin, vmax)
1370 # Overall figure title.
1371 fig.suptitle(title, fontsize=16)
1373 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1374 logging.info("Saved histogram postage stamp plot to %s", filename)
1375 plt.close(fig)
1378def _plot_and_save_postage_stamps_in_single_plot_histogram_series(
1379 cube: iris.cube.Cube,
1380 filename: str,
1381 title: str,
1382 stamp_coordinate: str,
1383 vmin: float,
1384 vmax: float,
1385 **kwargs,
1386):
1387 fig, ax = plt.subplots(figsize=(10, 10), facecolor="w", edgecolor="k")
1388 ax.set_title(title, fontsize=16)
1389 ax.set_xlim(vmin, vmax)
1390 ax.set_xlabel(f"{cube.name()} / {cube.units}", fontsize=14)
1391 ax.set_ylabel("normalised probability density", fontsize=14)
1392 # Loop over all slices along the stamp_coordinate
1393 for member in cube.slices_over(stamp_coordinate):
1394 # Flatten the member data to 1D
1395 member_data_1d = member.data.flatten()
1396 # Plot the histogram using plt.hist
1397 plt.hist(
1398 member_data_1d,
1399 density=True,
1400 stacked=True,
1401 label=f"Member #{member.coord(stamp_coordinate).points[0]}",
1402 )
1404 # Add a legend
1405 ax.legend(fontsize=16)
1407 # Save the figure to a file
1408 plt.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1410 # Close the figure
1411 plt.close(fig)
1414def _plot_and_save_scattermap_plot(
1415 cube: iris.cube.Cube, filename: str, title: str, projection=None, **kwargs
1416):
1417 """Plot and save a geographical scatter plot.
1419 Parameters
1420 ----------
1421 cube: Cube
1422 1 dimensional Cube of the data points with auxiliary latitude and
1423 longitude coordinates,
1424 filename: str
1425 Filename of the plot to write.
1426 title: str
1427 Plot title.
1428 projection: str
1429 Mapping projection to be used by cartopy.
1430 """
1431 # Setup plot details, size, resolution, etc.
1432 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
1433 if projection is not None:
1434 # Apart from the default, the only projection we currently support is
1435 # a stereographic projection over the North Pole.
1436 if projection == "NP_Stereo":
1437 axes = plt.axes(projection=ccrs.NorthPolarStereo(central_longitude=0.0))
1438 else:
1439 raise ValueError(f"Unknown projection: {projection}")
1440 else:
1441 axes = plt.axes(projection=ccrs.PlateCarree())
1443 # Scatter plot of the field. The marker size is chosen to give
1444 # symbols that decrease in size as the number of observations
1445 # increases, although the fraction of the figure covered by
1446 # symbols increases roughly as N^(1/2), disregarding overlaps,
1447 # and has been selected for the default figure size of (10, 10).
1448 # Should this be changed, the marker size should be adjusted in
1449 # proportion to the area of the figure.
1450 mrk_size = int(np.sqrt(2500000.0 / len(cube.data)))
1451 klon = None
1452 klat = None
1453 for kc in range(len(cube.aux_coords)):
1454 if cube.aux_coords[kc].standard_name == "latitude":
1455 klat = kc
1456 elif cube.aux_coords[kc].standard_name == "longitude":
1457 klon = kc
1458 scatter_map = iplt.scatter(
1459 cube.aux_coords[klon],
1460 cube.aux_coords[klat],
1461 c=cube.data[:],
1462 s=mrk_size,
1463 cmap="jet",
1464 edgecolors="k",
1465 )
1467 # Add coastlines.
1468 try:
1469 axes.coastlines(resolution="10m")
1470 except AttributeError:
1471 pass
1473 # Add title.
1474 axes.set_title(title, fontsize=16)
1476 # Add colour bar.
1477 cbar = fig.colorbar(scatter_map)
1478 cbar.set_label(label=f"{cube.name()} ({cube.units})", size=20)
1480 # Save plot.
1481 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1482 logging.info("Saved geographical scatter plot to %s", filename)
1483 plt.close(fig)
1486def _spatial_plot(
1487 method: Literal["contourf", "pcolormesh"],
1488 cube: iris.cube.Cube,
1489 filename: str | None,
1490 sequence_coordinate: str,
1491 stamp_coordinate: str,
1492 **kwargs,
1493):
1494 """Plot a spatial variable onto a map from a 2D, 3D, or 4D cube.
1496 A 2D spatial field can be plotted, but if the sequence_coordinate is present
1497 then a sequence of plots will be produced. Similarly if the stamp_coordinate
1498 is present then postage stamp plots will be produced.
1500 Parameters
1501 ----------
1502 method: "contourf" | "pcolormesh"
1503 The plotting method to use.
1504 cube: Cube
1505 Iris cube of the data to plot. It should have two spatial dimensions,
1506 such as lat and lon, and may also have a another two dimension to be
1507 plotted sequentially and/or as postage stamp plots.
1508 filename: str | None
1509 Name of the plot to write, used as a prefix for plot sequences. If None
1510 uses the recipe name.
1511 sequence_coordinate: str
1512 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
1513 This coordinate must exist in the cube.
1514 stamp_coordinate: str
1515 Coordinate about which to plot postage stamp plots. Defaults to
1516 ``"realization"``.
1518 Raises
1519 ------
1520 ValueError
1521 If the cube doesn't have the right dimensions.
1522 TypeError
1523 If the cube isn't a single cube.
1524 """
1525 recipe_title = get_recipe_metadata().get("title", "Untitled")
1527 # Ensure we have a name for the plot file.
1528 if filename is None:
1529 filename = slugify(recipe_title)
1531 # Ensure we've got a single cube.
1532 cube = _check_single_cube(cube)
1534 # Make postage stamp plots if stamp_coordinate exists and has more than a
1535 # single point.
1536 plotting_func = _plot_and_save_spatial_plot
1537 try:
1538 if cube.coord(stamp_coordinate).shape[0] > 1:
1539 plotting_func = _plot_and_save_postage_stamp_spatial_plot
1540 except iris.exceptions.CoordinateNotFoundError:
1541 pass
1543 # Produce a geographical scatter plot if the data have a
1544 # dimension called observation or model_obs_error
1545 if any( 1545 ↛ 1549line 1545 didn't jump to line 1549 because the condition on line 1545 was never true
1546 crd.var_name == "station" or crd.var_name == "model_obs_error"
1547 for crd in cube.coords()
1548 ):
1549 plotting_func = _plot_and_save_scattermap_plot
1551 # Must have a sequence coordinate.
1552 try:
1553 cube.coord(sequence_coordinate)
1554 except iris.exceptions.CoordinateNotFoundError as err:
1555 raise ValueError(f"Cube must have a {sequence_coordinate} coordinate.") from err
1557 # Create a plot for each value of the sequence coordinate.
1558 plot_index = []
1559 nplot = np.size(cube.coord(sequence_coordinate).points)
1560 for cube_slice in cube.slices_over(sequence_coordinate):
1561 # Use sequence value so multiple sequences can merge.
1562 sequence_value = cube_slice.coord(sequence_coordinate).points[0]
1563 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png"
1564 coord = cube_slice.coord(sequence_coordinate)
1565 # Format the coordinate value in a unit appropriate way.
1566 title = f"{recipe_title}\n [{coord.units.title(coord.points[0])}]"
1567 # Use sequence (e.g. time) bounds if plotting single non-sequence outputs
1568 if nplot == 1 and coord.has_bounds:
1569 if np.size(coord.bounds) > 1:
1570 title = f"{recipe_title}\n [{coord.units.title(coord.bounds[0][0])} to {coord.units.title(coord.bounds[0][1])}]"
1571 # Do the actual plotting.
1572 plotting_func(
1573 cube_slice,
1574 filename=plot_filename,
1575 stamp_coordinate=stamp_coordinate,
1576 title=title,
1577 method=method,
1578 **kwargs,
1579 )
1580 plot_index.append(plot_filename)
1582 # Add list of plots to plot metadata.
1583 complete_plot_index = _append_to_plot_index(plot_index)
1585 # Make a page to display the plots.
1586 _make_plot_html_page(complete_plot_index)
1589def _custom_colormap_mask(cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None):
1590 """Get colourmap for mask.
1592 If "mask_for_" appears anywhere in the name of a cube this function will be called
1593 regardless of the name of the variable to ensure a consistent plot.
1595 Parameters
1596 ----------
1597 cube: Cube
1598 Cube of variable for which the colorbar information is desired.
1599 axis: "x", "y", optional
1600 Select the levels for just this axis of a line plot. The min and max
1601 can be set by xmin/xmax or ymin/ymax respectively. For variables where
1602 setting a universal range is not desirable (e.g. temperature), users
1603 can set ymin/ymax values to "auto" in the colorbar definitions file.
1604 Where no additional xmin/xmax or ymin/ymax values are provided, the
1605 axis bounds default to use the vmin/vmax values provided.
1607 Returns
1608 -------
1609 cmap:
1610 Matplotlib colormap.
1611 levels:
1612 List of levels to use for plotting. For continuous plots the min and max
1613 should be taken as the range.
1614 norm:
1615 BoundaryNorm information.
1616 """
1617 if "difference" not in cube.long_name:
1618 if axis:
1619 levels = [0, 1]
1620 # Complete settings based on levels.
1621 return None, levels, None
1622 else:
1623 # Define the levels and colors.
1624 levels = [0, 1, 2]
1625 colors = ["white", "dodgerblue"]
1626 # Create a custom color map.
1627 cmap = mcolors.ListedColormap(colors)
1628 # Normalize the levels.
1629 norm = mcolors.BoundaryNorm(levels, cmap.N)
1630 logging.debug("Colourmap for %s.", cube.long_name)
1631 return cmap, levels, norm
1632 else:
1633 if axis:
1634 levels = [-1, 1]
1635 return None, levels, None
1636 else:
1637 # Search for if mask difference, set to +/- 0.5 as values plotted <
1638 # not <=.
1639 levels = [-2, -0.5, 0.5, 2]
1640 colors = ["goldenrod", "white", "teal"]
1641 cmap = mcolors.ListedColormap(colors)
1642 norm = mcolors.BoundaryNorm(levels, cmap.N)
1643 logging.debug("Colourmap for %s.", cube.long_name)
1644 return cmap, levels, norm
1647def _custom_beaufort_scale(cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None):
1648 """Get a custom colorbar for a cube in the Beaufort Scale.
1650 Specific variable ranges can be separately set in user-supplied definition
1651 for x- or y-axis limits, or indicate where automated range preferred.
1653 Parameters
1654 ----------
1655 cube: Cube
1656 Cube of variable with Beaufort Scale in name.
1657 axis: "x", "y", optional
1658 Select the levels for just this axis of a line plot. The min and max
1659 can be set by xmin/xmax or ymin/ymax respectively. For variables where
1660 setting a universal range is not desirable (e.g. temperature), users
1661 can set ymin/ymax values to "auto" in the colorbar definitions file.
1662 Where no additional xmin/xmax or ymin/ymax values are provided, the
1663 axis bounds default to use the vmin/vmax values provided.
1665 Returns
1666 -------
1667 cmap:
1668 Matplotlib colormap.
1669 levels:
1670 List of levels to use for plotting. For continuous plots the min and max
1671 should be taken as the range.
1672 norm:
1673 BoundaryNorm information.
1674 """
1675 if "difference" not in cube.long_name:
1676 if axis:
1677 levels = [0, 12]
1678 return None, levels, None
1679 else:
1680 levels = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]
1681 colors = [
1682 "black",
1683 (0, 0, 0.6),
1684 "blue",
1685 "cyan",
1686 "green",
1687 "yellow",
1688 (1, 0.5, 0),
1689 "red",
1690 "pink",
1691 "magenta",
1692 "purple",
1693 "maroon",
1694 "white",
1695 ]
1696 cmap = mcolors.ListedColormap(colors)
1697 norm = mcolors.BoundaryNorm(levels, cmap.N)
1698 logging.info("change colormap for Beaufort Scale colorbar.")
1699 return cmap, levels, norm
1700 else:
1701 if axis:
1702 levels = [-4, 4]
1703 return None, levels, None
1704 else:
1705 levels = [
1706 -3.5,
1707 -2.5,
1708 -1.5,
1709 -0.5,
1710 0.5,
1711 1.5,
1712 2.5,
1713 3.5,
1714 ]
1715 cmap = plt.get_cmap("bwr", 8)
1716 norm = mcolors.BoundaryNorm(levels, cmap.N)
1717 return cmap, levels, norm
1720def _custom_colormap_celsius(cube: iris.cube.Cube, cmap, levels, norm):
1721 """Return altered colourmap for temperature with change in units to Celsius.
1723 If "Celsius" appears anywhere in the name of a cube this function will be called.
1725 Parameters
1726 ----------
1727 cube: Cube
1728 Cube of variable for which the colorbar information is desired.
1729 cmap: Matplotlib colormap.
1730 levels: List
1731 List of levels to use for plotting. For continuous plots the min and max
1732 should be taken as the range.
1733 norm: BoundaryNorm.
1735 Returns
1736 -------
1737 cmap: Matplotlib colormap.
1738 levels: List
1739 List of levels to use for plotting. For continuous plots the min and max
1740 should be taken as the range.
1741 norm: BoundaryNorm.
1742 """
1743 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name])
1744 if any("temperature" in name for name in varnames) and "Celsius" == cube.units:
1745 levels = np.array(levels)
1746 levels -= 273
1747 levels = levels.tolist()
1748 else:
1749 # Do nothing keep the existing colourbar attributes
1750 levels = levels
1751 cmap = cmap
1752 norm = norm
1753 return cmap, levels, norm
1756def _custom_colormap_probability(
1757 cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None
1758):
1759 """Get a custom colorbar for a probability cube.
1761 Specific variable ranges can be separately set in user-supplied definition
1762 for x- or y-axis limits, or indicate where automated range preferred.
1764 Parameters
1765 ----------
1766 cube: Cube
1767 Cube of variable with probability in name.
1768 axis: "x", "y", optional
1769 Select the levels for just this axis of a line plot. The min and max
1770 can be set by xmin/xmax or ymin/ymax respectively. For variables where
1771 setting a universal range is not desirable (e.g. temperature), users
1772 can set ymin/ymax values to "auto" in the colorbar definitions file.
1773 Where no additional xmin/xmax or ymin/ymax values are provided, the
1774 axis bounds default to use the vmin/vmax values provided.
1776 Returns
1777 -------
1778 cmap:
1779 Matplotlib colormap.
1780 levels:
1781 List of levels to use for plotting. For continuous plots the min and max
1782 should be taken as the range.
1783 norm:
1784 BoundaryNorm information.
1785 """
1786 if axis:
1787 levels = [0, 1]
1788 return None, levels, None
1789 else:
1790 cmap = mcolors.ListedColormap(
1791 [
1792 "#FFFFFF",
1793 "#636363",
1794 "#e1dada",
1795 "#B5CAFF",
1796 "#8FB3FF",
1797 "#7F97FF",
1798 "#ABCF63",
1799 "#E8F59E",
1800 "#FFFA14",
1801 "#FFD121",
1802 "#FFA30A",
1803 ]
1804 )
1805 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]
1806 norm = mcolors.BoundaryNorm(levels, cmap.N)
1807 return cmap, levels, norm
1810def _custom_colourmap_precipitation(cube: iris.cube.Cube, cmap, levels, norm):
1811 """Return a custom colourmap for the current recipe."""
1812 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name])
1813 if (
1814 any("surface_microphysical" in name for name in varnames)
1815 and "difference" not in cube.long_name
1816 and "mask" not in cube.long_name
1817 ):
1818 # Define the levels and colors
1819 levels = [0, 0.125, 0.25, 0.5, 1, 2, 4, 8, 16, 32, 64, 128, 256]
1820 colors = [
1821 "w",
1822 (0, 0, 0.6),
1823 "b",
1824 "c",
1825 "g",
1826 "y",
1827 (1, 0.5, 0),
1828 "r",
1829 "pink",
1830 "m",
1831 "purple",
1832 "maroon",
1833 "gray",
1834 ]
1835 # Create a custom colormap
1836 cmap = mcolors.ListedColormap(colors)
1837 # Normalize the levels
1838 norm = mcolors.BoundaryNorm(levels, cmap.N)
1839 logging.info("change colormap for surface_microphysical variable colorbar.")
1840 else:
1841 # do nothing and keep existing colorbar attributes
1842 cmap = cmap
1843 levels = levels
1844 norm = norm
1845 return cmap, levels, norm
1848def _custom_colormap_aviation_colour_state(cube: iris.cube.Cube):
1849 """Return custom colourmap for aviation colour state.
1851 If "aviation_colour_state" appears anywhere in the name of a cube
1852 this function will be called.
1854 Parameters
1855 ----------
1856 cube: Cube
1857 Cube of variable for which the colorbar information is desired.
1859 Returns
1860 -------
1861 cmap: Matplotlib colormap.
1862 levels: List
1863 List of levels to use for plotting. For continuous plots the min and max
1864 should be taken as the range.
1865 norm: BoundaryNorm.
1866 """
1867 levels = [-0.5, 0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5]
1868 colors = [
1869 "#87ceeb",
1870 "#ffffff",
1871 "#8ced69",
1872 "#ffff00",
1873 "#ffd700",
1874 "#ffa500",
1875 "#fe3620",
1876 ]
1877 # Create a custom colormap
1878 cmap = mcolors.ListedColormap(colors)
1879 # Normalise the levels
1880 norm = mcolors.BoundaryNorm(levels, cmap.N)
1881 return cmap, levels, norm
1884def _custom_colourmap_visibility_in_air(cube: iris.cube.Cube, cmap, levels, norm):
1885 """Return a custom colourmap for the current recipe."""
1886 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name])
1887 if (
1888 any("visibility_in_air" in name for name in varnames)
1889 and "difference" not in cube.long_name
1890 and "mask" not in cube.long_name
1891 ):
1892 # Define the levels and colors (in km)
1893 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]
1894 norm = mcolors.BoundaryNorm(levels, cmap.N)
1895 colours = [
1896 "#8f00d6",
1897 "#d10000",
1898 "#ff9700",
1899 "#ffff00",
1900 "#00007f",
1901 "#6c9ccd",
1902 "#aae8ff",
1903 "#37a648",
1904 "#8edc64",
1905 "#c5ffc5",
1906 "#dcdcdc",
1907 "#ffffff",
1908 ]
1909 # Create a custom colormap
1910 cmap = mcolors.ListedColormap(colours)
1911 # Normalize the levels
1912 norm = mcolors.BoundaryNorm(levels, cmap.N)
1913 logging.info("change colormap for visibility_in_air variable colorbar.")
1914 else:
1915 # do nothing and keep existing colorbar attributes
1916 cmap = cmap
1917 levels = levels
1918 norm = norm
1919 return cmap, levels, norm
1922def _get_num_models(cube: iris.cube.Cube | iris.cube.CubeList) -> int:
1923 """Return number of models based on cube attributes."""
1924 model_names = list(
1925 filter(
1926 lambda x: x is not None,
1927 {cb.attributes.get("model_name", None) for cb in iter_maybe(cube)},
1928 )
1929 )
1930 if not model_names:
1931 logging.debug("Missing model names. Will assume single model.")
1932 return 1
1933 else:
1934 return len(model_names)
1937def _validate_cube_shape(
1938 cube: iris.cube.Cube | iris.cube.CubeList, num_models: int
1939) -> None:
1940 """Check all cubes have a model name."""
1941 if isinstance(cube, iris.cube.CubeList) and len(cube) != num_models: 1941 ↛ 1942line 1941 didn't jump to line 1942 because the condition on line 1941 was never true
1942 raise ValueError(
1943 f"The number of model names ({num_models}) should equal the number "
1944 f"of cubes ({len(cube)})."
1945 )
1948def _validate_cubes_coords(
1949 cubes: iris.cube.CubeList, coords: list[iris.coords.Coord]
1950) -> None:
1951 """Check same number of cubes as sequence coordinate for zip functions."""
1952 if len(cubes) != len(coords): 1952 ↛ 1953line 1952 didn't jump to line 1953 because the condition on line 1952 was never true
1953 raise ValueError(
1954 f"The number of CubeList entries ({len(cubes)}) should equal the number "
1955 f"of sequence coordinates ({len(coords)})."
1956 f"Check that number of time entries in input data are consistent if "
1957 f"performing time-averaging steps prior to plotting outputs."
1958 )
1961####################
1962# Public functions #
1963####################
1966def spatial_contour_plot(
1967 cube: iris.cube.Cube,
1968 filename: str = None,
1969 sequence_coordinate: str = "time",
1970 stamp_coordinate: str = "realization",
1971 **kwargs,
1972) -> iris.cube.Cube:
1973 """Plot a spatial variable onto a map from a 2D, 3D, or 4D cube.
1975 A 2D spatial field can be plotted, but if the sequence_coordinate is present
1976 then a sequence of plots will be produced. Similarly if the stamp_coordinate
1977 is present then postage stamp plots will be produced.
1979 Parameters
1980 ----------
1981 cube: Cube
1982 Iris cube of the data to plot. It should have two spatial dimensions,
1983 such as lat and lon, and may also have a another two dimension to be
1984 plotted sequentially and/or as postage stamp plots.
1985 filename: str, optional
1986 Name of the plot to write, used as a prefix for plot sequences. Defaults
1987 to the recipe name.
1988 sequence_coordinate: str, optional
1989 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
1990 This coordinate must exist in the cube.
1991 stamp_coordinate: str, optional
1992 Coordinate about which to plot postage stamp plots. Defaults to
1993 ``"realization"``.
1995 Returns
1996 -------
1997 Cube
1998 The original cube (so further operations can be applied).
2000 Raises
2001 ------
2002 ValueError
2003 If the cube doesn't have the right dimensions.
2004 TypeError
2005 If the cube isn't a single cube.
2006 """
2007 _spatial_plot(
2008 "contourf", cube, filename, sequence_coordinate, stamp_coordinate, **kwargs
2009 )
2010 return cube
2013def spatial_pcolormesh_plot(
2014 cube: iris.cube.Cube,
2015 filename: str = None,
2016 sequence_coordinate: str = "time",
2017 stamp_coordinate: str = "realization",
2018 **kwargs,
2019) -> iris.cube.Cube:
2020 """Plot a spatial variable onto a map from a 2D, 3D, or 4D cube.
2022 A 2D spatial field can be plotted, but if the sequence_coordinate is present
2023 then a sequence of plots will be produced. Similarly if the stamp_coordinate
2024 is present then postage stamp plots will be produced.
2026 This function is significantly faster than ``spatial_contour_plot``,
2027 especially at high resolutions, and should be preferred unless contiguous
2028 contour areas are important.
2030 Parameters
2031 ----------
2032 cube: Cube
2033 Iris cube of the data to plot. It should have two spatial dimensions,
2034 such as lat and lon, and may also have a another two dimension to be
2035 plotted sequentially and/or as postage stamp plots.
2036 filename: str, optional
2037 Name of the plot to write, used as a prefix for plot sequences. Defaults
2038 to the recipe name.
2039 sequence_coordinate: str, optional
2040 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
2041 This coordinate must exist in the cube.
2042 stamp_coordinate: str, optional
2043 Coordinate about which to plot postage stamp plots. Defaults to
2044 ``"realization"``.
2046 Returns
2047 -------
2048 Cube
2049 The original cube (so further operations can be applied).
2051 Raises
2052 ------
2053 ValueError
2054 If the cube doesn't have the right dimensions.
2055 TypeError
2056 If the cube isn't a single cube.
2057 """
2058 _spatial_plot(
2059 "pcolormesh", cube, filename, sequence_coordinate, stamp_coordinate, **kwargs
2060 )
2061 return cube
2064# TODO: Expand function to handle ensemble data.
2065# line_coordinate: str, optional
2066# Coordinate about which to plot multiple lines. Defaults to
2067# ``"realization"``.
2068def plot_line_series(
2069 cube: iris.cube.Cube | iris.cube.CubeList,
2070 filename: str = None,
2071 series_coordinate: str = "time",
2072 sequence_coordinate: str = "time",
2073 # line_coordinate: str = "realization",
2074 **kwargs,
2075) -> iris.cube.Cube | iris.cube.CubeList:
2076 """Plot a line plot for the specified coordinate.
2078 The Cube or CubeList must be 1D.
2080 Parameters
2081 ----------
2082 iris.cube | iris.cube.CubeList
2083 Cube or CubeList of the data to plot. The individual cubes should have a single dimension.
2084 The cubes should cover the same phenomenon i.e. all cubes contain temperature data.
2085 We do not support different data such as temperature and humidity in the same CubeList for plotting.
2086 filename: str, optional
2087 Name of the plot to write, used as a prefix for plot sequences. Defaults
2088 to the recipe name.
2089 series_coordinate: str, optional
2090 Coordinate about which to make a series. Defaults to ``"time"``. This
2091 coordinate must exist in the cube.
2093 Returns
2094 -------
2095 iris.cube.Cube | iris.cube.CubeList
2096 The original Cube or CubeList (so further operations can be applied).
2097 plotted data.
2099 Raises
2100 ------
2101 ValueError
2102 If the cubes don't have the right dimensions.
2103 TypeError
2104 If the cube isn't a Cube or CubeList.
2105 """
2106 # Ensure we have a name for the plot file.
2107 recipe_title = get_recipe_metadata().get("title", "Untitled")
2109 if filename is None:
2110 filename = slugify(recipe_title)
2112 # Add file extension. This may be overwritten later on.
2113 plot_filename = f"{filename.rsplit('.', 1)[0]}.png"
2115 num_models = _get_num_models(cube)
2117 _validate_cube_shape(cube, num_models)
2119 # Iterate over all cubes and extract coordinate to plot.
2120 cubes = iter_maybe(cube)
2122 coords = []
2123 for cube in cubes:
2124 try:
2125 coords.append(cube.coord(series_coordinate))
2126 except iris.exceptions.CoordinateNotFoundError as err:
2127 raise ValueError(
2128 f"Cube must have a {series_coordinate} coordinate."
2129 ) from err
2130 if cube.ndim > 2 or not cube.coords("realization"):
2131 raise ValueError("Cube must be 1D or 2D with a realization coordinate.")
2133 plot_index = []
2134 if series_coordinate == "time":
2135 # Do the actual plotting for timeseries.
2136 _plot_and_save_line_series(
2137 cubes, coords, "realization", plot_filename, recipe_title
2138 )
2140 plot_index.append(plot_filename)
2141 else:
2142 # If series coordinate is not time, for example power spectra with series
2143 # coordinate frequency/wavelength.
2144 # If several power spectra are plotted with time as sequence_coordinate for the
2145 # time slider option.
2146 for cube in cubes:
2147 try:
2148 cube.coord(sequence_coordinate)
2149 except iris.exceptions.CoordinateNotFoundError as err:
2150 raise ValueError(
2151 f"Cube must have a {sequence_coordinate} coordinate."
2152 ) from err
2154 if num_models == 1: 2154 ↛ 2157line 2154 didn't jump to line 2157 because the condition on line 2154 was always true
2155 cube_iterables = cubes[0].slices_over(sequence_coordinate)
2156 else:
2157 all_points = sorted(
2158 set(
2159 itertools.chain.from_iterable(
2160 cb.coord(sequence_coordinate).points for cb in cubes
2161 )
2162 )
2163 )
2164 all_slices = list(
2165 itertools.chain.from_iterable(
2166 cb.slices_over(sequence_coordinate) for cb in cubes
2167 )
2168 )
2169 # Matched slices (matched by seq coord point; it may happen that
2170 # evaluated models do not cover the same seq coord range, hence matching
2171 # necessary)
2172 cube_iterables = [
2173 iris.cube.CubeList(
2174 s
2175 for s in all_slices
2176 if s.coord(sequence_coordinate).points[0] == point
2177 )
2178 for point in all_points
2179 ]
2181 nplot = np.size(cube.coord(sequence_coordinate).points)
2183 # Create a plot for each value of the sequence coordinate. Allowing for
2184 # multiple cubes in a CubeList to be plotted in the same plot for similar
2185 # sequence values. Passing a CubeList into the internal plotting function
2186 # for similar values of the sequence coordinate. cube_slice can be an
2187 # iris.cube.Cube or an iris.cube.CubeList.
2189 for cube_slice in cube_iterables:
2190 # Normalize cube_slice to a list of cubes
2191 if isinstance(cube_slice, iris.cube.CubeList): 2191 ↛ 2192line 2191 didn't jump to line 2192 because the condition on line 2191 was never true
2192 cubes = list(cube_slice)
2193 elif isinstance(cube_slice, iris.cube.Cube): 2193 ↛ 2196line 2193 didn't jump to line 2196 because the condition on line 2193 was always true
2194 cubes = [cube_slice]
2195 else:
2196 raise TypeError(f"Expected Cube or CubeList, got {type(cube_slice)}")
2198 single_cube = cubes[0]
2200 # Use sequence value so multiple sequences can merge.
2201 sequence_value = single_cube.coord(sequence_coordinate).points[0]
2202 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png"
2203 coord = single_cube.coord(sequence_coordinate)
2205 # Format the coordinate value in a unit appropriate way.
2206 title = f"{recipe_title}\n [{coord.units.title(coord.points[0])}]"
2208 # Use sequence (e.g. time) bounds if plotting single non-sequence outputs
2209 if nplot == 1 and coord.has_bounds: 2209 ↛ 2215line 2209 didn't jump to line 2215 because the condition on line 2209 was always true
2210 if np.size(coord.bounds) > 1:
2211 title = f"{recipe_title}\n [{coord.units.title(coord.bounds[0][0])} to {coord.units.title(coord.bounds[0][1])}]"
2213 # Do the actual plotting.
2215 _plot_and_save_line_1D(
2216 cube_slice, coords, "realization", plot_filename, title
2217 )
2219 plot_index.append(plot_filename)
2221 # append plot to list of plots
2222 complete_plot_index = _append_to_plot_index(plot_index)
2224 # Make a page to display the plots.
2225 _make_plot_html_page(complete_plot_index)
2227 return cube
2230def plot_vertical_line_series(
2231 cubes: iris.cube.Cube | iris.cube.CubeList,
2232 filename: str = None,
2233 series_coordinate: str = "model_level_number",
2234 sequence_coordinate: str = "time",
2235 # line_coordinate: str = "realization",
2236 **kwargs,
2237) -> iris.cube.Cube | iris.cube.CubeList:
2238 """Plot a line plot against a type of vertical coordinate.
2240 The Cube or CubeList must be 1D.
2242 A 1D line plot with y-axis as pressure coordinate can be plotted, but if the sequence_coordinate is present
2243 then a sequence of plots will be produced.
2245 Parameters
2246 ----------
2247 iris.cube | iris.cube.CubeList
2248 Cube or CubeList of the data to plot. The individual cubes should have a single dimension.
2249 The cubes should cover the same phenomenon i.e. all cubes contain temperature data.
2250 We do not support different data such as temperature and humidity in the same CubeList for plotting.
2251 filename: str, optional
2252 Name of the plot to write, used as a prefix for plot sequences. Defaults
2253 to the recipe name.
2254 series_coordinate: str, optional
2255 Coordinate to plot on the y-axis. Can be ``pressure`` or
2256 ``model_level_number`` for UM, or ``full_levels`` or ``half_levels``
2257 for LFRic. Defaults to ``model_level_number``.
2258 This coordinate must exist in the cube.
2259 sequence_coordinate: str, optional
2260 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
2261 This coordinate must exist in the cube.
2263 Returns
2264 -------
2265 iris.cube.Cube | iris.cube.CubeList
2266 The original Cube or CubeList (so further operations can be applied).
2267 Plotted data.
2269 Raises
2270 ------
2271 ValueError
2272 If the cubes doesn't have the right dimensions.
2273 TypeError
2274 If the cube isn't a Cube or CubeList.
2275 """
2276 # Ensure we have a name for the plot file.
2277 recipe_title = get_recipe_metadata().get("title", "Untitled")
2279 if filename is None:
2280 filename = slugify(recipe_title)
2282 cubes = iter_maybe(cubes)
2283 # Initialise empty list to hold all data from all cubes in a CubeList
2284 all_data = []
2286 # Store min/max ranges for x range.
2287 x_levels = []
2289 num_models = _get_num_models(cubes)
2291 _validate_cube_shape(cubes, num_models)
2293 # Iterate over all cubes in cube or CubeList and plot.
2294 coords = []
2295 for cube in cubes:
2296 # Test if series coordinate i.e. pressure level exist for any cube with cube.ndim >=1.
2297 try:
2298 coords.append(cube.coord(series_coordinate))
2299 except iris.exceptions.CoordinateNotFoundError as err:
2300 raise ValueError(
2301 f"Cube must have a {series_coordinate} coordinate."
2302 ) from err
2304 try:
2305 if cube.ndim > 1 or not cube.coords("realization"): 2305 ↛ 2313line 2305 didn't jump to line 2313 because the condition on line 2305 was always true
2306 cube.coord(sequence_coordinate)
2307 except iris.exceptions.CoordinateNotFoundError as err:
2308 raise ValueError(
2309 f"Cube must have a {sequence_coordinate} coordinate or be 1D, or 2D with a realization coordinate."
2310 ) from err
2312 # Get minimum and maximum from levels information.
2313 _, levels, _ = _colorbar_map_levels(cube, axis="x")
2314 if levels is not None: 2314 ↛ 2318line 2314 didn't jump to line 2318 because the condition on line 2314 was always true
2315 x_levels.append(min(levels))
2316 x_levels.append(max(levels))
2317 else:
2318 all_data.append(cube.data)
2320 if len(x_levels) == 0: 2320 ↛ 2322line 2320 didn't jump to line 2322 because the condition on line 2320 was never true
2321 # Combine all data into a single NumPy array
2322 combined_data = np.concatenate(all_data)
2324 # Set the lower and upper limit for the x-axis to ensure all plots have
2325 # same range. This needs to read the whole cube over the range of the
2326 # sequence and if applicable postage stamp coordinate.
2327 vmin = np.floor(combined_data.min())
2328 vmax = np.ceil(combined_data.max())
2329 else:
2330 vmin = min(x_levels)
2331 vmax = max(x_levels)
2333 # Matching the slices (matching by seq coord point; it may happen that
2334 # evaluated models do not cover the same seq coord range, hence matching
2335 # necessary)
2336 def filter_cube_iterables(cube_iterables) -> bool:
2337 return len(cube_iterables) == len(coords)
2339 cube_iterables = filter(
2340 filter_cube_iterables,
2341 (
2342 iris.cube.CubeList(
2343 s
2344 for s in itertools.chain.from_iterable(
2345 cb.slices_over(sequence_coordinate) for cb in cubes
2346 )
2347 if s.coord(sequence_coordinate).points[0] == point
2348 )
2349 for point in sorted(
2350 set(
2351 itertools.chain.from_iterable(
2352 cb.coord(sequence_coordinate).points for cb in cubes
2353 )
2354 )
2355 )
2356 ),
2357 )
2359 # Create a plot for each value of the sequence coordinate.
2360 # Allowing for multiple cubes in a CubeList to be plotted in the same plot for
2361 # similar sequence values. Passing a CubeList into the internal plotting function
2362 # for similar values of the sequence coordinate. cube_slice can be an iris.cube.Cube
2363 # or an iris.cube.CubeList.
2364 plot_index = []
2365 nplot = np.size(cubes[0].coord(sequence_coordinate).points)
2366 for cubes_slice in cube_iterables:
2367 # Use sequence value so multiple sequences can merge.
2368 seq_coord = cubes_slice[0].coord(sequence_coordinate)
2369 sequence_value = seq_coord.points[0]
2370 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png"
2371 # Format the coordinate value in a unit appropriate way.
2372 title = f"{recipe_title}\n [{seq_coord.units.title(sequence_value)}]"
2373 # Use sequence (e.g. time) bounds if plotting single non-sequence outputs
2374 if nplot == 1 and seq_coord.has_bounds: 2374 ↛ 2375line 2374 didn't jump to line 2375 because the condition on line 2374 was never true
2375 if np.size(seq_coord.bounds) > 1:
2376 title = f"{recipe_title}\n [{seq_coord.units.title(seq_coord.bounds[0][0])} to {seq_coord.units.title(seq_coord.bounds[0][1])}]"
2377 # Do the actual plotting.
2378 _plot_and_save_vertical_line_series(
2379 cubes_slice,
2380 coords,
2381 "realization",
2382 plot_filename,
2383 series_coordinate,
2384 title=title,
2385 vmin=vmin,
2386 vmax=vmax,
2387 )
2388 plot_index.append(plot_filename)
2390 # Add list of plots to plot metadata.
2391 complete_plot_index = _append_to_plot_index(plot_index)
2393 # Make a page to display the plots.
2394 _make_plot_html_page(complete_plot_index)
2396 return cubes
2399def qq_plot(
2400 cubes: iris.cube.CubeList,
2401 coordinates: list[str],
2402 percentiles: list[float],
2403 model_names: list[str],
2404 filename: str = None,
2405 one_to_one: bool = True,
2406 **kwargs,
2407) -> iris.cube.CubeList:
2408 """Plot a Quantile-Quantile plot between two models for common time points.
2410 The cubes will be normalised by collapsing each cube to its percentiles. Cubes are
2411 collapsed within the operator over all specified coordinates such as
2412 grid_latitude, grid_longitude, vertical levels, but also realisation representing
2413 ensemble members to ensure a 1D cube (array).
2415 Parameters
2416 ----------
2417 cubes: iris.cube.CubeList
2418 Two cubes of the same variable with different models.
2419 coordinate: list[str]
2420 The list of coordinates to collapse over. This list should be
2421 every coordinate within the cube to result in a 1D cube around
2422 the percentile coordinate.
2423 percent: list[float]
2424 A list of percentiles to appear in the plot.
2425 model_names: list[str]
2426 A list of model names to appear on the axis of the plot.
2427 filename: str, optional
2428 Filename of the plot to write.
2429 one_to_one: bool, optional
2430 If True a 1:1 line is plotted; if False it is not. Default is True.
2432 Raises
2433 ------
2434 ValueError
2435 When the cubes are not compatible.
2437 Notes
2438 -----
2439 The quantile-quantile plot is a variant on the scatter plot representing
2440 two datasets by their quantiles (percentiles) for common time points.
2441 This plot does not use a theoretical distribution to compare against, but
2442 compares percentiles of two datasets. This plot does
2443 not use all raw data points, but plots the selected percentiles (quantiles) of
2444 each variable instead for the two datasets, thereby normalising the data for a
2445 direct comparison between the selected percentiles of the two dataset distributions.
2447 Quantile-quantile plots are valuable for comparing against
2448 observations and other models. Identical percentiles between the variables
2449 will lie on the one-to-one line implying the values correspond well to each
2450 other. Where there is a deviation from the one-to-one line a range of
2451 possibilities exist depending on how and where the data is shifted (e.g.,
2452 Wilks 2011 [Wilks2011]_).
2454 For distributions above the one-to-one line the distribution is left-skewed;
2455 below is right-skewed. A distinct break implies a bimodal distribution, and
2456 closer values/values further apart at the tails imply poor representation of
2457 the extremes.
2459 References
2460 ----------
2461 .. [Wilks2011] Wilks, D.S., (2011) "Statistical Methods in the Atmospheric
2462 Sciences" Third Edition, vol. 100, Academic Press, Oxford, UK, 676 pp.
2463 """
2464 # Check cubes using same functionality as the difference operator.
2465 if len(cubes) != 2:
2466 raise ValueError("cubes should contain exactly 2 cubes.")
2467 base: Cube = cubes.extract_cube(iris.AttributeConstraint(cset_comparison_base=1))
2468 other: Cube = cubes.extract_cube(
2469 iris.Constraint(
2470 cube_func=lambda cube: "cset_comparison_base" not in cube.attributes
2471 )
2472 )
2474 # Get spatial coord names.
2475 base_lat_name, base_lon_name = get_cube_yxcoordname(base)
2476 other_lat_name, other_lon_name = get_cube_yxcoordname(other)
2478 # Ensure cubes to compare are on common differencing grid.
2479 # This is triggered if either
2480 # i) latitude and longitude shapes are not the same. Note grid points
2481 # are not compared directly as these can differ through rounding
2482 # errors.
2483 # ii) or variables are known to often sit on different grid staggering
2484 # in different models (e.g. cell center vs cell edge), as is the case
2485 # for UM and LFRic comparisons.
2486 # In future greater choice of regridding method might be applied depending
2487 # on variable type. Linear regridding can in general be appropriate for smooth
2488 # variables. Care should be taken with interpretation of differences
2489 # given this dependency on regridding.
2490 if (
2491 base.coord(base_lat_name).shape != other.coord(other_lat_name).shape
2492 or base.coord(base_lon_name).shape != other.coord(other_lon_name).shape
2493 ) or (
2494 base.long_name
2495 in [
2496 "eastward_wind_at_10m",
2497 "northward_wind_at_10m",
2498 "northward_wind_at_cell_centres",
2499 "eastward_wind_at_cell_centres",
2500 "zonal_wind_at_pressure_levels",
2501 "meridional_wind_at_pressure_levels",
2502 "potential_vorticity_at_pressure_levels",
2503 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging",
2504 ]
2505 ):
2506 logging.debug(
2507 "Linear regridding base cube to other grid to compute differences"
2508 )
2509 base = regrid_onto_cube(base, other, method="Linear")
2511 # Extract just common time points.
2512 base, other = _extract_common_time_points(base, other)
2514 # Equalise attributes so we can merge.
2515 fully_equalise_attributes([base, other])
2516 logging.debug("Base: %s\nOther: %s", base, other)
2518 # Collapse cubes.
2519 base = collapse(
2520 base,
2521 coordinate=coordinates,
2522 method="PERCENTILE",
2523 additional_percent=percentiles,
2524 )
2525 other = collapse(
2526 other,
2527 coordinate=coordinates,
2528 method="PERCENTILE",
2529 additional_percent=percentiles,
2530 )
2532 # Ensure we have a name for the plot file.
2533 title = get_recipe_metadata().get("title", "Untitled")
2535 if filename is None:
2536 filename = slugify(title)
2538 # Add file extension.
2539 plot_filename = f"{filename.rsplit('.', 1)[0]}.png"
2541 # Do the actual plotting on a scatter plot
2542 _plot_and_save_scatter_plot(
2543 base, other, plot_filename, title, one_to_one, model_names
2544 )
2546 # Add list of plots to plot metadata.
2547 plot_index = _append_to_plot_index([plot_filename])
2549 # Make a page to display the plots.
2550 _make_plot_html_page(plot_index)
2552 return iris.cube.CubeList([base, other])
2555def scatter_plot(
2556 cube_x: iris.cube.Cube | iris.cube.CubeList,
2557 cube_y: iris.cube.Cube | iris.cube.CubeList,
2558 filename: str = None,
2559 one_to_one: bool = True,
2560 **kwargs,
2561) -> iris.cube.CubeList:
2562 """Plot a scatter plot between two variables.
2564 Both cubes must be 1D.
2566 Parameters
2567 ----------
2568 cube_x: Cube | CubeList
2569 1 dimensional Cube of the data to plot on y-axis.
2570 cube_y: Cube | CubeList
2571 1 dimensional Cube of the data to plot on x-axis.
2572 filename: str, optional
2573 Filename of the plot to write.
2574 one_to_one: bool, optional
2575 If True a 1:1 line is plotted; if False it is not. Default is True.
2577 Returns
2578 -------
2579 cubes: CubeList
2580 CubeList of the original x and y cubes for further processing.
2582 Raises
2583 ------
2584 ValueError
2585 If the cube doesn't have the right dimensions and cubes not the same
2586 size.
2587 TypeError
2588 If the cube isn't a single cube.
2590 Notes
2591 -----
2592 Scatter plots are used for determining if there is a relationship between
2593 two variables. Positive relations have a slope going from bottom left to top
2594 right; Negative relations have a slope going from top left to bottom right.
2595 """
2596 # Iterate over all cubes in cube or CubeList and plot.
2597 for cube_iter in iter_maybe(cube_x):
2598 # Check cubes are correct shape.
2599 cube_iter = _check_single_cube(cube_iter)
2600 if cube_iter.ndim > 1:
2601 raise ValueError("cube_x must be 1D.")
2603 # Iterate over all cubes in cube or CubeList and plot.
2604 for cube_iter in iter_maybe(cube_y):
2605 # Check cubes are correct shape.
2606 cube_iter = _check_single_cube(cube_iter)
2607 if cube_iter.ndim > 1:
2608 raise ValueError("cube_y must be 1D.")
2610 # Ensure we have a name for the plot file.
2611 title = get_recipe_metadata().get("title", "Untitled")
2613 if filename is None:
2614 filename = slugify(title)
2616 # Add file extension.
2617 plot_filename = f"{filename.rsplit('.', 1)[0]}.png"
2619 # Do the actual plotting.
2620 _plot_and_save_scatter_plot(cube_x, cube_y, plot_filename, title, one_to_one)
2622 # Add list of plots to plot metadata.
2623 plot_index = _append_to_plot_index([plot_filename])
2625 # Make a page to display the plots.
2626 _make_plot_html_page(plot_index)
2628 return iris.cube.CubeList([cube_x, cube_y])
2631def vector_plot(
2632 cube_u: iris.cube.Cube,
2633 cube_v: iris.cube.Cube,
2634 filename: str = None,
2635 sequence_coordinate: str = "time",
2636 **kwargs,
2637) -> iris.cube.CubeList:
2638 """Plot a vector plot based on the input u and v components."""
2639 recipe_title = get_recipe_metadata().get("title", "Untitled")
2641 # Ensure we have a name for the plot file.
2642 if filename is None: 2642 ↛ 2643line 2642 didn't jump to line 2643 because the condition on line 2642 was never true
2643 filename = slugify(recipe_title)
2645 # Cubes must have a matching sequence coordinate.
2646 try:
2647 # Check that the u and v cubes have the same sequence coordinate.
2648 if cube_u.coord(sequence_coordinate) != cube_v.coord(sequence_coordinate): 2648 ↛ 2649line 2648 didn't jump to line 2649 because the condition on line 2648 was never true
2649 raise ValueError("Coordinates do not match.")
2650 except (iris.exceptions.CoordinateNotFoundError, ValueError) as err:
2651 raise ValueError(
2652 f"Cubes should have matching {sequence_coordinate} coordinate:\n{cube_u}\n{cube_v}"
2653 ) from err
2655 # Create a plot for each value of the sequence coordinate.
2656 plot_index = []
2657 for cube_u_slice, cube_v_slice in zip(
2658 cube_u.slices_over(sequence_coordinate),
2659 cube_v.slices_over(sequence_coordinate),
2660 strict=True,
2661 ):
2662 # Use sequence value so multiple sequences can merge.
2663 sequence_value = cube_u_slice.coord(sequence_coordinate).points[0]
2664 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png"
2665 coord = cube_u_slice.coord(sequence_coordinate)
2666 # Format the coordinate value in a unit appropriate way.
2667 title = f"{recipe_title}\n{coord.units.title(coord.points[0])}"
2668 # Do the actual plotting.
2669 _plot_and_save_vector_plot(
2670 cube_u_slice,
2671 cube_v_slice,
2672 filename=plot_filename,
2673 title=title,
2674 method="contourf",
2675 )
2676 plot_index.append(plot_filename)
2678 # Add list of plots to plot metadata.
2679 complete_plot_index = _append_to_plot_index(plot_index)
2681 # Make a page to display the plots.
2682 _make_plot_html_page(complete_plot_index)
2684 return iris.cube.CubeList([cube_u, cube_v])
2687def plot_histogram_series(
2688 cubes: iris.cube.Cube | iris.cube.CubeList,
2689 filename: str = None,
2690 sequence_coordinate: str = "time",
2691 stamp_coordinate: str = "realization",
2692 single_plot: bool = False,
2693 **kwargs,
2694) -> iris.cube.Cube | iris.cube.CubeList:
2695 """Plot a histogram plot for each vertical level provided.
2697 A histogram plot can be plotted, but if the sequence_coordinate (i.e. time)
2698 is present then a sequence of plots will be produced using the time slider
2699 functionality to scroll through histograms against time. If a
2700 stamp_coordinate is present then postage stamp plots will be produced. If
2701 stamp_coordinate and single_plot is True, all postage stamp plots will be
2702 plotted in a single plot instead of separate postage stamp plots.
2704 Parameters
2705 ----------
2706 cubes: Cube | iris.cube.CubeList
2707 Iris cube or CubeList of the data to plot. It should have a single dimension other
2708 than the stamp coordinate.
2709 The cubes should cover the same phenomenon i.e. all cubes contain temperature data.
2710 We do not support different data such as temperature and humidity in the same CubeList for plotting.
2711 filename: str, optional
2712 Name of the plot to write, used as a prefix for plot sequences. Defaults
2713 to the recipe name.
2714 sequence_coordinate: str, optional
2715 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
2716 This coordinate must exist in the cube and will be used for the time
2717 slider.
2718 stamp_coordinate: str, optional
2719 Coordinate about which to plot postage stamp plots. Defaults to
2720 ``"realization"``.
2721 single_plot: bool, optional
2722 If True, all postage stamp plots will be plotted in a single plot. If
2723 False, each postage stamp plot will be plotted separately. Is only valid
2724 if stamp_coordinate exists and has more than a single point.
2726 Returns
2727 -------
2728 iris.cube.Cube | iris.cube.CubeList
2729 The original Cube or CubeList (so further operations can be applied).
2730 Plotted data.
2732 Raises
2733 ------
2734 ValueError
2735 If the cube doesn't have the right dimensions.
2736 TypeError
2737 If the cube isn't a Cube or CubeList.
2738 """
2739 recipe_title = get_recipe_metadata().get("title", "Untitled")
2741 cubes = iter_maybe(cubes)
2743 # Ensure we have a name for the plot file.
2744 if filename is None:
2745 filename = slugify(recipe_title)
2747 # Internal plotting function.
2748 plotting_func = _plot_and_save_histogram_series
2750 num_models = _get_num_models(cubes)
2752 _validate_cube_shape(cubes, num_models)
2754 # If several histograms are plotted with time as sequence_coordinate for the
2755 # time slider option.
2756 for cube in cubes:
2757 try:
2758 cube.coord(sequence_coordinate)
2759 except iris.exceptions.CoordinateNotFoundError as err:
2760 raise ValueError(
2761 f"Cube must have a {sequence_coordinate} coordinate."
2762 ) from err
2764 # Get minimum and maximum from levels information.
2765 levels = None
2766 for cube in cubes: 2766 ↛ 2782line 2766 didn't jump to line 2782 because the loop on line 2766 didn't complete
2767 # First check if user-specified "auto" range variable.
2768 # This maintains the value of levels as None, so proceed.
2769 _, levels, _ = _colorbar_map_levels(cube, axis="y")
2770 if levels is None:
2771 break
2772 # If levels is changed, recheck to use the vmin,vmax or
2773 # levels-based ranges for histogram plots.
2774 _, levels, _ = _colorbar_map_levels(cube)
2775 logging.debug("levels: %s", levels)
2776 if levels is not None: 2776 ↛ 2766line 2776 didn't jump to line 2766 because the condition on line 2776 was always true
2777 vmin = min(levels)
2778 vmax = max(levels)
2779 logging.debug("Updated vmin, vmax: %s, %s", vmin, vmax)
2780 break
2782 if levels is None:
2783 vmin = min(cb.data.min() for cb in cubes)
2784 vmax = max(cb.data.max() for cb in cubes)
2786 # Make postage stamp plots if stamp_coordinate exists and has more than a
2787 # single point. If single_plot is True:
2788 # -- all postage stamp plots will be plotted in a single plot instead of
2789 # separate postage stamp plots.
2790 # -- model names (hidden in cube attrs) are ignored, that is stamp plots are
2791 # produced per single model only
2793 if num_models == 1: 2793 ↛ 2806line 2793 didn't jump to line 2806 because the condition on line 2793 was always true
2794 if ( 2794 ↛ 2798line 2794 didn't jump to line 2798 because the condition on line 2794 was never true
2795 stamp_coordinate in [c.name() for c in cubes[0].coords()]
2796 and cubes[0].coord(stamp_coordinate).shape[0] > 1
2797 ):
2798 if single_plot:
2799 plotting_func = (
2800 _plot_and_save_postage_stamps_in_single_plot_histogram_series
2801 )
2802 else:
2803 plotting_func = _plot_and_save_postage_stamp_histogram_series
2804 cube_iterables = cubes[0].slices_over(sequence_coordinate)
2805 else:
2806 all_points = sorted(
2807 set(
2808 itertools.chain.from_iterable(
2809 cb.coord(sequence_coordinate).points for cb in cubes
2810 )
2811 )
2812 )
2813 all_slices = list(
2814 itertools.chain.from_iterable(
2815 cb.slices_over(sequence_coordinate) for cb in cubes
2816 )
2817 )
2818 # Matched slices (matched by seq coord point; it may happen that
2819 # evaluated models do not cover the same seq coord range, hence matching
2820 # necessary)
2821 cube_iterables = [
2822 iris.cube.CubeList(
2823 s for s in all_slices if s.coord(sequence_coordinate).points[0] == point
2824 )
2825 for point in all_points
2826 ]
2828 plot_index = []
2829 nplot = np.size(cube.coord(sequence_coordinate).points)
2830 # Create a plot for each value of the sequence coordinate. Allowing for
2831 # multiple cubes in a CubeList to be plotted in the same plot for similar
2832 # sequence values. Passing a CubeList into the internal plotting function
2833 # for similar values of the sequence coordinate. cube_slice can be an
2834 # iris.cube.Cube or an iris.cube.CubeList.
2835 for cube_slice in cube_iterables:
2836 single_cube = cube_slice
2837 if isinstance(cube_slice, iris.cube.CubeList): 2837 ↛ 2838line 2837 didn't jump to line 2838 because the condition on line 2837 was never true
2838 single_cube = cube_slice[0]
2840 # Use sequence value so multiple sequences can merge.
2841 sequence_value = single_cube.coord(sequence_coordinate).points[0]
2842 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png"
2843 coord = single_cube.coord(sequence_coordinate)
2844 # Format the coordinate value in a unit appropriate way.
2845 title = f"{recipe_title}\n [{coord.units.title(coord.points[0])}]"
2846 # Use sequence (e.g. time) bounds if plotting single non-sequence outputs
2847 if nplot == 1 and coord.has_bounds: 2847 ↛ 2848line 2847 didn't jump to line 2848 because the condition on line 2847 was never true
2848 if np.size(coord.bounds) > 1:
2849 title = f"{recipe_title}\n [{coord.units.title(coord.bounds[0][0])} to {coord.units.title(coord.bounds[0][1])}]"
2850 # Do the actual plotting.
2852 plotting_func(
2853 cube_slice,
2854 filename=plot_filename,
2855 stamp_coordinate=stamp_coordinate,
2856 title=title,
2857 vmin=vmin,
2858 vmax=vmax,
2859 )
2860 plot_index.append(plot_filename)
2862 # Add list of plots to plot metadata.
2863 complete_plot_index = _append_to_plot_index(plot_index)
2865 # Make a page to display the plots.
2866 _make_plot_html_page(complete_plot_index)
2868 return cubes