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

1055 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-15 15:48 +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 

25from typing import Literal 

26 

27import cartopy.crs as ccrs 

28import cartopy.feature as cfeature 

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 

38import scipy.fft as fft 

39from cartopy.mpl.geoaxes import GeoAxes 

40from iris.cube import Cube 

41from markdown_it import MarkdownIt 

42from mpl_toolkits.axes_grid1.inset_locator import inset_axes 

43 

44from CSET._common import ( 

45 combine_dicts, 

46 filename_slugify, 

47 get_recipe_metadata, 

48 iter_maybe, 

49 render_file, 

50 slugify, 

51) 

52from CSET.operators._utils import ( 

53 check_stamp_coordinate, 

54 fully_equalise_attributes, 

55 get_cube_yxcoordname, 

56 is_transect, 

57 slice_over_maybe, 

58) 

59from CSET.operators.collapse import collapse 

60from CSET.operators.misc import _extract_common_time_points 

61from CSET.operators.regrid import regrid_onto_cube 

62 

63# Use a non-interactive plotting backend. 

64mpl.use("agg") 

65 

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

67 

68############################ 

69# Private helper functions # 

70############################ 

71 

72 

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

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

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

76 fcntl.flock(fp, fcntl.LOCK_EX) 

77 fp.seek(0) 

78 meta = json.load(fp) 

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

80 complete_plot_index = complete_plot_index + plot_index 

81 meta["plots"] = complete_plot_index 

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

83 os.getenv("DO_CASE_AGGREGATION") 

84 ): 

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

86 fp.seek(0) 

87 fp.truncate() 

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

89 return complete_plot_index 

90 

91 

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

93 """Ensure a single cube is given. 

94 

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

96 otherwise an error is raised. 

97 

98 Parameters 

99 ---------- 

100 cube: Cube | CubeList 

101 The cube to check. 

102 

103 Returns 

104 ------- 

105 cube: Cube 

106 The checked cube. 

107 

108 Raises 

109 ------ 

110 TypeError 

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

112 """ 

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

114 return cube 

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

116 if len(cube) == 1: 

117 return cube[0] 

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

119 

120 

121def _make_plot_html_page(plots: list): 

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

123 # Debug check that plots actually contains some strings. 

124 assert isinstance(plots[0], str) 

125 

126 # Load HTML template file. 

127 operator_files = importlib.resources.files() 

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

129 

130 # Get some metadata. 

131 meta = get_recipe_metadata() 

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

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

134 

135 # Prepare template variables. 

136 variables = { 

137 "title": title, 

138 "description": description, 

139 "initial_plot": plots[0], 

140 "plots": plots, 

141 "title_slug": slugify(title), 

142 } 

143 

144 # Render template. 

145 html = render_file(template_file, **variables) 

146 

147 # Save completed HTML. 

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

149 fp.write(html) 

150 

151 

152@functools.cache 

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

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

155 

156 This is a separate function to make it cacheable. 

157 """ 

158 colorbar_file = importlib.resources.files().joinpath("_colorbar_definition.json") 

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

160 colorbar = json.load(fp) 

161 

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

163 override_colorbar = {} 

164 if user_colorbar_file: 

165 try: 

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

167 override_colorbar = json.load(fp) 

168 except FileNotFoundError: 

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

170 

171 # Overwrite values with the user supplied colorbar definition. 

172 colorbar = combine_dicts(colorbar, override_colorbar) 

173 return colorbar 

174 

175 

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

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

178 

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

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

181 to model_name attribute. 

182 

183 Parameters 

184 ---------- 

185 cubes: CubeList or Cube 

186 Cubes with model_name attribute 

187 

188 Returns 

189 ------- 

190 model_colors_map: 

191 Dictionary mapping model_name attribute to colors 

192 """ 

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

194 colorbar = _load_colorbar_map(user_colorbar_file) 

195 model_names = sorted( 

196 filter( 

197 lambda x: x is not None, 

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

199 ) 

200 ) 

201 if not model_names: 

202 return {} 

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

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

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

206 

207 color_list = itertools.cycle(DEFAULT_DISCRETE_COLORS) 

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

209 

210 

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

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

213 

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

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

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

217 exist for specific pressure levels to account for variables with 

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

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

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

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

222 

223 Parameters 

224 ---------- 

225 cube: Cube 

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

227 axis: "x", "y", optional 

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

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

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

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

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

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

234 

235 Returns 

236 ------- 

237 cmap: 

238 Matplotlib colormap. 

239 levels: 

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

241 should be taken as the range. 

242 norm: 

243 BoundaryNorm information. 

244 """ 

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

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

247 colorbar = _load_colorbar_map(user_colorbar_file) 

248 cmap = None 

249 

250 try: 

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

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

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

254 pressure_level = str(int(pressure_level_raw)) 

255 except iris.exceptions.CoordinateNotFoundError: 

256 pressure_level = None 

257 

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

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

260 # consistent. 

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

262 for varname in varnames: 

263 # Get the colormap for this variable. 

264 try: 

265 var_colorbar = colorbar[varname] 

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

267 varname_key = varname 

268 break 

269 except KeyError: 

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

271 

272 # Get colormap if it is a mask. 

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

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

275 return cmap, levels, norm 

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

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

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

279 return cmap, levels, norm 

280 # If probability is plotted use custom colorbar and levels 

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

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

283 return cmap, levels, norm 

284 # If aviation colour state use custom colorbar and levels 

285 if any("aviation_colour_state" in name for name in varnames): 

286 cmap, levels, norm = _custom_colormap_aviation_colour_state(cube) 

287 return cmap, levels, norm 

288 

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

290 if not cmap: 

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

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

293 return cmap, levels, norm 

294 

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

296 if pressure_level: 

297 try: 

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

299 except KeyError: 

300 logging.debug( 

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

302 varname, 

303 pressure_level, 

304 ) 

305 

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

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

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

309 if axis: 

310 if axis == "x": 

311 try: 

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

313 except KeyError: 

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

315 if axis == "y": 

316 try: 

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

318 except KeyError: 

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

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

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

322 levels = None 

323 else: 

324 levels = [vmin, vmax] 

325 return None, levels, None 

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

327 else: 

328 try: 

329 levels = var_colorbar["levels"] 

330 # Use discrete bins when levels are specified, rather 

331 # than a smooth range. 

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

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

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

335 except KeyError: 

336 # Get the range for this variable. 

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

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

339 # Calculate levels from range. 

340 if vmin == "auto" or vmax == "auto": 340 ↛ 341line 340 didn't jump to line 341 because the condition on line 340 was never true

341 levels = None 

342 else: 

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

344 norm = None 

345 

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

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

348 # JSON file. 

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

350 cmap, levels, norm = _custom_colourmap_visibility_in_air( 

351 cube, cmap, levels, norm 

352 ) 

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

354 return cmap, levels, norm 

355 

356 

357def _setup_spatial_map( 

358 cube: iris.cube.Cube, 

359 figure, 

360 cmap, 

361 grid_size: int | None = None, 

362 subplot: int | None = None, 

363): 

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

365 

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

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

368 

369 Parameters 

370 ---------- 

371 cube: Cube 

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

373 figure: 

374 Matplotlib Figure object holding all plot elements. 

375 cmap: 

376 Matplotlib colormap. 

377 grid_size: (int,int), optional 

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

379 subplot: int, optional 

380 Subplot index if multiple spatial subplots in figure. 

381 

382 Returns 

383 ------- 

384 axes: 

385 Matplotlib GeoAxes definition. 

386 """ 

387 # Identify min/max plot bounds. 

388 try: 

389 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

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

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

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

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

394 

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

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

397 x1 = x1 - 180.0 

398 x2 = x2 - 180.0 

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

400 

401 # Consider map projection orientation. 

402 # Adapting orientation enables plotting across international dateline. 

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

404 if x2 > 180.0 or x1 < -180.0: 

405 central_longitude = 180.0 

406 else: 

407 central_longitude = 0.0 

408 

409 # Define spatial map projection. 

410 coord_system = cube.coord(lat_axis).coord_system 

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

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

413 projection = ccrs.RotatedPole( 

414 pole_longitude=coord_system.grid_north_pole_longitude, 

415 pole_latitude=coord_system.grid_north_pole_latitude, 

416 central_rotated_longitude=central_longitude, 

417 ) 

418 crs = projection 

419 elif isinstance(coord_system, iris.coord_systems.TransverseMercator): 419 ↛ 421line 419 didn't jump to line 421 because the condition on line 419 was never true

420 # Define Transverse Mercator projection for TM inputs. 

421 projection = ccrs.TransverseMercator( 

422 central_longitude=coord_system.longitude_of_central_meridian, 

423 central_latitude=coord_system.latitude_of_projection_origin, 

424 false_easting=coord_system.false_easting, 

425 false_northing=coord_system.false_northing, 

426 scale_factor=coord_system.scale_factor_at_central_meridian, 

427 ) 

428 crs = projection 

429 else: 

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

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

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

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

434 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

435 crs = ccrs.PlateCarree() 

436 

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

438 if subplot is not None: 

439 axes = figure.add_subplot( 

440 grid_size[1], grid_size[0], subplot, projection=projection 

441 ) 

442 else: 

443 axes = figure.add_subplot(projection=projection) 

444 

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

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

447 coastcol = "magenta" 

448 else: 

449 coastcol = "black" 

450 logging.debug("Plotting coastlines and borderlines in colour %s.", coastcol) 

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

452 axes.add_feature(cfeature.BORDERS, edgecolor=coastcol) 

453 

454 # Add gridlines. 

455 if subplot is None: 

456 draw_labels = True 

457 else: 

458 draw_labels = False 

459 gl = axes.gridlines( 

460 alpha=0.3, 

461 draw_labels=draw_labels, 

462 dms=False, 

463 x_inline=False, 

464 y_inline=False, 

465 ) 

466 gl.top_labels = False 

467 gl.right_labels = False 

468 

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

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

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

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

473 

474 except ValueError: 

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

476 axes = figure.gca() 

477 pass 

478 

479 return axes 

480 

481 

482def _get_plot_resolution() -> int: 

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

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

485 

486 

487def _set_title_and_filename( 

488 seq_coord: iris.coords.Coord, 

489 nplot: int, 

490 recipe_title: str, 

491 filename: str, 

492): 

493 """Set plot title and filename based on cube coordinate. 

494 

495 Parameters 

496 ---------- 

497 sequence_coordinate: iris.coords.Coord 

498 Coordinate about which to make a plot sequence. 

499 nplot: int 

500 Number of output plots to generate - controls title/naming. 

501 recipe_title: str 

502 Default plot title, potentially to update. 

503 filename: str 

504 Input plot filename, potentially to update. 

505 

506 Returns 

507 ------- 

508 plot_title: str 

509 Output formatted plot title string, based on plotted data. 

510 plot_filename: str 

511 Output formatted plot filename string. 

512 """ 

513 ndim = seq_coord.ndim 

514 npoints = np.size(seq_coord.points) 

515 sequence_title = "" 

516 sequence_fname = "" 

517 

518 # Account for case with multi-dimension sequence input 

519 # (e.g. aggregation histogram plots) 

520 if ndim > 1: 

521 ncase = np.shape(seq_coord)[0] 

522 sequence_title = f"\n [{ncase} cases]" 

523 sequence_fname = f"_{ncase}cases" 

524 

525 else: 

526 if npoints == 1: 

527 if nplot > 1: 

528 # Set default labels for sequence inputs 

529 sequence_value = seq_coord.units.title(seq_coord.points[0]) 

530 sequence_title = f"\n [{sequence_value}]" 

531 sequence_fname = f"_{filename_slugify(sequence_value)}" 

532 else: 

533 # Use coord aggregated attribute where input collapsed over aggregation 

534 try: 

535 ncase = seq_coord.attributes["number_reference_times"] 

536 sequence_title = f"\n [{ncase} cases]" 

537 sequence_fname = f"_{ncase}cases" 

538 except KeyError: 

539 sequence_title = "" 

540 sequence_fname = "" 

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

542 # Take title endpoints from coord points where series input (e.g. timeseries) 

543 if npoints > 1: 

544 if not seq_coord.has_bounds(): 

545 startstring = seq_coord.units.title(seq_coord.points[0]) 

546 endstring = seq_coord.units.title(seq_coord.points[-1]) 

547 else: 

548 # Take title endpoint from coord bounds where single input (e.g. map) 

549 startstring = seq_coord.units.title(seq_coord.bounds.flatten()[0]) 

550 endstring = seq_coord.units.title(seq_coord.bounds.flatten()[-1]) 

551 sequence_title = f"\n [{startstring} to {endstring}]" 

552 sequence_fname = ( 

553 f"_{filename_slugify(startstring)}_{filename_slugify(endstring)}" 

554 ) 

555 

556 # Set plot title and filename 

557 plot_title = f"{recipe_title}{sequence_title}" 

558 

559 # Set plot filename, defaulting to user input if provided. 

560 if filename is None: 

561 filename = slugify(recipe_title) 

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

563 else: 

564 if nplot > 1: 

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

566 else: 

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

568 

569 return plot_title, plot_filename 

570 

571 

572def _plot_and_save_spatial_plot( 

573 cube: iris.cube.Cube, 

574 filename: str, 

575 title: str, 

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

577 overlay_cube: iris.cube.Cube | None = None, 

578 contour_cube: iris.cube.Cube | None = None, 

579 **kwargs, 

580): 

581 """Plot and save a spatial plot. 

582 

583 Parameters 

584 ---------- 

585 cube: Cube 

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

587 filename: str 

588 Filename of the plot to write. 

589 title: str 

590 Plot title. 

591 method: "contourf" | "pcolormesh" 

592 The plotting method to use. 

593 overlay_cube: Cube, optional 

594 Optional 2 dimensional (lat and lon) Cube of data to overplot on top of base cube 

595 contour_cube: Cube, optional 

596 Optional 2 dimensional (lat and lon) Cube of data to overplot as contours over base cube 

597 """ 

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

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

600 

601 # Specify the color bar 

602 cmap, levels, norm = _colorbar_map_levels(cube) 

603 

604 # If overplotting, set required colorbars 

605 if overlay_cube: 

606 over_cmap, over_levels, over_norm = _colorbar_map_levels(overlay_cube) 

607 if contour_cube: 

608 cntr_cmap, cntr_levels, cntr_norm = _colorbar_map_levels(contour_cube) 

609 

610 # Setup plot map projection, extent and coastlines and borderlines. 

611 axes = _setup_spatial_map(cube, fig, cmap) 

612 

613 # Plot the field. 

614 if method == "contourf": 

615 # Filled contour plot of the field. 

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

617 elif method == "pcolormesh": 

618 try: 

619 vmin = min(levels) 

620 vmax = max(levels) 

621 except TypeError: 

622 vmin, vmax = None, None 

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

624 # if levels are defined. 

625 if norm is not None: 

626 vmin = None 

627 vmax = None 

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

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

630 else: 

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

632 

633 # Overplot overlay field, if required 

634 if overlay_cube: 

635 try: 

636 over_vmin = min(over_levels) 

637 over_vmax = max(over_levels) 

638 except TypeError: 

639 over_vmin, over_vmax = None, None 

640 if over_norm is not None: 640 ↛ 641line 640 didn't jump to line 641 because the condition on line 640 was never true

641 over_vmin = None 

642 over_vmax = None 

643 overlay = iplt.pcolormesh( 

644 overlay_cube, 

645 cmap=over_cmap, 

646 norm=over_norm, 

647 alpha=0.8, 

648 vmin=over_vmin, 

649 vmax=over_vmax, 

650 ) 

651 # Overplot contour field, if required, with contour labelling. 

652 if contour_cube: 

653 contour = iplt.contour( 

654 contour_cube, 

655 colors="darkgray", 

656 levels=cntr_levels, 

657 norm=cntr_norm, 

658 alpha=0.5, 

659 linestyles="--", 

660 linewidths=1, 

661 ) 

662 plt.clabel(contour) 

663 

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

665 if is_transect(cube): 

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

667 axes.invert_yaxis() 

668 axes.set_yscale("log") 

669 axes.set_ylim(1100, 100) 

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

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

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

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

674 ): 

675 axes.set_yscale("log") 

676 

677 axes.set_title( 

678 f"{title}\n" 

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

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

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

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

683 fontsize=16, 

684 ) 

685 

686 # Inset code 

687 axins = inset_axes( 

688 axes, 

689 width="20%", 

690 height="20%", 

691 loc="upper right", 

692 axes_class=GeoAxes, 

693 axes_kwargs={"map_projection": ccrs.PlateCarree()}, 

694 ) 

695 

696 # Slightly transparent to reduce plot blocking. 

697 axins.patch.set_alpha(0.4) 

698 

699 axins.coastlines(resolution="50m") 

700 axins.add_feature(cfeature.BORDERS, linewidth=0.3) 

701 

702 SLat, SLon, ELat, ELon = ( 

703 float(coord) for coord in cube.attributes["transect_coords"].split("_") 

704 ) 

705 

706 # Draw line between them 

707 axins.plot( 

708 [SLon, ELon], [SLat, ELat], color="black", transform=ccrs.PlateCarree() 

709 ) 

710 

711 # Plot points (note: lon, lat order for Cartopy) 

712 axins.plot(SLon, SLat, marker="x", color="green", transform=ccrs.PlateCarree()) 

713 axins.plot(ELon, ELat, marker="x", color="red", transform=ccrs.PlateCarree()) 

714 

715 lon_min, lon_max = sorted([SLon, ELon]) 

716 lat_min, lat_max = sorted([SLat, ELat]) 

717 

718 # Midpoints 

719 lon_mid = (lon_min + lon_max) / 2 

720 lat_mid = (lat_min + lat_max) / 2 

721 

722 # Maximum half-range 

723 half_range = max(lon_max - lon_min, lat_max - lat_min) / 2 

724 if half_range == 0: # points identical → provide small default 724 ↛ 728line 724 didn't jump to line 728 because the condition on line 724 was always true

725 half_range = 1 

726 

727 # Set square extent 

728 axins.set_extent( 

729 [ 

730 lon_mid - half_range, 

731 lon_mid + half_range, 

732 lat_mid - half_range, 

733 lat_mid + half_range, 

734 ], 

735 crs=ccrs.PlateCarree(), 

736 ) 

737 

738 # Ensure square aspect 

739 axins.set_aspect("equal") 

740 

741 else: 

742 # Add title. 

743 axes.set_title(title, fontsize=16) 

744 

745 # Adjust padding if spatial plot or transect 

746 if is_transect(cube): 

747 yinfopad = -0.1 

748 ycbarpad = 0.1 

749 else: 

750 yinfopad = 0.01 

751 ycbarpad = 0.042 

752 

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

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

755 axes.annotate( 

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

757 xy=(1, yinfopad), 

758 xycoords="axes fraction", 

759 xytext=(-5, 5), 

760 textcoords="offset points", 

761 ha="right", 

762 va="bottom", 

763 size=11, 

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

765 ) 

766 

767 # Add secondary colour bar for overlay_cube field if required. 

768 if overlay_cube: 

769 cbarB = fig.colorbar( 

770 overlay, orientation="horizontal", location="bottom", pad=0.0, shrink=0.7 

771 ) 

772 cbarB.set_label(label=f"{overlay_cube.name()} ({overlay_cube.units})", size=14) 

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

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

775 cbarB.set_ticks(over_levels) 

776 cbarB.set_ticklabels([f"{level:.2f}" for level in over_levels]) 

777 if "rainfall" or "snowfall" or "visibility" in overlay_cube.name(): 

778 cbarB.set_ticklabels([f"{level:.3g}" for level in over_levels]) 

779 logging.debug("Set secondary colorbar ticks and labels.") 

780 

781 # Add main colour bar. 

782 cbar = fig.colorbar( 

783 plot, orientation="horizontal", location="bottom", pad=ycbarpad, shrink=0.7 

784 ) 

785 

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

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

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

789 cbar.set_ticks(levels) 

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

791 if "rainfall" or "snowfall" or "visibility" in cube.name(): 791 ↛ 793line 791 didn't jump to line 793 because the condition on line 791 was always true

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

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

794 

795 # Save plot. 

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

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

798 plt.close(fig) 

799 

800 

801def _plot_and_save_postage_stamp_spatial_plot( 

802 cube: iris.cube.Cube, 

803 filename: str, 

804 stamp_coordinate: str, 

805 title: str, 

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

807 overlay_cube: iris.cube.Cube | None = None, 

808 contour_cube: iris.cube.Cube | None = None, 

809 **kwargs, 

810): 

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

812 

813 Parameters 

814 ---------- 

815 cube: Cube 

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

817 filename: str 

818 Filename of the plot to write. 

819 stamp_coordinate: str 

820 Coordinate that becomes different plots. 

821 method: "contourf" | "pcolormesh" 

822 The plotting method to use. 

823 overlay_cube: Cube, optional 

824 Optional 2 dimensional (lat and lon) Cube of data to overplot on top of base cube 

825 contour_cube: Cube, optional 

826 Optional 2 dimensional (lat and lon) Cube of data to overplot as contours over base cube 

827 

828 Raises 

829 ------ 

830 ValueError 

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

832 """ 

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

834 nmember = len(cube.coord(stamp_coordinate).points) 

835 grid_size = int(math.ceil(math.sqrt(nmember))) 

836 grid_rows = math.ceil(nmember / grid_size) 

837 

838 fig = plt.figure( 

839 figsize=(10, 10 * grid_rows / grid_size), facecolor="w", edgecolor="k" 

840 ) 

841 

842 # Specify the color bar 

843 cmap, levels, norm = _colorbar_map_levels(cube) 

844 # If overplotting, set required colorbars 

845 if overlay_cube: 845 ↛ 846line 845 didn't jump to line 846 because the condition on line 845 was never true

846 over_cmap, over_levels, over_norm = _colorbar_map_levels(overlay_cube) 

847 if contour_cube: 847 ↛ 848line 847 didn't jump to line 848 because the condition on line 847 was never true

848 cntr_cmap, cntr_levels, cntr_norm = _colorbar_map_levels(contour_cube) 

849 

850 # Make a subplot for each member. 

851 for member, subplot in zip( 

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

853 ): 

854 # Setup subplot map projection, extent and coastlines and borderlines. 

855 axes = _setup_spatial_map( 

856 member, fig, cmap, grid_size=(grid_size, grid_rows), subplot=subplot 

857 ) 

858 if method == "contourf": 

859 # Filled contour plot of the field. 

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

861 elif method == "pcolormesh": 

862 if levels is not None: 

863 vmin = min(levels) 

864 vmax = max(levels) 

865 else: 

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

867 vmin, vmax = None, None 

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

869 # if levels are defined. 

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

871 vmin = None 

872 vmax = None 

873 # pcolormesh plot of the field. 

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

875 else: 

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

877 

878 # Overplot overlay field, if required 

879 if overlay_cube: 879 ↛ 880line 879 didn't jump to line 880 because the condition on line 879 was never true

880 try: 

881 over_vmin = min(over_levels) 

882 over_vmax = max(over_levels) 

883 except TypeError: 

884 over_vmin, over_vmax = None, None 

885 if over_norm is not None: 

886 over_vmin = None 

887 over_vmax = None 

888 iplt.pcolormesh( 

889 overlay_cube[member.coord(stamp_coordinate).points[0]], 

890 cmap=over_cmap, 

891 norm=over_norm, 

892 alpha=0.6, 

893 vmin=over_vmin, 

894 vmax=over_vmax, 

895 ) 

896 # Overplot contour field, if required 

897 if contour_cube: 897 ↛ 898line 897 didn't jump to line 898 because the condition on line 897 was never true

898 iplt.contour( 

899 contour_cube[member.coord(stamp_coordinate).points[0]], 

900 colors="darkgray", 

901 levels=cntr_levels, 

902 norm=cntr_norm, 

903 alpha=0.6, 

904 linestyles="--", 

905 linewidths=1, 

906 ) 

907 mtitle = member.coord(stamp_coordinate).name().capitalize() 

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

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

910 axes.set_axis_off() 

911 

912 # Put the shared colorbar in its own axes. 

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

914 colorbar = fig.colorbar( 

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

916 ) 

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

918 

919 # Overall figure title. 

920 fig.suptitle(title, fontsize=16) 

921 

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

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

924 plt.close(fig) 

925 

926 

927def _plot_and_save_line_series( 

928 cubes: iris.cube.CubeList, 

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

930 ensemble_coord: str, 

931 filename: str, 

932 title: str, 

933 **kwargs, 

934): 

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

936 

937 Parameters 

938 ---------- 

939 cubes: Cube or CubeList 

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

941 coords: list[Coord] 

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

943 ensemble_coord: str 

944 Ensemble coordinate in the cube. 

945 filename: str 

946 Filename of the plot to write. 

947 title: str 

948 Plot title. 

949 """ 

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

951 

952 model_colors_map = _get_model_colors_map(cubes) 

953 

954 # Store min/max ranges. 

955 y_levels = [] 

956 

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

958 _validate_cubes_coords(cubes, coords) 

959 

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

961 label = None 

962 color = "black" 

963 if model_colors_map: 

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

965 color = model_colors_map.get(label) 

966 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

969 iplt.plot( 

970 coord, 

971 cube_slice, 

972 color=color, 

973 marker="o", 

974 ls="-", 

975 lw=3, 

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

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

978 else label, 

979 ) 

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

981 else: 

982 iplt.plot( 

983 coord, 

984 cube_slice, 

985 color=color, 

986 ls="-", 

987 lw=1.5, 

988 alpha=0.75, 

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

990 ) 

991 

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

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

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

995 y_levels.append(min(levels)) 

996 y_levels.append(max(levels)) 

997 

998 # Get the current axes. 

999 ax = plt.gca() 

1000 

1001 # Add some labels and tweak the style. 

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

1003 if coords[0].name() == "time": 

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

1005 else: 

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

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

1008 ax.set_title(title, fontsize=16) 

1009 

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

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

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

1013 

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

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

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

1017 # Add zero line. 

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

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

1020 logging.debug( 

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

1022 ) 

1023 else: 

1024 ax.autoscale() 

1025 

1026 # Add gridlines 

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

1028 # Ientify unique labels for legend 

1029 handles = list( 

1030 { 

1031 label: handle 

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

1033 }.values() 

1034 ) 

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

1036 

1037 # Save plot. 

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

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

1040 plt.close(fig) 

1041 

1042 

1043def _plot_and_save_vertical_line_series( 

1044 cubes: iris.cube.CubeList, 

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

1046 ensemble_coord: str, 

1047 filename: str, 

1048 series_coordinate: str, 

1049 title: str, 

1050 vmin: float, 

1051 vmax: float, 

1052 **kwargs, 

1053): 

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

1055 

1056 Parameters 

1057 ---------- 

1058 cubes: CubeList 

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

1060 coord: list[Coord] 

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

1062 ensemble_coord: str 

1063 Ensemble coordinate in the cube. 

1064 filename: str 

1065 Filename of the plot to write. 

1066 series_coordinate: str 

1067 Coordinate to use as vertical axis. 

1068 title: str 

1069 Plot title. 

1070 vmin: float 

1071 Minimum value for the x-axis. 

1072 vmax: float 

1073 Maximum value for the x-axis. 

1074 """ 

1075 # plot the vertical pressure axis using log scale 

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

1077 

1078 model_colors_map = _get_model_colors_map(cubes) 

1079 

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

1081 _validate_cubes_coords(cubes, coords) 

1082 

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

1084 label = None 

1085 color = "black" 

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

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

1088 color = model_colors_map.get(label) 

1089 

1090 for cube_slice in cube.slices_over(ensemble_coord): 

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

1092 # unless single forecast. 

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

1094 iplt.plot( 

1095 cube_slice, 

1096 coord, 

1097 color=color, 

1098 marker="o", 

1099 ls="-", 

1100 lw=3, 

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

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

1103 else label, 

1104 ) 

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

1106 else: 

1107 iplt.plot( 

1108 cube_slice, 

1109 coord, 

1110 color=color, 

1111 ls="-", 

1112 lw=1.5, 

1113 alpha=0.75, 

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

1115 ) 

1116 

1117 # Get the current axis 

1118 ax = plt.gca() 

1119 

1120 # Special handling for pressure level data. 

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

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

1123 ax.invert_yaxis() 

1124 ax.set_yscale("log") 

1125 

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

1127 y_tick_labels = [ 

1128 "1000", 

1129 "850", 

1130 "700", 

1131 "500", 

1132 "300", 

1133 "200", 

1134 "100", 

1135 ] 

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

1137 

1138 # Set y-axis limits and ticks. 

1139 ax.set_ylim(1100, 100) 

1140 

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

1142 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

1148 

1149 ax.set_yticks(y_ticks) 

1150 ax.set_yticklabels(y_tick_labels) 

1151 

1152 # Set x-axis limits. 

1153 ax.set_xlim(vmin, vmax) 

1154 # Mark y=0 if present in plot. 

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

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

1157 

1158 # Add some labels and tweak the style. 

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

1160 ax.set_xlabel( 

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

1162 ) 

1163 ax.set_title(title, fontsize=16) 

1164 ax.ticklabel_format(axis="x") 

1165 ax.tick_params(axis="y") 

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

1167 

1168 # Add gridlines 

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

1170 # Ientify unique labels for legend 

1171 handles = list( 

1172 { 

1173 label: handle 

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

1175 }.values() 

1176 ) 

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

1178 

1179 # Save plot. 

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

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

1182 plt.close(fig) 

1183 

1184 

1185def _plot_and_save_scatter_plot( 

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

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

1188 filename: str, 

1189 title: str, 

1190 one_to_one: bool, 

1191 model_names: list[str] = None, 

1192 **kwargs, 

1193): 

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

1195 

1196 Parameters 

1197 ---------- 

1198 cube_x: Cube | CubeList 

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

1200 cube_y: Cube | CubeList 

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

1202 filename: str 

1203 Filename of the plot to write. 

1204 title: str 

1205 Plot title. 

1206 one_to_one: bool 

1207 Whether a 1:1 line is plotted. 

1208 """ 

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

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

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

1212 # over the pairs simultaneously. 

1213 

1214 # Ensure cube_x and cube_y are iterable 

1215 cube_x_iterable = iter_maybe(cube_x) 

1216 cube_y_iterable = iter_maybe(cube_y) 

1217 

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

1219 iplt.scatter(cube_x_iter, cube_y_iter) 

1220 if one_to_one is True: 

1221 plt.plot( 

1222 [ 

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

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

1225 ], 

1226 [ 

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

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

1229 ], 

1230 "k", 

1231 linestyle="--", 

1232 ) 

1233 ax = plt.gca() 

1234 

1235 # Add some labels and tweak the style. 

1236 if model_names is None: 

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

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

1239 else: 

1240 # Add the model names, these should be order of base (x) and other (y). 

1241 ax.set_xlabel( 

1242 f"{model_names[0]}_{cube_x[0].name()} / {cube_x[0].units}", fontsize=14 

1243 ) 

1244 ax.set_ylabel( 

1245 f"{model_names[1]}_{cube_y[0].name()} / {cube_y[0].units}", fontsize=14 

1246 ) 

1247 ax.set_title(title, fontsize=16) 

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

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

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

1251 ax.autoscale() 

1252 

1253 # Save plot. 

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

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

1256 plt.close(fig) 

1257 

1258 

1259def _plot_and_save_vector_plot( 

1260 cube_u: iris.cube.Cube, 

1261 cube_v: iris.cube.Cube, 

1262 filename: str, 

1263 title: str, 

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

1265 **kwargs, 

1266): 

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

1268 

1269 Parameters 

1270 ---------- 

1271 cube_u: Cube 

1272 2 dimensional Cube of u component of the data. 

1273 cube_v: Cube 

1274 2 dimensional Cube of v component of the data. 

1275 filename: str 

1276 Filename of the plot to write. 

1277 title: str 

1278 Plot title. 

1279 """ 

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

1281 

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

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

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

1285 

1286 # Specify the color bar 

1287 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag) 

1288 

1289 # Setup plot map projection, extent and coastlines and borderlines. 

1290 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

1291 

1292 if method == "contourf": 

1293 # Filled contour plot of the field. 

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

1295 elif method == "pcolormesh": 

1296 try: 

1297 vmin = min(levels) 

1298 vmax = max(levels) 

1299 except TypeError: 

1300 vmin, vmax = None, None 

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

1302 # if levels are defined. 

1303 if norm is not None: 

1304 vmin = None 

1305 vmax = None 

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

1307 else: 

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

1309 

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

1311 if is_transect(cube_vec_mag): 

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

1313 axes.invert_yaxis() 

1314 axes.set_yscale("log") 

1315 axes.set_ylim(1100, 100) 

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

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

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

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

1320 ): 

1321 axes.set_yscale("log") 

1322 

1323 axes.set_title( 

1324 f"{title}\n" 

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

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

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

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

1329 fontsize=16, 

1330 ) 

1331 

1332 else: 

1333 # Add title. 

1334 axes.set_title(title, fontsize=16) 

1335 

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

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

1338 axes.annotate( 

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

1340 xy=(1, -0.05), 

1341 xycoords="axes fraction", 

1342 xytext=(-5, 5), 

1343 textcoords="offset points", 

1344 ha="right", 

1345 va="bottom", 

1346 size=11, 

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

1348 ) 

1349 

1350 # Add colour bar. 

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

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

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

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

1355 cbar.set_ticks(levels) 

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

1357 

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

1359 # with less than 30 points. 

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

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

1362 

1363 # Save plot. 

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

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

1366 plt.close(fig) 

1367 

1368 

1369def _plot_and_save_histogram_series( 

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

1371 filename: str, 

1372 title: str, 

1373 vmin: float, 

1374 vmax: float, 

1375 **kwargs, 

1376): 

1377 """Plot and save a histogram series. 

1378 

1379 Parameters 

1380 ---------- 

1381 cubes: Cube or CubeList 

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

1383 filename: str 

1384 Filename of the plot to write. 

1385 title: str 

1386 Plot title. 

1387 vmin: float 

1388 minimum for colorbar 

1389 vmax: float 

1390 maximum for colorbar 

1391 """ 

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

1393 ax = plt.gca() 

1394 

1395 model_colors_map = _get_model_colors_map(cubes) 

1396 

1397 # Set default that histograms will produce probability density function 

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

1399 density = True 

1400 

1401 for cube in iter_maybe(cubes): 

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

1403 # than seeing if long names exist etc. 

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

1405 if "surface_microphysical" in title: 

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

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

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

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

1410 density = False 

1411 else: 

1412 bins = 10.0 ** ( 

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

1414 ) # Suggestion from RMED toolbox. 

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

1416 ax.set_yscale("log") 

1417 vmin = bins[1] 

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

1419 ax.set_xscale("log") 

1420 elif "lightning" in title: 

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

1422 else: 

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

1424 logging.debug( 

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

1426 np.size(bins), 

1427 np.min(bins), 

1428 np.max(bins), 

1429 ) 

1430 

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

1432 # Otherwise we plot xdim histograms stacked. 

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

1434 

1435 label = None 

1436 color = "black" 

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

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

1439 color = model_colors_map[label] 

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

1441 

1442 # Compute area under curve. 

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

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

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

1446 x = x[1:] 

1447 y = y[1:] 

1448 

1449 ax.plot( 

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

1451 ) 

1452 

1453 # Add some labels and tweak the style. 

1454 ax.set_title(title, fontsize=16) 

1455 ax.set_xlabel( 

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

1457 ) 

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

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

1460 ax.set_ylabel( 

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

1462 ) 

1463 ax.set_xlim(vmin, vmax) 

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

1465 

1466 # Overlay grid-lines onto histogram plot. 

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

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

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

1470 

1471 # Save plot. 

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

1473 logging.info("Saved histogram plot to %s", filename) 

1474 plt.close(fig) 

1475 

1476 

1477def _plot_and_save_postage_stamp_histogram_series( 

1478 cube: iris.cube.Cube, 

1479 filename: str, 

1480 title: str, 

1481 stamp_coordinate: str, 

1482 vmin: float, 

1483 vmax: float, 

1484 **kwargs, 

1485): 

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

1487 

1488 Parameters 

1489 ---------- 

1490 cube: Cube 

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

1492 filename: str 

1493 Filename of the plot to write. 

1494 title: str 

1495 Plot title. 

1496 stamp_coordinate: str 

1497 Coordinate that becomes different plots. 

1498 vmin: float 

1499 minimum for pdf x-axis 

1500 vmax: float 

1501 maximum for pdf x-axis 

1502 """ 

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

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

1505 

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

1507 # Make a subplot for each member. 

1508 for member, subplot in zip( 

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

1510 ): 

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

1512 # cartopy GeoAxes generated. 

1513 plt.subplot(grid_size, grid_size, subplot) 

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

1515 # Otherwise we plot xdim histograms stacked. 

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

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

1518 axes = plt.gca() 

1519 

1520 mtitle = member.coord(stamp_coordinate).name().capitalize() 

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

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

1523 axes.set_xlim(vmin, vmax) 

1524 

1525 # Overall figure title. 

1526 fig.suptitle(title, fontsize=16) 

1527 

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

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

1530 plt.close(fig) 

1531 

1532 

1533def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1534 cube: iris.cube.Cube, 

1535 filename: str, 

1536 title: str, 

1537 stamp_coordinate: str, 

1538 vmin: float, 

1539 vmax: float, 

1540 **kwargs, 

1541): 

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

1543 ax.set_title(title, fontsize=16) 

1544 ax.set_xlim(vmin, vmax) 

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

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

1547 # Loop over all slices along the stamp_coordinate 

1548 for member in cube.slices_over(stamp_coordinate): 

1549 # Flatten the member data to 1D 

1550 member_data_1d = member.data.flatten() 

1551 # Plot the histogram using plt.hist 

1552 mtitle = member.coord(stamp_coordinate).name().capitalize() 

1553 plt.hist( 

1554 member_data_1d, 

1555 density=True, 

1556 stacked=True, 

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

1558 ) 

1559 

1560 # Add a legend 

1561 ax.legend(fontsize=16) 

1562 

1563 # Save the figure to a file 

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

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

1566 

1567 # Close the figure 

1568 plt.close(fig) 

1569 

1570 

1571def _plot_and_save_scattermap_plot( 

1572 cube: iris.cube.Cube, filename: str, title: str, projection=None, **kwargs 

1573): 

1574 """Plot and save a geographical scatter plot. 

1575 

1576 Parameters 

1577 ---------- 

1578 cube: Cube 

1579 1 dimensional Cube of the data points with auxiliary latitude and 

1580 longitude coordinates, 

1581 filename: str 

1582 Filename of the plot to write. 

1583 title: str 

1584 Plot title. 

1585 projection: str 

1586 Mapping projection to be used by cartopy. 

1587 """ 

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

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

1590 if projection is not None: 

1591 # Apart from the default, the only projection we currently support is 

1592 # a stereographic projection over the North Pole. 

1593 if projection == "NP_Stereo": 

1594 axes = plt.axes(projection=ccrs.NorthPolarStereo(central_longitude=0.0)) 

1595 else: 

1596 raise ValueError(f"Unknown projection: {projection}") 

1597 else: 

1598 axes = plt.axes(projection=ccrs.PlateCarree()) 

1599 

1600 # Scatter plot of the field. The marker size is chosen to give 

1601 # symbols that decrease in size as the number of observations 

1602 # increases, although the fraction of the figure covered by 

1603 # symbols increases roughly as N^(1/2), disregarding overlaps, 

1604 # and has been selected for the default figure size of (10, 10). 

1605 # Should this be changed, the marker size should be adjusted in 

1606 # proportion to the area of the figure. 

1607 mrk_size = int(np.sqrt(2500000.0 / len(cube.data))) 

1608 klon = None 

1609 klat = None 

1610 for kc in range(len(cube.aux_coords)): 

1611 if cube.aux_coords[kc].standard_name == "latitude": 

1612 klat = kc 

1613 elif cube.aux_coords[kc].standard_name == "longitude": 

1614 klon = kc 

1615 scatter_map = iplt.scatter( 

1616 cube.aux_coords[klon], 

1617 cube.aux_coords[klat], 

1618 c=cube.data[:], 

1619 s=mrk_size, 

1620 cmap="jet", 

1621 edgecolors="k", 

1622 ) 

1623 

1624 # Add coastlines and borderlines. 

1625 try: 

1626 axes.coastlines(resolution="10m") 

1627 axes.add_feature(cfeature.BORDERS) 

1628 except AttributeError: 

1629 pass 

1630 

1631 # Add title. 

1632 axes.set_title(title, fontsize=16) 

1633 

1634 # Add colour bar. 

1635 cbar = fig.colorbar(scatter_map) 

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

1637 

1638 # Save plot. 

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

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

1641 plt.close(fig) 

1642 

1643 

1644def _plot_and_save_power_spectrum_series( 

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

1646 filename: str, 

1647 title: str, 

1648 **kwargs, 

1649): 

1650 """Plot and save a power spectrum series. 

1651 

1652 Parameters 

1653 ---------- 

1654 cubes: Cube or CubeList 

1655 2 dimensional Cube or CubeList of the data to plot as power spectrum. 

1656 filename: str 

1657 Filename of the plot to write. 

1658 title: str 

1659 Plot title. 

1660 """ 

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

1662 ax = plt.gca() 

1663 

1664 model_colors_map = _get_model_colors_map(cubes) 

1665 

1666 for cube in iter_maybe(cubes): 

1667 # Calculate power spectrum 

1668 

1669 # Extract time coordinate and convert to datetime 

1670 time_coord = cube.coord("time") 

1671 time_points = time_coord.units.num2date(time_coord.points) 

1672 

1673 # Choose one time point (e.g., the first one) 

1674 target_time = time_points[0] 

1675 

1676 # Bind target_time inside the lambda using a default argument 

1677 time_constraint = iris.Constraint( 

1678 time=lambda cell, target_time=target_time: cell.point == target_time 

1679 ) 

1680 

1681 cube = cube.extract(time_constraint) 

1682 

1683 if cube.ndim == 2: 

1684 cube_3d = cube.data[np.newaxis, :, :] 

1685 logging.debug("Adding in new axis for a 2 dimensional cube.") 

1686 elif cube.ndim == 3: 1686 ↛ 1687line 1686 didn't jump to line 1687 because the condition on line 1686 was never true

1687 cube_3d = cube.data 

1688 else: 

1689 raise ValueError("Cube dimensions unsuitable for power spectra code") 

1690 raise ValueError( 

1691 f"Cube is {cube.ndim} dimensional. Cube should be 2 or 3 dimensional." 

1692 ) 

1693 

1694 # Calculate spectra 

1695 ps_array = _DCT_ps(cube_3d) 

1696 

1697 ps_cube = iris.cube.Cube( 

1698 ps_array, 

1699 long_name="power_spectra", 

1700 ) 

1701 

1702 ps_cube.attributes["model_name"] = cube.attributes.get("model_name") 

1703 

1704 # Create a frequency/wavelength array for coordinate 

1705 ps_len = ps_cube.data.shape[1] 

1706 freqs = np.arange(1, ps_len + 1) 

1707 freq_coord = iris.coords.DimCoord(freqs, long_name="frequency", units="1") 

1708 

1709 # Convert datetime to numeric time using original units 

1710 numeric_time = time_coord.units.date2num(time_points) 

1711 # Create a new DimCoord with numeric time 

1712 new_time_coord = iris.coords.DimCoord( 

1713 numeric_time, standard_name="time", units=time_coord.units 

1714 ) 

1715 

1716 # Add time and frequency coordinate to spectra cube. 

1717 ps_cube.add_dim_coord(new_time_coord.copy(), 0) 

1718 ps_cube.add_dim_coord(freq_coord.copy(), 1) 

1719 

1720 # Extract data from the cube 

1721 frequency = ps_cube.coord("frequency").points 

1722 power_spectrum = ps_cube.data 

1723 

1724 label = None 

1725 color = "black" 

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

1727 label = ps_cube.attributes.get("model_name") 

1728 color = model_colors_map[label] 

1729 ax.plot(frequency, power_spectrum[0], color=color, label=label) 

1730 

1731 # Add some labels and tweak the style. 

1732 ax.set_title(title, fontsize=16) 

1733 ax.set_xlabel("Wavenumber", fontsize=14) 

1734 ax.set_ylabel("Power", fontsize=14) 

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

1736 

1737 # Set log-log scale 

1738 ax.set_xscale("log") 

1739 ax.set_yscale("log") 

1740 

1741 # Overlay grid-lines onto power spectrum plot. 

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

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

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

1745 

1746 # Save plot. 

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

1748 logging.info("Saved power spectrum plot to %s", filename) 

1749 plt.close(fig) 

1750 

1751 

1752def _plot_and_save_postage_stamp_power_spectrum_series( 

1753 cube: iris.cube.Cube, 

1754 filename: str, 

1755 title: str, 

1756 stamp_coordinate: str, 

1757 **kwargs, 

1758): 

1759 """Plot and save postage (ensemble members) stamps for a power spectrum series. 

1760 

1761 Parameters 

1762 ---------- 

1763 cube: Cube 

1764 2 dimensional Cube of the data to plot as power spectrum. 

1765 filename: str 

1766 Filename of the plot to write. 

1767 title: str 

1768 Plot title. 

1769 stamp_coordinate: str 

1770 Coordinate that becomes different plots. 

1771 """ 

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

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

1774 

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

1776 # Make a subplot for each member. 

1777 for member, subplot in zip( 

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

1779 ): 

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

1781 # cartopy GeoAxes generated. 

1782 plt.subplot(grid_size, grid_size, subplot) 

1783 

1784 frequency = member.coord("frequency").points 

1785 

1786 axes = plt.gca() 

1787 axes.plot(frequency, member.data) 

1788 mtitle = member.coord(stamp_coordinate).name().capitalize() 

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

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

1791 

1792 # Overall figure title. 

1793 fig.suptitle(title, fontsize=16) 

1794 

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

1796 logging.info("Saved power spectra postage stamp plot to %s", filename) 

1797 plt.close(fig) 

1798 

1799 

1800def _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series( 

1801 cube: iris.cube.Cube, 

1802 filename: str, 

1803 title: str, 

1804 stamp_coordinate: str, 

1805 **kwargs, 

1806): 

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

1808 ax.set_title(title, fontsize=16) 

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

1810 ax.set_ylabel("Power", fontsize=14) 

1811 # Loop over all slices along the stamp_coordinate 

1812 for member in cube.slices_over(stamp_coordinate): 

1813 frequency = member.coord("frequency").points 

1814 mtitle = member.coord(stamp_coordinate).name().capitalize() 

1815 ax.plot( 

1816 frequency, 

1817 member.data, 

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

1819 ) 

1820 

1821 # Add a legend 

1822 ax.legend(fontsize=16) 

1823 

1824 # Save the figure to a file 

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

1826 logging.info("Saved power spectra plot to %s", filename) 

1827 

1828 # Close the figure 

1829 plt.close(fig) 

1830 

1831 

1832def _spatial_plot( 

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

1834 cube: iris.cube.Cube, 

1835 filename: str | None, 

1836 sequence_coordinate: str, 

1837 stamp_coordinate: str, 

1838 overlay_cube: iris.cube.Cube | None = None, 

1839 contour_cube: iris.cube.Cube | None = None, 

1840 **kwargs, 

1841): 

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

1843 

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

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

1846 is present then postage stamp plots will be produced. 

1847 

1848 If an overlay_cube and/or contour_cube are specified, multiple variables can 

1849 be overplotted on the same figure. 

1850 

1851 Parameters 

1852 ---------- 

1853 method: "contourf" | "pcolormesh" 

1854 The plotting method to use. 

1855 cube: Cube 

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

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

1858 plotted sequentially and/or as postage stamp plots. 

1859 filename: str | None 

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

1861 uses the recipe name. 

1862 sequence_coordinate: str 

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

1864 This coordinate must exist in the cube. 

1865 stamp_coordinate: str 

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

1867 ``"realization"``. 

1868 overlay_cube: Cube | None, optional 

1869 Optional 2 dimensional (lat and lon) Cube of data to overplot on top of base cube 

1870 contour_cube: Cube | None, optional 

1871 Optional 2 dimensional (lat and lon) Cube of data to overplot as contours over base cube 

1872 

1873 Raises 

1874 ------ 

1875 ValueError 

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

1877 TypeError 

1878 If the cube isn't a single cube. 

1879 """ 

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

1881 

1882 # Ensure we've got a single cube. 

1883 cube = _check_single_cube(cube) 

1884 

1885 # Test if valid stamp coordinate in cube dimensions. 

1886 stamp_coordinate = check_stamp_coordinate(cube) 

1887 

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

1889 # single point. 

1890 plotting_func = _plot_and_save_spatial_plot 

1891 try: 

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

1893 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1894 except iris.exceptions.CoordinateNotFoundError: 

1895 pass 

1896 

1897 # Produce a geographical scatter plot if the data have a 

1898 # dimension called observation or model_obs_error 

1899 if any( 1899 ↛ 1903line 1899 didn't jump to line 1903 because the condition on line 1899 was never true

1900 crd.var_name == "station" or crd.var_name == "model_obs_error" 

1901 for crd in cube.coords() 

1902 ): 

1903 plotting_func = _plot_and_save_scattermap_plot 

1904 

1905 # Must have a sequence coordinate. 

1906 try: 

1907 cube.coord(sequence_coordinate) 

1908 except iris.exceptions.CoordinateNotFoundError as err: 

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

1910 

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

1912 plot_index = [] 

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

1914 

1915 for iseq, cube_slice in enumerate(cube.slices_over(sequence_coordinate)): 

1916 # Set plot titles and filename 

1917 seq_coord = cube_slice.coord(sequence_coordinate) 

1918 plot_title, plot_filename = _set_title_and_filename( 

1919 seq_coord, nplot, recipe_title, filename 

1920 ) 

1921 

1922 # Extract sequence slice for overlay_cube and contour_cube if required. 

1923 overlay_slice = slice_over_maybe(overlay_cube, sequence_coordinate, iseq) 

1924 contour_slice = slice_over_maybe(contour_cube, sequence_coordinate, iseq) 

1925 

1926 # Do the actual plotting. 

1927 plotting_func( 

1928 cube_slice, 

1929 filename=plot_filename, 

1930 stamp_coordinate=stamp_coordinate, 

1931 title=plot_title, 

1932 method=method, 

1933 overlay_cube=overlay_slice, 

1934 contour_cube=contour_slice, 

1935 **kwargs, 

1936 ) 

1937 plot_index.append(plot_filename) 

1938 

1939 # Add list of plots to plot metadata. 

1940 complete_plot_index = _append_to_plot_index(plot_index) 

1941 

1942 # Make a page to display the plots. 

1943 _make_plot_html_page(complete_plot_index) 

1944 

1945 

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

1947 """Get colourmap for mask. 

1948 

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

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

1951 

1952 Parameters 

1953 ---------- 

1954 cube: Cube 

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

1956 axis: "x", "y", optional 

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

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

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

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

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

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

1963 

1964 Returns 

1965 ------- 

1966 cmap: 

1967 Matplotlib colormap. 

1968 levels: 

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

1970 should be taken as the range. 

1971 norm: 

1972 BoundaryNorm information. 

1973 """ 

1974 if "difference" not in cube.long_name: 

1975 if axis: 

1976 levels = [0, 1] 

1977 # Complete settings based on levels. 

1978 return None, levels, None 

1979 else: 

1980 # Define the levels and colors. 

1981 levels = [0, 1, 2] 

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

1983 # Create a custom color map. 

1984 cmap = mcolors.ListedColormap(colors) 

1985 # Normalize the levels. 

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

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

1988 return cmap, levels, norm 

1989 else: 

1990 if axis: 

1991 levels = [-1, 1] 

1992 return None, levels, None 

1993 else: 

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

1995 # not <=. 

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

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

1998 cmap = mcolors.ListedColormap(colors) 

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

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

2001 return cmap, levels, norm 

2002 

2003 

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

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

2006 

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

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

2009 

2010 Parameters 

2011 ---------- 

2012 cube: Cube 

2013 Cube of variable with Beaufort Scale in name. 

2014 axis: "x", "y", optional 

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

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

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

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

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

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

2021 

2022 Returns 

2023 ------- 

2024 cmap: 

2025 Matplotlib colormap. 

2026 levels: 

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

2028 should be taken as the range. 

2029 norm: 

2030 BoundaryNorm information. 

2031 """ 

2032 if "difference" not in cube.long_name: 

2033 if axis: 

2034 levels = [0, 12] 

2035 return None, levels, None 

2036 else: 

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

2038 colors = [ 

2039 "black", 

2040 (0, 0, 0.6), 

2041 "blue", 

2042 "cyan", 

2043 "green", 

2044 "yellow", 

2045 (1, 0.5, 0), 

2046 "red", 

2047 "pink", 

2048 "magenta", 

2049 "purple", 

2050 "maroon", 

2051 "white", 

2052 ] 

2053 cmap = mcolors.ListedColormap(colors) 

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

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

2056 return cmap, levels, norm 

2057 else: 

2058 if axis: 

2059 levels = [-4, 4] 

2060 return None, levels, None 

2061 else: 

2062 levels = [ 

2063 -3.5, 

2064 -2.5, 

2065 -1.5, 

2066 -0.5, 

2067 0.5, 

2068 1.5, 

2069 2.5, 

2070 3.5, 

2071 ] 

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

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

2074 return cmap, levels, norm 

2075 

2076 

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

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

2079 

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

2081 

2082 Parameters 

2083 ---------- 

2084 cube: Cube 

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

2086 cmap: Matplotlib colormap. 

2087 levels: List 

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

2089 should be taken as the range. 

2090 norm: BoundaryNorm. 

2091 

2092 Returns 

2093 ------- 

2094 cmap: Matplotlib colormap. 

2095 levels: List 

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

2097 should be taken as the range. 

2098 norm: BoundaryNorm. 

2099 """ 

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

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

2102 levels = np.array(levels) 

2103 levels -= 273 

2104 levels = levels.tolist() 

2105 else: 

2106 # Do nothing keep the existing colourbar attributes 

2107 levels = levels 

2108 cmap = cmap 

2109 norm = norm 

2110 return cmap, levels, norm 

2111 

2112 

2113def _custom_colormap_probability( 

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

2115): 

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

2117 

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

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

2120 

2121 Parameters 

2122 ---------- 

2123 cube: Cube 

2124 Cube of variable with probability in name. 

2125 axis: "x", "y", optional 

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

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

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

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

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

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

2132 

2133 Returns 

2134 ------- 

2135 cmap: 

2136 Matplotlib colormap. 

2137 levels: 

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

2139 should be taken as the range. 

2140 norm: 

2141 BoundaryNorm information. 

2142 """ 

2143 if axis: 

2144 levels = [0, 1] 

2145 return None, levels, None 

2146 else: 

2147 cmap = mcolors.ListedColormap( 

2148 [ 

2149 "#FFFFFF", 

2150 "#636363", 

2151 "#e1dada", 

2152 "#B5CAFF", 

2153 "#8FB3FF", 

2154 "#7F97FF", 

2155 "#ABCF63", 

2156 "#E8F59E", 

2157 "#FFFA14", 

2158 "#FFD121", 

2159 "#FFA30A", 

2160 ] 

2161 ) 

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

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

2164 return cmap, levels, norm 

2165 

2166 

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

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

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

2170 if ( 

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

2172 and "difference" not in cube.long_name 

2173 and "mask" not in cube.long_name 

2174 ): 

2175 # Define the levels and colors 

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

2177 colors = [ 

2178 "w", 

2179 (0, 0, 0.6), 

2180 "b", 

2181 "c", 

2182 "g", 

2183 "y", 

2184 (1, 0.5, 0), 

2185 "r", 

2186 "pink", 

2187 "m", 

2188 "purple", 

2189 "maroon", 

2190 "gray", 

2191 ] 

2192 # Create a custom colormap 

2193 cmap = mcolors.ListedColormap(colors) 

2194 # Normalize the levels 

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

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

2197 else: 

2198 # do nothing and keep existing colorbar attributes 

2199 cmap = cmap 

2200 levels = levels 

2201 norm = norm 

2202 return cmap, levels, norm 

2203 

2204 

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

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

2207 

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

2209 this function will be called. 

2210 

2211 Parameters 

2212 ---------- 

2213 cube: Cube 

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

2215 

2216 Returns 

2217 ------- 

2218 cmap: Matplotlib colormap. 

2219 levels: List 

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

2221 should be taken as the range. 

2222 norm: BoundaryNorm. 

2223 """ 

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

2225 colors = [ 

2226 "#87ceeb", 

2227 "#ffffff", 

2228 "#8ced69", 

2229 "#ffff00", 

2230 "#ffd700", 

2231 "#ffa500", 

2232 "#fe3620", 

2233 ] 

2234 # Create a custom colormap 

2235 cmap = mcolors.ListedColormap(colors) 

2236 # Normalise the levels 

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

2238 return cmap, levels, norm 

2239 

2240 

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

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

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

2244 if ( 

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

2246 and "difference" not in cube.long_name 

2247 and "mask" not in cube.long_name 

2248 ): 

2249 # Define the levels and colors (in km) 

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

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

2252 colours = [ 

2253 "#8f00d6", 

2254 "#d10000", 

2255 "#ff9700", 

2256 "#ffff00", 

2257 "#00007f", 

2258 "#6c9ccd", 

2259 "#aae8ff", 

2260 "#37a648", 

2261 "#8edc64", 

2262 "#c5ffc5", 

2263 "#dcdcdc", 

2264 "#ffffff", 

2265 ] 

2266 # Create a custom colormap 

2267 cmap = mcolors.ListedColormap(colours) 

2268 # Normalize the levels 

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

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

2271 else: 

2272 # do nothing and keep existing colorbar attributes 

2273 cmap = cmap 

2274 levels = levels 

2275 norm = norm 

2276 return cmap, levels, norm 

2277 

2278 

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

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

2281 model_names = list( 

2282 filter( 

2283 lambda x: x is not None, 

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

2285 ) 

2286 ) 

2287 if not model_names: 

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

2289 return 1 

2290 else: 

2291 return len(model_names) 

2292 

2293 

2294def _validate_cube_shape( 

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

2296) -> None: 

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

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

2299 raise ValueError( 

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

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

2302 ) 

2303 

2304 

2305def _validate_cubes_coords( 

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

2307) -> None: 

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

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

2310 raise ValueError( 

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

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

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

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

2315 ) 

2316 

2317 

2318#################### 

2319# Public functions # 

2320#################### 

2321 

2322 

2323def spatial_contour_plot( 

2324 cube: iris.cube.Cube, 

2325 filename: str = None, 

2326 sequence_coordinate: str = "time", 

2327 stamp_coordinate: str = "realization", 

2328 **kwargs, 

2329) -> iris.cube.Cube: 

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

2331 

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

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

2334 is present then postage stamp plots will be produced. 

2335 

2336 Parameters 

2337 ---------- 

2338 cube: Cube 

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

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

2341 plotted sequentially and/or as postage stamp plots. 

2342 filename: str, optional 

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

2344 to the recipe name. 

2345 sequence_coordinate: str, optional 

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

2347 This coordinate must exist in the cube. 

2348 stamp_coordinate: str, optional 

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

2350 ``"realization"``. 

2351 

2352 Returns 

2353 ------- 

2354 Cube 

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

2356 

2357 Raises 

2358 ------ 

2359 ValueError 

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

2361 TypeError 

2362 If the cube isn't a single cube. 

2363 """ 

2364 _spatial_plot( 

2365 "contourf", cube, filename, sequence_coordinate, stamp_coordinate, **kwargs 

2366 ) 

2367 return cube 

2368 

2369 

2370def spatial_pcolormesh_plot( 

2371 cube: iris.cube.Cube, 

2372 filename: str = None, 

2373 sequence_coordinate: str = "time", 

2374 stamp_coordinate: str = "realization", 

2375 **kwargs, 

2376) -> iris.cube.Cube: 

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

2378 

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

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

2381 is present then postage stamp plots will be produced. 

2382 

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

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

2385 contour areas are important. 

2386 

2387 Parameters 

2388 ---------- 

2389 cube: Cube 

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

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

2392 plotted sequentially and/or as postage stamp plots. 

2393 filename: str, optional 

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

2395 to the recipe name. 

2396 sequence_coordinate: str, optional 

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

2398 This coordinate must exist in the cube. 

2399 stamp_coordinate: str, optional 

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

2401 ``"realization"``. 

2402 

2403 Returns 

2404 ------- 

2405 Cube 

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

2407 

2408 Raises 

2409 ------ 

2410 ValueError 

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

2412 TypeError 

2413 If the cube isn't a single cube. 

2414 """ 

2415 _spatial_plot( 

2416 "pcolormesh", cube, filename, sequence_coordinate, stamp_coordinate, **kwargs 

2417 ) 

2418 return cube 

2419 

2420 

2421def spatial_multi_pcolormesh_plot( 

2422 cube: iris.cube.Cube, 

2423 overlay_cube: iris.cube.Cube, 

2424 contour_cube: iris.cube.Cube, 

2425 filename: str = None, 

2426 sequence_coordinate: str = "time", 

2427 stamp_coordinate: str = "realization", 

2428 **kwargs, 

2429) -> iris.cube.Cube: 

2430 """Plot a set of spatial variables onto a map from a 2D, 3D, or 4D cube. 

2431 

2432 A 2D basis cube spatial field can be plotted, but if the sequence_coordinate is present 

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

2434 is present then postage stamp plots will be produced. 

2435 

2436 If specified, a masked overlay_cube can be overplotted on top of the base cube. 

2437 

2438 If specified, contours of a contour_cube can be overplotted on top of those. 

2439 

2440 For single-variable equivalent of this routine, use spatial_pcolormesh_plot. 

2441 

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

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

2444 contour areas are important. 

2445 

2446 Parameters 

2447 ---------- 

2448 cube: Cube 

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

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

2451 plotted sequentially and/or as postage stamp plots. 

2452 overlay_cube: Cube 

2453 Iris cube of the data to plot as an overlay on top of basis cube. It should have two spatial dimensions, 

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

2455 plotted sequentially and/or as postage stamp plots. This is likely to be a masked cube in order not to hide the underlying basis cube. 

2456 contour_cube: Cube 

2457 Iris cube of the data to plot as a contour overlay on top of basis cube and overlay_cube. It should have two spatial dimensions, 

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

2459 plotted sequentially and/or as postage stamp plots. 

2460 filename: str, optional 

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

2462 to the recipe name. 

2463 sequence_coordinate: str, optional 

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

2465 This coordinate must exist in the cube. 

2466 stamp_coordinate: str, optional 

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

2468 ``"realization"``. 

2469 

2470 Returns 

2471 ------- 

2472 Cube 

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

2474 

2475 Raises 

2476 ------ 

2477 ValueError 

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

2479 TypeError 

2480 If the cube isn't a single cube. 

2481 """ 

2482 _spatial_plot( 

2483 "pcolormesh", 

2484 cube, 

2485 filename, 

2486 sequence_coordinate, 

2487 stamp_coordinate, 

2488 overlay_cube=overlay_cube, 

2489 contour_cube=contour_cube, 

2490 ) 

2491 return cube, overlay_cube, contour_cube 

2492 

2493 

2494# TODO: Expand function to handle ensemble data. 

2495# line_coordinate: str, optional 

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

2497# ``"realization"``. 

2498def plot_line_series( 

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

2500 filename: str = None, 

2501 series_coordinate: str = "time", 

2502 # line_coordinate: str = "realization", 

2503 **kwargs, 

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

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

2506 

2507 The Cube or CubeList must be 1D. 

2508 

2509 Parameters 

2510 ---------- 

2511 iris.cube | iris.cube.CubeList 

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

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

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

2515 filename: str, optional 

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

2517 to the recipe name. 

2518 series_coordinate: str, optional 

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

2520 coordinate must exist in the cube. 

2521 

2522 Returns 

2523 ------- 

2524 iris.cube.Cube | iris.cube.CubeList 

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

2526 plotted data. 

2527 

2528 Raises 

2529 ------ 

2530 ValueError 

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

2532 TypeError 

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

2534 """ 

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

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

2537 

2538 num_models = _get_num_models(cube) 

2539 

2540 _validate_cube_shape(cube, num_models) 

2541 

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

2543 cubes = iter_maybe(cube) 

2544 coords = [] 

2545 for cube in cubes: 

2546 try: 

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

2548 except iris.exceptions.CoordinateNotFoundError as err: 

2549 raise ValueError( 

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

2551 ) from err 

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

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

2554 

2555 # Format the title and filename using plotted series coordinate 

2556 nplot = 1 

2557 seq_coord = coords[0] 

2558 plot_title, plot_filename = _set_title_and_filename( 

2559 seq_coord, nplot, recipe_title, filename 

2560 ) 

2561 

2562 # Do the actual plotting. 

2563 _plot_and_save_line_series(cubes, coords, "realization", plot_filename, plot_title) 

2564 

2565 # Add list of plots to plot metadata. 

2566 plot_index = _append_to_plot_index([plot_filename]) 

2567 

2568 # Make a page to display the plots. 

2569 _make_plot_html_page(plot_index) 

2570 

2571 return cube 

2572 

2573 

2574def plot_vertical_line_series( 

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

2576 filename: str = None, 

2577 series_coordinate: str = "model_level_number", 

2578 sequence_coordinate: str = "time", 

2579 # line_coordinate: str = "realization", 

2580 **kwargs, 

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

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

2583 

2584 The Cube or CubeList must be 1D. 

2585 

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

2587 then a sequence of plots will be produced. 

2588 

2589 Parameters 

2590 ---------- 

2591 iris.cube | iris.cube.CubeList 

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

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

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

2595 filename: str, optional 

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

2597 to the recipe name. 

2598 series_coordinate: str, optional 

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

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

2601 for LFRic. Defaults to ``model_level_number``. 

2602 This coordinate must exist in the cube. 

2603 sequence_coordinate: str, optional 

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

2605 This coordinate must exist in the cube. 

2606 

2607 Returns 

2608 ------- 

2609 iris.cube.Cube | iris.cube.CubeList 

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

2611 Plotted data. 

2612 

2613 Raises 

2614 ------ 

2615 ValueError 

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

2617 TypeError 

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

2619 """ 

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

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

2622 

2623 cubes = iter_maybe(cubes) 

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

2625 all_data = [] 

2626 

2627 # Store min/max ranges for x range. 

2628 x_levels = [] 

2629 

2630 num_models = _get_num_models(cubes) 

2631 

2632 _validate_cube_shape(cubes, num_models) 

2633 

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

2635 coords = [] 

2636 for cube in cubes: 

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

2638 try: 

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

2640 except iris.exceptions.CoordinateNotFoundError as err: 

2641 raise ValueError( 

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

2643 ) from err 

2644 

2645 try: 

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

2647 cube.coord(sequence_coordinate) 

2648 except iris.exceptions.CoordinateNotFoundError as err: 

2649 raise ValueError( 

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

2651 ) from err 

2652 

2653 # Get minimum and maximum from levels information. 

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

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

2656 x_levels.append(min(levels)) 

2657 x_levels.append(max(levels)) 

2658 else: 

2659 all_data.append(cube.data) 

2660 

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

2662 # Combine all data into a single NumPy array 

2663 combined_data = np.concatenate(all_data) 

2664 

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

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

2667 # sequence and if applicable postage stamp coordinate. 

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

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

2670 else: 

2671 vmin = min(x_levels) 

2672 vmax = max(x_levels) 

2673 

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

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

2676 # necessary) 

2677 def filter_cube_iterables(cube_iterables) -> bool: 

2678 return len(cube_iterables) == len(coords) 

2679 

2680 cube_iterables = filter( 

2681 filter_cube_iterables, 

2682 ( 

2683 iris.cube.CubeList( 

2684 s 

2685 for s in itertools.chain.from_iterable( 

2686 cb.slices_over(sequence_coordinate) for cb in cubes 

2687 ) 

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

2689 ) 

2690 for point in sorted( 

2691 set( 

2692 itertools.chain.from_iterable( 

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

2694 ) 

2695 ) 

2696 ) 

2697 ), 

2698 ) 

2699 

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

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

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

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

2704 # or an iris.cube.CubeList. 

2705 plot_index = [] 

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

2707 for cubes_slice in cube_iterables: 

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

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

2710 plot_title, plot_filename = _set_title_and_filename( 

2711 seq_coord, nplot, recipe_title, filename 

2712 ) 

2713 

2714 # Do the actual plotting. 

2715 _plot_and_save_vertical_line_series( 

2716 cubes_slice, 

2717 coords, 

2718 "realization", 

2719 plot_filename, 

2720 series_coordinate, 

2721 title=plot_title, 

2722 vmin=vmin, 

2723 vmax=vmax, 

2724 ) 

2725 plot_index.append(plot_filename) 

2726 

2727 # Add list of plots to plot metadata. 

2728 complete_plot_index = _append_to_plot_index(plot_index) 

2729 

2730 # Make a page to display the plots. 

2731 _make_plot_html_page(complete_plot_index) 

2732 

2733 return cubes 

2734 

2735 

2736def qq_plot( 

2737 cubes: iris.cube.CubeList, 

2738 coordinates: list[str], 

2739 percentiles: list[float], 

2740 model_names: list[str], 

2741 filename: str = None, 

2742 one_to_one: bool = True, 

2743 **kwargs, 

2744) -> iris.cube.CubeList: 

2745 """Plot a Quantile-Quantile plot between two models for common time points. 

2746 

2747 The cubes will be normalised by collapsing each cube to its percentiles. Cubes are 

2748 collapsed within the operator over all specified coordinates such as 

2749 grid_latitude, grid_longitude, vertical levels, but also realisation representing 

2750 ensemble members to ensure a 1D cube (array). 

2751 

2752 Parameters 

2753 ---------- 

2754 cubes: iris.cube.CubeList 

2755 Two cubes of the same variable with different models. 

2756 coordinate: list[str] 

2757 The list of coordinates to collapse over. This list should be 

2758 every coordinate within the cube to result in a 1D cube around 

2759 the percentile coordinate. 

2760 percent: list[float] 

2761 A list of percentiles to appear in the plot. 

2762 model_names: list[str] 

2763 A list of model names to appear on the axis of the plot. 

2764 filename: str, optional 

2765 Filename of the plot to write. 

2766 one_to_one: bool, optional 

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

2768 

2769 Raises 

2770 ------ 

2771 ValueError 

2772 When the cubes are not compatible. 

2773 

2774 Notes 

2775 ----- 

2776 The quantile-quantile plot is a variant on the scatter plot representing 

2777 two datasets by their quantiles (percentiles) for common time points. 

2778 This plot does not use a theoretical distribution to compare against, but 

2779 compares percentiles of two datasets. This plot does 

2780 not use all raw data points, but plots the selected percentiles (quantiles) of 

2781 each variable instead for the two datasets, thereby normalising the data for a 

2782 direct comparison between the selected percentiles of the two dataset distributions. 

2783 

2784 Quantile-quantile plots are valuable for comparing against 

2785 observations and other models. Identical percentiles between the variables 

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

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

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

2789 Wilks 2011 [Wilks2011]_). 

2790 

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

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

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

2794 the extremes. 

2795 

2796 References 

2797 ---------- 

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

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

2800 """ 

2801 # Check cubes using same functionality as the difference operator. 

2802 if len(cubes) != 2: 

2803 raise ValueError("cubes should contain exactly 2 cubes.") 

2804 base: Cube = cubes.extract_cube(iris.AttributeConstraint(cset_comparison_base=1)) 

2805 other: Cube = cubes.extract_cube( 

2806 iris.Constraint( 

2807 cube_func=lambda cube: "cset_comparison_base" not in cube.attributes 

2808 ) 

2809 ) 

2810 

2811 # Get spatial coord names. 

2812 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

2813 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

2814 

2815 # Ensure cubes to compare are on common differencing grid. 

2816 # This is triggered if either 

2817 # i) latitude and longitude shapes are not the same. Note grid points 

2818 # are not compared directly as these can differ through rounding 

2819 # errors. 

2820 # ii) or variables are known to often sit on different grid staggering 

2821 # in different models (e.g. cell center vs cell edge), as is the case 

2822 # for UM and LFRic comparisons. 

2823 # In future greater choice of regridding method might be applied depending 

2824 # on variable type. Linear regridding can in general be appropriate for smooth 

2825 # variables. Care should be taken with interpretation of differences 

2826 # given this dependency on regridding. 

2827 if ( 

2828 base.coord(base_lat_name).shape != other.coord(other_lat_name).shape 

2829 or base.coord(base_lon_name).shape != other.coord(other_lon_name).shape 

2830 ) or ( 

2831 base.long_name 

2832 in [ 

2833 "eastward_wind_at_10m", 

2834 "northward_wind_at_10m", 

2835 "northward_wind_at_cell_centres", 

2836 "eastward_wind_at_cell_centres", 

2837 "zonal_wind_at_pressure_levels", 

2838 "meridional_wind_at_pressure_levels", 

2839 "potential_vorticity_at_pressure_levels", 

2840 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

2841 ] 

2842 ): 

2843 logging.debug( 

2844 "Linear regridding base cube to other grid to compute differences" 

2845 ) 

2846 base = regrid_onto_cube(base, other, method="Linear") 

2847 

2848 # Extract just common time points. 

2849 base, other = _extract_common_time_points(base, other) 

2850 

2851 # Equalise attributes so we can merge. 

2852 fully_equalise_attributes([base, other]) 

2853 logging.debug("Base: %s\nOther: %s", base, other) 

2854 

2855 # Collapse cubes. 

2856 base = collapse( 

2857 base, 

2858 coordinate=coordinates, 

2859 method="PERCENTILE", 

2860 additional_percent=percentiles, 

2861 ) 

2862 other = collapse( 

2863 other, 

2864 coordinate=coordinates, 

2865 method="PERCENTILE", 

2866 additional_percent=percentiles, 

2867 ) 

2868 

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

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

2871 title = f"{recipe_title}" 

2872 

2873 if filename is None: 

2874 filename = slugify(recipe_title) 

2875 

2876 # Add file extension. 

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

2878 

2879 # Do the actual plotting on a scatter plot 

2880 _plot_and_save_scatter_plot( 

2881 base, other, plot_filename, title, one_to_one, model_names 

2882 ) 

2883 

2884 # Add list of plots to plot metadata. 

2885 plot_index = _append_to_plot_index([plot_filename]) 

2886 

2887 # Make a page to display the plots. 

2888 _make_plot_html_page(plot_index) 

2889 

2890 return iris.cube.CubeList([base, other]) 

2891 

2892 

2893def scatter_plot( 

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

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

2896 filename: str = None, 

2897 one_to_one: bool = True, 

2898 **kwargs, 

2899) -> iris.cube.CubeList: 

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

2901 

2902 Both cubes must be 1D. 

2903 

2904 Parameters 

2905 ---------- 

2906 cube_x: Cube | CubeList 

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

2908 cube_y: Cube | CubeList 

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

2910 filename: str, optional 

2911 Filename of the plot to write. 

2912 one_to_one: bool, optional 

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

2914 

2915 Returns 

2916 ------- 

2917 cubes: CubeList 

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

2919 

2920 Raises 

2921 ------ 

2922 ValueError 

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

2924 size. 

2925 TypeError 

2926 If the cube isn't a single cube. 

2927 

2928 Notes 

2929 ----- 

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

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

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

2933 """ 

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

2935 for cube_iter in iter_maybe(cube_x): 

2936 # Check cubes are correct shape. 

2937 cube_iter = _check_single_cube(cube_iter) 

2938 if cube_iter.ndim > 1: 

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

2940 

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

2942 for cube_iter in iter_maybe(cube_y): 

2943 # Check cubes are correct shape. 

2944 cube_iter = _check_single_cube(cube_iter) 

2945 if cube_iter.ndim > 1: 

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

2947 

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

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

2950 title = f"{recipe_title}" 

2951 

2952 if filename is None: 

2953 filename = slugify(recipe_title) 

2954 

2955 # Add file extension. 

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

2957 

2958 # Do the actual plotting. 

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

2960 

2961 # Add list of plots to plot metadata. 

2962 plot_index = _append_to_plot_index([plot_filename]) 

2963 

2964 # Make a page to display the plots. 

2965 _make_plot_html_page(plot_index) 

2966 

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

2968 

2969 

2970def vector_plot( 

2971 cube_u: iris.cube.Cube, 

2972 cube_v: iris.cube.Cube, 

2973 filename: str = None, 

2974 sequence_coordinate: str = "time", 

2975 **kwargs, 

2976) -> iris.cube.CubeList: 

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

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

2979 

2980 # Cubes must have a matching sequence coordinate. 

2981 try: 

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

2983 if cube_u.coord(sequence_coordinate) != cube_v.coord(sequence_coordinate): 2983 ↛ anywhereline 2983 didn't jump anywhere: it always raised an exception.

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

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

2986 raise ValueError( 

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

2988 ) from err 

2989 

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

2991 plot_index = [] 

2992 nplot = np.size(cube_u[0].coord(sequence_coordinate).points) 

2993 for cube_u_slice, cube_v_slice in zip( 

2994 cube_u.slices_over(sequence_coordinate), 

2995 cube_v.slices_over(sequence_coordinate), 

2996 strict=True, 

2997 ): 

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

2999 seq_coord = cube_u_slice.coord(sequence_coordinate) 

3000 plot_title, plot_filename = _set_title_and_filename( 

3001 seq_coord, nplot, recipe_title, filename 

3002 ) 

3003 

3004 # Do the actual plotting. 

3005 _plot_and_save_vector_plot( 

3006 cube_u_slice, 

3007 cube_v_slice, 

3008 filename=plot_filename, 

3009 title=plot_title, 

3010 method="contourf", 

3011 ) 

3012 plot_index.append(plot_filename) 

3013 

3014 # Add list of plots to plot metadata. 

3015 complete_plot_index = _append_to_plot_index(plot_index) 

3016 

3017 # Make a page to display the plots. 

3018 _make_plot_html_page(complete_plot_index) 

3019 

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

3021 

3022 

3023def plot_histogram_series( 

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

3025 filename: str = None, 

3026 sequence_coordinate: str = "time", 

3027 stamp_coordinate: str = "realization", 

3028 single_plot: bool = False, 

3029 **kwargs, 

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

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

3032 

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

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

3035 functionality to scroll through histograms against time. If a 

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

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

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

3039 

3040 Parameters 

3041 ---------- 

3042 cubes: Cube | iris.cube.CubeList 

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

3044 than the stamp coordinate. 

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

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

3047 filename: str, optional 

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

3049 to the recipe name. 

3050 sequence_coordinate: str, optional 

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

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

3053 slider. 

3054 stamp_coordinate: str, optional 

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

3056 ``"realization"``. 

3057 single_plot: bool, optional 

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

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

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

3061 

3062 Returns 

3063 ------- 

3064 iris.cube.Cube | iris.cube.CubeList 

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

3066 Plotted data. 

3067 

3068 Raises 

3069 ------ 

3070 ValueError 

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

3072 TypeError 

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

3074 """ 

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

3076 

3077 cubes = iter_maybe(cubes) 

3078 

3079 # Internal plotting function. 

3080 plotting_func = _plot_and_save_histogram_series 

3081 

3082 num_models = _get_num_models(cubes) 

3083 

3084 _validate_cube_shape(cubes, num_models) 

3085 

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

3087 # time slider option. 

3088 for cube in cubes: 

3089 try: 

3090 cube.coord(sequence_coordinate) 

3091 except iris.exceptions.CoordinateNotFoundError as err: 

3092 raise ValueError( 

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

3094 ) from err 

3095 

3096 # Get minimum and maximum from levels information. 

3097 levels = None 

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

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

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

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

3102 if levels is None: 

3103 break 

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

3105 # levels-based ranges for histogram plots. 

3106 _, levels, _ = _colorbar_map_levels(cube) 

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

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

3109 vmin = min(levels) 

3110 vmax = max(levels) 

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

3112 break 

3113 

3114 if levels is None: 

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

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

3117 

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

3119 # single point. If single_plot is True: 

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

3121 # separate postage stamp plots. 

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

3123 # produced per single model only 

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

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

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

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

3128 ): 

3129 if single_plot: 

3130 plotting_func = ( 

3131 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

3132 ) 

3133 else: 

3134 plotting_func = _plot_and_save_postage_stamp_histogram_series 

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

3136 else: 

3137 all_points = sorted( 

3138 set( 

3139 itertools.chain.from_iterable( 

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

3141 ) 

3142 ) 

3143 ) 

3144 all_slices = list( 

3145 itertools.chain.from_iterable( 

3146 cb.slices_over(sequence_coordinate) for cb in cubes 

3147 ) 

3148 ) 

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

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

3151 # necessary) 

3152 cube_iterables = [ 

3153 iris.cube.CubeList( 

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

3155 ) 

3156 for point in all_points 

3157 ] 

3158 

3159 plot_index = [] 

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

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

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

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

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

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

3166 for cube_slice in cube_iterables: 

3167 single_cube = cube_slice 

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

3169 single_cube = cube_slice[0] 

3170 

3171 # Set plot titles and filename, based on sequence coordinate 

3172 seq_coord = single_cube.coord(sequence_coordinate) 

3173 # Use time coordinate in title and filename if single histogram output. 

3174 if sequence_coordinate == "realization" and nplot == 1: 3174 ↛ 3175line 3174 didn't jump to line 3175 because the condition on line 3174 was never true

3175 seq_coord = single_cube.coord("time") 

3176 plot_title, plot_filename = _set_title_and_filename( 

3177 seq_coord, nplot, recipe_title, filename 

3178 ) 

3179 

3180 # Do the actual plotting. 

3181 plotting_func( 

3182 cube_slice, 

3183 filename=plot_filename, 

3184 stamp_coordinate=stamp_coordinate, 

3185 title=plot_title, 

3186 vmin=vmin, 

3187 vmax=vmax, 

3188 ) 

3189 plot_index.append(plot_filename) 

3190 

3191 # Add list of plots to plot metadata. 

3192 complete_plot_index = _append_to_plot_index(plot_index) 

3193 

3194 # Make a page to display the plots. 

3195 _make_plot_html_page(complete_plot_index) 

3196 

3197 return cubes 

3198 

3199 

3200def plot_power_spectrum_series( 

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

3202 filename: str = None, 

3203 sequence_coordinate: str = "time", 

3204 stamp_coordinate: str = "realization", 

3205 single_plot: bool = False, 

3206 **kwargs, 

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

3208 """Plot a power spectrum plot for each vertical level provided. 

3209 

3210 A power spectrum plot can be plotted, but if the sequence_coordinate (i.e. time) 

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

3212 functionality to scroll through power spectrum against time. If a 

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

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

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

3216 

3217 Parameters 

3218 ---------- 

3219 cubes: Cube | iris.cube.CubeList 

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

3221 than the stamp coordinate. 

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

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

3224 filename: str, optional 

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

3226 to the recipe name. 

3227 sequence_coordinate: str, optional 

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

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

3230 slider. 

3231 stamp_coordinate: str, optional 

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

3233 ``"realization"``. 

3234 single_plot: bool, optional 

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

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

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

3238 

3239 Returns 

3240 ------- 

3241 iris.cube.Cube | iris.cube.CubeList 

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

3243 Plotted data. 

3244 

3245 Raises 

3246 ------ 

3247 ValueError 

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

3249 TypeError 

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

3251 """ 

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

3253 

3254 cubes = iter_maybe(cubes) 

3255 

3256 # Internal plotting function. 

3257 plotting_func = _plot_and_save_power_spectrum_series 

3258 

3259 num_models = _get_num_models(cubes) 

3260 

3261 _validate_cube_shape(cubes, num_models) 

3262 

3263 # If several power spectra are plotted with time as sequence_coordinate for the 

3264 # time slider option. 

3265 for cube in cubes: 

3266 try: 

3267 cube.coord(sequence_coordinate) 

3268 except iris.exceptions.CoordinateNotFoundError as err: 

3269 raise ValueError( 

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

3271 ) from err 

3272 

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

3274 # single point. If single_plot is True: 

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

3276 # separate postage stamp plots. 

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

3278 # produced per single model only 

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

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

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

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

3283 ): 

3284 if single_plot: 

3285 plotting_func = ( 

3286 _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series 

3287 ) 

3288 else: 

3289 plotting_func = _plot_and_save_postage_stamp_power_spectrum_series 

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

3291 else: 

3292 all_points = sorted( 

3293 set( 

3294 itertools.chain.from_iterable( 

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

3296 ) 

3297 ) 

3298 ) 

3299 all_slices = list( 

3300 itertools.chain.from_iterable( 

3301 cb.slices_over(sequence_coordinate) for cb in cubes 

3302 ) 

3303 ) 

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

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

3306 # necessary) 

3307 cube_iterables = [ 

3308 iris.cube.CubeList( 

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

3310 ) 

3311 for point in all_points 

3312 ] 

3313 

3314 plot_index = [] 

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

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

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

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

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

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

3321 for cube_slice in cube_iterables: 

3322 single_cube = cube_slice 

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

3324 single_cube = cube_slice[0] 

3325 

3326 # Set plot title and filenames based on sequence values 

3327 seq_coord = single_cube.coord(sequence_coordinate) 

3328 plot_title, plot_filename = _set_title_and_filename( 

3329 seq_coord, nplot, recipe_title, filename 

3330 ) 

3331 

3332 # Do the actual plotting. 

3333 plotting_func( 

3334 cube_slice, 

3335 filename=plot_filename, 

3336 stamp_coordinate=stamp_coordinate, 

3337 title=plot_title, 

3338 ) 

3339 plot_index.append(plot_filename) 

3340 

3341 # Add list of plots to plot metadata. 

3342 complete_plot_index = _append_to_plot_index(plot_index) 

3343 

3344 # Make a page to display the plots. 

3345 _make_plot_html_page(complete_plot_index) 

3346 

3347 return cubes 

3348 

3349 

3350def _DCT_ps(y_3d): 

3351 """Calculate power spectra for regional domains. 

3352 

3353 Parameters 

3354 ---------- 

3355 y_3d: 3D array 

3356 3 dimensional array to calculate spectrum for. 

3357 (2D field data with 3rd dimension of time) 

3358 

3359 Returns: ps_array 

3360 Array of power spectra values calculated for input field (for each time) 

3361 

3362 Method for regional domains: 

3363 Calculate power spectra over limited area domain using Discrete Cosine Transform (DCT) 

3364 as described in Denis et al 2002 [Denis_etal_2002]_. 

3365 

3366 References 

3367 ---------- 

3368 .. [Denis_etal_2002] Bertrand Denis, Jean Côté and René Laprise (2002) 

3369 "Spectral Decomposition of Two-Dimensional Atmospheric Fields on 

3370 Limited-Area Domains Using the Discrete Cosine Transform (DCT)" 

3371 Monthly Weather Review, Vol. 130, 1812-1828 

3372 doi: https://doi.org/10.1175/1520-0493(2002)130<1812:SDOTDA>2.0.CO;2 

3373 """ 

3374 Nt, Ny, Nx = y_3d.shape 

3375 

3376 # Max coefficient 

3377 Nmin = min(Nx - 1, Ny - 1) 

3378 

3379 # Create alpha matrix (of wavenumbers) 

3380 alpha_matrix = _create_alpha_matrix(Ny, Nx) 

3381 

3382 # Prepare output array 

3383 ps_array = np.zeros((Nt, Nmin)) 

3384 

3385 # Loop over time to get spectrum for each time. 

3386 for t in range(Nt): 

3387 y_2d = y_3d[t] 

3388 

3389 # Apply 2D DCT to transform y_3d[t] from physical space to spectral space. 

3390 # fkk is a 2D array of DCT coefficients, representing the amplitudes of 

3391 # cosine basis functions at different spatial frequencies. 

3392 

3393 # normalise spectrum to allow comparison between models. 

3394 fkk = fft.dctn(y_2d, norm="ortho") 

3395 

3396 # Normalise fkk 

3397 fkk = fkk / np.sqrt(Ny * Nx) 

3398 

3399 # calculate variance of spectral coefficient 

3400 sigma_2 = fkk**2 / Nx / Ny 

3401 

3402 # Group ellipses of alphas into the same wavenumber k/Nmin 

3403 for k in range(1, Nmin + 1): 

3404 alpha = k / Nmin 

3405 alpha_p1 = (k + 1) / Nmin 

3406 

3407 # Sum up elements matching k 

3408 mask_k = np.where((alpha_matrix >= alpha) & (alpha_matrix < alpha_p1)) 

3409 ps_array[t, k - 1] = np.sum(sigma_2[mask_k]) 

3410 

3411 return ps_array 

3412 

3413 

3414def _create_alpha_matrix(Ny, Nx): 

3415 """Construct an array of 2D wavenumbers from 2D wavenumber pair. 

3416 

3417 Parameters 

3418 ---------- 

3419 Ny, Nx: 

3420 Dimensions of the 2D field for which the power spectra is calculated. Used to 

3421 create the array of 2D wavenumbers. Each Ny, Nx pair is associated with a 

3422 single-scale parameter. 

3423 

3424 Returns: alpha_matrix 

3425 normalisation of 2D wavenumber axes, transforming the spectral domain into 

3426 an elliptic coordinate system. 

3427 

3428 """ 

3429 # Create x_indices: each row is [1, 2, ..., Nx] 

3430 x_indices = np.tile(np.arange(1, Nx + 1), (Ny, 1)) 

3431 

3432 # Create y_indices: each column is [1, 2, ..., Ny] 

3433 y_indices = np.tile(np.arange(1, Ny + 1).reshape(Ny, 1), (1, Nx)) 

3434 

3435 # Compute alpha_matrix 

3436 alpha_matrix = np.sqrt((x_indices**2) / Nx**2 + (y_indices**2) / Ny**2) 

3437 

3438 return alpha_matrix