Coverage for src/CSET/operators/plot.py: 88%
734 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-05 21:08 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-05 21:08 +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
25import sys
26from typing import Literal
28import cartopy.crs as ccrs
29import iris
30import iris.coords
31import iris.cube
32import iris.exceptions
33import iris.plot as iplt
34import matplotlib as mpl
35import matplotlib.colors as mcolors
36import matplotlib.pyplot as plt
37import numpy as np
38from 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 get_cube_yxcoordname, is_transect
49# Use a non-interactive plotting backend.
50mpl.use("agg")
52DEFAULT_DISCRETE_COLORS = mpl.colormaps["tab10"].colors + mpl.colormaps["Accent"].colors
54############################
55# Private helper functions #
56############################
59def _append_to_plot_index(plot_index: list) -> list:
60 """Add plots into the plot index, returning the complete plot index."""
61 with open("meta.json", "r+t", encoding="UTF-8") as fp:
62 fcntl.flock(fp, fcntl.LOCK_EX)
63 fp.seek(0)
64 meta = json.load(fp)
65 complete_plot_index = meta.get("plots", [])
66 complete_plot_index = complete_plot_index + plot_index
67 meta["plots"] = complete_plot_index
68 if os.getenv("CYLC_TASK_CYCLE_POINT") and not bool(
69 os.getenv("DO_CASE_AGGREGATION")
70 ):
71 meta["case_date"] = os.getenv("CYLC_TASK_CYCLE_POINT", "")
72 fp.seek(0)
73 fp.truncate()
74 json.dump(meta, fp, indent=2)
75 return complete_plot_index
78def _check_single_cube(cube: iris.cube.Cube | iris.cube.CubeList) -> iris.cube.Cube:
79 """Ensure a single cube is given.
81 If a CubeList of length one is given that the contained cube is returned,
82 otherwise an error is raised.
84 Parameters
85 ----------
86 cube: Cube | CubeList
87 The cube to check.
89 Returns
90 -------
91 cube: Cube
92 The checked cube.
94 Raises
95 ------
96 TypeError
97 If the input cube is not a Cube or CubeList of a single Cube.
98 """
99 if isinstance(cube, iris.cube.Cube):
100 return cube
101 if isinstance(cube, iris.cube.CubeList):
102 if len(cube) == 1:
103 return cube[0]
104 raise TypeError("Must have a single cube", cube)
107def _py312_importlib_resources_files_shim():
108 """Importlib behaviour changed in 3.12 to avoid circular dependencies.
110 This shim is needed until python 3.12 is our oldest supported version, after
111 which it can just be replaced by directly using importlib.resources.files.
112 """
113 if sys.version_info.minor >= 12:
114 files = importlib.resources.files()
115 else:
116 import CSET.operators
118 files = importlib.resources.files(CSET.operators)
119 return files
122def _make_plot_html_page(plots: list):
123 """Create a HTML page to display a plot image."""
124 # Debug check that plots actually contains some strings.
125 assert isinstance(plots[0], str)
127 # Load HTML template file.
128 operator_files = _py312_importlib_resources_files_shim()
129 template_file = operator_files.joinpath("_plot_page_template.html")
131 # Get some metadata.
132 meta = get_recipe_metadata()
133 title = meta.get("title", "Untitled")
134 description = MarkdownIt().render(meta.get("description", "*No description.*"))
136 # Prepare template variables.
137 variables = {
138 "title": title,
139 "description": description,
140 "initial_plot": plots[0],
141 "plots": plots,
142 "title_slug": slugify(title),
143 }
145 # Render template.
146 html = render_file(template_file, **variables)
148 # Save completed HTML.
149 with open("index.html", "wt", encoding="UTF-8") as fp:
150 fp.write(html)
153@functools.cache
154def _load_colorbar_map(user_colorbar_file: str = None) -> dict:
155 """Load the colorbar definitions from a file.
157 This is a separate function to make it cacheable.
158 """
159 colorbar_file = _py312_importlib_resources_files_shim().joinpath(
160 "_colorbar_definition.json"
161 )
162 with open(colorbar_file, "rt", encoding="UTF-8") as fp:
163 colorbar = json.load(fp)
165 logging.debug("User colour bar file: %s", user_colorbar_file)
166 override_colorbar = {}
167 if user_colorbar_file:
168 try:
169 with open(user_colorbar_file, "rt", encoding="UTF-8") as fp:
170 override_colorbar = json.load(fp)
171 except FileNotFoundError:
172 logging.warning("Colorbar file does not exist. Using default values.")
174 # Overwrite values with the user supplied colorbar definition.
175 colorbar = combine_dicts(colorbar, override_colorbar)
176 return colorbar
179def _get_model_colors_map(cubes: iris.cube.CubeList | iris.cube.Cube) -> dict:
180 """Get an appropriate colors for model lines in line plots.
182 For each model in the list of cubes colors either from user provided
183 color definition file (so-called style file) or from default colors are mapped
184 to model_name attribute.
186 Parameters
187 ----------
188 cubes: CubeList or Cube
189 Cubes with model_name attribute
191 Returns
192 -------
193 model_colors_map:
194 Dictionary mapping model_name attribute to colors
195 """
196 user_colorbar_file = get_recipe_metadata().get("style_file_path", None)
197 colorbar = _load_colorbar_map(user_colorbar_file)
198 model_names = sorted(
199 filter(
200 lambda x: x is not None,
201 (cube.attributes.get("model_name", None) for cube in iter_maybe(cubes)),
202 )
203 )
204 if not model_names:
205 return {}
206 use_user_colors = all(mname in colorbar.keys() for mname in model_names)
207 if use_user_colors: 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true
208 return {mname: colorbar[mname] for mname in model_names}
210 color_list = itertools.cycle(DEFAULT_DISCRETE_COLORS)
211 return {mname: color for mname, color in zip(model_names, color_list, strict=False)}
214def _colorbar_map_levels(cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None):
215 """Get an appropriate colorbar for the given cube.
217 For the given variable the appropriate colorbar is looked up from a
218 combination of the built-in CSET colorbar definitions, and any user supplied
219 definitions. As well as varying on variables, these definitions may also
220 exist for specific pressure levels to account for variables with
221 significantly different ranges at different heights. The colorbars also exist
222 for masks and mask differences for considering variable presence diagnostics.
223 Specific variable ranges can be separately set in user-supplied definition
224 for x- or y-axis limits, or indicate where automated range preferred.
226 Parameters
227 ----------
228 cube: Cube
229 Cube of variable for which the colorbar information is desired.
230 axis: "x", "y", optional
231 Select the levels for just this axis of a line plot. The min and max
232 can be set by xmin/xmax or ymin/ymax respectively. For variables where
233 setting a universal range is not desirable (e.g. temperature), users
234 can set ymin/ymax values to "auto" in the colorbar definitions file.
235 Where no additional xmin/xmax or ymin/ymax values are provided, the
236 axis bounds default to use the vmin/vmax values provided.
238 Returns
239 -------
240 cmap:
241 Matplotlib colormap.
242 levels:
243 List of levels to use for plotting. For continuous plots the min and max
244 should be taken as the range.
245 norm:
246 BoundaryNorm information.
247 """
248 # Grab the colorbar file from the recipe global metadata.
249 user_colorbar_file = get_recipe_metadata().get("style_file_path", None)
250 colorbar = _load_colorbar_map(user_colorbar_file)
251 cmap = None
253 try:
254 # We assume that pressure is a scalar coordinate here.
255 pressure_level_raw = cube.coord("pressure").points[0]
256 # Ensure pressure_level is a string, as it is used as a JSON key.
257 pressure_level = str(int(pressure_level_raw))
258 except iris.exceptions.CoordinateNotFoundError:
259 pressure_level = None
261 # First try long name, then standard name, then var name. This order is used
262 # as long name is the one we correct between models, so it most likely to be
263 # consistent.
264 varnames = list(filter(None, [cube.long_name, cube.standard_name, cube.var_name]))
265 for varname in varnames:
266 # Get the colormap for this variable.
267 try:
268 var_colorbar = colorbar[varname]
269 cmap = plt.get_cmap(colorbar[varname]["cmap"], 51)
270 varname_key = varname
271 break
272 except KeyError:
273 logging.debug("Cube name %s has no colorbar definition.", varname)
275 # Get colormap if it is a mask.
276 if any("mask_for_" in name for name in varnames):
277 cmap, levels, norm = _custom_colormap_mask(cube, axis=axis)
278 return cmap, levels, norm
279 # If winds on Beaufort Scale use custom colorbar and levels
280 if any("Beaufort_Scale" in name for name in varnames):
281 cmap, levels, norm = _custom_beaufort_scale(cube, axis=axis)
282 return cmap, levels, norm
284 # If no valid colormap has been defined, use defaults and return.
285 if not cmap:
286 logging.warning("No colorbar definition exists for %s.", cube.name())
287 cmap, levels, norm = mpl.colormaps["viridis"], None, None
288 return cmap, levels, norm
290 # Test if pressure-level specific settings are provided for cube.
291 if pressure_level:
292 try:
293 var_colorbar = colorbar[varname_key]["pressure_levels"][pressure_level]
294 except KeyError:
295 logging.debug(
296 "%s has no colorbar definition for pressure level %s.",
297 varname,
298 pressure_level,
299 )
301 # Check for availability of x-axis or y-axis user-specific overrides
302 # for setting level bounds for line plot types and return just levels.
303 # Line plots do not need a colormap, and just use the data range.
304 if axis:
305 if axis == "x":
306 try:
307 vmin, vmax = var_colorbar["xmin"], var_colorbar["xmax"]
308 except KeyError:
309 vmin, vmax = var_colorbar["min"], var_colorbar["max"]
310 if axis == "y":
311 try:
312 vmin, vmax = var_colorbar["ymin"], var_colorbar["ymax"]
313 except KeyError:
314 vmin, vmax = var_colorbar["min"], var_colorbar["max"]
315 # Check if user-specified auto-scaling for this variable
316 if vmin == "auto" or vmax == "auto":
317 levels = None
318 else:
319 levels = [vmin, vmax]
320 return None, levels, None
321 # Get and use the colorbar levels for this variable if spatial or histogram.
322 else:
323 try:
324 levels = var_colorbar["levels"]
325 # Use discrete bins when levels are specified, rather
326 # than a smooth range.
327 norm = mpl.colors.BoundaryNorm(levels, ncolors=cmap.N)
328 logging.debug("Using levels for %s colorbar.", varname)
329 logging.info("Using levels: %s", levels)
330 except KeyError:
331 # Get the range for this variable.
332 vmin, vmax = var_colorbar["min"], var_colorbar["max"]
333 logging.debug("Using min and max for %s colorbar.", varname)
334 # Calculate levels from range.
335 levels = np.linspace(vmin, vmax, 101)
336 norm = None
338 # Overwrite cmap, levels and norm for specific variables that
339 # require custom colorbar_map as these can not be defined in the
340 # JSON file.
341 cmap, levels, norm = _custom_colourmap_precipitation(cube, cmap, levels, norm)
342 cmap, levels, norm = _custom_colourmap_visibility_in_air(
343 cube, cmap, levels, norm
344 )
345 return cmap, levels, norm
348def _setup_spatial_map(
349 cube: iris.cube.Cube,
350 figure,
351 cmap,
352 grid_size: int | None = None,
353 subplot: int | None = None,
354):
355 """Define map projections, extent and add coastlines for spatial plots.
357 For spatial map plots, a relevant map projection for rotated or non-rotated inputs
358 is specified, and map extent defined based on the input data.
360 Parameters
361 ----------
362 cube: Cube
363 2 dimensional (lat and lon) Cube of the data to plot.
364 figure:
365 Matplotlib Figure object holding all plot elements.
366 cmap:
367 Matplotlib colormap.
368 grid_size: int, optional
369 Size of grid for subplots if multiple spatial subplots in figure.
370 subplot: int, optional
371 Subplot index if multiple spatial subplots in figure.
373 Returns
374 -------
375 axes:
376 Matplotlib GeoAxes definition.
377 """
378 # Identify min/max plot bounds.
379 try:
380 lat_axis, lon_axis = get_cube_yxcoordname(cube)
381 x1 = np.min(cube.coord(lon_axis).points)
382 x2 = np.max(cube.coord(lon_axis).points)
383 y1 = np.min(cube.coord(lat_axis).points)
384 y2 = np.max(cube.coord(lat_axis).points)
386 # Adjust bounds within +/- 180.0 if x dimension extends beyond half-globe.
387 if np.abs(x2 - x1) > 180.0:
388 x1 = x1 - 180.0
389 x2 = x2 - 180.0
390 logging.debug("Adjusting plot bounds to fit global extent.")
392 # Consider map projection orientation.
393 # Adapting orientation enables plotting across international dateline.
394 # Users can adapt the default central_longitude if alternative projections views.
395 if x2 > 180.0:
396 central_longitude = 180.0
397 else:
398 central_longitude = 0.0
400 # Define spatial map projection.
401 coord_system = cube.coord(lat_axis).coord_system
402 if isinstance(coord_system, iris.coord_systems.RotatedGeogCS):
403 # Define rotated pole map projection for rotated pole inputs.
404 projection = ccrs.RotatedPole(
405 pole_longitude=coord_system.grid_north_pole_longitude,
406 pole_latitude=coord_system.grid_north_pole_latitude,
407 central_rotated_longitude=0.0,
408 )
409 crs = projection
410 else:
411 # Define regular map projection for non-rotated pole inputs.
412 # Alternatives might include e.g. for global model outputs:
413 # projection=ccrs.Robinson(central_longitude=X.y, globe=None)
414 # See also https://scitools.org.uk/cartopy/docs/v0.15/crs/projections.html.
415 projection = ccrs.PlateCarree(central_longitude=central_longitude)
416 crs = ccrs.PlateCarree()
418 # Define axes for plot (or subplot) with required map projection.
419 if subplot is not None:
420 axes = figure.add_subplot(
421 grid_size, grid_size, subplot, projection=projection
422 )
423 else:
424 axes = figure.add_subplot(projection=projection)
426 # Add coastlines if cube contains x and y map coordinates.
427 if cmap.name in ["viridis", "Greys"]:
428 coastcol = "magenta"
429 else:
430 coastcol = "black"
431 logging.debug("Plotting coastlines in colour %s.", coastcol)
432 axes.coastlines(resolution="10m", color=coastcol)
434 # If is lat/lon spatial map, fix extent to keep plot tight.
435 # Specifying crs within set_extent helps ensure only data region is shown.
436 if isinstance(coord_system, iris.coord_systems.GeogCS):
437 axes.set_extent([x1, x2, y1, y2], crs=crs)
439 except ValueError:
440 # Skip if not both x and y map coordinates.
441 axes = figure.gca()
442 pass
444 return axes
447def _get_plot_resolution() -> int:
448 """Get resolution of rasterised plots in pixels per inch."""
449 return get_recipe_metadata().get("plot_resolution", 100)
452def _plot_and_save_spatial_plot(
453 cube: iris.cube.Cube,
454 filename: str,
455 title: str,
456 method: Literal["contourf", "pcolormesh"],
457 **kwargs,
458):
459 """Plot and save a spatial plot.
461 Parameters
462 ----------
463 cube: Cube
464 2 dimensional (lat and lon) Cube of the data to plot.
465 filename: str
466 Filename of the plot to write.
467 title: str
468 Plot title.
469 method: "contourf" | "pcolormesh"
470 The plotting method to use.
471 """
472 # Setup plot details, size, resolution, etc.
473 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
475 # Specify the color bar
476 cmap, levels, norm = _colorbar_map_levels(cube)
478 # Setup plot map projection, extent and coastlines.
479 axes = _setup_spatial_map(cube, fig, cmap)
481 # Plot the field.
482 if method == "contourf":
483 # Filled contour plot of the field.
484 plot = iplt.contourf(cube, cmap=cmap, levels=levels, norm=norm)
485 elif method == "pcolormesh":
486 try:
487 vmin = min(levels)
488 vmax = max(levels)
489 except TypeError:
490 vmin, vmax = None, None
491 # pcolormesh plot of the field and ensure to use norm and not vmin/vmax
492 # if levels are defined.
493 if norm is not None:
494 vmin = None
495 vmax = None
496 logging.debug("Plotting using defined levels.")
497 plot = iplt.pcolormesh(cube, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax)
498 else:
499 raise ValueError(f"Unknown plotting method: {method}")
501 # Check to see if transect, and if so, adjust y axis.
502 if is_transect(cube):
503 if "pressure" in [coord.name() for coord in cube.coords()]:
504 axes.invert_yaxis()
505 axes.set_yscale("log")
506 axes.set_ylim(1100, 100)
507 # If both model_level_number and level_height exists, iplt can construct
508 # plot as a function of height above orography (NOT sea level).
509 elif {"model_level_number", "level_height"}.issubset( 509 ↛ 514line 509 didn't jump to line 514 because the condition on line 509 was always true
510 {coord.name() for coord in cube.coords()}
511 ):
512 axes.set_yscale("log")
514 axes.set_title(
515 f"{title}\n"
516 f"Start Lat: {cube.attributes['transect_coords'].split('_')[0]}"
517 f" Start Lon: {cube.attributes['transect_coords'].split('_')[1]}"
518 f" End Lat: {cube.attributes['transect_coords'].split('_')[2]}"
519 f" End Lon: {cube.attributes['transect_coords'].split('_')[3]}",
520 fontsize=16,
521 )
523 else:
524 # Add title.
525 axes.set_title(title, fontsize=16)
527 # Add watermark with min/max/mean. Currently not user togglable.
528 # In the bbox dictionary, fc and ec are hex colour codes for grey shade.
529 axes.annotate(
530 f"Min: {np.min(cube.data):.3g} Max: {np.max(cube.data):.3g} Mean: {np.mean(cube.data):.3g}",
531 xy=(1, -0.05),
532 xycoords="axes fraction",
533 xytext=(-5, 5),
534 textcoords="offset points",
535 ha="right",
536 va="bottom",
537 size=11,
538 bbox=dict(boxstyle="round", fc="#cccccc", ec="#808080", alpha=0.9),
539 )
541 # Add colour bar.
542 cbar = fig.colorbar(plot, orientation="horizontal", pad=0.042, shrink=0.7)
543 cbar.set_label(label=f"{cube.name()} ({cube.units})", size=14)
544 # add ticks and tick_labels for every levels if less than 20 levels exist
545 if levels is not None and len(levels) < 20:
546 cbar.set_ticks(levels)
547 cbar.set_ticklabels([f"{level:.1f}" for level in levels])
548 if "visibility" in cube.name(): 548 ↛ 549line 548 didn't jump to line 549 because the condition on line 548 was never true
549 cbar.set_ticklabels([f"{level:.3g}" for level in levels])
550 logging.debug("Set colorbar ticks and labels.")
552 # Save plot.
553 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
554 logging.info("Saved spatial plot to %s", filename)
555 plt.close(fig)
558def _plot_and_save_postage_stamp_spatial_plot(
559 cube: iris.cube.Cube,
560 filename: str,
561 stamp_coordinate: str,
562 title: str,
563 method: Literal["contourf", "pcolormesh"],
564 **kwargs,
565):
566 """Plot postage stamp spatial plots from an ensemble.
568 Parameters
569 ----------
570 cube: Cube
571 Iris cube of data to be plotted. It must have the stamp coordinate.
572 filename: str
573 Filename of the plot to write.
574 stamp_coordinate: str
575 Coordinate that becomes different plots.
576 method: "contourf" | "pcolormesh"
577 The plotting method to use.
579 Raises
580 ------
581 ValueError
582 If the cube doesn't have the right dimensions.
583 """
584 # Use the smallest square grid that will fit the members.
585 grid_size = int(math.ceil(math.sqrt(len(cube.coord(stamp_coordinate).points))))
587 fig = plt.figure(figsize=(10, 10))
589 # Specify the color bar
590 cmap, levels, norm = _colorbar_map_levels(cube)
592 # Make a subplot for each member.
593 for member, subplot in zip(
594 cube.slices_over(stamp_coordinate), range(1, grid_size**2 + 1), strict=False
595 ):
596 # Setup subplot map projection, extent and coastlines.
597 axes = _setup_spatial_map(
598 member, fig, cmap, grid_size=grid_size, subplot=subplot
599 )
600 if method == "contourf":
601 # Filled contour plot of the field.
602 plot = iplt.contourf(member, cmap=cmap, levels=levels, norm=norm)
603 elif method == "pcolormesh":
604 if levels is not None:
605 vmin = min(levels)
606 vmax = max(levels)
607 else:
608 raise TypeError("Unknown vmin and vmax range.")
609 vmin, vmax = None, None
610 # pcolormesh plot of the field and ensure to use norm and not vmin/vmax
611 # if levels are defined.
612 if norm is not None: 612 ↛ 613line 612 didn't jump to line 613 because the condition on line 612 was never true
613 vmin = None
614 vmax = None
615 # pcolormesh plot of the field.
616 plot = iplt.pcolormesh(member, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax)
617 else:
618 raise ValueError(f"Unknown plotting method: {method}")
619 axes.set_title(f"Member #{member.coord(stamp_coordinate).points[0]}")
620 axes.set_axis_off()
622 # Put the shared colorbar in its own axes.
623 colorbar_axes = fig.add_axes([0.15, 0.07, 0.7, 0.03])
624 colorbar = fig.colorbar(
625 plot, colorbar_axes, orientation="horizontal", pad=0.042, shrink=0.7
626 )
627 colorbar.set_label(f"{cube.name()} ({cube.units})", size=14)
629 # Overall figure title.
630 fig.suptitle(title, fontsize=16)
632 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
633 logging.info("Saved contour postage stamp plot to %s", filename)
634 plt.close(fig)
637def _plot_and_save_line_series(
638 cubes: iris.cube.CubeList,
639 coords: list[iris.coords.Coord],
640 ensemble_coord: str,
641 filename: str,
642 title: str,
643 **kwargs,
644):
645 """Plot and save a 1D line series.
647 Parameters
648 ----------
649 cubes: Cube or CubeList
650 Cube or CubeList containing the cubes to plot on the y-axis.
651 coords: list[Coord]
652 Coordinates to plot on the x-axis, one per cube.
653 ensemble_coord: str
654 Ensemble coordinate in the cube.
655 filename: str
656 Filename of the plot to write.
657 title: str
658 Plot title.
659 """
660 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
662 model_colors_map = _get_model_colors_map(cubes)
664 # Store min/max ranges.
665 y_levels = []
667 # Check match-up across sequence coords gives consistent sizes
668 _validate_cubes_coords(cubes, coords)
670 for cube, coord in zip(cubes, coords, strict=True):
671 label = None
672 color = "black"
673 if model_colors_map:
674 label = cube.attributes.get("model_name")
675 color = model_colors_map.get(label)
676 for cube_slice in cube.slices_over(ensemble_coord):
677 # Label with (control) if part of an ensemble or not otherwise.
678 if cube_slice.coord(ensemble_coord).points == [0]:
679 iplt.plot(
680 coord,
681 cube_slice,
682 color=color,
683 marker="o",
684 ls="-",
685 lw=3,
686 label=f"{label} (control)"
687 if len(cube.coord(ensemble_coord).points) > 1
688 else label,
689 )
690 # Label with (perturbed) if part of an ensemble and not the control.
691 else:
692 iplt.plot(
693 coord,
694 cube_slice,
695 color=color,
696 ls="-",
697 lw=1.5,
698 alpha=0.75,
699 label=f"{label} (member)",
700 )
702 # Calculate the global min/max if multiple cubes are given.
703 _, levels, _ = _colorbar_map_levels(cube, axis="y")
704 if levels is not None: 704 ↛ 705line 704 didn't jump to line 705 because the condition on line 704 was never true
705 y_levels.append(min(levels))
706 y_levels.append(max(levels))
708 # Get the current axes.
709 ax = plt.gca()
711 # Add some labels and tweak the style.
712 # check if cubes[0] works for single cube if not CubeList
713 ax.set_xlabel(f"{coords[0].name()} / {coords[0].units}", fontsize=14)
714 ax.set_ylabel(f"{cubes[0].name()} / {cubes[0].units}", fontsize=14)
715 ax.set_title(title, fontsize=16)
717 ax.ticklabel_format(axis="y", useOffset=False)
718 ax.tick_params(axis="x", labelrotation=15)
719 ax.tick_params(axis="both", labelsize=12)
721 # Set y limits to global min and max, autoscale if colorbar doesn't exist.
722 if y_levels: 722 ↛ 723line 722 didn't jump to line 723 because the condition on line 722 was never true
723 ax.set_ylim(min(y_levels), max(y_levels))
724 # Add zero line.
725 if min(y_levels) < 0.0 and max(y_levels) > 0.0:
726 ax.axhline(y=0, xmin=0, xmax=1, ls="-", color="grey", lw=2)
727 logging.debug(
728 "Line plot with y-axis limits %s-%s", min(y_levels), max(y_levels)
729 )
730 else:
731 ax.autoscale()
733 # Add gridlines
734 ax.grid(linestyle="--", color="grey", linewidth=1)
735 # Ientify unique labels for legend
736 handles = list(
737 {
738 label: handle
739 for (handle, label) in zip(*ax.get_legend_handles_labels(), strict=True)
740 }.values()
741 )
742 ax.legend(handles=handles, loc="best", ncol=1, frameon=False, fontsize=16)
744 # Save plot.
745 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
746 logging.info("Saved line plot to %s", filename)
747 plt.close(fig)
750def _plot_and_save_vertical_line_series(
751 cubes: iris.cube.CubeList,
752 coords: list[iris.coords.Coord],
753 ensemble_coord: str,
754 filename: str,
755 series_coordinate: str,
756 title: str,
757 vmin: float,
758 vmax: float,
759 **kwargs,
760):
761 """Plot and save a 1D line series in vertical.
763 Parameters
764 ----------
765 cubes: CubeList
766 1 dimensional Cube or CubeList of the data to plot on x-axis.
767 coord: list[Coord]
768 Coordinates to plot on the y-axis, one per cube.
769 ensemble_coord: str
770 Ensemble coordinate in the cube.
771 filename: str
772 Filename of the plot to write.
773 series_coordinate: str
774 Coordinate to use as vertical axis.
775 title: str
776 Plot title.
777 vmin: float
778 Minimum value for the x-axis.
779 vmax: float
780 Maximum value for the x-axis.
781 """
782 # plot the vertical pressure axis using log scale
783 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
785 model_colors_map = _get_model_colors_map(cubes)
787 # Check match-up across sequence coords gives consistent sizes
788 _validate_cubes_coords(cubes, coords)
790 for cube, coord in zip(cubes, coords, strict=True):
791 label = None
792 color = "black"
793 if model_colors_map: 793 ↛ 794line 793 didn't jump to line 794 because the condition on line 793 was never true
794 label = cube.attributes.get("model_name")
795 color = model_colors_map.get(label)
797 for cube_slice in cube.slices_over(ensemble_coord):
798 # If ensemble data given plot control member with (control)
799 # unless single forecast.
800 if cube_slice.coord(ensemble_coord).points == [0]:
801 iplt.plot(
802 cube_slice,
803 coord,
804 color=color,
805 marker="o",
806 ls="-",
807 lw=3,
808 label=f"{label} (control)"
809 if len(cube.coord(ensemble_coord).points) > 1
810 else label,
811 )
812 # If ensemble data given plot perturbed members with (perturbed).
813 else:
814 iplt.plot(
815 cube_slice,
816 coord,
817 color=color,
818 ls="-",
819 lw=1.5,
820 alpha=0.75,
821 label=f"{label} (member)",
822 )
824 # Get the current axis
825 ax = plt.gca()
827 # Special handling for pressure level data.
828 if series_coordinate == "pressure": 828 ↛ 850line 828 didn't jump to line 850 because the condition on line 828 was always true
829 # Invert y-axis and set to log scale.
830 ax.invert_yaxis()
831 ax.set_yscale("log")
833 # Define y-ticks and labels for pressure log axis.
834 y_tick_labels = [
835 "1000",
836 "850",
837 "700",
838 "500",
839 "300",
840 "200",
841 "100",
842 ]
843 y_ticks = [1000, 850, 700, 500, 300, 200, 100]
845 # Set y-axis limits and ticks.
846 ax.set_ylim(1100, 100)
848 # Test if series_coordinate is model level data. The UM data uses
849 # model_level_number and lfric uses full_levels as coordinate.
850 elif series_coordinate in ("model_level_number", "full_levels", "half_levels"):
851 # Define y-ticks and labels for vertical axis.
852 y_ticks = iter_maybe(cubes)[0].coord(series_coordinate).points
853 y_tick_labels = [str(int(i)) for i in y_ticks]
854 ax.set_ylim(min(y_ticks), max(y_ticks))
856 ax.set_yticks(y_ticks)
857 ax.set_yticklabels(y_tick_labels)
859 # Set x-axis limits.
860 ax.set_xlim(vmin, vmax)
861 # Mark y=0 if present in plot.
862 if vmin < 0.0 and vmax > 0.0: 862 ↛ 863line 862 didn't jump to line 863 because the condition on line 862 was never true
863 ax.axvline(x=0, ymin=0, ymax=1, ls="-", color="grey", lw=2)
865 # Add some labels and tweak the style.
866 ax.set_ylabel(f"{coord.name()} / {coord.units}", fontsize=14)
867 ax.set_xlabel(
868 f"{iter_maybe(cubes)[0].name()} / {iter_maybe(cubes)[0].units}", fontsize=14
869 )
870 ax.set_title(title, fontsize=16)
871 ax.ticklabel_format(axis="x")
872 ax.tick_params(axis="y")
873 ax.tick_params(axis="both", labelsize=12)
875 # Add gridlines
876 ax.grid(linestyle="--", color="grey", linewidth=1)
877 # Ientify unique labels for legend
878 handles = list(
879 {
880 label: handle
881 for (handle, label) in zip(*ax.get_legend_handles_labels(), strict=True)
882 }.values()
883 )
884 ax.legend(handles=handles, loc="best", ncol=1, frameon=False, fontsize=16)
886 # Save plot.
887 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
888 logging.info("Saved line plot to %s", filename)
889 plt.close(fig)
892def _plot_and_save_scatter_plot(
893 cube_x: iris.cube.Cube | iris.cube.CubeList,
894 cube_y: iris.cube.Cube | iris.cube.CubeList,
895 filename: str,
896 title: str,
897 one_to_one: bool,
898 **kwargs,
899):
900 """Plot and save a 2D scatter plot.
902 Parameters
903 ----------
904 cube_x: Cube | CubeList
905 1 dimensional Cube or CubeList of the data to plot on x-axis.
906 cube_y: Cube | CubeList
907 1 dimensional Cube or CubeList of the data to plot on y-axis.
908 filename: str
909 Filename of the plot to write.
910 title: str
911 Plot title.
912 one_to_one: bool
913 Whether a 1:1 line is plotted.
914 """
915 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
916 # plot the cube_x and cube_y 1D fields as a scatter plot. If they are CubeLists this ensures
917 # to pair each cube from cube_x with the corresponding cube from cube_y, allowing to iterate
918 # over the pairs simultaneously.
920 # Ensure cube_x and cube_y are iterable
921 cube_x_iterable = iter_maybe(cube_x)
922 cube_y_iterable = iter_maybe(cube_y)
924 for cube_x_iter, cube_y_iter in zip(cube_x_iterable, cube_y_iterable, strict=True):
925 iplt.scatter(cube_x_iter, cube_y_iter)
926 if one_to_one is True:
927 plt.plot(
928 [
929 np.nanmin([np.nanmin(cube_y.data), np.nanmin(cube_x.data)]),
930 np.nanmax([np.nanmax(cube_y.data), np.nanmax(cube_x.data)]),
931 ],
932 [
933 np.nanmin([np.nanmin(cube_y.data), np.nanmin(cube_x.data)]),
934 np.nanmax([np.nanmax(cube_y.data), np.nanmax(cube_x.data)]),
935 ],
936 "k",
937 linestyle="--",
938 )
939 ax = plt.gca()
941 # Add some labels and tweak the style.
942 ax.set_xlabel(f"{cube_x[0].name()} / {cube_x[0].units}", fontsize=14)
943 ax.set_ylabel(f"{cube_y[0].name()} / {cube_y[0].units}", fontsize=14)
944 ax.set_title(title, fontsize=16)
945 ax.ticklabel_format(axis="y", useOffset=False)
946 ax.tick_params(axis="x", labelrotation=15)
947 ax.tick_params(axis="both", labelsize=12)
948 ax.autoscale()
950 # Save plot.
951 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
952 logging.info("Saved scatter plot to %s", filename)
953 plt.close(fig)
956def _plot_and_save_vector_plot(
957 cube_u: iris.cube.Cube,
958 cube_v: iris.cube.Cube,
959 filename: str,
960 title: str,
961 method: Literal["contourf", "pcolormesh"],
962 **kwargs,
963):
964 """Plot and save a 2D vector plot.
966 Parameters
967 ----------
968 cube_u: Cube
969 2 dimensional Cube of u component of the data.
970 cube_v: Cube
971 2 dimensional Cube of v component of the data.
972 filename: str
973 Filename of the plot to write.
974 title: str
975 Plot title.
976 """
977 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
979 # Create a cube containing the magnitude of the vector field.
980 cube_vec_mag = (cube_u**2 + cube_v**2) ** 0.5
981 cube_vec_mag.rename(f"{cube_u.name()}_{cube_v.name()}_magnitude")
983 # Specify the color bar
984 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag)
986 # Setup plot map projection, extent and coastlines.
987 axes = _setup_spatial_map(cube_vec_mag, fig, cmap)
989 if method == "contourf": 989 ↛ 992line 989 didn't jump to line 992 because the condition on line 989 was always true
990 # Filled contour plot of the field.
991 plot = iplt.contourf(cube_vec_mag, cmap=cmap, levels=levels, norm=norm)
992 elif method == "pcolormesh":
993 try:
994 vmin = min(levels)
995 vmax = max(levels)
996 except TypeError:
997 vmin, vmax = None, None
998 # pcolormesh plot of the field and ensure to use norm and not vmin/vmax
999 # if levels are defined.
1000 if norm is not None:
1001 vmin = None
1002 vmax = None
1003 plot = iplt.pcolormesh(cube_vec_mag, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax)
1004 else:
1005 raise ValueError(f"Unknown plotting method: {method}")
1007 # Check to see if transect, and if so, adjust y axis.
1008 if is_transect(cube_vec_mag): 1008 ↛ 1009line 1008 didn't jump to line 1009 because the condition on line 1008 was never true
1009 if "pressure" in [coord.name() for coord in cube_vec_mag.coords()]:
1010 axes.invert_yaxis()
1011 axes.set_yscale("log")
1012 axes.set_ylim(1100, 100)
1013 # If both model_level_number and level_height exists, iplt can construct
1014 # plot as a function of height above orography (NOT sea level).
1015 elif {"model_level_number", "level_height"}.issubset(
1016 {coord.name() for coord in cube_vec_mag.coords()}
1017 ):
1018 axes.set_yscale("log")
1020 axes.set_title(
1021 f"{title}\n"
1022 f"Start Lat: {cube_vec_mag.attributes['transect_coords'].split('_')[0]}"
1023 f" Start Lon: {cube_vec_mag.attributes['transect_coords'].split('_')[1]}"
1024 f" End Lat: {cube_vec_mag.attributes['transect_coords'].split('_')[2]}"
1025 f" End Lon: {cube_vec_mag.attributes['transect_coords'].split('_')[3]}",
1026 fontsize=16,
1027 )
1029 else:
1030 # Add title.
1031 axes.set_title(title, fontsize=16)
1033 # Add watermark with min/max/mean. Currently not user togglable.
1034 # In the bbox dictionary, fc and ec are hex colour codes for grey shade.
1035 axes.annotate(
1036 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}",
1037 xy=(1, -0.05),
1038 xycoords="axes fraction",
1039 xytext=(-5, 5),
1040 textcoords="offset points",
1041 ha="right",
1042 va="bottom",
1043 size=11,
1044 bbox=dict(boxstyle="round", fc="#cccccc", ec="#808080", alpha=0.9),
1045 )
1047 # Add colour bar.
1048 cbar = fig.colorbar(plot, orientation="horizontal", pad=0.042, shrink=0.7)
1049 cbar.set_label(label=f"{cube_vec_mag.name()} ({cube_vec_mag.units})", size=14)
1050 # add ticks and tick_labels for every levels if less than 20 levels exist
1051 if levels is not None and len(levels) < 20: 1051 ↛ 1052line 1051 didn't jump to line 1052 because the condition on line 1051 was never true
1052 cbar.set_ticks(levels)
1053 cbar.set_ticklabels([f"{level:.1f}" for level in levels])
1055 # 30 barbs along the longest axis of the plot, or a barb per point for data
1056 # with less than 30 points.
1057 step = max(max(cube_u.shape) // 30, 1)
1058 iplt.quiver(cube_u[::step, ::step], cube_v[::step, ::step], pivot="middle")
1060 # Save plot.
1061 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1062 logging.info("Saved vector plot to %s", filename)
1063 plt.close(fig)
1066def _plot_and_save_histogram_series(
1067 cubes: iris.cube.Cube | iris.cube.CubeList,
1068 filename: str,
1069 title: str,
1070 vmin: float,
1071 vmax: float,
1072 **kwargs,
1073):
1074 """Plot and save a histogram series.
1076 Parameters
1077 ----------
1078 cubes: Cube or CubeList
1079 2 dimensional Cube or CubeList of the data to plot as histogram.
1080 filename: str
1081 Filename of the plot to write.
1082 title: str
1083 Plot title.
1084 vmin: float
1085 minimum for colorbar
1086 vmax: float
1087 maximum for colorbar
1088 """
1089 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
1090 ax = plt.gca()
1092 model_colors_map = _get_model_colors_map(cubes)
1094 # Set default that histograms will produce probability density function
1095 # at each bin (integral over range sums to 1).
1096 density = True
1098 for cube in iter_maybe(cubes):
1099 # Easier to check title (where var name originates)
1100 # than seeing if long names exist etc.
1101 # Exception case, where distribution better fits log scales/bins.
1102 if "surface_microphysical" in title:
1103 if "amount" in title: 1103 ↛ 1105line 1103 didn't jump to line 1105 because the condition on line 1103 was never true
1104 # Compute histogram following Klingaman et al. (2017): ASoP
1105 bin2 = np.exp(np.log(0.02) + 0.1 * np.linspace(0, 99, 100))
1106 bins = np.pad(bin2, (1, 0), "constant", constant_values=0)
1107 density = False
1108 else:
1109 bins = 10.0 ** (
1110 np.arange(-10, 27, 1) / 10.0
1111 ) # Suggestion from RMED toolbox.
1112 bins = np.insert(bins, 0, 0)
1113 ax.set_yscale("log")
1114 vmin = bins[1]
1115 vmax = bins[-1] # Manually set vmin/vmax to override json derived value.
1116 ax.set_xscale("log")
1117 elif "lightning" in title:
1118 bins = [0, 1, 2, 3, 4, 5]
1119 else:
1120 bins = np.linspace(vmin, vmax, 51)
1121 logging.debug(
1122 "Plotting histogram with %s bins %s - %s.",
1123 np.size(bins),
1124 np.min(bins),
1125 np.max(bins),
1126 )
1128 # Reshape cube data into a single array to allow for a single histogram.
1129 # Otherwise we plot xdim histograms stacked.
1130 cube_data_1d = (cube.data).flatten()
1132 label = None
1133 color = "black"
1134 if model_colors_map: 1134 ↛ 1135line 1134 didn't jump to line 1135 because the condition on line 1134 was never true
1135 label = cube.attributes.get("model_name")
1136 color = model_colors_map[label]
1137 x, y = np.histogram(cube_data_1d, bins=bins, density=density)
1139 # Compute area under curve.
1140 if "surface_microphysical" in title and "amount" in title: 1140 ↛ 1141line 1140 didn't jump to line 1141 because the condition on line 1140 was never true
1141 bin_mean = (bins[:-1] + bins[1:]) / 2.0
1142 x = x * bin_mean / x.sum()
1143 x = x[1:]
1144 y = y[1:]
1146 ax.plot(
1147 y[:-1], x, color=color, linewidth=3, marker="o", markersize=6, label=label
1148 )
1150 # Add some labels and tweak the style.
1151 ax.set_title(title, fontsize=16)
1152 ax.set_xlabel(
1153 f"{iter_maybe(cubes)[0].name()} / {iter_maybe(cubes)[0].units}", fontsize=14
1154 )
1155 ax.set_ylabel("Normalised probability density", fontsize=14)
1156 if "surface_microphysical" in title and "amount" in title: 1156 ↛ 1157line 1156 didn't jump to line 1157 because the condition on line 1156 was never true
1157 ax.set_ylabel(
1158 f"Contribution to mean ({iter_maybe(cubes)[0].units})", fontsize=14
1159 )
1160 ax.set_xlim(vmin, vmax)
1161 ax.tick_params(axis="both", labelsize=12)
1163 # Overlay grid-lines onto histogram plot.
1164 ax.grid(linestyle="--", color="grey", linewidth=1)
1165 if model_colors_map: 1165 ↛ 1166line 1165 didn't jump to line 1166 because the condition on line 1165 was never true
1166 ax.legend(loc="best", ncol=1, frameon=False, fontsize=16)
1168 # Save plot.
1169 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1170 logging.info("Saved line plot to %s", filename)
1171 plt.close(fig)
1174def _plot_and_save_postage_stamp_histogram_series(
1175 cube: iris.cube.Cube,
1176 filename: str,
1177 title: str,
1178 stamp_coordinate: str,
1179 vmin: float,
1180 vmax: float,
1181 **kwargs,
1182):
1183 """Plot and save postage (ensemble members) stamps for a histogram series.
1185 Parameters
1186 ----------
1187 cube: Cube
1188 2 dimensional Cube of the data to plot as histogram.
1189 filename: str
1190 Filename of the plot to write.
1191 title: str
1192 Plot title.
1193 stamp_coordinate: str
1194 Coordinate that becomes different plots.
1195 vmin: float
1196 minimum for pdf x-axis
1197 vmax: float
1198 maximum for pdf x-axis
1199 """
1200 # Use the smallest square grid that will fit the members.
1201 grid_size = int(math.ceil(math.sqrt(len(cube.coord(stamp_coordinate).points))))
1203 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
1204 # Make a subplot for each member.
1205 for member, subplot in zip(
1206 cube.slices_over(stamp_coordinate), range(1, grid_size**2 + 1), strict=False
1207 ):
1208 # Implicit interface is much easier here, due to needing to have the
1209 # cartopy GeoAxes generated.
1210 plt.subplot(grid_size, grid_size, subplot)
1211 # Reshape cube data into a single array to allow for a single histogram.
1212 # Otherwise we plot xdim histograms stacked.
1213 member_data_1d = (member.data).flatten()
1214 plt.hist(member_data_1d, density=True, stacked=True)
1215 ax = plt.gca()
1216 ax.set_title(f"Member #{member.coord(stamp_coordinate).points[0]}")
1217 ax.set_xlim(vmin, vmax)
1219 # Overall figure title.
1220 fig.suptitle(title, fontsize=16)
1222 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1223 logging.info("Saved histogram postage stamp plot to %s", filename)
1224 plt.close(fig)
1227def _plot_and_save_postage_stamps_in_single_plot_histogram_series(
1228 cube: iris.cube.Cube,
1229 filename: str,
1230 title: str,
1231 stamp_coordinate: str,
1232 vmin: float,
1233 vmax: float,
1234 **kwargs,
1235):
1236 fig, ax = plt.subplots(figsize=(10, 10), facecolor="w", edgecolor="k")
1237 ax.set_title(title, fontsize=16)
1238 ax.set_xlim(vmin, vmax)
1239 ax.set_xlabel(f"{cube.name()} / {cube.units}", fontsize=14)
1240 ax.set_ylabel("normalised probability density", fontsize=14)
1241 # Loop over all slices along the stamp_coordinate
1242 for member in cube.slices_over(stamp_coordinate):
1243 # Flatten the member data to 1D
1244 member_data_1d = member.data.flatten()
1245 # Plot the histogram using plt.hist
1246 plt.hist(
1247 member_data_1d,
1248 density=True,
1249 stacked=True,
1250 label=f"Member #{member.coord(stamp_coordinate).points[0]}",
1251 )
1253 # Add a legend
1254 ax.legend(fontsize=16)
1256 # Save the figure to a file
1257 plt.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1259 # Close the figure
1260 plt.close(fig)
1263def _spatial_plot(
1264 method: Literal["contourf", "pcolormesh"],
1265 cube: iris.cube.Cube,
1266 filename: str | None,
1267 sequence_coordinate: str,
1268 stamp_coordinate: str,
1269):
1270 """Plot a spatial variable onto a map from a 2D, 3D, or 4D cube.
1272 A 2D spatial field can be plotted, but if the sequence_coordinate is present
1273 then a sequence of plots will be produced. Similarly if the stamp_coordinate
1274 is present then postage stamp plots will be produced.
1276 Parameters
1277 ----------
1278 method: "contourf" | "pcolormesh"
1279 The plotting method to use.
1280 cube: Cube
1281 Iris cube of the data to plot. It should have two spatial dimensions,
1282 such as lat and lon, and may also have a another two dimension to be
1283 plotted sequentially and/or as postage stamp plots.
1284 filename: str | None
1285 Name of the plot to write, used as a prefix for plot sequences. If None
1286 uses the recipe name.
1287 sequence_coordinate: str
1288 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
1289 This coordinate must exist in the cube.
1290 stamp_coordinate: str
1291 Coordinate about which to plot postage stamp plots. Defaults to
1292 ``"realization"``.
1294 Raises
1295 ------
1296 ValueError
1297 If the cube doesn't have the right dimensions.
1298 TypeError
1299 If the cube isn't a single cube.
1300 """
1301 recipe_title = get_recipe_metadata().get("title", "Untitled")
1303 # Ensure we have a name for the plot file.
1304 if filename is None:
1305 filename = slugify(recipe_title)
1307 # Ensure we've got a single cube.
1308 cube = _check_single_cube(cube)
1310 # Make postage stamp plots if stamp_coordinate exists and has more than a
1311 # single point.
1312 plotting_func = _plot_and_save_spatial_plot
1313 try:
1314 if cube.coord(stamp_coordinate).shape[0] > 1:
1315 plotting_func = _plot_and_save_postage_stamp_spatial_plot
1316 except iris.exceptions.CoordinateNotFoundError:
1317 pass
1319 # Must have a sequence coordinate.
1320 try:
1321 cube.coord(sequence_coordinate)
1322 except iris.exceptions.CoordinateNotFoundError as err:
1323 raise ValueError(f"Cube must have a {sequence_coordinate} coordinate.") from err
1325 # Create a plot for each value of the sequence coordinate.
1326 plot_index = []
1327 nplot = np.size(cube.coord(sequence_coordinate).points)
1328 for cube_slice in cube.slices_over(sequence_coordinate):
1329 # Use sequence value so multiple sequences can merge.
1330 sequence_value = cube_slice.coord(sequence_coordinate).points[0]
1331 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png"
1332 coord = cube_slice.coord(sequence_coordinate)
1333 # Format the coordinate value in a unit appropriate way.
1334 title = f"{recipe_title}\n [{coord.units.title(coord.points[0])}]"
1335 # Use sequence (e.g. time) bounds if plotting single non-sequence outputs
1336 if nplot == 1 and coord.has_bounds:
1337 if np.size(coord.bounds) > 1:
1338 title = f"{recipe_title}\n [{coord.units.title(coord.bounds[0][0])} to {coord.units.title(coord.bounds[0][1])}]"
1339 # Do the actual plotting.
1340 plotting_func(
1341 cube_slice,
1342 filename=plot_filename,
1343 stamp_coordinate=stamp_coordinate,
1344 title=title,
1345 method=method,
1346 )
1347 plot_index.append(plot_filename)
1349 # Add list of plots to plot metadata.
1350 complete_plot_index = _append_to_plot_index(plot_index)
1352 # Make a page to display the plots.
1353 _make_plot_html_page(complete_plot_index)
1356def _custom_colormap_mask(cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None):
1357 """Get colourmap for mask.
1359 If "mask_for_" appears anywhere in the name of a cube this function will be called
1360 regardless of the name of the variable to ensure a consistent plot.
1362 Parameters
1363 ----------
1364 cube: Cube
1365 Cube of variable for which the colorbar information is desired.
1366 axis: "x", "y", optional
1367 Select the levels for just this axis of a line plot. The min and max
1368 can be set by xmin/xmax or ymin/ymax respectively. For variables where
1369 setting a universal range is not desirable (e.g. temperature), users
1370 can set ymin/ymax values to "auto" in the colorbar definitions file.
1371 Where no additional xmin/xmax or ymin/ymax values are provided, the
1372 axis bounds default to use the vmin/vmax values provided.
1374 Returns
1375 -------
1376 cmap:
1377 Matplotlib colormap.
1378 levels:
1379 List of levels to use for plotting. For continuous plots the min and max
1380 should be taken as the range.
1381 norm:
1382 BoundaryNorm information.
1383 """
1384 if "difference" not in cube.long_name:
1385 if axis:
1386 levels = [0, 1]
1387 # Complete settings based on levels.
1388 return None, levels, None
1389 else:
1390 # Define the levels and colors.
1391 levels = [0, 1, 2]
1392 colors = ["white", "dodgerblue"]
1393 # Create a custom color map.
1394 cmap = mcolors.ListedColormap(colors)
1395 # Normalize the levels.
1396 norm = mcolors.BoundaryNorm(levels, cmap.N)
1397 logging.debug("Colourmap for %s.", cube.long_name)
1398 return cmap, levels, norm
1399 else:
1400 if axis:
1401 levels = [-1, 1]
1402 return None, levels, None
1403 else:
1404 # Search for if mask difference, set to +/- 0.5 as values plotted <
1405 # not <=.
1406 levels = [-2, -0.5, 0.5, 2]
1407 colors = ["goldenrod", "white", "teal"]
1408 cmap = mcolors.ListedColormap(colors)
1409 norm = mcolors.BoundaryNorm(levels, cmap.N)
1410 logging.debug("Colourmap for %s.", cube.long_name)
1411 return cmap, levels, norm
1414def _custom_beaufort_scale(cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None):
1415 """Get a custom colorbar for a cube in the Beaufort Scale.
1417 Specific variable ranges can be separately set in user-supplied definition
1418 for x- or y-axis limits, or indicate where automated range preferred.
1420 Parameters
1421 ----------
1422 cube: Cube
1423 Cube of variable with Beaufort Scale in name.
1424 axis: "x", "y", optional
1425 Select the levels for just this axis of a line plot. The min and max
1426 can be set by xmin/xmax or ymin/ymax respectively. For variables where
1427 setting a universal range is not desirable (e.g. temperature), users
1428 can set ymin/ymax values to "auto" in the colorbar definitions file.
1429 Where no additional xmin/xmax or ymin/ymax values are provided, the
1430 axis bounds default to use the vmin/vmax values provided.
1432 Returns
1433 -------
1434 cmap:
1435 Matplotlib colormap.
1436 levels:
1437 List of levels to use for plotting. For continuous plots the min and max
1438 should be taken as the range.
1439 norm:
1440 BoundaryNorm information.
1441 """
1442 if "difference" not in cube.long_name:
1443 if axis:
1444 levels = [0, 12]
1445 return None, levels, None
1446 else:
1447 levels = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]
1448 colors = [
1449 "black",
1450 (0, 0, 0.6),
1451 "blue",
1452 "cyan",
1453 "green",
1454 "yellow",
1455 (1, 0.5, 0),
1456 "red",
1457 "pink",
1458 "magenta",
1459 "purple",
1460 "maroon",
1461 "white",
1462 ]
1463 cmap = mcolors.ListedColormap(colors)
1464 norm = mcolors.BoundaryNorm(levels, cmap.N)
1465 logging.info("change colormap for Beaufort Scale colorbar.")
1466 return cmap, levels, norm
1467 else:
1468 if axis:
1469 levels = [-4, 4]
1470 return None, levels, None
1471 else:
1472 levels = [
1473 -3.5,
1474 -2.5,
1475 -1.5,
1476 -0.5,
1477 0.5,
1478 1.5,
1479 2.5,
1480 3.5,
1481 ]
1482 cmap = plt.get_cmap("bwr", 8)
1483 norm = mcolors.BoundaryNorm(levels, cmap.N)
1484 return cmap, levels, norm
1487def _custom_colourmap_precipitation(cube: iris.cube.Cube, cmap, levels, norm):
1488 """Return a custom colourmap for the current recipe."""
1489 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name])
1490 if (
1491 any("surface_microphysical" in name for name in varnames)
1492 and "difference" not in cube.long_name
1493 and "mask" not in cube.long_name
1494 ):
1495 # Define the levels and colors
1496 levels = [0, 0.125, 0.25, 0.5, 1, 2, 4, 8, 16, 32, 64, 128, 256]
1497 colors = [
1498 "w",
1499 (0, 0, 0.6),
1500 "b",
1501 "c",
1502 "g",
1503 "y",
1504 (1, 0.5, 0),
1505 "r",
1506 "pink",
1507 "m",
1508 "purple",
1509 "maroon",
1510 "gray",
1511 ]
1512 # Create a custom colormap
1513 cmap = mcolors.ListedColormap(colors)
1514 # Normalize the levels
1515 norm = mcolors.BoundaryNorm(levels, cmap.N)
1516 logging.info("change colormap for surface_microphysical variable colorbar.")
1517 else:
1518 # do nothing and keep existing colorbar attributes
1519 cmap = cmap
1520 levels = levels
1521 norm = norm
1522 return cmap, levels, norm
1525def _custom_colourmap_visibility_in_air(cube: iris.cube.Cube, cmap, levels, norm):
1526 """Return a custom colourmap for the current recipe."""
1527 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name])
1528 if (
1529 any("visibility_in_air" in name for name in varnames)
1530 and "difference" not in cube.long_name
1531 and "mask" not in cube.long_name
1532 ):
1533 # Define the levels and colors (in km)
1534 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]
1535 norm = mcolors.BoundaryNorm(levels, cmap.N)
1536 colours = [
1537 "#8f00d6",
1538 "#d10000",
1539 "#ff9700",
1540 "#ffff00",
1541 "#00007f",
1542 "#6c9ccd",
1543 "#aae8ff",
1544 "#37a648",
1545 "#8edc64",
1546 "#c5ffc5",
1547 "#dcdcdc",
1548 "#ffffff",
1549 ]
1550 # Create a custom colormap
1551 cmap = mcolors.ListedColormap(colours)
1552 # Normalize the levels
1553 norm = mcolors.BoundaryNorm(levels, cmap.N)
1554 logging.info("change colormap for visibility_in_air variable colorbar.")
1555 else:
1556 # do nothing and keep existing colorbar attributes
1557 cmap = cmap
1558 levels = levels
1559 norm = norm
1560 return cmap, levels, norm
1563def _get_num_models(cube: iris.cube.Cube | iris.cube.CubeList) -> int:
1564 """Return number of models based on cube attributes."""
1565 model_names = list(
1566 filter(
1567 lambda x: x is not None,
1568 {cb.attributes.get("model_name", None) for cb in iter_maybe(cube)},
1569 )
1570 )
1571 if not model_names:
1572 logging.debug("Missing model names. Will assume single model.")
1573 return 1
1574 else:
1575 return len(model_names)
1578def _validate_cube_shape(
1579 cube: iris.cube.Cube | iris.cube.CubeList, num_models: int
1580) -> None:
1581 """Check all cubes have a model name."""
1582 if isinstance(cube, iris.cube.CubeList) and len(cube) != num_models: 1582 ↛ 1583line 1582 didn't jump to line 1583 because the condition on line 1582 was never true
1583 raise ValueError(
1584 f"The number of model names ({num_models}) should equal the number "
1585 f"of cubes ({len(cube)})."
1586 )
1589def _validate_cubes_coords(
1590 cubes: iris.cube.CubeList, coords: list[iris.coords.Coord]
1591) -> None:
1592 """Check same number of cubes as sequence coordinate for zip functions."""
1593 if len(cubes) != len(coords): 1593 ↛ 1594line 1593 didn't jump to line 1594 because the condition on line 1593 was never true
1594 raise ValueError(
1595 f"The number of CubeList entries ({len(cubes)}) should equal the number "
1596 f"of sequence coordinates ({len(coords)})."
1597 f"Check that number of time entries in input data are consistent if "
1598 f"performing time-averaging steps prior to plotting outputs."
1599 )
1602####################
1603# Public functions #
1604####################
1607def spatial_contour_plot(
1608 cube: iris.cube.Cube,
1609 filename: str = None,
1610 sequence_coordinate: str = "time",
1611 stamp_coordinate: str = "realization",
1612 **kwargs,
1613) -> iris.cube.Cube:
1614 """Plot a spatial variable onto a map from a 2D, 3D, or 4D cube.
1616 A 2D spatial field can be plotted, but if the sequence_coordinate is present
1617 then a sequence of plots will be produced. Similarly if the stamp_coordinate
1618 is present then postage stamp plots will be produced.
1620 Parameters
1621 ----------
1622 cube: Cube
1623 Iris cube of the data to plot. It should have two spatial dimensions,
1624 such as lat and lon, and may also have a another two dimension to be
1625 plotted sequentially and/or as postage stamp plots.
1626 filename: str, optional
1627 Name of the plot to write, used as a prefix for plot sequences. Defaults
1628 to the recipe name.
1629 sequence_coordinate: str, optional
1630 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
1631 This coordinate must exist in the cube.
1632 stamp_coordinate: str, optional
1633 Coordinate about which to plot postage stamp plots. Defaults to
1634 ``"realization"``.
1636 Returns
1637 -------
1638 Cube
1639 The original cube (so further operations can be applied).
1641 Raises
1642 ------
1643 ValueError
1644 If the cube doesn't have the right dimensions.
1645 TypeError
1646 If the cube isn't a single cube.
1647 """
1648 _spatial_plot("contourf", cube, filename, sequence_coordinate, stamp_coordinate)
1649 return cube
1652def spatial_pcolormesh_plot(
1653 cube: iris.cube.Cube,
1654 filename: str = None,
1655 sequence_coordinate: str = "time",
1656 stamp_coordinate: str = "realization",
1657 **kwargs,
1658) -> iris.cube.Cube:
1659 """Plot a spatial variable onto a map from a 2D, 3D, or 4D cube.
1661 A 2D spatial field can be plotted, but if the sequence_coordinate is present
1662 then a sequence of plots will be produced. Similarly if the stamp_coordinate
1663 is present then postage stamp plots will be produced.
1665 This function is significantly faster than ``spatial_contour_plot``,
1666 especially at high resolutions, and should be preferred unless contiguous
1667 contour areas are important.
1669 Parameters
1670 ----------
1671 cube: Cube
1672 Iris cube of the data to plot. It should have two spatial dimensions,
1673 such as lat and lon, and may also have a another two dimension to be
1674 plotted sequentially and/or as postage stamp plots.
1675 filename: str, optional
1676 Name of the plot to write, used as a prefix for plot sequences. Defaults
1677 to the recipe name.
1678 sequence_coordinate: str, optional
1679 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
1680 This coordinate must exist in the cube.
1681 stamp_coordinate: str, optional
1682 Coordinate about which to plot postage stamp plots. Defaults to
1683 ``"realization"``.
1685 Returns
1686 -------
1687 Cube
1688 The original cube (so further operations can be applied).
1690 Raises
1691 ------
1692 ValueError
1693 If the cube doesn't have the right dimensions.
1694 TypeError
1695 If the cube isn't a single cube.
1696 """
1697 _spatial_plot("pcolormesh", cube, filename, sequence_coordinate, stamp_coordinate)
1698 return cube
1701# TODO: Expand function to handle ensemble data.
1702# line_coordinate: str, optional
1703# Coordinate about which to plot multiple lines. Defaults to
1704# ``"realization"``.
1705def plot_line_series(
1706 cube: iris.cube.Cube | iris.cube.CubeList,
1707 filename: str = None,
1708 series_coordinate: str = "time",
1709 # line_coordinate: str = "realization",
1710 **kwargs,
1711) -> iris.cube.Cube | iris.cube.CubeList:
1712 """Plot a line plot for the specified coordinate.
1714 The Cube or CubeList must be 1D.
1716 Parameters
1717 ----------
1718 iris.cube | iris.cube.CubeList
1719 Cube or CubeList of the data to plot. The individual cubes should have a single dimension.
1720 The cubes should cover the same phenomenon i.e. all cubes contain temperature data.
1721 We do not support different data such as temperature and humidity in the same CubeList for plotting.
1722 filename: str, optional
1723 Name of the plot to write, used as a prefix for plot sequences. Defaults
1724 to the recipe name.
1725 series_coordinate: str, optional
1726 Coordinate about which to make a series. Defaults to ``"time"``. This
1727 coordinate must exist in the cube.
1729 Returns
1730 -------
1731 iris.cube.Cube | iris.cube.CubeList
1732 The original Cube or CubeList (so further operations can be applied).
1733 plotted data.
1735 Raises
1736 ------
1737 ValueError
1738 If the cubes don't have the right dimensions.
1739 TypeError
1740 If the cube isn't a Cube or CubeList.
1741 """
1742 # Ensure we have a name for the plot file.
1743 title = get_recipe_metadata().get("title", "Untitled")
1745 if filename is None:
1746 filename = slugify(title)
1748 # Add file extension.
1749 plot_filename = f"{filename.rsplit('.', 1)[0]}.png"
1751 num_models = _get_num_models(cube)
1753 _validate_cube_shape(cube, num_models)
1755 # Iterate over all cubes and extract coordinate to plot.
1756 cubes = iter_maybe(cube)
1757 coords = []
1758 for cube in cubes:
1759 try:
1760 coords.append(cube.coord(series_coordinate))
1761 except iris.exceptions.CoordinateNotFoundError as err:
1762 raise ValueError(
1763 f"Cube must have a {series_coordinate} coordinate."
1764 ) from err
1765 if cube.ndim > 2 or not cube.coords("realization"):
1766 raise ValueError("Cube must be 1D or 2D with a realization coordinate.")
1768 # Do the actual plotting.
1769 _plot_and_save_line_series(cubes, coords, "realization", plot_filename, title)
1771 # Add list of plots to plot metadata.
1772 plot_index = _append_to_plot_index([plot_filename])
1774 # Make a page to display the plots.
1775 _make_plot_html_page(plot_index)
1777 return cube
1780def plot_vertical_line_series(
1781 cubes: iris.cube.Cube | iris.cube.CubeList,
1782 filename: str = None,
1783 series_coordinate: str = "model_level_number",
1784 sequence_coordinate: str = "time",
1785 # line_coordinate: str = "realization",
1786 **kwargs,
1787) -> iris.cube.Cube | iris.cube.CubeList:
1788 """Plot a line plot against a type of vertical coordinate.
1790 The Cube or CubeList must be 1D.
1792 A 1D line plot with y-axis as pressure coordinate can be plotted, but if the sequence_coordinate is present
1793 then a sequence of plots will be produced.
1795 Parameters
1796 ----------
1797 iris.cube | iris.cube.CubeList
1798 Cube or CubeList of the data to plot. The individual cubes should have a single dimension.
1799 The cubes should cover the same phenomenon i.e. all cubes contain temperature data.
1800 We do not support different data such as temperature and humidity in the same CubeList for plotting.
1801 filename: str, optional
1802 Name of the plot to write, used as a prefix for plot sequences. Defaults
1803 to the recipe name.
1804 series_coordinate: str, optional
1805 Coordinate to plot on the y-axis. Can be ``pressure`` or
1806 ``model_level_number`` for UM, or ``full_levels`` or ``half_levels``
1807 for LFRic. Defaults to ``model_level_number``.
1808 This coordinate must exist in the cube.
1809 sequence_coordinate: str, optional
1810 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
1811 This coordinate must exist in the cube.
1813 Returns
1814 -------
1815 iris.cube.Cube | iris.cube.CubeList
1816 The original Cube or CubeList (so further operations can be applied).
1817 Plotted data.
1819 Raises
1820 ------
1821 ValueError
1822 If the cubes doesn't have the right dimensions.
1823 TypeError
1824 If the cube isn't a Cube or CubeList.
1825 """
1826 # Ensure we have a name for the plot file.
1827 recipe_title = get_recipe_metadata().get("title", "Untitled")
1829 if filename is None:
1830 filename = slugify(recipe_title)
1832 cubes = iter_maybe(cubes)
1833 # Initialise empty list to hold all data from all cubes in a CubeList
1834 all_data = []
1836 # Store min/max ranges for x range.
1837 x_levels = []
1839 num_models = _get_num_models(cubes)
1841 _validate_cube_shape(cubes, num_models)
1843 # Iterate over all cubes in cube or CubeList and plot.
1844 coords = []
1845 for cube in cubes:
1846 # Test if series coordinate i.e. pressure level exist for any cube with cube.ndim >=1.
1847 try:
1848 coords.append(cube.coord(series_coordinate))
1849 except iris.exceptions.CoordinateNotFoundError as err:
1850 raise ValueError(
1851 f"Cube must have a {series_coordinate} coordinate."
1852 ) from err
1854 try:
1855 if cube.ndim > 1 or not cube.coords("realization"): 1855 ↛ 1863line 1855 didn't jump to line 1863 because the condition on line 1855 was always true
1856 cube.coord(sequence_coordinate)
1857 except iris.exceptions.CoordinateNotFoundError as err:
1858 raise ValueError(
1859 f"Cube must have a {sequence_coordinate} coordinate or be 1D, or 2D with a realization coordinate."
1860 ) from err
1862 # Get minimum and maximum from levels information.
1863 _, levels, _ = _colorbar_map_levels(cube, axis="x")
1864 if levels is not None: 1864 ↛ 1868line 1864 didn't jump to line 1868 because the condition on line 1864 was always true
1865 x_levels.append(min(levels))
1866 x_levels.append(max(levels))
1867 else:
1868 all_data.append(cube.data)
1870 if len(x_levels) == 0: 1870 ↛ 1872line 1870 didn't jump to line 1872 because the condition on line 1870 was never true
1871 # Combine all data into a single NumPy array
1872 combined_data = np.concatenate(all_data)
1874 # Set the lower and upper limit for the x-axis to ensure all plots have
1875 # same range. This needs to read the whole cube over the range of the
1876 # sequence and if applicable postage stamp coordinate.
1877 vmin = np.floor(combined_data.min())
1878 vmax = np.ceil(combined_data.max())
1879 else:
1880 vmin = min(x_levels)
1881 vmax = max(x_levels)
1883 # Matching the slices (matching by seq coord point; it may happen that
1884 # evaluated models do not cover the same seq coord range, hence matching
1885 # necessary)
1886 def filter_cube_iterables(cube_iterables) -> bool:
1887 return len(cube_iterables) == len(coords)
1889 cube_iterables = filter(
1890 filter_cube_iterables,
1891 (
1892 iris.cube.CubeList(
1893 s
1894 for s in itertools.chain.from_iterable(
1895 cb.slices_over(sequence_coordinate) for cb in cubes
1896 )
1897 if s.coord(sequence_coordinate).points[0] == point
1898 )
1899 for point in sorted(
1900 set(
1901 itertools.chain.from_iterable(
1902 cb.coord(sequence_coordinate).points for cb in cubes
1903 )
1904 )
1905 )
1906 ),
1907 )
1909 # Create a plot for each value of the sequence coordinate.
1910 # Allowing for multiple cubes in a CubeList to be plotted in the same plot for
1911 # similar sequence values. Passing a CubeList into the internal plotting function
1912 # for similar values of the sequence coordinate. cube_slice can be an iris.cube.Cube
1913 # or an iris.cube.CubeList.
1914 plot_index = []
1915 nplot = np.size(cubes[0].coord(sequence_coordinate).points)
1916 for cubes_slice in cube_iterables:
1917 # Use sequence value so multiple sequences can merge.
1918 seq_coord = cubes_slice[0].coord(sequence_coordinate)
1919 sequence_value = seq_coord.points[0]
1920 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png"
1921 # Format the coordinate value in a unit appropriate way.
1922 title = f"{recipe_title}\n [{seq_coord.units.title(sequence_value)}]"
1923 # Use sequence (e.g. time) bounds if plotting single non-sequence outputs
1924 if nplot == 1 and seq_coord.has_bounds: 1924 ↛ 1925line 1924 didn't jump to line 1925 because the condition on line 1924 was never true
1925 if np.size(seq_coord.bounds) > 1:
1926 title = f"{recipe_title}\n [{seq_coord.units.title(seq_coord.bounds[0][0])} to {seq_coord.units.title(seq_coord.bounds[0][1])}]"
1927 # Do the actual plotting.
1928 _plot_and_save_vertical_line_series(
1929 cubes_slice,
1930 coords,
1931 "realization",
1932 plot_filename,
1933 series_coordinate,
1934 title=title,
1935 vmin=vmin,
1936 vmax=vmax,
1937 )
1938 plot_index.append(plot_filename)
1940 # Add list of plots to plot metadata.
1941 complete_plot_index = _append_to_plot_index(plot_index)
1943 # Make a page to display the plots.
1944 _make_plot_html_page(complete_plot_index)
1946 return cubes
1949def scatter_plot(
1950 cube_x: iris.cube.Cube | iris.cube.CubeList,
1951 cube_y: iris.cube.Cube | iris.cube.CubeList,
1952 filename: str = None,
1953 one_to_one: bool = True,
1954 **kwargs,
1955) -> iris.cube.CubeList:
1956 """Plot a scatter plot between two variables.
1958 Both cubes must be 1D.
1960 Parameters
1961 ----------
1962 cube_x: Cube | CubeList
1963 1 dimensional Cube of the data to plot on y-axis.
1964 cube_y: Cube | CubeList
1965 1 dimensional Cube of the data to plot on x-axis.
1966 filename: str, optional
1967 Filename of the plot to write.
1968 one_to_one: bool, optional
1969 If True a 1:1 line is plotted; if False it is not. Default is True.
1971 Returns
1972 -------
1973 cubes: CubeList
1974 CubeList of the original x and y cubes for further processing.
1976 Raises
1977 ------
1978 ValueError
1979 If the cube doesn't have the right dimensions and cubes not the same
1980 size.
1981 TypeError
1982 If the cube isn't a single cube.
1984 Notes
1985 -----
1986 Scatter plots are used for determining if there is a relationship between
1987 two variables. Positive relations have a slope going from bottom left to top
1988 right; Negative relations have a slope going from top left to bottom right.
1990 A variant of the scatter plot is the quantile-quantile plot. This plot does
1991 not use all data points, but the selected quantiles of each variable
1992 instead. Quantile-quantile plots are valuable for comparing against
1993 observations and other models. Identical percentiles between the variables
1994 will lie on the one-to-one line implying the values correspond well to each
1995 other. Where there is a deviation from the one-to-one line a range of
1996 possibilities exist depending on how and where the data is shifted (e.g.,
1997 Wilks 2011 [Wilks2011]_).
1999 For distributions above the one-to-one line the distribution is left-skewed;
2000 below is right-skewed. A distinct break implies a bimodal distribution, and
2001 closer values/values further apart at the tails imply poor representation of
2002 the extremes.
2004 References
2005 ----------
2006 .. [Wilks2011] Wilks, D.S., (2011) "Statistical Methods in the Atmospheric
2007 Sciences" Third Edition, vol. 100, Academic Press, Oxford, UK, 676 pp.
2008 """
2009 # Iterate over all cubes in cube or CubeList and plot.
2010 for cube_iter in iter_maybe(cube_x):
2011 # Check cubes are correct shape.
2012 cube_iter = _check_single_cube(cube_iter)
2013 if cube_iter.ndim > 1:
2014 raise ValueError("cube_x must be 1D.")
2016 # Iterate over all cubes in cube or CubeList and plot.
2017 for cube_iter in iter_maybe(cube_y):
2018 # Check cubes are correct shape.
2019 cube_iter = _check_single_cube(cube_iter)
2020 if cube_iter.ndim > 1:
2021 raise ValueError("cube_y must be 1D.")
2023 # Ensure we have a name for the plot file.
2024 title = get_recipe_metadata().get("title", "Untitled")
2026 if filename is None:
2027 filename = slugify(title)
2029 # Add file extension.
2030 plot_filename = f"{filename.rsplit('.', 1)[0]}.png"
2032 # Do the actual plotting.
2033 _plot_and_save_scatter_plot(cube_x, cube_y, plot_filename, title, one_to_one)
2035 # Add list of plots to plot metadata.
2036 plot_index = _append_to_plot_index([plot_filename])
2038 # Make a page to display the plots.
2039 _make_plot_html_page(plot_index)
2041 return iris.cube.CubeList([cube_x, cube_y])
2044def vector_plot(
2045 cube_u: iris.cube.Cube,
2046 cube_v: iris.cube.Cube,
2047 filename: str = None,
2048 sequence_coordinate: str = "time",
2049 **kwargs,
2050) -> iris.cube.CubeList:
2051 """Plot a vector plot based on the input u and v components."""
2052 recipe_title = get_recipe_metadata().get("title", "Untitled")
2054 # Ensure we have a name for the plot file.
2055 if filename is None: 2055 ↛ 2056line 2055 didn't jump to line 2056 because the condition on line 2055 was never true
2056 filename = slugify(recipe_title)
2058 # Cubes must have a matching sequence coordinate.
2059 try:
2060 # Check that the u and v cubes have the same sequence coordinate.
2061 if cube_u.coord(sequence_coordinate) != cube_v.coord(sequence_coordinate): 2061 ↛ 2062line 2061 didn't jump to line 2062 because the condition on line 2061 was never true
2062 raise ValueError("Coordinates do not match.")
2063 except (iris.exceptions.CoordinateNotFoundError, ValueError) as err:
2064 raise ValueError(
2065 f"Cubes should have matching {sequence_coordinate} coordinate:\n{cube_u}\n{cube_v}"
2066 ) from err
2068 # Create a plot for each value of the sequence coordinate.
2069 plot_index = []
2070 for cube_u_slice, cube_v_slice in zip(
2071 cube_u.slices_over(sequence_coordinate),
2072 cube_v.slices_over(sequence_coordinate),
2073 strict=True,
2074 ):
2075 # Use sequence value so multiple sequences can merge.
2076 sequence_value = cube_u_slice.coord(sequence_coordinate).points[0]
2077 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png"
2078 coord = cube_u_slice.coord(sequence_coordinate)
2079 # Format the coordinate value in a unit appropriate way.
2080 title = f"{recipe_title}\n{coord.units.title(coord.points[0])}"
2081 # Do the actual plotting.
2082 _plot_and_save_vector_plot(
2083 cube_u_slice,
2084 cube_v_slice,
2085 filename=plot_filename,
2086 title=title,
2087 method="contourf",
2088 )
2089 plot_index.append(plot_filename)
2091 # Add list of plots to plot metadata.
2092 complete_plot_index = _append_to_plot_index(plot_index)
2094 # Make a page to display the plots.
2095 _make_plot_html_page(complete_plot_index)
2097 return iris.cube.CubeList([cube_u, cube_v])
2100def plot_histogram_series(
2101 cubes: iris.cube.Cube | iris.cube.CubeList,
2102 filename: str = None,
2103 sequence_coordinate: str = "time",
2104 stamp_coordinate: str = "realization",
2105 single_plot: bool = False,
2106 **kwargs,
2107) -> iris.cube.Cube | iris.cube.CubeList:
2108 """Plot a histogram plot for each vertical level provided.
2110 A histogram plot can be plotted, but if the sequence_coordinate (i.e. time)
2111 is present then a sequence of plots will be produced using the time slider
2112 functionality to scroll through histograms against time. If a
2113 stamp_coordinate is present then postage stamp plots will be produced. If
2114 stamp_coordinate and single_plot is True, all postage stamp plots will be
2115 plotted in a single plot instead of separate postage stamp plots.
2117 Parameters
2118 ----------
2119 cubes: Cube | iris.cube.CubeList
2120 Iris cube or CubeList of the data to plot. It should have a single dimension other
2121 than the stamp coordinate.
2122 The cubes should cover the same phenomenon i.e. all cubes contain temperature data.
2123 We do not support different data such as temperature and humidity in the same CubeList for plotting.
2124 filename: str, optional
2125 Name of the plot to write, used as a prefix for plot sequences. Defaults
2126 to the recipe name.
2127 sequence_coordinate: str, optional
2128 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
2129 This coordinate must exist in the cube and will be used for the time
2130 slider.
2131 stamp_coordinate: str, optional
2132 Coordinate about which to plot postage stamp plots. Defaults to
2133 ``"realization"``.
2134 single_plot: bool, optional
2135 If True, all postage stamp plots will be plotted in a single plot. If
2136 False, each postage stamp plot will be plotted separately. Is only valid
2137 if stamp_coordinate exists and has more than a single point.
2139 Returns
2140 -------
2141 iris.cube.Cube | iris.cube.CubeList
2142 The original Cube or CubeList (so further operations can be applied).
2143 Plotted data.
2145 Raises
2146 ------
2147 ValueError
2148 If the cube doesn't have the right dimensions.
2149 TypeError
2150 If the cube isn't a Cube or CubeList.
2151 """
2152 recipe_title = get_recipe_metadata().get("title", "Untitled")
2154 cubes = iter_maybe(cubes)
2156 # Ensure we have a name for the plot file.
2157 if filename is None:
2158 filename = slugify(recipe_title)
2160 # Internal plotting function.
2161 plotting_func = _plot_and_save_histogram_series
2163 num_models = _get_num_models(cubes)
2165 _validate_cube_shape(cubes, num_models)
2167 # If several histograms are plotted with time as sequence_coordinate for the
2168 # time slider option.
2169 for cube in cubes:
2170 try:
2171 cube.coord(sequence_coordinate)
2172 except iris.exceptions.CoordinateNotFoundError as err:
2173 raise ValueError(
2174 f"Cube must have a {sequence_coordinate} coordinate."
2175 ) from err
2177 # Get minimum and maximum from levels information.
2178 levels = None
2179 for cube in cubes: 2179 ↛ 2195line 2179 didn't jump to line 2195 because the loop on line 2179 didn't complete
2180 # First check if user-specified "auto" range variable.
2181 # This maintains the value of levels as None, so proceed.
2182 _, levels, _ = _colorbar_map_levels(cube, axis="y")
2183 if levels is None:
2184 break
2185 # If levels is changed, recheck to use the vmin,vmax or
2186 # levels-based ranges for histogram plots.
2187 _, levels, _ = _colorbar_map_levels(cube)
2188 logging.debug("levels: %s", levels)
2189 if levels is not None: 2189 ↛ 2179line 2189 didn't jump to line 2179 because the condition on line 2189 was always true
2190 vmin = min(levels)
2191 vmax = max(levels)
2192 logging.debug("Updated vmin, vmax: %s, %s", vmin, vmax)
2193 break
2195 if levels is None:
2196 vmin = min(cb.data.min() for cb in cubes)
2197 vmax = max(cb.data.max() for cb in cubes)
2199 # Make postage stamp plots if stamp_coordinate exists and has more than a
2200 # single point. If single_plot is True:
2201 # -- all postage stamp plots will be plotted in a single plot instead of
2202 # separate postage stamp plots.
2203 # -- model names (hidden in cube attrs) are ignored, that is stamp plots are
2204 # produced per single model only
2205 if num_models == 1: 2205 ↛ 2218line 2205 didn't jump to line 2218 because the condition on line 2205 was always true
2206 if ( 2206 ↛ 2210line 2206 didn't jump to line 2210 because the condition on line 2206 was never true
2207 stamp_coordinate in [c.name() for c in cubes[0].coords()]
2208 and cubes[0].coord(stamp_coordinate).shape[0] > 1
2209 ):
2210 if single_plot:
2211 plotting_func = (
2212 _plot_and_save_postage_stamps_in_single_plot_histogram_series
2213 )
2214 else:
2215 plotting_func = _plot_and_save_postage_stamp_histogram_series
2216 cube_iterables = cubes[0].slices_over(sequence_coordinate)
2217 else:
2218 all_points = sorted(
2219 set(
2220 itertools.chain.from_iterable(
2221 cb.coord(sequence_coordinate).points for cb in cubes
2222 )
2223 )
2224 )
2225 all_slices = list(
2226 itertools.chain.from_iterable(
2227 cb.slices_over(sequence_coordinate) for cb in cubes
2228 )
2229 )
2230 # Matched slices (matched by seq coord point; it may happen that
2231 # evaluated models do not cover the same seq coord range, hence matching
2232 # necessary)
2233 cube_iterables = [
2234 iris.cube.CubeList(
2235 s for s in all_slices if s.coord(sequence_coordinate).points[0] == point
2236 )
2237 for point in all_points
2238 ]
2240 plot_index = []
2241 nplot = np.size(cube.coord(sequence_coordinate).points)
2242 # Create a plot for each value of the sequence coordinate. Allowing for
2243 # multiple cubes in a CubeList to be plotted in the same plot for similar
2244 # sequence values. Passing a CubeList into the internal plotting function
2245 # for similar values of the sequence coordinate. cube_slice can be an
2246 # iris.cube.Cube or an iris.cube.CubeList.
2247 for cube_slice in cube_iterables:
2248 single_cube = cube_slice
2249 if isinstance(cube_slice, iris.cube.CubeList): 2249 ↛ 2250line 2249 didn't jump to line 2250 because the condition on line 2249 was never true
2250 single_cube = cube_slice[0]
2252 # Use sequence value so multiple sequences can merge.
2253 sequence_value = single_cube.coord(sequence_coordinate).points[0]
2254 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png"
2255 coord = single_cube.coord(sequence_coordinate)
2256 # Format the coordinate value in a unit appropriate way.
2257 title = f"{recipe_title}\n [{coord.units.title(coord.points[0])}]"
2258 # Use sequence (e.g. time) bounds if plotting single non-sequence outputs
2259 if nplot == 1 and coord.has_bounds: 2259 ↛ 2260line 2259 didn't jump to line 2260 because the condition on line 2259 was never true
2260 if np.size(coord.bounds) > 1:
2261 title = f"{recipe_title}\n [{coord.units.title(coord.bounds[0][0])} to {coord.units.title(coord.bounds[0][1])}]"
2262 # Do the actual plotting.
2263 plotting_func(
2264 cube_slice,
2265 filename=plot_filename,
2266 stamp_coordinate=stamp_coordinate,
2267 title=title,
2268 vmin=vmin,
2269 vmax=vmax,
2270 )
2271 plot_index.append(plot_filename)
2273 # Add list of plots to plot metadata.
2274 complete_plot_index = _append_to_plot_index(plot_index)
2276 # Make a page to display the plots.
2277 _make_plot_html_page(complete_plot_index)
2279 return cubes