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

1057 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-15 15:46 +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 levels = np.linspace(vmin, vmax, 101) 

341 norm = None 

342 

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

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

345 # JSON file. 

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

347 cmap, levels, norm = _custom_colourmap_visibility_in_air( 

348 cube, cmap, levels, norm 

349 ) 

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

351 return cmap, levels, norm 

352 

353 

354def _setup_spatial_map( 

355 cube: iris.cube.Cube, 

356 figure, 

357 cmap, 

358 grid_size: Literal[int, int] | None = None, 

359 subplot: int | None = None, 

360): 

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

362 

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

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

365 

366 Parameters 

367 ---------- 

368 cube: Cube 

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

370 figure: 

371 Matplotlib Figure object holding all plot elements. 

372 cmap: 

373 Matplotlib colormap. 

374 grid_size: (int,int) optional 

375 Size of grid (rows, cols) for subplots if multiple spatial subplots in figure. 

376 subplot: int, optional 

377 Subplot index if multiple spatial subplots in figure. 

378 

379 Returns 

380 ------- 

381 axes: 

382 Matplotlib GeoAxes definition. 

383 """ 

384 # Identify min/max plot bounds. 

385 try: 

386 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

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

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

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

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

391 

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

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

394 x1 = x1 - 180.0 

395 x2 = x2 - 180.0 

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

397 

398 # Consider map projection orientation. 

399 # Adapting orientation enables plotting across international dateline. 

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

401 if x2 > 180.0 or x1 < -180.0: 

402 central_longitude = 180.0 

403 else: 

404 central_longitude = 0.0 

405 

406 # Define spatial map projection. 

407 coord_system = cube.coord(lat_axis).coord_system 

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

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

410 projection = ccrs.RotatedPole( 

411 pole_longitude=coord_system.grid_north_pole_longitude, 

412 pole_latitude=coord_system.grid_north_pole_latitude, 

413 central_rotated_longitude=central_longitude, 

414 ) 

415 crs = projection 

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

417 # Define Transverse Mercator projection for TM inputs. 

418 projection = ccrs.TransverseMercator( 

419 central_longitude=coord_system.longitude_of_central_meridian, 

420 central_latitude=coord_system.latitude_of_projection_origin, 

421 false_easting=coord_system.false_easting, 

422 false_northing=coord_system.false_northing, 

423 scale_factor=coord_system.scale_factor_at_central_meridian, 

424 ) 

425 crs = projection 

426 else: 

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

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

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

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

431 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

432 crs = ccrs.PlateCarree() 

433 

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

435 if subplot is not None: 

436 axes = figure.add_subplot( 

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

438 ) 

439 else: 

440 axes = figure.add_subplot(projection=projection) 

441 

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

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

444 coastcol = "magenta" 

445 else: 

446 coastcol = "black" 

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

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

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

450 

451 # Add gridlines. 

452 if subplot is None: 

453 draw_labels = True 

454 else: 

455 draw_labels = False 

456 gl = axes.gridlines( 

457 alpha=0.3, 

458 draw_labels=draw_labels, 

459 dms=False, 

460 x_inline=False, 

461 y_inline=False, 

462 ) 

463 gl.top_labels = False 

464 gl.right_labels = False 

465 

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

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

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

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

470 

471 except ValueError: 

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

473 axes = figure.gca() 

474 pass 

475 

476 return axes 

477 

478 

479def _get_plot_resolution() -> int: 

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

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

482 

483 

484def _set_title_and_filename( 

485 seq_coord: iris.coords.Coord, 

486 nplot: int, 

487 recipe_title: str, 

488 filename: str, 

489): 

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

491 

492 Parameters 

493 ---------- 

494 sequence_coordinate: iris.coords.Coord 

495 Coordinate about which to make a plot sequence. 

496 nplot: int 

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

498 recipe_title: str 

499 Default plot title, potentially to update. 

500 filename: str 

501 Input plot filename, potentially to update. 

502 

503 Returns 

504 ------- 

505 plot_title: str 

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

507 plot_filename: str 

508 Output formatted plot filename string. 

509 """ 

510 ndim = seq_coord.ndim 

511 npoints = np.size(seq_coord.points) 

512 sequence_title = "" 

513 sequence_fname = "" 

514 

515 # Account for case with multi-dimension sequence input 

516 # (e.g. aggregation histogram plots) 

517 if ndim > 1: 

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

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

520 sequence_fname = f"_{ncase}cases" 

521 

522 else: 

523 if npoints == 1: 

524 if nplot > 1: 

525 # Set default labels for sequence inputs 

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

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

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

529 else: 

530 # Use coord aggregated attribute where input collapsed over aggregation 

531 try: 

532 ncase = seq_coord.attributes["number_reference_times"] 

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

534 sequence_fname = f"_{ncase}cases" 

535 except KeyError: 

536 sequence_title = "" 

537 sequence_fname = "" 

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

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

540 if npoints > 1: 

541 if not seq_coord.has_bounds(): 

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

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

544 else: 

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

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

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

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

549 sequence_fname = ( 

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

551 ) 

552 

553 # Set plot title and filename 

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

555 

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

557 if filename is None: 

558 filename = slugify(recipe_title) 

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

560 else: 

561 if nplot > 1: 

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

563 else: 

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

565 

566 return plot_title, plot_filename 

567 

568 

569def _plot_and_save_spatial_plot( 

570 cube: iris.cube.Cube, 

571 filename: str, 

572 title: str, 

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

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

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

576 **kwargs, 

577): 

578 """Plot and save a spatial plot. 

579 

580 Parameters 

581 ---------- 

582 cube: Cube 

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

584 filename: str 

585 Filename of the plot to write. 

586 title: str 

587 Plot title. 

588 method: "contourf" | "pcolormesh" 

589 The plotting method to use. 

590 overlay_cube: Cube, optional 

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

592 contour_cube: Cube, optional 

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

594 """ 

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

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

597 

598 # Specify the color bar 

599 cmap, levels, norm = _colorbar_map_levels(cube) 

600 

601 # If overplotting, set required colorbars 

602 if overlay_cube: 

603 over_cmap, over_levels, over_norm = _colorbar_map_levels(overlay_cube) 

604 if contour_cube: 

605 cntr_cmap, cntr_levels, cntr_norm = _colorbar_map_levels(contour_cube) 

606 

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

608 axes = _setup_spatial_map(cube, fig, cmap) 

609 

610 # Plot the field. 

611 if method == "contourf": 

612 # Filled contour plot of the field. 

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

614 elif method == "pcolormesh": 

615 try: 

616 vmin = min(levels) 

617 vmax = max(levels) 

618 except TypeError: 

619 vmin, vmax = None, None 

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

621 # if levels are defined. 

622 if norm is not None: 

623 vmin = None 

624 vmax = None 

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

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

627 else: 

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

629 

630 # Overplot overlay field, if required 

631 if overlay_cube: 

632 try: 

633 over_vmin = min(over_levels) 

634 over_vmax = max(over_levels) 

635 except TypeError: 

636 over_vmin, over_vmax = None, None 

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

638 over_vmin = None 

639 over_vmax = None 

640 overlay = iplt.pcolormesh( 

641 overlay_cube, 

642 cmap=over_cmap, 

643 norm=over_norm, 

644 alpha=0.8, 

645 vmin=over_vmin, 

646 vmax=over_vmax, 

647 ) 

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

649 if contour_cube: 

650 contour = iplt.contour( 

651 contour_cube, 

652 colors="darkgray", 

653 levels=cntr_levels, 

654 norm=cntr_norm, 

655 alpha=0.5, 

656 linestyles="--", 

657 linewidths=1, 

658 ) 

659 plt.clabel(contour) 

660 

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

662 if is_transect(cube): 

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

664 axes.invert_yaxis() 

665 axes.set_yscale("log") 

666 axes.set_ylim(1100, 100) 

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

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

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

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

671 ): 

672 axes.set_yscale("log") 

673 

674 axes.set_title( 

675 f"{title}\n" 

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

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

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

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

680 fontsize=16, 

681 ) 

682 

683 # Inset code 

684 axins = inset_axes( 

685 axes, 

686 width="20%", 

687 height="20%", 

688 loc="upper right", 

689 axes_class=GeoAxes, 

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

691 ) 

692 

693 # Slightly transparent to reduce plot blocking. 

694 axins.patch.set_alpha(0.4) 

695 

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

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

698 

699 SLat, SLon, ELat, ELon = ( 

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

701 ) 

702 

703 # Draw line between them 

704 axins.plot( 

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

706 ) 

707 

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

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

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

711 

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

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

714 

715 # Midpoints 

716 lon_mid = (lon_min + lon_max) / 2 

717 lat_mid = (lat_min + lat_max) / 2 

718 

719 # Maximum half-range 

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

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

722 half_range = 1 

723 

724 # Set square extent 

725 axins.set_extent( 

726 [ 

727 lon_mid - half_range, 

728 lon_mid + half_range, 

729 lat_mid - half_range, 

730 lat_mid + half_range, 

731 ], 

732 crs=ccrs.PlateCarree(), 

733 ) 

734 

735 # Ensure square aspect 

736 axins.set_aspect("equal") 

737 

738 else: 

739 # Add title. 

740 axes.set_title(title, fontsize=16) 

741 

742 # Adjust padding if spatial plot or transect 

743 if is_transect(cube): 

744 yinfopad = -0.1 

745 ycbarpad = 0.1 

746 else: 

747 yinfopad = 0.01 

748 ycbarpad = 0.042 

749 

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

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

752 axes.annotate( 

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

754 xy=(1, yinfopad), 

755 xycoords="axes fraction", 

756 xytext=(-5, 5), 

757 textcoords="offset points", 

758 ha="right", 

759 va="bottom", 

760 size=11, 

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

762 ) 

763 

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

765 if overlay_cube: 

766 cbarB = fig.colorbar( 

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

768 ) 

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

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

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

772 cbarB.set_ticks(over_levels) 

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

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

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

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

777 

778 # Add main colour bar. 

779 cbar = fig.colorbar( 

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

781 ) 

782 

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

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

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

786 cbar.set_ticks(levels) 

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

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

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

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

791 

792 # Save plot. 

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

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

795 plt.close(fig) 

796 

797 

798def _plot_and_save_postage_stamp_spatial_plot( 

799 cube: iris.cube.Cube, 

800 filename: str, 

801 stamp_coordinate: str, 

802 title: str, 

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

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

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

806 **kwargs, 

807): 

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

809 

810 Parameters 

811 ---------- 

812 cube: Cube 

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

814 filename: str 

815 Filename of the plot to write. 

816 stamp_coordinate: str 

817 Coordinate that becomes different plots. 

818 method: "contourf" | "pcolormesh" 

819 The plotting method to use. 

820 overlay_cube: Cube, optional 

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

822 contour_cube: Cube, optional 

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

824 

825 Raises 

826 ------ 

827 ValueError 

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

829 """ 

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

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

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

833 grid_rows = math.ceil(nmember / grid_size) 

834 

835 fig = plt.figure( 

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

837 ) 

838 

839 # Specify the color bar 

840 cmap, levels, norm = _colorbar_map_levels(cube) 

841 # If overplotting, set required colorbars 

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

843 over_cmap, over_levels, over_norm = _colorbar_map_levels(overlay_cube) 

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

845 cntr_cmap, cntr_levels, cntr_norm = _colorbar_map_levels(contour_cube) 

846 

847 # Make a subplot for each member. 

848 for member, subplot in zip( 

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

850 ): 

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

852 axes = _setup_spatial_map( 

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

854 ) 

855 if method == "contourf": 

856 # Filled contour plot of the field. 

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

858 elif method == "pcolormesh": 

859 if levels is not None: 

860 vmin = min(levels) 

861 vmax = max(levels) 

862 else: 

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

864 vmin, vmax = None, None 

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

866 # if levels are defined. 

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

868 vmin = None 

869 vmax = None 

870 # pcolormesh plot of the field. 

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

872 else: 

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

874 

875 # Overplot overlay field, if required 

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

877 try: 

878 over_vmin = min(over_levels) 

879 over_vmax = max(over_levels) 

880 except TypeError: 

881 over_vmin, over_vmax = None, None 

882 if over_norm is not None: 

883 over_vmin = None 

884 over_vmax = None 

885 iplt.pcolormesh( 

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

887 cmap=over_cmap, 

888 norm=over_norm, 

889 alpha=0.6, 

890 vmin=over_vmin, 

891 vmax=over_vmax, 

892 ) 

893 # Overplot contour field, if required 

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

895 iplt.contour( 

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

897 colors="darkgray", 

898 levels=cntr_levels, 

899 norm=cntr_norm, 

900 alpha=0.6, 

901 linestyles="--", 

902 linewidths=1, 

903 ) 

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

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

906 axes.set_axis_off() 

907 

908 # Put the shared colorbar in its own axes. 

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

910 colorbar = fig.colorbar( 

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

912 ) 

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

914 

915 # Overall figure title. 

916 fig.suptitle(title, fontsize=16) 

917 

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

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

920 plt.close(fig) 

921 

922 

923def _plot_and_save_line_series( 

924 cubes: iris.cube.CubeList, 

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

926 ensemble_coord: str, 

927 filename: str, 

928 title: str, 

929 **kwargs, 

930): 

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

932 

933 Parameters 

934 ---------- 

935 cubes: Cube or CubeList 

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

937 coords: list[Coord] 

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

939 ensemble_coord: str 

940 Ensemble coordinate in the cube. 

941 filename: str 

942 Filename of the plot to write. 

943 title: str 

944 Plot title. 

945 """ 

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

947 

948 model_colors_map = _get_model_colors_map(cubes) 

949 

950 # Store min/max ranges. 

951 y_levels = [] 

952 

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

954 _validate_cubes_coords(cubes, coords) 

955 

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

957 label = None 

958 color = "black" 

959 if model_colors_map: 

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

961 color = model_colors_map.get(label) 

962 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

965 iplt.plot( 

966 coord, 

967 cube_slice, 

968 color=color, 

969 marker="o", 

970 ls="-", 

971 lw=3, 

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

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

974 else label, 

975 ) 

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

977 else: 

978 iplt.plot( 

979 coord, 

980 cube_slice, 

981 color=color, 

982 ls="-", 

983 lw=1.5, 

984 alpha=0.75, 

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

986 ) 

987 

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

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

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

991 y_levels.append(min(levels)) 

992 y_levels.append(max(levels)) 

993 

994 # Get the current axes. 

995 ax = plt.gca() 

996 

997 # Add some labels and tweak the style. 

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

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

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

1001 else: 

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

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

1004 ax.set_title(title, fontsize=16) 

1005 

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

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

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

1009 

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

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

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

1013 # Add zero line. 

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

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

1016 logging.debug( 

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

1018 ) 

1019 else: 

1020 ax.autoscale() 

1021 

1022 # Add gridlines 

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

1024 # Ientify unique labels for legend 

1025 handles = list( 

1026 { 

1027 label: handle 

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

1029 }.values() 

1030 ) 

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

1032 

1033 # Save plot. 

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

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

1036 plt.close(fig) 

1037 

1038 

1039def _plot_and_save_vertical_line_series( 

1040 cubes: iris.cube.CubeList, 

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

1042 ensemble_coord: str, 

1043 filename: str, 

1044 series_coordinate: str, 

1045 title: str, 

1046 vmin: float, 

1047 vmax: float, 

1048 **kwargs, 

1049): 

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

1051 

1052 Parameters 

1053 ---------- 

1054 cubes: CubeList 

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

1056 coord: list[Coord] 

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

1058 ensemble_coord: str 

1059 Ensemble coordinate in the cube. 

1060 filename: str 

1061 Filename of the plot to write. 

1062 series_coordinate: str 

1063 Coordinate to use as vertical axis. 

1064 title: str 

1065 Plot title. 

1066 vmin: float 

1067 Minimum value for the x-axis. 

1068 vmax: float 

1069 Maximum value for the x-axis. 

1070 """ 

1071 # plot the vertical pressure axis using log scale 

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

1073 

1074 model_colors_map = _get_model_colors_map(cubes) 

1075 

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

1077 _validate_cubes_coords(cubes, coords) 

1078 

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

1080 label = None 

1081 color = "black" 

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

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

1084 color = model_colors_map.get(label) 

1085 

1086 for cube_slice in cube.slices_over(ensemble_coord): 

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

1088 # unless single forecast. 

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

1090 iplt.plot( 

1091 cube_slice, 

1092 coord, 

1093 color=color, 

1094 marker="o", 

1095 ls="-", 

1096 lw=3, 

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

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

1099 else label, 

1100 ) 

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

1102 else: 

1103 iplt.plot( 

1104 cube_slice, 

1105 coord, 

1106 color=color, 

1107 ls="-", 

1108 lw=1.5, 

1109 alpha=0.75, 

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

1111 ) 

1112 

1113 # Get the current axis 

1114 ax = plt.gca() 

1115 

1116 # Special handling for pressure level data. 

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

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

1119 ax.invert_yaxis() 

1120 ax.set_yscale("log") 

1121 

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

1123 y_tick_labels = [ 

1124 "1000", 

1125 "850", 

1126 "700", 

1127 "500", 

1128 "300", 

1129 "200", 

1130 "100", 

1131 ] 

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

1133 

1134 # Set y-axis limits and ticks. 

1135 ax.set_ylim(1100, 100) 

1136 

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

1138 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

1144 

1145 ax.set_yticks(y_ticks) 

1146 ax.set_yticklabels(y_tick_labels) 

1147 

1148 # Set x-axis limits. 

1149 ax.set_xlim(vmin, vmax) 

1150 # Mark y=0 if present in plot. 

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

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

1153 

1154 # Add some labels and tweak the style. 

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

1156 ax.set_xlabel( 

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

1158 ) 

1159 ax.set_title(title, fontsize=16) 

1160 ax.ticklabel_format(axis="x") 

1161 ax.tick_params(axis="y") 

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

1163 

1164 # Add gridlines 

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

1166 # Ientify unique labels for legend 

1167 handles = list( 

1168 { 

1169 label: handle 

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

1171 }.values() 

1172 ) 

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

1174 

1175 # Save plot. 

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

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

1178 plt.close(fig) 

1179 

1180 

1181def _plot_and_save_scatter_plot( 

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

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

1184 filename: str, 

1185 title: str, 

1186 one_to_one: bool, 

1187 model_names: list[str] = None, 

1188 **kwargs, 

1189): 

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

1191 

1192 Parameters 

1193 ---------- 

1194 cube_x: Cube | CubeList 

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

1196 cube_y: Cube | CubeList 

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

1198 filename: str 

1199 Filename of the plot to write. 

1200 title: str 

1201 Plot title. 

1202 one_to_one: bool 

1203 Whether a 1:1 line is plotted. 

1204 """ 

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

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

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

1208 # over the pairs simultaneously. 

1209 

1210 # Ensure cube_x and cube_y are iterable 

1211 cube_x_iterable = iter_maybe(cube_x) 

1212 cube_y_iterable = iter_maybe(cube_y) 

1213 

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

1215 iplt.scatter(cube_x_iter, cube_y_iter) 

1216 if one_to_one is True: 

1217 plt.plot( 

1218 [ 

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

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

1221 ], 

1222 [ 

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

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

1225 ], 

1226 "k", 

1227 linestyle="--", 

1228 ) 

1229 ax = plt.gca() 

1230 

1231 # Add some labels and tweak the style. 

1232 if model_names is None: 

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

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

1235 else: 

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

1237 ax.set_xlabel( 

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

1239 ) 

1240 ax.set_ylabel( 

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

1242 ) 

1243 ax.set_title(title, fontsize=16) 

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

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

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

1247 ax.autoscale() 

1248 

1249 # Save plot. 

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

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

1252 plt.close(fig) 

1253 

1254 

1255def _plot_and_save_vector_plot( 

1256 cube_u: iris.cube.Cube, 

1257 cube_v: iris.cube.Cube, 

1258 filename: str, 

1259 title: str, 

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

1261 **kwargs, 

1262): 

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

1264 

1265 Parameters 

1266 ---------- 

1267 cube_u: Cube 

1268 2 dimensional Cube of u component of the data. 

1269 cube_v: Cube 

1270 2 dimensional Cube of v component of the data. 

1271 filename: str 

1272 Filename of the plot to write. 

1273 title: str 

1274 Plot title. 

1275 """ 

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

1277 

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

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

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

1281 

1282 # Specify the color bar 

1283 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag) 

1284 

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

1286 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

1287 

1288 if method == "contourf": 

1289 # Filled contour plot of the field. 

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

1291 elif method == "pcolormesh": 

1292 try: 

1293 vmin = min(levels) 

1294 vmax = max(levels) 

1295 except TypeError: 

1296 vmin, vmax = None, None 

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

1298 # if levels are defined. 

1299 if norm is not None: 

1300 vmin = None 

1301 vmax = None 

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

1303 else: 

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

1305 

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

1307 if is_transect(cube_vec_mag): 

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

1309 axes.invert_yaxis() 

1310 axes.set_yscale("log") 

1311 axes.set_ylim(1100, 100) 

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

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

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

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

1316 ): 

1317 axes.set_yscale("log") 

1318 

1319 axes.set_title( 

1320 f"{title}\n" 

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

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

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

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

1325 fontsize=16, 

1326 ) 

1327 

1328 else: 

1329 # Add title. 

1330 axes.set_title(title, fontsize=16) 

1331 

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

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

1334 axes.annotate( 

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

1336 xy=(1, -0.05), 

1337 xycoords="axes fraction", 

1338 xytext=(-5, 5), 

1339 textcoords="offset points", 

1340 ha="right", 

1341 va="bottom", 

1342 size=11, 

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

1344 ) 

1345 

1346 # Add colour bar. 

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

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

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

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

1351 cbar.set_ticks(levels) 

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

1353 

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

1355 # with less than 30 points. 

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

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

1358 

1359 # Save plot. 

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

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

1362 plt.close(fig) 

1363 

1364 

1365def _plot_and_save_histogram_series( 

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

1367 filename: str, 

1368 title: str, 

1369 vmin: float, 

1370 vmax: float, 

1371 **kwargs, 

1372): 

1373 """Plot and save a histogram series. 

1374 

1375 Parameters 

1376 ---------- 

1377 cubes: Cube or CubeList 

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

1379 filename: str 

1380 Filename of the plot to write. 

1381 title: str 

1382 Plot title. 

1383 vmin: float 

1384 minimum for colorbar 

1385 vmax: float 

1386 maximum for colorbar 

1387 """ 

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

1389 ax = plt.gca() 

1390 

1391 model_colors_map = _get_model_colors_map(cubes) 

1392 

1393 # Set default that histograms will produce probability density function 

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

1395 density = True 

1396 

1397 for cube in iter_maybe(cubes): 

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

1399 # than seeing if long names exist etc. 

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

1401 if "surface_microphysical" in title: 

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

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

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

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

1406 density = False 

1407 else: 

1408 bins = 10.0 ** ( 

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

1410 ) # Suggestion from RMED toolbox. 

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

1412 ax.set_yscale("log") 

1413 vmin = bins[1] 

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

1415 ax.set_xscale("log") 

1416 elif "lightning" in title: 

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

1418 else: 

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

1420 logging.debug( 

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

1422 np.size(bins), 

1423 np.min(bins), 

1424 np.max(bins), 

1425 ) 

1426 

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

1428 # Otherwise we plot xdim histograms stacked. 

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

1430 

1431 label = None 

1432 color = "black" 

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

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

1435 color = model_colors_map[label] 

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

1437 

1438 # Compute area under curve. 

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

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

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

1442 x = x[1:] 

1443 y = y[1:] 

1444 

1445 ax.plot( 

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

1447 ) 

1448 

1449 # Add some labels and tweak the style. 

1450 ax.set_title(title, fontsize=16) 

1451 ax.set_xlabel( 

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

1453 ) 

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

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

1456 ax.set_ylabel( 

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

1458 ) 

1459 ax.set_xlim(vmin, vmax) 

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

1461 

1462 # Overlay grid-lines onto histogram plot. 

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

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

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

1466 

1467 # Save plot. 

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

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

1470 plt.close(fig) 

1471 

1472 

1473def _plot_and_save_postage_stamp_histogram_series( 

1474 cube: iris.cube.Cube, 

1475 filename: str, 

1476 title: str, 

1477 stamp_coordinate: str, 

1478 vmin: float, 

1479 vmax: float, 

1480 **kwargs, 

1481): 

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

1483 

1484 Parameters 

1485 ---------- 

1486 cube: Cube 

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

1488 filename: str 

1489 Filename of the plot to write. 

1490 title: str 

1491 Plot title. 

1492 stamp_coordinate: str 

1493 Coordinate that becomes different plots. 

1494 vmin: float 

1495 minimum for pdf x-axis 

1496 vmax: float 

1497 maximum for pdf x-axis 

1498 """ 

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

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

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

1502 grid_rows = math.ceil(nmember / grid_size) 

1503 

1504 fig = plt.figure( 

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

1506 ) 

1507 # Make a subplot for each member. 

1508 for member, subplot in zip( 

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

1510 ): 

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

1512 # cartopy GeoAxes generated. 

1513 plt.subplot(grid_rows, grid_size, subplot) 

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

1515 # Otherwise we plot xdim histograms stacked. 

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

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

1518 ax = plt.gca() 

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

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

1521 ax.set_xlim(vmin, vmax) 

1522 

1523 # Overall figure title. 

1524 fig.suptitle(title, fontsize=16) 

1525 

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

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

1528 plt.close(fig) 

1529 

1530 

1531def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1532 cube: iris.cube.Cube, 

1533 filename: str, 

1534 title: str, 

1535 stamp_coordinate: str, 

1536 vmin: float, 

1537 vmax: float, 

1538 **kwargs, 

1539): 

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

1541 ax.set_title(title, fontsize=16) 

1542 ax.set_xlim(vmin, vmax) 

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

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

1545 # Loop over all slices along the stamp_coordinate 

1546 for member in cube.slices_over(stamp_coordinate): 

1547 # Flatten the member data to 1D 

1548 member_data_1d = member.data.flatten() 

1549 # Plot the histogram using plt.hist 

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

1551 plt.hist( 

1552 member_data_1d, 

1553 density=True, 

1554 stacked=True, 

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

1556 ) 

1557 

1558 # Add a legend 

1559 ax.legend(fontsize=16) 

1560 

1561 # Save the figure to a file 

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

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

1564 

1565 # Close the figure 

1566 plt.close(fig) 

1567 

1568 

1569def _plot_and_save_scattermap_plot( 

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

1571): 

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

1573 

1574 Parameters 

1575 ---------- 

1576 cube: Cube 

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

1578 longitude coordinates, 

1579 filename: str 

1580 Filename of the plot to write. 

1581 title: str 

1582 Plot title. 

1583 projection: str 

1584 Mapping projection to be used by cartopy. 

1585 """ 

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

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

1588 if projection is not None: 

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

1590 # a stereographic projection over the North Pole. 

1591 if projection == "NP_Stereo": 

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

1593 else: 

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

1595 else: 

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

1597 

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

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

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

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

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

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

1604 # proportion to the area of the figure. 

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

1606 klon = None 

1607 klat = None 

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

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

1610 klat = kc 

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

1612 klon = kc 

1613 scatter_map = iplt.scatter( 

1614 cube.aux_coords[klon], 

1615 cube.aux_coords[klat], 

1616 c=cube.data[:], 

1617 s=mrk_size, 

1618 cmap="jet", 

1619 edgecolors="k", 

1620 ) 

1621 

1622 # Add coastlines and borderlines. 

1623 try: 

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

1625 axes.add_feature(cfeature.BORDERS) 

1626 except AttributeError: 

1627 pass 

1628 

1629 # Add title. 

1630 axes.set_title(title, fontsize=16) 

1631 

1632 # Add colour bar. 

1633 cbar = fig.colorbar(scatter_map) 

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

1635 

1636 # Save plot. 

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

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

1639 plt.close(fig) 

1640 

1641 

1642def _plot_and_save_power_spectrum_series( 

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

1644 filename: str, 

1645 title: str, 

1646 **kwargs, 

1647): 

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

1649 

1650 Parameters 

1651 ---------- 

1652 cubes: Cube or CubeList 

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

1654 filename: str 

1655 Filename of the plot to write. 

1656 title: str 

1657 Plot title. 

1658 """ 

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

1660 ax = plt.gca() 

1661 

1662 model_colors_map = _get_model_colors_map(cubes) 

1663 

1664 for cube in iter_maybe(cubes): 

1665 # Calculate power spectrum 

1666 

1667 # Extract time coordinate and convert to datetime 

1668 time_coord = cube.coord("time") 

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

1670 

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

1672 target_time = time_points[0] 

1673 

1674 # Bind target_time inside the lambda using a default argument 

1675 time_constraint = iris.Constraint( 

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

1677 ) 

1678 

1679 cube = cube.extract(time_constraint) 

1680 

1681 if cube.ndim == 2: 

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

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

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

1685 cube_3d = cube.data 

1686 else: 

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

1688 raise ValueError( 

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

1690 ) 

1691 

1692 # Calculate spectra 

1693 ps_array = _DCT_ps(cube_3d) 

1694 

1695 ps_cube = iris.cube.Cube( 

1696 ps_array, 

1697 long_name="power_spectra", 

1698 ) 

1699 

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

1701 

1702 # Create a frequency/wavelength array for coordinate 

1703 ps_len = ps_cube.data.shape[1] 

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

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

1706 

1707 # Convert datetime to numeric time using original units 

1708 numeric_time = time_coord.units.date2num(time_points) 

1709 # Create a new DimCoord with numeric time 

1710 new_time_coord = iris.coords.DimCoord( 

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

1712 ) 

1713 

1714 # Add time and frequency coordinate to spectra cube. 

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

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

1717 

1718 # Extract data from the cube 

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

1720 power_spectrum = ps_cube.data 

1721 

1722 label = None 

1723 color = "black" 

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

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

1726 color = model_colors_map[label] 

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

1728 

1729 # Add some labels and tweak the style. 

1730 ax.set_title(title, fontsize=16) 

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

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

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

1734 

1735 # Set log-log scale 

1736 ax.set_xscale("log") 

1737 ax.set_yscale("log") 

1738 

1739 # Overlay grid-lines onto power spectrum plot. 

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

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

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

1743 

1744 # Save plot. 

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

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

1747 plt.close(fig) 

1748 

1749 

1750def _plot_and_save_postage_stamp_power_spectrum_series( 

1751 cube: iris.cube.Cube, 

1752 filename: str, 

1753 title: str, 

1754 stamp_coordinate: str, 

1755 **kwargs, 

1756): 

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

1758 

1759 Parameters 

1760 ---------- 

1761 cube: Cube 

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

1763 filename: str 

1764 Filename of the plot to write. 

1765 title: str 

1766 Plot title. 

1767 stamp_coordinate: str 

1768 Coordinate that becomes different plots. 

1769 """ 

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

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

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

1773 grid_rows = math.ceil(nmember / grid_size) 

1774 

1775 fig = plt.figure( 

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

1777 ) 

1778 

1779 # Make a subplot for each member. 

1780 for member, subplot in zip( 

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

1782 ): 

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

1784 # cartopy GeoAxes generated. 

1785 plt.subplot(grid_rows, grid_size, subplot) 

1786 

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

1788 

1789 ax = plt.gca() 

1790 ax.plot(frequency, member.data) 

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

1792 

1793 # Overall figure title. 

1794 fig.suptitle(title, fontsize=16) 

1795 

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

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

1798 plt.close(fig) 

1799 

1800 

1801def _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series( 

1802 cube: iris.cube.Cube, 

1803 filename: str, 

1804 title: str, 

1805 stamp_coordinate: str, 

1806 **kwargs, 

1807): 

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

1809 ax.set_title(title, fontsize=16) 

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

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

1812 # Loop over all slices along the stamp_coordinate 

1813 for member in cube.slices_over(stamp_coordinate): 

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

1815 ax.plot( 

1816 frequency, 

1817 member.data, 

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

1819 ) 

1820 

1821 # Add a legend 

1822 ax.legend(fontsize=16) 

1823 

1824 # Save the figure to a file 

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

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

1827 

1828 # Close the figure 

1829 plt.close(fig) 

1830 

1831 

1832def _spatial_plot( 

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

1834 cube: iris.cube.Cube, 

1835 filename: str | None, 

1836 sequence_coordinate: str, 

1837 stamp_coordinate: str, 

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

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

1840 **kwargs, 

1841): 

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

1843 

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

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

1846 is present then postage stamp plots will be produced. 

1847 

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

1849 be overplotted on the same figure. 

1850 

1851 Parameters 

1852 ---------- 

1853 method: "contourf" | "pcolormesh" 

1854 The plotting method to use. 

1855 cube: Cube 

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

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

1858 plotted sequentially and/or as postage stamp plots. 

1859 filename: str | None 

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

1861 uses the recipe name. 

1862 sequence_coordinate: str 

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

1864 This coordinate must exist in the cube. 

1865 stamp_coordinate: str 

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

1867 ``"realization"``. 

1868 overlay_cube: Cube | None, optional 

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

1870 contour_cube: Cube | None, optional 

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

1872 

1873 Raises 

1874 ------ 

1875 ValueError 

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

1877 TypeError 

1878 If the cube isn't a single cube. 

1879 """ 

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

1881 

1882 # Ensure we've got a single cube. 

1883 cube = _check_single_cube(cube) 

1884 

1885 # Check if there is a valid stamp coordinate in cube dimensions. 

1886 stamp_coordinate = check_stamp_coordinate(cube) 

1887 

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

1889 # single point. 

1890 plotting_func = _plot_and_save_spatial_plot 

1891 try: 

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

1893 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1894 except iris.exceptions.CoordinateNotFoundError: 

1895 pass 

1896 

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

1898 # dimension called observation or model_obs_error 

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

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

1901 for crd in cube.coords() 

1902 ): 

1903 plotting_func = _plot_and_save_scattermap_plot 

1904 

1905 # Must have a sequence coordinate. 

1906 try: 

1907 cube.coord(sequence_coordinate) 

1908 except iris.exceptions.CoordinateNotFoundError as err: 

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

1910 

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

1912 plot_index = [] 

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

1914 

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

1916 # Set plot titles and filename 

1917 seq_coord = cube_slice.coord(sequence_coordinate) 

1918 plot_title, plot_filename = _set_title_and_filename( 

1919 seq_coord, nplot, recipe_title, filename 

1920 ) 

1921 

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

1923 overlay_slice = slice_over_maybe(overlay_cube, sequence_coordinate, iseq) 

1924 contour_slice = slice_over_maybe(contour_cube, sequence_coordinate, iseq) 

1925 

1926 # Do the actual plotting. 

1927 plotting_func( 

1928 cube_slice, 

1929 filename=plot_filename, 

1930 stamp_coordinate=stamp_coordinate, 

1931 title=plot_title, 

1932 method=method, 

1933 overlay_cube=overlay_slice, 

1934 contour_cube=contour_slice, 

1935 **kwargs, 

1936 ) 

1937 plot_index.append(plot_filename) 

1938 

1939 # Add list of plots to plot metadata. 

1940 complete_plot_index = _append_to_plot_index(plot_index) 

1941 

1942 # Make a page to display the plots. 

1943 _make_plot_html_page(complete_plot_index) 

1944 

1945 

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

1947 """Get colourmap for mask. 

1948 

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

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

1951 

1952 Parameters 

1953 ---------- 

1954 cube: Cube 

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

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

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

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

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

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

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

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

1963 

1964 Returns 

1965 ------- 

1966 cmap: 

1967 Matplotlib colormap. 

1968 levels: 

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

1970 should be taken as the range. 

1971 norm: 

1972 BoundaryNorm information. 

1973 """ 

1974 if "difference" not in cube.long_name: 

1975 if axis: 

1976 levels = [0, 1] 

1977 # Complete settings based on levels. 

1978 return None, levels, None 

1979 else: 

1980 # Define the levels and colors. 

1981 levels = [0, 1, 2] 

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

1983 # Create a custom color map. 

1984 cmap = mcolors.ListedColormap(colors) 

1985 # Normalize the levels. 

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

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

1988 return cmap, levels, norm 

1989 else: 

1990 if axis: 

1991 levels = [-1, 1] 

1992 return None, levels, None 

1993 else: 

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

1995 # not <=. 

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

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

1998 cmap = mcolors.ListedColormap(colors) 

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

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

2001 return cmap, levels, norm 

2002 

2003 

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

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

2006 

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

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

2009 

2010 Parameters 

2011 ---------- 

2012 cube: Cube 

2013 Cube of variable with Beaufort Scale in name. 

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

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

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

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

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

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

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

2021 

2022 Returns 

2023 ------- 

2024 cmap: 

2025 Matplotlib colormap. 

2026 levels: 

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

2028 should be taken as the range. 

2029 norm: 

2030 BoundaryNorm information. 

2031 """ 

2032 if "difference" not in cube.long_name: 

2033 if axis: 

2034 levels = [0, 12] 

2035 return None, levels, None 

2036 else: 

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

2038 colors = [ 

2039 "black", 

2040 (0, 0, 0.6), 

2041 "blue", 

2042 "cyan", 

2043 "green", 

2044 "yellow", 

2045 (1, 0.5, 0), 

2046 "red", 

2047 "pink", 

2048 "magenta", 

2049 "purple", 

2050 "maroon", 

2051 "white", 

2052 ] 

2053 cmap = mcolors.ListedColormap(colors) 

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

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

2056 return cmap, levels, norm 

2057 else: 

2058 if axis: 

2059 levels = [-4, 4] 

2060 return None, levels, None 

2061 else: 

2062 levels = [ 

2063 -3.5, 

2064 -2.5, 

2065 -1.5, 

2066 -0.5, 

2067 0.5, 

2068 1.5, 

2069 2.5, 

2070 3.5, 

2071 ] 

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

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

2074 return cmap, levels, norm 

2075 

2076 

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

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

2079 

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

2081 

2082 Parameters 

2083 ---------- 

2084 cube: Cube 

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

2086 cmap: Matplotlib colormap. 

2087 levels: List 

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

2089 should be taken as the range. 

2090 norm: BoundaryNorm. 

2091 

2092 Returns 

2093 ------- 

2094 cmap: Matplotlib colormap. 

2095 levels: List 

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

2097 should be taken as the range. 

2098 norm: BoundaryNorm. 

2099 """ 

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

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

2102 levels = np.array(levels) 

2103 levels -= 273 

2104 levels = levels.tolist() 

2105 else: 

2106 # Do nothing keep the existing colourbar attributes 

2107 levels = levels 

2108 cmap = cmap 

2109 norm = norm 

2110 return cmap, levels, norm 

2111 

2112 

2113def _custom_colormap_probability( 

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

2115): 

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

2117 

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

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

2120 

2121 Parameters 

2122 ---------- 

2123 cube: Cube 

2124 Cube of variable with probability in name. 

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

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

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

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

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

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

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

2132 

2133 Returns 

2134 ------- 

2135 cmap: 

2136 Matplotlib colormap. 

2137 levels: 

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

2139 should be taken as the range. 

2140 norm: 

2141 BoundaryNorm information. 

2142 """ 

2143 if axis: 

2144 levels = [0, 1] 

2145 return None, levels, None 

2146 else: 

2147 cmap = mcolors.ListedColormap( 

2148 [ 

2149 "#FFFFFF", 

2150 "#636363", 

2151 "#e1dada", 

2152 "#B5CAFF", 

2153 "#8FB3FF", 

2154 "#7F97FF", 

2155 "#ABCF63", 

2156 "#E8F59E", 

2157 "#FFFA14", 

2158 "#FFD121", 

2159 "#FFA30A", 

2160 ] 

2161 ) 

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

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

2164 return cmap, levels, norm 

2165 

2166 

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

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

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

2170 if ( 

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

2172 and "difference" not in cube.long_name 

2173 and "mask" not in cube.long_name 

2174 ): 

2175 # Define the levels and colors 

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

2177 colors = [ 

2178 "w", 

2179 (0, 0, 0.6), 

2180 "b", 

2181 "c", 

2182 "g", 

2183 "y", 

2184 (1, 0.5, 0), 

2185 "r", 

2186 "pink", 

2187 "m", 

2188 "purple", 

2189 "maroon", 

2190 "gray", 

2191 ] 

2192 # Create a custom colormap 

2193 cmap = mcolors.ListedColormap(colors) 

2194 # Normalize the levels 

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

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

2197 else: 

2198 # do nothing and keep existing colorbar attributes 

2199 cmap = cmap 

2200 levels = levels 

2201 norm = norm 

2202 return cmap, levels, norm 

2203 

2204 

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

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

2207 

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

2209 this function will be called. 

2210 

2211 Parameters 

2212 ---------- 

2213 cube: Cube 

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

2215 

2216 Returns 

2217 ------- 

2218 cmap: Matplotlib colormap. 

2219 levels: List 

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

2221 should be taken as the range. 

2222 norm: BoundaryNorm. 

2223 """ 

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

2225 colors = [ 

2226 "#87ceeb", 

2227 "#ffffff", 

2228 "#8ced69", 

2229 "#ffff00", 

2230 "#ffd700", 

2231 "#ffa500", 

2232 "#fe3620", 

2233 ] 

2234 # Create a custom colormap 

2235 cmap = mcolors.ListedColormap(colors) 

2236 # Normalise the levels 

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

2238 return cmap, levels, norm 

2239 

2240 

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

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

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

2244 if ( 

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

2246 and "difference" not in cube.long_name 

2247 and "mask" not in cube.long_name 

2248 ): 

2249 # Define the levels and colors (in km) 

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

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

2252 colours = [ 

2253 "#8f00d6", 

2254 "#d10000", 

2255 "#ff9700", 

2256 "#ffff00", 

2257 "#00007f", 

2258 "#6c9ccd", 

2259 "#aae8ff", 

2260 "#37a648", 

2261 "#8edc64", 

2262 "#c5ffc5", 

2263 "#dcdcdc", 

2264 "#ffffff", 

2265 ] 

2266 # Create a custom colormap 

2267 cmap = mcolors.ListedColormap(colours) 

2268 # Normalize the levels 

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

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

2271 else: 

2272 # do nothing and keep existing colorbar attributes 

2273 cmap = cmap 

2274 levels = levels 

2275 norm = norm 

2276 return cmap, levels, norm 

2277 

2278 

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

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

2281 model_names = list( 

2282 filter( 

2283 lambda x: x is not None, 

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

2285 ) 

2286 ) 

2287 if not model_names: 

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

2289 return 1 

2290 else: 

2291 return len(model_names) 

2292 

2293 

2294def _validate_cube_shape( 

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

2296) -> None: 

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

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

2299 raise ValueError( 

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

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

2302 ) 

2303 

2304 

2305def _validate_cubes_coords( 

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

2307) -> None: 

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

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

2310 raise ValueError( 

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

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

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

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

2315 ) 

2316 

2317 

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

2319# Public functions # 

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

2321 

2322 

2323def spatial_contour_plot( 

2324 cube: iris.cube.Cube, 

2325 filename: str = None, 

2326 sequence_coordinate: str = "time", 

2327 stamp_coordinate: str = "realization", 

2328 **kwargs, 

2329) -> iris.cube.Cube: 

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

2331 

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

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

2334 is present then postage stamp plots will be produced. 

2335 

2336 Parameters 

2337 ---------- 

2338 cube: Cube 

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

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

2341 plotted sequentially and/or as postage stamp plots. 

2342 filename: str, optional 

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

2344 to the recipe name. 

2345 sequence_coordinate: str, optional 

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

2347 This coordinate must exist in the cube. 

2348 stamp_coordinate: str, optional 

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

2350 ``"realization"``. 

2351 

2352 Returns 

2353 ------- 

2354 Cube 

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

2356 

2357 Raises 

2358 ------ 

2359 ValueError 

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

2361 TypeError 

2362 If the cube isn't a single cube. 

2363 """ 

2364 _spatial_plot( 

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

2366 ) 

2367 return cube 

2368 

2369 

2370def spatial_pcolormesh_plot( 

2371 cube: iris.cube.Cube, 

2372 filename: str = None, 

2373 sequence_coordinate: str = "time", 

2374 stamp_coordinate: str = "realization", 

2375 **kwargs, 

2376) -> iris.cube.Cube: 

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

2378 

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

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

2381 is present then postage stamp plots will be produced. 

2382 

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

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

2385 contour areas are important. 

2386 

2387 Parameters 

2388 ---------- 

2389 cube: Cube 

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

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

2392 plotted sequentially and/or as postage stamp plots. 

2393 filename: str, optional 

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

2395 to the recipe name. 

2396 sequence_coordinate: str, optional 

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

2398 This coordinate must exist in the cube. 

2399 stamp_coordinate: str, optional 

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

2401 ``"realization"``. 

2402 

2403 Returns 

2404 ------- 

2405 Cube 

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

2407 

2408 Raises 

2409 ------ 

2410 ValueError 

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

2412 TypeError 

2413 If the cube isn't a single cube. 

2414 """ 

2415 _spatial_plot( 

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

2417 ) 

2418 return cube 

2419 

2420 

2421def spatial_multi_pcolormesh_plot( 

2422 cube: iris.cube.Cube, 

2423 overlay_cube: iris.cube.Cube, 

2424 contour_cube: iris.cube.Cube, 

2425 filename: str = None, 

2426 sequence_coordinate: str = "time", 

2427 stamp_coordinate: str = "realization", 

2428 **kwargs, 

2429) -> iris.cube.Cube: 

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

2431 

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

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

2434 is present then postage stamp plots will be produced. 

2435 

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

2437 

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

2439 

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

2441 

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

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

2444 contour areas are important. 

2445 

2446 Parameters 

2447 ---------- 

2448 cube: Cube 

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

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

2451 plotted sequentially and/or as postage stamp plots. 

2452 overlay_cube: Cube 

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

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

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

2456 contour_cube: Cube 

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

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

2459 plotted sequentially and/or as postage stamp plots. 

2460 filename: str, optional 

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

2462 to the recipe name. 

2463 sequence_coordinate: str, optional 

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

2465 This coordinate must exist in the cube. 

2466 stamp_coordinate: str, optional 

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

2468 ``"realization"``. 

2469 

2470 Returns 

2471 ------- 

2472 Cube 

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

2474 

2475 Raises 

2476 ------ 

2477 ValueError 

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

2479 TypeError 

2480 If the cube isn't a single cube. 

2481 """ 

2482 _spatial_plot( 

2483 "pcolormesh", 

2484 cube, 

2485 filename, 

2486 sequence_coordinate, 

2487 stamp_coordinate, 

2488 overlay_cube=overlay_cube, 

2489 contour_cube=contour_cube, 

2490 ) 

2491 return cube, overlay_cube, contour_cube 

2492 

2493 

2494# TODO: Expand function to handle ensemble data. 

2495# line_coordinate: str, optional 

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

2497# ``"realization"``. 

2498def plot_line_series( 

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

2500 filename: str = None, 

2501 series_coordinate: str = "time", 

2502 # line_coordinate: str = "realization", 

2503 **kwargs, 

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

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

2506 

2507 The Cube or CubeList must be 1D. 

2508 

2509 Parameters 

2510 ---------- 

2511 iris.cube | iris.cube.CubeList 

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

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

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

2515 filename: str, optional 

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

2517 to the recipe name. 

2518 series_coordinate: str, optional 

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

2520 coordinate must exist in the cube. 

2521 

2522 Returns 

2523 ------- 

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

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

2526 plotted data. 

2527 

2528 Raises 

2529 ------ 

2530 ValueError 

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

2532 TypeError 

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

2534 """ 

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

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

2537 

2538 num_models = _get_num_models(cube) 

2539 

2540 _validate_cube_shape(cube, num_models) 

2541 

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

2543 cubes = iter_maybe(cube) 

2544 coords = [] 

2545 for cube in cubes: 

2546 try: 

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

2548 except iris.exceptions.CoordinateNotFoundError as err: 

2549 raise ValueError( 

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

2551 ) from err 

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

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

2554 

2555 # Format the title and filename using plotted series coordinate 

2556 nplot = 1 

2557 seq_coord = coords[0] 

2558 plot_title, plot_filename = _set_title_and_filename( 

2559 seq_coord, nplot, recipe_title, filename 

2560 ) 

2561 

2562 # Do the actual plotting. 

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

2564 

2565 # Add list of plots to plot metadata. 

2566 plot_index = _append_to_plot_index([plot_filename]) 

2567 

2568 # Make a page to display the plots. 

2569 _make_plot_html_page(plot_index) 

2570 

2571 return cube 

2572 

2573 

2574def plot_vertical_line_series( 

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

2576 filename: str = None, 

2577 series_coordinate: str = "model_level_number", 

2578 sequence_coordinate: str = "time", 

2579 # line_coordinate: str = "realization", 

2580 **kwargs, 

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

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

2583 

2584 The Cube or CubeList must be 1D. 

2585 

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

2587 then a sequence of plots will be produced. 

2588 

2589 Parameters 

2590 ---------- 

2591 iris.cube | iris.cube.CubeList 

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

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

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

2595 filename: str, optional 

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

2597 to the recipe name. 

2598 series_coordinate: str, optional 

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

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

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

2602 This coordinate must exist in the cube. 

2603 sequence_coordinate: str, optional 

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

2605 This coordinate must exist in the cube. 

2606 

2607 Returns 

2608 ------- 

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

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

2611 Plotted data. 

2612 

2613 Raises 

2614 ------ 

2615 ValueError 

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

2617 TypeError 

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

2619 """ 

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

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

2622 

2623 cubes = iter_maybe(cubes) 

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

2625 all_data = [] 

2626 

2627 # Store min/max ranges for x range. 

2628 x_levels = [] 

2629 

2630 num_models = _get_num_models(cubes) 

2631 

2632 _validate_cube_shape(cubes, num_models) 

2633 

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

2635 coords = [] 

2636 for cube in cubes: 

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

2638 try: 

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

2640 except iris.exceptions.CoordinateNotFoundError as err: 

2641 raise ValueError( 

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

2643 ) from err 

2644 

2645 try: 

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

2647 cube.coord(sequence_coordinate) 

2648 except iris.exceptions.CoordinateNotFoundError as err: 

2649 raise ValueError( 

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

2651 ) from err 

2652 

2653 # Get minimum and maximum from levels information. 

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

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

2656 x_levels.append(min(levels)) 

2657 x_levels.append(max(levels)) 

2658 else: 

2659 all_data.append(cube.data) 

2660 

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

2662 # Combine all data into a single NumPy array 

2663 combined_data = np.concatenate(all_data) 

2664 

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

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

2667 # sequence and if applicable postage stamp coordinate. 

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

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

2670 else: 

2671 vmin = min(x_levels) 

2672 vmax = max(x_levels) 

2673 

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

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

2676 # necessary) 

2677 def filter_cube_iterables(cube_iterables) -> bool: 

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

2679 

2680 cube_iterables = filter( 

2681 filter_cube_iterables, 

2682 ( 

2683 iris.cube.CubeList( 

2684 s 

2685 for s in itertools.chain.from_iterable( 

2686 cb.slices_over(sequence_coordinate) for cb in cubes 

2687 ) 

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

2689 ) 

2690 for point in sorted( 

2691 set( 

2692 itertools.chain.from_iterable( 

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

2694 ) 

2695 ) 

2696 ) 

2697 ), 

2698 ) 

2699 

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

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

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

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

2704 # or an iris.cube.CubeList. 

2705 plot_index = [] 

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

2707 for cubes_slice in cube_iterables: 

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

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

2710 plot_title, plot_filename = _set_title_and_filename( 

2711 seq_coord, nplot, recipe_title, filename 

2712 ) 

2713 

2714 # Do the actual plotting. 

2715 _plot_and_save_vertical_line_series( 

2716 cubes_slice, 

2717 coords, 

2718 "realization", 

2719 plot_filename, 

2720 series_coordinate, 

2721 title=plot_title, 

2722 vmin=vmin, 

2723 vmax=vmax, 

2724 ) 

2725 plot_index.append(plot_filename) 

2726 

2727 # Add list of plots to plot metadata. 

2728 complete_plot_index = _append_to_plot_index(plot_index) 

2729 

2730 # Make a page to display the plots. 

2731 _make_plot_html_page(complete_plot_index) 

2732 

2733 return cubes 

2734 

2735 

2736def qq_plot( 

2737 cubes: iris.cube.CubeList, 

2738 coordinates: list[str], 

2739 percentiles: list[float], 

2740 model_names: list[str], 

2741 filename: str = None, 

2742 one_to_one: bool = True, 

2743 **kwargs, 

2744) -> iris.cube.CubeList: 

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

2746 

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

2748 collapsed within the operator over all specified coordinates such as 

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

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

2751 

2752 Parameters 

2753 ---------- 

2754 cubes: iris.cube.CubeList 

2755 Two cubes of the same variable with different models. 

2756 coordinate: list[str] 

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

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

2759 the percentile coordinate. 

2760 percent: list[float] 

2761 A list of percentiles to appear in the plot. 

2762 model_names: list[str] 

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

2764 filename: str, optional 

2765 Filename of the plot to write. 

2766 one_to_one: bool, optional 

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

2768 

2769 Raises 

2770 ------ 

2771 ValueError 

2772 When the cubes are not compatible. 

2773 

2774 Notes 

2775 ----- 

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

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

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

2779 compares percentiles of two datasets. This plot does 

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

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

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

2783 

2784 Quantile-quantile plots are valuable for comparing against 

2785 observations and other models. Identical percentiles between the variables 

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

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

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

2789 Wilks 2011 [Wilks2011]_). 

2790 

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

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

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

2794 the extremes. 

2795 

2796 References 

2797 ---------- 

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

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

2800 """ 

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

2802 if len(cubes) != 2: 

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

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

2805 other: Cube = cubes.extract_cube( 

2806 iris.Constraint( 

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

2808 ) 

2809 ) 

2810 

2811 # Get spatial coord names. 

2812 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

2813 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

2814 

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

2816 # This is triggered if either 

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

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

2819 # errors. 

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

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

2822 # for UM and LFRic comparisons. 

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

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

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

2826 # given this dependency on regridding. 

2827 if ( 

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

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

2830 ) or ( 

2831 base.long_name 

2832 in [ 

2833 "eastward_wind_at_10m", 

2834 "northward_wind_at_10m", 

2835 "northward_wind_at_cell_centres", 

2836 "eastward_wind_at_cell_centres", 

2837 "zonal_wind_at_pressure_levels", 

2838 "meridional_wind_at_pressure_levels", 

2839 "potential_vorticity_at_pressure_levels", 

2840 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

2841 ] 

2842 ): 

2843 logging.debug( 

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

2845 ) 

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

2847 

2848 # Extract just common time points. 

2849 base, other = _extract_common_time_points(base, other) 

2850 

2851 # Equalise attributes so we can merge. 

2852 fully_equalise_attributes([base, other]) 

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

2854 

2855 # Collapse cubes. 

2856 base = collapse( 

2857 base, 

2858 coordinate=coordinates, 

2859 method="PERCENTILE", 

2860 additional_percent=percentiles, 

2861 ) 

2862 other = collapse( 

2863 other, 

2864 coordinate=coordinates, 

2865 method="PERCENTILE", 

2866 additional_percent=percentiles, 

2867 ) 

2868 

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

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

2871 title = f"{recipe_title}" 

2872 

2873 if filename is None: 

2874 filename = slugify(recipe_title) 

2875 

2876 # Add file extension. 

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

2878 

2879 # Do the actual plotting on a scatter plot 

2880 _plot_and_save_scatter_plot( 

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

2882 ) 

2883 

2884 # Add list of plots to plot metadata. 

2885 plot_index = _append_to_plot_index([plot_filename]) 

2886 

2887 # Make a page to display the plots. 

2888 _make_plot_html_page(plot_index) 

2889 

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

2891 

2892 

2893def scatter_plot( 

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

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

2896 filename: str = None, 

2897 one_to_one: bool = True, 

2898 **kwargs, 

2899) -> iris.cube.CubeList: 

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

2901 

2902 Both cubes must be 1D. 

2903 

2904 Parameters 

2905 ---------- 

2906 cube_x: Cube | CubeList 

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

2908 cube_y: Cube | CubeList 

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

2910 filename: str, optional 

2911 Filename of the plot to write. 

2912 one_to_one: bool, optional 

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

2914 

2915 Returns 

2916 ------- 

2917 cubes: CubeList 

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

2919 

2920 Raises 

2921 ------ 

2922 ValueError 

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

2924 size. 

2925 TypeError 

2926 If the cube isn't a single cube. 

2927 

2928 Notes 

2929 ----- 

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

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

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

2933 """ 

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

2935 for cube_iter in iter_maybe(cube_x): 

2936 # Check cubes are correct shape. 

2937 cube_iter = _check_single_cube(cube_iter) 

2938 if cube_iter.ndim > 1: 

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

2940 

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

2942 for cube_iter in iter_maybe(cube_y): 

2943 # Check cubes are correct shape. 

2944 cube_iter = _check_single_cube(cube_iter) 

2945 if cube_iter.ndim > 1: 

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

2947 

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

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

2950 title = f"{recipe_title}" 

2951 

2952 if filename is None: 

2953 filename = slugify(recipe_title) 

2954 

2955 # Add file extension. 

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

2957 

2958 # Do the actual plotting. 

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

2960 

2961 # Add list of plots to plot metadata. 

2962 plot_index = _append_to_plot_index([plot_filename]) 

2963 

2964 # Make a page to display the plots. 

2965 _make_plot_html_page(plot_index) 

2966 

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

2968 

2969 

2970def vector_plot( 

2971 cube_u: iris.cube.Cube, 

2972 cube_v: iris.cube.Cube, 

2973 filename: str = None, 

2974 sequence_coordinate: str = "time", 

2975 **kwargs, 

2976) -> iris.cube.CubeList: 

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

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

2979 

2980 # Cubes must have a matching sequence coordinate. 

2981 try: 

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

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

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

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

2986 raise ValueError( 

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

2988 ) from err 

2989 

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

2991 plot_index = [] 

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

2993 for cube_u_slice, cube_v_slice in zip( 

2994 cube_u.slices_over(sequence_coordinate), 

2995 cube_v.slices_over(sequence_coordinate), 

2996 strict=True, 

2997 ): 

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

2999 seq_coord = cube_u_slice.coord(sequence_coordinate) 

3000 plot_title, plot_filename = _set_title_and_filename( 

3001 seq_coord, nplot, recipe_title, filename 

3002 ) 

3003 

3004 # Do the actual plotting. 

3005 _plot_and_save_vector_plot( 

3006 cube_u_slice, 

3007 cube_v_slice, 

3008 filename=plot_filename, 

3009 title=plot_title, 

3010 method="contourf", 

3011 ) 

3012 plot_index.append(plot_filename) 

3013 

3014 # Add list of plots to plot metadata. 

3015 complete_plot_index = _append_to_plot_index(plot_index) 

3016 

3017 # Make a page to display the plots. 

3018 _make_plot_html_page(complete_plot_index) 

3019 

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

3021 

3022 

3023def plot_histogram_series( 

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

3025 filename: str = None, 

3026 sequence_coordinate: str = "time", 

3027 stamp_coordinate: str = "realization", 

3028 single_plot: bool = False, 

3029 **kwargs, 

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

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

3032 

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

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

3035 functionality to scroll through histograms against time. If a 

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

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

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

3039 

3040 Parameters 

3041 ---------- 

3042 cubes: Cube | iris.cube.CubeList 

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

3044 than the stamp coordinate. 

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

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

3047 filename: str, optional 

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

3049 to the recipe name. 

3050 sequence_coordinate: str, optional 

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

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

3053 slider. 

3054 stamp_coordinate: str, optional 

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

3056 ``"realization"``. 

3057 single_plot: bool, optional 

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

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

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

3061 

3062 Returns 

3063 ------- 

3064 iris.cube.Cube | iris.cube.CubeList 

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

3066 Plotted data. 

3067 

3068 Raises 

3069 ------ 

3070 ValueError 

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

3072 TypeError 

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

3074 """ 

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

3076 

3077 cubes = iter_maybe(cubes) 

3078 

3079 # Internal plotting function. 

3080 plotting_func = _plot_and_save_histogram_series 

3081 

3082 num_models = _get_num_models(cubes) 

3083 

3084 _validate_cube_shape(cubes, num_models) 

3085 

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

3087 # time slider option. 

3088 for cube in cubes: 

3089 try: 

3090 cube.coord(sequence_coordinate) 

3091 except iris.exceptions.CoordinateNotFoundError as err: 

3092 raise ValueError( 

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

3094 ) from err 

3095 

3096 # Get minimum and maximum from levels information. 

3097 levels = None 

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

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

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

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

3102 if levels is None: 

3103 break 

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

3105 # levels-based ranges for histogram plots. 

3106 _, levels, _ = _colorbar_map_levels(cube) 

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

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

3109 vmin = min(levels) 

3110 vmax = max(levels) 

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

3112 break 

3113 

3114 if levels is None: 

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

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

3117 

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

3119 # single point. If single_plot is True: 

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

3121 # separate postage stamp plots. 

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

3123 # produced per single model only 

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

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

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

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

3128 ): 

3129 if single_plot: 

3130 plotting_func = ( 

3131 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

3132 ) 

3133 else: 

3134 plotting_func = _plot_and_save_postage_stamp_histogram_series 

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

3136 else: 

3137 all_points = sorted( 

3138 set( 

3139 itertools.chain.from_iterable( 

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

3141 ) 

3142 ) 

3143 ) 

3144 all_slices = list( 

3145 itertools.chain.from_iterable( 

3146 cb.slices_over(sequence_coordinate) for cb in cubes 

3147 ) 

3148 ) 

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

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

3151 # necessary) 

3152 cube_iterables = [ 

3153 iris.cube.CubeList( 

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

3155 ) 

3156 for point in all_points 

3157 ] 

3158 

3159 plot_index = [] 

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

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

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

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

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

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

3166 for cube_slice in cube_iterables: 

3167 single_cube = cube_slice 

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

3169 single_cube = cube_slice[0] 

3170 

3171 # Ensure valid stamp coordinate in cube dimensions 

3172 stamp_coordinate = check_stamp_coordinate(single_cube) 

3173 # Set plot titles and filename, based on sequence coordinate 

3174 seq_coord = single_cube.coord(sequence_coordinate) 

3175 # Use time coordinate in title and filename if single histogram output. 

3176 if sequence_coordinate == "realization" and nplot == 1: 3176 ↛ 3177line 3176 didn't jump to line 3177 because the condition on line 3176 was never true

3177 seq_coord = single_cube.coord("time") 

3178 plot_title, plot_filename = _set_title_and_filename( 

3179 seq_coord, nplot, recipe_title, filename 

3180 ) 

3181 

3182 # Do the actual plotting. 

3183 plotting_func( 

3184 cube_slice, 

3185 filename=plot_filename, 

3186 stamp_coordinate=stamp_coordinate, 

3187 title=plot_title, 

3188 vmin=vmin, 

3189 vmax=vmax, 

3190 ) 

3191 plot_index.append(plot_filename) 

3192 

3193 # Add list of plots to plot metadata. 

3194 complete_plot_index = _append_to_plot_index(plot_index) 

3195 

3196 # Make a page to display the plots. 

3197 _make_plot_html_page(complete_plot_index) 

3198 

3199 return cubes 

3200 

3201 

3202def plot_power_spectrum_series( 

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

3204 filename: str = None, 

3205 sequence_coordinate: str = "time", 

3206 stamp_coordinate: str = "realization", 

3207 single_plot: bool = False, 

3208 **kwargs, 

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

3210 """Plot a power spectrum plot for each vertical level provided. 

3211 

3212 A power spectrum plot can be plotted, but if the sequence_coordinate (i.e. time) 

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

3214 functionality to scroll through power spectrum against time. If a 

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

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

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

3218 

3219 Parameters 

3220 ---------- 

3221 cubes: Cube | iris.cube.CubeList 

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

3223 than the stamp coordinate. 

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

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

3226 filename: str, optional 

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

3228 to the recipe name. 

3229 sequence_coordinate: str, optional 

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

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

3232 slider. 

3233 stamp_coordinate: str, optional 

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

3235 ``"realization"``. 

3236 single_plot: bool, optional 

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

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

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

3240 

3241 Returns 

3242 ------- 

3243 iris.cube.Cube | iris.cube.CubeList 

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

3245 Plotted data. 

3246 

3247 Raises 

3248 ------ 

3249 ValueError 

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

3251 TypeError 

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

3253 """ 

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

3255 

3256 cubes = iter_maybe(cubes) 

3257 

3258 # Internal plotting function. 

3259 plotting_func = _plot_and_save_power_spectrum_series 

3260 

3261 num_models = _get_num_models(cubes) 

3262 

3263 _validate_cube_shape(cubes, num_models) 

3264 

3265 # If several power spectra are plotted with time as sequence_coordinate for the 

3266 # time slider option. 

3267 for cube in cubes: 

3268 try: 

3269 cube.coord(sequence_coordinate) 

3270 except iris.exceptions.CoordinateNotFoundError as err: 

3271 raise ValueError( 

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

3273 ) from err 

3274 

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

3276 # single point. If single_plot is True: 

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

3278 # separate postage stamp plots. 

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

3280 # produced per single model only 

3281 if num_models == 1: 3281 ↛ 3294line 3281 didn't jump to line 3294 because the condition on line 3281 was always true

3282 if ( 3282 ↛ 3286line 3282 didn't jump to line 3286 because the condition on line 3282 was never true

3283 stamp_coordinate in [c.name() for c in cubes[0].coords()] 

3284 and cubes[0].coord(stamp_coordinate).shape[0] > 1 

3285 ): 

3286 if single_plot: 

3287 plotting_func = ( 

3288 _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series 

3289 ) 

3290 else: 

3291 plotting_func = _plot_and_save_postage_stamp_power_spectrum_series 

3292 cube_iterables = cubes[0].slices_over(sequence_coordinate) 

3293 else: 

3294 all_points = sorted( 

3295 set( 

3296 itertools.chain.from_iterable( 

3297 cb.coord(sequence_coordinate).points for cb in cubes 

3298 ) 

3299 ) 

3300 ) 

3301 all_slices = list( 

3302 itertools.chain.from_iterable( 

3303 cb.slices_over(sequence_coordinate) for cb in cubes 

3304 ) 

3305 ) 

3306 # Matched slices (matched by seq coord point; it may happen that 

3307 # evaluated models do not cover the same seq coord range, hence matching 

3308 # necessary) 

3309 cube_iterables = [ 

3310 iris.cube.CubeList( 

3311 s for s in all_slices if s.coord(sequence_coordinate).points[0] == point 

3312 ) 

3313 for point in all_points 

3314 ] 

3315 

3316 plot_index = [] 

3317 nplot = np.size(cube.coord(sequence_coordinate).points) 

3318 # Create a plot for each value of the sequence coordinate. Allowing for 

3319 # multiple cubes in a CubeList to be plotted in the same plot for similar 

3320 # sequence values. Passing a CubeList into the internal plotting function 

3321 # for similar values of the sequence coordinate. cube_slice can be an 

3322 # iris.cube.Cube or an iris.cube.CubeList. 

3323 for cube_slice in cube_iterables: 

3324 single_cube = cube_slice 

3325 if isinstance(cube_slice, iris.cube.CubeList): 3325 ↛ 3326line 3325 didn't jump to line 3326 because the condition on line 3325 was never true

3326 single_cube = cube_slice[0] 

3327 

3328 # Set stamp coordinate 

3329 stamp_coordinate = check_stamp_coordinate(single_cube) 

3330 # Set plot title and filenames based on sequence values 

3331 seq_coord = single_cube.coord(sequence_coordinate) 

3332 plot_title, plot_filename = _set_title_and_filename( 

3333 seq_coord, nplot, recipe_title, filename 

3334 ) 

3335 

3336 # Do the actual plotting. 

3337 plotting_func( 

3338 cube_slice, 

3339 filename=plot_filename, 

3340 stamp_coordinate=stamp_coordinate, 

3341 title=plot_title, 

3342 ) 

3343 plot_index.append(plot_filename) 

3344 

3345 # Add list of plots to plot metadata. 

3346 complete_plot_index = _append_to_plot_index(plot_index) 

3347 

3348 # Make a page to display the plots. 

3349 _make_plot_html_page(complete_plot_index) 

3350 

3351 return cubes 

3352 

3353 

3354def _DCT_ps(y_3d): 

3355 """Calculate power spectra for regional domains. 

3356 

3357 Parameters 

3358 ---------- 

3359 y_3d: 3D array 

3360 3 dimensional array to calculate spectrum for. 

3361 (2D field data with 3rd dimension of time) 

3362 

3363 Returns: ps_array 

3364 Array of power spectra values calculated for input field (for each time) 

3365 

3366 Method for regional domains: 

3367 Calculate power spectra over limited area domain using Discrete Cosine Transform (DCT) 

3368 as described in Denis et al 2002 [Denis_etal_2002]_. 

3369 

3370 References 

3371 ---------- 

3372 .. [Denis_etal_2002] Bertrand Denis, Jean Côté and René Laprise (2002) 

3373 "Spectral Decomposition of Two-Dimensional Atmospheric Fields on 

3374 Limited-Area Domains Using the Discrete Cosine Transform (DCT)" 

3375 Monthly Weather Review, Vol. 130, 1812-1828 

3376 doi: https://doi.org/10.1175/1520-0493(2002)130<1812:SDOTDA>2.0.CO;2 

3377 """ 

3378 Nt, Ny, Nx = y_3d.shape 

3379 

3380 # Max coefficient 

3381 Nmin = min(Nx - 1, Ny - 1) 

3382 

3383 # Create alpha matrix (of wavenumbers) 

3384 alpha_matrix = _create_alpha_matrix(Ny, Nx) 

3385 

3386 # Prepare output array 

3387 ps_array = np.zeros((Nt, Nmin)) 

3388 

3389 # Loop over time to get spectrum for each time. 

3390 for t in range(Nt): 

3391 y_2d = y_3d[t] 

3392 

3393 # Apply 2D DCT to transform y_3d[t] from physical space to spectral space. 

3394 # fkk is a 2D array of DCT coefficients, representing the amplitudes of 

3395 # cosine basis functions at different spatial frequencies. 

3396 

3397 # normalise spectrum to allow comparison between models. 

3398 fkk = fft.dctn(y_2d, norm="ortho") 

3399 

3400 # Normalise fkk 

3401 fkk = fkk / np.sqrt(Ny * Nx) 

3402 

3403 # calculate variance of spectral coefficient 

3404 sigma_2 = fkk**2 / Nx / Ny 

3405 

3406 # Group ellipses of alphas into the same wavenumber k/Nmin 

3407 for k in range(1, Nmin + 1): 

3408 alpha = k / Nmin 

3409 alpha_p1 = (k + 1) / Nmin 

3410 

3411 # Sum up elements matching k 

3412 mask_k = np.where((alpha_matrix >= alpha) & (alpha_matrix < alpha_p1)) 

3413 ps_array[t, k - 1] = np.sum(sigma_2[mask_k]) 

3414 

3415 return ps_array 

3416 

3417 

3418def _create_alpha_matrix(Ny, Nx): 

3419 """Construct an array of 2D wavenumbers from 2D wavenumber pair. 

3420 

3421 Parameters 

3422 ---------- 

3423 Ny, Nx: 

3424 Dimensions of the 2D field for which the power spectra is calculated. Used to 

3425 create the array of 2D wavenumbers. Each Ny, Nx pair is associated with a 

3426 single-scale parameter. 

3427 

3428 Returns: alpha_matrix 

3429 normalisation of 2D wavenumber axes, transforming the spectral domain into 

3430 an elliptic coordinate system. 

3431 

3432 """ 

3433 # Create x_indices: each row is [1, 2, ..., Nx] 

3434 x_indices = np.tile(np.arange(1, Nx + 1), (Ny, 1)) 

3435 

3436 # Create y_indices: each column is [1, 2, ..., Ny] 

3437 y_indices = np.tile(np.arange(1, Ny + 1).reshape(Ny, 1), (1, Nx)) 

3438 

3439 # Compute alpha_matrix 

3440 alpha_matrix = np.sqrt((x_indices**2) / Nx**2 + (y_indices**2) / Ny**2) 

3441 

3442 return alpha_matrix