Coverage for src/CSET/operators/plot.py: 88%
745 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-22 15:05 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-22 15:05 +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 cmap, levels, norm = _custom_colormap_celsius(cube, cmap, levels, norm)
346 return cmap, levels, norm
349def _setup_spatial_map(
350 cube: iris.cube.Cube,
351 figure,
352 cmap,
353 grid_size: int | None = None,
354 subplot: int | None = None,
355):
356 """Define map projections, extent and add coastlines for spatial plots.
358 For spatial map plots, a relevant map projection for rotated or non-rotated inputs
359 is specified, and map extent defined based on the input data.
361 Parameters
362 ----------
363 cube: Cube
364 2 dimensional (lat and lon) Cube of the data to plot.
365 figure:
366 Matplotlib Figure object holding all plot elements.
367 cmap:
368 Matplotlib colormap.
369 grid_size: int, optional
370 Size of grid for subplots if multiple spatial subplots in figure.
371 subplot: int, optional
372 Subplot index if multiple spatial subplots in figure.
374 Returns
375 -------
376 axes:
377 Matplotlib GeoAxes definition.
378 """
379 # Identify min/max plot bounds.
380 try:
381 lat_axis, lon_axis = get_cube_yxcoordname(cube)
382 x1 = np.min(cube.coord(lon_axis).points)
383 x2 = np.max(cube.coord(lon_axis).points)
384 y1 = np.min(cube.coord(lat_axis).points)
385 y2 = np.max(cube.coord(lat_axis).points)
387 # Adjust bounds within +/- 180.0 if x dimension extends beyond half-globe.
388 if np.abs(x2 - x1) > 180.0:
389 x1 = x1 - 180.0
390 x2 = x2 - 180.0
391 logging.debug("Adjusting plot bounds to fit global extent.")
393 # Consider map projection orientation.
394 # Adapting orientation enables plotting across international dateline.
395 # Users can adapt the default central_longitude if alternative projections views.
396 if x2 > 180.0:
397 central_longitude = 180.0
398 else:
399 central_longitude = 0.0
401 # Define spatial map projection.
402 coord_system = cube.coord(lat_axis).coord_system
403 if isinstance(coord_system, iris.coord_systems.RotatedGeogCS):
404 # Define rotated pole map projection for rotated pole inputs.
405 projection = ccrs.RotatedPole(
406 pole_longitude=coord_system.grid_north_pole_longitude,
407 pole_latitude=coord_system.grid_north_pole_latitude,
408 central_rotated_longitude=0.0,
409 )
410 crs = projection
411 else:
412 # Define regular map projection for non-rotated pole inputs.
413 # Alternatives might include e.g. for global model outputs:
414 # projection=ccrs.Robinson(central_longitude=X.y, globe=None)
415 # See also https://scitools.org.uk/cartopy/docs/v0.15/crs/projections.html.
416 projection = ccrs.PlateCarree(central_longitude=central_longitude)
417 crs = ccrs.PlateCarree()
419 # Define axes for plot (or subplot) with required map projection.
420 if subplot is not None:
421 axes = figure.add_subplot(
422 grid_size, grid_size, subplot, projection=projection
423 )
424 else:
425 axes = figure.add_subplot(projection=projection)
427 # Add coastlines if cube contains x and y map coordinates.
428 if cmap.name in ["viridis", "Greys"]:
429 coastcol = "magenta"
430 else:
431 coastcol = "black"
432 logging.debug("Plotting coastlines in colour %s.", coastcol)
433 axes.coastlines(resolution="10m", color=coastcol)
435 # If is lat/lon spatial map, fix extent to keep plot tight.
436 # Specifying crs within set_extent helps ensure only data region is shown.
437 if isinstance(coord_system, iris.coord_systems.GeogCS):
438 axes.set_extent([x1, x2, y1, y2], crs=crs)
440 except ValueError:
441 # Skip if not both x and y map coordinates.
442 axes = figure.gca()
443 pass
445 return axes
448def _get_plot_resolution() -> int:
449 """Get resolution of rasterised plots in pixels per inch."""
450 return get_recipe_metadata().get("plot_resolution", 100)
453def _plot_and_save_spatial_plot(
454 cube: iris.cube.Cube,
455 filename: str,
456 title: str,
457 method: Literal["contourf", "pcolormesh"],
458 **kwargs,
459):
460 """Plot and save a spatial plot.
462 Parameters
463 ----------
464 cube: Cube
465 2 dimensional (lat and lon) Cube of the data to plot.
466 filename: str
467 Filename of the plot to write.
468 title: str
469 Plot title.
470 method: "contourf" | "pcolormesh"
471 The plotting method to use.
472 """
473 # Setup plot details, size, resolution, etc.
474 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
476 # Specify the color bar
477 cmap, levels, norm = _colorbar_map_levels(cube)
479 # Setup plot map projection, extent and coastlines.
480 axes = _setup_spatial_map(cube, fig, cmap)
482 # Plot the field.
483 if method == "contourf":
484 # Filled contour plot of the field.
485 plot = iplt.contourf(cube, cmap=cmap, levels=levels, norm=norm)
486 elif method == "pcolormesh":
487 try:
488 vmin = min(levels)
489 vmax = max(levels)
490 except TypeError:
491 vmin, vmax = None, None
492 # pcolormesh plot of the field and ensure to use norm and not vmin/vmax
493 # if levels are defined.
494 if norm is not None:
495 vmin = None
496 vmax = None
497 logging.debug("Plotting using defined levels.")
498 plot = iplt.pcolormesh(cube, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax)
499 else:
500 raise ValueError(f"Unknown plotting method: {method}")
502 # Check to see if transect, and if so, adjust y axis.
503 if is_transect(cube):
504 if "pressure" in [coord.name() for coord in cube.coords()]:
505 axes.invert_yaxis()
506 axes.set_yscale("log")
507 axes.set_ylim(1100, 100)
508 # If both model_level_number and level_height exists, iplt can construct
509 # plot as a function of height above orography (NOT sea level).
510 elif {"model_level_number", "level_height"}.issubset( 510 ↛ 515line 510 didn't jump to line 515 because the condition on line 510 was always true
511 {coord.name() for coord in cube.coords()}
512 ):
513 axes.set_yscale("log")
515 axes.set_title(
516 f"{title}\n"
517 f"Start Lat: {cube.attributes['transect_coords'].split('_')[0]}"
518 f" Start Lon: {cube.attributes['transect_coords'].split('_')[1]}"
519 f" End Lat: {cube.attributes['transect_coords'].split('_')[2]}"
520 f" End Lon: {cube.attributes['transect_coords'].split('_')[3]}",
521 fontsize=16,
522 )
524 else:
525 # Add title.
526 axes.set_title(title, fontsize=16)
528 # Add watermark with min/max/mean. Currently not user togglable.
529 # In the bbox dictionary, fc and ec are hex colour codes for grey shade.
530 axes.annotate(
531 f"Min: {np.min(cube.data):.3g} Max: {np.max(cube.data):.3g} Mean: {np.mean(cube.data):.3g}",
532 xy=(1, -0.05),
533 xycoords="axes fraction",
534 xytext=(-5, 5),
535 textcoords="offset points",
536 ha="right",
537 va="bottom",
538 size=11,
539 bbox=dict(boxstyle="round", fc="#cccccc", ec="#808080", alpha=0.9),
540 )
542 # Add colour bar.
543 cbar = fig.colorbar(plot, orientation="horizontal", pad=0.042, shrink=0.7)
544 cbar.set_label(label=f"{cube.name()} ({cube.units})", size=14)
545 # add ticks and tick_labels for every levels if less than 20 levels exist
546 if levels is not None and len(levels) < 20:
547 cbar.set_ticks(levels)
548 cbar.set_ticklabels([f"{level:.1f}" for level in levels])
549 if "visibility" in cube.name(): 549 ↛ 550line 549 didn't jump to line 550 because the condition on line 549 was never true
550 cbar.set_ticklabels([f"{level:.3g}" for level in levels])
551 logging.debug("Set colorbar ticks and labels.")
553 # Save plot.
554 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
555 logging.info("Saved spatial plot to %s", filename)
556 plt.close(fig)
559def _plot_and_save_postage_stamp_spatial_plot(
560 cube: iris.cube.Cube,
561 filename: str,
562 stamp_coordinate: str,
563 title: str,
564 method: Literal["contourf", "pcolormesh"],
565 **kwargs,
566):
567 """Plot postage stamp spatial plots from an ensemble.
569 Parameters
570 ----------
571 cube: Cube
572 Iris cube of data to be plotted. It must have the stamp coordinate.
573 filename: str
574 Filename of the plot to write.
575 stamp_coordinate: str
576 Coordinate that becomes different plots.
577 method: "contourf" | "pcolormesh"
578 The plotting method to use.
580 Raises
581 ------
582 ValueError
583 If the cube doesn't have the right dimensions.
584 """
585 # Use the smallest square grid that will fit the members.
586 grid_size = int(math.ceil(math.sqrt(len(cube.coord(stamp_coordinate).points))))
588 fig = plt.figure(figsize=(10, 10))
590 # Specify the color bar
591 cmap, levels, norm = _colorbar_map_levels(cube)
593 # Make a subplot for each member.
594 for member, subplot in zip(
595 cube.slices_over(stamp_coordinate), range(1, grid_size**2 + 1), strict=False
596 ):
597 # Setup subplot map projection, extent and coastlines.
598 axes = _setup_spatial_map(
599 member, fig, cmap, grid_size=grid_size, subplot=subplot
600 )
601 if method == "contourf":
602 # Filled contour plot of the field.
603 plot = iplt.contourf(member, cmap=cmap, levels=levels, norm=norm)
604 elif method == "pcolormesh":
605 if levels is not None:
606 vmin = min(levels)
607 vmax = max(levels)
608 else:
609 raise TypeError("Unknown vmin and vmax range.")
610 vmin, vmax = None, None
611 # pcolormesh plot of the field and ensure to use norm and not vmin/vmax
612 # if levels are defined.
613 if norm is not None: 613 ↛ 614line 613 didn't jump to line 614 because the condition on line 613 was never true
614 vmin = None
615 vmax = None
616 # pcolormesh plot of the field.
617 plot = iplt.pcolormesh(member, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax)
618 else:
619 raise ValueError(f"Unknown plotting method: {method}")
620 axes.set_title(f"Member #{member.coord(stamp_coordinate).points[0]}")
621 axes.set_axis_off()
623 # Put the shared colorbar in its own axes.
624 colorbar_axes = fig.add_axes([0.15, 0.07, 0.7, 0.03])
625 colorbar = fig.colorbar(
626 plot, colorbar_axes, orientation="horizontal", pad=0.042, shrink=0.7
627 )
628 colorbar.set_label(f"{cube.name()} ({cube.units})", size=14)
630 # Overall figure title.
631 fig.suptitle(title, fontsize=16)
633 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
634 logging.info("Saved contour postage stamp plot to %s", filename)
635 plt.close(fig)
638def _plot_and_save_line_series(
639 cubes: iris.cube.CubeList,
640 coords: list[iris.coords.Coord],
641 ensemble_coord: str,
642 filename: str,
643 title: str,
644 **kwargs,
645):
646 """Plot and save a 1D line series.
648 Parameters
649 ----------
650 cubes: Cube or CubeList
651 Cube or CubeList containing the cubes to plot on the y-axis.
652 coords: list[Coord]
653 Coordinates to plot on the x-axis, one per cube.
654 ensemble_coord: str
655 Ensemble coordinate in the cube.
656 filename: str
657 Filename of the plot to write.
658 title: str
659 Plot title.
660 """
661 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
663 model_colors_map = _get_model_colors_map(cubes)
665 # Store min/max ranges.
666 y_levels = []
668 # Check match-up across sequence coords gives consistent sizes
669 _validate_cubes_coords(cubes, coords)
671 for cube, coord in zip(cubes, coords, strict=True):
672 label = None
673 color = "black"
674 if model_colors_map:
675 label = cube.attributes.get("model_name")
676 color = model_colors_map.get(label)
677 for cube_slice in cube.slices_over(ensemble_coord):
678 # Label with (control) if part of an ensemble or not otherwise.
679 if cube_slice.coord(ensemble_coord).points == [0]:
680 iplt.plot(
681 coord,
682 cube_slice,
683 color=color,
684 marker="o",
685 ls="-",
686 lw=3,
687 label=f"{label} (control)"
688 if len(cube.coord(ensemble_coord).points) > 1
689 else label,
690 )
691 # Label with (perturbed) if part of an ensemble and not the control.
692 else:
693 iplt.plot(
694 coord,
695 cube_slice,
696 color=color,
697 ls="-",
698 lw=1.5,
699 alpha=0.75,
700 label=f"{label} (member)",
701 )
703 # Calculate the global min/max if multiple cubes are given.
704 _, levels, _ = _colorbar_map_levels(cube, axis="y")
705 if levels is not None: 705 ↛ 706line 705 didn't jump to line 706 because the condition on line 705 was never true
706 y_levels.append(min(levels))
707 y_levels.append(max(levels))
709 # Get the current axes.
710 ax = plt.gca()
712 # Add some labels and tweak the style.
713 # check if cubes[0] works for single cube if not CubeList
714 ax.set_xlabel(f"{coords[0].name()} / {coords[0].units}", fontsize=14)
715 ax.set_ylabel(f"{cubes[0].name()} / {cubes[0].units}", fontsize=14)
716 ax.set_title(title, fontsize=16)
718 ax.ticklabel_format(axis="y", useOffset=False)
719 ax.tick_params(axis="x", labelrotation=15)
720 ax.tick_params(axis="both", labelsize=12)
722 # Set y limits to global min and max, autoscale if colorbar doesn't exist.
723 if y_levels: 723 ↛ 724line 723 didn't jump to line 724 because the condition on line 723 was never true
724 ax.set_ylim(min(y_levels), max(y_levels))
725 # Add zero line.
726 if min(y_levels) < 0.0 and max(y_levels) > 0.0:
727 ax.axhline(y=0, xmin=0, xmax=1, ls="-", color="grey", lw=2)
728 logging.debug(
729 "Line plot with y-axis limits %s-%s", min(y_levels), max(y_levels)
730 )
731 else:
732 ax.autoscale()
734 # Add gridlines
735 ax.grid(linestyle="--", color="grey", linewidth=1)
736 # Ientify unique labels for legend
737 handles = list(
738 {
739 label: handle
740 for (handle, label) in zip(*ax.get_legend_handles_labels(), strict=True)
741 }.values()
742 )
743 ax.legend(handles=handles, loc="best", ncol=1, frameon=False, fontsize=16)
745 # Save plot.
746 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
747 logging.info("Saved line plot to %s", filename)
748 plt.close(fig)
751def _plot_and_save_vertical_line_series(
752 cubes: iris.cube.CubeList,
753 coords: list[iris.coords.Coord],
754 ensemble_coord: str,
755 filename: str,
756 series_coordinate: str,
757 title: str,
758 vmin: float,
759 vmax: float,
760 **kwargs,
761):
762 """Plot and save a 1D line series in vertical.
764 Parameters
765 ----------
766 cubes: CubeList
767 1 dimensional Cube or CubeList of the data to plot on x-axis.
768 coord: list[Coord]
769 Coordinates to plot on the y-axis, one per cube.
770 ensemble_coord: str
771 Ensemble coordinate in the cube.
772 filename: str
773 Filename of the plot to write.
774 series_coordinate: str
775 Coordinate to use as vertical axis.
776 title: str
777 Plot title.
778 vmin: float
779 Minimum value for the x-axis.
780 vmax: float
781 Maximum value for the x-axis.
782 """
783 # plot the vertical pressure axis using log scale
784 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
786 model_colors_map = _get_model_colors_map(cubes)
788 # Check match-up across sequence coords gives consistent sizes
789 _validate_cubes_coords(cubes, coords)
791 for cube, coord in zip(cubes, coords, strict=True):
792 label = None
793 color = "black"
794 if model_colors_map: 794 ↛ 795line 794 didn't jump to line 795 because the condition on line 794 was never true
795 label = cube.attributes.get("model_name")
796 color = model_colors_map.get(label)
798 for cube_slice in cube.slices_over(ensemble_coord):
799 # If ensemble data given plot control member with (control)
800 # unless single forecast.
801 if cube_slice.coord(ensemble_coord).points == [0]:
802 iplt.plot(
803 cube_slice,
804 coord,
805 color=color,
806 marker="o",
807 ls="-",
808 lw=3,
809 label=f"{label} (control)"
810 if len(cube.coord(ensemble_coord).points) > 1
811 else label,
812 )
813 # If ensemble data given plot perturbed members with (perturbed).
814 else:
815 iplt.plot(
816 cube_slice,
817 coord,
818 color=color,
819 ls="-",
820 lw=1.5,
821 alpha=0.75,
822 label=f"{label} (member)",
823 )
825 # Get the current axis
826 ax = plt.gca()
828 # Special handling for pressure level data.
829 if series_coordinate == "pressure": 829 ↛ 851line 829 didn't jump to line 851 because the condition on line 829 was always true
830 # Invert y-axis and set to log scale.
831 ax.invert_yaxis()
832 ax.set_yscale("log")
834 # Define y-ticks and labels for pressure log axis.
835 y_tick_labels = [
836 "1000",
837 "850",
838 "700",
839 "500",
840 "300",
841 "200",
842 "100",
843 ]
844 y_ticks = [1000, 850, 700, 500, 300, 200, 100]
846 # Set y-axis limits and ticks.
847 ax.set_ylim(1100, 100)
849 # Test if series_coordinate is model level data. The UM data uses
850 # model_level_number and lfric uses full_levels as coordinate.
851 elif series_coordinate in ("model_level_number", "full_levels", "half_levels"):
852 # Define y-ticks and labels for vertical axis.
853 y_ticks = iter_maybe(cubes)[0].coord(series_coordinate).points
854 y_tick_labels = [str(int(i)) for i in y_ticks]
855 ax.set_ylim(min(y_ticks), max(y_ticks))
857 ax.set_yticks(y_ticks)
858 ax.set_yticklabels(y_tick_labels)
860 # Set x-axis limits.
861 ax.set_xlim(vmin, vmax)
862 # Mark y=0 if present in plot.
863 if vmin < 0.0 and vmax > 0.0: 863 ↛ 864line 863 didn't jump to line 864 because the condition on line 863 was never true
864 ax.axvline(x=0, ymin=0, ymax=1, ls="-", color="grey", lw=2)
866 # Add some labels and tweak the style.
867 ax.set_ylabel(f"{coord.name()} / {coord.units}", fontsize=14)
868 ax.set_xlabel(
869 f"{iter_maybe(cubes)[0].name()} / {iter_maybe(cubes)[0].units}", fontsize=14
870 )
871 ax.set_title(title, fontsize=16)
872 ax.ticklabel_format(axis="x")
873 ax.tick_params(axis="y")
874 ax.tick_params(axis="both", labelsize=12)
876 # Add gridlines
877 ax.grid(linestyle="--", color="grey", linewidth=1)
878 # Ientify unique labels for legend
879 handles = list(
880 {
881 label: handle
882 for (handle, label) in zip(*ax.get_legend_handles_labels(), strict=True)
883 }.values()
884 )
885 ax.legend(handles=handles, loc="best", ncol=1, frameon=False, fontsize=16)
887 # Save plot.
888 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
889 logging.info("Saved line plot to %s", filename)
890 plt.close(fig)
893def _plot_and_save_scatter_plot(
894 cube_x: iris.cube.Cube | iris.cube.CubeList,
895 cube_y: iris.cube.Cube | iris.cube.CubeList,
896 filename: str,
897 title: str,
898 one_to_one: bool,
899 **kwargs,
900):
901 """Plot and save a 2D scatter plot.
903 Parameters
904 ----------
905 cube_x: Cube | CubeList
906 1 dimensional Cube or CubeList of the data to plot on x-axis.
907 cube_y: Cube | CubeList
908 1 dimensional Cube or CubeList of the data to plot on y-axis.
909 filename: str
910 Filename of the plot to write.
911 title: str
912 Plot title.
913 one_to_one: bool
914 Whether a 1:1 line is plotted.
915 """
916 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
917 # plot the cube_x and cube_y 1D fields as a scatter plot. If they are CubeLists this ensures
918 # to pair each cube from cube_x with the corresponding cube from cube_y, allowing to iterate
919 # over the pairs simultaneously.
921 # Ensure cube_x and cube_y are iterable
922 cube_x_iterable = iter_maybe(cube_x)
923 cube_y_iterable = iter_maybe(cube_y)
925 for cube_x_iter, cube_y_iter in zip(cube_x_iterable, cube_y_iterable, strict=True):
926 iplt.scatter(cube_x_iter, cube_y_iter)
927 if one_to_one is True:
928 plt.plot(
929 [
930 np.nanmin([np.nanmin(cube_y.data), np.nanmin(cube_x.data)]),
931 np.nanmax([np.nanmax(cube_y.data), np.nanmax(cube_x.data)]),
932 ],
933 [
934 np.nanmin([np.nanmin(cube_y.data), np.nanmin(cube_x.data)]),
935 np.nanmax([np.nanmax(cube_y.data), np.nanmax(cube_x.data)]),
936 ],
937 "k",
938 linestyle="--",
939 )
940 ax = plt.gca()
942 # Add some labels and tweak the style.
943 ax.set_xlabel(f"{cube_x[0].name()} / {cube_x[0].units}", fontsize=14)
944 ax.set_ylabel(f"{cube_y[0].name()} / {cube_y[0].units}", fontsize=14)
945 ax.set_title(title, fontsize=16)
946 ax.ticklabel_format(axis="y", useOffset=False)
947 ax.tick_params(axis="x", labelrotation=15)
948 ax.tick_params(axis="both", labelsize=12)
949 ax.autoscale()
951 # Save plot.
952 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
953 logging.info("Saved scatter plot to %s", filename)
954 plt.close(fig)
957def _plot_and_save_vector_plot(
958 cube_u: iris.cube.Cube,
959 cube_v: iris.cube.Cube,
960 filename: str,
961 title: str,
962 method: Literal["contourf", "pcolormesh"],
963 **kwargs,
964):
965 """Plot and save a 2D vector plot.
967 Parameters
968 ----------
969 cube_u: Cube
970 2 dimensional Cube of u component of the data.
971 cube_v: Cube
972 2 dimensional Cube of v component of the data.
973 filename: str
974 Filename of the plot to write.
975 title: str
976 Plot title.
977 """
978 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
980 # Create a cube containing the magnitude of the vector field.
981 cube_vec_mag = (cube_u**2 + cube_v**2) ** 0.5
982 cube_vec_mag.rename(f"{cube_u.name()}_{cube_v.name()}_magnitude")
984 # Specify the color bar
985 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag)
987 # Setup plot map projection, extent and coastlines.
988 axes = _setup_spatial_map(cube_vec_mag, fig, cmap)
990 if method == "contourf": 990 ↛ 993line 990 didn't jump to line 993 because the condition on line 990 was always true
991 # Filled contour plot of the field.
992 plot = iplt.contourf(cube_vec_mag, cmap=cmap, levels=levels, norm=norm)
993 elif method == "pcolormesh":
994 try:
995 vmin = min(levels)
996 vmax = max(levels)
997 except TypeError:
998 vmin, vmax = None, None
999 # pcolormesh plot of the field and ensure to use norm and not vmin/vmax
1000 # if levels are defined.
1001 if norm is not None:
1002 vmin = None
1003 vmax = None
1004 plot = iplt.pcolormesh(cube_vec_mag, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax)
1005 else:
1006 raise ValueError(f"Unknown plotting method: {method}")
1008 # Check to see if transect, and if so, adjust y axis.
1009 if is_transect(cube_vec_mag): 1009 ↛ 1010line 1009 didn't jump to line 1010 because the condition on line 1009 was never true
1010 if "pressure" in [coord.name() for coord in cube_vec_mag.coords()]:
1011 axes.invert_yaxis()
1012 axes.set_yscale("log")
1013 axes.set_ylim(1100, 100)
1014 # If both model_level_number and level_height exists, iplt can construct
1015 # plot as a function of height above orography (NOT sea level).
1016 elif {"model_level_number", "level_height"}.issubset(
1017 {coord.name() for coord in cube_vec_mag.coords()}
1018 ):
1019 axes.set_yscale("log")
1021 axes.set_title(
1022 f"{title}\n"
1023 f"Start Lat: {cube_vec_mag.attributes['transect_coords'].split('_')[0]}"
1024 f" Start Lon: {cube_vec_mag.attributes['transect_coords'].split('_')[1]}"
1025 f" End Lat: {cube_vec_mag.attributes['transect_coords'].split('_')[2]}"
1026 f" End Lon: {cube_vec_mag.attributes['transect_coords'].split('_')[3]}",
1027 fontsize=16,
1028 )
1030 else:
1031 # Add title.
1032 axes.set_title(title, fontsize=16)
1034 # Add watermark with min/max/mean. Currently not user togglable.
1035 # In the bbox dictionary, fc and ec are hex colour codes for grey shade.
1036 axes.annotate(
1037 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}",
1038 xy=(1, -0.05),
1039 xycoords="axes fraction",
1040 xytext=(-5, 5),
1041 textcoords="offset points",
1042 ha="right",
1043 va="bottom",
1044 size=11,
1045 bbox=dict(boxstyle="round", fc="#cccccc", ec="#808080", alpha=0.9),
1046 )
1048 # Add colour bar.
1049 cbar = fig.colorbar(plot, orientation="horizontal", pad=0.042, shrink=0.7)
1050 cbar.set_label(label=f"{cube_vec_mag.name()} ({cube_vec_mag.units})", size=14)
1051 # add ticks and tick_labels for every levels if less than 20 levels exist
1052 if levels is not None and len(levels) < 20: 1052 ↛ 1053line 1052 didn't jump to line 1053 because the condition on line 1052 was never true
1053 cbar.set_ticks(levels)
1054 cbar.set_ticklabels([f"{level:.1f}" for level in levels])
1056 # 30 barbs along the longest axis of the plot, or a barb per point for data
1057 # with less than 30 points.
1058 step = max(max(cube_u.shape) // 30, 1)
1059 iplt.quiver(cube_u[::step, ::step], cube_v[::step, ::step], pivot="middle")
1061 # Save plot.
1062 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1063 logging.info("Saved vector plot to %s", filename)
1064 plt.close(fig)
1067def _plot_and_save_histogram_series(
1068 cubes: iris.cube.Cube | iris.cube.CubeList,
1069 filename: str,
1070 title: str,
1071 vmin: float,
1072 vmax: float,
1073 **kwargs,
1074):
1075 """Plot and save a histogram series.
1077 Parameters
1078 ----------
1079 cubes: Cube or CubeList
1080 2 dimensional Cube or CubeList of the data to plot as histogram.
1081 filename: str
1082 Filename of the plot to write.
1083 title: str
1084 Plot title.
1085 vmin: float
1086 minimum for colorbar
1087 vmax: float
1088 maximum for colorbar
1089 """
1090 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
1091 ax = plt.gca()
1093 model_colors_map = _get_model_colors_map(cubes)
1095 # Set default that histograms will produce probability density function
1096 # at each bin (integral over range sums to 1).
1097 density = True
1099 for cube in iter_maybe(cubes):
1100 # Easier to check title (where var name originates)
1101 # than seeing if long names exist etc.
1102 # Exception case, where distribution better fits log scales/bins.
1103 if "surface_microphysical" in title:
1104 if "amount" in title: 1104 ↛ 1106line 1104 didn't jump to line 1106 because the condition on line 1104 was never true
1105 # Compute histogram following Klingaman et al. (2017): ASoP
1106 bin2 = np.exp(np.log(0.02) + 0.1 * np.linspace(0, 99, 100))
1107 bins = np.pad(bin2, (1, 0), "constant", constant_values=0)
1108 density = False
1109 else:
1110 bins = 10.0 ** (
1111 np.arange(-10, 27, 1) / 10.0
1112 ) # Suggestion from RMED toolbox.
1113 bins = np.insert(bins, 0, 0)
1114 ax.set_yscale("log")
1115 vmin = bins[1]
1116 vmax = bins[-1] # Manually set vmin/vmax to override json derived value.
1117 ax.set_xscale("log")
1118 elif "lightning" in title:
1119 bins = [0, 1, 2, 3, 4, 5]
1120 else:
1121 bins = np.linspace(vmin, vmax, 51)
1122 logging.debug(
1123 "Plotting histogram with %s bins %s - %s.",
1124 np.size(bins),
1125 np.min(bins),
1126 np.max(bins),
1127 )
1129 # Reshape cube data into a single array to allow for a single histogram.
1130 # Otherwise we plot xdim histograms stacked.
1131 cube_data_1d = (cube.data).flatten()
1133 label = None
1134 color = "black"
1135 if model_colors_map: 1135 ↛ 1136line 1135 didn't jump to line 1136 because the condition on line 1135 was never true
1136 label = cube.attributes.get("model_name")
1137 color = model_colors_map[label]
1138 x, y = np.histogram(cube_data_1d, bins=bins, density=density)
1140 # Compute area under curve.
1141 if "surface_microphysical" in title and "amount" in title: 1141 ↛ 1142line 1141 didn't jump to line 1142 because the condition on line 1141 was never true
1142 bin_mean = (bins[:-1] + bins[1:]) / 2.0
1143 x = x * bin_mean / x.sum()
1144 x = x[1:]
1145 y = y[1:]
1147 ax.plot(
1148 y[:-1], x, color=color, linewidth=3, marker="o", markersize=6, label=label
1149 )
1151 # Add some labels and tweak the style.
1152 ax.set_title(title, fontsize=16)
1153 ax.set_xlabel(
1154 f"{iter_maybe(cubes)[0].name()} / {iter_maybe(cubes)[0].units}", fontsize=14
1155 )
1156 ax.set_ylabel("Normalised probability density", fontsize=14)
1157 if "surface_microphysical" in title and "amount" in title: 1157 ↛ 1158line 1157 didn't jump to line 1158 because the condition on line 1157 was never true
1158 ax.set_ylabel(
1159 f"Contribution to mean ({iter_maybe(cubes)[0].units})", fontsize=14
1160 )
1161 ax.set_xlim(vmin, vmax)
1162 ax.tick_params(axis="both", labelsize=12)
1164 # Overlay grid-lines onto histogram plot.
1165 ax.grid(linestyle="--", color="grey", linewidth=1)
1166 if model_colors_map: 1166 ↛ 1167line 1166 didn't jump to line 1167 because the condition on line 1166 was never true
1167 ax.legend(loc="best", ncol=1, frameon=False, fontsize=16)
1169 # Save plot.
1170 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1171 logging.info("Saved line plot to %s", filename)
1172 plt.close(fig)
1175def _plot_and_save_postage_stamp_histogram_series(
1176 cube: iris.cube.Cube,
1177 filename: str,
1178 title: str,
1179 stamp_coordinate: str,
1180 vmin: float,
1181 vmax: float,
1182 **kwargs,
1183):
1184 """Plot and save postage (ensemble members) stamps for a histogram series.
1186 Parameters
1187 ----------
1188 cube: Cube
1189 2 dimensional Cube of the data to plot as histogram.
1190 filename: str
1191 Filename of the plot to write.
1192 title: str
1193 Plot title.
1194 stamp_coordinate: str
1195 Coordinate that becomes different plots.
1196 vmin: float
1197 minimum for pdf x-axis
1198 vmax: float
1199 maximum for pdf x-axis
1200 """
1201 # Use the smallest square grid that will fit the members.
1202 grid_size = int(math.ceil(math.sqrt(len(cube.coord(stamp_coordinate).points))))
1204 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k")
1205 # Make a subplot for each member.
1206 for member, subplot in zip(
1207 cube.slices_over(stamp_coordinate), range(1, grid_size**2 + 1), strict=False
1208 ):
1209 # Implicit interface is much easier here, due to needing to have the
1210 # cartopy GeoAxes generated.
1211 plt.subplot(grid_size, grid_size, subplot)
1212 # Reshape cube data into a single array to allow for a single histogram.
1213 # Otherwise we plot xdim histograms stacked.
1214 member_data_1d = (member.data).flatten()
1215 plt.hist(member_data_1d, density=True, stacked=True)
1216 ax = plt.gca()
1217 ax.set_title(f"Member #{member.coord(stamp_coordinate).points[0]}")
1218 ax.set_xlim(vmin, vmax)
1220 # Overall figure title.
1221 fig.suptitle(title, fontsize=16)
1223 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1224 logging.info("Saved histogram postage stamp plot to %s", filename)
1225 plt.close(fig)
1228def _plot_and_save_postage_stamps_in_single_plot_histogram_series(
1229 cube: iris.cube.Cube,
1230 filename: str,
1231 title: str,
1232 stamp_coordinate: str,
1233 vmin: float,
1234 vmax: float,
1235 **kwargs,
1236):
1237 fig, ax = plt.subplots(figsize=(10, 10), facecolor="w", edgecolor="k")
1238 ax.set_title(title, fontsize=16)
1239 ax.set_xlim(vmin, vmax)
1240 ax.set_xlabel(f"{cube.name()} / {cube.units}", fontsize=14)
1241 ax.set_ylabel("normalised probability density", fontsize=14)
1242 # Loop over all slices along the stamp_coordinate
1243 for member in cube.slices_over(stamp_coordinate):
1244 # Flatten the member data to 1D
1245 member_data_1d = member.data.flatten()
1246 # Plot the histogram using plt.hist
1247 plt.hist(
1248 member_data_1d,
1249 density=True,
1250 stacked=True,
1251 label=f"Member #{member.coord(stamp_coordinate).points[0]}",
1252 )
1254 # Add a legend
1255 ax.legend(fontsize=16)
1257 # Save the figure to a file
1258 plt.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution())
1260 # Close the figure
1261 plt.close(fig)
1264def _spatial_plot(
1265 method: Literal["contourf", "pcolormesh"],
1266 cube: iris.cube.Cube,
1267 filename: str | None,
1268 sequence_coordinate: str,
1269 stamp_coordinate: str,
1270):
1271 """Plot a spatial variable onto a map from a 2D, 3D, or 4D cube.
1273 A 2D spatial field can be plotted, but if the sequence_coordinate is present
1274 then a sequence of plots will be produced. Similarly if the stamp_coordinate
1275 is present then postage stamp plots will be produced.
1277 Parameters
1278 ----------
1279 method: "contourf" | "pcolormesh"
1280 The plotting method to use.
1281 cube: Cube
1282 Iris cube of the data to plot. It should have two spatial dimensions,
1283 such as lat and lon, and may also have a another two dimension to be
1284 plotted sequentially and/or as postage stamp plots.
1285 filename: str | None
1286 Name of the plot to write, used as a prefix for plot sequences. If None
1287 uses the recipe name.
1288 sequence_coordinate: str
1289 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
1290 This coordinate must exist in the cube.
1291 stamp_coordinate: str
1292 Coordinate about which to plot postage stamp plots. Defaults to
1293 ``"realization"``.
1295 Raises
1296 ------
1297 ValueError
1298 If the cube doesn't have the right dimensions.
1299 TypeError
1300 If the cube isn't a single cube.
1301 """
1302 recipe_title = get_recipe_metadata().get("title", "Untitled")
1304 # Ensure we have a name for the plot file.
1305 if filename is None:
1306 filename = slugify(recipe_title)
1308 # Ensure we've got a single cube.
1309 cube = _check_single_cube(cube)
1311 # Make postage stamp plots if stamp_coordinate exists and has more than a
1312 # single point.
1313 plotting_func = _plot_and_save_spatial_plot
1314 try:
1315 if cube.coord(stamp_coordinate).shape[0] > 1:
1316 plotting_func = _plot_and_save_postage_stamp_spatial_plot
1317 except iris.exceptions.CoordinateNotFoundError:
1318 pass
1320 # Must have a sequence coordinate.
1321 try:
1322 cube.coord(sequence_coordinate)
1323 except iris.exceptions.CoordinateNotFoundError as err:
1324 raise ValueError(f"Cube must have a {sequence_coordinate} coordinate.") from err
1326 # Create a plot for each value of the sequence coordinate.
1327 plot_index = []
1328 nplot = np.size(cube.coord(sequence_coordinate).points)
1329 for cube_slice in cube.slices_over(sequence_coordinate):
1330 # Use sequence value so multiple sequences can merge.
1331 sequence_value = cube_slice.coord(sequence_coordinate).points[0]
1332 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png"
1333 coord = cube_slice.coord(sequence_coordinate)
1334 # Format the coordinate value in a unit appropriate way.
1335 title = f"{recipe_title}\n [{coord.units.title(coord.points[0])}]"
1336 # Use sequence (e.g. time) bounds if plotting single non-sequence outputs
1337 if nplot == 1 and coord.has_bounds:
1338 if np.size(coord.bounds) > 1:
1339 title = f"{recipe_title}\n [{coord.units.title(coord.bounds[0][0])} to {coord.units.title(coord.bounds[0][1])}]"
1340 # Do the actual plotting.
1341 plotting_func(
1342 cube_slice,
1343 filename=plot_filename,
1344 stamp_coordinate=stamp_coordinate,
1345 title=title,
1346 method=method,
1347 )
1348 plot_index.append(plot_filename)
1350 # Add list of plots to plot metadata.
1351 complete_plot_index = _append_to_plot_index(plot_index)
1353 # Make a page to display the plots.
1354 _make_plot_html_page(complete_plot_index)
1357def _custom_colormap_mask(cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None):
1358 """Get colourmap for mask.
1360 If "mask_for_" appears anywhere in the name of a cube this function will be called
1361 regardless of the name of the variable to ensure a consistent plot.
1363 Parameters
1364 ----------
1365 cube: Cube
1366 Cube of variable for which the colorbar information is desired.
1367 axis: "x", "y", optional
1368 Select the levels for just this axis of a line plot. The min and max
1369 can be set by xmin/xmax or ymin/ymax respectively. For variables where
1370 setting a universal range is not desirable (e.g. temperature), users
1371 can set ymin/ymax values to "auto" in the colorbar definitions file.
1372 Where no additional xmin/xmax or ymin/ymax values are provided, the
1373 axis bounds default to use the vmin/vmax values provided.
1375 Returns
1376 -------
1377 cmap:
1378 Matplotlib colormap.
1379 levels:
1380 List of levels to use for plotting. For continuous plots the min and max
1381 should be taken as the range.
1382 norm:
1383 BoundaryNorm information.
1384 """
1385 if "difference" not in cube.long_name:
1386 if axis:
1387 levels = [0, 1]
1388 # Complete settings based on levels.
1389 return None, levels, None
1390 else:
1391 # Define the levels and colors.
1392 levels = [0, 1, 2]
1393 colors = ["white", "dodgerblue"]
1394 # Create a custom color map.
1395 cmap = mcolors.ListedColormap(colors)
1396 # Normalize the levels.
1397 norm = mcolors.BoundaryNorm(levels, cmap.N)
1398 logging.debug("Colourmap for %s.", cube.long_name)
1399 return cmap, levels, norm
1400 else:
1401 if axis:
1402 levels = [-1, 1]
1403 return None, levels, None
1404 else:
1405 # Search for if mask difference, set to +/- 0.5 as values plotted <
1406 # not <=.
1407 levels = [-2, -0.5, 0.5, 2]
1408 colors = ["goldenrod", "white", "teal"]
1409 cmap = mcolors.ListedColormap(colors)
1410 norm = mcolors.BoundaryNorm(levels, cmap.N)
1411 logging.debug("Colourmap for %s.", cube.long_name)
1412 return cmap, levels, norm
1415def _custom_beaufort_scale(cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None):
1416 """Get a custom colorbar for a cube in the Beaufort Scale.
1418 Specific variable ranges can be separately set in user-supplied definition
1419 for x- or y-axis limits, or indicate where automated range preferred.
1421 Parameters
1422 ----------
1423 cube: Cube
1424 Cube of variable with Beaufort Scale in name.
1425 axis: "x", "y", optional
1426 Select the levels for just this axis of a line plot. The min and max
1427 can be set by xmin/xmax or ymin/ymax respectively. For variables where
1428 setting a universal range is not desirable (e.g. temperature), users
1429 can set ymin/ymax values to "auto" in the colorbar definitions file.
1430 Where no additional xmin/xmax or ymin/ymax values are provided, the
1431 axis bounds default to use the vmin/vmax values provided.
1433 Returns
1434 -------
1435 cmap:
1436 Matplotlib colormap.
1437 levels:
1438 List of levels to use for plotting. For continuous plots the min and max
1439 should be taken as the range.
1440 norm:
1441 BoundaryNorm information.
1442 """
1443 if "difference" not in cube.long_name:
1444 if axis:
1445 levels = [0, 12]
1446 return None, levels, None
1447 else:
1448 levels = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]
1449 colors = [
1450 "black",
1451 (0, 0, 0.6),
1452 "blue",
1453 "cyan",
1454 "green",
1455 "yellow",
1456 (1, 0.5, 0),
1457 "red",
1458 "pink",
1459 "magenta",
1460 "purple",
1461 "maroon",
1462 "white",
1463 ]
1464 cmap = mcolors.ListedColormap(colors)
1465 norm = mcolors.BoundaryNorm(levels, cmap.N)
1466 logging.info("change colormap for Beaufort Scale colorbar.")
1467 return cmap, levels, norm
1468 else:
1469 if axis:
1470 levels = [-4, 4]
1471 return None, levels, None
1472 else:
1473 levels = [
1474 -3.5,
1475 -2.5,
1476 -1.5,
1477 -0.5,
1478 0.5,
1479 1.5,
1480 2.5,
1481 3.5,
1482 ]
1483 cmap = plt.get_cmap("bwr", 8)
1484 norm = mcolors.BoundaryNorm(levels, cmap.N)
1485 return cmap, levels, norm
1488def _custom_colormap_celsius(cube: iris.cube.Cube, cmap, levels, norm):
1489 """Return altered colourmap for temperature with change in units to Celsius.
1491 If "Celsius" appears anywhere in the name of a cube this function will be called.
1493 Parameters
1494 ----------
1495 cube: Cube
1496 Cube of variable for which the colorbar information is desired.
1497 cmap: Matplotlib colormap.
1498 levels: List
1499 List of levels to use for plotting. For continuous plots the min and max
1500 should be taken as the range.
1501 norm: BoundaryNorm.
1503 Returns
1504 -------
1505 cmap: Matplotlib colormap.
1506 levels: List
1507 List of levels to use for plotting. For continuous plots the min and max
1508 should be taken as the range.
1509 norm: BoundaryNorm.
1510 """
1511 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name])
1512 if any("temperature" in name for name in varnames) and "Celsius" == cube.units:
1513 levels = np.array(levels)
1514 levels -= 273
1515 levels = levels.tolist()
1516 else:
1517 # Do nothing keep the existing colourbar attributes
1518 levels = levels
1519 cmap = cmap
1520 norm = norm
1521 return cmap, levels, norm
1524def _custom_colourmap_precipitation(cube: iris.cube.Cube, cmap, levels, norm):
1525 """Return a custom colourmap for the current recipe."""
1526 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name])
1527 if (
1528 any("surface_microphysical" in name for name in varnames)
1529 and "difference" not in cube.long_name
1530 and "mask" not in cube.long_name
1531 ):
1532 # Define the levels and colors
1533 levels = [0, 0.125, 0.25, 0.5, 1, 2, 4, 8, 16, 32, 64, 128, 256]
1534 colors = [
1535 "w",
1536 (0, 0, 0.6),
1537 "b",
1538 "c",
1539 "g",
1540 "y",
1541 (1, 0.5, 0),
1542 "r",
1543 "pink",
1544 "m",
1545 "purple",
1546 "maroon",
1547 "gray",
1548 ]
1549 # Create a custom colormap
1550 cmap = mcolors.ListedColormap(colors)
1551 # Normalize the levels
1552 norm = mcolors.BoundaryNorm(levels, cmap.N)
1553 logging.info("change colormap for surface_microphysical variable colorbar.")
1554 else:
1555 # do nothing and keep existing colorbar attributes
1556 cmap = cmap
1557 levels = levels
1558 norm = norm
1559 return cmap, levels, norm
1562def _custom_colourmap_visibility_in_air(cube: iris.cube.Cube, cmap, levels, norm):
1563 """Return a custom colourmap for the current recipe."""
1564 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name])
1565 if (
1566 any("visibility_in_air" in name for name in varnames)
1567 and "difference" not in cube.long_name
1568 and "mask" not in cube.long_name
1569 ):
1570 # Define the levels and colors (in km)
1571 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]
1572 norm = mcolors.BoundaryNorm(levels, cmap.N)
1573 colours = [
1574 "#8f00d6",
1575 "#d10000",
1576 "#ff9700",
1577 "#ffff00",
1578 "#00007f",
1579 "#6c9ccd",
1580 "#aae8ff",
1581 "#37a648",
1582 "#8edc64",
1583 "#c5ffc5",
1584 "#dcdcdc",
1585 "#ffffff",
1586 ]
1587 # Create a custom colormap
1588 cmap = mcolors.ListedColormap(colours)
1589 # Normalize the levels
1590 norm = mcolors.BoundaryNorm(levels, cmap.N)
1591 logging.info("change colormap for visibility_in_air variable colorbar.")
1592 else:
1593 # do nothing and keep existing colorbar attributes
1594 cmap = cmap
1595 levels = levels
1596 norm = norm
1597 return cmap, levels, norm
1600def _get_num_models(cube: iris.cube.Cube | iris.cube.CubeList) -> int:
1601 """Return number of models based on cube attributes."""
1602 model_names = list(
1603 filter(
1604 lambda x: x is not None,
1605 {cb.attributes.get("model_name", None) for cb in iter_maybe(cube)},
1606 )
1607 )
1608 if not model_names:
1609 logging.debug("Missing model names. Will assume single model.")
1610 return 1
1611 else:
1612 return len(model_names)
1615def _validate_cube_shape(
1616 cube: iris.cube.Cube | iris.cube.CubeList, num_models: int
1617) -> None:
1618 """Check all cubes have a model name."""
1619 if isinstance(cube, iris.cube.CubeList) and len(cube) != num_models: 1619 ↛ 1620line 1619 didn't jump to line 1620 because the condition on line 1619 was never true
1620 raise ValueError(
1621 f"The number of model names ({num_models}) should equal the number "
1622 f"of cubes ({len(cube)})."
1623 )
1626def _validate_cubes_coords(
1627 cubes: iris.cube.CubeList, coords: list[iris.coords.Coord]
1628) -> None:
1629 """Check same number of cubes as sequence coordinate for zip functions."""
1630 if len(cubes) != len(coords): 1630 ↛ 1631line 1630 didn't jump to line 1631 because the condition on line 1630 was never true
1631 raise ValueError(
1632 f"The number of CubeList entries ({len(cubes)}) should equal the number "
1633 f"of sequence coordinates ({len(coords)})."
1634 f"Check that number of time entries in input data are consistent if "
1635 f"performing time-averaging steps prior to plotting outputs."
1636 )
1639####################
1640# Public functions #
1641####################
1644def spatial_contour_plot(
1645 cube: iris.cube.Cube,
1646 filename: str = None,
1647 sequence_coordinate: str = "time",
1648 stamp_coordinate: str = "realization",
1649 **kwargs,
1650) -> iris.cube.Cube:
1651 """Plot a spatial variable onto a map from a 2D, 3D, or 4D cube.
1653 A 2D spatial field can be plotted, but if the sequence_coordinate is present
1654 then a sequence of plots will be produced. Similarly if the stamp_coordinate
1655 is present then postage stamp plots will be produced.
1657 Parameters
1658 ----------
1659 cube: Cube
1660 Iris cube of the data to plot. It should have two spatial dimensions,
1661 such as lat and lon, and may also have a another two dimension to be
1662 plotted sequentially and/or as postage stamp plots.
1663 filename: str, optional
1664 Name of the plot to write, used as a prefix for plot sequences. Defaults
1665 to the recipe name.
1666 sequence_coordinate: str, optional
1667 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
1668 This coordinate must exist in the cube.
1669 stamp_coordinate: str, optional
1670 Coordinate about which to plot postage stamp plots. Defaults to
1671 ``"realization"``.
1673 Returns
1674 -------
1675 Cube
1676 The original cube (so further operations can be applied).
1678 Raises
1679 ------
1680 ValueError
1681 If the cube doesn't have the right dimensions.
1682 TypeError
1683 If the cube isn't a single cube.
1684 """
1685 _spatial_plot("contourf", cube, filename, sequence_coordinate, stamp_coordinate)
1686 return cube
1689def spatial_pcolormesh_plot(
1690 cube: iris.cube.Cube,
1691 filename: str = None,
1692 sequence_coordinate: str = "time",
1693 stamp_coordinate: str = "realization",
1694 **kwargs,
1695) -> iris.cube.Cube:
1696 """Plot a spatial variable onto a map from a 2D, 3D, or 4D cube.
1698 A 2D spatial field can be plotted, but if the sequence_coordinate is present
1699 then a sequence of plots will be produced. Similarly if the stamp_coordinate
1700 is present then postage stamp plots will be produced.
1702 This function is significantly faster than ``spatial_contour_plot``,
1703 especially at high resolutions, and should be preferred unless contiguous
1704 contour areas are important.
1706 Parameters
1707 ----------
1708 cube: Cube
1709 Iris cube of the data to plot. It should have two spatial dimensions,
1710 such as lat and lon, and may also have a another two dimension to be
1711 plotted sequentially and/or as postage stamp plots.
1712 filename: str, optional
1713 Name of the plot to write, used as a prefix for plot sequences. Defaults
1714 to the recipe name.
1715 sequence_coordinate: str, optional
1716 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
1717 This coordinate must exist in the cube.
1718 stamp_coordinate: str, optional
1719 Coordinate about which to plot postage stamp plots. Defaults to
1720 ``"realization"``.
1722 Returns
1723 -------
1724 Cube
1725 The original cube (so further operations can be applied).
1727 Raises
1728 ------
1729 ValueError
1730 If the cube doesn't have the right dimensions.
1731 TypeError
1732 If the cube isn't a single cube.
1733 """
1734 _spatial_plot("pcolormesh", cube, filename, sequence_coordinate, stamp_coordinate)
1735 return cube
1738# TODO: Expand function to handle ensemble data.
1739# line_coordinate: str, optional
1740# Coordinate about which to plot multiple lines. Defaults to
1741# ``"realization"``.
1742def plot_line_series(
1743 cube: iris.cube.Cube | iris.cube.CubeList,
1744 filename: str = None,
1745 series_coordinate: str = "time",
1746 # line_coordinate: str = "realization",
1747 **kwargs,
1748) -> iris.cube.Cube | iris.cube.CubeList:
1749 """Plot a line plot for the specified coordinate.
1751 The Cube or CubeList must be 1D.
1753 Parameters
1754 ----------
1755 iris.cube | iris.cube.CubeList
1756 Cube or CubeList of the data to plot. The individual cubes should have a single dimension.
1757 The cubes should cover the same phenomenon i.e. all cubes contain temperature data.
1758 We do not support different data such as temperature and humidity in the same CubeList for plotting.
1759 filename: str, optional
1760 Name of the plot to write, used as a prefix for plot sequences. Defaults
1761 to the recipe name.
1762 series_coordinate: str, optional
1763 Coordinate about which to make a series. Defaults to ``"time"``. This
1764 coordinate must exist in the cube.
1766 Returns
1767 -------
1768 iris.cube.Cube | iris.cube.CubeList
1769 The original Cube or CubeList (so further operations can be applied).
1770 plotted data.
1772 Raises
1773 ------
1774 ValueError
1775 If the cubes don't have the right dimensions.
1776 TypeError
1777 If the cube isn't a Cube or CubeList.
1778 """
1779 # Ensure we have a name for the plot file.
1780 title = get_recipe_metadata().get("title", "Untitled")
1782 if filename is None:
1783 filename = slugify(title)
1785 # Add file extension.
1786 plot_filename = f"{filename.rsplit('.', 1)[0]}.png"
1788 num_models = _get_num_models(cube)
1790 _validate_cube_shape(cube, num_models)
1792 # Iterate over all cubes and extract coordinate to plot.
1793 cubes = iter_maybe(cube)
1794 coords = []
1795 for cube in cubes:
1796 try:
1797 coords.append(cube.coord(series_coordinate))
1798 except iris.exceptions.CoordinateNotFoundError as err:
1799 raise ValueError(
1800 f"Cube must have a {series_coordinate} coordinate."
1801 ) from err
1802 if cube.ndim > 2 or not cube.coords("realization"):
1803 raise ValueError("Cube must be 1D or 2D with a realization coordinate.")
1805 # Do the actual plotting.
1806 _plot_and_save_line_series(cubes, coords, "realization", plot_filename, title)
1808 # Add list of plots to plot metadata.
1809 plot_index = _append_to_plot_index([plot_filename])
1811 # Make a page to display the plots.
1812 _make_plot_html_page(plot_index)
1814 return cube
1817def plot_vertical_line_series(
1818 cubes: iris.cube.Cube | iris.cube.CubeList,
1819 filename: str = None,
1820 series_coordinate: str = "model_level_number",
1821 sequence_coordinate: str = "time",
1822 # line_coordinate: str = "realization",
1823 **kwargs,
1824) -> iris.cube.Cube | iris.cube.CubeList:
1825 """Plot a line plot against a type of vertical coordinate.
1827 The Cube or CubeList must be 1D.
1829 A 1D line plot with y-axis as pressure coordinate can be plotted, but if the sequence_coordinate is present
1830 then a sequence of plots will be produced.
1832 Parameters
1833 ----------
1834 iris.cube | iris.cube.CubeList
1835 Cube or CubeList of the data to plot. The individual cubes should have a single dimension.
1836 The cubes should cover the same phenomenon i.e. all cubes contain temperature data.
1837 We do not support different data such as temperature and humidity in the same CubeList for plotting.
1838 filename: str, optional
1839 Name of the plot to write, used as a prefix for plot sequences. Defaults
1840 to the recipe name.
1841 series_coordinate: str, optional
1842 Coordinate to plot on the y-axis. Can be ``pressure`` or
1843 ``model_level_number`` for UM, or ``full_levels`` or ``half_levels``
1844 for LFRic. Defaults to ``model_level_number``.
1845 This coordinate must exist in the cube.
1846 sequence_coordinate: str, optional
1847 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
1848 This coordinate must exist in the cube.
1850 Returns
1851 -------
1852 iris.cube.Cube | iris.cube.CubeList
1853 The original Cube or CubeList (so further operations can be applied).
1854 Plotted data.
1856 Raises
1857 ------
1858 ValueError
1859 If the cubes doesn't have the right dimensions.
1860 TypeError
1861 If the cube isn't a Cube or CubeList.
1862 """
1863 # Ensure we have a name for the plot file.
1864 recipe_title = get_recipe_metadata().get("title", "Untitled")
1866 if filename is None:
1867 filename = slugify(recipe_title)
1869 cubes = iter_maybe(cubes)
1870 # Initialise empty list to hold all data from all cubes in a CubeList
1871 all_data = []
1873 # Store min/max ranges for x range.
1874 x_levels = []
1876 num_models = _get_num_models(cubes)
1878 _validate_cube_shape(cubes, num_models)
1880 # Iterate over all cubes in cube or CubeList and plot.
1881 coords = []
1882 for cube in cubes:
1883 # Test if series coordinate i.e. pressure level exist for any cube with cube.ndim >=1.
1884 try:
1885 coords.append(cube.coord(series_coordinate))
1886 except iris.exceptions.CoordinateNotFoundError as err:
1887 raise ValueError(
1888 f"Cube must have a {series_coordinate} coordinate."
1889 ) from err
1891 try:
1892 if cube.ndim > 1 or not cube.coords("realization"): 1892 ↛ 1900line 1892 didn't jump to line 1900 because the condition on line 1892 was always true
1893 cube.coord(sequence_coordinate)
1894 except iris.exceptions.CoordinateNotFoundError as err:
1895 raise ValueError(
1896 f"Cube must have a {sequence_coordinate} coordinate or be 1D, or 2D with a realization coordinate."
1897 ) from err
1899 # Get minimum and maximum from levels information.
1900 _, levels, _ = _colorbar_map_levels(cube, axis="x")
1901 if levels is not None: 1901 ↛ 1905line 1901 didn't jump to line 1905 because the condition on line 1901 was always true
1902 x_levels.append(min(levels))
1903 x_levels.append(max(levels))
1904 else:
1905 all_data.append(cube.data)
1907 if len(x_levels) == 0: 1907 ↛ 1909line 1907 didn't jump to line 1909 because the condition on line 1907 was never true
1908 # Combine all data into a single NumPy array
1909 combined_data = np.concatenate(all_data)
1911 # Set the lower and upper limit for the x-axis to ensure all plots have
1912 # same range. This needs to read the whole cube over the range of the
1913 # sequence and if applicable postage stamp coordinate.
1914 vmin = np.floor(combined_data.min())
1915 vmax = np.ceil(combined_data.max())
1916 else:
1917 vmin = min(x_levels)
1918 vmax = max(x_levels)
1920 # Matching the slices (matching by seq coord point; it may happen that
1921 # evaluated models do not cover the same seq coord range, hence matching
1922 # necessary)
1923 def filter_cube_iterables(cube_iterables) -> bool:
1924 return len(cube_iterables) == len(coords)
1926 cube_iterables = filter(
1927 filter_cube_iterables,
1928 (
1929 iris.cube.CubeList(
1930 s
1931 for s in itertools.chain.from_iterable(
1932 cb.slices_over(sequence_coordinate) for cb in cubes
1933 )
1934 if s.coord(sequence_coordinate).points[0] == point
1935 )
1936 for point in sorted(
1937 set(
1938 itertools.chain.from_iterable(
1939 cb.coord(sequence_coordinate).points for cb in cubes
1940 )
1941 )
1942 )
1943 ),
1944 )
1946 # Create a plot for each value of the sequence coordinate.
1947 # Allowing for multiple cubes in a CubeList to be plotted in the same plot for
1948 # similar sequence values. Passing a CubeList into the internal plotting function
1949 # for similar values of the sequence coordinate. cube_slice can be an iris.cube.Cube
1950 # or an iris.cube.CubeList.
1951 plot_index = []
1952 nplot = np.size(cubes[0].coord(sequence_coordinate).points)
1953 for cubes_slice in cube_iterables:
1954 # Use sequence value so multiple sequences can merge.
1955 seq_coord = cubes_slice[0].coord(sequence_coordinate)
1956 sequence_value = seq_coord.points[0]
1957 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png"
1958 # Format the coordinate value in a unit appropriate way.
1959 title = f"{recipe_title}\n [{seq_coord.units.title(sequence_value)}]"
1960 # Use sequence (e.g. time) bounds if plotting single non-sequence outputs
1961 if nplot == 1 and seq_coord.has_bounds: 1961 ↛ 1962line 1961 didn't jump to line 1962 because the condition on line 1961 was never true
1962 if np.size(seq_coord.bounds) > 1:
1963 title = f"{recipe_title}\n [{seq_coord.units.title(seq_coord.bounds[0][0])} to {seq_coord.units.title(seq_coord.bounds[0][1])}]"
1964 # Do the actual plotting.
1965 _plot_and_save_vertical_line_series(
1966 cubes_slice,
1967 coords,
1968 "realization",
1969 plot_filename,
1970 series_coordinate,
1971 title=title,
1972 vmin=vmin,
1973 vmax=vmax,
1974 )
1975 plot_index.append(plot_filename)
1977 # Add list of plots to plot metadata.
1978 complete_plot_index = _append_to_plot_index(plot_index)
1980 # Make a page to display the plots.
1981 _make_plot_html_page(complete_plot_index)
1983 return cubes
1986def scatter_plot(
1987 cube_x: iris.cube.Cube | iris.cube.CubeList,
1988 cube_y: iris.cube.Cube | iris.cube.CubeList,
1989 filename: str = None,
1990 one_to_one: bool = True,
1991 **kwargs,
1992) -> iris.cube.CubeList:
1993 """Plot a scatter plot between two variables.
1995 Both cubes must be 1D.
1997 Parameters
1998 ----------
1999 cube_x: Cube | CubeList
2000 1 dimensional Cube of the data to plot on y-axis.
2001 cube_y: Cube | CubeList
2002 1 dimensional Cube of the data to plot on x-axis.
2003 filename: str, optional
2004 Filename of the plot to write.
2005 one_to_one: bool, optional
2006 If True a 1:1 line is plotted; if False it is not. Default is True.
2008 Returns
2009 -------
2010 cubes: CubeList
2011 CubeList of the original x and y cubes for further processing.
2013 Raises
2014 ------
2015 ValueError
2016 If the cube doesn't have the right dimensions and cubes not the same
2017 size.
2018 TypeError
2019 If the cube isn't a single cube.
2021 Notes
2022 -----
2023 Scatter plots are used for determining if there is a relationship between
2024 two variables. Positive relations have a slope going from bottom left to top
2025 right; Negative relations have a slope going from top left to bottom right.
2027 A variant of the scatter plot is the quantile-quantile plot. This plot does
2028 not use all data points, but the selected quantiles of each variable
2029 instead. Quantile-quantile plots are valuable for comparing against
2030 observations and other models. Identical percentiles between the variables
2031 will lie on the one-to-one line implying the values correspond well to each
2032 other. Where there is a deviation from the one-to-one line a range of
2033 possibilities exist depending on how and where the data is shifted (e.g.,
2034 Wilks 2011 [Wilks2011]_).
2036 For distributions above the one-to-one line the distribution is left-skewed;
2037 below is right-skewed. A distinct break implies a bimodal distribution, and
2038 closer values/values further apart at the tails imply poor representation of
2039 the extremes.
2041 References
2042 ----------
2043 .. [Wilks2011] Wilks, D.S., (2011) "Statistical Methods in the Atmospheric
2044 Sciences" Third Edition, vol. 100, Academic Press, Oxford, UK, 676 pp.
2045 """
2046 # Iterate over all cubes in cube or CubeList and plot.
2047 for cube_iter in iter_maybe(cube_x):
2048 # Check cubes are correct shape.
2049 cube_iter = _check_single_cube(cube_iter)
2050 if cube_iter.ndim > 1:
2051 raise ValueError("cube_x must be 1D.")
2053 # Iterate over all cubes in cube or CubeList and plot.
2054 for cube_iter in iter_maybe(cube_y):
2055 # Check cubes are correct shape.
2056 cube_iter = _check_single_cube(cube_iter)
2057 if cube_iter.ndim > 1:
2058 raise ValueError("cube_y must be 1D.")
2060 # Ensure we have a name for the plot file.
2061 title = get_recipe_metadata().get("title", "Untitled")
2063 if filename is None:
2064 filename = slugify(title)
2066 # Add file extension.
2067 plot_filename = f"{filename.rsplit('.', 1)[0]}.png"
2069 # Do the actual plotting.
2070 _plot_and_save_scatter_plot(cube_x, cube_y, plot_filename, title, one_to_one)
2072 # Add list of plots to plot metadata.
2073 plot_index = _append_to_plot_index([plot_filename])
2075 # Make a page to display the plots.
2076 _make_plot_html_page(plot_index)
2078 return iris.cube.CubeList([cube_x, cube_y])
2081def vector_plot(
2082 cube_u: iris.cube.Cube,
2083 cube_v: iris.cube.Cube,
2084 filename: str = None,
2085 sequence_coordinate: str = "time",
2086 **kwargs,
2087) -> iris.cube.CubeList:
2088 """Plot a vector plot based on the input u and v components."""
2089 recipe_title = get_recipe_metadata().get("title", "Untitled")
2091 # Ensure we have a name for the plot file.
2092 if filename is None: 2092 ↛ 2093line 2092 didn't jump to line 2093 because the condition on line 2092 was never true
2093 filename = slugify(recipe_title)
2095 # Cubes must have a matching sequence coordinate.
2096 try:
2097 # Check that the u and v cubes have the same sequence coordinate.
2098 if cube_u.coord(sequence_coordinate) != cube_v.coord(sequence_coordinate): 2098 ↛ 2099line 2098 didn't jump to line 2099 because the condition on line 2098 was never true
2099 raise ValueError("Coordinates do not match.")
2100 except (iris.exceptions.CoordinateNotFoundError, ValueError) as err:
2101 raise ValueError(
2102 f"Cubes should have matching {sequence_coordinate} coordinate:\n{cube_u}\n{cube_v}"
2103 ) from err
2105 # Create a plot for each value of the sequence coordinate.
2106 plot_index = []
2107 for cube_u_slice, cube_v_slice in zip(
2108 cube_u.slices_over(sequence_coordinate),
2109 cube_v.slices_over(sequence_coordinate),
2110 strict=True,
2111 ):
2112 # Use sequence value so multiple sequences can merge.
2113 sequence_value = cube_u_slice.coord(sequence_coordinate).points[0]
2114 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png"
2115 coord = cube_u_slice.coord(sequence_coordinate)
2116 # Format the coordinate value in a unit appropriate way.
2117 title = f"{recipe_title}\n{coord.units.title(coord.points[0])}"
2118 # Do the actual plotting.
2119 _plot_and_save_vector_plot(
2120 cube_u_slice,
2121 cube_v_slice,
2122 filename=plot_filename,
2123 title=title,
2124 method="contourf",
2125 )
2126 plot_index.append(plot_filename)
2128 # Add list of plots to plot metadata.
2129 complete_plot_index = _append_to_plot_index(plot_index)
2131 # Make a page to display the plots.
2132 _make_plot_html_page(complete_plot_index)
2134 return iris.cube.CubeList([cube_u, cube_v])
2137def plot_histogram_series(
2138 cubes: iris.cube.Cube | iris.cube.CubeList,
2139 filename: str = None,
2140 sequence_coordinate: str = "time",
2141 stamp_coordinate: str = "realization",
2142 single_plot: bool = False,
2143 **kwargs,
2144) -> iris.cube.Cube | iris.cube.CubeList:
2145 """Plot a histogram plot for each vertical level provided.
2147 A histogram plot can be plotted, but if the sequence_coordinate (i.e. time)
2148 is present then a sequence of plots will be produced using the time slider
2149 functionality to scroll through histograms against time. If a
2150 stamp_coordinate is present then postage stamp plots will be produced. If
2151 stamp_coordinate and single_plot is True, all postage stamp plots will be
2152 plotted in a single plot instead of separate postage stamp plots.
2154 Parameters
2155 ----------
2156 cubes: Cube | iris.cube.CubeList
2157 Iris cube or CubeList of the data to plot. It should have a single dimension other
2158 than the stamp coordinate.
2159 The cubes should cover the same phenomenon i.e. all cubes contain temperature data.
2160 We do not support different data such as temperature and humidity in the same CubeList for plotting.
2161 filename: str, optional
2162 Name of the plot to write, used as a prefix for plot sequences. Defaults
2163 to the recipe name.
2164 sequence_coordinate: str, optional
2165 Coordinate about which to make a plot sequence. Defaults to ``"time"``.
2166 This coordinate must exist in the cube and will be used for the time
2167 slider.
2168 stamp_coordinate: str, optional
2169 Coordinate about which to plot postage stamp plots. Defaults to
2170 ``"realization"``.
2171 single_plot: bool, optional
2172 If True, all postage stamp plots will be plotted in a single plot. If
2173 False, each postage stamp plot will be plotted separately. Is only valid
2174 if stamp_coordinate exists and has more than a single point.
2176 Returns
2177 -------
2178 iris.cube.Cube | iris.cube.CubeList
2179 The original Cube or CubeList (so further operations can be applied).
2180 Plotted data.
2182 Raises
2183 ------
2184 ValueError
2185 If the cube doesn't have the right dimensions.
2186 TypeError
2187 If the cube isn't a Cube or CubeList.
2188 """
2189 recipe_title = get_recipe_metadata().get("title", "Untitled")
2191 cubes = iter_maybe(cubes)
2193 # Ensure we have a name for the plot file.
2194 if filename is None:
2195 filename = slugify(recipe_title)
2197 # Internal plotting function.
2198 plotting_func = _plot_and_save_histogram_series
2200 num_models = _get_num_models(cubes)
2202 _validate_cube_shape(cubes, num_models)
2204 # If several histograms are plotted with time as sequence_coordinate for the
2205 # time slider option.
2206 for cube in cubes:
2207 try:
2208 cube.coord(sequence_coordinate)
2209 except iris.exceptions.CoordinateNotFoundError as err:
2210 raise ValueError(
2211 f"Cube must have a {sequence_coordinate} coordinate."
2212 ) from err
2214 # Get minimum and maximum from levels information.
2215 levels = None
2216 for cube in cubes: 2216 ↛ 2232line 2216 didn't jump to line 2232 because the loop on line 2216 didn't complete
2217 # First check if user-specified "auto" range variable.
2218 # This maintains the value of levels as None, so proceed.
2219 _, levels, _ = _colorbar_map_levels(cube, axis="y")
2220 if levels is None:
2221 break
2222 # If levels is changed, recheck to use the vmin,vmax or
2223 # levels-based ranges for histogram plots.
2224 _, levels, _ = _colorbar_map_levels(cube)
2225 logging.debug("levels: %s", levels)
2226 if levels is not None: 2226 ↛ 2216line 2226 didn't jump to line 2216 because the condition on line 2226 was always true
2227 vmin = min(levels)
2228 vmax = max(levels)
2229 logging.debug("Updated vmin, vmax: %s, %s", vmin, vmax)
2230 break
2232 if levels is None:
2233 vmin = min(cb.data.min() for cb in cubes)
2234 vmax = max(cb.data.max() for cb in cubes)
2236 # Make postage stamp plots if stamp_coordinate exists and has more than a
2237 # single point. If single_plot is True:
2238 # -- all postage stamp plots will be plotted in a single plot instead of
2239 # separate postage stamp plots.
2240 # -- model names (hidden in cube attrs) are ignored, that is stamp plots are
2241 # produced per single model only
2242 if num_models == 1: 2242 ↛ 2255line 2242 didn't jump to line 2255 because the condition on line 2242 was always true
2243 if ( 2243 ↛ 2247line 2243 didn't jump to line 2247 because the condition on line 2243 was never true
2244 stamp_coordinate in [c.name() for c in cubes[0].coords()]
2245 and cubes[0].coord(stamp_coordinate).shape[0] > 1
2246 ):
2247 if single_plot:
2248 plotting_func = (
2249 _plot_and_save_postage_stamps_in_single_plot_histogram_series
2250 )
2251 else:
2252 plotting_func = _plot_and_save_postage_stamp_histogram_series
2253 cube_iterables = cubes[0].slices_over(sequence_coordinate)
2254 else:
2255 all_points = sorted(
2256 set(
2257 itertools.chain.from_iterable(
2258 cb.coord(sequence_coordinate).points for cb in cubes
2259 )
2260 )
2261 )
2262 all_slices = list(
2263 itertools.chain.from_iterable(
2264 cb.slices_over(sequence_coordinate) for cb in cubes
2265 )
2266 )
2267 # Matched slices (matched by seq coord point; it may happen that
2268 # evaluated models do not cover the same seq coord range, hence matching
2269 # necessary)
2270 cube_iterables = [
2271 iris.cube.CubeList(
2272 s for s in all_slices if s.coord(sequence_coordinate).points[0] == point
2273 )
2274 for point in all_points
2275 ]
2277 plot_index = []
2278 nplot = np.size(cube.coord(sequence_coordinate).points)
2279 # Create a plot for each value of the sequence coordinate. Allowing for
2280 # multiple cubes in a CubeList to be plotted in the same plot for similar
2281 # sequence values. Passing a CubeList into the internal plotting function
2282 # for similar values of the sequence coordinate. cube_slice can be an
2283 # iris.cube.Cube or an iris.cube.CubeList.
2284 for cube_slice in cube_iterables:
2285 single_cube = cube_slice
2286 if isinstance(cube_slice, iris.cube.CubeList): 2286 ↛ 2287line 2286 didn't jump to line 2287 because the condition on line 2286 was never true
2287 single_cube = cube_slice[0]
2289 # Use sequence value so multiple sequences can merge.
2290 sequence_value = single_cube.coord(sequence_coordinate).points[0]
2291 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png"
2292 coord = single_cube.coord(sequence_coordinate)
2293 # Format the coordinate value in a unit appropriate way.
2294 title = f"{recipe_title}\n [{coord.units.title(coord.points[0])}]"
2295 # Use sequence (e.g. time) bounds if plotting single non-sequence outputs
2296 if nplot == 1 and coord.has_bounds: 2296 ↛ 2297line 2296 didn't jump to line 2297 because the condition on line 2296 was never true
2297 if np.size(coord.bounds) > 1:
2298 title = f"{recipe_title}\n [{coord.units.title(coord.bounds[0][0])} to {coord.units.title(coord.bounds[0][1])}]"
2299 # Do the actual plotting.
2300 plotting_func(
2301 cube_slice,
2302 filename=plot_filename,
2303 stamp_coordinate=stamp_coordinate,
2304 title=title,
2305 vmin=vmin,
2306 vmax=vmax,
2307 )
2308 plot_index.append(plot_filename)
2310 # Add list of plots to plot metadata.
2311 complete_plot_index = _append_to_plot_index(plot_index)
2313 # Make a page to display the plots.
2314 _make_plot_html_page(complete_plot_index)
2316 return cubes