Coverage for src/CSET/operators/plot.py: 88%

765 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-30 15:17 +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. 

14 

15"""Operators to produce various kinds of plots.""" 

16 

17import fcntl 

18import functools 

19import importlib.resources 

20import itertools 

21import json 

22import logging 

23import math 

24import os 

25import sys 

26from typing import Literal 

27 

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 

39 

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 

48 

49# Use a non-interactive plotting backend. 

50mpl.use("agg") 

51 

52DEFAULT_DISCRETE_COLORS = mpl.colormaps["tab10"].colors + mpl.colormaps["Accent"].colors 

53 

54############################ 

55# Private helper functions # 

56############################ 

57 

58 

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 

76 

77 

78def _check_single_cube(cube: iris.cube.Cube | iris.cube.CubeList) -> iris.cube.Cube: 

79 """Ensure a single cube is given. 

80 

81 If a CubeList of length one is given that the contained cube is returned, 

82 otherwise an error is raised. 

83 

84 Parameters 

85 ---------- 

86 cube: Cube | CubeList 

87 The cube to check. 

88 

89 Returns 

90 ------- 

91 cube: Cube 

92 The checked cube. 

93 

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) 

105 

106 

107def _py312_importlib_resources_files_shim(): 

108 """Importlib behaviour changed in 3.12 to avoid circular dependencies. 

109 

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 

117 

118 files = importlib.resources.files(CSET.operators) 

119 return files 

120 

121 

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) 

126 

127 # Load HTML template file. 

128 operator_files = _py312_importlib_resources_files_shim() 

129 template_file = operator_files.joinpath("_plot_page_template.html") 

130 

131 # Get some metadata. 

132 meta = get_recipe_metadata() 

133 title = meta.get("title", "Untitled") 

134 description = MarkdownIt().render(meta.get("description", "*No description.*")) 

135 

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 } 

144 

145 # Render template. 

146 html = render_file(template_file, **variables) 

147 

148 # Save completed HTML. 

149 with open("index.html", "wt", encoding="UTF-8") as fp: 

150 fp.write(html) 

151 

152 

153@functools.cache 

154def _load_colorbar_map(user_colorbar_file: str = None) -> dict: 

155 """Load the colorbar definitions from a file. 

156 

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) 

164 

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.") 

173 

174 # Overwrite values with the user supplied colorbar definition. 

175 colorbar = combine_dicts(colorbar, override_colorbar) 

176 return colorbar 

177 

178 

179def _get_model_colors_map(cubes: iris.cube.CubeList | iris.cube.Cube) -> dict: 

180 """Get an appropriate colors for model lines in line plots. 

181 

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. 

185 

186 Parameters 

187 ---------- 

188 cubes: CubeList or Cube 

189 Cubes with model_name attribute 

190 

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} 

209 

210 color_list = itertools.cycle(DEFAULT_DISCRETE_COLORS) 

211 return {mname: color for mname, color in zip(model_names, color_list, strict=False)} 

212 

213 

214def _colorbar_map_levels(cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None): 

215 """Get an appropriate colorbar for the given cube. 

216 

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. 

225 

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. 

237 

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 

252 

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 

260 

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) 

274 

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 aviation colour state use custom colorbar and levels 

288 if any("aviation_colour_state" in name for name in varnames): 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true

289 cmap, levels, norm = _custom_colormap_aviation_colour_state(cube) 

290 return cmap, levels, norm 

291 

292 # If no valid colormap has been defined, use defaults and return. 

293 if not cmap: 

294 logging.warning("No colorbar definition exists for %s.", cube.name()) 

295 cmap, levels, norm = mpl.colormaps["viridis"], None, None 

296 return cmap, levels, norm 

297 

298 # Test if pressure-level specific settings are provided for cube. 

299 if pressure_level: 

300 try: 

301 var_colorbar = colorbar[varname_key]["pressure_levels"][pressure_level] 

302 except KeyError: 

303 logging.debug( 

304 "%s has no colorbar definition for pressure level %s.", 

305 varname, 

306 pressure_level, 

307 ) 

308 

309 # Check for availability of x-axis or y-axis user-specific overrides 

310 # for setting level bounds for line plot types and return just levels. 

311 # Line plots do not need a colormap, and just use the data range. 

312 if axis: 

313 if axis == "x": 

314 try: 

315 vmin, vmax = var_colorbar["xmin"], var_colorbar["xmax"] 

316 except KeyError: 

317 vmin, vmax = var_colorbar["min"], var_colorbar["max"] 

318 if axis == "y": 

319 try: 

320 vmin, vmax = var_colorbar["ymin"], var_colorbar["ymax"] 

321 except KeyError: 

322 vmin, vmax = var_colorbar["min"], var_colorbar["max"] 

323 # Check if user-specified auto-scaling for this variable 

324 if vmin == "auto" or vmax == "auto": 

325 levels = None 

326 else: 

327 levels = [vmin, vmax] 

328 return None, levels, None 

329 # Get and use the colorbar levels for this variable if spatial or histogram. 

330 else: 

331 try: 

332 levels = var_colorbar["levels"] 

333 # Use discrete bins when levels are specified, rather 

334 # than a smooth range. 

335 norm = mpl.colors.BoundaryNorm(levels, ncolors=cmap.N) 

336 logging.debug("Using levels for %s colorbar.", varname) 

337 logging.info("Using levels: %s", levels) 

338 except KeyError: 

339 # Get the range for this variable. 

340 vmin, vmax = var_colorbar["min"], var_colorbar["max"] 

341 logging.debug("Using min and max for %s colorbar.", varname) 

342 # Calculate levels from range. 

343 levels = np.linspace(vmin, vmax, 101) 

344 norm = None 

345 

346 # Overwrite cmap, levels and norm for specific variables that 

347 # require custom colorbar_map as these can not be defined in the 

348 # JSON file. 

349 cmap, levels, norm = _custom_colourmap_precipitation(cube, cmap, levels, norm) 

350 cmap, levels, norm = _custom_colourmap_visibility_in_air( 

351 cube, cmap, levels, norm 

352 ) 

353 cmap, levels, norm = _custom_colormap_celsius(cube, cmap, levels, norm) 

354 return cmap, levels, norm 

355 

356 

357def _setup_spatial_map( 

358 cube: iris.cube.Cube, 

359 figure, 

360 cmap, 

361 grid_size: int | None = None, 

362 subplot: int | None = None, 

363): 

364 """Define map projections, extent and add coastlines for spatial plots. 

365 

366 For spatial map plots, a relevant map projection for rotated or non-rotated inputs 

367 is specified, and map extent defined based on the input data. 

368 

369 Parameters 

370 ---------- 

371 cube: Cube 

372 2 dimensional (lat and lon) Cube of the data to plot. 

373 figure: 

374 Matplotlib Figure object holding all plot elements. 

375 cmap: 

376 Matplotlib colormap. 

377 grid_size: int, optional 

378 Size of grid for subplots if multiple spatial subplots in figure. 

379 subplot: int, optional 

380 Subplot index if multiple spatial subplots in figure. 

381 

382 Returns 

383 ------- 

384 axes: 

385 Matplotlib GeoAxes definition. 

386 """ 

387 # Identify min/max plot bounds. 

388 try: 

389 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

390 x1 = np.min(cube.coord(lon_axis).points) 

391 x2 = np.max(cube.coord(lon_axis).points) 

392 y1 = np.min(cube.coord(lat_axis).points) 

393 y2 = np.max(cube.coord(lat_axis).points) 

394 

395 # Adjust bounds within +/- 180.0 if x dimension extends beyond half-globe. 

396 if np.abs(x2 - x1) > 180.0: 

397 x1 = x1 - 180.0 

398 x2 = x2 - 180.0 

399 logging.debug("Adjusting plot bounds to fit global extent.") 

400 

401 # Consider map projection orientation. 

402 # Adapting orientation enables plotting across international dateline. 

403 # Users can adapt the default central_longitude if alternative projections views. 

404 if x2 > 180.0: 

405 central_longitude = 180.0 

406 else: 

407 central_longitude = 0.0 

408 

409 # Define spatial map projection. 

410 coord_system = cube.coord(lat_axis).coord_system 

411 if isinstance(coord_system, iris.coord_systems.RotatedGeogCS): 

412 # Define rotated pole map projection for rotated pole inputs. 

413 projection = ccrs.RotatedPole( 

414 pole_longitude=coord_system.grid_north_pole_longitude, 

415 pole_latitude=coord_system.grid_north_pole_latitude, 

416 central_rotated_longitude=0.0, 

417 ) 

418 crs = projection 

419 else: 

420 # Define regular map projection for non-rotated pole inputs. 

421 # Alternatives might include e.g. for global model outputs: 

422 # projection=ccrs.Robinson(central_longitude=X.y, globe=None) 

423 # See also https://scitools.org.uk/cartopy/docs/v0.15/crs/projections.html. 

424 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

425 crs = ccrs.PlateCarree() 

426 

427 # Define axes for plot (or subplot) with required map projection. 

428 if subplot is not None: 

429 axes = figure.add_subplot( 

430 grid_size, grid_size, subplot, projection=projection 

431 ) 

432 else: 

433 axes = figure.add_subplot(projection=projection) 

434 

435 # Add coastlines if cube contains x and y map coordinates. 

436 if cmap.name in ["viridis", "Greys"]: 

437 coastcol = "magenta" 

438 else: 

439 coastcol = "black" 

440 logging.debug("Plotting coastlines in colour %s.", coastcol) 

441 axes.coastlines(resolution="10m", color=coastcol) 

442 

443 # If is lat/lon spatial map, fix extent to keep plot tight. 

444 # Specifying crs within set_extent helps ensure only data region is shown. 

445 if isinstance(coord_system, iris.coord_systems.GeogCS): 

446 axes.set_extent([x1, x2, y1, y2], crs=crs) 

447 

448 except ValueError: 

449 # Skip if not both x and y map coordinates. 

450 axes = figure.gca() 

451 pass 

452 

453 return axes 

454 

455 

456def _get_plot_resolution() -> int: 

457 """Get resolution of rasterised plots in pixels per inch.""" 

458 return get_recipe_metadata().get("plot_resolution", 100) 

459 

460 

461def _plot_and_save_spatial_plot( 

462 cube: iris.cube.Cube, 

463 filename: str, 

464 title: str, 

465 method: Literal["contourf", "pcolormesh"], 

466 **kwargs, 

467): 

468 """Plot and save a spatial plot. 

469 

470 Parameters 

471 ---------- 

472 cube: Cube 

473 2 dimensional (lat and lon) Cube of the data to plot. 

474 filename: str 

475 Filename of the plot to write. 

476 title: str 

477 Plot title. 

478 method: "contourf" | "pcolormesh" 

479 The plotting method to use. 

480 """ 

481 # Setup plot details, size, resolution, etc. 

482 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k") 

483 

484 # Specify the color bar 

485 cmap, levels, norm = _colorbar_map_levels(cube) 

486 

487 # Setup plot map projection, extent and coastlines. 

488 axes = _setup_spatial_map(cube, fig, cmap) 

489 

490 # Plot the field. 

491 if method == "contourf": 

492 # Filled contour plot of the field. 

493 plot = iplt.contourf(cube, cmap=cmap, levels=levels, norm=norm) 

494 elif method == "pcolormesh": 

495 try: 

496 vmin = min(levels) 

497 vmax = max(levels) 

498 except TypeError: 

499 vmin, vmax = None, None 

500 # pcolormesh plot of the field and ensure to use norm and not vmin/vmax 

501 # if levels are defined. 

502 if norm is not None: 

503 vmin = None 

504 vmax = None 

505 logging.debug("Plotting using defined levels.") 

506 plot = iplt.pcolormesh(cube, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax) 

507 else: 

508 raise ValueError(f"Unknown plotting method: {method}") 

509 

510 # Check to see if transect, and if so, adjust y axis. 

511 if is_transect(cube): 

512 if "pressure" in [coord.name() for coord in cube.coords()]: 

513 axes.invert_yaxis() 

514 axes.set_yscale("log") 

515 axes.set_ylim(1100, 100) 

516 # If both model_level_number and level_height exists, iplt can construct 

517 # plot as a function of height above orography (NOT sea level). 

518 elif {"model_level_number", "level_height"}.issubset( 518 ↛ 523line 518 didn't jump to line 523 because the condition on line 518 was always true

519 {coord.name() for coord in cube.coords()} 

520 ): 

521 axes.set_yscale("log") 

522 

523 axes.set_title( 

524 f"{title}\n" 

525 f"Start Lat: {cube.attributes['transect_coords'].split('_')[0]}" 

526 f" Start Lon: {cube.attributes['transect_coords'].split('_')[1]}" 

527 f" End Lat: {cube.attributes['transect_coords'].split('_')[2]}" 

528 f" End Lon: {cube.attributes['transect_coords'].split('_')[3]}", 

529 fontsize=16, 

530 ) 

531 

532 else: 

533 # Add title. 

534 axes.set_title(title, fontsize=16) 

535 

536 # Add watermark with min/max/mean. Currently not user togglable. 

537 # In the bbox dictionary, fc and ec are hex colour codes for grey shade. 

538 axes.annotate( 

539 f"Min: {np.min(cube.data):.3g} Max: {np.max(cube.data):.3g} Mean: {np.mean(cube.data):.3g}", 

540 xy=(1, -0.05), 

541 xycoords="axes fraction", 

542 xytext=(-5, 5), 

543 textcoords="offset points", 

544 ha="right", 

545 va="bottom", 

546 size=11, 

547 bbox=dict(boxstyle="round", fc="#cccccc", ec="#808080", alpha=0.9), 

548 ) 

549 

550 # Add colour bar. 

551 cbar = fig.colorbar(plot, orientation="horizontal", pad=0.042, shrink=0.7) 

552 cbar.set_label(label=f"{cube.name()} ({cube.units})", size=14) 

553 # add ticks and tick_labels for every levels if less than 20 levels exist 

554 if levels is not None and len(levels) < 20: 

555 cbar.set_ticks(levels) 

556 cbar.set_ticklabels([f"{level:.2f}" for level in levels]) 

557 if "visibility" in cube.name(): 557 ↛ 558line 557 didn't jump to line 558 because the condition on line 557 was never true

558 cbar.set_ticklabels([f"{level:.3g}" for level in levels]) 

559 logging.debug("Set colorbar ticks and labels.") 

560 

561 # Save plot. 

562 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution()) 

563 logging.info("Saved spatial plot to %s", filename) 

564 plt.close(fig) 

565 

566 

567def _plot_and_save_postage_stamp_spatial_plot( 

568 cube: iris.cube.Cube, 

569 filename: str, 

570 stamp_coordinate: str, 

571 title: str, 

572 method: Literal["contourf", "pcolormesh"], 

573 **kwargs, 

574): 

575 """Plot postage stamp spatial plots from an ensemble. 

576 

577 Parameters 

578 ---------- 

579 cube: Cube 

580 Iris cube of data to be plotted. It must have the stamp coordinate. 

581 filename: str 

582 Filename of the plot to write. 

583 stamp_coordinate: str 

584 Coordinate that becomes different plots. 

585 method: "contourf" | "pcolormesh" 

586 The plotting method to use. 

587 

588 Raises 

589 ------ 

590 ValueError 

591 If the cube doesn't have the right dimensions. 

592 """ 

593 # Use the smallest square grid that will fit the members. 

594 grid_size = int(math.ceil(math.sqrt(len(cube.coord(stamp_coordinate).points)))) 

595 

596 fig = plt.figure(figsize=(10, 10)) 

597 

598 # Specify the color bar 

599 cmap, levels, norm = _colorbar_map_levels(cube) 

600 

601 # Make a subplot for each member. 

602 for member, subplot in zip( 

603 cube.slices_over(stamp_coordinate), range(1, grid_size**2 + 1), strict=False 

604 ): 

605 # Setup subplot map projection, extent and coastlines. 

606 axes = _setup_spatial_map( 

607 member, fig, cmap, grid_size=grid_size, subplot=subplot 

608 ) 

609 if method == "contourf": 

610 # Filled contour plot of the field. 

611 plot = iplt.contourf(member, cmap=cmap, levels=levels, norm=norm) 

612 elif method == "pcolormesh": 

613 if levels is not None: 

614 vmin = min(levels) 

615 vmax = max(levels) 

616 else: 

617 raise TypeError("Unknown vmin and vmax range.") 

618 vmin, vmax = None, None 

619 # pcolormesh plot of the field and ensure to use norm and not vmin/vmax 

620 # if levels are defined. 

621 if norm is not None: 621 ↛ 622line 621 didn't jump to line 622 because the condition on line 621 was never true

622 vmin = None 

623 vmax = None 

624 # pcolormesh plot of the field. 

625 plot = iplt.pcolormesh(member, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax) 

626 else: 

627 raise ValueError(f"Unknown plotting method: {method}") 

628 axes.set_title(f"Member #{member.coord(stamp_coordinate).points[0]}") 

629 axes.set_axis_off() 

630 

631 # Put the shared colorbar in its own axes. 

632 colorbar_axes = fig.add_axes([0.15, 0.07, 0.7, 0.03]) 

633 colorbar = fig.colorbar( 

634 plot, colorbar_axes, orientation="horizontal", pad=0.042, shrink=0.7 

635 ) 

636 colorbar.set_label(f"{cube.name()} ({cube.units})", size=14) 

637 

638 # Overall figure title. 

639 fig.suptitle(title, fontsize=16) 

640 

641 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution()) 

642 logging.info("Saved contour postage stamp plot to %s", filename) 

643 plt.close(fig) 

644 

645 

646def _plot_and_save_line_series( 

647 cubes: iris.cube.CubeList, 

648 coords: list[iris.coords.Coord], 

649 ensemble_coord: str, 

650 filename: str, 

651 title: str, 

652 **kwargs, 

653): 

654 """Plot and save a 1D line series. 

655 

656 Parameters 

657 ---------- 

658 cubes: Cube or CubeList 

659 Cube or CubeList containing the cubes to plot on the y-axis. 

660 coords: list[Coord] 

661 Coordinates to plot on the x-axis, one per cube. 

662 ensemble_coord: str 

663 Ensemble coordinate in the cube. 

664 filename: str 

665 Filename of the plot to write. 

666 title: str 

667 Plot title. 

668 """ 

669 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k") 

670 

671 model_colors_map = _get_model_colors_map(cubes) 

672 

673 # Store min/max ranges. 

674 y_levels = [] 

675 

676 # Check match-up across sequence coords gives consistent sizes 

677 _validate_cubes_coords(cubes, coords) 

678 

679 for cube, coord in zip(cubes, coords, strict=True): 

680 label = None 

681 color = "black" 

682 if model_colors_map: 

683 label = cube.attributes.get("model_name") 

684 color = model_colors_map.get(label) 

685 for cube_slice in cube.slices_over(ensemble_coord): 

686 # Label with (control) if part of an ensemble or not otherwise. 

687 if cube_slice.coord(ensemble_coord).points == [0]: 

688 iplt.plot( 

689 coord, 

690 cube_slice, 

691 color=color, 

692 marker="o", 

693 ls="-", 

694 lw=3, 

695 label=f"{label} (control)" 

696 if len(cube.coord(ensemble_coord).points) > 1 

697 else label, 

698 ) 

699 # Label with (perturbed) if part of an ensemble and not the control. 

700 else: 

701 iplt.plot( 

702 coord, 

703 cube_slice, 

704 color=color, 

705 ls="-", 

706 lw=1.5, 

707 alpha=0.75, 

708 label=f"{label} (member)", 

709 ) 

710 

711 # Calculate the global min/max if multiple cubes are given. 

712 _, levels, _ = _colorbar_map_levels(cube, axis="y") 

713 if levels is not None: 713 ↛ 714line 713 didn't jump to line 714 because the condition on line 713 was never true

714 y_levels.append(min(levels)) 

715 y_levels.append(max(levels)) 

716 

717 # Get the current axes. 

718 ax = plt.gca() 

719 

720 # Add some labels and tweak the style. 

721 # check if cubes[0] works for single cube if not CubeList 

722 ax.set_xlabel(f"{coords[0].name()} / {coords[0].units}", fontsize=14) 

723 ax.set_ylabel(f"{cubes[0].name()} / {cubes[0].units}", fontsize=14) 

724 ax.set_title(title, fontsize=16) 

725 

726 ax.ticklabel_format(axis="y", useOffset=False) 

727 ax.tick_params(axis="x", labelrotation=15) 

728 ax.tick_params(axis="both", labelsize=12) 

729 

730 # Set y limits to global min and max, autoscale if colorbar doesn't exist. 

731 if y_levels: 731 ↛ 732line 731 didn't jump to line 732 because the condition on line 731 was never true

732 ax.set_ylim(min(y_levels), max(y_levels)) 

733 # Add zero line. 

734 if min(y_levels) < 0.0 and max(y_levels) > 0.0: 

735 ax.axhline(y=0, xmin=0, xmax=1, ls="-", color="grey", lw=2) 

736 logging.debug( 

737 "Line plot with y-axis limits %s-%s", min(y_levels), max(y_levels) 

738 ) 

739 else: 

740 ax.autoscale() 

741 

742 # Add gridlines 

743 ax.grid(linestyle="--", color="grey", linewidth=1) 

744 # Ientify unique labels for legend 

745 handles = list( 

746 { 

747 label: handle 

748 for (handle, label) in zip(*ax.get_legend_handles_labels(), strict=True) 

749 }.values() 

750 ) 

751 ax.legend(handles=handles, loc="best", ncol=1, frameon=False, fontsize=16) 

752 

753 # Save plot. 

754 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution()) 

755 logging.info("Saved line plot to %s", filename) 

756 plt.close(fig) 

757 

758 

759def _plot_and_save_vertical_line_series( 

760 cubes: iris.cube.CubeList, 

761 coords: list[iris.coords.Coord], 

762 ensemble_coord: str, 

763 filename: str, 

764 series_coordinate: str, 

765 title: str, 

766 vmin: float, 

767 vmax: float, 

768 **kwargs, 

769): 

770 """Plot and save a 1D line series in vertical. 

771 

772 Parameters 

773 ---------- 

774 cubes: CubeList 

775 1 dimensional Cube or CubeList of the data to plot on x-axis. 

776 coord: list[Coord] 

777 Coordinates to plot on the y-axis, one per cube. 

778 ensemble_coord: str 

779 Ensemble coordinate in the cube. 

780 filename: str 

781 Filename of the plot to write. 

782 series_coordinate: str 

783 Coordinate to use as vertical axis. 

784 title: str 

785 Plot title. 

786 vmin: float 

787 Minimum value for the x-axis. 

788 vmax: float 

789 Maximum value for the x-axis. 

790 """ 

791 # plot the vertical pressure axis using log scale 

792 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k") 

793 

794 model_colors_map = _get_model_colors_map(cubes) 

795 

796 # Check match-up across sequence coords gives consistent sizes 

797 _validate_cubes_coords(cubes, coords) 

798 

799 for cube, coord in zip(cubes, coords, strict=True): 

800 label = None 

801 color = "black" 

802 if model_colors_map: 802 ↛ 803line 802 didn't jump to line 803 because the condition on line 802 was never true

803 label = cube.attributes.get("model_name") 

804 color = model_colors_map.get(label) 

805 

806 for cube_slice in cube.slices_over(ensemble_coord): 

807 # If ensemble data given plot control member with (control) 

808 # unless single forecast. 

809 if cube_slice.coord(ensemble_coord).points == [0]: 

810 iplt.plot( 

811 cube_slice, 

812 coord, 

813 color=color, 

814 marker="o", 

815 ls="-", 

816 lw=3, 

817 label=f"{label} (control)" 

818 if len(cube.coord(ensemble_coord).points) > 1 

819 else label, 

820 ) 

821 # If ensemble data given plot perturbed members with (perturbed). 

822 else: 

823 iplt.plot( 

824 cube_slice, 

825 coord, 

826 color=color, 

827 ls="-", 

828 lw=1.5, 

829 alpha=0.75, 

830 label=f"{label} (member)", 

831 ) 

832 

833 # Get the current axis 

834 ax = plt.gca() 

835 

836 # Special handling for pressure level data. 

837 if series_coordinate == "pressure": 837 ↛ 859line 837 didn't jump to line 859 because the condition on line 837 was always true

838 # Invert y-axis and set to log scale. 

839 ax.invert_yaxis() 

840 ax.set_yscale("log") 

841 

842 # Define y-ticks and labels for pressure log axis. 

843 y_tick_labels = [ 

844 "1000", 

845 "850", 

846 "700", 

847 "500", 

848 "300", 

849 "200", 

850 "100", 

851 ] 

852 y_ticks = [1000, 850, 700, 500, 300, 200, 100] 

853 

854 # Set y-axis limits and ticks. 

855 ax.set_ylim(1100, 100) 

856 

857 # Test if series_coordinate is model level data. The UM data uses 

858 # model_level_number and lfric uses full_levels as coordinate. 

859 elif series_coordinate in ("model_level_number", "full_levels", "half_levels"): 

860 # Define y-ticks and labels for vertical axis. 

861 y_ticks = iter_maybe(cubes)[0].coord(series_coordinate).points 

862 y_tick_labels = [str(int(i)) for i in y_ticks] 

863 ax.set_ylim(min(y_ticks), max(y_ticks)) 

864 

865 ax.set_yticks(y_ticks) 

866 ax.set_yticklabels(y_tick_labels) 

867 

868 # Set x-axis limits. 

869 ax.set_xlim(vmin, vmax) 

870 # Mark y=0 if present in plot. 

871 if vmin < 0.0 and vmax > 0.0: 871 ↛ 872line 871 didn't jump to line 872 because the condition on line 871 was never true

872 ax.axvline(x=0, ymin=0, ymax=1, ls="-", color="grey", lw=2) 

873 

874 # Add some labels and tweak the style. 

875 ax.set_ylabel(f"{coord.name()} / {coord.units}", fontsize=14) 

876 ax.set_xlabel( 

877 f"{iter_maybe(cubes)[0].name()} / {iter_maybe(cubes)[0].units}", fontsize=14 

878 ) 

879 ax.set_title(title, fontsize=16) 

880 ax.ticklabel_format(axis="x") 

881 ax.tick_params(axis="y") 

882 ax.tick_params(axis="both", labelsize=12) 

883 

884 # Add gridlines 

885 ax.grid(linestyle="--", color="grey", linewidth=1) 

886 # Ientify unique labels for legend 

887 handles = list( 

888 { 

889 label: handle 

890 for (handle, label) in zip(*ax.get_legend_handles_labels(), strict=True) 

891 }.values() 

892 ) 

893 ax.legend(handles=handles, loc="best", ncol=1, frameon=False, fontsize=16) 

894 

895 # Save plot. 

896 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution()) 

897 logging.info("Saved line plot to %s", filename) 

898 plt.close(fig) 

899 

900 

901def _plot_and_save_scatter_plot( 

902 cube_x: iris.cube.Cube | iris.cube.CubeList, 

903 cube_y: iris.cube.Cube | iris.cube.CubeList, 

904 filename: str, 

905 title: str, 

906 one_to_one: bool, 

907 **kwargs, 

908): 

909 """Plot and save a 2D scatter plot. 

910 

911 Parameters 

912 ---------- 

913 cube_x: Cube | CubeList 

914 1 dimensional Cube or CubeList of the data to plot on x-axis. 

915 cube_y: Cube | CubeList 

916 1 dimensional Cube or CubeList of the data to plot on y-axis. 

917 filename: str 

918 Filename of the plot to write. 

919 title: str 

920 Plot title. 

921 one_to_one: bool 

922 Whether a 1:1 line is plotted. 

923 """ 

924 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k") 

925 # plot the cube_x and cube_y 1D fields as a scatter plot. If they are CubeLists this ensures 

926 # to pair each cube from cube_x with the corresponding cube from cube_y, allowing to iterate 

927 # over the pairs simultaneously. 

928 

929 # Ensure cube_x and cube_y are iterable 

930 cube_x_iterable = iter_maybe(cube_x) 

931 cube_y_iterable = iter_maybe(cube_y) 

932 

933 for cube_x_iter, cube_y_iter in zip(cube_x_iterable, cube_y_iterable, strict=True): 

934 iplt.scatter(cube_x_iter, cube_y_iter) 

935 if one_to_one is True: 

936 plt.plot( 

937 [ 

938 np.nanmin([np.nanmin(cube_y.data), np.nanmin(cube_x.data)]), 

939 np.nanmax([np.nanmax(cube_y.data), np.nanmax(cube_x.data)]), 

940 ], 

941 [ 

942 np.nanmin([np.nanmin(cube_y.data), np.nanmin(cube_x.data)]), 

943 np.nanmax([np.nanmax(cube_y.data), np.nanmax(cube_x.data)]), 

944 ], 

945 "k", 

946 linestyle="--", 

947 ) 

948 ax = plt.gca() 

949 

950 # Add some labels and tweak the style. 

951 ax.set_xlabel(f"{cube_x[0].name()} / {cube_x[0].units}", fontsize=14) 

952 ax.set_ylabel(f"{cube_y[0].name()} / {cube_y[0].units}", fontsize=14) 

953 ax.set_title(title, fontsize=16) 

954 ax.ticklabel_format(axis="y", useOffset=False) 

955 ax.tick_params(axis="x", labelrotation=15) 

956 ax.tick_params(axis="both", labelsize=12) 

957 ax.autoscale() 

958 

959 # Save plot. 

960 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution()) 

961 logging.info("Saved scatter plot to %s", filename) 

962 plt.close(fig) 

963 

964 

965def _plot_and_save_vector_plot( 

966 cube_u: iris.cube.Cube, 

967 cube_v: iris.cube.Cube, 

968 filename: str, 

969 title: str, 

970 method: Literal["contourf", "pcolormesh"], 

971 **kwargs, 

972): 

973 """Plot and save a 2D vector plot. 

974 

975 Parameters 

976 ---------- 

977 cube_u: Cube 

978 2 dimensional Cube of u component of the data. 

979 cube_v: Cube 

980 2 dimensional Cube of v component of the data. 

981 filename: str 

982 Filename of the plot to write. 

983 title: str 

984 Plot title. 

985 """ 

986 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k") 

987 

988 # Create a cube containing the magnitude of the vector field. 

989 cube_vec_mag = (cube_u**2 + cube_v**2) ** 0.5 

990 cube_vec_mag.rename(f"{cube_u.name()}_{cube_v.name()}_magnitude") 

991 

992 # Specify the color bar 

993 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag) 

994 

995 # Setup plot map projection, extent and coastlines. 

996 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

997 

998 if method == "contourf": 998 ↛ 1001line 998 didn't jump to line 1001 because the condition on line 998 was always true

999 # Filled contour plot of the field. 

1000 plot = iplt.contourf(cube_vec_mag, cmap=cmap, levels=levels, norm=norm) 

1001 elif method == "pcolormesh": 

1002 try: 

1003 vmin = min(levels) 

1004 vmax = max(levels) 

1005 except TypeError: 

1006 vmin, vmax = None, None 

1007 # pcolormesh plot of the field and ensure to use norm and not vmin/vmax 

1008 # if levels are defined. 

1009 if norm is not None: 

1010 vmin = None 

1011 vmax = None 

1012 plot = iplt.pcolormesh(cube_vec_mag, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax) 

1013 else: 

1014 raise ValueError(f"Unknown plotting method: {method}") 

1015 

1016 # Check to see if transect, and if so, adjust y axis. 

1017 if is_transect(cube_vec_mag): 1017 ↛ 1018line 1017 didn't jump to line 1018 because the condition on line 1017 was never true

1018 if "pressure" in [coord.name() for coord in cube_vec_mag.coords()]: 

1019 axes.invert_yaxis() 

1020 axes.set_yscale("log") 

1021 axes.set_ylim(1100, 100) 

1022 # If both model_level_number and level_height exists, iplt can construct 

1023 # plot as a function of height above orography (NOT sea level). 

1024 elif {"model_level_number", "level_height"}.issubset( 

1025 {coord.name() for coord in cube_vec_mag.coords()} 

1026 ): 

1027 axes.set_yscale("log") 

1028 

1029 axes.set_title( 

1030 f"{title}\n" 

1031 f"Start Lat: {cube_vec_mag.attributes['transect_coords'].split('_')[0]}" 

1032 f" Start Lon: {cube_vec_mag.attributes['transect_coords'].split('_')[1]}" 

1033 f" End Lat: {cube_vec_mag.attributes['transect_coords'].split('_')[2]}" 

1034 f" End Lon: {cube_vec_mag.attributes['transect_coords'].split('_')[3]}", 

1035 fontsize=16, 

1036 ) 

1037 

1038 else: 

1039 # Add title. 

1040 axes.set_title(title, fontsize=16) 

1041 

1042 # Add watermark with min/max/mean. Currently not user togglable. 

1043 # In the bbox dictionary, fc and ec are hex colour codes for grey shade. 

1044 axes.annotate( 

1045 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}", 

1046 xy=(1, -0.05), 

1047 xycoords="axes fraction", 

1048 xytext=(-5, 5), 

1049 textcoords="offset points", 

1050 ha="right", 

1051 va="bottom", 

1052 size=11, 

1053 bbox=dict(boxstyle="round", fc="#cccccc", ec="#808080", alpha=0.9), 

1054 ) 

1055 

1056 # Add colour bar. 

1057 cbar = fig.colorbar(plot, orientation="horizontal", pad=0.042, shrink=0.7) 

1058 cbar.set_label(label=f"{cube_vec_mag.name()} ({cube_vec_mag.units})", size=14) 

1059 # add ticks and tick_labels for every levels if less than 20 levels exist 

1060 if levels is not None and len(levels) < 20: 1060 ↛ 1061line 1060 didn't jump to line 1061 because the condition on line 1060 was never true

1061 cbar.set_ticks(levels) 

1062 cbar.set_ticklabels([f"{level:.1f}" for level in levels]) 

1063 

1064 # 30 barbs along the longest axis of the plot, or a barb per point for data 

1065 # with less than 30 points. 

1066 step = max(max(cube_u.shape) // 30, 1) 

1067 iplt.quiver(cube_u[::step, ::step], cube_v[::step, ::step], pivot="middle") 

1068 

1069 # Save plot. 

1070 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution()) 

1071 logging.info("Saved vector plot to %s", filename) 

1072 plt.close(fig) 

1073 

1074 

1075def _plot_and_save_histogram_series( 

1076 cubes: iris.cube.Cube | iris.cube.CubeList, 

1077 filename: str, 

1078 title: str, 

1079 vmin: float, 

1080 vmax: float, 

1081 **kwargs, 

1082): 

1083 """Plot and save a histogram series. 

1084 

1085 Parameters 

1086 ---------- 

1087 cubes: Cube or CubeList 

1088 2 dimensional Cube or CubeList of the data to plot as histogram. 

1089 filename: str 

1090 Filename of the plot to write. 

1091 title: str 

1092 Plot title. 

1093 vmin: float 

1094 minimum for colorbar 

1095 vmax: float 

1096 maximum for colorbar 

1097 """ 

1098 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k") 

1099 ax = plt.gca() 

1100 

1101 model_colors_map = _get_model_colors_map(cubes) 

1102 

1103 # Set default that histograms will produce probability density function 

1104 # at each bin (integral over range sums to 1). 

1105 density = True 

1106 

1107 for cube in iter_maybe(cubes): 

1108 # Easier to check title (where var name originates) 

1109 # than seeing if long names exist etc. 

1110 # Exception case, where distribution better fits log scales/bins. 

1111 if "surface_microphysical" in title: 

1112 if "amount" in title: 1112 ↛ 1114line 1112 didn't jump to line 1114 because the condition on line 1112 was never true

1113 # Compute histogram following Klingaman et al. (2017): ASoP 

1114 bin2 = np.exp(np.log(0.02) + 0.1 * np.linspace(0, 99, 100)) 

1115 bins = np.pad(bin2, (1, 0), "constant", constant_values=0) 

1116 density = False 

1117 else: 

1118 bins = 10.0 ** ( 

1119 np.arange(-10, 27, 1) / 10.0 

1120 ) # Suggestion from RMED toolbox. 

1121 bins = np.insert(bins, 0, 0) 

1122 ax.set_yscale("log") 

1123 vmin = bins[1] 

1124 vmax = bins[-1] # Manually set vmin/vmax to override json derived value. 

1125 ax.set_xscale("log") 

1126 elif "lightning" in title: 

1127 bins = [0, 1, 2, 3, 4, 5] 

1128 else: 

1129 bins = np.linspace(vmin, vmax, 51) 

1130 logging.debug( 

1131 "Plotting histogram with %s bins %s - %s.", 

1132 np.size(bins), 

1133 np.min(bins), 

1134 np.max(bins), 

1135 ) 

1136 

1137 # Reshape cube data into a single array to allow for a single histogram. 

1138 # Otherwise we plot xdim histograms stacked. 

1139 cube_data_1d = (cube.data).flatten() 

1140 

1141 label = None 

1142 color = "black" 

1143 if model_colors_map: 1143 ↛ 1144line 1143 didn't jump to line 1144 because the condition on line 1143 was never true

1144 label = cube.attributes.get("model_name") 

1145 color = model_colors_map[label] 

1146 x, y = np.histogram(cube_data_1d, bins=bins, density=density) 

1147 

1148 # Compute area under curve. 

1149 if "surface_microphysical" in title and "amount" in title: 1149 ↛ 1150line 1149 didn't jump to line 1150 because the condition on line 1149 was never true

1150 bin_mean = (bins[:-1] + bins[1:]) / 2.0 

1151 x = x * bin_mean / x.sum() 

1152 x = x[1:] 

1153 y = y[1:] 

1154 

1155 ax.plot( 

1156 y[:-1], x, color=color, linewidth=3, marker="o", markersize=6, label=label 

1157 ) 

1158 

1159 # Add some labels and tweak the style. 

1160 ax.set_title(title, fontsize=16) 

1161 ax.set_xlabel( 

1162 f"{iter_maybe(cubes)[0].name()} / {iter_maybe(cubes)[0].units}", fontsize=14 

1163 ) 

1164 ax.set_ylabel("Normalised probability density", fontsize=14) 

1165 if "surface_microphysical" in title and "amount" in title: 1165 ↛ 1166line 1165 didn't jump to line 1166 because the condition on line 1165 was never true

1166 ax.set_ylabel( 

1167 f"Contribution to mean ({iter_maybe(cubes)[0].units})", fontsize=14 

1168 ) 

1169 ax.set_xlim(vmin, vmax) 

1170 ax.tick_params(axis="both", labelsize=12) 

1171 

1172 # Overlay grid-lines onto histogram plot. 

1173 ax.grid(linestyle="--", color="grey", linewidth=1) 

1174 if model_colors_map: 1174 ↛ 1175line 1174 didn't jump to line 1175 because the condition on line 1174 was never true

1175 ax.legend(loc="best", ncol=1, frameon=False, fontsize=16) 

1176 

1177 # Save plot. 

1178 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution()) 

1179 logging.info("Saved line plot to %s", filename) 

1180 plt.close(fig) 

1181 

1182 

1183def _plot_and_save_postage_stamp_histogram_series( 

1184 cube: iris.cube.Cube, 

1185 filename: str, 

1186 title: str, 

1187 stamp_coordinate: str, 

1188 vmin: float, 

1189 vmax: float, 

1190 **kwargs, 

1191): 

1192 """Plot and save postage (ensemble members) stamps for a histogram series. 

1193 

1194 Parameters 

1195 ---------- 

1196 cube: Cube 

1197 2 dimensional Cube of the data to plot as histogram. 

1198 filename: str 

1199 Filename of the plot to write. 

1200 title: str 

1201 Plot title. 

1202 stamp_coordinate: str 

1203 Coordinate that becomes different plots. 

1204 vmin: float 

1205 minimum for pdf x-axis 

1206 vmax: float 

1207 maximum for pdf x-axis 

1208 """ 

1209 # Use the smallest square grid that will fit the members. 

1210 grid_size = int(math.ceil(math.sqrt(len(cube.coord(stamp_coordinate).points)))) 

1211 

1212 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k") 

1213 # Make a subplot for each member. 

1214 for member, subplot in zip( 

1215 cube.slices_over(stamp_coordinate), range(1, grid_size**2 + 1), strict=False 

1216 ): 

1217 # Implicit interface is much easier here, due to needing to have the 

1218 # cartopy GeoAxes generated. 

1219 plt.subplot(grid_size, grid_size, subplot) 

1220 # Reshape cube data into a single array to allow for a single histogram. 

1221 # Otherwise we plot xdim histograms stacked. 

1222 member_data_1d = (member.data).flatten() 

1223 plt.hist(member_data_1d, density=True, stacked=True) 

1224 ax = plt.gca() 

1225 ax.set_title(f"Member #{member.coord(stamp_coordinate).points[0]}") 

1226 ax.set_xlim(vmin, vmax) 

1227 

1228 # Overall figure title. 

1229 fig.suptitle(title, fontsize=16) 

1230 

1231 fig.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution()) 

1232 logging.info("Saved histogram postage stamp plot to %s", filename) 

1233 plt.close(fig) 

1234 

1235 

1236def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1237 cube: iris.cube.Cube, 

1238 filename: str, 

1239 title: str, 

1240 stamp_coordinate: str, 

1241 vmin: float, 

1242 vmax: float, 

1243 **kwargs, 

1244): 

1245 fig, ax = plt.subplots(figsize=(10, 10), facecolor="w", edgecolor="k") 

1246 ax.set_title(title, fontsize=16) 

1247 ax.set_xlim(vmin, vmax) 

1248 ax.set_xlabel(f"{cube.name()} / {cube.units}", fontsize=14) 

1249 ax.set_ylabel("normalised probability density", fontsize=14) 

1250 # Loop over all slices along the stamp_coordinate 

1251 for member in cube.slices_over(stamp_coordinate): 

1252 # Flatten the member data to 1D 

1253 member_data_1d = member.data.flatten() 

1254 # Plot the histogram using plt.hist 

1255 plt.hist( 

1256 member_data_1d, 

1257 density=True, 

1258 stacked=True, 

1259 label=f"Member #{member.coord(stamp_coordinate).points[0]}", 

1260 ) 

1261 

1262 # Add a legend 

1263 ax.legend(fontsize=16) 

1264 

1265 # Save the figure to a file 

1266 plt.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution()) 

1267 

1268 # Close the figure 

1269 plt.close(fig) 

1270 

1271 

1272def _spatial_plot( 

1273 method: Literal["contourf", "pcolormesh"], 

1274 cube: iris.cube.Cube, 

1275 filename: str | None, 

1276 sequence_coordinate: str, 

1277 stamp_coordinate: str, 

1278): 

1279 """Plot a spatial variable onto a map from a 2D, 3D, or 4D cube. 

1280 

1281 A 2D spatial field can be plotted, but if the sequence_coordinate is present 

1282 then a sequence of plots will be produced. Similarly if the stamp_coordinate 

1283 is present then postage stamp plots will be produced. 

1284 

1285 Parameters 

1286 ---------- 

1287 method: "contourf" | "pcolormesh" 

1288 The plotting method to use. 

1289 cube: Cube 

1290 Iris cube of the data to plot. It should have two spatial dimensions, 

1291 such as lat and lon, and may also have a another two dimension to be 

1292 plotted sequentially and/or as postage stamp plots. 

1293 filename: str | None 

1294 Name of the plot to write, used as a prefix for plot sequences. If None 

1295 uses the recipe name. 

1296 sequence_coordinate: str 

1297 Coordinate about which to make a plot sequence. Defaults to ``"time"``. 

1298 This coordinate must exist in the cube. 

1299 stamp_coordinate: str 

1300 Coordinate about which to plot postage stamp plots. Defaults to 

1301 ``"realization"``. 

1302 

1303 Raises 

1304 ------ 

1305 ValueError 

1306 If the cube doesn't have the right dimensions. 

1307 TypeError 

1308 If the cube isn't a single cube. 

1309 """ 

1310 recipe_title = get_recipe_metadata().get("title", "Untitled") 

1311 

1312 # Ensure we have a name for the plot file. 

1313 if filename is None: 

1314 filename = slugify(recipe_title) 

1315 

1316 # Ensure we've got a single cube. 

1317 cube = _check_single_cube(cube) 

1318 

1319 # Make postage stamp plots if stamp_coordinate exists and has more than a 

1320 # single point. 

1321 plotting_func = _plot_and_save_spatial_plot 

1322 try: 

1323 if cube.coord(stamp_coordinate).shape[0] > 1: 

1324 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1325 except iris.exceptions.CoordinateNotFoundError: 

1326 pass 

1327 

1328 # Must have a sequence coordinate. 

1329 try: 

1330 cube.coord(sequence_coordinate) 

1331 except iris.exceptions.CoordinateNotFoundError as err: 

1332 raise ValueError(f"Cube must have a {sequence_coordinate} coordinate.") from err 

1333 

1334 # Create a plot for each value of the sequence coordinate. 

1335 plot_index = [] 

1336 nplot = np.size(cube.coord(sequence_coordinate).points) 

1337 for cube_slice in cube.slices_over(sequence_coordinate): 

1338 # Use sequence value so multiple sequences can merge. 

1339 sequence_value = cube_slice.coord(sequence_coordinate).points[0] 

1340 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png" 

1341 coord = cube_slice.coord(sequence_coordinate) 

1342 # Format the coordinate value in a unit appropriate way. 

1343 title = f"{recipe_title}\n [{coord.units.title(coord.points[0])}]" 

1344 # Use sequence (e.g. time) bounds if plotting single non-sequence outputs 

1345 if nplot == 1 and coord.has_bounds: 

1346 if np.size(coord.bounds) > 1: 

1347 title = f"{recipe_title}\n [{coord.units.title(coord.bounds[0][0])} to {coord.units.title(coord.bounds[0][1])}]" 

1348 # Do the actual plotting. 

1349 plotting_func( 

1350 cube_slice, 

1351 filename=plot_filename, 

1352 stamp_coordinate=stamp_coordinate, 

1353 title=title, 

1354 method=method, 

1355 ) 

1356 plot_index.append(plot_filename) 

1357 

1358 # Add list of plots to plot metadata. 

1359 complete_plot_index = _append_to_plot_index(plot_index) 

1360 

1361 # Make a page to display the plots. 

1362 _make_plot_html_page(complete_plot_index) 

1363 

1364 

1365def _custom_colormap_mask(cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None): 

1366 """Get colourmap for mask. 

1367 

1368 If "mask_for_" appears anywhere in the name of a cube this function will be called 

1369 regardless of the name of the variable to ensure a consistent plot. 

1370 

1371 Parameters 

1372 ---------- 

1373 cube: Cube 

1374 Cube of variable for which the colorbar information is desired. 

1375 axis: "x", "y", optional 

1376 Select the levels for just this axis of a line plot. The min and max 

1377 can be set by xmin/xmax or ymin/ymax respectively. For variables where 

1378 setting a universal range is not desirable (e.g. temperature), users 

1379 can set ymin/ymax values to "auto" in the colorbar definitions file. 

1380 Where no additional xmin/xmax or ymin/ymax values are provided, the 

1381 axis bounds default to use the vmin/vmax values provided. 

1382 

1383 Returns 

1384 ------- 

1385 cmap: 

1386 Matplotlib colormap. 

1387 levels: 

1388 List of levels to use for plotting. For continuous plots the min and max 

1389 should be taken as the range. 

1390 norm: 

1391 BoundaryNorm information. 

1392 """ 

1393 if "difference" not in cube.long_name: 

1394 if axis: 

1395 levels = [0, 1] 

1396 # Complete settings based on levels. 

1397 return None, levels, None 

1398 else: 

1399 # Define the levels and colors. 

1400 levels = [0, 1, 2] 

1401 colors = ["white", "dodgerblue"] 

1402 # Create a custom color map. 

1403 cmap = mcolors.ListedColormap(colors) 

1404 # Normalize the levels. 

1405 norm = mcolors.BoundaryNorm(levels, cmap.N) 

1406 logging.debug("Colourmap for %s.", cube.long_name) 

1407 return cmap, levels, norm 

1408 else: 

1409 if axis: 

1410 levels = [-1, 1] 

1411 return None, levels, None 

1412 else: 

1413 # Search for if mask difference, set to +/- 0.5 as values plotted < 

1414 # not <=. 

1415 levels = [-2, -0.5, 0.5, 2] 

1416 colors = ["goldenrod", "white", "teal"] 

1417 cmap = mcolors.ListedColormap(colors) 

1418 norm = mcolors.BoundaryNorm(levels, cmap.N) 

1419 logging.debug("Colourmap for %s.", cube.long_name) 

1420 return cmap, levels, norm 

1421 

1422 

1423def _custom_beaufort_scale(cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None): 

1424 """Get a custom colorbar for a cube in the Beaufort Scale. 

1425 

1426 Specific variable ranges can be separately set in user-supplied definition 

1427 for x- or y-axis limits, or indicate where automated range preferred. 

1428 

1429 Parameters 

1430 ---------- 

1431 cube: Cube 

1432 Cube of variable with Beaufort Scale in name. 

1433 axis: "x", "y", optional 

1434 Select the levels for just this axis of a line plot. The min and max 

1435 can be set by xmin/xmax or ymin/ymax respectively. For variables where 

1436 setting a universal range is not desirable (e.g. temperature), users 

1437 can set ymin/ymax values to "auto" in the colorbar definitions file. 

1438 Where no additional xmin/xmax or ymin/ymax values are provided, the 

1439 axis bounds default to use the vmin/vmax values provided. 

1440 

1441 Returns 

1442 ------- 

1443 cmap: 

1444 Matplotlib colormap. 

1445 levels: 

1446 List of levels to use for plotting. For continuous plots the min and max 

1447 should be taken as the range. 

1448 norm: 

1449 BoundaryNorm information. 

1450 """ 

1451 if "difference" not in cube.long_name: 

1452 if axis: 

1453 levels = [0, 12] 

1454 return None, levels, None 

1455 else: 

1456 levels = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13] 

1457 colors = [ 

1458 "black", 

1459 (0, 0, 0.6), 

1460 "blue", 

1461 "cyan", 

1462 "green", 

1463 "yellow", 

1464 (1, 0.5, 0), 

1465 "red", 

1466 "pink", 

1467 "magenta", 

1468 "purple", 

1469 "maroon", 

1470 "white", 

1471 ] 

1472 cmap = mcolors.ListedColormap(colors) 

1473 norm = mcolors.BoundaryNorm(levels, cmap.N) 

1474 logging.info("change colormap for Beaufort Scale colorbar.") 

1475 return cmap, levels, norm 

1476 else: 

1477 if axis: 

1478 levels = [-4, 4] 

1479 return None, levels, None 

1480 else: 

1481 levels = [ 

1482 -3.5, 

1483 -2.5, 

1484 -1.5, 

1485 -0.5, 

1486 0.5, 

1487 1.5, 

1488 2.5, 

1489 3.5, 

1490 ] 

1491 cmap = plt.get_cmap("bwr", 8) 

1492 norm = mcolors.BoundaryNorm(levels, cmap.N) 

1493 return cmap, levels, norm 

1494 

1495 

1496def _custom_colormap_celsius(cube: iris.cube.Cube, cmap, levels, norm): 

1497 """Return altered colourmap for temperature with change in units to Celsius. 

1498 

1499 If "Celsius" appears anywhere in the name of a cube this function will be called. 

1500 

1501 Parameters 

1502 ---------- 

1503 cube: Cube 

1504 Cube of variable for which the colorbar information is desired. 

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 Returns 

1512 ------- 

1513 cmap: Matplotlib colormap. 

1514 levels: List 

1515 List of levels to use for plotting. For continuous plots the min and max 

1516 should be taken as the range. 

1517 norm: BoundaryNorm. 

1518 """ 

1519 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name]) 

1520 if any("temperature" in name for name in varnames) and "Celsius" == cube.units: 

1521 levels = np.array(levels) 

1522 levels -= 273 

1523 levels = levels.tolist() 

1524 else: 

1525 # Do nothing keep the existing colourbar attributes 

1526 levels = levels 

1527 cmap = cmap 

1528 norm = norm 

1529 return cmap, levels, norm 

1530 

1531 

1532def _custom_colormap_probability( 

1533 cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None 

1534): 

1535 """Get a custom colorbar for a probability cube. 

1536 

1537 Specific variable ranges can be separately set in user-supplied definition 

1538 for x- or y-axis limits, or indicate where automated range preferred. 

1539 

1540 Parameters 

1541 ---------- 

1542 cube: Cube 

1543 Cube of variable with probability in name. 

1544 axis: "x", "y", optional 

1545 Select the levels for just this axis of a line plot. The min and max 

1546 can be set by xmin/xmax or ymin/ymax respectively. For variables where 

1547 setting a universal range is not desirable (e.g. temperature), users 

1548 can set ymin/ymax values to "auto" in the colorbar definitions file. 

1549 Where no additional xmin/xmax or ymin/ymax values are provided, the 

1550 axis bounds default to use the vmin/vmax values provided. 

1551 

1552 Returns 

1553 ------- 

1554 cmap: 

1555 Matplotlib colormap. 

1556 levels: 

1557 List of levels to use for plotting. For continuous plots the min and max 

1558 should be taken as the range. 

1559 norm: 

1560 BoundaryNorm information. 

1561 """ 

1562 if axis: 

1563 levels = [0, 1] 

1564 return None, levels, None 

1565 else: 

1566 cmap = mcolors.ListedColormap( 

1567 [ 

1568 "#FFFFFF", 

1569 "#636363", 

1570 "#e1dada", 

1571 "#B5CAFF", 

1572 "#8FB3FF", 

1573 "#7F97FF", 

1574 "#ABCF63", 

1575 "#E8F59E", 

1576 "#FFFA14", 

1577 "#FFD121", 

1578 "#FFA30A", 

1579 ] 

1580 ) 

1581 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] 

1582 norm = mcolors.BoundaryNorm(levels, cmap.N) 

1583 return cmap, levels, norm 

1584 

1585 

1586def _custom_colourmap_precipitation(cube: iris.cube.Cube, cmap, levels, norm): 

1587 """Return a custom colourmap for the current recipe.""" 

1588 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name]) 

1589 if ( 

1590 any("surface_microphysical" in name for name in varnames) 

1591 and "difference" not in cube.long_name 

1592 and "mask" not in cube.long_name 

1593 ): 

1594 # Define the levels and colors 

1595 levels = [0, 0.125, 0.25, 0.5, 1, 2, 4, 8, 16, 32, 64, 128, 256] 

1596 colors = [ 

1597 "w", 

1598 (0, 0, 0.6), 

1599 "b", 

1600 "c", 

1601 "g", 

1602 "y", 

1603 (1, 0.5, 0), 

1604 "r", 

1605 "pink", 

1606 "m", 

1607 "purple", 

1608 "maroon", 

1609 "gray", 

1610 ] 

1611 # Create a custom colormap 

1612 cmap = mcolors.ListedColormap(colors) 

1613 # Normalize the levels 

1614 norm = mcolors.BoundaryNorm(levels, cmap.N) 

1615 logging.info("change colormap for surface_microphysical variable colorbar.") 

1616 else: 

1617 # do nothing and keep existing colorbar attributes 

1618 cmap = cmap 

1619 levels = levels 

1620 norm = norm 

1621 return cmap, levels, norm 

1622 

1623 

1624def _custom_colormap_aviation_colour_state(cube: iris.cube.Cube): 

1625 """Return custom colourmap for aviation colour state. 

1626 

1627 If "aviation_colour_state" appears anywhere in the name of a cube 

1628 this function will be called. 

1629 

1630 Parameters 

1631 ---------- 

1632 cube: Cube 

1633 Cube of variable for which the colorbar information is desired. 

1634 

1635 Returns 

1636 ------- 

1637 cmap: Matplotlib colormap. 

1638 levels: List 

1639 List of levels to use for plotting. For continuous plots the min and max 

1640 should be taken as the range. 

1641 norm: BoundaryNorm. 

1642 """ 

1643 levels = [-0.5, 0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5] 

1644 colors = [ 

1645 "#87ceeb", 

1646 "#ffffff", 

1647 "#8ced69", 

1648 "#ffff00", 

1649 "#ffd700", 

1650 "#ffa500", 

1651 "#fe3620", 

1652 ] 

1653 # Create a custom colormap 

1654 cmap = mcolors.ListedColormap(colors) 

1655 # Normalise the levels 

1656 norm = mcolors.BoundaryNorm(levels, cmap.N) 

1657 return cmap, levels, norm 

1658 

1659 

1660def _custom_colourmap_visibility_in_air(cube: iris.cube.Cube, cmap, levels, norm): 

1661 """Return a custom colourmap for the current recipe.""" 

1662 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name]) 

1663 if ( 

1664 any("visibility_in_air" in name for name in varnames) 

1665 and "difference" not in cube.long_name 

1666 and "mask" not in cube.long_name 

1667 ): 

1668 # Define the levels and colors (in km) 

1669 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] 

1670 norm = mcolors.BoundaryNorm(levels, cmap.N) 

1671 colours = [ 

1672 "#8f00d6", 

1673 "#d10000", 

1674 "#ff9700", 

1675 "#ffff00", 

1676 "#00007f", 

1677 "#6c9ccd", 

1678 "#aae8ff", 

1679 "#37a648", 

1680 "#8edc64", 

1681 "#c5ffc5", 

1682 "#dcdcdc", 

1683 "#ffffff", 

1684 ] 

1685 # Create a custom colormap 

1686 cmap = mcolors.ListedColormap(colours) 

1687 # Normalize the levels 

1688 norm = mcolors.BoundaryNorm(levels, cmap.N) 

1689 logging.info("change colormap for visibility_in_air variable colorbar.") 

1690 else: 

1691 # do nothing and keep existing colorbar attributes 

1692 cmap = cmap 

1693 levels = levels 

1694 norm = norm 

1695 return cmap, levels, norm 

1696 

1697 

1698def _get_num_models(cube: iris.cube.Cube | iris.cube.CubeList) -> int: 

1699 """Return number of models based on cube attributes.""" 

1700 model_names = list( 

1701 filter( 

1702 lambda x: x is not None, 

1703 {cb.attributes.get("model_name", None) for cb in iter_maybe(cube)}, 

1704 ) 

1705 ) 

1706 if not model_names: 

1707 logging.debug("Missing model names. Will assume single model.") 

1708 return 1 

1709 else: 

1710 return len(model_names) 

1711 

1712 

1713def _validate_cube_shape( 

1714 cube: iris.cube.Cube | iris.cube.CubeList, num_models: int 

1715) -> None: 

1716 """Check all cubes have a model name.""" 

1717 if isinstance(cube, iris.cube.CubeList) and len(cube) != num_models: 1717 ↛ 1718line 1717 didn't jump to line 1718 because the condition on line 1717 was never true

1718 raise ValueError( 

1719 f"The number of model names ({num_models}) should equal the number " 

1720 f"of cubes ({len(cube)})." 

1721 ) 

1722 

1723 

1724def _validate_cubes_coords( 

1725 cubes: iris.cube.CubeList, coords: list[iris.coords.Coord] 

1726) -> None: 

1727 """Check same number of cubes as sequence coordinate for zip functions.""" 

1728 if len(cubes) != len(coords): 1728 ↛ 1729line 1728 didn't jump to line 1729 because the condition on line 1728 was never true

1729 raise ValueError( 

1730 f"The number of CubeList entries ({len(cubes)}) should equal the number " 

1731 f"of sequence coordinates ({len(coords)})." 

1732 f"Check that number of time entries in input data are consistent if " 

1733 f"performing time-averaging steps prior to plotting outputs." 

1734 ) 

1735 

1736 

1737#################### 

1738# Public functions # 

1739#################### 

1740 

1741 

1742def spatial_contour_plot( 

1743 cube: iris.cube.Cube, 

1744 filename: str = None, 

1745 sequence_coordinate: str = "time", 

1746 stamp_coordinate: str = "realization", 

1747 **kwargs, 

1748) -> iris.cube.Cube: 

1749 """Plot a spatial variable onto a map from a 2D, 3D, or 4D cube. 

1750 

1751 A 2D spatial field can be plotted, but if the sequence_coordinate is present 

1752 then a sequence of plots will be produced. Similarly if the stamp_coordinate 

1753 is present then postage stamp plots will be produced. 

1754 

1755 Parameters 

1756 ---------- 

1757 cube: Cube 

1758 Iris cube of the data to plot. It should have two spatial dimensions, 

1759 such as lat and lon, and may also have a another two dimension to be 

1760 plotted sequentially and/or as postage stamp plots. 

1761 filename: str, optional 

1762 Name of the plot to write, used as a prefix for plot sequences. Defaults 

1763 to the recipe name. 

1764 sequence_coordinate: str, optional 

1765 Coordinate about which to make a plot sequence. Defaults to ``"time"``. 

1766 This coordinate must exist in the cube. 

1767 stamp_coordinate: str, optional 

1768 Coordinate about which to plot postage stamp plots. Defaults to 

1769 ``"realization"``. 

1770 

1771 Returns 

1772 ------- 

1773 Cube 

1774 The original cube (so further operations can be applied). 

1775 

1776 Raises 

1777 ------ 

1778 ValueError 

1779 If the cube doesn't have the right dimensions. 

1780 TypeError 

1781 If the cube isn't a single cube. 

1782 """ 

1783 _spatial_plot("contourf", cube, filename, sequence_coordinate, stamp_coordinate) 

1784 return cube 

1785 

1786 

1787def spatial_pcolormesh_plot( 

1788 cube: iris.cube.Cube, 

1789 filename: str = None, 

1790 sequence_coordinate: str = "time", 

1791 stamp_coordinate: str = "realization", 

1792 **kwargs, 

1793) -> iris.cube.Cube: 

1794 """Plot a spatial variable onto a map from a 2D, 3D, or 4D cube. 

1795 

1796 A 2D spatial field can be plotted, but if the sequence_coordinate is present 

1797 then a sequence of plots will be produced. Similarly if the stamp_coordinate 

1798 is present then postage stamp plots will be produced. 

1799 

1800 This function is significantly faster than ``spatial_contour_plot``, 

1801 especially at high resolutions, and should be preferred unless contiguous 

1802 contour areas are important. 

1803 

1804 Parameters 

1805 ---------- 

1806 cube: Cube 

1807 Iris cube of the data to plot. It should have two spatial dimensions, 

1808 such as lat and lon, and may also have a another two dimension to be 

1809 plotted sequentially and/or as postage stamp plots. 

1810 filename: str, optional 

1811 Name of the plot to write, used as a prefix for plot sequences. Defaults 

1812 to the recipe name. 

1813 sequence_coordinate: str, optional 

1814 Coordinate about which to make a plot sequence. Defaults to ``"time"``. 

1815 This coordinate must exist in the cube. 

1816 stamp_coordinate: str, optional 

1817 Coordinate about which to plot postage stamp plots. Defaults to 

1818 ``"realization"``. 

1819 

1820 Returns 

1821 ------- 

1822 Cube 

1823 The original cube (so further operations can be applied). 

1824 

1825 Raises 

1826 ------ 

1827 ValueError 

1828 If the cube doesn't have the right dimensions. 

1829 TypeError 

1830 If the cube isn't a single cube. 

1831 """ 

1832 _spatial_plot("pcolormesh", cube, filename, sequence_coordinate, stamp_coordinate) 

1833 return cube 

1834 

1835 

1836# TODO: Expand function to handle ensemble data. 

1837# line_coordinate: str, optional 

1838# Coordinate about which to plot multiple lines. Defaults to 

1839# ``"realization"``. 

1840def plot_line_series( 

1841 cube: iris.cube.Cube | iris.cube.CubeList, 

1842 filename: str = None, 

1843 series_coordinate: str = "time", 

1844 # line_coordinate: str = "realization", 

1845 **kwargs, 

1846) -> iris.cube.Cube | iris.cube.CubeList: 

1847 """Plot a line plot for the specified coordinate. 

1848 

1849 The Cube or CubeList must be 1D. 

1850 

1851 Parameters 

1852 ---------- 

1853 iris.cube | iris.cube.CubeList 

1854 Cube or CubeList of the data to plot. The individual cubes should have a single dimension. 

1855 The cubes should cover the same phenomenon i.e. all cubes contain temperature data. 

1856 We do not support different data such as temperature and humidity in the same CubeList for plotting. 

1857 filename: str, optional 

1858 Name of the plot to write, used as a prefix for plot sequences. Defaults 

1859 to the recipe name. 

1860 series_coordinate: str, optional 

1861 Coordinate about which to make a series. Defaults to ``"time"``. This 

1862 coordinate must exist in the cube. 

1863 

1864 Returns 

1865 ------- 

1866 iris.cube.Cube | iris.cube.CubeList 

1867 The original Cube or CubeList (so further operations can be applied). 

1868 plotted data. 

1869 

1870 Raises 

1871 ------ 

1872 ValueError 

1873 If the cubes don't have the right dimensions. 

1874 TypeError 

1875 If the cube isn't a Cube or CubeList. 

1876 """ 

1877 # Ensure we have a name for the plot file. 

1878 title = get_recipe_metadata().get("title", "Untitled") 

1879 

1880 if filename is None: 

1881 filename = slugify(title) 

1882 

1883 # Add file extension. 

1884 plot_filename = f"{filename.rsplit('.', 1)[0]}.png" 

1885 

1886 num_models = _get_num_models(cube) 

1887 

1888 _validate_cube_shape(cube, num_models) 

1889 

1890 # Iterate over all cubes and extract coordinate to plot. 

1891 cubes = iter_maybe(cube) 

1892 coords = [] 

1893 for cube in cubes: 

1894 try: 

1895 coords.append(cube.coord(series_coordinate)) 

1896 except iris.exceptions.CoordinateNotFoundError as err: 

1897 raise ValueError( 

1898 f"Cube must have a {series_coordinate} coordinate." 

1899 ) from err 

1900 if cube.ndim > 2 or not cube.coords("realization"): 

1901 raise ValueError("Cube must be 1D or 2D with a realization coordinate.") 

1902 

1903 # Do the actual plotting. 

1904 _plot_and_save_line_series(cubes, coords, "realization", plot_filename, title) 

1905 

1906 # Add list of plots to plot metadata. 

1907 plot_index = _append_to_plot_index([plot_filename]) 

1908 

1909 # Make a page to display the plots. 

1910 _make_plot_html_page(plot_index) 

1911 

1912 return cube 

1913 

1914 

1915def plot_vertical_line_series( 

1916 cubes: iris.cube.Cube | iris.cube.CubeList, 

1917 filename: str = None, 

1918 series_coordinate: str = "model_level_number", 

1919 sequence_coordinate: str = "time", 

1920 # line_coordinate: str = "realization", 

1921 **kwargs, 

1922) -> iris.cube.Cube | iris.cube.CubeList: 

1923 """Plot a line plot against a type of vertical coordinate. 

1924 

1925 The Cube or CubeList must be 1D. 

1926 

1927 A 1D line plot with y-axis as pressure coordinate can be plotted, but if the sequence_coordinate is present 

1928 then a sequence of plots will be produced. 

1929 

1930 Parameters 

1931 ---------- 

1932 iris.cube | iris.cube.CubeList 

1933 Cube or CubeList of the data to plot. The individual cubes should have a single dimension. 

1934 The cubes should cover the same phenomenon i.e. all cubes contain temperature data. 

1935 We do not support different data such as temperature and humidity in the same CubeList for plotting. 

1936 filename: str, optional 

1937 Name of the plot to write, used as a prefix for plot sequences. Defaults 

1938 to the recipe name. 

1939 series_coordinate: str, optional 

1940 Coordinate to plot on the y-axis. Can be ``pressure`` or 

1941 ``model_level_number`` for UM, or ``full_levels`` or ``half_levels`` 

1942 for LFRic. Defaults to ``model_level_number``. 

1943 This coordinate must exist in the cube. 

1944 sequence_coordinate: str, optional 

1945 Coordinate about which to make a plot sequence. Defaults to ``"time"``. 

1946 This coordinate must exist in the cube. 

1947 

1948 Returns 

1949 ------- 

1950 iris.cube.Cube | iris.cube.CubeList 

1951 The original Cube or CubeList (so further operations can be applied). 

1952 Plotted data. 

1953 

1954 Raises 

1955 ------ 

1956 ValueError 

1957 If the cubes doesn't have the right dimensions. 

1958 TypeError 

1959 If the cube isn't a Cube or CubeList. 

1960 """ 

1961 # Ensure we have a name for the plot file. 

1962 recipe_title = get_recipe_metadata().get("title", "Untitled") 

1963 

1964 if filename is None: 

1965 filename = slugify(recipe_title) 

1966 

1967 cubes = iter_maybe(cubes) 

1968 # Initialise empty list to hold all data from all cubes in a CubeList 

1969 all_data = [] 

1970 

1971 # Store min/max ranges for x range. 

1972 x_levels = [] 

1973 

1974 num_models = _get_num_models(cubes) 

1975 

1976 _validate_cube_shape(cubes, num_models) 

1977 

1978 # Iterate over all cubes in cube or CubeList and plot. 

1979 coords = [] 

1980 for cube in cubes: 

1981 # Test if series coordinate i.e. pressure level exist for any cube with cube.ndim >=1. 

1982 try: 

1983 coords.append(cube.coord(series_coordinate)) 

1984 except iris.exceptions.CoordinateNotFoundError as err: 

1985 raise ValueError( 

1986 f"Cube must have a {series_coordinate} coordinate." 

1987 ) from err 

1988 

1989 try: 

1990 if cube.ndim > 1 or not cube.coords("realization"): 1990 ↛ 1998line 1990 didn't jump to line 1998 because the condition on line 1990 was always true

1991 cube.coord(sequence_coordinate) 

1992 except iris.exceptions.CoordinateNotFoundError as err: 

1993 raise ValueError( 

1994 f"Cube must have a {sequence_coordinate} coordinate or be 1D, or 2D with a realization coordinate." 

1995 ) from err 

1996 

1997 # Get minimum and maximum from levels information. 

1998 _, levels, _ = _colorbar_map_levels(cube, axis="x") 

1999 if levels is not None: 1999 ↛ 2003line 1999 didn't jump to line 2003 because the condition on line 1999 was always true

2000 x_levels.append(min(levels)) 

2001 x_levels.append(max(levels)) 

2002 else: 

2003 all_data.append(cube.data) 

2004 

2005 if len(x_levels) == 0: 2005 ↛ 2007line 2005 didn't jump to line 2007 because the condition on line 2005 was never true

2006 # Combine all data into a single NumPy array 

2007 combined_data = np.concatenate(all_data) 

2008 

2009 # Set the lower and upper limit for the x-axis to ensure all plots have 

2010 # same range. This needs to read the whole cube over the range of the 

2011 # sequence and if applicable postage stamp coordinate. 

2012 vmin = np.floor(combined_data.min()) 

2013 vmax = np.ceil(combined_data.max()) 

2014 else: 

2015 vmin = min(x_levels) 

2016 vmax = max(x_levels) 

2017 

2018 # Matching the slices (matching by seq coord point; it may happen that 

2019 # evaluated models do not cover the same seq coord range, hence matching 

2020 # necessary) 

2021 def filter_cube_iterables(cube_iterables) -> bool: 

2022 return len(cube_iterables) == len(coords) 

2023 

2024 cube_iterables = filter( 

2025 filter_cube_iterables, 

2026 ( 

2027 iris.cube.CubeList( 

2028 s 

2029 for s in itertools.chain.from_iterable( 

2030 cb.slices_over(sequence_coordinate) for cb in cubes 

2031 ) 

2032 if s.coord(sequence_coordinate).points[0] == point 

2033 ) 

2034 for point in sorted( 

2035 set( 

2036 itertools.chain.from_iterable( 

2037 cb.coord(sequence_coordinate).points for cb in cubes 

2038 ) 

2039 ) 

2040 ) 

2041 ), 

2042 ) 

2043 

2044 # Create a plot for each value of the sequence coordinate. 

2045 # Allowing for multiple cubes in a CubeList to be plotted in the same plot for 

2046 # similar sequence values. Passing a CubeList into the internal plotting function 

2047 # for similar values of the sequence coordinate. cube_slice can be an iris.cube.Cube 

2048 # or an iris.cube.CubeList. 

2049 plot_index = [] 

2050 nplot = np.size(cubes[0].coord(sequence_coordinate).points) 

2051 for cubes_slice in cube_iterables: 

2052 # Use sequence value so multiple sequences can merge. 

2053 seq_coord = cubes_slice[0].coord(sequence_coordinate) 

2054 sequence_value = seq_coord.points[0] 

2055 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png" 

2056 # Format the coordinate value in a unit appropriate way. 

2057 title = f"{recipe_title}\n [{seq_coord.units.title(sequence_value)}]" 

2058 # Use sequence (e.g. time) bounds if plotting single non-sequence outputs 

2059 if nplot == 1 and seq_coord.has_bounds: 2059 ↛ 2060line 2059 didn't jump to line 2060 because the condition on line 2059 was never true

2060 if np.size(seq_coord.bounds) > 1: 

2061 title = f"{recipe_title}\n [{seq_coord.units.title(seq_coord.bounds[0][0])} to {seq_coord.units.title(seq_coord.bounds[0][1])}]" 

2062 # Do the actual plotting. 

2063 _plot_and_save_vertical_line_series( 

2064 cubes_slice, 

2065 coords, 

2066 "realization", 

2067 plot_filename, 

2068 series_coordinate, 

2069 title=title, 

2070 vmin=vmin, 

2071 vmax=vmax, 

2072 ) 

2073 plot_index.append(plot_filename) 

2074 

2075 # Add list of plots to plot metadata. 

2076 complete_plot_index = _append_to_plot_index(plot_index) 

2077 

2078 # Make a page to display the plots. 

2079 _make_plot_html_page(complete_plot_index) 

2080 

2081 return cubes 

2082 

2083 

2084def scatter_plot( 

2085 cube_x: iris.cube.Cube | iris.cube.CubeList, 

2086 cube_y: iris.cube.Cube | iris.cube.CubeList, 

2087 filename: str = None, 

2088 one_to_one: bool = True, 

2089 **kwargs, 

2090) -> iris.cube.CubeList: 

2091 """Plot a scatter plot between two variables. 

2092 

2093 Both cubes must be 1D. 

2094 

2095 Parameters 

2096 ---------- 

2097 cube_x: Cube | CubeList 

2098 1 dimensional Cube of the data to plot on y-axis. 

2099 cube_y: Cube | CubeList 

2100 1 dimensional Cube of the data to plot on x-axis. 

2101 filename: str, optional 

2102 Filename of the plot to write. 

2103 one_to_one: bool, optional 

2104 If True a 1:1 line is plotted; if False it is not. Default is True. 

2105 

2106 Returns 

2107 ------- 

2108 cubes: CubeList 

2109 CubeList of the original x and y cubes for further processing. 

2110 

2111 Raises 

2112 ------ 

2113 ValueError 

2114 If the cube doesn't have the right dimensions and cubes not the same 

2115 size. 

2116 TypeError 

2117 If the cube isn't a single cube. 

2118 

2119 Notes 

2120 ----- 

2121 Scatter plots are used for determining if there is a relationship between 

2122 two variables. Positive relations have a slope going from bottom left to top 

2123 right; Negative relations have a slope going from top left to bottom right. 

2124 

2125 A variant of the scatter plot is the quantile-quantile plot. This plot does 

2126 not use all data points, but the selected quantiles of each variable 

2127 instead. Quantile-quantile plots are valuable for comparing against 

2128 observations and other models. Identical percentiles between the variables 

2129 will lie on the one-to-one line implying the values correspond well to each 

2130 other. Where there is a deviation from the one-to-one line a range of 

2131 possibilities exist depending on how and where the data is shifted (e.g., 

2132 Wilks 2011 [Wilks2011]_). 

2133 

2134 For distributions above the one-to-one line the distribution is left-skewed; 

2135 below is right-skewed. A distinct break implies a bimodal distribution, and 

2136 closer values/values further apart at the tails imply poor representation of 

2137 the extremes. 

2138 

2139 References 

2140 ---------- 

2141 .. [Wilks2011] Wilks, D.S., (2011) "Statistical Methods in the Atmospheric 

2142 Sciences" Third Edition, vol. 100, Academic Press, Oxford, UK, 676 pp. 

2143 """ 

2144 # Iterate over all cubes in cube or CubeList and plot. 

2145 for cube_iter in iter_maybe(cube_x): 

2146 # Check cubes are correct shape. 

2147 cube_iter = _check_single_cube(cube_iter) 

2148 if cube_iter.ndim > 1: 

2149 raise ValueError("cube_x must be 1D.") 

2150 

2151 # Iterate over all cubes in cube or CubeList and plot. 

2152 for cube_iter in iter_maybe(cube_y): 

2153 # Check cubes are correct shape. 

2154 cube_iter = _check_single_cube(cube_iter) 

2155 if cube_iter.ndim > 1: 

2156 raise ValueError("cube_y must be 1D.") 

2157 

2158 # Ensure we have a name for the plot file. 

2159 title = get_recipe_metadata().get("title", "Untitled") 

2160 

2161 if filename is None: 

2162 filename = slugify(title) 

2163 

2164 # Add file extension. 

2165 plot_filename = f"{filename.rsplit('.', 1)[0]}.png" 

2166 

2167 # Do the actual plotting. 

2168 _plot_and_save_scatter_plot(cube_x, cube_y, plot_filename, title, one_to_one) 

2169 

2170 # Add list of plots to plot metadata. 

2171 plot_index = _append_to_plot_index([plot_filename]) 

2172 

2173 # Make a page to display the plots. 

2174 _make_plot_html_page(plot_index) 

2175 

2176 return iris.cube.CubeList([cube_x, cube_y]) 

2177 

2178 

2179def vector_plot( 

2180 cube_u: iris.cube.Cube, 

2181 cube_v: iris.cube.Cube, 

2182 filename: str = None, 

2183 sequence_coordinate: str = "time", 

2184 **kwargs, 

2185) -> iris.cube.CubeList: 

2186 """Plot a vector plot based on the input u and v components.""" 

2187 recipe_title = get_recipe_metadata().get("title", "Untitled") 

2188 

2189 # Ensure we have a name for the plot file. 

2190 if filename is None: 2190 ↛ 2191line 2190 didn't jump to line 2191 because the condition on line 2190 was never true

2191 filename = slugify(recipe_title) 

2192 

2193 # Cubes must have a matching sequence coordinate. 

2194 try: 

2195 # Check that the u and v cubes have the same sequence coordinate. 

2196 if cube_u.coord(sequence_coordinate) != cube_v.coord(sequence_coordinate): 2196 ↛ 2197line 2196 didn't jump to line 2197 because the condition on line 2196 was never true

2197 raise ValueError("Coordinates do not match.") 

2198 except (iris.exceptions.CoordinateNotFoundError, ValueError) as err: 

2199 raise ValueError( 

2200 f"Cubes should have matching {sequence_coordinate} coordinate:\n{cube_u}\n{cube_v}" 

2201 ) from err 

2202 

2203 # Create a plot for each value of the sequence coordinate. 

2204 plot_index = [] 

2205 for cube_u_slice, cube_v_slice in zip( 

2206 cube_u.slices_over(sequence_coordinate), 

2207 cube_v.slices_over(sequence_coordinate), 

2208 strict=True, 

2209 ): 

2210 # Use sequence value so multiple sequences can merge. 

2211 sequence_value = cube_u_slice.coord(sequence_coordinate).points[0] 

2212 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png" 

2213 coord = cube_u_slice.coord(sequence_coordinate) 

2214 # Format the coordinate value in a unit appropriate way. 

2215 title = f"{recipe_title}\n{coord.units.title(coord.points[0])}" 

2216 # Do the actual plotting. 

2217 _plot_and_save_vector_plot( 

2218 cube_u_slice, 

2219 cube_v_slice, 

2220 filename=plot_filename, 

2221 title=title, 

2222 method="contourf", 

2223 ) 

2224 plot_index.append(plot_filename) 

2225 

2226 # Add list of plots to plot metadata. 

2227 complete_plot_index = _append_to_plot_index(plot_index) 

2228 

2229 # Make a page to display the plots. 

2230 _make_plot_html_page(complete_plot_index) 

2231 

2232 return iris.cube.CubeList([cube_u, cube_v]) 

2233 

2234 

2235def plot_histogram_series( 

2236 cubes: iris.cube.Cube | iris.cube.CubeList, 

2237 filename: str = None, 

2238 sequence_coordinate: str = "time", 

2239 stamp_coordinate: str = "realization", 

2240 single_plot: bool = False, 

2241 **kwargs, 

2242) -> iris.cube.Cube | iris.cube.CubeList: 

2243 """Plot a histogram plot for each vertical level provided. 

2244 

2245 A histogram plot can be plotted, but if the sequence_coordinate (i.e. time) 

2246 is present then a sequence of plots will be produced using the time slider 

2247 functionality to scroll through histograms against time. If a 

2248 stamp_coordinate is present then postage stamp plots will be produced. If 

2249 stamp_coordinate and single_plot is True, all postage stamp plots will be 

2250 plotted in a single plot instead of separate postage stamp plots. 

2251 

2252 Parameters 

2253 ---------- 

2254 cubes: Cube | iris.cube.CubeList 

2255 Iris cube or CubeList of the data to plot. It should have a single dimension other 

2256 than the stamp coordinate. 

2257 The cubes should cover the same phenomenon i.e. all cubes contain temperature data. 

2258 We do not support different data such as temperature and humidity in the same CubeList for plotting. 

2259 filename: str, optional 

2260 Name of the plot to write, used as a prefix for plot sequences. Defaults 

2261 to the recipe name. 

2262 sequence_coordinate: str, optional 

2263 Coordinate about which to make a plot sequence. Defaults to ``"time"``. 

2264 This coordinate must exist in the cube and will be used for the time 

2265 slider. 

2266 stamp_coordinate: str, optional 

2267 Coordinate about which to plot postage stamp plots. Defaults to 

2268 ``"realization"``. 

2269 single_plot: bool, optional 

2270 If True, all postage stamp plots will be plotted in a single plot. If 

2271 False, each postage stamp plot will be plotted separately. Is only valid 

2272 if stamp_coordinate exists and has more than a single point. 

2273 

2274 Returns 

2275 ------- 

2276 iris.cube.Cube | iris.cube.CubeList 

2277 The original Cube or CubeList (so further operations can be applied). 

2278 Plotted data. 

2279 

2280 Raises 

2281 ------ 

2282 ValueError 

2283 If the cube doesn't have the right dimensions. 

2284 TypeError 

2285 If the cube isn't a Cube or CubeList. 

2286 """ 

2287 recipe_title = get_recipe_metadata().get("title", "Untitled") 

2288 

2289 cubes = iter_maybe(cubes) 

2290 

2291 # Ensure we have a name for the plot file. 

2292 if filename is None: 

2293 filename = slugify(recipe_title) 

2294 

2295 # Internal plotting function. 

2296 plotting_func = _plot_and_save_histogram_series 

2297 

2298 num_models = _get_num_models(cubes) 

2299 

2300 _validate_cube_shape(cubes, num_models) 

2301 

2302 # If several histograms are plotted with time as sequence_coordinate for the 

2303 # time slider option. 

2304 for cube in cubes: 

2305 try: 

2306 cube.coord(sequence_coordinate) 

2307 except iris.exceptions.CoordinateNotFoundError as err: 

2308 raise ValueError( 

2309 f"Cube must have a {sequence_coordinate} coordinate." 

2310 ) from err 

2311 

2312 # Get minimum and maximum from levels information. 

2313 levels = None 

2314 for cube in cubes: 2314 ↛ 2330line 2314 didn't jump to line 2330 because the loop on line 2314 didn't complete

2315 # First check if user-specified "auto" range variable. 

2316 # This maintains the value of levels as None, so proceed. 

2317 _, levels, _ = _colorbar_map_levels(cube, axis="y") 

2318 if levels is None: 

2319 break 

2320 # If levels is changed, recheck to use the vmin,vmax or 

2321 # levels-based ranges for histogram plots. 

2322 _, levels, _ = _colorbar_map_levels(cube) 

2323 logging.debug("levels: %s", levels) 

2324 if levels is not None: 2324 ↛ 2314line 2324 didn't jump to line 2314 because the condition on line 2324 was always true

2325 vmin = min(levels) 

2326 vmax = max(levels) 

2327 logging.debug("Updated vmin, vmax: %s, %s", vmin, vmax) 

2328 break 

2329 

2330 if levels is None: 

2331 vmin = min(cb.data.min() for cb in cubes) 

2332 vmax = max(cb.data.max() for cb in cubes) 

2333 

2334 # Make postage stamp plots if stamp_coordinate exists and has more than a 

2335 # single point. If single_plot is True: 

2336 # -- all postage stamp plots will be plotted in a single plot instead of 

2337 # separate postage stamp plots. 

2338 # -- model names (hidden in cube attrs) are ignored, that is stamp plots are 

2339 # produced per single model only 

2340 if num_models == 1: 2340 ↛ 2353line 2340 didn't jump to line 2353 because the condition on line 2340 was always true

2341 if ( 2341 ↛ 2345line 2341 didn't jump to line 2345 because the condition on line 2341 was never true

2342 stamp_coordinate in [c.name() for c in cubes[0].coords()] 

2343 and cubes[0].coord(stamp_coordinate).shape[0] > 1 

2344 ): 

2345 if single_plot: 

2346 plotting_func = ( 

2347 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

2348 ) 

2349 else: 

2350 plotting_func = _plot_and_save_postage_stamp_histogram_series 

2351 cube_iterables = cubes[0].slices_over(sequence_coordinate) 

2352 else: 

2353 all_points = sorted( 

2354 set( 

2355 itertools.chain.from_iterable( 

2356 cb.coord(sequence_coordinate).points for cb in cubes 

2357 ) 

2358 ) 

2359 ) 

2360 all_slices = list( 

2361 itertools.chain.from_iterable( 

2362 cb.slices_over(sequence_coordinate) for cb in cubes 

2363 ) 

2364 ) 

2365 # Matched slices (matched by seq coord point; it may happen that 

2366 # evaluated models do not cover the same seq coord range, hence matching 

2367 # necessary) 

2368 cube_iterables = [ 

2369 iris.cube.CubeList( 

2370 s for s in all_slices if s.coord(sequence_coordinate).points[0] == point 

2371 ) 

2372 for point in all_points 

2373 ] 

2374 

2375 plot_index = [] 

2376 nplot = np.size(cube.coord(sequence_coordinate).points) 

2377 # Create a plot for each value of the sequence coordinate. Allowing for 

2378 # multiple cubes in a CubeList to be plotted in the same plot for similar 

2379 # sequence values. Passing a CubeList into the internal plotting function 

2380 # for similar values of the sequence coordinate. cube_slice can be an 

2381 # iris.cube.Cube or an iris.cube.CubeList. 

2382 for cube_slice in cube_iterables: 

2383 single_cube = cube_slice 

2384 if isinstance(cube_slice, iris.cube.CubeList): 2384 ↛ 2385line 2384 didn't jump to line 2385 because the condition on line 2384 was never true

2385 single_cube = cube_slice[0] 

2386 

2387 # Use sequence value so multiple sequences can merge. 

2388 sequence_value = single_cube.coord(sequence_coordinate).points[0] 

2389 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png" 

2390 coord = single_cube.coord(sequence_coordinate) 

2391 # Format the coordinate value in a unit appropriate way. 

2392 title = f"{recipe_title}\n [{coord.units.title(coord.points[0])}]" 

2393 # Use sequence (e.g. time) bounds if plotting single non-sequence outputs 

2394 if nplot == 1 and coord.has_bounds: 2394 ↛ 2395line 2394 didn't jump to line 2395 because the condition on line 2394 was never true

2395 if np.size(coord.bounds) > 1: 

2396 title = f"{recipe_title}\n [{coord.units.title(coord.bounds[0][0])} to {coord.units.title(coord.bounds[0][1])}]" 

2397 # Do the actual plotting. 

2398 plotting_func( 

2399 cube_slice, 

2400 filename=plot_filename, 

2401 stamp_coordinate=stamp_coordinate, 

2402 title=title, 

2403 vmin=vmin, 

2404 vmax=vmax, 

2405 ) 

2406 plot_index.append(plot_filename) 

2407 

2408 # Add list of plots to plot metadata. 

2409 complete_plot_index = _append_to_plot_index(plot_index) 

2410 

2411 # Make a page to display the plots. 

2412 _make_plot_html_page(complete_plot_index) 

2413 

2414 return cubes