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

756 statements  

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

1# © Crown copyright, Met Office (2022-2025) and CSET contributors. 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"); 

4# you may not use this file except in compliance with the License. 

5# You may obtain a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, 

11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

12# See the License for the specific language governing permissions and 

13# limitations under the License. 

14 

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

16 

17import fcntl 

18import functools 

19import importlib.resources 

20import itertools 

21import json 

22import logging 

23import math 

24import os 

25import sys 

26from typing import Literal 

27 

28import cartopy.crs as ccrs 

29import iris 

30import iris.coords 

31import iris.cube 

32import iris.exceptions 

33import iris.plot as iplt 

34import matplotlib as mpl 

35import matplotlib.colors as mcolors 

36import matplotlib.pyplot as plt 

37import numpy as np 

38from markdown_it import MarkdownIt 

39 

40from CSET._common import ( 

41 combine_dicts, 

42 get_recipe_metadata, 

43 iter_maybe, 

44 render_file, 

45 slugify, 

46) 

47from CSET.operators._utils import get_cube_yxcoordname, is_transect 

48 

49# Use a non-interactive plotting backend. 

50mpl.use("agg") 

51 

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

53 

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

55# Private helper functions # 

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

57 

58 

59def _append_to_plot_index(plot_index: list) -> list: 

60 """Add plots into the plot index, returning the complete plot index.""" 

61 with open("meta.json", "r+t", encoding="UTF-8") as fp: 

62 fcntl.flock(fp, fcntl.LOCK_EX) 

63 fp.seek(0) 

64 meta = json.load(fp) 

65 complete_plot_index = meta.get("plots", []) 

66 complete_plot_index = complete_plot_index + plot_index 

67 meta["plots"] = complete_plot_index 

68 if os.getenv("CYLC_TASK_CYCLE_POINT") and not bool( 

69 os.getenv("DO_CASE_AGGREGATION") 

70 ): 

71 meta["case_date"] = os.getenv("CYLC_TASK_CYCLE_POINT", "") 

72 fp.seek(0) 

73 fp.truncate() 

74 json.dump(meta, fp, indent=2) 

75 return complete_plot_index 

76 

77 

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

79 """Ensure a single cube is given. 

80 

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

82 otherwise an error is raised. 

83 

84 Parameters 

85 ---------- 

86 cube: Cube | CubeList 

87 The cube to check. 

88 

89 Returns 

90 ------- 

91 cube: Cube 

92 The checked cube. 

93 

94 Raises 

95 ------ 

96 TypeError 

97 If the input cube is not a Cube or CubeList of a single Cube. 

98 """ 

99 if isinstance(cube, iris.cube.Cube): 

100 return cube 

101 if isinstance(cube, iris.cube.CubeList): 

102 if len(cube) == 1: 

103 return cube[0] 

104 raise TypeError("Must have a single cube", cube) 

105 

106 

107def _py312_importlib_resources_files_shim(): 

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

109 

110 This shim is needed until python 3.12 is our oldest supported version, after 

111 which it can just be replaced by directly using importlib.resources.files. 

112 """ 

113 if sys.version_info.minor >= 12: 

114 files = importlib.resources.files() 

115 else: 

116 import CSET.operators 

117 

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

119 return files 

120 

121 

122def _make_plot_html_page(plots: list): 

123 """Create a HTML page to display a plot image.""" 

124 # Debug check that plots actually contains some strings. 

125 assert isinstance(plots[0], str) 

126 

127 # Load HTML template file. 

128 operator_files = _py312_importlib_resources_files_shim() 

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

130 

131 # Get some metadata. 

132 meta = get_recipe_metadata() 

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

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

135 

136 # Prepare template variables. 

137 variables = { 

138 "title": title, 

139 "description": description, 

140 "initial_plot": plots[0], 

141 "plots": plots, 

142 "title_slug": slugify(title), 

143 } 

144 

145 # Render template. 

146 html = render_file(template_file, **variables) 

147 

148 # Save completed HTML. 

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

150 fp.write(html) 

151 

152 

153@functools.cache 

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

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

156 

157 This is a separate function to make it cacheable. 

158 """ 

159 colorbar_file = _py312_importlib_resources_files_shim().joinpath( 

160 "_colorbar_definition.json" 

161 ) 

162 with open(colorbar_file, "rt", encoding="UTF-8") as fp: 

163 colorbar = json.load(fp) 

164 

165 logging.debug("User colour bar file: %s", user_colorbar_file) 

166 override_colorbar = {} 

167 if user_colorbar_file: 

168 try: 

169 with open(user_colorbar_file, "rt", encoding="UTF-8") as fp: 

170 override_colorbar = json.load(fp) 

171 except FileNotFoundError: 

172 logging.warning("Colorbar file does not exist. Using default values.") 

173 

174 # Overwrite values with the user supplied colorbar definition. 

175 colorbar = combine_dicts(colorbar, override_colorbar) 

176 return colorbar 

177 

178 

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

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

181 

182 For each model in the list of cubes colors either from user provided 

183 color definition file (so-called style file) or from default colors are mapped 

184 to model_name attribute. 

185 

186 Parameters 

187 ---------- 

188 cubes: CubeList or Cube 

189 Cubes with model_name attribute 

190 

191 Returns 

192 ------- 

193 model_colors_map: 

194 Dictionary mapping model_name attribute to colors 

195 """ 

196 user_colorbar_file = get_recipe_metadata().get("style_file_path", None) 

197 colorbar = _load_colorbar_map(user_colorbar_file) 

198 model_names = sorted( 

199 filter( 

200 lambda x: x is not None, 

201 (cube.attributes.get("model_name", None) for cube in iter_maybe(cubes)), 

202 ) 

203 ) 

204 if not model_names: 

205 return {} 

206 use_user_colors = all(mname in colorbar.keys() for mname in model_names) 

207 if use_user_colors: 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true

208 return {mname: colorbar[mname] for mname in model_names} 

209 

210 color_list = itertools.cycle(DEFAULT_DISCRETE_COLORS) 

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

212 

213 

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

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

216 

217 For the given variable the appropriate colorbar is looked up from a 

218 combination of the built-in CSET colorbar definitions, and any user supplied 

219 definitions. As well as varying on variables, these definitions may also 

220 exist for specific pressure levels to account for variables with 

221 significantly different ranges at different heights. The colorbars also exist 

222 for masks and mask differences for considering variable presence diagnostics. 

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

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

225 

226 Parameters 

227 ---------- 

228 cube: Cube 

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

230 axis: "x", "y", optional 

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

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

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

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

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

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

237 

238 Returns 

239 ------- 

240 cmap: 

241 Matplotlib colormap. 

242 levels: 

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

244 should be taken as the range. 

245 norm: 

246 BoundaryNorm information. 

247 """ 

248 # Grab the colorbar file from the recipe global metadata. 

249 user_colorbar_file = get_recipe_metadata().get("style_file_path", None) 

250 colorbar = _load_colorbar_map(user_colorbar_file) 

251 cmap = None 

252 

253 try: 

254 # We assume that pressure is a scalar coordinate here. 

255 pressure_level_raw = cube.coord("pressure").points[0] 

256 # Ensure pressure_level is a string, as it is used as a JSON key. 

257 pressure_level = str(int(pressure_level_raw)) 

258 except iris.exceptions.CoordinateNotFoundError: 

259 pressure_level = None 

260 

261 # First try long name, then standard name, then var name. This order is used 

262 # as long name is the one we correct between models, so it most likely to be 

263 # consistent. 

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

265 for varname in varnames: 

266 # Get the colormap for this variable. 

267 try: 

268 var_colorbar = colorbar[varname] 

269 cmap = plt.get_cmap(colorbar[varname]["cmap"], 51) 

270 varname_key = varname 

271 break 

272 except KeyError: 

273 logging.debug("Cube name %s has no colorbar definition.", varname) 

274 

275 # Get colormap if it is a mask. 

276 if any("mask_for_" in name for name in varnames): 

277 cmap, levels, norm = _custom_colormap_mask(cube, axis=axis) 

278 return cmap, levels, norm 

279 # If winds on Beaufort Scale use custom colorbar and levels 

280 if any("Beaufort_Scale" in name for name in varnames): 

281 cmap, levels, norm = _custom_beaufort_scale(cube, axis=axis) 

282 return cmap, levels, norm 

283 # If probability is plotted use custom colorbar and levels 

284 if any("probability_of_" in name for name in varnames): 

285 cmap, levels, norm = _custom_colormap_probability(cube, axis=axis) 

286 return cmap, levels, norm 

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

288 if not cmap: 

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

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

291 return cmap, levels, norm 

292 

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

294 if pressure_level: 

295 try: 

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

297 except KeyError: 

298 logging.debug( 

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

300 varname, 

301 pressure_level, 

302 ) 

303 

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

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

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

307 if axis: 

308 if axis == "x": 

309 try: 

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

311 except KeyError: 

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

313 if axis == "y": 

314 try: 

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

316 except KeyError: 

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

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

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

320 levels = None 

321 else: 

322 levels = [vmin, vmax] 

323 return None, levels, None 

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

325 else: 

326 try: 

327 levels = var_colorbar["levels"] 

328 # Use discrete bins when levels are specified, rather 

329 # than a smooth range. 

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

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

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

333 except KeyError: 

334 # Get the range for this variable. 

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

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

337 # Calculate levels from range. 

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

339 norm = None 

340 

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

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

343 # JSON file. 

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

345 cmap, levels, norm = _custom_colourmap_visibility_in_air( 

346 cube, cmap, levels, norm 

347 ) 

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

349 return cmap, levels, norm 

350 

351 

352def _setup_spatial_map( 

353 cube: iris.cube.Cube, 

354 figure, 

355 cmap, 

356 grid_size: int | None = None, 

357 subplot: int | None = None, 

358): 

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

360 

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

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

363 

364 Parameters 

365 ---------- 

366 cube: Cube 

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

368 figure: 

369 Matplotlib Figure object holding all plot elements. 

370 cmap: 

371 Matplotlib colormap. 

372 grid_size: int, optional 

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

374 subplot: int, optional 

375 Subplot index if multiple spatial subplots in figure. 

376 

377 Returns 

378 ------- 

379 axes: 

380 Matplotlib GeoAxes definition. 

381 """ 

382 # Identify min/max plot bounds. 

383 try: 

384 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

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

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

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

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

389 

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

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

392 x1 = x1 - 180.0 

393 x2 = x2 - 180.0 

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

395 

396 # Consider map projection orientation. 

397 # Adapting orientation enables plotting across international dateline. 

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

399 if x2 > 180.0: 

400 central_longitude = 180.0 

401 else: 

402 central_longitude = 0.0 

403 

404 # Define spatial map projection. 

405 coord_system = cube.coord(lat_axis).coord_system 

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

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

408 projection = ccrs.RotatedPole( 

409 pole_longitude=coord_system.grid_north_pole_longitude, 

410 pole_latitude=coord_system.grid_north_pole_latitude, 

411 central_rotated_longitude=0.0, 

412 ) 

413 crs = projection 

414 else: 

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

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

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

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

419 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

420 crs = ccrs.PlateCarree() 

421 

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

423 if subplot is not None: 

424 axes = figure.add_subplot( 

425 grid_size, grid_size, subplot, projection=projection 

426 ) 

427 else: 

428 axes = figure.add_subplot(projection=projection) 

429 

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

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

432 coastcol = "magenta" 

433 else: 

434 coastcol = "black" 

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

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

437 

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

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

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

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

442 

443 except ValueError: 

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

445 axes = figure.gca() 

446 pass 

447 

448 return axes 

449 

450 

451def _get_plot_resolution() -> int: 

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

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

454 

455 

456def _plot_and_save_spatial_plot( 

457 cube: iris.cube.Cube, 

458 filename: str, 

459 title: str, 

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

461 **kwargs, 

462): 

463 """Plot and save a spatial plot. 

464 

465 Parameters 

466 ---------- 

467 cube: Cube 

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

469 filename: str 

470 Filename of the plot to write. 

471 title: str 

472 Plot title. 

473 method: "contourf" | "pcolormesh" 

474 The plotting method to use. 

475 """ 

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

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

478 

479 # Specify the color bar 

480 cmap, levels, norm = _colorbar_map_levels(cube) 

481 

482 # Setup plot map projection, extent and coastlines. 

483 axes = _setup_spatial_map(cube, fig, cmap) 

484 

485 # Plot the field. 

486 if method == "contourf": 

487 # Filled contour plot of the field. 

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

489 elif method == "pcolormesh": 

490 try: 

491 vmin = min(levels) 

492 vmax = max(levels) 

493 except TypeError: 

494 vmin, vmax = None, None 

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

496 # if levels are defined. 

497 if norm is not None: 

498 vmin = None 

499 vmax = None 

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

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

502 else: 

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

504 

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

506 if is_transect(cube): 

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

508 axes.invert_yaxis() 

509 axes.set_yscale("log") 

510 axes.set_ylim(1100, 100) 

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

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

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

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

515 ): 

516 axes.set_yscale("log") 

517 

518 axes.set_title( 

519 f"{title}\n" 

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

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

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

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

524 fontsize=16, 

525 ) 

526 

527 else: 

528 # Add title. 

529 axes.set_title(title, fontsize=16) 

530 

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

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

533 axes.annotate( 

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

535 xy=(1, -0.05), 

536 xycoords="axes fraction", 

537 xytext=(-5, 5), 

538 textcoords="offset points", 

539 ha="right", 

540 va="bottom", 

541 size=11, 

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

543 ) 

544 

545 # Add colour bar. 

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

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

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

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

550 cbar.set_ticks(levels) 

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

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

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

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

555 

556 # Save plot. 

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

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

559 plt.close(fig) 

560 

561 

562def _plot_and_save_postage_stamp_spatial_plot( 

563 cube: iris.cube.Cube, 

564 filename: str, 

565 stamp_coordinate: str, 

566 title: str, 

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

568 **kwargs, 

569): 

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

571 

572 Parameters 

573 ---------- 

574 cube: Cube 

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

576 filename: str 

577 Filename of the plot to write. 

578 stamp_coordinate: str 

579 Coordinate that becomes different plots. 

580 method: "contourf" | "pcolormesh" 

581 The plotting method to use. 

582 

583 Raises 

584 ------ 

585 ValueError 

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

587 """ 

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

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

590 

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

592 

593 # Specify the color bar 

594 cmap, levels, norm = _colorbar_map_levels(cube) 

595 

596 # Make a subplot for each member. 

597 for member, subplot in zip( 

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

599 ): 

600 # Setup subplot map projection, extent and coastlines. 

601 axes = _setup_spatial_map( 

602 member, fig, cmap, grid_size=grid_size, subplot=subplot 

603 ) 

604 if method == "contourf": 

605 # Filled contour plot of the field. 

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

607 elif method == "pcolormesh": 

608 if levels is not None: 

609 vmin = min(levels) 

610 vmax = max(levels) 

611 else: 

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

613 vmin, vmax = None, None 

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

615 # if levels are defined. 

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

617 vmin = None 

618 vmax = None 

619 # pcolormesh plot of the field. 

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

621 else: 

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

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

624 axes.set_axis_off() 

625 

626 # Put the shared colorbar in its own axes. 

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

628 colorbar = fig.colorbar( 

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

630 ) 

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

632 

633 # Overall figure title. 

634 fig.suptitle(title, fontsize=16) 

635 

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

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

638 plt.close(fig) 

639 

640 

641def _plot_and_save_line_series( 

642 cubes: iris.cube.CubeList, 

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

644 ensemble_coord: str, 

645 filename: str, 

646 title: str, 

647 **kwargs, 

648): 

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

650 

651 Parameters 

652 ---------- 

653 cubes: Cube or CubeList 

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

655 coords: list[Coord] 

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

657 ensemble_coord: str 

658 Ensemble coordinate in the cube. 

659 filename: str 

660 Filename of the plot to write. 

661 title: str 

662 Plot title. 

663 """ 

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

665 

666 model_colors_map = _get_model_colors_map(cubes) 

667 

668 # Store min/max ranges. 

669 y_levels = [] 

670 

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

672 _validate_cubes_coords(cubes, coords) 

673 

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

675 label = None 

676 color = "black" 

677 if model_colors_map: 

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

679 color = model_colors_map.get(label) 

680 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

683 iplt.plot( 

684 coord, 

685 cube_slice, 

686 color=color, 

687 marker="o", 

688 ls="-", 

689 lw=3, 

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

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

692 else label, 

693 ) 

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

695 else: 

696 iplt.plot( 

697 coord, 

698 cube_slice, 

699 color=color, 

700 ls="-", 

701 lw=1.5, 

702 alpha=0.75, 

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

704 ) 

705 

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

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

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

709 y_levels.append(min(levels)) 

710 y_levels.append(max(levels)) 

711 

712 # Get the current axes. 

713 ax = plt.gca() 

714 

715 # Add some labels and tweak the style. 

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

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

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

719 ax.set_title(title, fontsize=16) 

720 

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

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

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

724 

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

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

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

728 # Add zero line. 

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

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

731 logging.debug( 

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

733 ) 

734 else: 

735 ax.autoscale() 

736 

737 # Add gridlines 

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

739 # Ientify unique labels for legend 

740 handles = list( 

741 { 

742 label: handle 

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

744 }.values() 

745 ) 

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

747 

748 # Save plot. 

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

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

751 plt.close(fig) 

752 

753 

754def _plot_and_save_vertical_line_series( 

755 cubes: iris.cube.CubeList, 

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

757 ensemble_coord: str, 

758 filename: str, 

759 series_coordinate: str, 

760 title: str, 

761 vmin: float, 

762 vmax: float, 

763 **kwargs, 

764): 

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

766 

767 Parameters 

768 ---------- 

769 cubes: CubeList 

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

771 coord: list[Coord] 

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

773 ensemble_coord: str 

774 Ensemble coordinate in the cube. 

775 filename: str 

776 Filename of the plot to write. 

777 series_coordinate: str 

778 Coordinate to use as vertical axis. 

779 title: str 

780 Plot title. 

781 vmin: float 

782 Minimum value for the x-axis. 

783 vmax: float 

784 Maximum value for the x-axis. 

785 """ 

786 # plot the vertical pressure axis using log scale 

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

788 

789 model_colors_map = _get_model_colors_map(cubes) 

790 

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

792 _validate_cubes_coords(cubes, coords) 

793 

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

795 label = None 

796 color = "black" 

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

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

799 color = model_colors_map.get(label) 

800 

801 for cube_slice in cube.slices_over(ensemble_coord): 

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

803 # unless single forecast. 

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

805 iplt.plot( 

806 cube_slice, 

807 coord, 

808 color=color, 

809 marker="o", 

810 ls="-", 

811 lw=3, 

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

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

814 else label, 

815 ) 

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

817 else: 

818 iplt.plot( 

819 cube_slice, 

820 coord, 

821 color=color, 

822 ls="-", 

823 lw=1.5, 

824 alpha=0.75, 

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

826 ) 

827 

828 # Get the current axis 

829 ax = plt.gca() 

830 

831 # Special handling for pressure level data. 

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

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

834 ax.invert_yaxis() 

835 ax.set_yscale("log") 

836 

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

838 y_tick_labels = [ 

839 "1000", 

840 "850", 

841 "700", 

842 "500", 

843 "300", 

844 "200", 

845 "100", 

846 ] 

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

848 

849 # Set y-axis limits and ticks. 

850 ax.set_ylim(1100, 100) 

851 

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

853 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

859 

860 ax.set_yticks(y_ticks) 

861 ax.set_yticklabels(y_tick_labels) 

862 

863 # Set x-axis limits. 

864 ax.set_xlim(vmin, vmax) 

865 # Mark y=0 if present in plot. 

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

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

868 

869 # Add some labels and tweak the style. 

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

871 ax.set_xlabel( 

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

873 ) 

874 ax.set_title(title, fontsize=16) 

875 ax.ticklabel_format(axis="x") 

876 ax.tick_params(axis="y") 

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

878 

879 # Add gridlines 

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

881 # Ientify unique labels for legend 

882 handles = list( 

883 { 

884 label: handle 

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

886 }.values() 

887 ) 

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

889 

890 # Save plot. 

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

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

893 plt.close(fig) 

894 

895 

896def _plot_and_save_scatter_plot( 

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

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

899 filename: str, 

900 title: str, 

901 one_to_one: bool, 

902 **kwargs, 

903): 

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

905 

906 Parameters 

907 ---------- 

908 cube_x: Cube | CubeList 

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

910 cube_y: Cube | CubeList 

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

912 filename: str 

913 Filename of the plot to write. 

914 title: str 

915 Plot title. 

916 one_to_one: bool 

917 Whether a 1:1 line is plotted. 

918 """ 

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

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

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

922 # over the pairs simultaneously. 

923 

924 # Ensure cube_x and cube_y are iterable 

925 cube_x_iterable = iter_maybe(cube_x) 

926 cube_y_iterable = iter_maybe(cube_y) 

927 

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

929 iplt.scatter(cube_x_iter, cube_y_iter) 

930 if one_to_one is True: 

931 plt.plot( 

932 [ 

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

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

935 ], 

936 [ 

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

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

939 ], 

940 "k", 

941 linestyle="--", 

942 ) 

943 ax = plt.gca() 

944 

945 # Add some labels and tweak the style. 

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

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

948 ax.set_title(title, fontsize=16) 

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

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

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

952 ax.autoscale() 

953 

954 # Save plot. 

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

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

957 plt.close(fig) 

958 

959 

960def _plot_and_save_vector_plot( 

961 cube_u: iris.cube.Cube, 

962 cube_v: iris.cube.Cube, 

963 filename: str, 

964 title: str, 

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

966 **kwargs, 

967): 

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

969 

970 Parameters 

971 ---------- 

972 cube_u: Cube 

973 2 dimensional Cube of u component of the data. 

974 cube_v: Cube 

975 2 dimensional Cube of v component of the data. 

976 filename: str 

977 Filename of the plot to write. 

978 title: str 

979 Plot title. 

980 """ 

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

982 

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

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

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

986 

987 # Specify the color bar 

988 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag) 

989 

990 # Setup plot map projection, extent and coastlines. 

991 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

992 

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

994 # Filled contour plot of the field. 

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

996 elif method == "pcolormesh": 

997 try: 

998 vmin = min(levels) 

999 vmax = max(levels) 

1000 except TypeError: 

1001 vmin, vmax = None, None 

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

1003 # if levels are defined. 

1004 if norm is not None: 

1005 vmin = None 

1006 vmax = None 

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

1008 else: 

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

1010 

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

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

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

1014 axes.invert_yaxis() 

1015 axes.set_yscale("log") 

1016 axes.set_ylim(1100, 100) 

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

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

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

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

1021 ): 

1022 axes.set_yscale("log") 

1023 

1024 axes.set_title( 

1025 f"{title}\n" 

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

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

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

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

1030 fontsize=16, 

1031 ) 

1032 

1033 else: 

1034 # Add title. 

1035 axes.set_title(title, fontsize=16) 

1036 

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

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

1039 axes.annotate( 

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

1041 xy=(1, -0.05), 

1042 xycoords="axes fraction", 

1043 xytext=(-5, 5), 

1044 textcoords="offset points", 

1045 ha="right", 

1046 va="bottom", 

1047 size=11, 

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

1049 ) 

1050 

1051 # Add colour bar. 

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

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

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

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

1056 cbar.set_ticks(levels) 

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

1058 

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

1060 # with less than 30 points. 

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

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

1063 

1064 # Save plot. 

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

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

1067 plt.close(fig) 

1068 

1069 

1070def _plot_and_save_histogram_series( 

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

1072 filename: str, 

1073 title: str, 

1074 vmin: float, 

1075 vmax: float, 

1076 **kwargs, 

1077): 

1078 """Plot and save a histogram series. 

1079 

1080 Parameters 

1081 ---------- 

1082 cubes: Cube or CubeList 

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

1084 filename: str 

1085 Filename of the plot to write. 

1086 title: str 

1087 Plot title. 

1088 vmin: float 

1089 minimum for colorbar 

1090 vmax: float 

1091 maximum for colorbar 

1092 """ 

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

1094 ax = plt.gca() 

1095 

1096 model_colors_map = _get_model_colors_map(cubes) 

1097 

1098 # Set default that histograms will produce probability density function 

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

1100 density = True 

1101 

1102 for cube in iter_maybe(cubes): 

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

1104 # than seeing if long names exist etc. 

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

1106 if "surface_microphysical" in title: 

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

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

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

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

1111 density = False 

1112 else: 

1113 bins = 10.0 ** ( 

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

1115 ) # Suggestion from RMED toolbox. 

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

1117 ax.set_yscale("log") 

1118 vmin = bins[1] 

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

1120 ax.set_xscale("log") 

1121 elif "lightning" in title: 

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

1123 else: 

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

1125 logging.debug( 

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

1127 np.size(bins), 

1128 np.min(bins), 

1129 np.max(bins), 

1130 ) 

1131 

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

1133 # Otherwise we plot xdim histograms stacked. 

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

1135 

1136 label = None 

1137 color = "black" 

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

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

1140 color = model_colors_map[label] 

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

1142 

1143 # Compute area under curve. 

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

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

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

1147 x = x[1:] 

1148 y = y[1:] 

1149 

1150 ax.plot( 

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

1152 ) 

1153 

1154 # Add some labels and tweak the style. 

1155 ax.set_title(title, fontsize=16) 

1156 ax.set_xlabel( 

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

1158 ) 

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

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

1161 ax.set_ylabel( 

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

1163 ) 

1164 ax.set_xlim(vmin, vmax) 

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

1166 

1167 # Overlay grid-lines onto histogram plot. 

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

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

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

1171 

1172 # Save plot. 

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

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

1175 plt.close(fig) 

1176 

1177 

1178def _plot_and_save_postage_stamp_histogram_series( 

1179 cube: iris.cube.Cube, 

1180 filename: str, 

1181 title: str, 

1182 stamp_coordinate: str, 

1183 vmin: float, 

1184 vmax: float, 

1185 **kwargs, 

1186): 

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

1188 

1189 Parameters 

1190 ---------- 

1191 cube: Cube 

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

1193 filename: str 

1194 Filename of the plot to write. 

1195 title: str 

1196 Plot title. 

1197 stamp_coordinate: str 

1198 Coordinate that becomes different plots. 

1199 vmin: float 

1200 minimum for pdf x-axis 

1201 vmax: float 

1202 maximum for pdf x-axis 

1203 """ 

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

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

1206 

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

1208 # Make a subplot for each member. 

1209 for member, subplot in zip( 

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

1211 ): 

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

1213 # cartopy GeoAxes generated. 

1214 plt.subplot(grid_size, grid_size, subplot) 

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

1216 # Otherwise we plot xdim histograms stacked. 

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

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

1219 ax = plt.gca() 

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

1221 ax.set_xlim(vmin, vmax) 

1222 

1223 # Overall figure title. 

1224 fig.suptitle(title, fontsize=16) 

1225 

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

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

1228 plt.close(fig) 

1229 

1230 

1231def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1232 cube: iris.cube.Cube, 

1233 filename: str, 

1234 title: str, 

1235 stamp_coordinate: str, 

1236 vmin: float, 

1237 vmax: float, 

1238 **kwargs, 

1239): 

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

1241 ax.set_title(title, fontsize=16) 

1242 ax.set_xlim(vmin, vmax) 

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

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

1245 # Loop over all slices along the stamp_coordinate 

1246 for member in cube.slices_over(stamp_coordinate): 

1247 # Flatten the member data to 1D 

1248 member_data_1d = member.data.flatten() 

1249 # Plot the histogram using plt.hist 

1250 plt.hist( 

1251 member_data_1d, 

1252 density=True, 

1253 stacked=True, 

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

1255 ) 

1256 

1257 # Add a legend 

1258 ax.legend(fontsize=16) 

1259 

1260 # Save the figure to a file 

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

1262 

1263 # Close the figure 

1264 plt.close(fig) 

1265 

1266 

1267def _spatial_plot( 

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

1269 cube: iris.cube.Cube, 

1270 filename: str | None, 

1271 sequence_coordinate: str, 

1272 stamp_coordinate: str, 

1273): 

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

1275 

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

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

1278 is present then postage stamp plots will be produced. 

1279 

1280 Parameters 

1281 ---------- 

1282 method: "contourf" | "pcolormesh" 

1283 The plotting method to use. 

1284 cube: Cube 

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

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

1287 plotted sequentially and/or as postage stamp plots. 

1288 filename: str | None 

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

1290 uses the recipe name. 

1291 sequence_coordinate: str 

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

1293 This coordinate must exist in the cube. 

1294 stamp_coordinate: str 

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

1296 ``"realization"``. 

1297 

1298 Raises 

1299 ------ 

1300 ValueError 

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

1302 TypeError 

1303 If the cube isn't a single cube. 

1304 """ 

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

1306 

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

1308 if filename is None: 

1309 filename = slugify(recipe_title) 

1310 

1311 # Ensure we've got a single cube. 

1312 cube = _check_single_cube(cube) 

1313 

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

1315 # single point. 

1316 plotting_func = _plot_and_save_spatial_plot 

1317 try: 

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

1319 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1320 except iris.exceptions.CoordinateNotFoundError: 

1321 pass 

1322 

1323 # Must have a sequence coordinate. 

1324 try: 

1325 cube.coord(sequence_coordinate) 

1326 except iris.exceptions.CoordinateNotFoundError as err: 

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

1328 

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

1330 plot_index = [] 

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

1332 for cube_slice in cube.slices_over(sequence_coordinate): 

1333 # Use sequence value so multiple sequences can merge. 

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

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

1336 coord = cube_slice.coord(sequence_coordinate) 

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

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

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

1340 if nplot == 1 and coord.has_bounds: 

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

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

1343 # Do the actual plotting. 

1344 plotting_func( 

1345 cube_slice, 

1346 filename=plot_filename, 

1347 stamp_coordinate=stamp_coordinate, 

1348 title=title, 

1349 method=method, 

1350 ) 

1351 plot_index.append(plot_filename) 

1352 

1353 # Add list of plots to plot metadata. 

1354 complete_plot_index = _append_to_plot_index(plot_index) 

1355 

1356 # Make a page to display the plots. 

1357 _make_plot_html_page(complete_plot_index) 

1358 

1359 

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

1361 """Get colourmap for mask. 

1362 

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

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

1365 

1366 Parameters 

1367 ---------- 

1368 cube: Cube 

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

1370 axis: "x", "y", optional 

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

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

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

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

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

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

1377 

1378 Returns 

1379 ------- 

1380 cmap: 

1381 Matplotlib colormap. 

1382 levels: 

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

1384 should be taken as the range. 

1385 norm: 

1386 BoundaryNorm information. 

1387 """ 

1388 if "difference" not in cube.long_name: 

1389 if axis: 

1390 levels = [0, 1] 

1391 # Complete settings based on levels. 

1392 return None, levels, None 

1393 else: 

1394 # Define the levels and colors. 

1395 levels = [0, 1, 2] 

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

1397 # Create a custom color map. 

1398 cmap = mcolors.ListedColormap(colors) 

1399 # Normalize the levels. 

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

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

1402 return cmap, levels, norm 

1403 else: 

1404 if axis: 

1405 levels = [-1, 1] 

1406 return None, levels, None 

1407 else: 

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

1409 # not <=. 

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

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

1412 cmap = mcolors.ListedColormap(colors) 

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

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

1415 return cmap, levels, norm 

1416 

1417 

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

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

1420 

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

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

1423 

1424 Parameters 

1425 ---------- 

1426 cube: Cube 

1427 Cube of variable with Beaufort Scale in name. 

1428 axis: "x", "y", optional 

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

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

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

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

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

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

1435 

1436 Returns 

1437 ------- 

1438 cmap: 

1439 Matplotlib colormap. 

1440 levels: 

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

1442 should be taken as the range. 

1443 norm: 

1444 BoundaryNorm information. 

1445 """ 

1446 if "difference" not in cube.long_name: 

1447 if axis: 

1448 levels = [0, 12] 

1449 return None, levels, None 

1450 else: 

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

1452 colors = [ 

1453 "black", 

1454 (0, 0, 0.6), 

1455 "blue", 

1456 "cyan", 

1457 "green", 

1458 "yellow", 

1459 (1, 0.5, 0), 

1460 "red", 

1461 "pink", 

1462 "magenta", 

1463 "purple", 

1464 "maroon", 

1465 "white", 

1466 ] 

1467 cmap = mcolors.ListedColormap(colors) 

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

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

1470 return cmap, levels, norm 

1471 else: 

1472 if axis: 

1473 levels = [-4, 4] 

1474 return None, levels, None 

1475 else: 

1476 levels = [ 

1477 -3.5, 

1478 -2.5, 

1479 -1.5, 

1480 -0.5, 

1481 0.5, 

1482 1.5, 

1483 2.5, 

1484 3.5, 

1485 ] 

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

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

1488 return cmap, levels, norm 

1489 

1490 

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

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

1493 

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

1495 

1496 Parameters 

1497 ---------- 

1498 cube: Cube 

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

1500 cmap: Matplotlib colormap. 

1501 levels: List 

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

1503 should be taken as the range. 

1504 norm: BoundaryNorm. 

1505 

1506 Returns 

1507 ------- 

1508 cmap: Matplotlib colormap. 

1509 levels: List 

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

1511 should be taken as the range. 

1512 norm: BoundaryNorm. 

1513 """ 

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

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

1516 levels = np.array(levels) 

1517 levels -= 273 

1518 levels = levels.tolist() 

1519 else: 

1520 # Do nothing keep the existing colourbar attributes 

1521 levels = levels 

1522 cmap = cmap 

1523 norm = norm 

1524 return cmap, levels, norm 

1525 

1526 

1527def _custom_colormap_probability( 

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

1529): 

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

1531 

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

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

1534 

1535 Parameters 

1536 ---------- 

1537 cube: Cube 

1538 Cube of variable with probability in name. 

1539 axis: "x", "y", optional 

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

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

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

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

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

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

1546 

1547 Returns 

1548 ------- 

1549 cmap: 

1550 Matplotlib colormap. 

1551 levels: 

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

1553 should be taken as the range. 

1554 norm: 

1555 BoundaryNorm information. 

1556 """ 

1557 if axis: 

1558 levels = [0, 1] 

1559 return None, levels, None 

1560 else: 

1561 cmap = mcolors.ListedColormap( 

1562 [ 

1563 "#FFFFFF", 

1564 "#636363", 

1565 "#e1dada", 

1566 "#B5CAFF", 

1567 "#8FB3FF", 

1568 "#7F97FF", 

1569 "#ABCF63", 

1570 "#E8F59E", 

1571 "#FFFA14", 

1572 "#FFD121", 

1573 "#FFA30A", 

1574 ] 

1575 ) 

1576 levels = [0.0, 0.01, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] 

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

1578 return cmap, levels, norm 

1579 

1580 

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

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

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

1584 if ( 

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

1586 and "difference" not in cube.long_name 

1587 and "mask" not in cube.long_name 

1588 ): 

1589 # Define the levels and colors 

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

1591 colors = [ 

1592 "w", 

1593 (0, 0, 0.6), 

1594 "b", 

1595 "c", 

1596 "g", 

1597 "y", 

1598 (1, 0.5, 0), 

1599 "r", 

1600 "pink", 

1601 "m", 

1602 "purple", 

1603 "maroon", 

1604 "gray", 

1605 ] 

1606 # Create a custom colormap 

1607 cmap = mcolors.ListedColormap(colors) 

1608 # Normalize the levels 

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

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

1611 else: 

1612 # do nothing and keep existing colorbar attributes 

1613 cmap = cmap 

1614 levels = levels 

1615 norm = norm 

1616 return cmap, levels, norm 

1617 

1618 

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

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

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

1622 if ( 

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

1624 and "difference" not in cube.long_name 

1625 and "mask" not in cube.long_name 

1626 ): 

1627 # Define the levels and colors (in km) 

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

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

1630 colours = [ 

1631 "#8f00d6", 

1632 "#d10000", 

1633 "#ff9700", 

1634 "#ffff00", 

1635 "#00007f", 

1636 "#6c9ccd", 

1637 "#aae8ff", 

1638 "#37a648", 

1639 "#8edc64", 

1640 "#c5ffc5", 

1641 "#dcdcdc", 

1642 "#ffffff", 

1643 ] 

1644 # Create a custom colormap 

1645 cmap = mcolors.ListedColormap(colours) 

1646 # Normalize the levels 

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

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

1649 else: 

1650 # do nothing and keep existing colorbar attributes 

1651 cmap = cmap 

1652 levels = levels 

1653 norm = norm 

1654 return cmap, levels, norm 

1655 

1656 

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

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

1659 model_names = list( 

1660 filter( 

1661 lambda x: x is not None, 

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

1663 ) 

1664 ) 

1665 if not model_names: 

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

1667 return 1 

1668 else: 

1669 return len(model_names) 

1670 

1671 

1672def _validate_cube_shape( 

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

1674) -> None: 

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

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

1677 raise ValueError( 

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

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

1680 ) 

1681 

1682 

1683def _validate_cubes_coords( 

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

1685) -> None: 

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

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

1688 raise ValueError( 

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

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

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

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

1693 ) 

1694 

1695 

1696#################### 

1697# Public functions # 

1698#################### 

1699 

1700 

1701def spatial_contour_plot( 

1702 cube: iris.cube.Cube, 

1703 filename: str = None, 

1704 sequence_coordinate: str = "time", 

1705 stamp_coordinate: str = "realization", 

1706 **kwargs, 

1707) -> iris.cube.Cube: 

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

1709 

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

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

1712 is present then postage stamp plots will be produced. 

1713 

1714 Parameters 

1715 ---------- 

1716 cube: Cube 

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

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

1719 plotted sequentially and/or as postage stamp plots. 

1720 filename: str, optional 

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

1722 to the recipe name. 

1723 sequence_coordinate: str, optional 

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

1725 This coordinate must exist in the cube. 

1726 stamp_coordinate: str, optional 

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

1728 ``"realization"``. 

1729 

1730 Returns 

1731 ------- 

1732 Cube 

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

1734 

1735 Raises 

1736 ------ 

1737 ValueError 

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

1739 TypeError 

1740 If the cube isn't a single cube. 

1741 """ 

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

1743 return cube 

1744 

1745 

1746def spatial_pcolormesh_plot( 

1747 cube: iris.cube.Cube, 

1748 filename: str = None, 

1749 sequence_coordinate: str = "time", 

1750 stamp_coordinate: str = "realization", 

1751 **kwargs, 

1752) -> iris.cube.Cube: 

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

1754 

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

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

1757 is present then postage stamp plots will be produced. 

1758 

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

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

1761 contour areas are important. 

1762 

1763 Parameters 

1764 ---------- 

1765 cube: Cube 

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

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

1768 plotted sequentially and/or as postage stamp plots. 

1769 filename: str, optional 

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

1771 to the recipe name. 

1772 sequence_coordinate: str, optional 

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

1774 This coordinate must exist in the cube. 

1775 stamp_coordinate: str, optional 

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

1777 ``"realization"``. 

1778 

1779 Returns 

1780 ------- 

1781 Cube 

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

1783 

1784 Raises 

1785 ------ 

1786 ValueError 

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

1788 TypeError 

1789 If the cube isn't a single cube. 

1790 """ 

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

1792 return cube 

1793 

1794 

1795# TODO: Expand function to handle ensemble data. 

1796# line_coordinate: str, optional 

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

1798# ``"realization"``. 

1799def plot_line_series( 

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

1801 filename: str = None, 

1802 series_coordinate: str = "time", 

1803 # line_coordinate: str = "realization", 

1804 **kwargs, 

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

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

1807 

1808 The Cube or CubeList must be 1D. 

1809 

1810 Parameters 

1811 ---------- 

1812 iris.cube | iris.cube.CubeList 

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

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

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

1816 filename: str, optional 

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

1818 to the recipe name. 

1819 series_coordinate: str, optional 

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

1821 coordinate must exist in the cube. 

1822 

1823 Returns 

1824 ------- 

1825 iris.cube.Cube | iris.cube.CubeList 

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

1827 plotted data. 

1828 

1829 Raises 

1830 ------ 

1831 ValueError 

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

1833 TypeError 

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

1835 """ 

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

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

1838 

1839 if filename is None: 

1840 filename = slugify(title) 

1841 

1842 # Add file extension. 

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

1844 

1845 num_models = _get_num_models(cube) 

1846 

1847 _validate_cube_shape(cube, num_models) 

1848 

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

1850 cubes = iter_maybe(cube) 

1851 coords = [] 

1852 for cube in cubes: 

1853 try: 

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

1855 except iris.exceptions.CoordinateNotFoundError as err: 

1856 raise ValueError( 

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

1858 ) from err 

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

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

1861 

1862 # Do the actual plotting. 

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

1864 

1865 # Add list of plots to plot metadata. 

1866 plot_index = _append_to_plot_index([plot_filename]) 

1867 

1868 # Make a page to display the plots. 

1869 _make_plot_html_page(plot_index) 

1870 

1871 return cube 

1872 

1873 

1874def plot_vertical_line_series( 

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

1876 filename: str = None, 

1877 series_coordinate: str = "model_level_number", 

1878 sequence_coordinate: str = "time", 

1879 # line_coordinate: str = "realization", 

1880 **kwargs, 

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

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

1883 

1884 The Cube or CubeList must be 1D. 

1885 

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

1887 then a sequence of plots will be produced. 

1888 

1889 Parameters 

1890 ---------- 

1891 iris.cube | iris.cube.CubeList 

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

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

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

1895 filename: str, optional 

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

1897 to the recipe name. 

1898 series_coordinate: str, optional 

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

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

1901 for LFRic. Defaults to ``model_level_number``. 

1902 This coordinate must exist in the cube. 

1903 sequence_coordinate: str, optional 

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

1905 This coordinate must exist in the cube. 

1906 

1907 Returns 

1908 ------- 

1909 iris.cube.Cube | iris.cube.CubeList 

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

1911 Plotted data. 

1912 

1913 Raises 

1914 ------ 

1915 ValueError 

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

1917 TypeError 

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

1919 """ 

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

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

1922 

1923 if filename is None: 

1924 filename = slugify(recipe_title) 

1925 

1926 cubes = iter_maybe(cubes) 

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

1928 all_data = [] 

1929 

1930 # Store min/max ranges for x range. 

1931 x_levels = [] 

1932 

1933 num_models = _get_num_models(cubes) 

1934 

1935 _validate_cube_shape(cubes, num_models) 

1936 

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

1938 coords = [] 

1939 for cube in cubes: 

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

1941 try: 

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

1943 except iris.exceptions.CoordinateNotFoundError as err: 

1944 raise ValueError( 

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

1946 ) from err 

1947 

1948 try: 

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

1950 cube.coord(sequence_coordinate) 

1951 except iris.exceptions.CoordinateNotFoundError as err: 

1952 raise ValueError( 

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

1954 ) from err 

1955 

1956 # Get minimum and maximum from levels information. 

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

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

1959 x_levels.append(min(levels)) 

1960 x_levels.append(max(levels)) 

1961 else: 

1962 all_data.append(cube.data) 

1963 

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

1965 # Combine all data into a single NumPy array 

1966 combined_data = np.concatenate(all_data) 

1967 

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

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

1970 # sequence and if applicable postage stamp coordinate. 

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

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

1973 else: 

1974 vmin = min(x_levels) 

1975 vmax = max(x_levels) 

1976 

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

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

1979 # necessary) 

1980 def filter_cube_iterables(cube_iterables) -> bool: 

1981 return len(cube_iterables) == len(coords) 

1982 

1983 cube_iterables = filter( 

1984 filter_cube_iterables, 

1985 ( 

1986 iris.cube.CubeList( 

1987 s 

1988 for s in itertools.chain.from_iterable( 

1989 cb.slices_over(sequence_coordinate) for cb in cubes 

1990 ) 

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

1992 ) 

1993 for point in sorted( 

1994 set( 

1995 itertools.chain.from_iterable( 

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

1997 ) 

1998 ) 

1999 ) 

2000 ), 

2001 ) 

2002 

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

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

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

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

2007 # or an iris.cube.CubeList. 

2008 plot_index = [] 

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

2010 for cubes_slice in cube_iterables: 

2011 # Use sequence value so multiple sequences can merge. 

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

2013 sequence_value = seq_coord.points[0] 

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

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

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

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

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

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

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

2021 # Do the actual plotting. 

2022 _plot_and_save_vertical_line_series( 

2023 cubes_slice, 

2024 coords, 

2025 "realization", 

2026 plot_filename, 

2027 series_coordinate, 

2028 title=title, 

2029 vmin=vmin, 

2030 vmax=vmax, 

2031 ) 

2032 plot_index.append(plot_filename) 

2033 

2034 # Add list of plots to plot metadata. 

2035 complete_plot_index = _append_to_plot_index(plot_index) 

2036 

2037 # Make a page to display the plots. 

2038 _make_plot_html_page(complete_plot_index) 

2039 

2040 return cubes 

2041 

2042 

2043def scatter_plot( 

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

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

2046 filename: str = None, 

2047 one_to_one: bool = True, 

2048 **kwargs, 

2049) -> iris.cube.CubeList: 

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

2051 

2052 Both cubes must be 1D. 

2053 

2054 Parameters 

2055 ---------- 

2056 cube_x: Cube | CubeList 

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

2058 cube_y: Cube | CubeList 

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

2060 filename: str, optional 

2061 Filename of the plot to write. 

2062 one_to_one: bool, optional 

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

2064 

2065 Returns 

2066 ------- 

2067 cubes: CubeList 

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

2069 

2070 Raises 

2071 ------ 

2072 ValueError 

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

2074 size. 

2075 TypeError 

2076 If the cube isn't a single cube. 

2077 

2078 Notes 

2079 ----- 

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

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

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

2083 

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

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

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

2087 observations and other models. Identical percentiles between the variables 

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

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

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

2091 Wilks 2011 [Wilks2011]_). 

2092 

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

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

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

2096 the extremes. 

2097 

2098 References 

2099 ---------- 

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

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

2102 """ 

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

2104 for cube_iter in iter_maybe(cube_x): 

2105 # Check cubes are correct shape. 

2106 cube_iter = _check_single_cube(cube_iter) 

2107 if cube_iter.ndim > 1: 

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

2109 

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

2111 for cube_iter in iter_maybe(cube_y): 

2112 # Check cubes are correct shape. 

2113 cube_iter = _check_single_cube(cube_iter) 

2114 if cube_iter.ndim > 1: 

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

2116 

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

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

2119 

2120 if filename is None: 

2121 filename = slugify(title) 

2122 

2123 # Add file extension. 

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

2125 

2126 # Do the actual plotting. 

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

2128 

2129 # Add list of plots to plot metadata. 

2130 plot_index = _append_to_plot_index([plot_filename]) 

2131 

2132 # Make a page to display the plots. 

2133 _make_plot_html_page(plot_index) 

2134 

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

2136 

2137 

2138def vector_plot( 

2139 cube_u: iris.cube.Cube, 

2140 cube_v: iris.cube.Cube, 

2141 filename: str = None, 

2142 sequence_coordinate: str = "time", 

2143 **kwargs, 

2144) -> iris.cube.CubeList: 

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

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

2147 

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

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

2150 filename = slugify(recipe_title) 

2151 

2152 # Cubes must have a matching sequence coordinate. 

2153 try: 

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

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

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

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

2158 raise ValueError( 

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

2160 ) from err 

2161 

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

2163 plot_index = [] 

2164 for cube_u_slice, cube_v_slice in zip( 

2165 cube_u.slices_over(sequence_coordinate), 

2166 cube_v.slices_over(sequence_coordinate), 

2167 strict=True, 

2168 ): 

2169 # Use sequence value so multiple sequences can merge. 

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

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

2172 coord = cube_u_slice.coord(sequence_coordinate) 

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

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

2175 # Do the actual plotting. 

2176 _plot_and_save_vector_plot( 

2177 cube_u_slice, 

2178 cube_v_slice, 

2179 filename=plot_filename, 

2180 title=title, 

2181 method="contourf", 

2182 ) 

2183 plot_index.append(plot_filename) 

2184 

2185 # Add list of plots to plot metadata. 

2186 complete_plot_index = _append_to_plot_index(plot_index) 

2187 

2188 # Make a page to display the plots. 

2189 _make_plot_html_page(complete_plot_index) 

2190 

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

2192 

2193 

2194def plot_histogram_series( 

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

2196 filename: str = None, 

2197 sequence_coordinate: str = "time", 

2198 stamp_coordinate: str = "realization", 

2199 single_plot: bool = False, 

2200 **kwargs, 

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

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

2203 

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

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

2206 functionality to scroll through histograms against time. If a 

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

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

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

2210 

2211 Parameters 

2212 ---------- 

2213 cubes: Cube | iris.cube.CubeList 

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

2215 than the stamp coordinate. 

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

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

2218 filename: str, optional 

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

2220 to the recipe name. 

2221 sequence_coordinate: str, optional 

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

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

2224 slider. 

2225 stamp_coordinate: str, optional 

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

2227 ``"realization"``. 

2228 single_plot: bool, optional 

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

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

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

2232 

2233 Returns 

2234 ------- 

2235 iris.cube.Cube | iris.cube.CubeList 

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

2237 Plotted data. 

2238 

2239 Raises 

2240 ------ 

2241 ValueError 

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

2243 TypeError 

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

2245 """ 

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

2247 

2248 cubes = iter_maybe(cubes) 

2249 

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

2251 if filename is None: 

2252 filename = slugify(recipe_title) 

2253 

2254 # Internal plotting function. 

2255 plotting_func = _plot_and_save_histogram_series 

2256 

2257 num_models = _get_num_models(cubes) 

2258 

2259 _validate_cube_shape(cubes, num_models) 

2260 

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

2262 # time slider option. 

2263 for cube in cubes: 

2264 try: 

2265 cube.coord(sequence_coordinate) 

2266 except iris.exceptions.CoordinateNotFoundError as err: 

2267 raise ValueError( 

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

2269 ) from err 

2270 

2271 # Get minimum and maximum from levels information. 

2272 levels = None 

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

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

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

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

2277 if levels is None: 

2278 break 

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

2280 # levels-based ranges for histogram plots. 

2281 _, levels, _ = _colorbar_map_levels(cube) 

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

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

2284 vmin = min(levels) 

2285 vmax = max(levels) 

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

2287 break 

2288 

2289 if levels is None: 

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

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

2292 

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

2294 # single point. If single_plot is True: 

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

2296 # separate postage stamp plots. 

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

2298 # produced per single model only 

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

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

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

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

2303 ): 

2304 if single_plot: 

2305 plotting_func = ( 

2306 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

2307 ) 

2308 else: 

2309 plotting_func = _plot_and_save_postage_stamp_histogram_series 

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

2311 else: 

2312 all_points = sorted( 

2313 set( 

2314 itertools.chain.from_iterable( 

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

2316 ) 

2317 ) 

2318 ) 

2319 all_slices = list( 

2320 itertools.chain.from_iterable( 

2321 cb.slices_over(sequence_coordinate) for cb in cubes 

2322 ) 

2323 ) 

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

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

2326 # necessary) 

2327 cube_iterables = [ 

2328 iris.cube.CubeList( 

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

2330 ) 

2331 for point in all_points 

2332 ] 

2333 

2334 plot_index = [] 

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

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

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

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

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

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

2341 for cube_slice in cube_iterables: 

2342 single_cube = cube_slice 

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

2344 single_cube = cube_slice[0] 

2345 

2346 # Use sequence value so multiple sequences can merge. 

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

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

2349 coord = single_cube.coord(sequence_coordinate) 

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

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

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

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

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

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

2356 # Do the actual plotting. 

2357 plotting_func( 

2358 cube_slice, 

2359 filename=plot_filename, 

2360 stamp_coordinate=stamp_coordinate, 

2361 title=title, 

2362 vmin=vmin, 

2363 vmax=vmax, 

2364 ) 

2365 plot_index.append(plot_filename) 

2366 

2367 # Add list of plots to plot metadata. 

2368 complete_plot_index = _append_to_plot_index(plot_index) 

2369 

2370 # Make a page to display the plots. 

2371 _make_plot_html_page(complete_plot_index) 

2372 

2373 return cubes