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

954 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-11 10: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 iris 

29import iris.coords 

30import iris.cube 

31import iris.exceptions 

32import iris.plot as iplt 

33import matplotlib as mpl 

34import matplotlib.colors as mcolors 

35import matplotlib.pyplot as plt 

36import numpy as np 

37import scipy.fft as fft 

38from iris.cube import Cube 

39from markdown_it import MarkdownIt 

40 

41from CSET._common import ( 

42 combine_dicts, 

43 filename_slugify, 

44 get_recipe_metadata, 

45 iter_maybe, 

46 render_file, 

47 slugify, 

48) 

49from CSET.operators._utils import ( 

50 fully_equalise_attributes, 

51 get_cube_yxcoordname, 

52 is_transect, 

53) 

54from CSET.operators.collapse import collapse 

55from CSET.operators.misc import _extract_common_time_points 

56from CSET.operators.regrid import regrid_onto_cube 

57 

58# Use a non-interactive plotting backend. 

59mpl.use("agg") 

60 

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

62 

63############################ 

64# Private helper functions # 

65############################ 

66 

67 

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

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

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

71 fcntl.flock(fp, fcntl.LOCK_EX) 

72 fp.seek(0) 

73 meta = json.load(fp) 

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

75 complete_plot_index = complete_plot_index + plot_index 

76 meta["plots"] = complete_plot_index 

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

78 os.getenv("DO_CASE_AGGREGATION") 

79 ): 

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

81 fp.seek(0) 

82 fp.truncate() 

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

84 return complete_plot_index 

85 

86 

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

88 """Ensure a single cube is given. 

89 

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

91 otherwise an error is raised. 

92 

93 Parameters 

94 ---------- 

95 cube: Cube | CubeList 

96 The cube to check. 

97 

98 Returns 

99 ------- 

100 cube: Cube 

101 The checked cube. 

102 

103 Raises 

104 ------ 

105 TypeError 

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

107 """ 

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

109 return cube 

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

111 if len(cube) == 1: 

112 return cube[0] 

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

114 

115 

116def _make_plot_html_page(plots: list): 

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

118 # Debug check that plots actually contains some strings. 

119 assert isinstance(plots[0], str) 

120 

121 # Load HTML template file. 

122 operator_files = importlib.resources.files() 

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

124 

125 # Get some metadata. 

126 meta = get_recipe_metadata() 

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

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

129 

130 # Prepare template variables. 

131 variables = { 

132 "title": title, 

133 "description": description, 

134 "initial_plot": plots[0], 

135 "plots": plots, 

136 "title_slug": slugify(title), 

137 } 

138 

139 # Render template. 

140 html = render_file(template_file, **variables) 

141 

142 # Save completed HTML. 

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

144 fp.write(html) 

145 

146 

147@functools.cache 

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

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

150 

151 This is a separate function to make it cacheable. 

152 """ 

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

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

155 colorbar = json.load(fp) 

156 

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

158 override_colorbar = {} 

159 if user_colorbar_file: 

160 try: 

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

162 override_colorbar = json.load(fp) 

163 except FileNotFoundError: 

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

165 

166 # Overwrite values with the user supplied colorbar definition. 

167 colorbar = combine_dicts(colorbar, override_colorbar) 

168 return colorbar 

169 

170 

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

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

173 

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

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

176 to model_name attribute. 

177 

178 Parameters 

179 ---------- 

180 cubes: CubeList or Cube 

181 Cubes with model_name attribute 

182 

183 Returns 

184 ------- 

185 model_colors_map: 

186 Dictionary mapping model_name attribute to colors 

187 """ 

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

189 colorbar = _load_colorbar_map(user_colorbar_file) 

190 model_names = sorted( 

191 filter( 

192 lambda x: x is not None, 

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

194 ) 

195 ) 

196 if not model_names: 

197 return {} 

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

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

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

201 

202 color_list = itertools.cycle(DEFAULT_DISCRETE_COLORS) 

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

204 

205 

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

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

208 

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

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

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

212 exist for specific pressure levels to account for variables with 

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

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

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

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

217 

218 Parameters 

219 ---------- 

220 cube: Cube 

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

222 axis: "x", "y", optional 

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

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

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

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

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

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

229 

230 Returns 

231 ------- 

232 cmap: 

233 Matplotlib colormap. 

234 levels: 

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

236 should be taken as the range. 

237 norm: 

238 BoundaryNorm information. 

239 """ 

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

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

242 colorbar = _load_colorbar_map(user_colorbar_file) 

243 cmap = None 

244 

245 try: 

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

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

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

249 pressure_level = str(int(pressure_level_raw)) 

250 except iris.exceptions.CoordinateNotFoundError: 

251 pressure_level = None 

252 

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

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

255 # consistent. 

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

257 for varname in varnames: 

258 # Get the colormap for this variable. 

259 try: 

260 var_colorbar = colorbar[varname] 

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

262 varname_key = varname 

263 break 

264 except KeyError: 

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

266 

267 # Get colormap if it is a mask. 

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

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

270 return cmap, levels, norm 

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

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

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

274 return cmap, levels, norm 

275 # If probability is plotted use custom colorbar and levels 

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

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

278 return cmap, levels, norm 

279 # If aviation colour state use custom colorbar and levels 

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

281 cmap, levels, norm = _custom_colormap_aviation_colour_state(cube) 

282 return cmap, levels, norm 

283 

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

285 if not cmap: 

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

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

288 return cmap, levels, norm 

289 

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

291 if pressure_level: 

292 try: 

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

294 except KeyError: 

295 logging.debug( 

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

297 varname, 

298 pressure_level, 

299 ) 

300 

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

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

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

304 if axis: 

305 if axis == "x": 

306 try: 

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

308 except KeyError: 

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

310 if axis == "y": 

311 try: 

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

313 except KeyError: 

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

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

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

317 levels = None 

318 else: 

319 levels = [vmin, vmax] 

320 return None, levels, None 

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

322 else: 

323 try: 

324 levels = var_colorbar["levels"] 

325 # Use discrete bins when levels are specified, rather 

326 # than a smooth range. 

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

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

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

330 except KeyError: 

331 # Get the range for this variable. 

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

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

334 # Calculate levels from range. 

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

336 norm = None 

337 

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

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

340 # JSON file. 

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

342 cmap, levels, norm = _custom_colourmap_visibility_in_air( 

343 cube, cmap, levels, norm 

344 ) 

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

346 return cmap, levels, norm 

347 

348 

349def _setup_spatial_map( 

350 cube: iris.cube.Cube, 

351 figure, 

352 cmap, 

353 grid_size: int | None = None, 

354 subplot: int | None = None, 

355): 

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

357 

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

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

360 

361 Parameters 

362 ---------- 

363 cube: Cube 

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

365 figure: 

366 Matplotlib Figure object holding all plot elements. 

367 cmap: 

368 Matplotlib colormap. 

369 grid_size: int, optional 

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

371 subplot: int, optional 

372 Subplot index if multiple spatial subplots in figure. 

373 

374 Returns 

375 ------- 

376 axes: 

377 Matplotlib GeoAxes definition. 

378 """ 

379 # Identify min/max plot bounds. 

380 try: 

381 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

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

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

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

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

386 

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

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

389 x1 = x1 - 180.0 

390 x2 = x2 - 180.0 

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

392 

393 # Consider map projection orientation. 

394 # Adapting orientation enables plotting across international dateline. 

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

396 if x2 > 180.0 or x1 < -180.0: 

397 central_longitude = 180.0 

398 else: 

399 central_longitude = 0.0 

400 

401 # Define spatial map projection. 

402 coord_system = cube.coord(lat_axis).coord_system 

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

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

405 projection = ccrs.RotatedPole( 

406 pole_longitude=coord_system.grid_north_pole_longitude, 

407 pole_latitude=coord_system.grid_north_pole_latitude, 

408 central_rotated_longitude=central_longitude, 

409 ) 

410 crs = projection 

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

412 # Define Transverse Mercator projection for TM inputs. 

413 projection = ccrs.TransverseMercator( 

414 central_longitude=coord_system.longitude_of_central_meridian, 

415 central_latitude=coord_system.latitude_of_projection_origin, 

416 false_easting=coord_system.false_easting, 

417 false_northing=coord_system.false_northing, 

418 scale_factor=coord_system.scale_factor_at_central_meridian, 

419 ) 

420 crs = projection 

421 else: 

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

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

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

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

426 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

427 crs = ccrs.PlateCarree() 

428 

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

430 if subplot is not None: 

431 axes = figure.add_subplot( 

432 grid_size, grid_size, subplot, projection=projection 

433 ) 

434 else: 

435 axes = figure.add_subplot(projection=projection) 

436 

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

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

439 coastcol = "magenta" 

440 else: 

441 coastcol = "black" 

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

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

444 

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

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

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

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

449 

450 except ValueError: 

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

452 axes = figure.gca() 

453 pass 

454 

455 return axes 

456 

457 

458def _get_plot_resolution() -> int: 

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

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

461 

462 

463def _set_title_and_filename( 

464 seq_coord: iris.coords.Coord, 

465 nplot: int, 

466 recipe_title: str, 

467 filename: str, 

468): 

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

470 

471 Parameters 

472 ---------- 

473 sequence_coordinate: iris.coords.Coord 

474 Coordinate about which to make a plot sequence. 

475 nplot: int 

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

477 recipe_title: str 

478 Default plot title, potentially to update. 

479 filename: str 

480 Input plot filename, potentially to update. 

481 

482 Returns 

483 ------- 

484 plot_title: str 

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

486 plot_filename: str 

487 Output formatted plot filename string. 

488 """ 

489 # Set default based on coord point value where only plotting single output 

490 ndim = seq_coord.ndim 

491 npoints = np.size(seq_coord.points) 

492 

493 if ndim == 1: 493 ↛ 499line 493 didn't jump to line 499 because the condition on line 493 was always true

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

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

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

497 else: 

498 # Account for case with multi-dimension sequence (e.g. aggregation) 

499 sequence_title = "" 

500 sequence_fname = "" 

501 

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

503 if ndim == 1 and nplot == 1: 

504 if seq_coord.has_bounds(): 

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

506 if npoints > 1: 

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

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

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

510 else: 

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

512 endstring = seq_coord.units.title(seq_coord.bounds[0][1]) 

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

514 sequence_fname = ( 

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

516 ) 

517 

518 # Set plot title and filename 

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

520 

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

522 if filename is None: 

523 filename = slugify(recipe_title) 

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

525 else: 

526 if nplot > 1: 

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

528 else: 

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

530 

531 return plot_title, plot_filename 

532 

533 

534def _plot_and_save_spatial_plot( 

535 cube: iris.cube.Cube, 

536 filename: str, 

537 title: str, 

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

539 **kwargs, 

540): 

541 """Plot and save a spatial plot. 

542 

543 Parameters 

544 ---------- 

545 cube: Cube 

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

547 filename: str 

548 Filename of the plot to write. 

549 title: str 

550 Plot title. 

551 method: "contourf" | "pcolormesh" 

552 The plotting method to use. 

553 """ 

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

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

556 

557 # Specify the color bar 

558 cmap, levels, norm = _colorbar_map_levels(cube) 

559 

560 # Setup plot map projection, extent and coastlines. 

561 axes = _setup_spatial_map(cube, fig, cmap) 

562 

563 # Plot the field. 

564 if method == "contourf": 

565 # Filled contour plot of the field. 

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

567 elif method == "pcolormesh": 

568 try: 

569 vmin = min(levels) 

570 vmax = max(levels) 

571 except TypeError: 

572 vmin, vmax = None, None 

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

574 # if levels are defined. 

575 if norm is not None: 

576 vmin = None 

577 vmax = None 

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

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

580 else: 

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

582 

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

584 if is_transect(cube): 

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

586 axes.invert_yaxis() 

587 axes.set_yscale("log") 

588 axes.set_ylim(1100, 100) 

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

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

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

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

593 ): 

594 axes.set_yscale("log") 

595 

596 axes.set_title( 

597 f"{title}\n" 

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

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

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

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

602 fontsize=16, 

603 ) 

604 

605 else: 

606 # Add title. 

607 axes.set_title(title, fontsize=16) 

608 

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

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

611 axes.annotate( 

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

613 xy=(1, -0.05), 

614 xycoords="axes fraction", 

615 xytext=(-5, 5), 

616 textcoords="offset points", 

617 ha="right", 

618 va="bottom", 

619 size=11, 

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

621 ) 

622 

623 # Add colour bar. 

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

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

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

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

628 cbar.set_ticks(levels) 

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

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

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

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

633 

634 # Save plot. 

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

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

637 plt.close(fig) 

638 

639 

640def _plot_and_save_postage_stamp_spatial_plot( 

641 cube: iris.cube.Cube, 

642 filename: str, 

643 stamp_coordinate: str, 

644 title: str, 

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

646 **kwargs, 

647): 

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

649 

650 Parameters 

651 ---------- 

652 cube: Cube 

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

654 filename: str 

655 Filename of the plot to write. 

656 stamp_coordinate: str 

657 Coordinate that becomes different plots. 

658 method: "contourf" | "pcolormesh" 

659 The plotting method to use. 

660 

661 Raises 

662 ------ 

663 ValueError 

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

665 """ 

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

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

668 

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

670 

671 # Specify the color bar 

672 cmap, levels, norm = _colorbar_map_levels(cube) 

673 

674 # Make a subplot for each member. 

675 for member, subplot in zip( 

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

677 ): 

678 # Setup subplot map projection, extent and coastlines. 

679 axes = _setup_spatial_map( 

680 member, fig, cmap, grid_size=grid_size, subplot=subplot 

681 ) 

682 if method == "contourf": 

683 # Filled contour plot of the field. 

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

685 elif method == "pcolormesh": 

686 if levels is not None: 

687 vmin = min(levels) 

688 vmax = max(levels) 

689 else: 

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

691 vmin, vmax = None, None 

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

693 # if levels are defined. 

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

695 vmin = None 

696 vmax = None 

697 # pcolormesh plot of the field. 

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

699 else: 

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

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

702 axes.set_axis_off() 

703 

704 # Put the shared colorbar in its own axes. 

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

706 colorbar = fig.colorbar( 

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

708 ) 

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

710 

711 # Overall figure title. 

712 fig.suptitle(title, fontsize=16) 

713 

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

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

716 plt.close(fig) 

717 

718 

719def _plot_and_save_line_series( 

720 cubes: iris.cube.CubeList, 

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

722 ensemble_coord: str, 

723 filename: str, 

724 title: str, 

725 **kwargs, 

726): 

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

728 

729 Parameters 

730 ---------- 

731 cubes: Cube or CubeList 

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

733 coords: list[Coord] 

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

735 ensemble_coord: str 

736 Ensemble coordinate in the cube. 

737 filename: str 

738 Filename of the plot to write. 

739 title: str 

740 Plot title. 

741 """ 

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

743 

744 model_colors_map = _get_model_colors_map(cubes) 

745 

746 # Store min/max ranges. 

747 y_levels = [] 

748 

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

750 _validate_cubes_coords(cubes, coords) 

751 

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

753 label = None 

754 color = "black" 

755 if model_colors_map: 

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

757 color = model_colors_map.get(label) 

758 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

761 iplt.plot( 

762 coord, 

763 cube_slice, 

764 color=color, 

765 marker="o", 

766 ls="-", 

767 lw=3, 

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

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

770 else label, 

771 ) 

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

773 else: 

774 iplt.plot( 

775 coord, 

776 cube_slice, 

777 color=color, 

778 ls="-", 

779 lw=1.5, 

780 alpha=0.75, 

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

782 ) 

783 

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

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

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

787 y_levels.append(min(levels)) 

788 y_levels.append(max(levels)) 

789 

790 # Get the current axes. 

791 ax = plt.gca() 

792 

793 # Add some labels and tweak the style. 

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

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

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

797 else: 

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

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

800 ax.set_title(title, fontsize=16) 

801 

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

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

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

805 

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

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

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

809 # Add zero line. 

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

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

812 logging.debug( 

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

814 ) 

815 else: 

816 ax.autoscale() 

817 

818 # Add gridlines 

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

820 # Ientify unique labels for legend 

821 handles = list( 

822 { 

823 label: handle 

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

825 }.values() 

826 ) 

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

828 

829 # Save plot. 

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

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

832 plt.close(fig) 

833 

834 

835def _plot_and_save_vertical_line_series( 

836 cubes: iris.cube.CubeList, 

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

838 ensemble_coord: str, 

839 filename: str, 

840 series_coordinate: str, 

841 title: str, 

842 vmin: float, 

843 vmax: float, 

844 **kwargs, 

845): 

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

847 

848 Parameters 

849 ---------- 

850 cubes: CubeList 

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

852 coord: list[Coord] 

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

854 ensemble_coord: str 

855 Ensemble coordinate in the cube. 

856 filename: str 

857 Filename of the plot to write. 

858 series_coordinate: str 

859 Coordinate to use as vertical axis. 

860 title: str 

861 Plot title. 

862 vmin: float 

863 Minimum value for the x-axis. 

864 vmax: float 

865 Maximum value for the x-axis. 

866 """ 

867 # plot the vertical pressure axis using log scale 

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

869 

870 model_colors_map = _get_model_colors_map(cubes) 

871 

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

873 _validate_cubes_coords(cubes, coords) 

874 

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

876 label = None 

877 color = "black" 

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

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

880 color = model_colors_map.get(label) 

881 

882 for cube_slice in cube.slices_over(ensemble_coord): 

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

884 # unless single forecast. 

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

886 iplt.plot( 

887 cube_slice, 

888 coord, 

889 color=color, 

890 marker="o", 

891 ls="-", 

892 lw=3, 

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

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

895 else label, 

896 ) 

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

898 else: 

899 iplt.plot( 

900 cube_slice, 

901 coord, 

902 color=color, 

903 ls="-", 

904 lw=1.5, 

905 alpha=0.75, 

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

907 ) 

908 

909 # Get the current axis 

910 ax = plt.gca() 

911 

912 # Special handling for pressure level data. 

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

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

915 ax.invert_yaxis() 

916 ax.set_yscale("log") 

917 

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

919 y_tick_labels = [ 

920 "1000", 

921 "850", 

922 "700", 

923 "500", 

924 "300", 

925 "200", 

926 "100", 

927 ] 

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

929 

930 # Set y-axis limits and ticks. 

931 ax.set_ylim(1100, 100) 

932 

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

934 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

940 

941 ax.set_yticks(y_ticks) 

942 ax.set_yticklabels(y_tick_labels) 

943 

944 # Set x-axis limits. 

945 ax.set_xlim(vmin, vmax) 

946 # Mark y=0 if present in plot. 

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

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

949 

950 # Add some labels and tweak the style. 

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

952 ax.set_xlabel( 

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

954 ) 

955 ax.set_title(title, fontsize=16) 

956 ax.ticklabel_format(axis="x") 

957 ax.tick_params(axis="y") 

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

959 

960 # Add gridlines 

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

962 # Ientify unique labels for legend 

963 handles = list( 

964 { 

965 label: handle 

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

967 }.values() 

968 ) 

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

970 

971 # Save plot. 

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

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

974 plt.close(fig) 

975 

976 

977def _plot_and_save_scatter_plot( 

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

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

980 filename: str, 

981 title: str, 

982 one_to_one: bool, 

983 model_names: list[str] = None, 

984 **kwargs, 

985): 

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

987 

988 Parameters 

989 ---------- 

990 cube_x: Cube | CubeList 

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

992 cube_y: Cube | CubeList 

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

994 filename: str 

995 Filename of the plot to write. 

996 title: str 

997 Plot title. 

998 one_to_one: bool 

999 Whether a 1:1 line is plotted. 

1000 """ 

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

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

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

1004 # over the pairs simultaneously. 

1005 

1006 # Ensure cube_x and cube_y are iterable 

1007 cube_x_iterable = iter_maybe(cube_x) 

1008 cube_y_iterable = iter_maybe(cube_y) 

1009 

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

1011 iplt.scatter(cube_x_iter, cube_y_iter) 

1012 if one_to_one is True: 

1013 plt.plot( 

1014 [ 

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

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

1017 ], 

1018 [ 

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

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

1021 ], 

1022 "k", 

1023 linestyle="--", 

1024 ) 

1025 ax = plt.gca() 

1026 

1027 # Add some labels and tweak the style. 

1028 if model_names is None: 

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

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

1031 else: 

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

1033 ax.set_xlabel( 

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

1035 ) 

1036 ax.set_ylabel( 

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

1038 ) 

1039 ax.set_title(title, fontsize=16) 

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

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

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

1043 ax.autoscale() 

1044 

1045 # Save plot. 

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

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

1048 plt.close(fig) 

1049 

1050 

1051def _plot_and_save_vector_plot( 

1052 cube_u: iris.cube.Cube, 

1053 cube_v: iris.cube.Cube, 

1054 filename: str, 

1055 title: str, 

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

1057 **kwargs, 

1058): 

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

1060 

1061 Parameters 

1062 ---------- 

1063 cube_u: Cube 

1064 2 dimensional Cube of u component of the data. 

1065 cube_v: Cube 

1066 2 dimensional Cube of v component of the data. 

1067 filename: str 

1068 Filename of the plot to write. 

1069 title: str 

1070 Plot title. 

1071 """ 

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

1073 

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

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

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

1077 

1078 # Specify the color bar 

1079 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag) 

1080 

1081 # Setup plot map projection, extent and coastlines. 

1082 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

1083 

1084 if method == "contourf": 

1085 # Filled contour plot of the field. 

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

1087 elif method == "pcolormesh": 

1088 try: 

1089 vmin = min(levels) 

1090 vmax = max(levels) 

1091 except TypeError: 

1092 vmin, vmax = None, None 

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

1094 # if levels are defined. 

1095 if norm is not None: 

1096 vmin = None 

1097 vmax = None 

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

1099 else: 

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

1101 

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

1103 if is_transect(cube_vec_mag): 

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

1105 axes.invert_yaxis() 

1106 axes.set_yscale("log") 

1107 axes.set_ylim(1100, 100) 

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

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

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

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

1112 ): 

1113 axes.set_yscale("log") 

1114 

1115 axes.set_title( 

1116 f"{title}\n" 

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

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

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

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

1121 fontsize=16, 

1122 ) 

1123 

1124 else: 

1125 # Add title. 

1126 axes.set_title(title, fontsize=16) 

1127 

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

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

1130 axes.annotate( 

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

1132 xy=(1, -0.05), 

1133 xycoords="axes fraction", 

1134 xytext=(-5, 5), 

1135 textcoords="offset points", 

1136 ha="right", 

1137 va="bottom", 

1138 size=11, 

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

1140 ) 

1141 

1142 # Add colour bar. 

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

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

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

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

1147 cbar.set_ticks(levels) 

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

1149 

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

1151 # with less than 30 points. 

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

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

1154 

1155 # Save plot. 

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

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

1158 plt.close(fig) 

1159 

1160 

1161def _plot_and_save_histogram_series( 

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

1163 filename: str, 

1164 title: str, 

1165 vmin: float, 

1166 vmax: float, 

1167 **kwargs, 

1168): 

1169 """Plot and save a histogram series. 

1170 

1171 Parameters 

1172 ---------- 

1173 cubes: Cube or CubeList 

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

1175 filename: str 

1176 Filename of the plot to write. 

1177 title: str 

1178 Plot title. 

1179 vmin: float 

1180 minimum for colorbar 

1181 vmax: float 

1182 maximum for colorbar 

1183 """ 

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

1185 ax = plt.gca() 

1186 

1187 model_colors_map = _get_model_colors_map(cubes) 

1188 

1189 # Set default that histograms will produce probability density function 

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

1191 density = True 

1192 

1193 for cube in iter_maybe(cubes): 

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

1195 # than seeing if long names exist etc. 

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

1197 if "surface_microphysical" in title: 

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

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

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

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

1202 density = False 

1203 else: 

1204 bins = 10.0 ** ( 

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

1206 ) # Suggestion from RMED toolbox. 

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

1208 ax.set_yscale("log") 

1209 vmin = bins[1] 

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

1211 ax.set_xscale("log") 

1212 elif "lightning" in title: 

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

1214 else: 

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

1216 logging.debug( 

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

1218 np.size(bins), 

1219 np.min(bins), 

1220 np.max(bins), 

1221 ) 

1222 

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

1224 # Otherwise we plot xdim histograms stacked. 

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

1226 

1227 label = None 

1228 color = "black" 

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

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

1231 color = model_colors_map[label] 

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

1233 

1234 # Compute area under curve. 

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

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

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

1238 x = x[1:] 

1239 y = y[1:] 

1240 

1241 ax.plot( 

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

1243 ) 

1244 

1245 # Add some labels and tweak the style. 

1246 ax.set_title(title, fontsize=16) 

1247 ax.set_xlabel( 

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

1249 ) 

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

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

1252 ax.set_ylabel( 

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

1254 ) 

1255 ax.set_xlim(vmin, vmax) 

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

1257 

1258 # Overlay grid-lines onto histogram plot. 

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

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

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

1262 

1263 # Save plot. 

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

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

1266 plt.close(fig) 

1267 

1268 

1269def _plot_and_save_postage_stamp_histogram_series( 

1270 cube: iris.cube.Cube, 

1271 filename: str, 

1272 title: str, 

1273 stamp_coordinate: str, 

1274 vmin: float, 

1275 vmax: float, 

1276 **kwargs, 

1277): 

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

1279 

1280 Parameters 

1281 ---------- 

1282 cube: Cube 

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

1284 filename: str 

1285 Filename of the plot to write. 

1286 title: str 

1287 Plot title. 

1288 stamp_coordinate: str 

1289 Coordinate that becomes different plots. 

1290 vmin: float 

1291 minimum for pdf x-axis 

1292 vmax: float 

1293 maximum for pdf x-axis 

1294 """ 

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

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

1297 

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

1299 # Make a subplot for each member. 

1300 for member, subplot in zip( 

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

1302 ): 

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

1304 # cartopy GeoAxes generated. 

1305 plt.subplot(grid_size, grid_size, subplot) 

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

1307 # Otherwise we plot xdim histograms stacked. 

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

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

1310 ax = plt.gca() 

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

1312 ax.set_xlim(vmin, vmax) 

1313 

1314 # Overall figure title. 

1315 fig.suptitle(title, fontsize=16) 

1316 

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

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

1319 plt.close(fig) 

1320 

1321 

1322def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1323 cube: iris.cube.Cube, 

1324 filename: str, 

1325 title: str, 

1326 stamp_coordinate: str, 

1327 vmin: float, 

1328 vmax: float, 

1329 **kwargs, 

1330): 

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

1332 ax.set_title(title, fontsize=16) 

1333 ax.set_xlim(vmin, vmax) 

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

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

1336 # Loop over all slices along the stamp_coordinate 

1337 for member in cube.slices_over(stamp_coordinate): 

1338 # Flatten the member data to 1D 

1339 member_data_1d = member.data.flatten() 

1340 # Plot the histogram using plt.hist 

1341 plt.hist( 

1342 member_data_1d, 

1343 density=True, 

1344 stacked=True, 

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

1346 ) 

1347 

1348 # Add a legend 

1349 ax.legend(fontsize=16) 

1350 

1351 # Save the figure to a file 

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

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

1354 

1355 # Close the figure 

1356 plt.close(fig) 

1357 

1358 

1359def _plot_and_save_scattermap_plot( 

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

1361): 

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

1363 

1364 Parameters 

1365 ---------- 

1366 cube: Cube 

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

1368 longitude coordinates, 

1369 filename: str 

1370 Filename of the plot to write. 

1371 title: str 

1372 Plot title. 

1373 projection: str 

1374 Mapping projection to be used by cartopy. 

1375 """ 

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

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

1378 if projection is not None: 

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

1380 # a stereographic projection over the North Pole. 

1381 if projection == "NP_Stereo": 

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

1383 else: 

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

1385 else: 

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

1387 

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

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

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

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

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

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

1394 # proportion to the area of the figure. 

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

1396 klon = None 

1397 klat = None 

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

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

1400 klat = kc 

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

1402 klon = kc 

1403 scatter_map = iplt.scatter( 

1404 cube.aux_coords[klon], 

1405 cube.aux_coords[klat], 

1406 c=cube.data[:], 

1407 s=mrk_size, 

1408 cmap="jet", 

1409 edgecolors="k", 

1410 ) 

1411 

1412 # Add coastlines. 

1413 try: 

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

1415 except AttributeError: 

1416 pass 

1417 

1418 # Add title. 

1419 axes.set_title(title, fontsize=16) 

1420 

1421 # Add colour bar. 

1422 cbar = fig.colorbar(scatter_map) 

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

1424 

1425 # Save plot. 

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

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

1428 plt.close(fig) 

1429 

1430 

1431def _plot_and_save_power_spectrum_series( 

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

1433 filename: str, 

1434 title: str, 

1435 **kwargs, 

1436): 

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

1438 

1439 Parameters 

1440 ---------- 

1441 cubes: Cube or CubeList 

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

1443 filename: str 

1444 Filename of the plot to write. 

1445 title: str 

1446 Plot title. 

1447 """ 

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

1449 ax = plt.gca() 

1450 

1451 model_colors_map = _get_model_colors_map(cubes) 

1452 

1453 for cube in iter_maybe(cubes): 

1454 # Calculate power spectrum 

1455 

1456 # Extract time coordinate and convert to datetime 

1457 time_coord = cube.coord("time") 

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

1459 

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

1461 target_time = time_points[0] 

1462 

1463 # Bind target_time inside the lambda using a default argument 

1464 time_constraint = iris.Constraint( 

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

1466 ) 

1467 

1468 cube = cube.extract(time_constraint) 

1469 

1470 if cube.ndim == 2: 

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

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

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

1474 cube_3d = cube.data 

1475 else: 

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

1477 raise ValueError( 

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

1479 ) 

1480 

1481 # Calculate spectra 

1482 ps_array = _DCT_ps(cube_3d) 

1483 

1484 ps_cube = iris.cube.Cube( 

1485 ps_array, 

1486 long_name="power_spectra", 

1487 ) 

1488 

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

1490 

1491 # Create a frequency/wavelength array for coordinate 

1492 ps_len = ps_cube.data.shape[1] 

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

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

1495 

1496 # Convert datetime to numeric time using original units 

1497 numeric_time = time_coord.units.date2num(time_points) 

1498 # Create a new DimCoord with numeric time 

1499 new_time_coord = iris.coords.DimCoord( 

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

1501 ) 

1502 

1503 # Add time and frequency coordinate to spectra cube. 

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

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

1506 

1507 # Extract data from the cube 

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

1509 power_spectrum = ps_cube.data 

1510 

1511 label = None 

1512 color = "black" 

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

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

1515 color = model_colors_map[label] 

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

1517 

1518 # Add some labels and tweak the style. 

1519 ax.set_title(title, fontsize=16) 

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

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

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

1523 

1524 # Set log-log scale 

1525 ax.set_xscale("log") 

1526 ax.set_yscale("log") 

1527 

1528 # Overlay grid-lines onto power spectrum plot. 

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

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

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

1532 

1533 # Save plot. 

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

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

1536 plt.close(fig) 

1537 

1538 

1539def _plot_and_save_postage_stamp_power_spectrum_series( 

1540 cube: iris.cube.Cube, 

1541 filename: str, 

1542 title: str, 

1543 stamp_coordinate: str, 

1544 **kwargs, 

1545): 

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

1547 

1548 Parameters 

1549 ---------- 

1550 cube: Cube 

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

1552 filename: str 

1553 Filename of the plot to write. 

1554 title: str 

1555 Plot title. 

1556 stamp_coordinate: str 

1557 Coordinate that becomes different plots. 

1558 """ 

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

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

1561 

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

1563 # Make a subplot for each member. 

1564 for member, subplot in zip( 

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

1566 ): 

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

1568 # cartopy GeoAxes generated. 

1569 plt.subplot(grid_size, grid_size, subplot) 

1570 

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

1572 

1573 ax = plt.gca() 

1574 ax.plot(frequency, member.data) 

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

1576 

1577 # Overall figure title. 

1578 fig.suptitle(title, fontsize=16) 

1579 

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

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

1582 plt.close(fig) 

1583 

1584 

1585def _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series( 

1586 cube: iris.cube.Cube, 

1587 filename: str, 

1588 title: str, 

1589 stamp_coordinate: str, 

1590 **kwargs, 

1591): 

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

1593 ax.set_title(title, fontsize=16) 

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

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

1596 # Loop over all slices along the stamp_coordinate 

1597 for member in cube.slices_over(stamp_coordinate): 

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

1599 ax.plot( 

1600 frequency, 

1601 member.data, 

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

1603 ) 

1604 

1605 # Add a legend 

1606 ax.legend(fontsize=16) 

1607 

1608 # Save the figure to a file 

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

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

1611 

1612 # Close the figure 

1613 plt.close(fig) 

1614 

1615 

1616def _spatial_plot( 

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

1618 cube: iris.cube.Cube, 

1619 filename: str | None, 

1620 sequence_coordinate: str, 

1621 stamp_coordinate: str, 

1622 **kwargs, 

1623): 

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

1625 

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

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

1628 is present then postage stamp plots will be produced. 

1629 

1630 Parameters 

1631 ---------- 

1632 method: "contourf" | "pcolormesh" 

1633 The plotting method to use. 

1634 cube: Cube 

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

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

1637 plotted sequentially and/or as postage stamp plots. 

1638 filename: str | None 

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

1640 uses the recipe name. 

1641 sequence_coordinate: str 

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

1643 This coordinate must exist in the cube. 

1644 stamp_coordinate: str 

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

1646 ``"realization"``. 

1647 

1648 Raises 

1649 ------ 

1650 ValueError 

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

1652 TypeError 

1653 If the cube isn't a single cube. 

1654 """ 

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

1656 

1657 # Ensure we've got a single cube. 

1658 cube = _check_single_cube(cube) 

1659 

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

1661 # single point. 

1662 plotting_func = _plot_and_save_spatial_plot 

1663 try: 

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

1665 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1666 except iris.exceptions.CoordinateNotFoundError: 

1667 pass 

1668 

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

1670 # dimension called observation or model_obs_error 

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

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

1673 for crd in cube.coords() 

1674 ): 

1675 plotting_func = _plot_and_save_scattermap_plot 

1676 

1677 # Must have a sequence coordinate. 

1678 try: 

1679 cube.coord(sequence_coordinate) 

1680 except iris.exceptions.CoordinateNotFoundError as err: 

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

1682 

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

1684 plot_index = [] 

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

1686 for cube_slice in cube.slices_over(sequence_coordinate): 

1687 # Set plot titles and filename 

1688 seq_coord = cube_slice.coord(sequence_coordinate) 

1689 plot_title, plot_filename = _set_title_and_filename( 

1690 seq_coord, nplot, recipe_title, filename 

1691 ) 

1692 

1693 # Do the actual plotting. 

1694 plotting_func( 

1695 cube_slice, 

1696 filename=plot_filename, 

1697 stamp_coordinate=stamp_coordinate, 

1698 title=plot_title, 

1699 method=method, 

1700 **kwargs, 

1701 ) 

1702 plot_index.append(plot_filename) 

1703 

1704 # Add list of plots to plot metadata. 

1705 complete_plot_index = _append_to_plot_index(plot_index) 

1706 

1707 # Make a page to display the plots. 

1708 _make_plot_html_page(complete_plot_index) 

1709 

1710 

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

1712 """Get colourmap for mask. 

1713 

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

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

1716 

1717 Parameters 

1718 ---------- 

1719 cube: Cube 

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

1721 axis: "x", "y", optional 

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

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

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

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

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

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

1728 

1729 Returns 

1730 ------- 

1731 cmap: 

1732 Matplotlib colormap. 

1733 levels: 

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

1735 should be taken as the range. 

1736 norm: 

1737 BoundaryNorm information. 

1738 """ 

1739 if "difference" not in cube.long_name: 

1740 if axis: 

1741 levels = [0, 1] 

1742 # Complete settings based on levels. 

1743 return None, levels, None 

1744 else: 

1745 # Define the levels and colors. 

1746 levels = [0, 1, 2] 

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

1748 # Create a custom color map. 

1749 cmap = mcolors.ListedColormap(colors) 

1750 # Normalize the levels. 

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

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

1753 return cmap, levels, norm 

1754 else: 

1755 if axis: 

1756 levels = [-1, 1] 

1757 return None, levels, None 

1758 else: 

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

1760 # not <=. 

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

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

1763 cmap = mcolors.ListedColormap(colors) 

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

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

1766 return cmap, levels, norm 

1767 

1768 

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

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

1771 

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

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

1774 

1775 Parameters 

1776 ---------- 

1777 cube: Cube 

1778 Cube of variable with Beaufort Scale in name. 

1779 axis: "x", "y", optional 

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

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

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

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

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

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

1786 

1787 Returns 

1788 ------- 

1789 cmap: 

1790 Matplotlib colormap. 

1791 levels: 

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

1793 should be taken as the range. 

1794 norm: 

1795 BoundaryNorm information. 

1796 """ 

1797 if "difference" not in cube.long_name: 

1798 if axis: 

1799 levels = [0, 12] 

1800 return None, levels, None 

1801 else: 

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

1803 colors = [ 

1804 "black", 

1805 (0, 0, 0.6), 

1806 "blue", 

1807 "cyan", 

1808 "green", 

1809 "yellow", 

1810 (1, 0.5, 0), 

1811 "red", 

1812 "pink", 

1813 "magenta", 

1814 "purple", 

1815 "maroon", 

1816 "white", 

1817 ] 

1818 cmap = mcolors.ListedColormap(colors) 

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

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

1821 return cmap, levels, norm 

1822 else: 

1823 if axis: 

1824 levels = [-4, 4] 

1825 return None, levels, None 

1826 else: 

1827 levels = [ 

1828 -3.5, 

1829 -2.5, 

1830 -1.5, 

1831 -0.5, 

1832 0.5, 

1833 1.5, 

1834 2.5, 

1835 3.5, 

1836 ] 

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

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

1839 return cmap, levels, norm 

1840 

1841 

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

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

1844 

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

1846 

1847 Parameters 

1848 ---------- 

1849 cube: Cube 

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

1851 cmap: Matplotlib colormap. 

1852 levels: List 

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

1854 should be taken as the range. 

1855 norm: BoundaryNorm. 

1856 

1857 Returns 

1858 ------- 

1859 cmap: Matplotlib colormap. 

1860 levels: List 

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

1862 should be taken as the range. 

1863 norm: BoundaryNorm. 

1864 """ 

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

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

1867 levels = np.array(levels) 

1868 levels -= 273 

1869 levels = levels.tolist() 

1870 else: 

1871 # Do nothing keep the existing colourbar attributes 

1872 levels = levels 

1873 cmap = cmap 

1874 norm = norm 

1875 return cmap, levels, norm 

1876 

1877 

1878def _custom_colormap_probability( 

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

1880): 

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

1882 

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

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

1885 

1886 Parameters 

1887 ---------- 

1888 cube: Cube 

1889 Cube of variable with probability in name. 

1890 axis: "x", "y", optional 

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

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

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

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

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

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

1897 

1898 Returns 

1899 ------- 

1900 cmap: 

1901 Matplotlib colormap. 

1902 levels: 

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

1904 should be taken as the range. 

1905 norm: 

1906 BoundaryNorm information. 

1907 """ 

1908 if axis: 

1909 levels = [0, 1] 

1910 return None, levels, None 

1911 else: 

1912 cmap = mcolors.ListedColormap( 

1913 [ 

1914 "#FFFFFF", 

1915 "#636363", 

1916 "#e1dada", 

1917 "#B5CAFF", 

1918 "#8FB3FF", 

1919 "#7F97FF", 

1920 "#ABCF63", 

1921 "#E8F59E", 

1922 "#FFFA14", 

1923 "#FFD121", 

1924 "#FFA30A", 

1925 ] 

1926 ) 

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

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

1929 return cmap, levels, norm 

1930 

1931 

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

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

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

1935 if ( 

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

1937 and "difference" not in cube.long_name 

1938 and "mask" not in cube.long_name 

1939 ): 

1940 # Define the levels and colors 

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

1942 colors = [ 

1943 "w", 

1944 (0, 0, 0.6), 

1945 "b", 

1946 "c", 

1947 "g", 

1948 "y", 

1949 (1, 0.5, 0), 

1950 "r", 

1951 "pink", 

1952 "m", 

1953 "purple", 

1954 "maroon", 

1955 "gray", 

1956 ] 

1957 # Create a custom colormap 

1958 cmap = mcolors.ListedColormap(colors) 

1959 # Normalize the levels 

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

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

1962 else: 

1963 # do nothing and keep existing colorbar attributes 

1964 cmap = cmap 

1965 levels = levels 

1966 norm = norm 

1967 return cmap, levels, norm 

1968 

1969 

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

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

1972 

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

1974 this function will be called. 

1975 

1976 Parameters 

1977 ---------- 

1978 cube: Cube 

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

1980 

1981 Returns 

1982 ------- 

1983 cmap: Matplotlib colormap. 

1984 levels: List 

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

1986 should be taken as the range. 

1987 norm: BoundaryNorm. 

1988 """ 

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

1990 colors = [ 

1991 "#87ceeb", 

1992 "#ffffff", 

1993 "#8ced69", 

1994 "#ffff00", 

1995 "#ffd700", 

1996 "#ffa500", 

1997 "#fe3620", 

1998 ] 

1999 # Create a custom colormap 

2000 cmap = mcolors.ListedColormap(colors) 

2001 # Normalise the levels 

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

2003 return cmap, levels, norm 

2004 

2005 

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

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

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

2009 if ( 

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

2011 and "difference" not in cube.long_name 

2012 and "mask" not in cube.long_name 

2013 ): 

2014 # Define the levels and colors (in km) 

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

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

2017 colours = [ 

2018 "#8f00d6", 

2019 "#d10000", 

2020 "#ff9700", 

2021 "#ffff00", 

2022 "#00007f", 

2023 "#6c9ccd", 

2024 "#aae8ff", 

2025 "#37a648", 

2026 "#8edc64", 

2027 "#c5ffc5", 

2028 "#dcdcdc", 

2029 "#ffffff", 

2030 ] 

2031 # Create a custom colormap 

2032 cmap = mcolors.ListedColormap(colours) 

2033 # Normalize the levels 

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

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

2036 else: 

2037 # do nothing and keep existing colorbar attributes 

2038 cmap = cmap 

2039 levels = levels 

2040 norm = norm 

2041 return cmap, levels, norm 

2042 

2043 

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

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

2046 model_names = list( 

2047 filter( 

2048 lambda x: x is not None, 

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

2050 ) 

2051 ) 

2052 if not model_names: 

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

2054 return 1 

2055 else: 

2056 return len(model_names) 

2057 

2058 

2059def _validate_cube_shape( 

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

2061) -> None: 

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

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

2064 raise ValueError( 

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

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

2067 ) 

2068 

2069 

2070def _validate_cubes_coords( 

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

2072) -> None: 

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

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

2075 raise ValueError( 

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

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

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

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

2080 ) 

2081 

2082 

2083#################### 

2084# Public functions # 

2085#################### 

2086 

2087 

2088def spatial_contour_plot( 

2089 cube: iris.cube.Cube, 

2090 filename: str = None, 

2091 sequence_coordinate: str = "time", 

2092 stamp_coordinate: str = "realization", 

2093 **kwargs, 

2094) -> iris.cube.Cube: 

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

2096 

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

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

2099 is present then postage stamp plots will be produced. 

2100 

2101 Parameters 

2102 ---------- 

2103 cube: Cube 

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

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

2106 plotted sequentially and/or as postage stamp plots. 

2107 filename: str, optional 

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

2109 to the recipe name. 

2110 sequence_coordinate: str, optional 

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

2112 This coordinate must exist in the cube. 

2113 stamp_coordinate: str, optional 

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

2115 ``"realization"``. 

2116 

2117 Returns 

2118 ------- 

2119 Cube 

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

2121 

2122 Raises 

2123 ------ 

2124 ValueError 

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

2126 TypeError 

2127 If the cube isn't a single cube. 

2128 """ 

2129 _spatial_plot( 

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

2131 ) 

2132 return cube 

2133 

2134 

2135def spatial_pcolormesh_plot( 

2136 cube: iris.cube.Cube, 

2137 filename: str = None, 

2138 sequence_coordinate: str = "time", 

2139 stamp_coordinate: str = "realization", 

2140 **kwargs, 

2141) -> iris.cube.Cube: 

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

2143 

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

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

2146 is present then postage stamp plots will be produced. 

2147 

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

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

2150 contour areas are important. 

2151 

2152 Parameters 

2153 ---------- 

2154 cube: Cube 

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

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

2157 plotted sequentially and/or as postage stamp plots. 

2158 filename: str, optional 

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

2160 to the recipe name. 

2161 sequence_coordinate: str, optional 

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

2163 This coordinate must exist in the cube. 

2164 stamp_coordinate: str, optional 

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

2166 ``"realization"``. 

2167 

2168 Returns 

2169 ------- 

2170 Cube 

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

2172 

2173 Raises 

2174 ------ 

2175 ValueError 

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

2177 TypeError 

2178 If the cube isn't a single cube. 

2179 """ 

2180 _spatial_plot( 

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

2182 ) 

2183 return cube 

2184 

2185 

2186# TODO: Expand function to handle ensemble data. 

2187# line_coordinate: str, optional 

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

2189# ``"realization"``. 

2190def plot_line_series( 

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

2192 filename: str = None, 

2193 series_coordinate: str = "time", 

2194 # line_coordinate: str = "realization", 

2195 **kwargs, 

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

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

2198 

2199 The Cube or CubeList must be 1D. 

2200 

2201 Parameters 

2202 ---------- 

2203 iris.cube | iris.cube.CubeList 

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

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

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

2207 filename: str, optional 

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

2209 to the recipe name. 

2210 series_coordinate: str, optional 

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

2212 coordinate must exist in the cube. 

2213 

2214 Returns 

2215 ------- 

2216 iris.cube.Cube | iris.cube.CubeList 

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

2218 plotted data. 

2219 

2220 Raises 

2221 ------ 

2222 ValueError 

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

2224 TypeError 

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

2226 """ 

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

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

2229 

2230 num_models = _get_num_models(cube) 

2231 

2232 _validate_cube_shape(cube, num_models) 

2233 

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

2235 cubes = iter_maybe(cube) 

2236 coords = [] 

2237 for cube in cubes: 

2238 try: 

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

2240 except iris.exceptions.CoordinateNotFoundError as err: 

2241 raise ValueError( 

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

2243 ) from err 

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

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

2246 

2247 # Format the title and filename using plotted series coordinate 

2248 nplot = 1 

2249 seq_coord = coords[0] 

2250 plot_title, plot_filename = _set_title_and_filename( 

2251 seq_coord, nplot, recipe_title, filename 

2252 ) 

2253 

2254 # Do the actual plotting. 

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

2256 

2257 # Add list of plots to plot metadata. 

2258 plot_index = _append_to_plot_index([plot_filename]) 

2259 

2260 # Make a page to display the plots. 

2261 _make_plot_html_page(plot_index) 

2262 

2263 return cube 

2264 

2265 

2266def plot_vertical_line_series( 

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

2268 filename: str = None, 

2269 series_coordinate: str = "model_level_number", 

2270 sequence_coordinate: str = "time", 

2271 # line_coordinate: str = "realization", 

2272 **kwargs, 

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

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

2275 

2276 The Cube or CubeList must be 1D. 

2277 

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

2279 then a sequence of plots will be produced. 

2280 

2281 Parameters 

2282 ---------- 

2283 iris.cube | iris.cube.CubeList 

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

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

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

2287 filename: str, optional 

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

2289 to the recipe name. 

2290 series_coordinate: str, optional 

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

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

2293 for LFRic. Defaults to ``model_level_number``. 

2294 This coordinate must exist in the cube. 

2295 sequence_coordinate: str, optional 

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

2297 This coordinate must exist in the cube. 

2298 

2299 Returns 

2300 ------- 

2301 iris.cube.Cube | iris.cube.CubeList 

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

2303 Plotted data. 

2304 

2305 Raises 

2306 ------ 

2307 ValueError 

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

2309 TypeError 

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

2311 """ 

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

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

2314 

2315 cubes = iter_maybe(cubes) 

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

2317 all_data = [] 

2318 

2319 # Store min/max ranges for x range. 

2320 x_levels = [] 

2321 

2322 num_models = _get_num_models(cubes) 

2323 

2324 _validate_cube_shape(cubes, num_models) 

2325 

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

2327 coords = [] 

2328 for cube in cubes: 

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

2330 try: 

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

2332 except iris.exceptions.CoordinateNotFoundError as err: 

2333 raise ValueError( 

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

2335 ) from err 

2336 

2337 try: 

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

2339 cube.coord(sequence_coordinate) 

2340 except iris.exceptions.CoordinateNotFoundError as err: 

2341 raise ValueError( 

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

2343 ) from err 

2344 

2345 # Get minimum and maximum from levels information. 

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

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

2348 x_levels.append(min(levels)) 

2349 x_levels.append(max(levels)) 

2350 else: 

2351 all_data.append(cube.data) 

2352 

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

2354 # Combine all data into a single NumPy array 

2355 combined_data = np.concatenate(all_data) 

2356 

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

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

2359 # sequence and if applicable postage stamp coordinate. 

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

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

2362 else: 

2363 vmin = min(x_levels) 

2364 vmax = max(x_levels) 

2365 

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

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

2368 # necessary) 

2369 def filter_cube_iterables(cube_iterables) -> bool: 

2370 return len(cube_iterables) == len(coords) 

2371 

2372 cube_iterables = filter( 

2373 filter_cube_iterables, 

2374 ( 

2375 iris.cube.CubeList( 

2376 s 

2377 for s in itertools.chain.from_iterable( 

2378 cb.slices_over(sequence_coordinate) for cb in cubes 

2379 ) 

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

2381 ) 

2382 for point in sorted( 

2383 set( 

2384 itertools.chain.from_iterable( 

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

2386 ) 

2387 ) 

2388 ) 

2389 ), 

2390 ) 

2391 

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

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

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

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

2396 # or an iris.cube.CubeList. 

2397 plot_index = [] 

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

2399 for cubes_slice in cube_iterables: 

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

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

2402 plot_title, plot_filename = _set_title_and_filename( 

2403 seq_coord, nplot, recipe_title, filename 

2404 ) 

2405 

2406 # Do the actual plotting. 

2407 _plot_and_save_vertical_line_series( 

2408 cubes_slice, 

2409 coords, 

2410 "realization", 

2411 plot_filename, 

2412 series_coordinate, 

2413 title=plot_title, 

2414 vmin=vmin, 

2415 vmax=vmax, 

2416 ) 

2417 plot_index.append(plot_filename) 

2418 

2419 # Add list of plots to plot metadata. 

2420 complete_plot_index = _append_to_plot_index(plot_index) 

2421 

2422 # Make a page to display the plots. 

2423 _make_plot_html_page(complete_plot_index) 

2424 

2425 return cubes 

2426 

2427 

2428def qq_plot( 

2429 cubes: iris.cube.CubeList, 

2430 coordinates: list[str], 

2431 percentiles: list[float], 

2432 model_names: list[str], 

2433 filename: str = None, 

2434 one_to_one: bool = True, 

2435 **kwargs, 

2436) -> iris.cube.CubeList: 

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

2438 

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

2440 collapsed within the operator over all specified coordinates such as 

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

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

2443 

2444 Parameters 

2445 ---------- 

2446 cubes: iris.cube.CubeList 

2447 Two cubes of the same variable with different models. 

2448 coordinate: list[str] 

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

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

2451 the percentile coordinate. 

2452 percent: list[float] 

2453 A list of percentiles to appear in the plot. 

2454 model_names: list[str] 

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

2456 filename: str, optional 

2457 Filename of the plot to write. 

2458 one_to_one: bool, optional 

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

2460 

2461 Raises 

2462 ------ 

2463 ValueError 

2464 When the cubes are not compatible. 

2465 

2466 Notes 

2467 ----- 

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

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

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

2471 compares percentiles of two datasets. This plot does 

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

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

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

2475 

2476 Quantile-quantile plots are valuable for comparing against 

2477 observations and other models. Identical percentiles between the variables 

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

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

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

2481 Wilks 2011 [Wilks2011]_). 

2482 

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

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

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

2486 the extremes. 

2487 

2488 References 

2489 ---------- 

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

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

2492 """ 

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

2494 if len(cubes) != 2: 

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

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

2497 other: Cube = cubes.extract_cube( 

2498 iris.Constraint( 

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

2500 ) 

2501 ) 

2502 

2503 # Get spatial coord names. 

2504 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

2505 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

2506 

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

2508 # This is triggered if either 

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

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

2511 # errors. 

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

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

2514 # for UM and LFRic comparisons. 

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

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

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

2518 # given this dependency on regridding. 

2519 if ( 

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

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

2522 ) or ( 

2523 base.long_name 

2524 in [ 

2525 "eastward_wind_at_10m", 

2526 "northward_wind_at_10m", 

2527 "northward_wind_at_cell_centres", 

2528 "eastward_wind_at_cell_centres", 

2529 "zonal_wind_at_pressure_levels", 

2530 "meridional_wind_at_pressure_levels", 

2531 "potential_vorticity_at_pressure_levels", 

2532 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

2533 ] 

2534 ): 

2535 logging.debug( 

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

2537 ) 

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

2539 

2540 # Extract just common time points. 

2541 base, other = _extract_common_time_points(base, other) 

2542 

2543 # Equalise attributes so we can merge. 

2544 fully_equalise_attributes([base, other]) 

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

2546 

2547 # Collapse cubes. 

2548 base = collapse( 

2549 base, 

2550 coordinate=coordinates, 

2551 method="PERCENTILE", 

2552 additional_percent=percentiles, 

2553 ) 

2554 other = collapse( 

2555 other, 

2556 coordinate=coordinates, 

2557 method="PERCENTILE", 

2558 additional_percent=percentiles, 

2559 ) 

2560 

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

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

2563 title = f"{recipe_title}" 

2564 

2565 if filename is None: 

2566 filename = slugify(recipe_title) 

2567 

2568 # Add file extension. 

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

2570 

2571 # Do the actual plotting on a scatter plot 

2572 _plot_and_save_scatter_plot( 

2573 base, other, plot_filename, title, one_to_one, model_names 

2574 ) 

2575 

2576 # Add list of plots to plot metadata. 

2577 plot_index = _append_to_plot_index([plot_filename]) 

2578 

2579 # Make a page to display the plots. 

2580 _make_plot_html_page(plot_index) 

2581 

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

2583 

2584 

2585def scatter_plot( 

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

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

2588 filename: str = None, 

2589 one_to_one: bool = True, 

2590 **kwargs, 

2591) -> iris.cube.CubeList: 

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

2593 

2594 Both cubes must be 1D. 

2595 

2596 Parameters 

2597 ---------- 

2598 cube_x: Cube | CubeList 

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

2600 cube_y: Cube | CubeList 

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

2602 filename: str, optional 

2603 Filename of the plot to write. 

2604 one_to_one: bool, optional 

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

2606 

2607 Returns 

2608 ------- 

2609 cubes: CubeList 

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

2611 

2612 Raises 

2613 ------ 

2614 ValueError 

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

2616 size. 

2617 TypeError 

2618 If the cube isn't a single cube. 

2619 

2620 Notes 

2621 ----- 

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

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

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

2625 """ 

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

2627 for cube_iter in iter_maybe(cube_x): 

2628 # Check cubes are correct shape. 

2629 cube_iter = _check_single_cube(cube_iter) 

2630 if cube_iter.ndim > 1: 

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

2632 

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

2634 for cube_iter in iter_maybe(cube_y): 

2635 # Check cubes are correct shape. 

2636 cube_iter = _check_single_cube(cube_iter) 

2637 if cube_iter.ndim > 1: 

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

2639 

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

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

2642 title = f"{recipe_title}" 

2643 

2644 if filename is None: 

2645 filename = slugify(recipe_title) 

2646 

2647 # Add file extension. 

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

2649 

2650 # Do the actual plotting. 

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

2652 

2653 # Add list of plots to plot metadata. 

2654 plot_index = _append_to_plot_index([plot_filename]) 

2655 

2656 # Make a page to display the plots. 

2657 _make_plot_html_page(plot_index) 

2658 

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

2660 

2661 

2662def vector_plot( 

2663 cube_u: iris.cube.Cube, 

2664 cube_v: iris.cube.Cube, 

2665 filename: str = None, 

2666 sequence_coordinate: str = "time", 

2667 **kwargs, 

2668) -> iris.cube.CubeList: 

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

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

2671 

2672 # Cubes must have a matching sequence coordinate. 

2673 try: 

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

2675 if cube_u.coord(sequence_coordinate) != cube_v.coord(sequence_coordinate): 2675 ↛ anywhereline 2675 didn't jump anywhere: it always raised an exception.

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

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

2678 raise ValueError( 

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

2680 ) from err 

2681 

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

2683 plot_index = [] 

2684 nplot = np.size(cube_u[0].coord(sequence_coordinate).points) 

2685 for cube_u_slice, cube_v_slice in zip( 

2686 cube_u.slices_over(sequence_coordinate), 

2687 cube_v.slices_over(sequence_coordinate), 

2688 strict=True, 

2689 ): 

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

2691 seq_coord = cube_u_slice.coord(sequence_coordinate) 

2692 plot_title, plot_filename = _set_title_and_filename( 

2693 seq_coord, nplot, recipe_title, filename 

2694 ) 

2695 

2696 # Do the actual plotting. 

2697 _plot_and_save_vector_plot( 

2698 cube_u_slice, 

2699 cube_v_slice, 

2700 filename=plot_filename, 

2701 title=plot_title, 

2702 method="contourf", 

2703 ) 

2704 plot_index.append(plot_filename) 

2705 

2706 # Add list of plots to plot metadata. 

2707 complete_plot_index = _append_to_plot_index(plot_index) 

2708 

2709 # Make a page to display the plots. 

2710 _make_plot_html_page(complete_plot_index) 

2711 

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

2713 

2714 

2715def plot_histogram_series( 

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

2717 filename: str = None, 

2718 sequence_coordinate: str = "time", 

2719 stamp_coordinate: str = "realization", 

2720 single_plot: bool = False, 

2721 **kwargs, 

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

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

2724 

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

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

2727 functionality to scroll through histograms against time. If a 

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

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

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

2731 

2732 Parameters 

2733 ---------- 

2734 cubes: Cube | iris.cube.CubeList 

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

2736 than the stamp coordinate. 

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

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

2739 filename: str, optional 

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

2741 to the recipe name. 

2742 sequence_coordinate: str, optional 

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

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

2745 slider. 

2746 stamp_coordinate: str, optional 

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

2748 ``"realization"``. 

2749 single_plot: bool, optional 

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

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

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

2753 

2754 Returns 

2755 ------- 

2756 iris.cube.Cube | iris.cube.CubeList 

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

2758 Plotted data. 

2759 

2760 Raises 

2761 ------ 

2762 ValueError 

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

2764 TypeError 

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

2766 """ 

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

2768 

2769 cubes = iter_maybe(cubes) 

2770 

2771 # Internal plotting function. 

2772 plotting_func = _plot_and_save_histogram_series 

2773 

2774 num_models = _get_num_models(cubes) 

2775 

2776 _validate_cube_shape(cubes, num_models) 

2777 

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

2779 # time slider option. 

2780 for cube in cubes: 

2781 try: 

2782 cube.coord(sequence_coordinate) 

2783 except iris.exceptions.CoordinateNotFoundError as err: 

2784 raise ValueError( 

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

2786 ) from err 

2787 

2788 # Get minimum and maximum from levels information. 

2789 levels = None 

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

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

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

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

2794 if levels is None: 

2795 break 

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

2797 # levels-based ranges for histogram plots. 

2798 _, levels, _ = _colorbar_map_levels(cube) 

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

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

2801 vmin = min(levels) 

2802 vmax = max(levels) 

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

2804 break 

2805 

2806 if levels is None: 

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

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

2809 

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

2811 # single point. If single_plot is True: 

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

2813 # separate postage stamp plots. 

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

2815 # produced per single model only 

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

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

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

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

2820 ): 

2821 if single_plot: 

2822 plotting_func = ( 

2823 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

2824 ) 

2825 else: 

2826 plotting_func = _plot_and_save_postage_stamp_histogram_series 

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

2828 else: 

2829 all_points = sorted( 

2830 set( 

2831 itertools.chain.from_iterable( 

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

2833 ) 

2834 ) 

2835 ) 

2836 all_slices = list( 

2837 itertools.chain.from_iterable( 

2838 cb.slices_over(sequence_coordinate) for cb in cubes 

2839 ) 

2840 ) 

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

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

2843 # necessary) 

2844 cube_iterables = [ 

2845 iris.cube.CubeList( 

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

2847 ) 

2848 for point in all_points 

2849 ] 

2850 

2851 plot_index = [] 

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

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

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

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

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

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

2858 for cube_slice in cube_iterables: 

2859 single_cube = cube_slice 

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

2861 single_cube = cube_slice[0] 

2862 

2863 # Set plot titles and filename, based on sequence coordinate 

2864 seq_coord = single_cube.coord(sequence_coordinate) 

2865 # Use time coordinate in title and filename if single histogram output. 

2866 if sequence_coordinate == "realization" and nplot == 1: 2866 ↛ 2867line 2866 didn't jump to line 2867 because the condition on line 2866 was never true

2867 seq_coord = single_cube.coord("time") 

2868 plot_title, plot_filename = _set_title_and_filename( 

2869 seq_coord, nplot, recipe_title, filename 

2870 ) 

2871 

2872 # Do the actual plotting. 

2873 plotting_func( 

2874 cube_slice, 

2875 filename=plot_filename, 

2876 stamp_coordinate=stamp_coordinate, 

2877 title=plot_title, 

2878 vmin=vmin, 

2879 vmax=vmax, 

2880 ) 

2881 plot_index.append(plot_filename) 

2882 

2883 # Add list of plots to plot metadata. 

2884 complete_plot_index = _append_to_plot_index(plot_index) 

2885 

2886 # Make a page to display the plots. 

2887 _make_plot_html_page(complete_plot_index) 

2888 

2889 return cubes 

2890 

2891 

2892def plot_power_spectrum_series( 

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

2894 filename: str = None, 

2895 sequence_coordinate: str = "time", 

2896 stamp_coordinate: str = "realization", 

2897 single_plot: bool = False, 

2898 **kwargs, 

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

2900 """Plot a power spectrum plot for each vertical level provided. 

2901 

2902 A power spectrum plot can be plotted, but if the sequence_coordinate (i.e. time) 

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

2904 functionality to scroll through power spectrum against time. If a 

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

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

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

2908 

2909 Parameters 

2910 ---------- 

2911 cubes: Cube | iris.cube.CubeList 

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

2913 than the stamp coordinate. 

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

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

2916 filename: str, optional 

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

2918 to the recipe name. 

2919 sequence_coordinate: str, optional 

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

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

2922 slider. 

2923 stamp_coordinate: str, optional 

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

2925 ``"realization"``. 

2926 single_plot: bool, optional 

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

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

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

2930 

2931 Returns 

2932 ------- 

2933 iris.cube.Cube | iris.cube.CubeList 

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

2935 Plotted data. 

2936 

2937 Raises 

2938 ------ 

2939 ValueError 

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

2941 TypeError 

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

2943 """ 

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

2945 

2946 cubes = iter_maybe(cubes) 

2947 

2948 # Internal plotting function. 

2949 plotting_func = _plot_and_save_power_spectrum_series 

2950 

2951 num_models = _get_num_models(cubes) 

2952 

2953 _validate_cube_shape(cubes, num_models) 

2954 

2955 # If several power spectra are plotted with time as sequence_coordinate for the 

2956 # time slider option. 

2957 for cube in cubes: 

2958 try: 

2959 cube.coord(sequence_coordinate) 

2960 except iris.exceptions.CoordinateNotFoundError as err: 

2961 raise ValueError( 

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

2963 ) from err 

2964 

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

2966 # single point. If single_plot is True: 

2967 # -- all postage stamp plots will be plotted in a single plot instead of 

2968 # separate postage stamp plots. 

2969 # -- model names (hidden in cube attrs) are ignored, that is stamp plots are 

2970 # produced per single model only 

2971 if num_models == 1: 2971 ↛ 2984line 2971 didn't jump to line 2984 because the condition on line 2971 was always true

2972 if ( 2972 ↛ 2976line 2972 didn't jump to line 2976 because the condition on line 2972 was never true

2973 stamp_coordinate in [c.name() for c in cubes[0].coords()] 

2974 and cubes[0].coord(stamp_coordinate).shape[0] > 1 

2975 ): 

2976 if single_plot: 

2977 plotting_func = ( 

2978 _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series 

2979 ) 

2980 else: 

2981 plotting_func = _plot_and_save_postage_stamp_power_spectrum_series 

2982 cube_iterables = cubes[0].slices_over(sequence_coordinate) 

2983 else: 

2984 all_points = sorted( 

2985 set( 

2986 itertools.chain.from_iterable( 

2987 cb.coord(sequence_coordinate).points for cb in cubes 

2988 ) 

2989 ) 

2990 ) 

2991 all_slices = list( 

2992 itertools.chain.from_iterable( 

2993 cb.slices_over(sequence_coordinate) for cb in cubes 

2994 ) 

2995 ) 

2996 # Matched slices (matched by seq coord point; it may happen that 

2997 # evaluated models do not cover the same seq coord range, hence matching 

2998 # necessary) 

2999 cube_iterables = [ 

3000 iris.cube.CubeList( 

3001 s for s in all_slices if s.coord(sequence_coordinate).points[0] == point 

3002 ) 

3003 for point in all_points 

3004 ] 

3005 

3006 plot_index = [] 

3007 nplot = np.size(cube.coord(sequence_coordinate).points) 

3008 # Create a plot for each value of the sequence coordinate. Allowing for 

3009 # multiple cubes in a CubeList to be plotted in the same plot for similar 

3010 # sequence values. Passing a CubeList into the internal plotting function 

3011 # for similar values of the sequence coordinate. cube_slice can be an 

3012 # iris.cube.Cube or an iris.cube.CubeList. 

3013 for cube_slice in cube_iterables: 

3014 single_cube = cube_slice 

3015 if isinstance(cube_slice, iris.cube.CubeList): 3015 ↛ 3016line 3015 didn't jump to line 3016 because the condition on line 3015 was never true

3016 single_cube = cube_slice[0] 

3017 

3018 # Set plot title and filenames based on sequence values 

3019 seq_coord = single_cube.coord(sequence_coordinate) 

3020 plot_title, plot_filename = _set_title_and_filename( 

3021 seq_coord, nplot, recipe_title, filename 

3022 ) 

3023 

3024 # Do the actual plotting. 

3025 plotting_func( 

3026 cube_slice, 

3027 filename=plot_filename, 

3028 stamp_coordinate=stamp_coordinate, 

3029 title=plot_title, 

3030 ) 

3031 plot_index.append(plot_filename) 

3032 

3033 # Add list of plots to plot metadata. 

3034 complete_plot_index = _append_to_plot_index(plot_index) 

3035 

3036 # Make a page to display the plots. 

3037 _make_plot_html_page(complete_plot_index) 

3038 

3039 return cubes 

3040 

3041 

3042def _DCT_ps(y_3d): 

3043 """Calculate power spectra for regional domains. 

3044 

3045 Parameters 

3046 ---------- 

3047 y_3d: 3D array 

3048 3 dimensional array to calculate spectrum for. 

3049 (2D field data with 3rd dimension of time) 

3050 

3051 Returns: ps_array 

3052 Array of power spectra values calculated for input field (for each time) 

3053 

3054 Method for regional domains: 

3055 Calculate power spectra over limited area domain using Discrete Cosine Transform (DCT) 

3056 as described in Denis et al 2002 [Denis_etal_2002]_. 

3057 

3058 References 

3059 ---------- 

3060 .. [Denis_etal_2002] Bertrand Denis, Jean Côté and René Laprise (2002) 

3061 "Spectral Decomposition of Two-Dimensional Atmospheric Fields on 

3062 Limited-Area Domains Using the Discrete Cosine Transform (DCT)" 

3063 Monthly Weather Review, Vol. 130, 1812-1828 

3064 doi: https://doi.org/10.1175/1520-0493(2002)130<1812:SDOTDA>2.0.CO;2 

3065 """ 

3066 Nt, Ny, Nx = y_3d.shape 

3067 

3068 # Max coefficient 

3069 Nmin = min(Nx - 1, Ny - 1) 

3070 

3071 # Create alpha matrix (of wavenumbers) 

3072 alpha_matrix = _create_alpha_matrix(Ny, Nx) 

3073 

3074 # Prepare output array 

3075 ps_array = np.zeros((Nt, Nmin)) 

3076 

3077 # Loop over time to get spectrum for each time. 

3078 for t in range(Nt): 

3079 y_2d = y_3d[t] 

3080 

3081 # Apply 2D DCT to transform y_3d[t] from physical space to spectral space. 

3082 # fkk is a 2D array of DCT coefficients, representing the amplitudes of 

3083 # cosine basis functions at different spatial frequencies. 

3084 

3085 # normalise spectrum to allow comparison between models. 

3086 fkk = fft.dctn(y_2d, norm="ortho") 

3087 

3088 # Normalise fkk 

3089 fkk = fkk / np.sqrt(Ny * Nx) 

3090 

3091 # calculate variance of spectral coefficient 

3092 sigma_2 = fkk**2 / Nx / Ny 

3093 

3094 # Group ellipses of alphas into the same wavenumber k/Nmin 

3095 for k in range(1, Nmin + 1): 

3096 alpha = k / Nmin 

3097 alpha_p1 = (k + 1) / Nmin 

3098 

3099 # Sum up elements matching k 

3100 mask_k = np.where((alpha_matrix >= alpha) & (alpha_matrix < alpha_p1)) 

3101 ps_array[t, k - 1] = np.sum(sigma_2[mask_k]) 

3102 

3103 return ps_array 

3104 

3105 

3106def _create_alpha_matrix(Ny, Nx): 

3107 """Construct an array of 2D wavenumbers from 2D wavenumber pair. 

3108 

3109 Parameters 

3110 ---------- 

3111 Ny, Nx: 

3112 Dimensions of the 2D field for which the power spectra is calculated. Used to 

3113 create the array of 2D wavenumbers. Each Ny, Nx pair is associated with a 

3114 single-scale parameter. 

3115 

3116 Returns: alpha_matrix 

3117 normalisation of 2D wavenumber axes, transforming the spectral domain into 

3118 an elliptic coordinate system. 

3119 

3120 """ 

3121 # Create x_indices: each row is [1, 2, ..., Nx] 

3122 x_indices = np.tile(np.arange(1, Nx + 1), (Ny, 1)) 

3123 

3124 # Create y_indices: each column is [1, 2, ..., Ny] 

3125 y_indices = np.tile(np.arange(1, Ny + 1).reshape(Ny, 1), (1, Nx)) 

3126 

3127 # Compute alpha_matrix 

3128 alpha_matrix = np.sqrt((x_indices**2) / Nx**2 + (y_indices**2) / Ny**2) 

3129 

3130 return alpha_matrix