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