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

734 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-05 21:08 +0000

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 return cmap, levels, norm 

346 

347 

348def _setup_spatial_map( 

349 cube: iris.cube.Cube, 

350 figure, 

351 cmap, 

352 grid_size: int | None = None, 

353 subplot: int | None = None, 

354): 

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

356 

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

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

359 

360 Parameters 

361 ---------- 

362 cube: Cube 

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

364 figure: 

365 Matplotlib Figure object holding all plot elements. 

366 cmap: 

367 Matplotlib colormap. 

368 grid_size: int, optional 

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

370 subplot: int, optional 

371 Subplot index if multiple spatial subplots in figure. 

372 

373 Returns 

374 ------- 

375 axes: 

376 Matplotlib GeoAxes definition. 

377 """ 

378 # Identify min/max plot bounds. 

379 try: 

380 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

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

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

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

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

385 

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

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

388 x1 = x1 - 180.0 

389 x2 = x2 - 180.0 

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

391 

392 # Consider map projection orientation. 

393 # Adapting orientation enables plotting across international dateline. 

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

395 if x2 > 180.0: 

396 central_longitude = 180.0 

397 else: 

398 central_longitude = 0.0 

399 

400 # Define spatial map projection. 

401 coord_system = cube.coord(lat_axis).coord_system 

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

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

404 projection = ccrs.RotatedPole( 

405 pole_longitude=coord_system.grid_north_pole_longitude, 

406 pole_latitude=coord_system.grid_north_pole_latitude, 

407 central_rotated_longitude=0.0, 

408 ) 

409 crs = projection 

410 else: 

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

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

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

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

415 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

416 crs = ccrs.PlateCarree() 

417 

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

419 if subplot is not None: 

420 axes = figure.add_subplot( 

421 grid_size, grid_size, subplot, projection=projection 

422 ) 

423 else: 

424 axes = figure.add_subplot(projection=projection) 

425 

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

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

428 coastcol = "magenta" 

429 else: 

430 coastcol = "black" 

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

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

433 

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

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

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

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

438 

439 except ValueError: 

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

441 axes = figure.gca() 

442 pass 

443 

444 return axes 

445 

446 

447def _get_plot_resolution() -> int: 

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

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

450 

451 

452def _plot_and_save_spatial_plot( 

453 cube: iris.cube.Cube, 

454 filename: str, 

455 title: str, 

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

457 **kwargs, 

458): 

459 """Plot and save a spatial plot. 

460 

461 Parameters 

462 ---------- 

463 cube: Cube 

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

465 filename: str 

466 Filename of the plot to write. 

467 title: str 

468 Plot title. 

469 method: "contourf" | "pcolormesh" 

470 The plotting method to use. 

471 """ 

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

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

474 

475 # Specify the color bar 

476 cmap, levels, norm = _colorbar_map_levels(cube) 

477 

478 # Setup plot map projection, extent and coastlines. 

479 axes = _setup_spatial_map(cube, fig, cmap) 

480 

481 # Plot the field. 

482 if method == "contourf": 

483 # Filled contour plot of the field. 

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

485 elif method == "pcolormesh": 

486 try: 

487 vmin = min(levels) 

488 vmax = max(levels) 

489 except TypeError: 

490 vmin, vmax = None, None 

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

492 # if levels are defined. 

493 if norm is not None: 

494 vmin = None 

495 vmax = None 

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

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

498 else: 

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

500 

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

502 if is_transect(cube): 

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

504 axes.invert_yaxis() 

505 axes.set_yscale("log") 

506 axes.set_ylim(1100, 100) 

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

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

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

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

511 ): 

512 axes.set_yscale("log") 

513 

514 axes.set_title( 

515 f"{title}\n" 

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

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

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

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

520 fontsize=16, 

521 ) 

522 

523 else: 

524 # Add title. 

525 axes.set_title(title, fontsize=16) 

526 

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

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

529 axes.annotate( 

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

531 xy=(1, -0.05), 

532 xycoords="axes fraction", 

533 xytext=(-5, 5), 

534 textcoords="offset points", 

535 ha="right", 

536 va="bottom", 

537 size=11, 

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

539 ) 

540 

541 # Add colour bar. 

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

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

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

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

546 cbar.set_ticks(levels) 

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

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

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

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

551 

552 # Save plot. 

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

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

555 plt.close(fig) 

556 

557 

558def _plot_and_save_postage_stamp_spatial_plot( 

559 cube: iris.cube.Cube, 

560 filename: str, 

561 stamp_coordinate: str, 

562 title: str, 

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

564 **kwargs, 

565): 

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

567 

568 Parameters 

569 ---------- 

570 cube: Cube 

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

572 filename: str 

573 Filename of the plot to write. 

574 stamp_coordinate: str 

575 Coordinate that becomes different plots. 

576 method: "contourf" | "pcolormesh" 

577 The plotting method to use. 

578 

579 Raises 

580 ------ 

581 ValueError 

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

583 """ 

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

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

586 

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

588 

589 # Specify the color bar 

590 cmap, levels, norm = _colorbar_map_levels(cube) 

591 

592 # Make a subplot for each member. 

593 for member, subplot in zip( 

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

595 ): 

596 # Setup subplot map projection, extent and coastlines. 

597 axes = _setup_spatial_map( 

598 member, fig, cmap, grid_size=grid_size, subplot=subplot 

599 ) 

600 if method == "contourf": 

601 # Filled contour plot of the field. 

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

603 elif method == "pcolormesh": 

604 if levels is not None: 

605 vmin = min(levels) 

606 vmax = max(levels) 

607 else: 

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

609 vmin, vmax = None, None 

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

611 # if levels are defined. 

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

613 vmin = None 

614 vmax = None 

615 # pcolormesh plot of the field. 

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

617 else: 

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

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

620 axes.set_axis_off() 

621 

622 # Put the shared colorbar in its own axes. 

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

624 colorbar = fig.colorbar( 

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

626 ) 

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

628 

629 # Overall figure title. 

630 fig.suptitle(title, fontsize=16) 

631 

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

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

634 plt.close(fig) 

635 

636 

637def _plot_and_save_line_series( 

638 cubes: iris.cube.CubeList, 

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

640 ensemble_coord: str, 

641 filename: str, 

642 title: str, 

643 **kwargs, 

644): 

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

646 

647 Parameters 

648 ---------- 

649 cubes: Cube or CubeList 

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

651 coords: list[Coord] 

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

653 ensemble_coord: str 

654 Ensemble coordinate in the cube. 

655 filename: str 

656 Filename of the plot to write. 

657 title: str 

658 Plot title. 

659 """ 

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

661 

662 model_colors_map = _get_model_colors_map(cubes) 

663 

664 # Store min/max ranges. 

665 y_levels = [] 

666 

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

668 _validate_cubes_coords(cubes, coords) 

669 

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

671 label = None 

672 color = "black" 

673 if model_colors_map: 

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

675 color = model_colors_map.get(label) 

676 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

679 iplt.plot( 

680 coord, 

681 cube_slice, 

682 color=color, 

683 marker="o", 

684 ls="-", 

685 lw=3, 

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

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

688 else label, 

689 ) 

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

691 else: 

692 iplt.plot( 

693 coord, 

694 cube_slice, 

695 color=color, 

696 ls="-", 

697 lw=1.5, 

698 alpha=0.75, 

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

700 ) 

701 

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

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

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

705 y_levels.append(min(levels)) 

706 y_levels.append(max(levels)) 

707 

708 # Get the current axes. 

709 ax = plt.gca() 

710 

711 # Add some labels and tweak the style. 

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

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

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

715 ax.set_title(title, fontsize=16) 

716 

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

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

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

720 

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

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

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

724 # Add zero line. 

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

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

727 logging.debug( 

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

729 ) 

730 else: 

731 ax.autoscale() 

732 

733 # Add gridlines 

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

735 # Ientify unique labels for legend 

736 handles = list( 

737 { 

738 label: handle 

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

740 }.values() 

741 ) 

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

743 

744 # Save plot. 

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

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

747 plt.close(fig) 

748 

749 

750def _plot_and_save_vertical_line_series( 

751 cubes: iris.cube.CubeList, 

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

753 ensemble_coord: str, 

754 filename: str, 

755 series_coordinate: str, 

756 title: str, 

757 vmin: float, 

758 vmax: float, 

759 **kwargs, 

760): 

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

762 

763 Parameters 

764 ---------- 

765 cubes: CubeList 

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

767 coord: list[Coord] 

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

769 ensemble_coord: str 

770 Ensemble coordinate in the cube. 

771 filename: str 

772 Filename of the plot to write. 

773 series_coordinate: str 

774 Coordinate to use as vertical axis. 

775 title: str 

776 Plot title. 

777 vmin: float 

778 Minimum value for the x-axis. 

779 vmax: float 

780 Maximum value for the x-axis. 

781 """ 

782 # plot the vertical pressure axis using log scale 

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

784 

785 model_colors_map = _get_model_colors_map(cubes) 

786 

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

788 _validate_cubes_coords(cubes, coords) 

789 

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

791 label = None 

792 color = "black" 

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

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

795 color = model_colors_map.get(label) 

796 

797 for cube_slice in cube.slices_over(ensemble_coord): 

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

799 # unless single forecast. 

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

801 iplt.plot( 

802 cube_slice, 

803 coord, 

804 color=color, 

805 marker="o", 

806 ls="-", 

807 lw=3, 

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

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

810 else label, 

811 ) 

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

813 else: 

814 iplt.plot( 

815 cube_slice, 

816 coord, 

817 color=color, 

818 ls="-", 

819 lw=1.5, 

820 alpha=0.75, 

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

822 ) 

823 

824 # Get the current axis 

825 ax = plt.gca() 

826 

827 # Special handling for pressure level data. 

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

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

830 ax.invert_yaxis() 

831 ax.set_yscale("log") 

832 

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

834 y_tick_labels = [ 

835 "1000", 

836 "850", 

837 "700", 

838 "500", 

839 "300", 

840 "200", 

841 "100", 

842 ] 

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

844 

845 # Set y-axis limits and ticks. 

846 ax.set_ylim(1100, 100) 

847 

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

849 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

855 

856 ax.set_yticks(y_ticks) 

857 ax.set_yticklabels(y_tick_labels) 

858 

859 # Set x-axis limits. 

860 ax.set_xlim(vmin, vmax) 

861 # Mark y=0 if present in plot. 

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

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

864 

865 # Add some labels and tweak the style. 

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

867 ax.set_xlabel( 

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

869 ) 

870 ax.set_title(title, fontsize=16) 

871 ax.ticklabel_format(axis="x") 

872 ax.tick_params(axis="y") 

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

874 

875 # Add gridlines 

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

877 # Ientify unique labels for legend 

878 handles = list( 

879 { 

880 label: handle 

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

882 }.values() 

883 ) 

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

885 

886 # Save plot. 

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

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

889 plt.close(fig) 

890 

891 

892def _plot_and_save_scatter_plot( 

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

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

895 filename: str, 

896 title: str, 

897 one_to_one: bool, 

898 **kwargs, 

899): 

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

901 

902 Parameters 

903 ---------- 

904 cube_x: Cube | CubeList 

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

906 cube_y: Cube | CubeList 

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

908 filename: str 

909 Filename of the plot to write. 

910 title: str 

911 Plot title. 

912 one_to_one: bool 

913 Whether a 1:1 line is plotted. 

914 """ 

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

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

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

918 # over the pairs simultaneously. 

919 

920 # Ensure cube_x and cube_y are iterable 

921 cube_x_iterable = iter_maybe(cube_x) 

922 cube_y_iterable = iter_maybe(cube_y) 

923 

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

925 iplt.scatter(cube_x_iter, cube_y_iter) 

926 if one_to_one is True: 

927 plt.plot( 

928 [ 

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

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

931 ], 

932 [ 

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

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

935 ], 

936 "k", 

937 linestyle="--", 

938 ) 

939 ax = plt.gca() 

940 

941 # Add some labels and tweak the style. 

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

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

944 ax.set_title(title, fontsize=16) 

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

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

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

948 ax.autoscale() 

949 

950 # Save plot. 

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

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

953 plt.close(fig) 

954 

955 

956def _plot_and_save_vector_plot( 

957 cube_u: iris.cube.Cube, 

958 cube_v: iris.cube.Cube, 

959 filename: str, 

960 title: str, 

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

962 **kwargs, 

963): 

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

965 

966 Parameters 

967 ---------- 

968 cube_u: Cube 

969 2 dimensional Cube of u component of the data. 

970 cube_v: Cube 

971 2 dimensional Cube of v component of the data. 

972 filename: str 

973 Filename of the plot to write. 

974 title: str 

975 Plot title. 

976 """ 

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

978 

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

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

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

982 

983 # Specify the color bar 

984 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag) 

985 

986 # Setup plot map projection, extent and coastlines. 

987 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

988 

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

990 # Filled contour plot of the field. 

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

992 elif method == "pcolormesh": 

993 try: 

994 vmin = min(levels) 

995 vmax = max(levels) 

996 except TypeError: 

997 vmin, vmax = None, None 

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

999 # if levels are defined. 

1000 if norm is not None: 

1001 vmin = None 

1002 vmax = None 

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

1004 else: 

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

1006 

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

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

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

1010 axes.invert_yaxis() 

1011 axes.set_yscale("log") 

1012 axes.set_ylim(1100, 100) 

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

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

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

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

1017 ): 

1018 axes.set_yscale("log") 

1019 

1020 axes.set_title( 

1021 f"{title}\n" 

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

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

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

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

1026 fontsize=16, 

1027 ) 

1028 

1029 else: 

1030 # Add title. 

1031 axes.set_title(title, fontsize=16) 

1032 

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

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

1035 axes.annotate( 

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

1037 xy=(1, -0.05), 

1038 xycoords="axes fraction", 

1039 xytext=(-5, 5), 

1040 textcoords="offset points", 

1041 ha="right", 

1042 va="bottom", 

1043 size=11, 

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

1045 ) 

1046 

1047 # Add colour bar. 

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

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

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

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

1052 cbar.set_ticks(levels) 

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

1054 

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

1056 # with less than 30 points. 

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

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

1059 

1060 # Save plot. 

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

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

1063 plt.close(fig) 

1064 

1065 

1066def _plot_and_save_histogram_series( 

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

1068 filename: str, 

1069 title: str, 

1070 vmin: float, 

1071 vmax: float, 

1072 **kwargs, 

1073): 

1074 """Plot and save a histogram series. 

1075 

1076 Parameters 

1077 ---------- 

1078 cubes: Cube or CubeList 

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

1080 filename: str 

1081 Filename of the plot to write. 

1082 title: str 

1083 Plot title. 

1084 vmin: float 

1085 minimum for colorbar 

1086 vmax: float 

1087 maximum for colorbar 

1088 """ 

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

1090 ax = plt.gca() 

1091 

1092 model_colors_map = _get_model_colors_map(cubes) 

1093 

1094 # Set default that histograms will produce probability density function 

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

1096 density = True 

1097 

1098 for cube in iter_maybe(cubes): 

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

1100 # than seeing if long names exist etc. 

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

1102 if "surface_microphysical" in title: 

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

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

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

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

1107 density = False 

1108 else: 

1109 bins = 10.0 ** ( 

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

1111 ) # Suggestion from RMED toolbox. 

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

1113 ax.set_yscale("log") 

1114 vmin = bins[1] 

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

1116 ax.set_xscale("log") 

1117 elif "lightning" in title: 

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

1119 else: 

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

1121 logging.debug( 

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

1123 np.size(bins), 

1124 np.min(bins), 

1125 np.max(bins), 

1126 ) 

1127 

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

1129 # Otherwise we plot xdim histograms stacked. 

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

1131 

1132 label = None 

1133 color = "black" 

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

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

1136 color = model_colors_map[label] 

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

1138 

1139 # Compute area under curve. 

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

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

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

1143 x = x[1:] 

1144 y = y[1:] 

1145 

1146 ax.plot( 

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

1148 ) 

1149 

1150 # Add some labels and tweak the style. 

1151 ax.set_title(title, fontsize=16) 

1152 ax.set_xlabel( 

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

1154 ) 

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

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

1157 ax.set_ylabel( 

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

1159 ) 

1160 ax.set_xlim(vmin, vmax) 

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

1162 

1163 # Overlay grid-lines onto histogram plot. 

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

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

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

1167 

1168 # Save plot. 

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

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

1171 plt.close(fig) 

1172 

1173 

1174def _plot_and_save_postage_stamp_histogram_series( 

1175 cube: iris.cube.Cube, 

1176 filename: str, 

1177 title: str, 

1178 stamp_coordinate: str, 

1179 vmin: float, 

1180 vmax: float, 

1181 **kwargs, 

1182): 

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

1184 

1185 Parameters 

1186 ---------- 

1187 cube: Cube 

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

1189 filename: str 

1190 Filename of the plot to write. 

1191 title: str 

1192 Plot title. 

1193 stamp_coordinate: str 

1194 Coordinate that becomes different plots. 

1195 vmin: float 

1196 minimum for pdf x-axis 

1197 vmax: float 

1198 maximum for pdf x-axis 

1199 """ 

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

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

1202 

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

1204 # Make a subplot for each member. 

1205 for member, subplot in zip( 

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

1207 ): 

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

1209 # cartopy GeoAxes generated. 

1210 plt.subplot(grid_size, grid_size, subplot) 

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

1212 # Otherwise we plot xdim histograms stacked. 

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

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

1215 ax = plt.gca() 

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

1217 ax.set_xlim(vmin, vmax) 

1218 

1219 # Overall figure title. 

1220 fig.suptitle(title, fontsize=16) 

1221 

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

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

1224 plt.close(fig) 

1225 

1226 

1227def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1228 cube: iris.cube.Cube, 

1229 filename: str, 

1230 title: str, 

1231 stamp_coordinate: str, 

1232 vmin: float, 

1233 vmax: float, 

1234 **kwargs, 

1235): 

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

1237 ax.set_title(title, fontsize=16) 

1238 ax.set_xlim(vmin, vmax) 

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

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

1241 # Loop over all slices along the stamp_coordinate 

1242 for member in cube.slices_over(stamp_coordinate): 

1243 # Flatten the member data to 1D 

1244 member_data_1d = member.data.flatten() 

1245 # Plot the histogram using plt.hist 

1246 plt.hist( 

1247 member_data_1d, 

1248 density=True, 

1249 stacked=True, 

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

1251 ) 

1252 

1253 # Add a legend 

1254 ax.legend(fontsize=16) 

1255 

1256 # Save the figure to a file 

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

1258 

1259 # Close the figure 

1260 plt.close(fig) 

1261 

1262 

1263def _spatial_plot( 

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

1265 cube: iris.cube.Cube, 

1266 filename: str | None, 

1267 sequence_coordinate: str, 

1268 stamp_coordinate: str, 

1269): 

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

1271 

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

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

1274 is present then postage stamp plots will be produced. 

1275 

1276 Parameters 

1277 ---------- 

1278 method: "contourf" | "pcolormesh" 

1279 The plotting method to use. 

1280 cube: Cube 

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

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

1283 plotted sequentially and/or as postage stamp plots. 

1284 filename: str | None 

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

1286 uses the recipe name. 

1287 sequence_coordinate: str 

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

1289 This coordinate must exist in the cube. 

1290 stamp_coordinate: str 

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

1292 ``"realization"``. 

1293 

1294 Raises 

1295 ------ 

1296 ValueError 

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

1298 TypeError 

1299 If the cube isn't a single cube. 

1300 """ 

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

1302 

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

1304 if filename is None: 

1305 filename = slugify(recipe_title) 

1306 

1307 # Ensure we've got a single cube. 

1308 cube = _check_single_cube(cube) 

1309 

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

1311 # single point. 

1312 plotting_func = _plot_and_save_spatial_plot 

1313 try: 

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

1315 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1316 except iris.exceptions.CoordinateNotFoundError: 

1317 pass 

1318 

1319 # Must have a sequence coordinate. 

1320 try: 

1321 cube.coord(sequence_coordinate) 

1322 except iris.exceptions.CoordinateNotFoundError as err: 

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

1324 

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

1326 plot_index = [] 

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

1328 for cube_slice in cube.slices_over(sequence_coordinate): 

1329 # Use sequence value so multiple sequences can merge. 

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

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

1332 coord = cube_slice.coord(sequence_coordinate) 

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

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

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

1336 if nplot == 1 and coord.has_bounds: 

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

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

1339 # Do the actual plotting. 

1340 plotting_func( 

1341 cube_slice, 

1342 filename=plot_filename, 

1343 stamp_coordinate=stamp_coordinate, 

1344 title=title, 

1345 method=method, 

1346 ) 

1347 plot_index.append(plot_filename) 

1348 

1349 # Add list of plots to plot metadata. 

1350 complete_plot_index = _append_to_plot_index(plot_index) 

1351 

1352 # Make a page to display the plots. 

1353 _make_plot_html_page(complete_plot_index) 

1354 

1355 

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

1357 """Get colourmap for mask. 

1358 

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

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

1361 

1362 Parameters 

1363 ---------- 

1364 cube: Cube 

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

1366 axis: "x", "y", optional 

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

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

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

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

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

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

1373 

1374 Returns 

1375 ------- 

1376 cmap: 

1377 Matplotlib colormap. 

1378 levels: 

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

1380 should be taken as the range. 

1381 norm: 

1382 BoundaryNorm information. 

1383 """ 

1384 if "difference" not in cube.long_name: 

1385 if axis: 

1386 levels = [0, 1] 

1387 # Complete settings based on levels. 

1388 return None, levels, None 

1389 else: 

1390 # Define the levels and colors. 

1391 levels = [0, 1, 2] 

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

1393 # Create a custom color map. 

1394 cmap = mcolors.ListedColormap(colors) 

1395 # Normalize the levels. 

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

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

1398 return cmap, levels, norm 

1399 else: 

1400 if axis: 

1401 levels = [-1, 1] 

1402 return None, levels, None 

1403 else: 

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

1405 # not <=. 

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

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

1408 cmap = mcolors.ListedColormap(colors) 

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

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

1411 return cmap, levels, norm 

1412 

1413 

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

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

1416 

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

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

1419 

1420 Parameters 

1421 ---------- 

1422 cube: Cube 

1423 Cube of variable with Beaufort Scale in name. 

1424 axis: "x", "y", optional 

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

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

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

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

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

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

1431 

1432 Returns 

1433 ------- 

1434 cmap: 

1435 Matplotlib colormap. 

1436 levels: 

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

1438 should be taken as the range. 

1439 norm: 

1440 BoundaryNorm information. 

1441 """ 

1442 if "difference" not in cube.long_name: 

1443 if axis: 

1444 levels = [0, 12] 

1445 return None, levels, None 

1446 else: 

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

1448 colors = [ 

1449 "black", 

1450 (0, 0, 0.6), 

1451 "blue", 

1452 "cyan", 

1453 "green", 

1454 "yellow", 

1455 (1, 0.5, 0), 

1456 "red", 

1457 "pink", 

1458 "magenta", 

1459 "purple", 

1460 "maroon", 

1461 "white", 

1462 ] 

1463 cmap = mcolors.ListedColormap(colors) 

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

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

1466 return cmap, levels, norm 

1467 else: 

1468 if axis: 

1469 levels = [-4, 4] 

1470 return None, levels, None 

1471 else: 

1472 levels = [ 

1473 -3.5, 

1474 -2.5, 

1475 -1.5, 

1476 -0.5, 

1477 0.5, 

1478 1.5, 

1479 2.5, 

1480 3.5, 

1481 ] 

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

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

1484 return cmap, levels, norm 

1485 

1486 

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

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

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

1490 if ( 

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

1492 and "difference" not in cube.long_name 

1493 and "mask" not in cube.long_name 

1494 ): 

1495 # Define the levels and colors 

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

1497 colors = [ 

1498 "w", 

1499 (0, 0, 0.6), 

1500 "b", 

1501 "c", 

1502 "g", 

1503 "y", 

1504 (1, 0.5, 0), 

1505 "r", 

1506 "pink", 

1507 "m", 

1508 "purple", 

1509 "maroon", 

1510 "gray", 

1511 ] 

1512 # Create a custom colormap 

1513 cmap = mcolors.ListedColormap(colors) 

1514 # Normalize the levels 

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

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

1517 else: 

1518 # do nothing and keep existing colorbar attributes 

1519 cmap = cmap 

1520 levels = levels 

1521 norm = norm 

1522 return cmap, levels, norm 

1523 

1524 

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

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

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

1528 if ( 

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

1530 and "difference" not in cube.long_name 

1531 and "mask" not in cube.long_name 

1532 ): 

1533 # Define the levels and colors (in km) 

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

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

1536 colours = [ 

1537 "#8f00d6", 

1538 "#d10000", 

1539 "#ff9700", 

1540 "#ffff00", 

1541 "#00007f", 

1542 "#6c9ccd", 

1543 "#aae8ff", 

1544 "#37a648", 

1545 "#8edc64", 

1546 "#c5ffc5", 

1547 "#dcdcdc", 

1548 "#ffffff", 

1549 ] 

1550 # Create a custom colormap 

1551 cmap = mcolors.ListedColormap(colours) 

1552 # Normalize the levels 

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

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

1555 else: 

1556 # do nothing and keep existing colorbar attributes 

1557 cmap = cmap 

1558 levels = levels 

1559 norm = norm 

1560 return cmap, levels, norm 

1561 

1562 

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

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

1565 model_names = list( 

1566 filter( 

1567 lambda x: x is not None, 

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

1569 ) 

1570 ) 

1571 if not model_names: 

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

1573 return 1 

1574 else: 

1575 return len(model_names) 

1576 

1577 

1578def _validate_cube_shape( 

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

1580) -> None: 

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

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

1583 raise ValueError( 

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

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

1586 ) 

1587 

1588 

1589def _validate_cubes_coords( 

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

1591) -> None: 

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

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

1594 raise ValueError( 

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

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

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

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

1599 ) 

1600 

1601 

1602#################### 

1603# Public functions # 

1604#################### 

1605 

1606 

1607def spatial_contour_plot( 

1608 cube: iris.cube.Cube, 

1609 filename: str = None, 

1610 sequence_coordinate: str = "time", 

1611 stamp_coordinate: str = "realization", 

1612 **kwargs, 

1613) -> iris.cube.Cube: 

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

1615 

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

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

1618 is present then postage stamp plots will be produced. 

1619 

1620 Parameters 

1621 ---------- 

1622 cube: Cube 

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

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

1625 plotted sequentially and/or as postage stamp plots. 

1626 filename: str, optional 

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

1628 to the recipe name. 

1629 sequence_coordinate: str, optional 

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

1631 This coordinate must exist in the cube. 

1632 stamp_coordinate: str, optional 

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

1634 ``"realization"``. 

1635 

1636 Returns 

1637 ------- 

1638 Cube 

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

1640 

1641 Raises 

1642 ------ 

1643 ValueError 

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

1645 TypeError 

1646 If the cube isn't a single cube. 

1647 """ 

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

1649 return cube 

1650 

1651 

1652def spatial_pcolormesh_plot( 

1653 cube: iris.cube.Cube, 

1654 filename: str = None, 

1655 sequence_coordinate: str = "time", 

1656 stamp_coordinate: str = "realization", 

1657 **kwargs, 

1658) -> iris.cube.Cube: 

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

1660 

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

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

1663 is present then postage stamp plots will be produced. 

1664 

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

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

1667 contour areas are important. 

1668 

1669 Parameters 

1670 ---------- 

1671 cube: Cube 

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

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

1674 plotted sequentially and/or as postage stamp plots. 

1675 filename: str, optional 

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

1677 to the recipe name. 

1678 sequence_coordinate: str, optional 

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

1680 This coordinate must exist in the cube. 

1681 stamp_coordinate: str, optional 

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

1683 ``"realization"``. 

1684 

1685 Returns 

1686 ------- 

1687 Cube 

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

1689 

1690 Raises 

1691 ------ 

1692 ValueError 

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

1694 TypeError 

1695 If the cube isn't a single cube. 

1696 """ 

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

1698 return cube 

1699 

1700 

1701# TODO: Expand function to handle ensemble data. 

1702# line_coordinate: str, optional 

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

1704# ``"realization"``. 

1705def plot_line_series( 

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

1707 filename: str = None, 

1708 series_coordinate: str = "time", 

1709 # line_coordinate: str = "realization", 

1710 **kwargs, 

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

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

1713 

1714 The Cube or CubeList must be 1D. 

1715 

1716 Parameters 

1717 ---------- 

1718 iris.cube | iris.cube.CubeList 

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

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

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

1722 filename: str, optional 

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

1724 to the recipe name. 

1725 series_coordinate: str, optional 

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

1727 coordinate must exist in the cube. 

1728 

1729 Returns 

1730 ------- 

1731 iris.cube.Cube | iris.cube.CubeList 

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

1733 plotted data. 

1734 

1735 Raises 

1736 ------ 

1737 ValueError 

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

1739 TypeError 

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

1741 """ 

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

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

1744 

1745 if filename is None: 

1746 filename = slugify(title) 

1747 

1748 # Add file extension. 

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

1750 

1751 num_models = _get_num_models(cube) 

1752 

1753 _validate_cube_shape(cube, num_models) 

1754 

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

1756 cubes = iter_maybe(cube) 

1757 coords = [] 

1758 for cube in cubes: 

1759 try: 

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

1761 except iris.exceptions.CoordinateNotFoundError as err: 

1762 raise ValueError( 

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

1764 ) from err 

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

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

1767 

1768 # Do the actual plotting. 

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

1770 

1771 # Add list of plots to plot metadata. 

1772 plot_index = _append_to_plot_index([plot_filename]) 

1773 

1774 # Make a page to display the plots. 

1775 _make_plot_html_page(plot_index) 

1776 

1777 return cube 

1778 

1779 

1780def plot_vertical_line_series( 

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

1782 filename: str = None, 

1783 series_coordinate: str = "model_level_number", 

1784 sequence_coordinate: str = "time", 

1785 # line_coordinate: str = "realization", 

1786 **kwargs, 

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

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

1789 

1790 The Cube or CubeList must be 1D. 

1791 

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

1793 then a sequence of plots will be produced. 

1794 

1795 Parameters 

1796 ---------- 

1797 iris.cube | iris.cube.CubeList 

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

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

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

1801 filename: str, optional 

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

1803 to the recipe name. 

1804 series_coordinate: str, optional 

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

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

1807 for LFRic. Defaults to ``model_level_number``. 

1808 This coordinate must exist in the cube. 

1809 sequence_coordinate: str, optional 

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

1811 This coordinate must exist in the cube. 

1812 

1813 Returns 

1814 ------- 

1815 iris.cube.Cube | iris.cube.CubeList 

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

1817 Plotted data. 

1818 

1819 Raises 

1820 ------ 

1821 ValueError 

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

1823 TypeError 

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

1825 """ 

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

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

1828 

1829 if filename is None: 

1830 filename = slugify(recipe_title) 

1831 

1832 cubes = iter_maybe(cubes) 

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

1834 all_data = [] 

1835 

1836 # Store min/max ranges for x range. 

1837 x_levels = [] 

1838 

1839 num_models = _get_num_models(cubes) 

1840 

1841 _validate_cube_shape(cubes, num_models) 

1842 

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

1844 coords = [] 

1845 for cube in cubes: 

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

1847 try: 

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

1849 except iris.exceptions.CoordinateNotFoundError as err: 

1850 raise ValueError( 

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

1852 ) from err 

1853 

1854 try: 

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

1856 cube.coord(sequence_coordinate) 

1857 except iris.exceptions.CoordinateNotFoundError as err: 

1858 raise ValueError( 

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

1860 ) from err 

1861 

1862 # Get minimum and maximum from levels information. 

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

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

1865 x_levels.append(min(levels)) 

1866 x_levels.append(max(levels)) 

1867 else: 

1868 all_data.append(cube.data) 

1869 

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

1871 # Combine all data into a single NumPy array 

1872 combined_data = np.concatenate(all_data) 

1873 

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

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

1876 # sequence and if applicable postage stamp coordinate. 

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

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

1879 else: 

1880 vmin = min(x_levels) 

1881 vmax = max(x_levels) 

1882 

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

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

1885 # necessary) 

1886 def filter_cube_iterables(cube_iterables) -> bool: 

1887 return len(cube_iterables) == len(coords) 

1888 

1889 cube_iterables = filter( 

1890 filter_cube_iterables, 

1891 ( 

1892 iris.cube.CubeList( 

1893 s 

1894 for s in itertools.chain.from_iterable( 

1895 cb.slices_over(sequence_coordinate) for cb in cubes 

1896 ) 

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

1898 ) 

1899 for point in sorted( 

1900 set( 

1901 itertools.chain.from_iterable( 

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

1903 ) 

1904 ) 

1905 ) 

1906 ), 

1907 ) 

1908 

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

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

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

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

1913 # or an iris.cube.CubeList. 

1914 plot_index = [] 

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

1916 for cubes_slice in cube_iterables: 

1917 # Use sequence value so multiple sequences can merge. 

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

1919 sequence_value = seq_coord.points[0] 

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

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

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

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

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

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

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

1927 # Do the actual plotting. 

1928 _plot_and_save_vertical_line_series( 

1929 cubes_slice, 

1930 coords, 

1931 "realization", 

1932 plot_filename, 

1933 series_coordinate, 

1934 title=title, 

1935 vmin=vmin, 

1936 vmax=vmax, 

1937 ) 

1938 plot_index.append(plot_filename) 

1939 

1940 # Add list of plots to plot metadata. 

1941 complete_plot_index = _append_to_plot_index(plot_index) 

1942 

1943 # Make a page to display the plots. 

1944 _make_plot_html_page(complete_plot_index) 

1945 

1946 return cubes 

1947 

1948 

1949def scatter_plot( 

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

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

1952 filename: str = None, 

1953 one_to_one: bool = True, 

1954 **kwargs, 

1955) -> iris.cube.CubeList: 

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

1957 

1958 Both cubes must be 1D. 

1959 

1960 Parameters 

1961 ---------- 

1962 cube_x: Cube | CubeList 

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

1964 cube_y: Cube | CubeList 

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

1966 filename: str, optional 

1967 Filename of the plot to write. 

1968 one_to_one: bool, optional 

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

1970 

1971 Returns 

1972 ------- 

1973 cubes: CubeList 

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

1975 

1976 Raises 

1977 ------ 

1978 ValueError 

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

1980 size. 

1981 TypeError 

1982 If the cube isn't a single cube. 

1983 

1984 Notes 

1985 ----- 

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

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

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

1989 

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

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

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

1993 observations and other models. Identical percentiles between the variables 

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

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

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

1997 Wilks 2011 [Wilks2011]_). 

1998 

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

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

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

2002 the extremes. 

2003 

2004 References 

2005 ---------- 

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

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

2008 """ 

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

2010 for cube_iter in iter_maybe(cube_x): 

2011 # Check cubes are correct shape. 

2012 cube_iter = _check_single_cube(cube_iter) 

2013 if cube_iter.ndim > 1: 

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

2015 

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

2017 for cube_iter in iter_maybe(cube_y): 

2018 # Check cubes are correct shape. 

2019 cube_iter = _check_single_cube(cube_iter) 

2020 if cube_iter.ndim > 1: 

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

2022 

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

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

2025 

2026 if filename is None: 

2027 filename = slugify(title) 

2028 

2029 # Add file extension. 

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

2031 

2032 # Do the actual plotting. 

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

2034 

2035 # Add list of plots to plot metadata. 

2036 plot_index = _append_to_plot_index([plot_filename]) 

2037 

2038 # Make a page to display the plots. 

2039 _make_plot_html_page(plot_index) 

2040 

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

2042 

2043 

2044def vector_plot( 

2045 cube_u: iris.cube.Cube, 

2046 cube_v: iris.cube.Cube, 

2047 filename: str = None, 

2048 sequence_coordinate: str = "time", 

2049 **kwargs, 

2050) -> iris.cube.CubeList: 

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

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

2053 

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

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

2056 filename = slugify(recipe_title) 

2057 

2058 # Cubes must have a matching sequence coordinate. 

2059 try: 

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

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

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

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

2064 raise ValueError( 

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

2066 ) from err 

2067 

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

2069 plot_index = [] 

2070 for cube_u_slice, cube_v_slice in zip( 

2071 cube_u.slices_over(sequence_coordinate), 

2072 cube_v.slices_over(sequence_coordinate), 

2073 strict=True, 

2074 ): 

2075 # Use sequence value so multiple sequences can merge. 

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

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

2078 coord = cube_u_slice.coord(sequence_coordinate) 

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

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

2081 # Do the actual plotting. 

2082 _plot_and_save_vector_plot( 

2083 cube_u_slice, 

2084 cube_v_slice, 

2085 filename=plot_filename, 

2086 title=title, 

2087 method="contourf", 

2088 ) 

2089 plot_index.append(plot_filename) 

2090 

2091 # Add list of plots to plot metadata. 

2092 complete_plot_index = _append_to_plot_index(plot_index) 

2093 

2094 # Make a page to display the plots. 

2095 _make_plot_html_page(complete_plot_index) 

2096 

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

2098 

2099 

2100def plot_histogram_series( 

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

2102 filename: str = None, 

2103 sequence_coordinate: str = "time", 

2104 stamp_coordinate: str = "realization", 

2105 single_plot: bool = False, 

2106 **kwargs, 

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

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

2109 

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

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

2112 functionality to scroll through histograms against time. If a 

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

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

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

2116 

2117 Parameters 

2118 ---------- 

2119 cubes: Cube | iris.cube.CubeList 

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

2121 than the stamp coordinate. 

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

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

2124 filename: str, optional 

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

2126 to the recipe name. 

2127 sequence_coordinate: str, optional 

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

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

2130 slider. 

2131 stamp_coordinate: str, optional 

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

2133 ``"realization"``. 

2134 single_plot: bool, optional 

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

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

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

2138 

2139 Returns 

2140 ------- 

2141 iris.cube.Cube | iris.cube.CubeList 

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

2143 Plotted data. 

2144 

2145 Raises 

2146 ------ 

2147 ValueError 

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

2149 TypeError 

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

2151 """ 

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

2153 

2154 cubes = iter_maybe(cubes) 

2155 

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

2157 if filename is None: 

2158 filename = slugify(recipe_title) 

2159 

2160 # Internal plotting function. 

2161 plotting_func = _plot_and_save_histogram_series 

2162 

2163 num_models = _get_num_models(cubes) 

2164 

2165 _validate_cube_shape(cubes, num_models) 

2166 

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

2168 # time slider option. 

2169 for cube in cubes: 

2170 try: 

2171 cube.coord(sequence_coordinate) 

2172 except iris.exceptions.CoordinateNotFoundError as err: 

2173 raise ValueError( 

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

2175 ) from err 

2176 

2177 # Get minimum and maximum from levels information. 

2178 levels = None 

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

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

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

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

2183 if levels is None: 

2184 break 

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

2186 # levels-based ranges for histogram plots. 

2187 _, levels, _ = _colorbar_map_levels(cube) 

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

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

2190 vmin = min(levels) 

2191 vmax = max(levels) 

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

2193 break 

2194 

2195 if levels is None: 

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

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

2198 

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

2200 # single point. If single_plot is True: 

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

2202 # separate postage stamp plots. 

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

2204 # produced per single model only 

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

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

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

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

2209 ): 

2210 if single_plot: 

2211 plotting_func = ( 

2212 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

2213 ) 

2214 else: 

2215 plotting_func = _plot_and_save_postage_stamp_histogram_series 

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

2217 else: 

2218 all_points = sorted( 

2219 set( 

2220 itertools.chain.from_iterable( 

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

2222 ) 

2223 ) 

2224 ) 

2225 all_slices = list( 

2226 itertools.chain.from_iterable( 

2227 cb.slices_over(sequence_coordinate) for cb in cubes 

2228 ) 

2229 ) 

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

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

2232 # necessary) 

2233 cube_iterables = [ 

2234 iris.cube.CubeList( 

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

2236 ) 

2237 for point in all_points 

2238 ] 

2239 

2240 plot_index = [] 

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

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

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

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

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

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

2247 for cube_slice in cube_iterables: 

2248 single_cube = cube_slice 

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

2250 single_cube = cube_slice[0] 

2251 

2252 # Use sequence value so multiple sequences can merge. 

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

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

2255 coord = single_cube.coord(sequence_coordinate) 

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

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

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

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

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

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

2262 # Do the actual plotting. 

2263 plotting_func( 

2264 cube_slice, 

2265 filename=plot_filename, 

2266 stamp_coordinate=stamp_coordinate, 

2267 title=title, 

2268 vmin=vmin, 

2269 vmax=vmax, 

2270 ) 

2271 plot_index.append(plot_filename) 

2272 

2273 # Add list of plots to plot metadata. 

2274 complete_plot_index = _append_to_plot_index(plot_index) 

2275 

2276 # Make a page to display the plots. 

2277 _make_plot_html_page(complete_plot_index) 

2278 

2279 return cubes