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

1056 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-28 09:58 +0000

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

2# 

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

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

5# You may obtain a copy of the License at 

6# 

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

8# 

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

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

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

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

13# limitations under the License. 

14 

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

16 

17import fcntl 

18import functools 

19import importlib.resources 

20import itertools 

21import json 

22import logging 

23import math 

24import os 

25from typing import Literal 

26 

27import cartopy.crs as ccrs 

28import cartopy.feature as cfeature 

29import iris 

30import iris.coords 

31import iris.cube 

32import iris.exceptions 

33import iris.plot as iplt 

34import matplotlib as mpl 

35import matplotlib.colors as mcolors 

36import matplotlib.pyplot as plt 

37import numpy as np 

38import scipy.fft as fft 

39from cartopy.mpl.geoaxes import GeoAxes 

40from iris.cube import Cube 

41from markdown_it import MarkdownIt 

42from mpl_toolkits.axes_grid1.inset_locator import inset_axes 

43 

44from CSET._common import ( 

45 combine_dicts, 

46 filename_slugify, 

47 get_recipe_metadata, 

48 iter_maybe, 

49 render_file, 

50 slugify, 

51) 

52from CSET.operators._utils import ( 

53 check_stamp_coordinate, 

54 fully_equalise_attributes, 

55 get_cube_yxcoordname, 

56 is_transect, 

57 slice_over_maybe, 

58) 

59from CSET.operators.collapse import collapse 

60from CSET.operators.misc import _extract_common_time_points 

61from CSET.operators.regrid import regrid_onto_cube 

62 

63# Use a non-interactive plotting backend. 

64mpl.use("agg") 

65 

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

67 

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

69# Private helper functions # 

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

71 

72 

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

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

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

76 fcntl.flock(fp, fcntl.LOCK_EX) 

77 fp.seek(0) 

78 meta = json.load(fp) 

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

80 complete_plot_index = complete_plot_index + plot_index 

81 meta["plots"] = complete_plot_index 

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

83 os.getenv("DO_CASE_AGGREGATION") 

84 ): 

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

86 fp.seek(0) 

87 fp.truncate() 

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

89 return complete_plot_index 

90 

91 

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

93 """Ensure a single cube is given. 

94 

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

96 otherwise an error is raised. 

97 

98 Parameters 

99 ---------- 

100 cube: Cube | CubeList 

101 The cube to check. 

102 

103 Returns 

104 ------- 

105 cube: Cube 

106 The checked cube. 

107 

108 Raises 

109 ------ 

110 TypeError 

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

112 """ 

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

114 return cube 

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

116 if len(cube) == 1: 

117 return cube[0] 

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

119 

120 

121def _make_plot_html_page(plots: list): 

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

123 # Debug check that plots actually contains some strings. 

124 assert isinstance(plots[0], str) 

125 

126 # Load HTML template file. 

127 operator_files = importlib.resources.files() 

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

129 

130 # Get some metadata. 

131 meta = get_recipe_metadata() 

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

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

134 

135 # Prepare template variables. 

136 variables = { 

137 "title": title, 

138 "description": description, 

139 "initial_plot": plots[0], 

140 "plots": plots, 

141 "title_slug": slugify(title), 

142 } 

143 

144 # Render template. 

145 html = render_file(template_file, **variables) 

146 

147 # Save completed HTML. 

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

149 fp.write(html) 

150 

151 

152@functools.cache 

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

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

155 

156 This is a separate function to make it cacheable. 

157 """ 

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

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

160 colorbar = json.load(fp) 

161 

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

163 override_colorbar = {} 

164 if user_colorbar_file: 

165 try: 

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

167 override_colorbar = json.load(fp) 

168 except FileNotFoundError: 

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

170 

171 # Overwrite values with the user supplied colorbar definition. 

172 colorbar = combine_dicts(colorbar, override_colorbar) 

173 return colorbar 

174 

175 

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

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

178 

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

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

181 to model_name attribute. 

182 

183 Parameters 

184 ---------- 

185 cubes: CubeList or Cube 

186 Cubes with model_name attribute 

187 

188 Returns 

189 ------- 

190 model_colors_map: 

191 Dictionary mapping model_name attribute to colors 

192 """ 

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

194 colorbar = _load_colorbar_map(user_colorbar_file) 

195 model_names = sorted( 

196 filter( 

197 lambda x: x is not None, 

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

199 ) 

200 ) 

201 if not model_names: 

202 return {} 

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

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

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

206 

207 color_list = itertools.cycle(DEFAULT_DISCRETE_COLORS) 

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

209 

210 

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

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

213 

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

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

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

217 exist for specific pressure levels to account for variables with 

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

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

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

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

222 

223 Parameters 

224 ---------- 

225 cube: Cube 

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

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

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

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

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

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

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

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

234 

235 Returns 

236 ------- 

237 cmap: 

238 Matplotlib colormap. 

239 levels: 

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

241 should be taken as the range. 

242 norm: 

243 BoundaryNorm information. 

244 """ 

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

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

247 colorbar = _load_colorbar_map(user_colorbar_file) 

248 cmap = None 

249 

250 try: 

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

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

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

254 pressure_level = str(int(pressure_level_raw)) 

255 except iris.exceptions.CoordinateNotFoundError: 

256 pressure_level = None 

257 

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

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

260 # consistent. 

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

262 for varname in varnames: 

263 # Get the colormap for this variable. 

264 try: 

265 var_colorbar = colorbar[varname] 

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

267 varname_key = varname 

268 break 

269 except KeyError: 

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

271 

272 # Get colormap if it is a mask. 

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

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

275 return cmap, levels, norm 

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

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

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

279 return cmap, levels, norm 

280 # If probability is plotted use custom colorbar and levels 

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

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

283 return cmap, levels, norm 

284 # If aviation colour state use custom colorbar and levels 

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

286 cmap, levels, norm = _custom_colormap_aviation_colour_state(cube) 

287 return cmap, levels, norm 

288 

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

290 if not cmap: 

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

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

293 return cmap, levels, norm 

294 

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

296 if pressure_level: 

297 try: 

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

299 except KeyError: 

300 logging.debug( 

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

302 varname, 

303 pressure_level, 

304 ) 

305 

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

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

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

309 if axis: 

310 if axis == "x": 

311 try: 

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

313 except KeyError: 

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

315 if axis == "y": 

316 try: 

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

318 except KeyError: 

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

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

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

322 levels = None 

323 else: 

324 levels = [vmin, vmax] 

325 return None, levels, None 

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

327 else: 

328 try: 

329 levels = var_colorbar["levels"] 

330 # Use discrete bins when levels are specified, rather 

331 # than a smooth range. 

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

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

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

335 except KeyError: 

336 # Get the range for this variable. 

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

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

339 # Calculate levels from range. 

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

341 levels = None 

342 else: 

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

344 norm = None 

345 

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

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

348 # JSON file. 

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

350 cmap, levels, norm = _custom_colourmap_visibility_in_air( 

351 cube, cmap, levels, norm 

352 ) 

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

354 return cmap, levels, norm 

355 

356 

357def _setup_spatial_map( 

358 cube: iris.cube.Cube, 

359 figure, 

360 cmap, 

361 grid_size: int | None = None, 

362 subplot: int | None = None, 

363): 

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

365 

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

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

368 

369 Parameters 

370 ---------- 

371 cube: Cube 

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

373 figure: 

374 Matplotlib Figure object holding all plot elements. 

375 cmap: 

376 Matplotlib colormap. 

377 grid_size: int, optional 

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

379 subplot: int, optional 

380 Subplot index if multiple spatial subplots in figure. 

381 

382 Returns 

383 ------- 

384 axes: 

385 Matplotlib GeoAxes definition. 

386 """ 

387 # Identify min/max plot bounds. 

388 try: 

389 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

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

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

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

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

394 

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

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

397 x1 = x1 - 180.0 

398 x2 = x2 - 180.0 

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

400 

401 # Consider map projection orientation. 

402 # Adapting orientation enables plotting across international dateline. 

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

404 if x2 > 180.0 or x1 < -180.0: 

405 central_longitude = 180.0 

406 else: 

407 central_longitude = 0.0 

408 

409 # Define spatial map projection. 

410 coord_system = cube.coord(lat_axis).coord_system 

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

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

413 projection = ccrs.RotatedPole( 

414 pole_longitude=coord_system.grid_north_pole_longitude, 

415 pole_latitude=coord_system.grid_north_pole_latitude, 

416 central_rotated_longitude=central_longitude, 

417 ) 

418 crs = projection 

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

420 # Define Transverse Mercator projection for TM inputs. 

421 projection = ccrs.TransverseMercator( 

422 central_longitude=coord_system.longitude_of_central_meridian, 

423 central_latitude=coord_system.latitude_of_projection_origin, 

424 false_easting=coord_system.false_easting, 

425 false_northing=coord_system.false_northing, 

426 scale_factor=coord_system.scale_factor_at_central_meridian, 

427 ) 

428 crs = projection 

429 else: 

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

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

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

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

434 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

435 crs = ccrs.PlateCarree() 

436 

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

438 if subplot is not None: 

439 axes = figure.add_subplot( 

440 grid_size, grid_size, subplot, projection=projection 

441 ) 

442 else: 

443 axes = figure.add_subplot(projection=projection) 

444 

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

446 # Avoid adding lines for masked data or specific fixed ancillary spatial plots. 

447 if iris.util.is_masked(cube.data) or any( 447 ↛ 450line 447 didn't jump to line 450 because the condition on line 447 was never true

448 name in cube.name() for name in ["land_", "orography", "altitude"] 

449 ): 

450 pass 

451 else: 

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

453 coastcol = "magenta" 

454 else: 

455 coastcol = "black" 

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

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

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

459 

460 # Add gridlines. 

461 if subplot is None: 

462 draw_labels = True 

463 else: 

464 draw_labels = False 

465 gl = axes.gridlines( 

466 alpha=0.3, 

467 draw_labels=draw_labels, 

468 dms=False, 

469 x_inline=False, 

470 y_inline=False, 

471 ) 

472 gl.top_labels = False 

473 gl.right_labels = False 

474 

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

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

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

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

479 

480 except ValueError: 

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

482 axes = figure.gca() 

483 pass 

484 

485 return axes 

486 

487 

488def _get_plot_resolution() -> int: 

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

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

491 

492 

493def _get_start_end_strings(seq_coord: iris.coords.Coord, use_bounds: bool): 

494 """Return title and filename based on start and end points or bounds.""" 

495 if use_bounds and seq_coord.has_bounds(): 

496 vals = seq_coord.bounds.flatten() 

497 else: 

498 vals = seq_coord.points 

499 start = seq_coord.units.title(vals[0]) 

500 end = seq_coord.units.title(vals[-1]) 

501 

502 if start == end: 

503 sequence_title = f"\n [{start}]" 

504 sequence_fname = f"_{filename_slugify(start)}" 

505 else: 

506 sequence_title = f"\n [{start} to {end}]" 

507 sequence_fname = f"_{filename_slugify(start)}_{filename_slugify(end)}" 

508 

509 # Do not include time if coord set to zero. 

510 if ( 

511 seq_coord.units == "hours since 0001-01-01 00:00:00" 

512 and vals[0] == 0 

513 and vals[-1] == 0 

514 ): 

515 sequence_title = "" 

516 sequence_fname = "" 

517 

518 return sequence_title, sequence_fname 

519 

520 

521def _set_title_and_filename( 

522 seq_coord: iris.coords.Coord, 

523 nplot: int, 

524 recipe_title: str, 

525 filename: str, 

526): 

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

528 

529 Parameters 

530 ---------- 

531 sequence_coordinate: iris.coords.Coord 

532 Coordinate about which to make a plot sequence. 

533 nplot: int 

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

535 recipe_title: str 

536 Default plot title, potentially to update. 

537 filename: str 

538 Input plot filename, potentially to update. 

539 

540 Returns 

541 ------- 

542 plot_title: str 

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

544 plot_filename: str 

545 Output formatted plot filename string. 

546 """ 

547 ndim = seq_coord.ndim 

548 npoints = np.size(seq_coord.points) 

549 sequence_title = "" 

550 sequence_fname = "" 

551 

552 # Case 1: Multiple dimension sequence input - list number of aggregated cases 

553 # (e.g. aggregation histogram plots) 

554 if ndim > 1: 

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

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

557 sequence_fname = f"_{ncase}cases" 

558 

559 # Case 2: Single dimension input 

560 else: 

561 # Single sequence point 

562 if npoints == 1: 

563 if nplot > 1: 

564 # Default labels for sequence inputs 

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

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

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

568 else: 

569 # Aggregated attribute available where input collapsed over aggregation 

570 try: 

571 ncase = seq_coord.attributes["number_reference_times"] 

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

573 sequence_fname = f"_{ncase}cases" 

574 except KeyError: 

575 sequence_title, sequence_fname = _get_start_end_strings( 

576 seq_coord, use_bounds=seq_coord.has_bounds() 

577 ) 

578 # Multiple sequence (e.g. time) points 

579 else: 

580 sequence_title, sequence_fname = _get_start_end_strings( 

581 seq_coord, use_bounds=False 

582 ) 

583 

584 # Set plot title and filename 

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

586 

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

588 if filename is None: 

589 filename = slugify(recipe_title) 

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

591 else: 

592 if nplot > 1: 

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

594 else: 

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

596 

597 return plot_title, plot_filename 

598 

599 

600def _plot_and_save_spatial_plot( 

601 cube: iris.cube.Cube, 

602 filename: str, 

603 title: str, 

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

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

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

607 **kwargs, 

608): 

609 """Plot and save a spatial plot. 

610 

611 Parameters 

612 ---------- 

613 cube: Cube 

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

615 filename: str 

616 Filename of the plot to write. 

617 title: str 

618 Plot title. 

619 method: "contourf" | "pcolormesh" 

620 The plotting method to use. 

621 overlay_cube: Cube, optional 

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

623 contour_cube: Cube, optional 

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

625 """ 

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

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

628 

629 # Specify the color bar 

630 cmap, levels, norm = _colorbar_map_levels(cube) 

631 

632 # If overplotting, set required colorbars 

633 if overlay_cube: 

634 over_cmap, over_levels, over_norm = _colorbar_map_levels(overlay_cube) 

635 if contour_cube: 

636 cntr_cmap, cntr_levels, cntr_norm = _colorbar_map_levels(contour_cube) 

637 

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

639 axes = _setup_spatial_map(cube, fig, cmap) 

640 

641 # Plot the field. 

642 if method == "contourf": 

643 # Filled contour plot of the field. 

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

645 elif method == "pcolormesh": 

646 try: 

647 vmin = min(levels) 

648 vmax = max(levels) 

649 except TypeError: 

650 vmin, vmax = None, None 

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

652 # if levels are defined. 

653 if norm is not None: 

654 vmin = None 

655 vmax = None 

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

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

658 else: 

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

660 

661 # Overplot overlay field, if required 

662 if overlay_cube: 

663 try: 

664 over_vmin = min(over_levels) 

665 over_vmax = max(over_levels) 

666 except TypeError: 

667 over_vmin, over_vmax = None, None 

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

669 over_vmin = None 

670 over_vmax = None 

671 overlay = iplt.pcolormesh( 

672 overlay_cube, 

673 cmap=over_cmap, 

674 norm=over_norm, 

675 alpha=0.8, 

676 vmin=over_vmin, 

677 vmax=over_vmax, 

678 ) 

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

680 if contour_cube: 

681 contour = iplt.contour( 

682 contour_cube, 

683 colors="darkgray", 

684 levels=cntr_levels, 

685 norm=cntr_norm, 

686 alpha=0.5, 

687 linestyles="--", 

688 linewidths=1, 

689 ) 

690 plt.clabel(contour) 

691 

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

693 if is_transect(cube): 

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

695 axes.invert_yaxis() 

696 axes.set_yscale("log") 

697 axes.set_ylim(1100, 100) 

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

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

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

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

702 ): 

703 axes.set_yscale("log") 

704 

705 axes.set_title( 

706 f"{title}\n" 

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

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

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

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

711 fontsize=16, 

712 ) 

713 

714 # Inset code 

715 axins = inset_axes( 

716 axes, 

717 width="20%", 

718 height="20%", 

719 loc="upper right", 

720 axes_class=GeoAxes, 

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

722 ) 

723 

724 # Slightly transparent to reduce plot blocking. 

725 axins.patch.set_alpha(0.4) 

726 

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

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

729 

730 SLat, SLon, ELat, ELon = ( 

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

732 ) 

733 

734 # Draw line between them 

735 axins.plot( 

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

737 ) 

738 

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

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

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

742 

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

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

745 

746 # Midpoints 

747 lon_mid = (lon_min + lon_max) / 2 

748 lat_mid = (lat_min + lat_max) / 2 

749 

750 # Maximum half-range 

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

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

753 half_range = 1 

754 

755 # Set square extent 

756 axins.set_extent( 

757 [ 

758 lon_mid - half_range, 

759 lon_mid + half_range, 

760 lat_mid - half_range, 

761 lat_mid + half_range, 

762 ], 

763 crs=ccrs.PlateCarree(), 

764 ) 

765 

766 # Ensure square aspect 

767 axins.set_aspect("equal") 

768 

769 else: 

770 # Add title. 

771 axes.set_title(title, fontsize=16) 

772 

773 # Adjust padding if spatial plot or transect 

774 if is_transect(cube): 

775 yinfopad = -0.1 

776 ycbarpad = 0.1 

777 else: 

778 yinfopad = 0.01 

779 ycbarpad = 0.042 

780 

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

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

783 axes.annotate( 

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

785 xy=(0.025, yinfopad), 

786 xycoords="axes fraction", 

787 xytext=(-5, 5), 

788 textcoords="offset points", 

789 ha="left", 

790 va="bottom", 

791 size=11, 

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

793 ) 

794 

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

796 if overlay_cube: 

797 cbarB = fig.colorbar( 

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

799 ) 

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

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

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

803 cbarB.set_ticks(over_levels) 

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

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

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

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

808 

809 # Add main colour bar. 

810 cbar = fig.colorbar( 

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

812 ) 

813 

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

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

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

817 cbar.set_ticks(levels) 

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

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

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

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

822 

823 # Save plot. 

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

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

826 plt.close(fig) 

827 

828 

829def _plot_and_save_postage_stamp_spatial_plot( 

830 cube: iris.cube.Cube, 

831 filename: str, 

832 stamp_coordinate: str, 

833 title: str, 

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

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

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

837 **kwargs, 

838): 

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

840 

841 Parameters 

842 ---------- 

843 cube: Cube 

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

845 filename: str 

846 Filename of the plot to write. 

847 stamp_coordinate: str 

848 Coordinate that becomes different plots. 

849 method: "contourf" | "pcolormesh" 

850 The plotting method to use. 

851 overlay_cube: Cube, optional 

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

853 contour_cube: Cube, optional 

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

855 

856 Raises 

857 ------ 

858 ValueError 

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

860 """ 

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

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

863 

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

865 

866 # Specify the color bar 

867 cmap, levels, norm = _colorbar_map_levels(cube) 

868 # If overplotting, set required colorbars 

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

870 over_cmap, over_levels, over_norm = _colorbar_map_levels(overlay_cube) 

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

872 cntr_cmap, cntr_levels, cntr_norm = _colorbar_map_levels(contour_cube) 

873 

874 # Make a subplot for each member. 

875 for member, subplot in zip( 

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

877 ): 

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

879 axes = _setup_spatial_map( 

880 member, fig, cmap, grid_size=grid_size, subplot=subplot 

881 ) 

882 if method == "contourf": 

883 # Filled contour plot of the field. 

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

885 elif method == "pcolormesh": 

886 if levels is not None: 

887 vmin = min(levels) 

888 vmax = max(levels) 

889 else: 

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

891 vmin, vmax = None, None 

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

893 # if levels are defined. 

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

895 vmin = None 

896 vmax = None 

897 # pcolormesh plot of the field. 

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

899 else: 

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

901 

902 # Overplot overlay field, if required 

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

904 try: 

905 over_vmin = min(over_levels) 

906 over_vmax = max(over_levels) 

907 except TypeError: 

908 over_vmin, over_vmax = None, None 

909 if over_norm is not None: 

910 over_vmin = None 

911 over_vmax = None 

912 iplt.pcolormesh( 

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

914 cmap=over_cmap, 

915 norm=over_norm, 

916 alpha=0.6, 

917 vmin=over_vmin, 

918 vmax=over_vmax, 

919 ) 

920 # Overplot contour field, if required 

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

922 iplt.contour( 

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

924 colors="darkgray", 

925 levels=cntr_levels, 

926 norm=cntr_norm, 

927 alpha=0.6, 

928 linestyles="--", 

929 linewidths=1, 

930 ) 

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

932 

933 # Put the shared colorbar in its own axes. 

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

935 colorbar = fig.colorbar( 

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

937 ) 

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

939 

940 # Overall figure title. 

941 fig.suptitle(title, fontsize=16) 

942 

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

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

945 plt.close(fig) 

946 

947 

948def _plot_and_save_line_series( 

949 cubes: iris.cube.CubeList, 

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

951 ensemble_coord: str, 

952 filename: str, 

953 title: str, 

954 **kwargs, 

955): 

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

957 

958 Parameters 

959 ---------- 

960 cubes: Cube or CubeList 

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

962 coords: list[Coord] 

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

964 ensemble_coord: str 

965 Ensemble coordinate in the cube. 

966 filename: str 

967 Filename of the plot to write. 

968 title: str 

969 Plot title. 

970 """ 

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

972 

973 model_colors_map = _get_model_colors_map(cubes) 

974 

975 # Store min/max ranges. 

976 y_levels = [] 

977 

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

979 _validate_cubes_coords(cubes, coords) 

980 

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

982 label = None 

983 color = "black" 

984 if model_colors_map: 

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

986 color = model_colors_map.get(label) 

987 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

990 iplt.plot( 

991 coord, 

992 cube_slice, 

993 color=color, 

994 marker="o", 

995 ls="-", 

996 lw=3, 

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

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

999 else label, 

1000 ) 

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

1002 else: 

1003 iplt.plot( 

1004 coord, 

1005 cube_slice, 

1006 color=color, 

1007 ls="-", 

1008 lw=1.5, 

1009 alpha=0.75, 

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

1011 ) 

1012 

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

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

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

1016 y_levels.append(min(levels)) 

1017 y_levels.append(max(levels)) 

1018 

1019 # Get the current axes. 

1020 ax = plt.gca() 

1021 

1022 # Add some labels and tweak the style. 

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

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

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

1026 else: 

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

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

1029 ax.set_title(title, fontsize=16) 

1030 

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

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

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

1034 

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

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

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

1038 # Add zero line. 

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

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

1041 logging.debug( 

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

1043 ) 

1044 else: 

1045 ax.autoscale() 

1046 

1047 # Add gridlines 

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

1049 # Ientify unique labels for legend 

1050 handles = list( 

1051 { 

1052 label: handle 

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

1054 }.values() 

1055 ) 

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

1057 

1058 # Save plot. 

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

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

1061 plt.close(fig) 

1062 

1063 

1064def _plot_and_save_vertical_line_series( 

1065 cubes: iris.cube.CubeList, 

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

1067 ensemble_coord: str, 

1068 filename: str, 

1069 series_coordinate: str, 

1070 title: str, 

1071 vmin: float, 

1072 vmax: float, 

1073 **kwargs, 

1074): 

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

1076 

1077 Parameters 

1078 ---------- 

1079 cubes: CubeList 

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

1081 coord: list[Coord] 

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

1083 ensemble_coord: str 

1084 Ensemble coordinate in the cube. 

1085 filename: str 

1086 Filename of the plot to write. 

1087 series_coordinate: str 

1088 Coordinate to use as vertical axis. 

1089 title: str 

1090 Plot title. 

1091 vmin: float 

1092 Minimum value for the x-axis. 

1093 vmax: float 

1094 Maximum value for the x-axis. 

1095 """ 

1096 # plot the vertical pressure axis using log scale 

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

1098 

1099 model_colors_map = _get_model_colors_map(cubes) 

1100 

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

1102 _validate_cubes_coords(cubes, coords) 

1103 

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

1105 label = None 

1106 color = "black" 

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

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

1109 color = model_colors_map.get(label) 

1110 

1111 for cube_slice in cube.slices_over(ensemble_coord): 

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

1113 # unless single forecast. 

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

1115 iplt.plot( 

1116 cube_slice, 

1117 coord, 

1118 color=color, 

1119 marker="o", 

1120 ls="-", 

1121 lw=3, 

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

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

1124 else label, 

1125 ) 

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

1127 else: 

1128 iplt.plot( 

1129 cube_slice, 

1130 coord, 

1131 color=color, 

1132 ls="-", 

1133 lw=1.5, 

1134 alpha=0.75, 

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

1136 ) 

1137 

1138 # Get the current axis 

1139 ax = plt.gca() 

1140 

1141 # Special handling for pressure level data. 

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

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

1144 ax.invert_yaxis() 

1145 ax.set_yscale("log") 

1146 

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

1148 y_tick_labels = [ 

1149 "1000", 

1150 "850", 

1151 "700", 

1152 "500", 

1153 "300", 

1154 "200", 

1155 "100", 

1156 ] 

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

1158 

1159 # Set y-axis limits and ticks. 

1160 ax.set_ylim(1100, 100) 

1161 

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

1163 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

1169 

1170 ax.set_yticks(y_ticks) 

1171 ax.set_yticklabels(y_tick_labels) 

1172 

1173 # Set x-axis limits. 

1174 ax.set_xlim(vmin, vmax) 

1175 # Mark y=0 if present in plot. 

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

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

1178 

1179 # Add some labels and tweak the style. 

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

1181 ax.set_xlabel( 

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

1183 ) 

1184 ax.set_title(title, fontsize=16) 

1185 ax.ticklabel_format(axis="x") 

1186 ax.tick_params(axis="y") 

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

1188 

1189 # Add gridlines 

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

1191 # Ientify unique labels for legend 

1192 handles = list( 

1193 { 

1194 label: handle 

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

1196 }.values() 

1197 ) 

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

1199 

1200 # Save plot. 

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

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

1203 plt.close(fig) 

1204 

1205 

1206def _plot_and_save_scatter_plot( 

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

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

1209 filename: str, 

1210 title: str, 

1211 one_to_one: bool, 

1212 model_names: list[str] = None, 

1213 **kwargs, 

1214): 

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

1216 

1217 Parameters 

1218 ---------- 

1219 cube_x: Cube | CubeList 

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

1221 cube_y: Cube | CubeList 

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

1223 filename: str 

1224 Filename of the plot to write. 

1225 title: str 

1226 Plot title. 

1227 one_to_one: bool 

1228 Whether a 1:1 line is plotted. 

1229 """ 

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

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

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

1233 # over the pairs simultaneously. 

1234 

1235 # Ensure cube_x and cube_y are iterable 

1236 cube_x_iterable = iter_maybe(cube_x) 

1237 cube_y_iterable = iter_maybe(cube_y) 

1238 

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

1240 iplt.scatter(cube_x_iter, cube_y_iter) 

1241 if one_to_one is True: 

1242 plt.plot( 

1243 [ 

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

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

1246 ], 

1247 [ 

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

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

1250 ], 

1251 "k", 

1252 linestyle="--", 

1253 ) 

1254 ax = plt.gca() 

1255 

1256 # Add some labels and tweak the style. 

1257 if model_names is None: 

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

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

1260 else: 

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

1262 ax.set_xlabel( 

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

1264 ) 

1265 ax.set_ylabel( 

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

1267 ) 

1268 ax.set_title(title, fontsize=16) 

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

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

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

1272 ax.autoscale() 

1273 

1274 # Save plot. 

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

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

1277 plt.close(fig) 

1278 

1279 

1280def _plot_and_save_vector_plot( 

1281 cube_u: iris.cube.Cube, 

1282 cube_v: iris.cube.Cube, 

1283 filename: str, 

1284 title: str, 

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

1286 **kwargs, 

1287): 

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

1289 

1290 Parameters 

1291 ---------- 

1292 cube_u: Cube 

1293 2 dimensional Cube of u component of the data. 

1294 cube_v: Cube 

1295 2 dimensional Cube of v component of the data. 

1296 filename: str 

1297 Filename of the plot to write. 

1298 title: str 

1299 Plot title. 

1300 """ 

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

1302 

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

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

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

1306 

1307 # Specify the color bar 

1308 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag) 

1309 

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

1311 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

1312 

1313 if method == "contourf": 

1314 # Filled contour plot of the field. 

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

1316 elif method == "pcolormesh": 

1317 try: 

1318 vmin = min(levels) 

1319 vmax = max(levels) 

1320 except TypeError: 

1321 vmin, vmax = None, None 

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

1323 # if levels are defined. 

1324 if norm is not None: 

1325 vmin = None 

1326 vmax = None 

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

1328 else: 

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

1330 

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

1332 if is_transect(cube_vec_mag): 

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

1334 axes.invert_yaxis() 

1335 axes.set_yscale("log") 

1336 axes.set_ylim(1100, 100) 

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

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

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

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

1341 ): 

1342 axes.set_yscale("log") 

1343 

1344 axes.set_title( 

1345 f"{title}\n" 

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

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

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

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

1350 fontsize=16, 

1351 ) 

1352 

1353 else: 

1354 # Add title. 

1355 axes.set_title(title, fontsize=16) 

1356 

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

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

1359 axes.annotate( 

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

1361 xy=(0.05, -0.05), 

1362 xycoords="axes fraction", 

1363 xytext=(-5, 5), 

1364 textcoords="offset points", 

1365 ha="right", 

1366 va="bottom", 

1367 size=11, 

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

1369 ) 

1370 

1371 # Add colour bar. 

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

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

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

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

1376 cbar.set_ticks(levels) 

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

1378 

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

1380 # with less than 30 points. 

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

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

1383 

1384 # Save plot. 

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

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

1387 plt.close(fig) 

1388 

1389 

1390def _plot_and_save_histogram_series( 

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

1392 filename: str, 

1393 title: str, 

1394 vmin: float, 

1395 vmax: float, 

1396 **kwargs, 

1397): 

1398 """Plot and save a histogram series. 

1399 

1400 Parameters 

1401 ---------- 

1402 cubes: Cube or CubeList 

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

1404 filename: str 

1405 Filename of the plot to write. 

1406 title: str 

1407 Plot title. 

1408 vmin: float 

1409 minimum for colorbar 

1410 vmax: float 

1411 maximum for colorbar 

1412 """ 

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

1414 ax = plt.gca() 

1415 

1416 model_colors_map = _get_model_colors_map(cubes) 

1417 

1418 # Set default that histograms will produce probability density function 

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

1420 density = True 

1421 

1422 for cube in iter_maybe(cubes): 

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

1424 # than seeing if long names exist etc. 

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

1426 if "surface_microphysical" in title: 

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

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

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

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

1431 density = False 

1432 else: 

1433 bins = 10.0 ** ( 

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

1435 ) # Suggestion from RMED toolbox. 

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

1437 ax.set_yscale("log") 

1438 vmin = bins[1] 

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

1440 ax.set_xscale("log") 

1441 elif "lightning" in title: 

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

1443 else: 

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

1445 logging.debug( 

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

1447 np.size(bins), 

1448 np.min(bins), 

1449 np.max(bins), 

1450 ) 

1451 

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

1453 # Otherwise we plot xdim histograms stacked. 

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

1455 

1456 label = None 

1457 color = "black" 

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

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

1460 color = model_colors_map[label] 

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

1462 

1463 # Compute area under curve. 

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

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

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

1467 x = x[1:] 

1468 y = y[1:] 

1469 

1470 ax.plot( 

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

1472 ) 

1473 

1474 # Add some labels and tweak the style. 

1475 ax.set_title(title, fontsize=16) 

1476 ax.set_xlabel( 

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

1478 ) 

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

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

1481 ax.set_ylabel( 

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

1483 ) 

1484 ax.set_xlim(vmin, vmax) 

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

1486 

1487 # Overlay grid-lines onto histogram plot. 

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

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

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

1491 

1492 # Save plot. 

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

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

1495 plt.close(fig) 

1496 

1497 

1498def _plot_and_save_postage_stamp_histogram_series( 

1499 cube: iris.cube.Cube, 

1500 filename: str, 

1501 title: str, 

1502 stamp_coordinate: str, 

1503 vmin: float, 

1504 vmax: float, 

1505 **kwargs, 

1506): 

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

1508 

1509 Parameters 

1510 ---------- 

1511 cube: Cube 

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

1513 filename: str 

1514 Filename of the plot to write. 

1515 title: str 

1516 Plot title. 

1517 stamp_coordinate: str 

1518 Coordinate that becomes different plots. 

1519 vmin: float 

1520 minimum for pdf x-axis 

1521 vmax: float 

1522 maximum for pdf x-axis 

1523 """ 

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

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

1526 

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

1528 # Make a subplot for each member. 

1529 for member, subplot in zip( 

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

1531 ): 

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

1533 # cartopy GeoAxes generated. 

1534 plt.subplot(grid_size, grid_size, subplot) 

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

1536 # Otherwise we plot xdim histograms stacked. 

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

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

1539 axes = plt.gca() 

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

1541 axes.set_xlim(vmin, vmax) 

1542 

1543 # Overall figure title. 

1544 fig.suptitle(title, fontsize=16) 

1545 

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

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

1548 plt.close(fig) 

1549 

1550 

1551def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1552 cube: iris.cube.Cube, 

1553 filename: str, 

1554 title: str, 

1555 stamp_coordinate: str, 

1556 vmin: float, 

1557 vmax: float, 

1558 **kwargs, 

1559): 

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

1561 ax.set_title(title, fontsize=16) 

1562 ax.set_xlim(vmin, vmax) 

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

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

1565 # Loop over all slices along the stamp_coordinate 

1566 for member in cube.slices_over(stamp_coordinate): 

1567 # Flatten the member data to 1D 

1568 member_data_1d = member.data.flatten() 

1569 # Plot the histogram using plt.hist 

1570 plt.hist( 

1571 member_data_1d, 

1572 density=True, 

1573 stacked=True, 

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

1575 ) 

1576 

1577 # Add a legend 

1578 ax.legend(fontsize=16) 

1579 

1580 # Save the figure to a file 

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

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

1583 

1584 # Close the figure 

1585 plt.close(fig) 

1586 

1587 

1588def _plot_and_save_scattermap_plot( 

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

1590): 

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

1592 

1593 Parameters 

1594 ---------- 

1595 cube: Cube 

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

1597 longitude coordinates, 

1598 filename: str 

1599 Filename of the plot to write. 

1600 title: str 

1601 Plot title. 

1602 projection: str 

1603 Mapping projection to be used by cartopy. 

1604 """ 

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

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

1607 if projection is not None: 

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

1609 # a stereographic projection over the North Pole. 

1610 if projection == "NP_Stereo": 

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

1612 else: 

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

1614 else: 

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

1616 

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

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

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

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

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

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

1623 # proportion to the area of the figure. 

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

1625 klon = None 

1626 klat = None 

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

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

1629 klat = kc 

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

1631 klon = kc 

1632 scatter_map = iplt.scatter( 

1633 cube.aux_coords[klon], 

1634 cube.aux_coords[klat], 

1635 c=cube.data[:], 

1636 s=mrk_size, 

1637 cmap="jet", 

1638 edgecolors="k", 

1639 ) 

1640 

1641 # Add coastlines and borderlines. 

1642 try: 

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

1644 axes.add_feature(cfeature.BORDERS) 

1645 except AttributeError: 

1646 pass 

1647 

1648 # Add title. 

1649 axes.set_title(title, fontsize=16) 

1650 

1651 # Add colour bar. 

1652 cbar = fig.colorbar(scatter_map) 

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

1654 

1655 # Save plot. 

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

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

1658 plt.close(fig) 

1659 

1660 

1661def _plot_and_save_power_spectrum_series( 

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

1663 filename: str, 

1664 title: str, 

1665 **kwargs, 

1666): 

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

1668 

1669 Parameters 

1670 ---------- 

1671 cubes: Cube or CubeList 

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

1673 filename: str 

1674 Filename of the plot to write. 

1675 title: str 

1676 Plot title. 

1677 """ 

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

1679 ax = plt.gca() 

1680 

1681 model_colors_map = _get_model_colors_map(cubes) 

1682 

1683 for cube in iter_maybe(cubes): 

1684 # Calculate power spectrum 

1685 

1686 # Extract time coordinate and convert to datetime 

1687 time_coord = cube.coord("time") 

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

1689 

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

1691 target_time = time_points[0] 

1692 

1693 # Bind target_time inside the lambda using a default argument 

1694 time_constraint = iris.Constraint( 

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

1696 ) 

1697 

1698 cube = cube.extract(time_constraint) 

1699 

1700 if cube.ndim == 2: 

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

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

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

1704 cube_3d = cube.data 

1705 else: 

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

1707 raise ValueError( 

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

1709 ) 

1710 

1711 # Calculate spectra 

1712 ps_array = _DCT_ps(cube_3d) 

1713 

1714 ps_cube = iris.cube.Cube( 

1715 ps_array, 

1716 long_name="power_spectra", 

1717 ) 

1718 

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

1720 

1721 # Create a frequency/wavelength array for coordinate 

1722 ps_len = ps_cube.data.shape[1] 

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

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

1725 

1726 # Convert datetime to numeric time using original units 

1727 numeric_time = time_coord.units.date2num(time_points) 

1728 # Create a new DimCoord with numeric time 

1729 new_time_coord = iris.coords.DimCoord( 

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

1731 ) 

1732 

1733 # Add time and frequency coordinate to spectra cube. 

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

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

1736 

1737 # Extract data from the cube 

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

1739 power_spectrum = ps_cube.data 

1740 

1741 label = None 

1742 color = "black" 

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

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

1745 color = model_colors_map[label] 

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

1747 

1748 # Add some labels and tweak the style. 

1749 ax.set_title(title, fontsize=16) 

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

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

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

1753 

1754 # Set log-log scale 

1755 ax.set_xscale("log") 

1756 ax.set_yscale("log") 

1757 

1758 # Overlay grid-lines onto power spectrum plot. 

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

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

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

1762 

1763 # Save plot. 

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

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

1766 plt.close(fig) 

1767 

1768 

1769def _plot_and_save_postage_stamp_power_spectrum_series( 

1770 cube: iris.cube.Cube, 

1771 filename: str, 

1772 title: str, 

1773 stamp_coordinate: str, 

1774 **kwargs, 

1775): 

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

1777 

1778 Parameters 

1779 ---------- 

1780 cube: Cube 

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

1782 filename: str 

1783 Filename of the plot to write. 

1784 title: str 

1785 Plot title. 

1786 stamp_coordinate: str 

1787 Coordinate that becomes different plots. 

1788 """ 

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

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

1791 

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

1793 # Make a subplot for each member. 

1794 for member, subplot in zip( 

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

1796 ): 

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

1798 # cartopy GeoAxes generated. 

1799 plt.subplot(grid_size, grid_size, subplot) 

1800 

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

1802 

1803 axes = plt.gca() 

1804 axes.plot(frequency, member.data) 

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

1806 

1807 # Overall figure title. 

1808 fig.suptitle(title, fontsize=16) 

1809 

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

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

1812 plt.close(fig) 

1813 

1814 

1815def _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series( 

1816 cube: iris.cube.Cube, 

1817 filename: str, 

1818 title: str, 

1819 stamp_coordinate: str, 

1820 **kwargs, 

1821): 

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

1823 ax.set_title(title, fontsize=16) 

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

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

1826 # Loop over all slices along the stamp_coordinate 

1827 for member in cube.slices_over(stamp_coordinate): 

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

1829 ax.plot( 

1830 frequency, 

1831 member.data, 

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

1833 ) 

1834 

1835 # Add a legend 

1836 ax.legend(fontsize=16) 

1837 

1838 # Save the figure to a file 

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

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

1841 

1842 # Close the figure 

1843 plt.close(fig) 

1844 

1845 

1846def _spatial_plot( 

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

1848 cube: iris.cube.Cube, 

1849 filename: str | None, 

1850 sequence_coordinate: str, 

1851 stamp_coordinate: str, 

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

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

1854 **kwargs, 

1855): 

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

1857 

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

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

1860 is present then postage stamp plots will be produced. 

1861 

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

1863 be overplotted on the same figure. 

1864 

1865 Parameters 

1866 ---------- 

1867 method: "contourf" | "pcolormesh" 

1868 The plotting method to use. 

1869 cube: Cube 

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

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

1872 plotted sequentially and/or as postage stamp plots. 

1873 filename: str | None 

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

1875 uses the recipe name. 

1876 sequence_coordinate: str 

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

1878 This coordinate must exist in the cube. 

1879 stamp_coordinate: str 

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

1881 ``"realization"``. 

1882 overlay_cube: Cube | None, optional 

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

1884 contour_cube: Cube | None, optional 

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

1886 

1887 Raises 

1888 ------ 

1889 ValueError 

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

1891 TypeError 

1892 If the cube isn't a single cube. 

1893 """ 

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

1895 

1896 # Ensure we've got a single cube. 

1897 cube = _check_single_cube(cube) 

1898 

1899 # Test if valid stamp coordinate in cube dimensions. 

1900 stamp_coordinate = check_stamp_coordinate(cube) 

1901 

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

1903 # single point. 

1904 plotting_func = _plot_and_save_spatial_plot 

1905 try: 

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

1907 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1908 except iris.exceptions.CoordinateNotFoundError: 

1909 pass 

1910 

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

1912 # dimension called observation or model_obs_error 

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

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

1915 for crd in cube.coords() 

1916 ): 

1917 plotting_func = _plot_and_save_scattermap_plot 

1918 

1919 # Must have a sequence coordinate. 

1920 try: 

1921 cube.coord(sequence_coordinate) 

1922 except iris.exceptions.CoordinateNotFoundError as err: 

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

1924 

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

1926 plot_index = [] 

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

1928 

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

1930 # Set plot titles and filename 

1931 seq_coord = cube_slice.coord(sequence_coordinate) 

1932 plot_title, plot_filename = _set_title_and_filename( 

1933 seq_coord, nplot, recipe_title, filename 

1934 ) 

1935 

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

1937 overlay_slice = slice_over_maybe(overlay_cube, sequence_coordinate, iseq) 

1938 contour_slice = slice_over_maybe(contour_cube, sequence_coordinate, iseq) 

1939 

1940 # Do the actual plotting. 

1941 plotting_func( 

1942 cube_slice, 

1943 filename=plot_filename, 

1944 stamp_coordinate=stamp_coordinate, 

1945 title=plot_title, 

1946 method=method, 

1947 overlay_cube=overlay_slice, 

1948 contour_cube=contour_slice, 

1949 **kwargs, 

1950 ) 

1951 plot_index.append(plot_filename) 

1952 

1953 # Add list of plots to plot metadata. 

1954 complete_plot_index = _append_to_plot_index(plot_index) 

1955 

1956 # Make a page to display the plots. 

1957 _make_plot_html_page(complete_plot_index) 

1958 

1959 

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

1961 """Get colourmap for mask. 

1962 

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

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

1965 

1966 Parameters 

1967 ---------- 

1968 cube: Cube 

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

1970 axis: "x", "y", optional 

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

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

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

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

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

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

1977 

1978 Returns 

1979 ------- 

1980 cmap: 

1981 Matplotlib colormap. 

1982 levels: 

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

1984 should be taken as the range. 

1985 norm: 

1986 BoundaryNorm information. 

1987 """ 

1988 if "difference" not in cube.long_name: 

1989 if axis: 

1990 levels = [0, 1] 

1991 # Complete settings based on levels. 

1992 return None, levels, None 

1993 else: 

1994 # Define the levels and colors. 

1995 levels = [0, 1, 2] 

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

1997 # Create a custom color map. 

1998 cmap = mcolors.ListedColormap(colors) 

1999 # Normalize the levels. 

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

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

2002 return cmap, levels, norm 

2003 else: 

2004 if axis: 

2005 levels = [-1, 1] 

2006 return None, levels, None 

2007 else: 

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

2009 # not <=. 

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

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

2012 cmap = mcolors.ListedColormap(colors) 

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

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

2015 return cmap, levels, norm 

2016 

2017 

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

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

2020 

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

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

2023 

2024 Parameters 

2025 ---------- 

2026 cube: Cube 

2027 Cube of variable with Beaufort Scale in name. 

2028 axis: "x", "y", optional 

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

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

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

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

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

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

2035 

2036 Returns 

2037 ------- 

2038 cmap: 

2039 Matplotlib colormap. 

2040 levels: 

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

2042 should be taken as the range. 

2043 norm: 

2044 BoundaryNorm information. 

2045 """ 

2046 if "difference" not in cube.long_name: 

2047 if axis: 

2048 levels = [0, 12] 

2049 return None, levels, None 

2050 else: 

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

2052 colors = [ 

2053 "black", 

2054 (0, 0, 0.6), 

2055 "blue", 

2056 "cyan", 

2057 "green", 

2058 "yellow", 

2059 (1, 0.5, 0), 

2060 "red", 

2061 "pink", 

2062 "magenta", 

2063 "purple", 

2064 "maroon", 

2065 "white", 

2066 ] 

2067 cmap = mcolors.ListedColormap(colors) 

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

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

2070 return cmap, levels, norm 

2071 else: 

2072 if axis: 

2073 levels = [-4, 4] 

2074 return None, levels, None 

2075 else: 

2076 levels = [ 

2077 -3.5, 

2078 -2.5, 

2079 -1.5, 

2080 -0.5, 

2081 0.5, 

2082 1.5, 

2083 2.5, 

2084 3.5, 

2085 ] 

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

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

2088 return cmap, levels, norm 

2089 

2090 

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

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

2093 

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

2095 

2096 Parameters 

2097 ---------- 

2098 cube: Cube 

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

2100 cmap: Matplotlib colormap. 

2101 levels: List 

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

2103 should be taken as the range. 

2104 norm: BoundaryNorm. 

2105 

2106 Returns 

2107 ------- 

2108 cmap: Matplotlib colormap. 

2109 levels: List 

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

2111 should be taken as the range. 

2112 norm: BoundaryNorm. 

2113 """ 

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

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

2116 levels = np.array(levels) 

2117 levels -= 273 

2118 levels = levels.tolist() 

2119 else: 

2120 # Do nothing keep the existing colourbar attributes 

2121 levels = levels 

2122 cmap = cmap 

2123 norm = norm 

2124 return cmap, levels, norm 

2125 

2126 

2127def _custom_colormap_probability( 

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

2129): 

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

2131 

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

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

2134 

2135 Parameters 

2136 ---------- 

2137 cube: Cube 

2138 Cube of variable with probability in name. 

2139 axis: "x", "y", optional 

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

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

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

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

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

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

2146 

2147 Returns 

2148 ------- 

2149 cmap: 

2150 Matplotlib colormap. 

2151 levels: 

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

2153 should be taken as the range. 

2154 norm: 

2155 BoundaryNorm information. 

2156 """ 

2157 if axis: 

2158 levels = [0, 1] 

2159 return None, levels, None 

2160 else: 

2161 cmap = mcolors.ListedColormap( 

2162 [ 

2163 "#FFFFFF", 

2164 "#636363", 

2165 "#e1dada", 

2166 "#B5CAFF", 

2167 "#8FB3FF", 

2168 "#7F97FF", 

2169 "#ABCF63", 

2170 "#E8F59E", 

2171 "#FFFA14", 

2172 "#FFD121", 

2173 "#FFA30A", 

2174 ] 

2175 ) 

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

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

2178 return cmap, levels, norm 

2179 

2180 

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

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

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

2184 if ( 

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

2186 and "difference" not in cube.long_name 

2187 and "mask" not in cube.long_name 

2188 ): 

2189 # Define the levels and colors 

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

2191 colors = [ 

2192 "w", 

2193 (0, 0, 0.6), 

2194 "b", 

2195 "c", 

2196 "g", 

2197 "y", 

2198 (1, 0.5, 0), 

2199 "r", 

2200 "pink", 

2201 "m", 

2202 "purple", 

2203 "maroon", 

2204 "gray", 

2205 ] 

2206 # Create a custom colormap 

2207 cmap = mcolors.ListedColormap(colors) 

2208 # Normalize the levels 

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

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

2211 else: 

2212 # do nothing and keep existing colorbar attributes 

2213 cmap = cmap 

2214 levels = levels 

2215 norm = norm 

2216 return cmap, levels, norm 

2217 

2218 

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

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

2221 

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

2223 this function will be called. 

2224 

2225 Parameters 

2226 ---------- 

2227 cube: Cube 

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

2229 

2230 Returns 

2231 ------- 

2232 cmap: Matplotlib colormap. 

2233 levels: List 

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

2235 should be taken as the range. 

2236 norm: BoundaryNorm. 

2237 """ 

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

2239 colors = [ 

2240 "#87ceeb", 

2241 "#ffffff", 

2242 "#8ced69", 

2243 "#ffff00", 

2244 "#ffd700", 

2245 "#ffa500", 

2246 "#fe3620", 

2247 ] 

2248 # Create a custom colormap 

2249 cmap = mcolors.ListedColormap(colors) 

2250 # Normalise the levels 

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

2252 return cmap, levels, norm 

2253 

2254 

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

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

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

2258 if ( 

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

2260 and "difference" not in cube.long_name 

2261 and "mask" not in cube.long_name 

2262 ): 

2263 # Define the levels and colors (in km) 

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

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

2266 colours = [ 

2267 "#8f00d6", 

2268 "#d10000", 

2269 "#ff9700", 

2270 "#ffff00", 

2271 "#00007f", 

2272 "#6c9ccd", 

2273 "#aae8ff", 

2274 "#37a648", 

2275 "#8edc64", 

2276 "#c5ffc5", 

2277 "#dcdcdc", 

2278 "#ffffff", 

2279 ] 

2280 # Create a custom colormap 

2281 cmap = mcolors.ListedColormap(colours) 

2282 # Normalize the levels 

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

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

2285 else: 

2286 # do nothing and keep existing colorbar attributes 

2287 cmap = cmap 

2288 levels = levels 

2289 norm = norm 

2290 return cmap, levels, norm 

2291 

2292 

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

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

2295 model_names = list( 

2296 filter( 

2297 lambda x: x is not None, 

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

2299 ) 

2300 ) 

2301 if not model_names: 

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

2303 return 1 

2304 else: 

2305 return len(model_names) 

2306 

2307 

2308def _validate_cube_shape( 

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

2310) -> None: 

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

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

2313 raise ValueError( 

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

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

2316 ) 

2317 

2318 

2319def _validate_cubes_coords( 

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

2321) -> None: 

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

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

2324 raise ValueError( 

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

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

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

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

2329 ) 

2330 

2331 

2332#################### 

2333# Public functions # 

2334#################### 

2335 

2336 

2337def spatial_contour_plot( 

2338 cube: iris.cube.Cube, 

2339 filename: str = None, 

2340 sequence_coordinate: str = "time", 

2341 stamp_coordinate: str = "realization", 

2342 **kwargs, 

2343) -> iris.cube.Cube: 

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

2345 

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

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

2348 is present then postage stamp plots will be produced. 

2349 

2350 Parameters 

2351 ---------- 

2352 cube: Cube 

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

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

2355 plotted sequentially and/or as postage stamp plots. 

2356 filename: str, optional 

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

2358 to the recipe name. 

2359 sequence_coordinate: str, optional 

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

2361 This coordinate must exist in the cube. 

2362 stamp_coordinate: str, optional 

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

2364 ``"realization"``. 

2365 

2366 Returns 

2367 ------- 

2368 Cube 

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

2370 

2371 Raises 

2372 ------ 

2373 ValueError 

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

2375 TypeError 

2376 If the cube isn't a single cube. 

2377 """ 

2378 _spatial_plot( 

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

2380 ) 

2381 return cube 

2382 

2383 

2384def spatial_pcolormesh_plot( 

2385 cube: iris.cube.Cube, 

2386 filename: str = None, 

2387 sequence_coordinate: str = "time", 

2388 stamp_coordinate: str = "realization", 

2389 **kwargs, 

2390) -> iris.cube.Cube: 

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

2392 

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

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

2395 is present then postage stamp plots will be produced. 

2396 

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

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

2399 contour areas are important. 

2400 

2401 Parameters 

2402 ---------- 

2403 cube: Cube 

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

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

2406 plotted sequentially and/or as postage stamp plots. 

2407 filename: str, optional 

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

2409 to the recipe name. 

2410 sequence_coordinate: str, optional 

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

2412 This coordinate must exist in the cube. 

2413 stamp_coordinate: str, optional 

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

2415 ``"realization"``. 

2416 

2417 Returns 

2418 ------- 

2419 Cube 

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

2421 

2422 Raises 

2423 ------ 

2424 ValueError 

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

2426 TypeError 

2427 If the cube isn't a single cube. 

2428 """ 

2429 _spatial_plot( 

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

2431 ) 

2432 return cube 

2433 

2434 

2435def spatial_multi_pcolormesh_plot( 

2436 cube: iris.cube.Cube, 

2437 overlay_cube: iris.cube.Cube, 

2438 contour_cube: iris.cube.Cube, 

2439 filename: str = None, 

2440 sequence_coordinate: str = "time", 

2441 stamp_coordinate: str = "realization", 

2442 **kwargs, 

2443) -> iris.cube.Cube: 

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

2445 

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

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

2448 is present then postage stamp plots will be produced. 

2449 

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

2451 

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

2453 

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

2455 

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

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

2458 contour areas are important. 

2459 

2460 Parameters 

2461 ---------- 

2462 cube: Cube 

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

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

2465 plotted sequentially and/or as postage stamp plots. 

2466 overlay_cube: Cube 

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

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

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

2470 contour_cube: Cube 

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

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

2473 plotted sequentially and/or as postage stamp plots. 

2474 filename: str, optional 

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

2476 to the recipe name. 

2477 sequence_coordinate: str, optional 

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

2479 This coordinate must exist in the cube. 

2480 stamp_coordinate: str, optional 

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

2482 ``"realization"``. 

2483 

2484 Returns 

2485 ------- 

2486 Cube 

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

2488 

2489 Raises 

2490 ------ 

2491 ValueError 

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

2493 TypeError 

2494 If the cube isn't a single cube. 

2495 """ 

2496 _spatial_plot( 

2497 "pcolormesh", 

2498 cube, 

2499 filename, 

2500 sequence_coordinate, 

2501 stamp_coordinate, 

2502 overlay_cube=overlay_cube, 

2503 contour_cube=contour_cube, 

2504 ) 

2505 return cube, overlay_cube, contour_cube 

2506 

2507 

2508# TODO: Expand function to handle ensemble data. 

2509# line_coordinate: str, optional 

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

2511# ``"realization"``. 

2512def plot_line_series( 

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

2514 filename: str = None, 

2515 series_coordinate: str = "time", 

2516 # line_coordinate: str = "realization", 

2517 **kwargs, 

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

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

2520 

2521 The Cube or CubeList must be 1D. 

2522 

2523 Parameters 

2524 ---------- 

2525 iris.cube | iris.cube.CubeList 

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

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

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

2529 filename: str, optional 

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

2531 to the recipe name. 

2532 series_coordinate: str, optional 

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

2534 coordinate must exist in the cube. 

2535 

2536 Returns 

2537 ------- 

2538 iris.cube.Cube | iris.cube.CubeList 

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

2540 plotted data. 

2541 

2542 Raises 

2543 ------ 

2544 ValueError 

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

2546 TypeError 

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

2548 """ 

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

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

2551 

2552 num_models = _get_num_models(cube) 

2553 

2554 _validate_cube_shape(cube, num_models) 

2555 

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

2557 cubes = iter_maybe(cube) 

2558 coords = [] 

2559 for cube in cubes: 

2560 try: 

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

2562 except iris.exceptions.CoordinateNotFoundError as err: 

2563 raise ValueError( 

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

2565 ) from err 

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

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

2568 

2569 # Format the title and filename using plotted series coordinate 

2570 nplot = 1 

2571 seq_coord = coords[0] 

2572 plot_title, plot_filename = _set_title_and_filename( 

2573 seq_coord, nplot, recipe_title, filename 

2574 ) 

2575 

2576 # Do the actual plotting. 

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

2578 

2579 # Add list of plots to plot metadata. 

2580 plot_index = _append_to_plot_index([plot_filename]) 

2581 

2582 # Make a page to display the plots. 

2583 _make_plot_html_page(plot_index) 

2584 

2585 return cube 

2586 

2587 

2588def plot_vertical_line_series( 

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

2590 filename: str = None, 

2591 series_coordinate: str = "model_level_number", 

2592 sequence_coordinate: str = "time", 

2593 # line_coordinate: str = "realization", 

2594 **kwargs, 

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

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

2597 

2598 The Cube or CubeList must be 1D. 

2599 

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

2601 then a sequence of plots will be produced. 

2602 

2603 Parameters 

2604 ---------- 

2605 iris.cube | iris.cube.CubeList 

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

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

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

2609 filename: str, optional 

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

2611 to the recipe name. 

2612 series_coordinate: str, optional 

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

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

2615 for LFRic. Defaults to ``model_level_number``. 

2616 This coordinate must exist in the cube. 

2617 sequence_coordinate: str, optional 

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

2619 This coordinate must exist in the cube. 

2620 

2621 Returns 

2622 ------- 

2623 iris.cube.Cube | iris.cube.CubeList 

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

2625 Plotted data. 

2626 

2627 Raises 

2628 ------ 

2629 ValueError 

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

2631 TypeError 

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

2633 """ 

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

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

2636 

2637 cubes = iter_maybe(cubes) 

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

2639 all_data = [] 

2640 

2641 # Store min/max ranges for x range. 

2642 x_levels = [] 

2643 

2644 num_models = _get_num_models(cubes) 

2645 

2646 _validate_cube_shape(cubes, num_models) 

2647 

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

2649 coords = [] 

2650 for cube in cubes: 

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

2652 try: 

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

2654 except iris.exceptions.CoordinateNotFoundError as err: 

2655 raise ValueError( 

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

2657 ) from err 

2658 

2659 try: 

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

2661 cube.coord(sequence_coordinate) 

2662 except iris.exceptions.CoordinateNotFoundError as err: 

2663 raise ValueError( 

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

2665 ) from err 

2666 

2667 # Get minimum and maximum from levels information. 

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

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

2670 x_levels.append(min(levels)) 

2671 x_levels.append(max(levels)) 

2672 else: 

2673 all_data.append(cube.data) 

2674 

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

2676 # Combine all data into a single NumPy array 

2677 combined_data = np.concatenate(all_data) 

2678 

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

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

2681 # sequence and if applicable postage stamp coordinate. 

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

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

2684 else: 

2685 vmin = min(x_levels) 

2686 vmax = max(x_levels) 

2687 

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

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

2690 # necessary) 

2691 def filter_cube_iterables(cube_iterables) -> bool: 

2692 return len(cube_iterables) == len(coords) 

2693 

2694 cube_iterables = filter( 

2695 filter_cube_iterables, 

2696 ( 

2697 iris.cube.CubeList( 

2698 s 

2699 for s in itertools.chain.from_iterable( 

2700 cb.slices_over(sequence_coordinate) for cb in cubes 

2701 ) 

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

2703 ) 

2704 for point in sorted( 

2705 set( 

2706 itertools.chain.from_iterable( 

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

2708 ) 

2709 ) 

2710 ) 

2711 ), 

2712 ) 

2713 

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

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

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

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

2718 # or an iris.cube.CubeList. 

2719 plot_index = [] 

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

2721 for cubes_slice in cube_iterables: 

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

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

2724 plot_title, plot_filename = _set_title_and_filename( 

2725 seq_coord, nplot, recipe_title, filename 

2726 ) 

2727 

2728 # Do the actual plotting. 

2729 _plot_and_save_vertical_line_series( 

2730 cubes_slice, 

2731 coords, 

2732 "realization", 

2733 plot_filename, 

2734 series_coordinate, 

2735 title=plot_title, 

2736 vmin=vmin, 

2737 vmax=vmax, 

2738 ) 

2739 plot_index.append(plot_filename) 

2740 

2741 # Add list of plots to plot metadata. 

2742 complete_plot_index = _append_to_plot_index(plot_index) 

2743 

2744 # Make a page to display the plots. 

2745 _make_plot_html_page(complete_plot_index) 

2746 

2747 return cubes 

2748 

2749 

2750def qq_plot( 

2751 cubes: iris.cube.CubeList, 

2752 coordinates: list[str], 

2753 percentiles: list[float], 

2754 model_names: list[str], 

2755 filename: str = None, 

2756 one_to_one: bool = True, 

2757 **kwargs, 

2758) -> iris.cube.CubeList: 

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

2760 

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

2762 collapsed within the operator over all specified coordinates such as 

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

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

2765 

2766 Parameters 

2767 ---------- 

2768 cubes: iris.cube.CubeList 

2769 Two cubes of the same variable with different models. 

2770 coordinate: list[str] 

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

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

2773 the percentile coordinate. 

2774 percent: list[float] 

2775 A list of percentiles to appear in the plot. 

2776 model_names: list[str] 

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

2778 filename: str, optional 

2779 Filename of the plot to write. 

2780 one_to_one: bool, optional 

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

2782 

2783 Raises 

2784 ------ 

2785 ValueError 

2786 When the cubes are not compatible. 

2787 

2788 Notes 

2789 ----- 

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

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

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

2793 compares percentiles of two datasets. This plot does 

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

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

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

2797 

2798 Quantile-quantile plots are valuable for comparing against 

2799 observations and other models. Identical percentiles between the variables 

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

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

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

2803 Wilks 2011 [Wilks2011]_). 

2804 

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

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

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

2808 the extremes. 

2809 

2810 References 

2811 ---------- 

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

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

2814 """ 

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

2816 if len(cubes) != 2: 

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

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

2819 other: Cube = cubes.extract_cube( 

2820 iris.Constraint( 

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

2822 ) 

2823 ) 

2824 

2825 # Get spatial coord names. 

2826 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

2827 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

2828 

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

2830 # This is triggered if either 

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

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

2833 # errors. 

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

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

2836 # for UM and LFRic comparisons. 

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

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

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

2840 # given this dependency on regridding. 

2841 if ( 

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

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

2844 ) or ( 

2845 base.long_name 

2846 in [ 

2847 "eastward_wind_at_10m", 

2848 "northward_wind_at_10m", 

2849 "northward_wind_at_cell_centres", 

2850 "eastward_wind_at_cell_centres", 

2851 "zonal_wind_at_pressure_levels", 

2852 "meridional_wind_at_pressure_levels", 

2853 "potential_vorticity_at_pressure_levels", 

2854 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

2855 ] 

2856 ): 

2857 logging.debug( 

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

2859 ) 

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

2861 

2862 # Extract just common time points. 

2863 base, other = _extract_common_time_points(base, other) 

2864 

2865 # Equalise attributes so we can merge. 

2866 fully_equalise_attributes([base, other]) 

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

2868 

2869 # Collapse cubes. 

2870 base = collapse( 

2871 base, 

2872 coordinate=coordinates, 

2873 method="PERCENTILE", 

2874 additional_percent=percentiles, 

2875 ) 

2876 other = collapse( 

2877 other, 

2878 coordinate=coordinates, 

2879 method="PERCENTILE", 

2880 additional_percent=percentiles, 

2881 ) 

2882 

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

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

2885 title = f"{recipe_title}" 

2886 

2887 if filename is None: 

2888 filename = slugify(recipe_title) 

2889 

2890 # Add file extension. 

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

2892 

2893 # Do the actual plotting on a scatter plot 

2894 _plot_and_save_scatter_plot( 

2895 base, other, plot_filename, title, one_to_one, model_names 

2896 ) 

2897 

2898 # Add list of plots to plot metadata. 

2899 plot_index = _append_to_plot_index([plot_filename]) 

2900 

2901 # Make a page to display the plots. 

2902 _make_plot_html_page(plot_index) 

2903 

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

2905 

2906 

2907def scatter_plot( 

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

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

2910 filename: str = None, 

2911 one_to_one: bool = True, 

2912 **kwargs, 

2913) -> iris.cube.CubeList: 

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

2915 

2916 Both cubes must be 1D. 

2917 

2918 Parameters 

2919 ---------- 

2920 cube_x: Cube | CubeList 

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

2922 cube_y: Cube | CubeList 

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

2924 filename: str, optional 

2925 Filename of the plot to write. 

2926 one_to_one: bool, optional 

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

2928 

2929 Returns 

2930 ------- 

2931 cubes: CubeList 

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

2933 

2934 Raises 

2935 ------ 

2936 ValueError 

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

2938 size. 

2939 TypeError 

2940 If the cube isn't a single cube. 

2941 

2942 Notes 

2943 ----- 

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

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

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

2947 """ 

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

2949 for cube_iter in iter_maybe(cube_x): 

2950 # Check cubes are correct shape. 

2951 cube_iter = _check_single_cube(cube_iter) 

2952 if cube_iter.ndim > 1: 

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

2954 

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

2956 for cube_iter in iter_maybe(cube_y): 

2957 # Check cubes are correct shape. 

2958 cube_iter = _check_single_cube(cube_iter) 

2959 if cube_iter.ndim > 1: 

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

2961 

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

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

2964 title = f"{recipe_title}" 

2965 

2966 if filename is None: 

2967 filename = slugify(recipe_title) 

2968 

2969 # Add file extension. 

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

2971 

2972 # Do the actual plotting. 

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

2974 

2975 # Add list of plots to plot metadata. 

2976 plot_index = _append_to_plot_index([plot_filename]) 

2977 

2978 # Make a page to display the plots. 

2979 _make_plot_html_page(plot_index) 

2980 

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

2982 

2983 

2984def vector_plot( 

2985 cube_u: iris.cube.Cube, 

2986 cube_v: iris.cube.Cube, 

2987 filename: str = None, 

2988 sequence_coordinate: str = "time", 

2989 **kwargs, 

2990) -> iris.cube.CubeList: 

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

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

2993 

2994 # Cubes must have a matching sequence coordinate. 

2995 try: 

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

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

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

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

3000 raise ValueError( 

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

3002 ) from err 

3003 

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

3005 plot_index = [] 

3006 nplot = np.size(cube_u[0].coord(sequence_coordinate).points) 

3007 for cube_u_slice, cube_v_slice in zip( 

3008 cube_u.slices_over(sequence_coordinate), 

3009 cube_v.slices_over(sequence_coordinate), 

3010 strict=True, 

3011 ): 

3012 # Format the coordinate value in a unit appropriate way. 

3013 seq_coord = cube_u_slice.coord(sequence_coordinate) 

3014 plot_title, plot_filename = _set_title_and_filename( 

3015 seq_coord, nplot, recipe_title, filename 

3016 ) 

3017 

3018 # Do the actual plotting. 

3019 _plot_and_save_vector_plot( 

3020 cube_u_slice, 

3021 cube_v_slice, 

3022 filename=plot_filename, 

3023 title=plot_title, 

3024 method="contourf", 

3025 ) 

3026 plot_index.append(plot_filename) 

3027 

3028 # Add list of plots to plot metadata. 

3029 complete_plot_index = _append_to_plot_index(plot_index) 

3030 

3031 # Make a page to display the plots. 

3032 _make_plot_html_page(complete_plot_index) 

3033 

3034 return iris.cube.CubeList([cube_u, cube_v]) 

3035 

3036 

3037def plot_histogram_series( 

3038 cubes: iris.cube.Cube | iris.cube.CubeList, 

3039 filename: str = None, 

3040 sequence_coordinate: str = "time", 

3041 stamp_coordinate: str = "realization", 

3042 single_plot: bool = False, 

3043 **kwargs, 

3044) -> iris.cube.Cube | iris.cube.CubeList: 

3045 """Plot a histogram plot for each vertical level provided. 

3046 

3047 A histogram plot can be plotted, but if the sequence_coordinate (i.e. time) 

3048 is present then a sequence of plots will be produced using the time slider 

3049 functionality to scroll through histograms against time. If a 

3050 stamp_coordinate is present then postage stamp plots will be produced. If 

3051 stamp_coordinate and single_plot is True, all postage stamp plots will be 

3052 plotted in a single plot instead of separate postage stamp plots. 

3053 

3054 Parameters 

3055 ---------- 

3056 cubes: Cube | iris.cube.CubeList 

3057 Iris cube or CubeList of the data to plot. It should have a single dimension other 

3058 than the stamp coordinate. 

3059 The cubes should cover the same phenomenon i.e. all cubes contain temperature data. 

3060 We do not support different data such as temperature and humidity in the same CubeList for plotting. 

3061 filename: str, optional 

3062 Name of the plot to write, used as a prefix for plot sequences. Defaults 

3063 to the recipe name. 

3064 sequence_coordinate: str, optional 

3065 Coordinate about which to make a plot sequence. Defaults to ``"time"``. 

3066 This coordinate must exist in the cube and will be used for the time 

3067 slider. 

3068 stamp_coordinate: str, optional 

3069 Coordinate about which to plot postage stamp plots. Defaults to 

3070 ``"realization"``. 

3071 single_plot: bool, optional 

3072 If True, all postage stamp plots will be plotted in a single plot. If 

3073 False, each postage stamp plot will be plotted separately. Is only valid 

3074 if stamp_coordinate exists and has more than a single point. 

3075 

3076 Returns 

3077 ------- 

3078 iris.cube.Cube | iris.cube.CubeList 

3079 The original Cube or CubeList (so further operations can be applied). 

3080 Plotted data. 

3081 

3082 Raises 

3083 ------ 

3084 ValueError 

3085 If the cube doesn't have the right dimensions. 

3086 TypeError 

3087 If the cube isn't a Cube or CubeList. 

3088 """ 

3089 recipe_title = get_recipe_metadata().get("title", "Untitled") 

3090 

3091 cubes = iter_maybe(cubes) 

3092 

3093 # Internal plotting function. 

3094 plotting_func = _plot_and_save_histogram_series 

3095 

3096 num_models = _get_num_models(cubes) 

3097 

3098 _validate_cube_shape(cubes, num_models) 

3099 

3100 # If several histograms are plotted with time as sequence_coordinate for the 

3101 # time slider option. 

3102 for cube in cubes: 

3103 try: 

3104 cube.coord(sequence_coordinate) 

3105 except iris.exceptions.CoordinateNotFoundError as err: 

3106 raise ValueError( 

3107 f"Cube must have a {sequence_coordinate} coordinate." 

3108 ) from err 

3109 

3110 # Get minimum and maximum from levels information. 

3111 levels = None 

3112 for cube in cubes: 3112 ↛ 3128line 3112 didn't jump to line 3128 because the loop on line 3112 didn't complete

3113 # First check if user-specified "auto" range variable. 

3114 # This maintains the value of levels as None, so proceed. 

3115 _, levels, _ = _colorbar_map_levels(cube, axis="y") 

3116 if levels is None: 

3117 break 

3118 # If levels is changed, recheck to use the vmin,vmax or 

3119 # levels-based ranges for histogram plots. 

3120 _, levels, _ = _colorbar_map_levels(cube) 

3121 logging.debug("levels: %s", levels) 

3122 if levels is not None: 3122 ↛ 3112line 3122 didn't jump to line 3112 because the condition on line 3122 was always true

3123 vmin = min(levels) 

3124 vmax = max(levels) 

3125 logging.debug("Updated vmin, vmax: %s, %s", vmin, vmax) 

3126 break 

3127 

3128 if levels is None: 

3129 vmin = min(cb.data.min() for cb in cubes) 

3130 vmax = max(cb.data.max() for cb in cubes) 

3131 

3132 # Make postage stamp plots if stamp_coordinate exists and has more than a 

3133 # single point. If single_plot is True: 

3134 # -- all postage stamp plots will be plotted in a single plot instead of 

3135 # separate postage stamp plots. 

3136 # -- model names (hidden in cube attrs) are ignored, that is stamp plots are 

3137 # produced per single model only 

3138 if num_models == 1: 3138 ↛ 3151line 3138 didn't jump to line 3151 because the condition on line 3138 was always true

3139 if ( 3139 ↛ 3143line 3139 didn't jump to line 3143 because the condition on line 3139 was never true

3140 stamp_coordinate in [c.name() for c in cubes[0].coords()] 

3141 and cubes[0].coord(stamp_coordinate).shape[0] > 1 

3142 ): 

3143 if single_plot: 

3144 plotting_func = ( 

3145 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

3146 ) 

3147 else: 

3148 plotting_func = _plot_and_save_postage_stamp_histogram_series 

3149 cube_iterables = cubes[0].slices_over(sequence_coordinate) 

3150 else: 

3151 all_points = sorted( 

3152 set( 

3153 itertools.chain.from_iterable( 

3154 cb.coord(sequence_coordinate).points for cb in cubes 

3155 ) 

3156 ) 

3157 ) 

3158 all_slices = list( 

3159 itertools.chain.from_iterable( 

3160 cb.slices_over(sequence_coordinate) for cb in cubes 

3161 ) 

3162 ) 

3163 # Matched slices (matched by seq coord point; it may happen that 

3164 # evaluated models do not cover the same seq coord range, hence matching 

3165 # necessary) 

3166 cube_iterables = [ 

3167 iris.cube.CubeList( 

3168 s for s in all_slices if s.coord(sequence_coordinate).points[0] == point 

3169 ) 

3170 for point in all_points 

3171 ] 

3172 

3173 plot_index = [] 

3174 nplot = np.size(cube.coord(sequence_coordinate).points) 

3175 # Create a plot for each value of the sequence coordinate. Allowing for 

3176 # multiple cubes in a CubeList to be plotted in the same plot for similar 

3177 # sequence values. Passing a CubeList into the internal plotting function 

3178 # for similar values of the sequence coordinate. cube_slice can be an 

3179 # iris.cube.Cube or an iris.cube.CubeList. 

3180 for cube_slice in cube_iterables: 

3181 single_cube = cube_slice 

3182 if isinstance(cube_slice, iris.cube.CubeList): 3182 ↛ 3183line 3182 didn't jump to line 3183 because the condition on line 3182 was never true

3183 single_cube = cube_slice[0] 

3184 

3185 # Set plot titles and filename, based on sequence coordinate 

3186 seq_coord = single_cube.coord(sequence_coordinate) 

3187 # Use time coordinate in title and filename if single histogram output. 

3188 if sequence_coordinate == "realization" and nplot == 1: 3188 ↛ 3189line 3188 didn't jump to line 3189 because the condition on line 3188 was never true

3189 seq_coord = single_cube.coord("time") 

3190 plot_title, plot_filename = _set_title_and_filename( 

3191 seq_coord, nplot, recipe_title, filename 

3192 ) 

3193 

3194 # Do the actual plotting. 

3195 plotting_func( 

3196 cube_slice, 

3197 filename=plot_filename, 

3198 stamp_coordinate=stamp_coordinate, 

3199 title=plot_title, 

3200 vmin=vmin, 

3201 vmax=vmax, 

3202 ) 

3203 plot_index.append(plot_filename) 

3204 

3205 # Add list of plots to plot metadata. 

3206 complete_plot_index = _append_to_plot_index(plot_index) 

3207 

3208 # Make a page to display the plots. 

3209 _make_plot_html_page(complete_plot_index) 

3210 

3211 return cubes 

3212 

3213 

3214def plot_power_spectrum_series( 

3215 cubes: iris.cube.Cube | iris.cube.CubeList, 

3216 filename: str = None, 

3217 sequence_coordinate: str = "time", 

3218 stamp_coordinate: str = "realization", 

3219 single_plot: bool = False, 

3220 **kwargs, 

3221) -> iris.cube.Cube | iris.cube.CubeList: 

3222 """Plot a power spectrum plot for each vertical level provided. 

3223 

3224 A power spectrum plot can be plotted, but if the sequence_coordinate (i.e. time) 

3225 is present then a sequence of plots will be produced using the time slider 

3226 functionality to scroll through power spectrum against time. If a 

3227 stamp_coordinate is present then postage stamp plots will be produced. If 

3228 stamp_coordinate and single_plot is True, all postage stamp plots will be 

3229 plotted in a single plot instead of separate postage stamp plots. 

3230 

3231 Parameters 

3232 ---------- 

3233 cubes: Cube | iris.cube.CubeList 

3234 Iris cube or CubeList of the data to plot. It should have a single dimension other 

3235 than the stamp coordinate. 

3236 The cubes should cover the same phenomenon i.e. all cubes contain temperature data. 

3237 We do not support different data such as temperature and humidity in the same CubeList for plotting. 

3238 filename: str, optional 

3239 Name of the plot to write, used as a prefix for plot sequences. Defaults 

3240 to the recipe name. 

3241 sequence_coordinate: str, optional 

3242 Coordinate about which to make a plot sequence. Defaults to ``"time"``. 

3243 This coordinate must exist in the cube and will be used for the time 

3244 slider. 

3245 stamp_coordinate: str, optional 

3246 Coordinate about which to plot postage stamp plots. Defaults to 

3247 ``"realization"``. 

3248 single_plot: bool, optional 

3249 If True, all postage stamp plots will be plotted in a single plot. If 

3250 False, each postage stamp plot will be plotted separately. Is only valid 

3251 if stamp_coordinate exists and has more than a single point. 

3252 

3253 Returns 

3254 ------- 

3255 iris.cube.Cube | iris.cube.CubeList 

3256 The original Cube or CubeList (so further operations can be applied). 

3257 Plotted data. 

3258 

3259 Raises 

3260 ------ 

3261 ValueError 

3262 If the cube doesn't have the right dimensions. 

3263 TypeError 

3264 If the cube isn't a Cube or CubeList. 

3265 """ 

3266 recipe_title = get_recipe_metadata().get("title", "Untitled") 

3267 

3268 cubes = iter_maybe(cubes) 

3269 

3270 # Internal plotting function. 

3271 plotting_func = _plot_and_save_power_spectrum_series 

3272 

3273 num_models = _get_num_models(cubes) 

3274 

3275 _validate_cube_shape(cubes, num_models) 

3276 

3277 # If several power spectra are plotted with time as sequence_coordinate for the 

3278 # time slider option. 

3279 for cube in cubes: 

3280 try: 

3281 cube.coord(sequence_coordinate) 

3282 except iris.exceptions.CoordinateNotFoundError as err: 

3283 raise ValueError( 

3284 f"Cube must have a {sequence_coordinate} coordinate." 

3285 ) from err 

3286 

3287 # Make postage stamp plots if stamp_coordinate exists and has more than a 

3288 # single point. If single_plot is True: 

3289 # -- all postage stamp plots will be plotted in a single plot instead of 

3290 # separate postage stamp plots. 

3291 # -- model names (hidden in cube attrs) are ignored, that is stamp plots are 

3292 # produced per single model only 

3293 if num_models == 1: 3293 ↛ 3306line 3293 didn't jump to line 3306 because the condition on line 3293 was always true

3294 if ( 3294 ↛ 3298line 3294 didn't jump to line 3298 because the condition on line 3294 was never true

3295 stamp_coordinate in [c.name() for c in cubes[0].coords()] 

3296 and cubes[0].coord(stamp_coordinate).shape[0] > 1 

3297 ): 

3298 if single_plot: 

3299 plotting_func = ( 

3300 _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series 

3301 ) 

3302 else: 

3303 plotting_func = _plot_and_save_postage_stamp_power_spectrum_series 

3304 cube_iterables = cubes[0].slices_over(sequence_coordinate) 

3305 else: 

3306 all_points = sorted( 

3307 set( 

3308 itertools.chain.from_iterable( 

3309 cb.coord(sequence_coordinate).points for cb in cubes 

3310 ) 

3311 ) 

3312 ) 

3313 all_slices = list( 

3314 itertools.chain.from_iterable( 

3315 cb.slices_over(sequence_coordinate) for cb in cubes 

3316 ) 

3317 ) 

3318 # Matched slices (matched by seq coord point; it may happen that 

3319 # evaluated models do not cover the same seq coord range, hence matching 

3320 # necessary) 

3321 cube_iterables = [ 

3322 iris.cube.CubeList( 

3323 s for s in all_slices if s.coord(sequence_coordinate).points[0] == point 

3324 ) 

3325 for point in all_points 

3326 ] 

3327 

3328 plot_index = [] 

3329 nplot = np.size(cube.coord(sequence_coordinate).points) 

3330 # Create a plot for each value of the sequence coordinate. Allowing for 

3331 # multiple cubes in a CubeList to be plotted in the same plot for similar 

3332 # sequence values. Passing a CubeList into the internal plotting function 

3333 # for similar values of the sequence coordinate. cube_slice can be an 

3334 # iris.cube.Cube or an iris.cube.CubeList. 

3335 for cube_slice in cube_iterables: 

3336 single_cube = cube_slice 

3337 if isinstance(cube_slice, iris.cube.CubeList): 3337 ↛ 3338line 3337 didn't jump to line 3338 because the condition on line 3337 was never true

3338 single_cube = cube_slice[0] 

3339 

3340 # Set plot title and filenames based on sequence values 

3341 seq_coord = single_cube.coord(sequence_coordinate) 

3342 plot_title, plot_filename = _set_title_and_filename( 

3343 seq_coord, nplot, recipe_title, filename 

3344 ) 

3345 

3346 # Do the actual plotting. 

3347 plotting_func( 

3348 cube_slice, 

3349 filename=plot_filename, 

3350 stamp_coordinate=stamp_coordinate, 

3351 title=plot_title, 

3352 ) 

3353 plot_index.append(plot_filename) 

3354 

3355 # Add list of plots to plot metadata. 

3356 complete_plot_index = _append_to_plot_index(plot_index) 

3357 

3358 # Make a page to display the plots. 

3359 _make_plot_html_page(complete_plot_index) 

3360 

3361 return cubes 

3362 

3363 

3364def _DCT_ps(y_3d): 

3365 """Calculate power spectra for regional domains. 

3366 

3367 Parameters 

3368 ---------- 

3369 y_3d: 3D array 

3370 3 dimensional array to calculate spectrum for. 

3371 (2D field data with 3rd dimension of time) 

3372 

3373 Returns: ps_array 

3374 Array of power spectra values calculated for input field (for each time) 

3375 

3376 Method for regional domains: 

3377 Calculate power spectra over limited area domain using Discrete Cosine Transform (DCT) 

3378 as described in Denis et al 2002 [Denis_etal_2002]_. 

3379 

3380 References 

3381 ---------- 

3382 .. [Denis_etal_2002] Bertrand Denis, Jean Côté and René Laprise (2002) 

3383 "Spectral Decomposition of Two-Dimensional Atmospheric Fields on 

3384 Limited-Area Domains Using the Discrete Cosine Transform (DCT)" 

3385 Monthly Weather Review, Vol. 130, 1812-1828 

3386 doi: https://doi.org/10.1175/1520-0493(2002)130<1812:SDOTDA>2.0.CO;2 

3387 """ 

3388 Nt, Ny, Nx = y_3d.shape 

3389 

3390 # Max coefficient 

3391 Nmin = min(Nx - 1, Ny - 1) 

3392 

3393 # Create alpha matrix (of wavenumbers) 

3394 alpha_matrix = _create_alpha_matrix(Ny, Nx) 

3395 

3396 # Prepare output array 

3397 ps_array = np.zeros((Nt, Nmin)) 

3398 

3399 # Loop over time to get spectrum for each time. 

3400 for t in range(Nt): 

3401 y_2d = y_3d[t] 

3402 

3403 # Apply 2D DCT to transform y_3d[t] from physical space to spectral space. 

3404 # fkk is a 2D array of DCT coefficients, representing the amplitudes of 

3405 # cosine basis functions at different spatial frequencies. 

3406 

3407 # normalise spectrum to allow comparison between models. 

3408 fkk = fft.dctn(y_2d, norm="ortho") 

3409 

3410 # Normalise fkk 

3411 fkk = fkk / np.sqrt(Ny * Nx) 

3412 

3413 # calculate variance of spectral coefficient 

3414 sigma_2 = fkk**2 / Nx / Ny 

3415 

3416 # Group ellipses of alphas into the same wavenumber k/Nmin 

3417 for k in range(1, Nmin + 1): 

3418 alpha = k / Nmin 

3419 alpha_p1 = (k + 1) / Nmin 

3420 

3421 # Sum up elements matching k 

3422 mask_k = np.where((alpha_matrix >= alpha) & (alpha_matrix < alpha_p1)) 

3423 ps_array[t, k - 1] = np.sum(sigma_2[mask_k]) 

3424 

3425 return ps_array 

3426 

3427 

3428def _create_alpha_matrix(Ny, Nx): 

3429 """Construct an array of 2D wavenumbers from 2D wavenumber pair. 

3430 

3431 Parameters 

3432 ---------- 

3433 Ny, Nx: 

3434 Dimensions of the 2D field for which the power spectra is calculated. Used to 

3435 create the array of 2D wavenumbers. Each Ny, Nx pair is associated with a 

3436 single-scale parameter. 

3437 

3438 Returns: alpha_matrix 

3439 normalisation of 2D wavenumber axes, transforming the spectral domain into 

3440 an elliptic coordinate system. 

3441 

3442 """ 

3443 # Create x_indices: each row is [1, 2, ..., Nx] 

3444 x_indices = np.tile(np.arange(1, Nx + 1), (Ny, 1)) 

3445 

3446 # Create y_indices: each column is [1, 2, ..., Ny] 

3447 y_indices = np.tile(np.arange(1, Ny + 1).reshape(Ny, 1), (1, Nx)) 

3448 

3449 # Compute alpha_matrix 

3450 alpha_matrix = np.sqrt((x_indices**2) / Nx**2 + (y_indices**2) / Ny**2) 

3451 

3452 return alpha_matrix