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

745 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-09-22 15:05 +0000

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 

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

285 if not cmap: 

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

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

288 return cmap, levels, norm 

289 

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

291 if pressure_level: 

292 try: 

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

294 except KeyError: 

295 logging.debug( 

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

297 varname, 

298 pressure_level, 

299 ) 

300 

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

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

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

304 if axis: 

305 if axis == "x": 

306 try: 

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

308 except KeyError: 

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

310 if axis == "y": 

311 try: 

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

313 except KeyError: 

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

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

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

317 levels = None 

318 else: 

319 levels = [vmin, vmax] 

320 return None, levels, None 

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

322 else: 

323 try: 

324 levels = var_colorbar["levels"] 

325 # Use discrete bins when levels are specified, rather 

326 # than a smooth range. 

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

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

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

330 except KeyError: 

331 # Get the range for this variable. 

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

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

334 # Calculate levels from range. 

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

336 norm = None 

337 

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

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

340 # JSON file. 

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

342 cmap, levels, norm = _custom_colourmap_visibility_in_air( 

343 cube, cmap, levels, norm 

344 ) 

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

346 return cmap, levels, norm 

347 

348 

349def _setup_spatial_map( 

350 cube: iris.cube.Cube, 

351 figure, 

352 cmap, 

353 grid_size: int | None = None, 

354 subplot: int | None = None, 

355): 

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

357 

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

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

360 

361 Parameters 

362 ---------- 

363 cube: Cube 

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

365 figure: 

366 Matplotlib Figure object holding all plot elements. 

367 cmap: 

368 Matplotlib colormap. 

369 grid_size: int, optional 

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

371 subplot: int, optional 

372 Subplot index if multiple spatial subplots in figure. 

373 

374 Returns 

375 ------- 

376 axes: 

377 Matplotlib GeoAxes definition. 

378 """ 

379 # Identify min/max plot bounds. 

380 try: 

381 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

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

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

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

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

386 

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

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

389 x1 = x1 - 180.0 

390 x2 = x2 - 180.0 

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

392 

393 # Consider map projection orientation. 

394 # Adapting orientation enables plotting across international dateline. 

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

396 if x2 > 180.0: 

397 central_longitude = 180.0 

398 else: 

399 central_longitude = 0.0 

400 

401 # Define spatial map projection. 

402 coord_system = cube.coord(lat_axis).coord_system 

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

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

405 projection = ccrs.RotatedPole( 

406 pole_longitude=coord_system.grid_north_pole_longitude, 

407 pole_latitude=coord_system.grid_north_pole_latitude, 

408 central_rotated_longitude=0.0, 

409 ) 

410 crs = projection 

411 else: 

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

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

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

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

416 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

417 crs = ccrs.PlateCarree() 

418 

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

420 if subplot is not None: 

421 axes = figure.add_subplot( 

422 grid_size, grid_size, subplot, projection=projection 

423 ) 

424 else: 

425 axes = figure.add_subplot(projection=projection) 

426 

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

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

429 coastcol = "magenta" 

430 else: 

431 coastcol = "black" 

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

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

434 

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

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

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

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

439 

440 except ValueError: 

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

442 axes = figure.gca() 

443 pass 

444 

445 return axes 

446 

447 

448def _get_plot_resolution() -> int: 

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

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

451 

452 

453def _plot_and_save_spatial_plot( 

454 cube: iris.cube.Cube, 

455 filename: str, 

456 title: str, 

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

458 **kwargs, 

459): 

460 """Plot and save a spatial plot. 

461 

462 Parameters 

463 ---------- 

464 cube: Cube 

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

466 filename: str 

467 Filename of the plot to write. 

468 title: str 

469 Plot title. 

470 method: "contourf" | "pcolormesh" 

471 The plotting method to use. 

472 """ 

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

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

475 

476 # Specify the color bar 

477 cmap, levels, norm = _colorbar_map_levels(cube) 

478 

479 # Setup plot map projection, extent and coastlines. 

480 axes = _setup_spatial_map(cube, fig, cmap) 

481 

482 # Plot the field. 

483 if method == "contourf": 

484 # Filled contour plot of the field. 

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

486 elif method == "pcolormesh": 

487 try: 

488 vmin = min(levels) 

489 vmax = max(levels) 

490 except TypeError: 

491 vmin, vmax = None, None 

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

493 # if levels are defined. 

494 if norm is not None: 

495 vmin = None 

496 vmax = None 

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

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

499 else: 

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

501 

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

503 if is_transect(cube): 

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

505 axes.invert_yaxis() 

506 axes.set_yscale("log") 

507 axes.set_ylim(1100, 100) 

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

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

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

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

512 ): 

513 axes.set_yscale("log") 

514 

515 axes.set_title( 

516 f"{title}\n" 

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

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

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

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

521 fontsize=16, 

522 ) 

523 

524 else: 

525 # Add title. 

526 axes.set_title(title, fontsize=16) 

527 

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

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

530 axes.annotate( 

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

532 xy=(1, -0.05), 

533 xycoords="axes fraction", 

534 xytext=(-5, 5), 

535 textcoords="offset points", 

536 ha="right", 

537 va="bottom", 

538 size=11, 

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

540 ) 

541 

542 # Add colour bar. 

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

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

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

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

547 cbar.set_ticks(levels) 

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

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

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

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

552 

553 # Save plot. 

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

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

556 plt.close(fig) 

557 

558 

559def _plot_and_save_postage_stamp_spatial_plot( 

560 cube: iris.cube.Cube, 

561 filename: str, 

562 stamp_coordinate: str, 

563 title: str, 

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

565 **kwargs, 

566): 

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

568 

569 Parameters 

570 ---------- 

571 cube: Cube 

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

573 filename: str 

574 Filename of the plot to write. 

575 stamp_coordinate: str 

576 Coordinate that becomes different plots. 

577 method: "contourf" | "pcolormesh" 

578 The plotting method to use. 

579 

580 Raises 

581 ------ 

582 ValueError 

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

584 """ 

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

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

587 

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

589 

590 # Specify the color bar 

591 cmap, levels, norm = _colorbar_map_levels(cube) 

592 

593 # Make a subplot for each member. 

594 for member, subplot in zip( 

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

596 ): 

597 # Setup subplot map projection, extent and coastlines. 

598 axes = _setup_spatial_map( 

599 member, fig, cmap, grid_size=grid_size, subplot=subplot 

600 ) 

601 if method == "contourf": 

602 # Filled contour plot of the field. 

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

604 elif method == "pcolormesh": 

605 if levels is not None: 

606 vmin = min(levels) 

607 vmax = max(levels) 

608 else: 

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

610 vmin, vmax = None, None 

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

612 # if levels are defined. 

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

614 vmin = None 

615 vmax = None 

616 # pcolormesh plot of the field. 

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

618 else: 

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

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

621 axes.set_axis_off() 

622 

623 # Put the shared colorbar in its own axes. 

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

625 colorbar = fig.colorbar( 

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

627 ) 

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

629 

630 # Overall figure title. 

631 fig.suptitle(title, fontsize=16) 

632 

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

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

635 plt.close(fig) 

636 

637 

638def _plot_and_save_line_series( 

639 cubes: iris.cube.CubeList, 

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

641 ensemble_coord: str, 

642 filename: str, 

643 title: str, 

644 **kwargs, 

645): 

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

647 

648 Parameters 

649 ---------- 

650 cubes: Cube or CubeList 

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

652 coords: list[Coord] 

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

654 ensemble_coord: str 

655 Ensemble coordinate in the cube. 

656 filename: str 

657 Filename of the plot to write. 

658 title: str 

659 Plot title. 

660 """ 

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

662 

663 model_colors_map = _get_model_colors_map(cubes) 

664 

665 # Store min/max ranges. 

666 y_levels = [] 

667 

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

669 _validate_cubes_coords(cubes, coords) 

670 

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

672 label = None 

673 color = "black" 

674 if model_colors_map: 

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

676 color = model_colors_map.get(label) 

677 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

680 iplt.plot( 

681 coord, 

682 cube_slice, 

683 color=color, 

684 marker="o", 

685 ls="-", 

686 lw=3, 

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

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

689 else label, 

690 ) 

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

692 else: 

693 iplt.plot( 

694 coord, 

695 cube_slice, 

696 color=color, 

697 ls="-", 

698 lw=1.5, 

699 alpha=0.75, 

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

701 ) 

702 

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

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

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

706 y_levels.append(min(levels)) 

707 y_levels.append(max(levels)) 

708 

709 # Get the current axes. 

710 ax = plt.gca() 

711 

712 # Add some labels and tweak the style. 

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

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

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

716 ax.set_title(title, fontsize=16) 

717 

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

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

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

721 

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

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

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

725 # Add zero line. 

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

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

728 logging.debug( 

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

730 ) 

731 else: 

732 ax.autoscale() 

733 

734 # Add gridlines 

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

736 # Ientify unique labels for legend 

737 handles = list( 

738 { 

739 label: handle 

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

741 }.values() 

742 ) 

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

744 

745 # Save plot. 

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

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

748 plt.close(fig) 

749 

750 

751def _plot_and_save_vertical_line_series( 

752 cubes: iris.cube.CubeList, 

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

754 ensemble_coord: str, 

755 filename: str, 

756 series_coordinate: str, 

757 title: str, 

758 vmin: float, 

759 vmax: float, 

760 **kwargs, 

761): 

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

763 

764 Parameters 

765 ---------- 

766 cubes: CubeList 

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

768 coord: list[Coord] 

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

770 ensemble_coord: str 

771 Ensemble coordinate in the cube. 

772 filename: str 

773 Filename of the plot to write. 

774 series_coordinate: str 

775 Coordinate to use as vertical axis. 

776 title: str 

777 Plot title. 

778 vmin: float 

779 Minimum value for the x-axis. 

780 vmax: float 

781 Maximum value for the x-axis. 

782 """ 

783 # plot the vertical pressure axis using log scale 

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

785 

786 model_colors_map = _get_model_colors_map(cubes) 

787 

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

789 _validate_cubes_coords(cubes, coords) 

790 

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

792 label = None 

793 color = "black" 

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

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

796 color = model_colors_map.get(label) 

797 

798 for cube_slice in cube.slices_over(ensemble_coord): 

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

800 # unless single forecast. 

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

802 iplt.plot( 

803 cube_slice, 

804 coord, 

805 color=color, 

806 marker="o", 

807 ls="-", 

808 lw=3, 

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

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

811 else label, 

812 ) 

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

814 else: 

815 iplt.plot( 

816 cube_slice, 

817 coord, 

818 color=color, 

819 ls="-", 

820 lw=1.5, 

821 alpha=0.75, 

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

823 ) 

824 

825 # Get the current axis 

826 ax = plt.gca() 

827 

828 # Special handling for pressure level data. 

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

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

831 ax.invert_yaxis() 

832 ax.set_yscale("log") 

833 

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

835 y_tick_labels = [ 

836 "1000", 

837 "850", 

838 "700", 

839 "500", 

840 "300", 

841 "200", 

842 "100", 

843 ] 

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

845 

846 # Set y-axis limits and ticks. 

847 ax.set_ylim(1100, 100) 

848 

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

850 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

856 

857 ax.set_yticks(y_ticks) 

858 ax.set_yticklabels(y_tick_labels) 

859 

860 # Set x-axis limits. 

861 ax.set_xlim(vmin, vmax) 

862 # Mark y=0 if present in plot. 

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

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

865 

866 # Add some labels and tweak the style. 

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

868 ax.set_xlabel( 

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

870 ) 

871 ax.set_title(title, fontsize=16) 

872 ax.ticklabel_format(axis="x") 

873 ax.tick_params(axis="y") 

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

875 

876 # Add gridlines 

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

878 # Ientify unique labels for legend 

879 handles = list( 

880 { 

881 label: handle 

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

883 }.values() 

884 ) 

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

886 

887 # Save plot. 

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

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

890 plt.close(fig) 

891 

892 

893def _plot_and_save_scatter_plot( 

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

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

896 filename: str, 

897 title: str, 

898 one_to_one: bool, 

899 **kwargs, 

900): 

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

902 

903 Parameters 

904 ---------- 

905 cube_x: Cube | CubeList 

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

907 cube_y: Cube | CubeList 

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

909 filename: str 

910 Filename of the plot to write. 

911 title: str 

912 Plot title. 

913 one_to_one: bool 

914 Whether a 1:1 line is plotted. 

915 """ 

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

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

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

919 # over the pairs simultaneously. 

920 

921 # Ensure cube_x and cube_y are iterable 

922 cube_x_iterable = iter_maybe(cube_x) 

923 cube_y_iterable = iter_maybe(cube_y) 

924 

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

926 iplt.scatter(cube_x_iter, cube_y_iter) 

927 if one_to_one is True: 

928 plt.plot( 

929 [ 

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

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

932 ], 

933 [ 

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

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

936 ], 

937 "k", 

938 linestyle="--", 

939 ) 

940 ax = plt.gca() 

941 

942 # Add some labels and tweak the style. 

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

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

945 ax.set_title(title, fontsize=16) 

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

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

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

949 ax.autoscale() 

950 

951 # Save plot. 

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

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

954 plt.close(fig) 

955 

956 

957def _plot_and_save_vector_plot( 

958 cube_u: iris.cube.Cube, 

959 cube_v: iris.cube.Cube, 

960 filename: str, 

961 title: str, 

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

963 **kwargs, 

964): 

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

966 

967 Parameters 

968 ---------- 

969 cube_u: Cube 

970 2 dimensional Cube of u component of the data. 

971 cube_v: Cube 

972 2 dimensional Cube of v component of the data. 

973 filename: str 

974 Filename of the plot to write. 

975 title: str 

976 Plot title. 

977 """ 

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

979 

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

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

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

983 

984 # Specify the color bar 

985 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag) 

986 

987 # Setup plot map projection, extent and coastlines. 

988 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

989 

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

991 # Filled contour plot of the field. 

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

993 elif method == "pcolormesh": 

994 try: 

995 vmin = min(levels) 

996 vmax = max(levels) 

997 except TypeError: 

998 vmin, vmax = None, None 

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

1000 # if levels are defined. 

1001 if norm is not None: 

1002 vmin = None 

1003 vmax = None 

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

1005 else: 

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

1007 

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

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

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

1011 axes.invert_yaxis() 

1012 axes.set_yscale("log") 

1013 axes.set_ylim(1100, 100) 

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

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

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

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

1018 ): 

1019 axes.set_yscale("log") 

1020 

1021 axes.set_title( 

1022 f"{title}\n" 

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

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

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

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

1027 fontsize=16, 

1028 ) 

1029 

1030 else: 

1031 # Add title. 

1032 axes.set_title(title, fontsize=16) 

1033 

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

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

1036 axes.annotate( 

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

1038 xy=(1, -0.05), 

1039 xycoords="axes fraction", 

1040 xytext=(-5, 5), 

1041 textcoords="offset points", 

1042 ha="right", 

1043 va="bottom", 

1044 size=11, 

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

1046 ) 

1047 

1048 # Add colour bar. 

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

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

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

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

1053 cbar.set_ticks(levels) 

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

1055 

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

1057 # with less than 30 points. 

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

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

1060 

1061 # Save plot. 

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

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

1064 plt.close(fig) 

1065 

1066 

1067def _plot_and_save_histogram_series( 

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

1069 filename: str, 

1070 title: str, 

1071 vmin: float, 

1072 vmax: float, 

1073 **kwargs, 

1074): 

1075 """Plot and save a histogram series. 

1076 

1077 Parameters 

1078 ---------- 

1079 cubes: Cube or CubeList 

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

1081 filename: str 

1082 Filename of the plot to write. 

1083 title: str 

1084 Plot title. 

1085 vmin: float 

1086 minimum for colorbar 

1087 vmax: float 

1088 maximum for colorbar 

1089 """ 

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

1091 ax = plt.gca() 

1092 

1093 model_colors_map = _get_model_colors_map(cubes) 

1094 

1095 # Set default that histograms will produce probability density function 

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

1097 density = True 

1098 

1099 for cube in iter_maybe(cubes): 

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

1101 # than seeing if long names exist etc. 

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

1103 if "surface_microphysical" in title: 

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

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

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

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

1108 density = False 

1109 else: 

1110 bins = 10.0 ** ( 

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

1112 ) # Suggestion from RMED toolbox. 

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

1114 ax.set_yscale("log") 

1115 vmin = bins[1] 

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

1117 ax.set_xscale("log") 

1118 elif "lightning" in title: 

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

1120 else: 

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

1122 logging.debug( 

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

1124 np.size(bins), 

1125 np.min(bins), 

1126 np.max(bins), 

1127 ) 

1128 

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

1130 # Otherwise we plot xdim histograms stacked. 

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

1132 

1133 label = None 

1134 color = "black" 

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

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

1137 color = model_colors_map[label] 

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

1139 

1140 # Compute area under curve. 

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

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

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

1144 x = x[1:] 

1145 y = y[1:] 

1146 

1147 ax.plot( 

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

1149 ) 

1150 

1151 # Add some labels and tweak the style. 

1152 ax.set_title(title, fontsize=16) 

1153 ax.set_xlabel( 

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

1155 ) 

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

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

1158 ax.set_ylabel( 

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

1160 ) 

1161 ax.set_xlim(vmin, vmax) 

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

1163 

1164 # Overlay grid-lines onto histogram plot. 

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

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

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

1168 

1169 # Save plot. 

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

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

1172 plt.close(fig) 

1173 

1174 

1175def _plot_and_save_postage_stamp_histogram_series( 

1176 cube: iris.cube.Cube, 

1177 filename: str, 

1178 title: str, 

1179 stamp_coordinate: str, 

1180 vmin: float, 

1181 vmax: float, 

1182 **kwargs, 

1183): 

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

1185 

1186 Parameters 

1187 ---------- 

1188 cube: Cube 

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

1190 filename: str 

1191 Filename of the plot to write. 

1192 title: str 

1193 Plot title. 

1194 stamp_coordinate: str 

1195 Coordinate that becomes different plots. 

1196 vmin: float 

1197 minimum for pdf x-axis 

1198 vmax: float 

1199 maximum for pdf x-axis 

1200 """ 

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

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

1203 

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

1205 # Make a subplot for each member. 

1206 for member, subplot in zip( 

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

1208 ): 

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

1210 # cartopy GeoAxes generated. 

1211 plt.subplot(grid_size, grid_size, subplot) 

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

1213 # Otherwise we plot xdim histograms stacked. 

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

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

1216 ax = plt.gca() 

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

1218 ax.set_xlim(vmin, vmax) 

1219 

1220 # Overall figure title. 

1221 fig.suptitle(title, fontsize=16) 

1222 

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

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

1225 plt.close(fig) 

1226 

1227 

1228def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1229 cube: iris.cube.Cube, 

1230 filename: str, 

1231 title: str, 

1232 stamp_coordinate: str, 

1233 vmin: float, 

1234 vmax: float, 

1235 **kwargs, 

1236): 

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

1238 ax.set_title(title, fontsize=16) 

1239 ax.set_xlim(vmin, vmax) 

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

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

1242 # Loop over all slices along the stamp_coordinate 

1243 for member in cube.slices_over(stamp_coordinate): 

1244 # Flatten the member data to 1D 

1245 member_data_1d = member.data.flatten() 

1246 # Plot the histogram using plt.hist 

1247 plt.hist( 

1248 member_data_1d, 

1249 density=True, 

1250 stacked=True, 

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

1252 ) 

1253 

1254 # Add a legend 

1255 ax.legend(fontsize=16) 

1256 

1257 # Save the figure to a file 

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

1259 

1260 # Close the figure 

1261 plt.close(fig) 

1262 

1263 

1264def _spatial_plot( 

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

1266 cube: iris.cube.Cube, 

1267 filename: str | None, 

1268 sequence_coordinate: str, 

1269 stamp_coordinate: str, 

1270): 

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

1272 

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

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

1275 is present then postage stamp plots will be produced. 

1276 

1277 Parameters 

1278 ---------- 

1279 method: "contourf" | "pcolormesh" 

1280 The plotting method to use. 

1281 cube: Cube 

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

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

1284 plotted sequentially and/or as postage stamp plots. 

1285 filename: str | None 

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

1287 uses the recipe name. 

1288 sequence_coordinate: str 

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

1290 This coordinate must exist in the cube. 

1291 stamp_coordinate: str 

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

1293 ``"realization"``. 

1294 

1295 Raises 

1296 ------ 

1297 ValueError 

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

1299 TypeError 

1300 If the cube isn't a single cube. 

1301 """ 

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

1303 

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

1305 if filename is None: 

1306 filename = slugify(recipe_title) 

1307 

1308 # Ensure we've got a single cube. 

1309 cube = _check_single_cube(cube) 

1310 

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

1312 # single point. 

1313 plotting_func = _plot_and_save_spatial_plot 

1314 try: 

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

1316 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1317 except iris.exceptions.CoordinateNotFoundError: 

1318 pass 

1319 

1320 # Must have a sequence coordinate. 

1321 try: 

1322 cube.coord(sequence_coordinate) 

1323 except iris.exceptions.CoordinateNotFoundError as err: 

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

1325 

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

1327 plot_index = [] 

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

1329 for cube_slice in cube.slices_over(sequence_coordinate): 

1330 # Use sequence value so multiple sequences can merge. 

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

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

1333 coord = cube_slice.coord(sequence_coordinate) 

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

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

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

1337 if nplot == 1 and coord.has_bounds: 

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

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

1340 # Do the actual plotting. 

1341 plotting_func( 

1342 cube_slice, 

1343 filename=plot_filename, 

1344 stamp_coordinate=stamp_coordinate, 

1345 title=title, 

1346 method=method, 

1347 ) 

1348 plot_index.append(plot_filename) 

1349 

1350 # Add list of plots to plot metadata. 

1351 complete_plot_index = _append_to_plot_index(plot_index) 

1352 

1353 # Make a page to display the plots. 

1354 _make_plot_html_page(complete_plot_index) 

1355 

1356 

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

1358 """Get colourmap for mask. 

1359 

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

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

1362 

1363 Parameters 

1364 ---------- 

1365 cube: Cube 

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

1367 axis: "x", "y", optional 

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

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

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

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

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

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

1374 

1375 Returns 

1376 ------- 

1377 cmap: 

1378 Matplotlib colormap. 

1379 levels: 

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

1381 should be taken as the range. 

1382 norm: 

1383 BoundaryNorm information. 

1384 """ 

1385 if "difference" not in cube.long_name: 

1386 if axis: 

1387 levels = [0, 1] 

1388 # Complete settings based on levels. 

1389 return None, levels, None 

1390 else: 

1391 # Define the levels and colors. 

1392 levels = [0, 1, 2] 

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

1394 # Create a custom color map. 

1395 cmap = mcolors.ListedColormap(colors) 

1396 # Normalize the levels. 

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

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

1399 return cmap, levels, norm 

1400 else: 

1401 if axis: 

1402 levels = [-1, 1] 

1403 return None, levels, None 

1404 else: 

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

1406 # not <=. 

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

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

1409 cmap = mcolors.ListedColormap(colors) 

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

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

1412 return cmap, levels, norm 

1413 

1414 

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

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

1417 

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

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

1420 

1421 Parameters 

1422 ---------- 

1423 cube: Cube 

1424 Cube of variable with Beaufort Scale in name. 

1425 axis: "x", "y", optional 

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

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

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

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

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

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

1432 

1433 Returns 

1434 ------- 

1435 cmap: 

1436 Matplotlib colormap. 

1437 levels: 

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

1439 should be taken as the range. 

1440 norm: 

1441 BoundaryNorm information. 

1442 """ 

1443 if "difference" not in cube.long_name: 

1444 if axis: 

1445 levels = [0, 12] 

1446 return None, levels, None 

1447 else: 

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

1449 colors = [ 

1450 "black", 

1451 (0, 0, 0.6), 

1452 "blue", 

1453 "cyan", 

1454 "green", 

1455 "yellow", 

1456 (1, 0.5, 0), 

1457 "red", 

1458 "pink", 

1459 "magenta", 

1460 "purple", 

1461 "maroon", 

1462 "white", 

1463 ] 

1464 cmap = mcolors.ListedColormap(colors) 

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

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

1467 return cmap, levels, norm 

1468 else: 

1469 if axis: 

1470 levels = [-4, 4] 

1471 return None, levels, None 

1472 else: 

1473 levels = [ 

1474 -3.5, 

1475 -2.5, 

1476 -1.5, 

1477 -0.5, 

1478 0.5, 

1479 1.5, 

1480 2.5, 

1481 3.5, 

1482 ] 

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

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

1485 return cmap, levels, norm 

1486 

1487 

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

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

1490 

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

1492 

1493 Parameters 

1494 ---------- 

1495 cube: Cube 

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

1497 cmap: Matplotlib colormap. 

1498 levels: List 

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

1500 should be taken as the range. 

1501 norm: BoundaryNorm. 

1502 

1503 Returns 

1504 ------- 

1505 cmap: Matplotlib colormap. 

1506 levels: List 

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

1508 should be taken as the range. 

1509 norm: BoundaryNorm. 

1510 """ 

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

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

1513 levels = np.array(levels) 

1514 levels -= 273 

1515 levels = levels.tolist() 

1516 else: 

1517 # Do nothing keep the existing colourbar attributes 

1518 levels = levels 

1519 cmap = cmap 

1520 norm = norm 

1521 return cmap, levels, norm 

1522 

1523 

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

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

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

1527 if ( 

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

1529 and "difference" not in cube.long_name 

1530 and "mask" not in cube.long_name 

1531 ): 

1532 # Define the levels and colors 

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

1534 colors = [ 

1535 "w", 

1536 (0, 0, 0.6), 

1537 "b", 

1538 "c", 

1539 "g", 

1540 "y", 

1541 (1, 0.5, 0), 

1542 "r", 

1543 "pink", 

1544 "m", 

1545 "purple", 

1546 "maroon", 

1547 "gray", 

1548 ] 

1549 # Create a custom colormap 

1550 cmap = mcolors.ListedColormap(colors) 

1551 # Normalize the levels 

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

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

1554 else: 

1555 # do nothing and keep existing colorbar attributes 

1556 cmap = cmap 

1557 levels = levels 

1558 norm = norm 

1559 return cmap, levels, norm 

1560 

1561 

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

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

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

1565 if ( 

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

1567 and "difference" not in cube.long_name 

1568 and "mask" not in cube.long_name 

1569 ): 

1570 # Define the levels and colors (in km) 

1571 levels = [0, 0.05, 0.1, 0.2, 1.0, 2.0, 5.0, 10.0, 20.0, 30.0, 50.0, 70.0, 100.0] 

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

1573 colours = [ 

1574 "#8f00d6", 

1575 "#d10000", 

1576 "#ff9700", 

1577 "#ffff00", 

1578 "#00007f", 

1579 "#6c9ccd", 

1580 "#aae8ff", 

1581 "#37a648", 

1582 "#8edc64", 

1583 "#c5ffc5", 

1584 "#dcdcdc", 

1585 "#ffffff", 

1586 ] 

1587 # Create a custom colormap 

1588 cmap = mcolors.ListedColormap(colours) 

1589 # Normalize the levels 

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

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

1592 else: 

1593 # do nothing and keep existing colorbar attributes 

1594 cmap = cmap 

1595 levels = levels 

1596 norm = norm 

1597 return cmap, levels, norm 

1598 

1599 

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

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

1602 model_names = list( 

1603 filter( 

1604 lambda x: x is not None, 

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

1606 ) 

1607 ) 

1608 if not model_names: 

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

1610 return 1 

1611 else: 

1612 return len(model_names) 

1613 

1614 

1615def _validate_cube_shape( 

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

1617) -> None: 

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

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

1620 raise ValueError( 

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

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

1623 ) 

1624 

1625 

1626def _validate_cubes_coords( 

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

1628) -> None: 

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

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

1631 raise ValueError( 

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

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

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

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

1636 ) 

1637 

1638 

1639#################### 

1640# Public functions # 

1641#################### 

1642 

1643 

1644def spatial_contour_plot( 

1645 cube: iris.cube.Cube, 

1646 filename: str = None, 

1647 sequence_coordinate: str = "time", 

1648 stamp_coordinate: str = "realization", 

1649 **kwargs, 

1650) -> iris.cube.Cube: 

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

1652 

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

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

1655 is present then postage stamp plots will be produced. 

1656 

1657 Parameters 

1658 ---------- 

1659 cube: Cube 

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

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

1662 plotted sequentially and/or as postage stamp plots. 

1663 filename: str, optional 

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

1665 to the recipe name. 

1666 sequence_coordinate: str, optional 

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

1668 This coordinate must exist in the cube. 

1669 stamp_coordinate: str, optional 

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

1671 ``"realization"``. 

1672 

1673 Returns 

1674 ------- 

1675 Cube 

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

1677 

1678 Raises 

1679 ------ 

1680 ValueError 

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

1682 TypeError 

1683 If the cube isn't a single cube. 

1684 """ 

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

1686 return cube 

1687 

1688 

1689def spatial_pcolormesh_plot( 

1690 cube: iris.cube.Cube, 

1691 filename: str = None, 

1692 sequence_coordinate: str = "time", 

1693 stamp_coordinate: str = "realization", 

1694 **kwargs, 

1695) -> iris.cube.Cube: 

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

1697 

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

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

1700 is present then postage stamp plots will be produced. 

1701 

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

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

1704 contour areas are important. 

1705 

1706 Parameters 

1707 ---------- 

1708 cube: Cube 

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

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

1711 plotted sequentially and/or as postage stamp plots. 

1712 filename: str, optional 

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

1714 to the recipe name. 

1715 sequence_coordinate: str, optional 

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

1717 This coordinate must exist in the cube. 

1718 stamp_coordinate: str, optional 

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

1720 ``"realization"``. 

1721 

1722 Returns 

1723 ------- 

1724 Cube 

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

1726 

1727 Raises 

1728 ------ 

1729 ValueError 

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

1731 TypeError 

1732 If the cube isn't a single cube. 

1733 """ 

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

1735 return cube 

1736 

1737 

1738# TODO: Expand function to handle ensemble data. 

1739# line_coordinate: str, optional 

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

1741# ``"realization"``. 

1742def plot_line_series( 

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

1744 filename: str = None, 

1745 series_coordinate: str = "time", 

1746 # line_coordinate: str = "realization", 

1747 **kwargs, 

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

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

1750 

1751 The Cube or CubeList must be 1D. 

1752 

1753 Parameters 

1754 ---------- 

1755 iris.cube | iris.cube.CubeList 

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

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

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

1759 filename: str, optional 

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

1761 to the recipe name. 

1762 series_coordinate: str, optional 

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

1764 coordinate must exist in the cube. 

1765 

1766 Returns 

1767 ------- 

1768 iris.cube.Cube | iris.cube.CubeList 

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

1770 plotted data. 

1771 

1772 Raises 

1773 ------ 

1774 ValueError 

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

1776 TypeError 

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

1778 """ 

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

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

1781 

1782 if filename is None: 

1783 filename = slugify(title) 

1784 

1785 # Add file extension. 

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

1787 

1788 num_models = _get_num_models(cube) 

1789 

1790 _validate_cube_shape(cube, num_models) 

1791 

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

1793 cubes = iter_maybe(cube) 

1794 coords = [] 

1795 for cube in cubes: 

1796 try: 

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

1798 except iris.exceptions.CoordinateNotFoundError as err: 

1799 raise ValueError( 

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

1801 ) from err 

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

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

1804 

1805 # Do the actual plotting. 

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

1807 

1808 # Add list of plots to plot metadata. 

1809 plot_index = _append_to_plot_index([plot_filename]) 

1810 

1811 # Make a page to display the plots. 

1812 _make_plot_html_page(plot_index) 

1813 

1814 return cube 

1815 

1816 

1817def plot_vertical_line_series( 

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

1819 filename: str = None, 

1820 series_coordinate: str = "model_level_number", 

1821 sequence_coordinate: str = "time", 

1822 # line_coordinate: str = "realization", 

1823 **kwargs, 

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

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

1826 

1827 The Cube or CubeList must be 1D. 

1828 

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

1830 then a sequence of plots will be produced. 

1831 

1832 Parameters 

1833 ---------- 

1834 iris.cube | iris.cube.CubeList 

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

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

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

1838 filename: str, optional 

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

1840 to the recipe name. 

1841 series_coordinate: str, optional 

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

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

1844 for LFRic. Defaults to ``model_level_number``. 

1845 This coordinate must exist in the cube. 

1846 sequence_coordinate: str, optional 

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

1848 This coordinate must exist in the cube. 

1849 

1850 Returns 

1851 ------- 

1852 iris.cube.Cube | iris.cube.CubeList 

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

1854 Plotted data. 

1855 

1856 Raises 

1857 ------ 

1858 ValueError 

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

1860 TypeError 

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

1862 """ 

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

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

1865 

1866 if filename is None: 

1867 filename = slugify(recipe_title) 

1868 

1869 cubes = iter_maybe(cubes) 

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

1871 all_data = [] 

1872 

1873 # Store min/max ranges for x range. 

1874 x_levels = [] 

1875 

1876 num_models = _get_num_models(cubes) 

1877 

1878 _validate_cube_shape(cubes, num_models) 

1879 

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

1881 coords = [] 

1882 for cube in cubes: 

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

1884 try: 

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

1886 except iris.exceptions.CoordinateNotFoundError as err: 

1887 raise ValueError( 

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

1889 ) from err 

1890 

1891 try: 

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

1893 cube.coord(sequence_coordinate) 

1894 except iris.exceptions.CoordinateNotFoundError as err: 

1895 raise ValueError( 

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

1897 ) from err 

1898 

1899 # Get minimum and maximum from levels information. 

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

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

1902 x_levels.append(min(levels)) 

1903 x_levels.append(max(levels)) 

1904 else: 

1905 all_data.append(cube.data) 

1906 

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

1908 # Combine all data into a single NumPy array 

1909 combined_data = np.concatenate(all_data) 

1910 

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

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

1913 # sequence and if applicable postage stamp coordinate. 

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

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

1916 else: 

1917 vmin = min(x_levels) 

1918 vmax = max(x_levels) 

1919 

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

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

1922 # necessary) 

1923 def filter_cube_iterables(cube_iterables) -> bool: 

1924 return len(cube_iterables) == len(coords) 

1925 

1926 cube_iterables = filter( 

1927 filter_cube_iterables, 

1928 ( 

1929 iris.cube.CubeList( 

1930 s 

1931 for s in itertools.chain.from_iterable( 

1932 cb.slices_over(sequence_coordinate) for cb in cubes 

1933 ) 

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

1935 ) 

1936 for point in sorted( 

1937 set( 

1938 itertools.chain.from_iterable( 

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

1940 ) 

1941 ) 

1942 ) 

1943 ), 

1944 ) 

1945 

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

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

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

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

1950 # or an iris.cube.CubeList. 

1951 plot_index = [] 

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

1953 for cubes_slice in cube_iterables: 

1954 # Use sequence value so multiple sequences can merge. 

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

1956 sequence_value = seq_coord.points[0] 

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

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

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

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

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

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

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

1964 # Do the actual plotting. 

1965 _plot_and_save_vertical_line_series( 

1966 cubes_slice, 

1967 coords, 

1968 "realization", 

1969 plot_filename, 

1970 series_coordinate, 

1971 title=title, 

1972 vmin=vmin, 

1973 vmax=vmax, 

1974 ) 

1975 plot_index.append(plot_filename) 

1976 

1977 # Add list of plots to plot metadata. 

1978 complete_plot_index = _append_to_plot_index(plot_index) 

1979 

1980 # Make a page to display the plots. 

1981 _make_plot_html_page(complete_plot_index) 

1982 

1983 return cubes 

1984 

1985 

1986def scatter_plot( 

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

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

1989 filename: str = None, 

1990 one_to_one: bool = True, 

1991 **kwargs, 

1992) -> iris.cube.CubeList: 

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

1994 

1995 Both cubes must be 1D. 

1996 

1997 Parameters 

1998 ---------- 

1999 cube_x: Cube | CubeList 

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

2001 cube_y: Cube | CubeList 

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

2003 filename: str, optional 

2004 Filename of the plot to write. 

2005 one_to_one: bool, optional 

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

2007 

2008 Returns 

2009 ------- 

2010 cubes: CubeList 

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

2012 

2013 Raises 

2014 ------ 

2015 ValueError 

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

2017 size. 

2018 TypeError 

2019 If the cube isn't a single cube. 

2020 

2021 Notes 

2022 ----- 

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

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

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

2026 

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

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

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

2030 observations and other models. Identical percentiles between the variables 

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

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

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

2034 Wilks 2011 [Wilks2011]_). 

2035 

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

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

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

2039 the extremes. 

2040 

2041 References 

2042 ---------- 

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

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

2045 """ 

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

2047 for cube_iter in iter_maybe(cube_x): 

2048 # Check cubes are correct shape. 

2049 cube_iter = _check_single_cube(cube_iter) 

2050 if cube_iter.ndim > 1: 

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

2052 

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

2054 for cube_iter in iter_maybe(cube_y): 

2055 # Check cubes are correct shape. 

2056 cube_iter = _check_single_cube(cube_iter) 

2057 if cube_iter.ndim > 1: 

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

2059 

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

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

2062 

2063 if filename is None: 

2064 filename = slugify(title) 

2065 

2066 # Add file extension. 

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

2068 

2069 # Do the actual plotting. 

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

2071 

2072 # Add list of plots to plot metadata. 

2073 plot_index = _append_to_plot_index([plot_filename]) 

2074 

2075 # Make a page to display the plots. 

2076 _make_plot_html_page(plot_index) 

2077 

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

2079 

2080 

2081def vector_plot( 

2082 cube_u: iris.cube.Cube, 

2083 cube_v: iris.cube.Cube, 

2084 filename: str = None, 

2085 sequence_coordinate: str = "time", 

2086 **kwargs, 

2087) -> iris.cube.CubeList: 

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

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

2090 

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

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

2093 filename = slugify(recipe_title) 

2094 

2095 # Cubes must have a matching sequence coordinate. 

2096 try: 

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

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

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

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

2101 raise ValueError( 

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

2103 ) from err 

2104 

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

2106 plot_index = [] 

2107 for cube_u_slice, cube_v_slice in zip( 

2108 cube_u.slices_over(sequence_coordinate), 

2109 cube_v.slices_over(sequence_coordinate), 

2110 strict=True, 

2111 ): 

2112 # Use sequence value so multiple sequences can merge. 

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

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

2115 coord = cube_u_slice.coord(sequence_coordinate) 

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

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

2118 # Do the actual plotting. 

2119 _plot_and_save_vector_plot( 

2120 cube_u_slice, 

2121 cube_v_slice, 

2122 filename=plot_filename, 

2123 title=title, 

2124 method="contourf", 

2125 ) 

2126 plot_index.append(plot_filename) 

2127 

2128 # Add list of plots to plot metadata. 

2129 complete_plot_index = _append_to_plot_index(plot_index) 

2130 

2131 # Make a page to display the plots. 

2132 _make_plot_html_page(complete_plot_index) 

2133 

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

2135 

2136 

2137def plot_histogram_series( 

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

2139 filename: str = None, 

2140 sequence_coordinate: str = "time", 

2141 stamp_coordinate: str = "realization", 

2142 single_plot: bool = False, 

2143 **kwargs, 

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

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

2146 

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

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

2149 functionality to scroll through histograms against time. If a 

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

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

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

2153 

2154 Parameters 

2155 ---------- 

2156 cubes: Cube | iris.cube.CubeList 

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

2158 than the stamp coordinate. 

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

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

2161 filename: str, optional 

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

2163 to the recipe name. 

2164 sequence_coordinate: str, optional 

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

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

2167 slider. 

2168 stamp_coordinate: str, optional 

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

2170 ``"realization"``. 

2171 single_plot: bool, optional 

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

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

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

2175 

2176 Returns 

2177 ------- 

2178 iris.cube.Cube | iris.cube.CubeList 

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

2180 Plotted data. 

2181 

2182 Raises 

2183 ------ 

2184 ValueError 

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

2186 TypeError 

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

2188 """ 

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

2190 

2191 cubes = iter_maybe(cubes) 

2192 

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

2194 if filename is None: 

2195 filename = slugify(recipe_title) 

2196 

2197 # Internal plotting function. 

2198 plotting_func = _plot_and_save_histogram_series 

2199 

2200 num_models = _get_num_models(cubes) 

2201 

2202 _validate_cube_shape(cubes, num_models) 

2203 

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

2205 # time slider option. 

2206 for cube in cubes: 

2207 try: 

2208 cube.coord(sequence_coordinate) 

2209 except iris.exceptions.CoordinateNotFoundError as err: 

2210 raise ValueError( 

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

2212 ) from err 

2213 

2214 # Get minimum and maximum from levels information. 

2215 levels = None 

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

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

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

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

2220 if levels is None: 

2221 break 

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

2223 # levels-based ranges for histogram plots. 

2224 _, levels, _ = _colorbar_map_levels(cube) 

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

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

2227 vmin = min(levels) 

2228 vmax = max(levels) 

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

2230 break 

2231 

2232 if levels is None: 

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

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

2235 

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

2237 # single point. If single_plot is True: 

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

2239 # separate postage stamp plots. 

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

2241 # produced per single model only 

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

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

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

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

2246 ): 

2247 if single_plot: 

2248 plotting_func = ( 

2249 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

2250 ) 

2251 else: 

2252 plotting_func = _plot_and_save_postage_stamp_histogram_series 

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

2254 else: 

2255 all_points = sorted( 

2256 set( 

2257 itertools.chain.from_iterable( 

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

2259 ) 

2260 ) 

2261 ) 

2262 all_slices = list( 

2263 itertools.chain.from_iterable( 

2264 cb.slices_over(sequence_coordinate) for cb in cubes 

2265 ) 

2266 ) 

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

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

2269 # necessary) 

2270 cube_iterables = [ 

2271 iris.cube.CubeList( 

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

2273 ) 

2274 for point in all_points 

2275 ] 

2276 

2277 plot_index = [] 

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

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

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

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

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

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

2284 for cube_slice in cube_iterables: 

2285 single_cube = cube_slice 

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

2287 single_cube = cube_slice[0] 

2288 

2289 # Use sequence value so multiple sequences can merge. 

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

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

2292 coord = single_cube.coord(sequence_coordinate) 

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

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

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

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

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

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

2299 # Do the actual plotting. 

2300 plotting_func( 

2301 cube_slice, 

2302 filename=plot_filename, 

2303 stamp_coordinate=stamp_coordinate, 

2304 title=title, 

2305 vmin=vmin, 

2306 vmax=vmax, 

2307 ) 

2308 plot_index.append(plot_filename) 

2309 

2310 # Add list of plots to plot metadata. 

2311 complete_plot_index = _append_to_plot_index(plot_index) 

2312 

2313 # Make a page to display the plots. 

2314 _make_plot_html_page(complete_plot_index) 

2315 

2316 return cubes