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

1092 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-16 13:58 +0000

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

2# 

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

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

5# You may obtain a copy of the License at 

6# 

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

8# 

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

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

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

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

13# limitations under the License. 

14 

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

16 

17import fcntl 

18import functools 

19import importlib.resources 

20import itertools 

21import json 

22import logging 

23import math 

24import os 

25from typing import Literal 

26 

27import cartopy.crs as ccrs 

28import cartopy.feature as cfeature 

29import iris 

30import iris.coords 

31import iris.cube 

32import iris.exceptions 

33import iris.plot as iplt 

34import matplotlib as mpl 

35import matplotlib.colors as mcolors 

36import matplotlib.pyplot as plt 

37import numpy as np 

38import scipy.fft as fft 

39from cartopy.mpl.geoaxes import GeoAxes 

40from iris.cube import Cube 

41from markdown_it import MarkdownIt 

42from mpl_toolkits.axes_grid1.inset_locator import inset_axes 

43 

44from CSET._common import ( 

45 combine_dicts, 

46 filename_slugify, 

47 get_recipe_metadata, 

48 iter_maybe, 

49 render_file, 

50 slugify, 

51) 

52from CSET.operators._utils import ( 

53 check_stamp_coordinate, 

54 fully_equalise_attributes, 

55 get_cube_yxcoordname, 

56 is_transect, 

57 slice_over_maybe, 

58) 

59from CSET.operators.collapse import collapse 

60from CSET.operators.misc import _extract_common_time_points 

61from CSET.operators.regrid import regrid_onto_cube 

62 

63# Use a non-interactive plotting backend. 

64mpl.use("agg") 

65 

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

67 

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

69# Private helper functions # 

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

71 

72 

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

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

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

76 fcntl.flock(fp, fcntl.LOCK_EX) 

77 fp.seek(0) 

78 meta = json.load(fp) 

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

80 complete_plot_index = complete_plot_index + plot_index 

81 meta["plots"] = complete_plot_index 

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

83 os.getenv("DO_CASE_AGGREGATION") 

84 ): 

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

86 fp.seek(0) 

87 fp.truncate() 

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

89 return complete_plot_index 

90 

91 

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

93 """Ensure a single cube is given. 

94 

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

96 otherwise an error is raised. 

97 

98 Parameters 

99 ---------- 

100 cube: Cube | CubeList 

101 The cube to check. 

102 

103 Returns 

104 ------- 

105 cube: Cube 

106 The checked cube. 

107 

108 Raises 

109 ------ 

110 TypeError 

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

112 """ 

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

114 return cube 

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

116 if len(cube) == 1: 

117 return cube[0] 

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

119 

120 

121def _make_plot_html_page(plots: list): 

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

123 # Debug check that plots actually contains some strings. 

124 assert isinstance(plots[0], str) 

125 

126 # Load HTML template file. 

127 operator_files = importlib.resources.files() 

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

129 

130 # Get some metadata. 

131 meta = get_recipe_metadata() 

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

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

134 

135 # Prepare template variables. 

136 variables = { 

137 "title": title, 

138 "description": description, 

139 "initial_plot": plots[0], 

140 "plots": plots, 

141 "title_slug": slugify(title), 

142 } 

143 

144 # Render template. 

145 html = render_file(template_file, **variables) 

146 

147 # Save completed HTML. 

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

149 fp.write(html) 

150 

151 

152@functools.cache 

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

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

155 

156 This is a separate function to make it cacheable. 

157 """ 

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

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

160 colorbar = json.load(fp) 

161 

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

163 override_colorbar = {} 

164 if user_colorbar_file: 

165 try: 

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

167 override_colorbar = json.load(fp) 

168 except FileNotFoundError: 

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

170 

171 # Overwrite values with the user supplied colorbar definition. 

172 colorbar = combine_dicts(colorbar, override_colorbar) 

173 return colorbar 

174 

175 

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

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

178 

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

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

181 to model_name attribute. 

182 

183 Parameters 

184 ---------- 

185 cubes: CubeList or Cube 

186 Cubes with model_name attribute 

187 

188 Returns 

189 ------- 

190 model_colors_map: 

191 Dictionary mapping model_name attribute to colors 

192 """ 

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

194 colorbar = _load_colorbar_map(user_colorbar_file) 

195 model_names = sorted( 

196 filter( 

197 lambda x: x is not None, 

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

199 ) 

200 ) 

201 if not model_names: 

202 return {} 

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

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

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

206 

207 color_list = itertools.cycle(DEFAULT_DISCRETE_COLORS) 

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

209 

210 

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

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

213 

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

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

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

217 exist for specific pressure levels to account for variables with 

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

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

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

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

222 

223 Parameters 

224 ---------- 

225 cube: Cube 

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

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

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

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

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

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

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

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

234 

235 Returns 

236 ------- 

237 cmap: 

238 Matplotlib colormap. 

239 levels: 

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

241 should be taken as the range. 

242 norm: 

243 BoundaryNorm information. 

244 """ 

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

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

247 colorbar = _load_colorbar_map(user_colorbar_file) 

248 cmap = None 

249 

250 try: 

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

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

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

254 pressure_level = str(int(pressure_level_raw)) 

255 except iris.exceptions.CoordinateNotFoundError: 

256 pressure_level = None 

257 

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

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

260 # consistent. 

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

262 for varname in varnames: 

263 # Get the colormap for this variable. 

264 try: 

265 var_colorbar = colorbar[varname] 

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

267 varname_key = varname 

268 break 

269 except KeyError: 

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

271 

272 # Get colormap if it is a mask. 

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

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

275 return cmap, levels, norm 

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

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

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

279 return cmap, levels, norm 

280 # If probability is plotted use custom colorbar and levels 

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

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

283 return cmap, levels, norm 

284 # If aviation colour state use custom colorbar and levels 

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

286 cmap, levels, norm = _custom_colormap_aviation_colour_state(cube) 

287 return cmap, levels, norm 

288 if any("RMSE_" in name for name in varnames): 

289 # Variables levels and norm set to None to use specific colorbar routine. 

290 levels = None 

291 norm = None 

292 cmap, levels, norm = _custom_colormap_scores(cube, cmap, levels, norm) 

293 return cmap, levels, norm 

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

295 if not cmap: 

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

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

298 return cmap, levels, norm 

299 

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

301 if pressure_level: 

302 try: 

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

304 except KeyError: 

305 logging.debug( 

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

307 varname, 

308 pressure_level, 

309 ) 

310 

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

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

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

314 if axis: 

315 if axis == "x": 

316 try: 

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

318 except KeyError: 

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

320 if axis == "y": 

321 try: 

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

323 except KeyError: 

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

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

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

327 levels = None 

328 else: 

329 levels = [vmin, vmax] 

330 return None, levels, None 

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

332 else: 

333 try: 

334 levels = var_colorbar["levels"] 

335 # Use discrete bins when levels are specified, rather 

336 # than a smooth range. 

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

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

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

340 except KeyError: 

341 # Get the range for this variable. 

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

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

344 # Calculate levels from range. 

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

346 levels = None 

347 else: 

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

349 norm = None 

350 

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

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

353 # JSON file. 

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

355 cmap, levels, norm = _custom_colourmap_visibility_in_air( 

356 cube, cmap, levels, norm 

357 ) 

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

359 return cmap, levels, norm 

360 

361 

362def _custom_colormap_scores(cube: iris.cube.Cube, cmap, levels, norm): 

363 """Return altered colourmap for statistical metrics. 

364 

365 Parameters 

366 ---------- 

367 cube: Cube 

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

369 cmap: Matplotlib colormap. 

370 levels: List 

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

372 should be taken as the range. 

373 norm: BoundaryNorm. 

374 

375 Returns 

376 ------- 

377 cmap: Matplotlib colormap. 

378 levels: List 

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

380 should be taken as the range. 

381 norm: BoundaryNorm. 

382 """ 

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

384 if any("RMSE_" in name for name in varnames): 384 ↛ 386line 384 didn't jump to line 386 because the condition on line 384 was always true

385 cmap = plt.get_cmap("PuRd", 51) 

386 return cmap, levels, norm 

387 

388 

389def _setup_spatial_map( 

390 cube: iris.cube.Cube, 

391 figure, 

392 cmap, 

393 grid_size: tuple[int, int] | None = None, 

394 subplot: int | None = None, 

395): 

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

397 

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

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

400 

401 Parameters 

402 ---------- 

403 cube: Cube 

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

405 figure: 

406 Matplotlib Figure object holding all plot elements. 

407 cmap: 

408 Matplotlib colormap. 

409 grid_size: (int, int), optional 

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

411 subplot: int, optional 

412 Subplot index if multiple spatial subplots in figure. 

413 

414 Returns 

415 ------- 

416 axes: 

417 Matplotlib GeoAxes definition. 

418 """ 

419 # Identify min/max plot bounds. 

420 try: 

421 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

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

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

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

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

426 

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

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

429 x1 = x1 - 180.0 

430 x2 = x2 - 180.0 

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

432 

433 # Consider map projection orientation. 

434 # Adapting orientation enables plotting across international dateline. 

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

436 if x2 > 180.0 or x1 < -180.0: 

437 central_longitude = 180.0 

438 else: 

439 central_longitude = 0.0 

440 

441 # Define spatial map projection. 

442 coord_system = cube.coord(lat_axis).coord_system 

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

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

445 projection = ccrs.RotatedPole( 

446 pole_longitude=coord_system.grid_north_pole_longitude, 

447 pole_latitude=coord_system.grid_north_pole_latitude, 

448 central_rotated_longitude=central_longitude, 

449 ) 

450 crs = projection 

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

452 # Define Transverse Mercator projection for TM inputs. 

453 projection = ccrs.TransverseMercator( 

454 central_longitude=coord_system.longitude_of_central_meridian, 

455 central_latitude=coord_system.latitude_of_projection_origin, 

456 false_easting=coord_system.false_easting, 

457 false_northing=coord_system.false_northing, 

458 scale_factor=coord_system.scale_factor_at_central_meridian, 

459 ) 

460 crs = projection 

461 else: 

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

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

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

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

466 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

467 crs = ccrs.PlateCarree() 

468 

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

470 if subplot is not None: 

471 axes = figure.add_subplot( 

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

473 ) 

474 else: 

475 axes = figure.add_subplot(projection=projection) 

476 

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

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

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

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

481 ): 

482 pass 

483 else: 

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

485 coastcol = "magenta" 

486 else: 

487 coastcol = "black" 

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

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

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

491 

492 # Add gridlines. 

493 gl = axes.gridlines( 

494 alpha=0.3, 

495 draw_labels=True, 

496 dms=False, 

497 x_inline=False, 

498 y_inline=False, 

499 ) 

500 gl.top_labels = False 

501 gl.right_labels = False 

502 if subplot: 

503 gl.bottom_labels = False 

504 gl.left_labels = False 

505 if subplot % grid_size[1] == 1: 

506 gl.left_labels = True 

507 if subplot > ((grid_size[0] - 1) * grid_size[1]): 507 ↛ 512line 507 didn't jump to line 512 because the condition on line 507 was always true

508 gl.bottom_labels = True 

509 

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

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

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

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

514 

515 except ValueError: 

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

517 axes = figure.gca() 

518 pass 

519 

520 return axes 

521 

522 

523def _get_plot_resolution() -> int: 

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

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

526 

527 

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

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

530 if use_bounds and seq_coord.has_bounds(): 

531 vals = seq_coord.bounds.flatten() 

532 else: 

533 vals = seq_coord.points 

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

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

536 

537 if start == end: 

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

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

540 else: 

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

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

543 

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

545 if ( 

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

547 and vals[0] == 0 

548 and vals[-1] == 0 

549 ): 

550 sequence_title = "" 

551 sequence_fname = "" 

552 

553 return sequence_title, sequence_fname 

554 

555 

556def _set_title_and_filename( 

557 seq_coord: iris.coords.Coord, 

558 nplot: int, 

559 recipe_title: str, 

560 filename: str, 

561): 

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

563 

564 Parameters 

565 ---------- 

566 sequence_coordinate: iris.coords.Coord 

567 Coordinate about which to make a plot sequence. 

568 nplot: int 

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

570 recipe_title: str 

571 Default plot title, potentially to update. 

572 filename: str 

573 Input plot filename, potentially to update. 

574 

575 Returns 

576 ------- 

577 plot_title: str 

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

579 plot_filename: str 

580 Output formatted plot filename string. 

581 """ 

582 ndim = seq_coord.ndim 

583 npoints = np.size(seq_coord.points) 

584 sequence_title = "" 

585 sequence_fname = "" 

586 

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

588 # (e.g. aggregation histogram plots) 

589 if ndim > 1: 

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

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

592 sequence_fname = f"_{ncase}cases" 

593 

594 # Case 2: Single dimension input 

595 else: 

596 # Single sequence point 

597 if npoints == 1: 

598 if nplot > 1: 

599 # Default labels for sequence inputs 

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

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

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

603 else: 

604 # Aggregated attribute available where input collapsed over aggregation 

605 try: 

606 ncase = seq_coord.attributes["number_reference_times"] 

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

608 sequence_fname = f"_{ncase}cases" 

609 except KeyError: 

610 sequence_title, sequence_fname = _get_start_end_strings( 

611 seq_coord, use_bounds=seq_coord.has_bounds() 

612 ) 

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

614 else: 

615 sequence_title, sequence_fname = _get_start_end_strings( 

616 seq_coord, use_bounds=False 

617 ) 

618 

619 # Set plot title and filename 

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

621 

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

623 if filename is None: 

624 filename = slugify(recipe_title) 

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

626 else: 

627 if nplot > 1: 

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

629 else: 

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

631 

632 return plot_title, plot_filename 

633 

634 

635def _set_postage_stamp_title(stamp_coord: iris.coords.Coord) -> str: 

636 """Control postage stamp plot output titles based on stamp coordinate.""" 

637 if stamp_coord.name() == "realization": 

638 mtitle = "Member" 

639 else: 

640 mtitle = stamp_coord.name().capitalize() 

641 

642 if stamp_coord.name() == "time": 

643 mtitle = f"{stamp_coord.units.title(stamp_coord.points[0])}" 

644 else: 

645 mtitle = f"{mtitle} #{stamp_coord.points[0]}" 

646 

647 return mtitle 

648 

649 

650def _plot_and_save_spatial_plot( 

651 cube: iris.cube.Cube, 

652 filename: str, 

653 title: str, 

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

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

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

657 **kwargs, 

658): 

659 """Plot and save a spatial plot. 

660 

661 Parameters 

662 ---------- 

663 cube: Cube 

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

665 filename: str 

666 Filename of the plot to write. 

667 title: str 

668 Plot title. 

669 method: "contourf" | "pcolormesh" 

670 The plotting method to use. 

671 overlay_cube: Cube, optional 

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

673 contour_cube: Cube, optional 

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

675 """ 

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

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

678 

679 # Specify the color bar 

680 cmap, levels, norm = _colorbar_map_levels(cube) 

681 

682 # If overplotting, set required colorbars 

683 if overlay_cube: 

684 over_cmap, over_levels, over_norm = _colorbar_map_levels(overlay_cube) 

685 if contour_cube: 

686 cntr_cmap, cntr_levels, cntr_norm = _colorbar_map_levels(contour_cube) 

687 

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

689 axes = _setup_spatial_map(cube, fig, cmap) 

690 

691 # Plot the field. 

692 if method == "contourf": 

693 # Filled contour plot of the field. 

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

695 elif method == "pcolormesh": 

696 try: 

697 vmin = min(levels) 

698 vmax = max(levels) 

699 except TypeError: 

700 vmin, vmax = None, None 

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

702 # if levels are defined. 

703 if norm is not None: 

704 vmin = None 

705 vmax = None 

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

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

708 else: 

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

710 

711 # Overplot overlay field, if required 

712 if overlay_cube: 

713 try: 

714 over_vmin = min(over_levels) 

715 over_vmax = max(over_levels) 

716 except TypeError: 

717 over_vmin, over_vmax = None, None 

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

719 over_vmin = None 

720 over_vmax = None 

721 overlay = iplt.pcolormesh( 

722 overlay_cube, 

723 cmap=over_cmap, 

724 norm=over_norm, 

725 alpha=0.8, 

726 vmin=over_vmin, 

727 vmax=over_vmax, 

728 ) 

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

730 if contour_cube: 

731 contour = iplt.contour( 

732 contour_cube, 

733 colors="darkgray", 

734 levels=cntr_levels, 

735 norm=cntr_norm, 

736 alpha=0.5, 

737 linestyles="--", 

738 linewidths=1, 

739 ) 

740 plt.clabel(contour) 

741 

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

743 if is_transect(cube): 

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

745 axes.invert_yaxis() 

746 axes.set_yscale("log") 

747 axes.set_ylim(1100, 100) 

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

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

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

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

752 ): 

753 axes.set_yscale("log") 

754 

755 axes.set_title( 

756 f"{title}\n" 

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

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

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

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

761 fontsize=16, 

762 ) 

763 

764 # Inset code 

765 axins = inset_axes( 

766 axes, 

767 width="20%", 

768 height="20%", 

769 loc="upper right", 

770 axes_class=GeoAxes, 

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

772 ) 

773 

774 # Slightly transparent to reduce plot blocking. 

775 axins.patch.set_alpha(0.4) 

776 

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

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

779 

780 SLat, SLon, ELat, ELon = ( 

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

782 ) 

783 

784 # Draw line between them 

785 axins.plot( 

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

787 ) 

788 

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

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

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

792 

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

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

795 

796 # Midpoints 

797 lon_mid = (lon_min + lon_max) / 2 

798 lat_mid = (lat_min + lat_max) / 2 

799 

800 # Maximum half-range 

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

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

803 half_range = 1 

804 

805 # Set square extent 

806 axins.set_extent( 

807 [ 

808 lon_mid - half_range, 

809 lon_mid + half_range, 

810 lat_mid - half_range, 

811 lat_mid + half_range, 

812 ], 

813 crs=ccrs.PlateCarree(), 

814 ) 

815 

816 # Ensure square aspect 

817 axins.set_aspect("equal") 

818 

819 else: 

820 # Add title. 

821 axes.set_title(title, fontsize=16) 

822 

823 # Adjust padding if spatial plot or transect 

824 if is_transect(cube): 

825 yinfopad = -0.1 

826 ycbarpad = 0.1 

827 else: 

828 yinfopad = 0.01 

829 ycbarpad = 0.042 

830 

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

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

833 axes.annotate( 

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

835 xy=(0.025, yinfopad), 

836 xycoords="axes fraction", 

837 xytext=(-5, 5), 

838 textcoords="offset points", 

839 ha="left", 

840 va="bottom", 

841 size=11, 

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

843 ) 

844 

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

846 if overlay_cube: 

847 cbarB = fig.colorbar( 

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

849 ) 

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

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

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

853 cbarB.set_ticks(over_levels) 

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

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

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

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

858 

859 # Add main colour bar. 

860 cbar = fig.colorbar( 

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

862 ) 

863 

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

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

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

867 cbar.set_ticks(levels) 

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

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

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

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

872 

873 # Save plot. 

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

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

876 plt.close(fig) 

877 

878 

879def _plot_and_save_postage_stamp_spatial_plot( 

880 cube: iris.cube.Cube, 

881 filename: str, 

882 stamp_coordinate: str, 

883 title: str, 

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

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

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

887 **kwargs, 

888): 

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

890 

891 Parameters 

892 ---------- 

893 cube: Cube 

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

895 filename: str 

896 Filename of the plot to write. 

897 stamp_coordinate: str 

898 Coordinate that becomes different plots. 

899 method: "contourf" | "pcolormesh" 

900 The plotting method to use. 

901 overlay_cube: Cube, optional 

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

903 contour_cube: Cube, optional 

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

905 

906 Raises 

907 ------ 

908 ValueError 

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

910 """ 

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

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

913 grid_rows = int(math.sqrt(nmember)) 

914 grid_size = math.ceil(nmember / grid_rows) 

915 

916 fig = plt.figure( 

917 figsize=(10, 10 * max(grid_rows / grid_size, 0.5)), facecolor="w", edgecolor="k" 

918 ) 

919 

920 # Specify the color bar 

921 cmap, levels, norm = _colorbar_map_levels(cube) 

922 # If overplotting, set required colorbars 

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

924 over_cmap, over_levels, over_norm = _colorbar_map_levels(overlay_cube) 

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

926 cntr_cmap, cntr_levels, cntr_norm = _colorbar_map_levels(contour_cube) 

927 

928 # Make a subplot for each member. 

929 for member, subplot in zip( 

930 cube.slices_over(stamp_coordinate), 

931 range(1, grid_size * grid_rows + 1), 

932 strict=False, 

933 ): 

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

935 axes = _setup_spatial_map( 

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

937 ) 

938 if method == "contourf": 

939 # Filled contour plot of the field. 

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

941 elif method == "pcolormesh": 

942 if levels is not None: 

943 vmin = min(levels) 

944 vmax = max(levels) 

945 else: 

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

947 vmin, vmax = None, None 

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

949 # if levels are defined. 

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

951 vmin = None 

952 vmax = None 

953 # pcolormesh plot of the field. 

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

955 else: 

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

957 

958 # Overplot overlay field, if required 

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

960 try: 

961 over_vmin = min(over_levels) 

962 over_vmax = max(over_levels) 

963 except TypeError: 

964 over_vmin, over_vmax = None, None 

965 if over_norm is not None: 

966 over_vmin = None 

967 over_vmax = None 

968 iplt.pcolormesh( 

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

970 cmap=over_cmap, 

971 norm=over_norm, 

972 alpha=0.6, 

973 vmin=over_vmin, 

974 vmax=over_vmax, 

975 ) 

976 # Overplot contour field, if required 

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

978 iplt.contour( 

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

980 colors="darkgray", 

981 levels=cntr_levels, 

982 norm=cntr_norm, 

983 alpha=0.6, 

984 linestyles="--", 

985 linewidths=1, 

986 ) 

987 mtitle = _set_postage_stamp_title(member.coord(stamp_coordinate)) 

988 axes.set_title(f"{mtitle}") 

989 

990 # Put the shared colorbar in its own axes. 

991 colorbar_axes = fig.add_axes([0.15, 0.05, 0.7, 0.03]) 

992 colorbar = fig.colorbar( 

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

994 ) 

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

996 

997 # Overall figure title. 

998 fig.suptitle(title, fontsize=16) 

999 

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

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

1002 plt.close(fig) 

1003 

1004 

1005def _plot_and_save_line_series( 

1006 cubes: iris.cube.CubeList, 

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

1008 ensemble_coord: str, 

1009 filename: str, 

1010 title: str, 

1011 **kwargs, 

1012): 

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

1014 

1015 Parameters 

1016 ---------- 

1017 cubes: Cube or CubeList 

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

1019 coords: list[Coord] 

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

1021 ensemble_coord: str 

1022 Ensemble coordinate in the cube. 

1023 filename: str 

1024 Filename of the plot to write. 

1025 title: str 

1026 Plot title. 

1027 """ 

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

1029 

1030 model_colors_map = _get_model_colors_map(cubes) 

1031 

1032 # Store min/max ranges. 

1033 y_levels = [] 

1034 

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

1036 _validate_cubes_coords(cubes, coords) 

1037 

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

1039 label = None 

1040 color = "black" 

1041 if model_colors_map: 

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

1043 color = model_colors_map.get(label) 

1044 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

1047 iplt.plot( 

1048 coord, 

1049 cube_slice, 

1050 color=color, 

1051 marker="o", 

1052 ls="-", 

1053 lw=3, 

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

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

1056 else label, 

1057 ) 

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

1059 else: 

1060 iplt.plot( 

1061 coord, 

1062 cube_slice, 

1063 color=color, 

1064 ls="-", 

1065 lw=1.5, 

1066 alpha=0.75, 

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

1068 ) 

1069 

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

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

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

1073 y_levels.append(min(levels)) 

1074 y_levels.append(max(levels)) 

1075 

1076 # Get the current axes. 

1077 ax = plt.gca() 

1078 

1079 # Add some labels and tweak the style. 

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

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

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

1083 else: 

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

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

1086 ax.set_title(title, fontsize=16) 

1087 

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

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

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

1091 

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

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

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

1095 # Add zero line. 

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

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

1098 logging.debug( 

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

1100 ) 

1101 else: 

1102 ax.autoscale() 

1103 

1104 # Add gridlines 

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

1106 # Ientify unique labels for legend 

1107 handles = list( 

1108 { 

1109 label: handle 

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

1111 }.values() 

1112 ) 

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

1114 

1115 # Save plot. 

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

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

1118 plt.close(fig) 

1119 

1120 

1121def _plot_and_save_vertical_line_series( 

1122 cubes: iris.cube.CubeList, 

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

1124 ensemble_coord: str, 

1125 filename: str, 

1126 series_coordinate: str, 

1127 title: str, 

1128 vmin: float, 

1129 vmax: float, 

1130 **kwargs, 

1131): 

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

1133 

1134 Parameters 

1135 ---------- 

1136 cubes: CubeList 

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

1138 coord: list[Coord] 

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

1140 ensemble_coord: str 

1141 Ensemble coordinate in the cube. 

1142 filename: str 

1143 Filename of the plot to write. 

1144 series_coordinate: str 

1145 Coordinate to use as vertical axis. 

1146 title: str 

1147 Plot title. 

1148 vmin: float 

1149 Minimum value for the x-axis. 

1150 vmax: float 

1151 Maximum value for the x-axis. 

1152 """ 

1153 # plot the vertical pressure axis using log scale 

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

1155 

1156 model_colors_map = _get_model_colors_map(cubes) 

1157 

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

1159 _validate_cubes_coords(cubes, coords) 

1160 

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

1162 label = None 

1163 color = "black" 

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

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

1166 color = model_colors_map.get(label) 

1167 

1168 for cube_slice in cube.slices_over(ensemble_coord): 

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

1170 # unless single forecast. 

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

1172 iplt.plot( 

1173 cube_slice, 

1174 coord, 

1175 color=color, 

1176 marker="o", 

1177 ls="-", 

1178 lw=3, 

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

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

1181 else label, 

1182 ) 

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

1184 else: 

1185 iplt.plot( 

1186 cube_slice, 

1187 coord, 

1188 color=color, 

1189 ls="-", 

1190 lw=1.5, 

1191 alpha=0.75, 

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

1193 ) 

1194 

1195 # Get the current axis 

1196 ax = plt.gca() 

1197 

1198 # Special handling for pressure level data. 

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

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

1201 ax.invert_yaxis() 

1202 ax.set_yscale("log") 

1203 

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

1205 y_tick_labels = [ 

1206 "1000", 

1207 "850", 

1208 "700", 

1209 "500", 

1210 "300", 

1211 "200", 

1212 "100", 

1213 ] 

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

1215 

1216 # Set y-axis limits and ticks. 

1217 ax.set_ylim(1100, 100) 

1218 

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

1220 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

1226 

1227 ax.set_yticks(y_ticks) 

1228 ax.set_yticklabels(y_tick_labels) 

1229 

1230 # Set x-axis limits. 

1231 ax.set_xlim(vmin, vmax) 

1232 # Mark y=0 if present in plot. 

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

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

1235 

1236 # Add some labels and tweak the style. 

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

1238 ax.set_xlabel( 

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

1240 ) 

1241 ax.set_title(title, fontsize=16) 

1242 ax.ticklabel_format(axis="x") 

1243 ax.tick_params(axis="y") 

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

1245 

1246 # Add gridlines 

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

1248 # Ientify unique labels for legend 

1249 handles = list( 

1250 { 

1251 label: handle 

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

1253 }.values() 

1254 ) 

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

1256 

1257 # Save plot. 

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

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

1260 plt.close(fig) 

1261 

1262 

1263def _plot_and_save_scatter_plot( 

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

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

1266 filename: str, 

1267 title: str, 

1268 one_to_one: bool, 

1269 model_names: list[str] = None, 

1270 **kwargs, 

1271): 

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

1273 

1274 Parameters 

1275 ---------- 

1276 cube_x: Cube | CubeList 

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

1278 cube_y: Cube | CubeList 

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

1280 filename: str 

1281 Filename of the plot to write. 

1282 title: str 

1283 Plot title. 

1284 one_to_one: bool 

1285 Whether a 1:1 line is plotted. 

1286 """ 

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

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

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

1290 # over the pairs simultaneously. 

1291 

1292 # Ensure cube_x and cube_y are iterable 

1293 cube_x_iterable = iter_maybe(cube_x) 

1294 cube_y_iterable = iter_maybe(cube_y) 

1295 

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

1297 iplt.scatter(cube_x_iter, cube_y_iter) 

1298 if one_to_one is True: 

1299 plt.plot( 

1300 [ 

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

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

1303 ], 

1304 [ 

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

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

1307 ], 

1308 "k", 

1309 linestyle="--", 

1310 ) 

1311 ax = plt.gca() 

1312 

1313 # Add some labels and tweak the style. 

1314 if model_names is None: 

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

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

1317 else: 

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

1319 ax.set_xlabel( 

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

1321 ) 

1322 ax.set_ylabel( 

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

1324 ) 

1325 ax.set_title(title, fontsize=16) 

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

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

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

1329 ax.autoscale() 

1330 

1331 # Save plot. 

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

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

1334 plt.close(fig) 

1335 

1336 

1337def _plot_and_save_vector_plot( 

1338 cube_u: iris.cube.Cube, 

1339 cube_v: iris.cube.Cube, 

1340 filename: str, 

1341 title: str, 

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

1343 **kwargs, 

1344): 

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

1346 

1347 Parameters 

1348 ---------- 

1349 cube_u: Cube 

1350 2 dimensional Cube of u component of the data. 

1351 cube_v: Cube 

1352 2 dimensional Cube of v component of the data. 

1353 filename: str 

1354 Filename of the plot to write. 

1355 title: str 

1356 Plot title. 

1357 """ 

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

1359 

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

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

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

1363 

1364 # Specify the color bar 

1365 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag) 

1366 

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

1368 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

1369 

1370 if method == "contourf": 

1371 # Filled contour plot of the field. 

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

1373 elif method == "pcolormesh": 

1374 try: 

1375 vmin = min(levels) 

1376 vmax = max(levels) 

1377 except TypeError: 

1378 vmin, vmax = None, None 

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

1380 # if levels are defined. 

1381 if norm is not None: 

1382 vmin = None 

1383 vmax = None 

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

1385 else: 

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

1387 

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

1389 if is_transect(cube_vec_mag): 

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

1391 axes.invert_yaxis() 

1392 axes.set_yscale("log") 

1393 axes.set_ylim(1100, 100) 

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

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

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

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

1398 ): 

1399 axes.set_yscale("log") 

1400 

1401 axes.set_title( 

1402 f"{title}\n" 

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

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

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

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

1407 fontsize=16, 

1408 ) 

1409 

1410 else: 

1411 # Add title. 

1412 axes.set_title(title, fontsize=16) 

1413 

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

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

1416 axes.annotate( 

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

1418 xy=(0.05, -0.05), 

1419 xycoords="axes fraction", 

1420 xytext=(-5, 5), 

1421 textcoords="offset points", 

1422 ha="right", 

1423 va="bottom", 

1424 size=11, 

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

1426 ) 

1427 

1428 # Add colour bar. 

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

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

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

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

1433 cbar.set_ticks(levels) 

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

1435 

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

1437 # with less than 30 points. 

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

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

1440 

1441 # Save plot. 

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

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

1444 plt.close(fig) 

1445 

1446 

1447def _plot_and_save_histogram_series( 

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

1449 filename: str, 

1450 title: str, 

1451 vmin: float, 

1452 vmax: float, 

1453 **kwargs, 

1454): 

1455 """Plot and save a histogram series. 

1456 

1457 Parameters 

1458 ---------- 

1459 cubes: Cube or CubeList 

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

1461 filename: str 

1462 Filename of the plot to write. 

1463 title: str 

1464 Plot title. 

1465 vmin: float 

1466 minimum for colorbar 

1467 vmax: float 

1468 maximum for colorbar 

1469 """ 

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

1471 ax = plt.gca() 

1472 

1473 model_colors_map = _get_model_colors_map(cubes) 

1474 

1475 # Set default that histograms will produce probability density function 

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

1477 density = True 

1478 

1479 for cube in iter_maybe(cubes): 

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

1481 # than seeing if long names exist etc. 

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

1483 if "surface_microphysical" in title: 

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

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

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

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

1488 density = False 

1489 else: 

1490 bins = 10.0 ** ( 

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

1492 ) # Suggestion from RMED toolbox. 

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

1494 ax.set_yscale("log") 

1495 vmin = bins[1] 

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

1497 ax.set_xscale("log") 

1498 elif "lightning" in title: 

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

1500 else: 

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

1502 logging.debug( 

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

1504 np.size(bins), 

1505 np.min(bins), 

1506 np.max(bins), 

1507 ) 

1508 

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

1510 # Otherwise we plot xdim histograms stacked. 

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

1512 

1513 label = None 

1514 color = "black" 

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

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

1517 color = model_colors_map[label] 

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

1519 

1520 # Compute area under curve. 

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

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

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

1524 x = x[1:] 

1525 y = y[1:] 

1526 

1527 ax.plot( 

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

1529 ) 

1530 

1531 # Add some labels and tweak the style. 

1532 ax.set_title(title, fontsize=16) 

1533 ax.set_xlabel( 

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

1535 ) 

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

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

1538 ax.set_ylabel( 

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

1540 ) 

1541 ax.set_xlim(vmin, vmax) 

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

1543 

1544 # Overlay grid-lines onto histogram plot. 

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

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

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

1548 

1549 # Save plot. 

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

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

1552 plt.close(fig) 

1553 

1554 

1555def _plot_and_save_postage_stamp_histogram_series( 

1556 cube: iris.cube.Cube, 

1557 filename: str, 

1558 title: str, 

1559 stamp_coordinate: str, 

1560 vmin: float, 

1561 vmax: float, 

1562 **kwargs, 

1563): 

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

1565 

1566 Parameters 

1567 ---------- 

1568 cube: Cube 

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

1570 filename: str 

1571 Filename of the plot to write. 

1572 title: str 

1573 Plot title. 

1574 stamp_coordinate: str 

1575 Coordinate that becomes different plots. 

1576 vmin: float 

1577 minimum for pdf x-axis 

1578 vmax: float 

1579 maximum for pdf x-axis 

1580 """ 

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

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

1583 grid_rows = int(math.sqrt(nmember)) 

1584 grid_size = math.ceil(nmember / grid_rows) 

1585 

1586 fig = plt.figure( 

1587 figsize=(10, 10 * max(grid_rows / grid_size, 0.5)), facecolor="w", edgecolor="k" 

1588 ) 

1589 # Make a subplot for each member. 

1590 for member, subplot in zip( 

1591 cube.slices_over(stamp_coordinate), 

1592 range(1, grid_size * grid_rows + 1), 

1593 strict=False, 

1594 ): 

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

1596 # cartopy GeoAxes generated. 

1597 plt.subplot(grid_rows, grid_size, subplot) 

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

1599 # Otherwise we plot xdim histograms stacked. 

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

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

1602 axes = plt.gca() 

1603 mtitle = _set_postage_stamp_title(member.coord(stamp_coordinate)) 

1604 axes.set_title(f"{mtitle}") 

1605 axes.set_xlim(vmin, vmax) 

1606 

1607 # Overall figure title. 

1608 fig.suptitle(title, fontsize=16) 

1609 

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

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

1612 plt.close(fig) 

1613 

1614 

1615def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1616 cube: iris.cube.Cube, 

1617 filename: str, 

1618 title: str, 

1619 stamp_coordinate: str, 

1620 vmin: float, 

1621 vmax: float, 

1622 **kwargs, 

1623): 

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

1625 ax.set_title(title, fontsize=16) 

1626 ax.set_xlim(vmin, vmax) 

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

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

1629 # Loop over all slices along the stamp_coordinate 

1630 for member in cube.slices_over(stamp_coordinate): 

1631 # Flatten the member data to 1D 

1632 member_data_1d = member.data.flatten() 

1633 # Plot the histogram using plt.hist 

1634 mtitle = _set_postage_stamp_title(member.coord(stamp_coordinate)) 

1635 plt.hist( 

1636 member_data_1d, 

1637 density=True, 

1638 stacked=True, 

1639 label=f"{mtitle}", 

1640 ) 

1641 

1642 # Add a legend 

1643 ax.legend(fontsize=16) 

1644 

1645 # Save the figure to a file 

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

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

1648 

1649 # Close the figure 

1650 plt.close(fig) 

1651 

1652 

1653def _plot_and_save_scattermap_plot( 

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

1655): 

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

1657 

1658 Parameters 

1659 ---------- 

1660 cube: Cube 

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

1662 longitude coordinates, 

1663 filename: str 

1664 Filename of the plot to write. 

1665 title: str 

1666 Plot title. 

1667 projection: str 

1668 Mapping projection to be used by cartopy. 

1669 """ 

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

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

1672 if projection is not None: 

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

1674 # a stereographic projection over the North Pole. 

1675 if projection == "NP_Stereo": 

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

1677 else: 

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

1679 else: 

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

1681 

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

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

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

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

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

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

1688 # proportion to the area of the figure. 

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

1690 klon = None 

1691 klat = None 

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

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

1694 klat = kc 

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

1696 klon = kc 

1697 scatter_map = iplt.scatter( 

1698 cube.aux_coords[klon], 

1699 cube.aux_coords[klat], 

1700 c=cube.data[:], 

1701 s=mrk_size, 

1702 cmap="jet", 

1703 edgecolors="k", 

1704 ) 

1705 

1706 # Add coastlines and borderlines. 

1707 try: 

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

1709 axes.add_feature(cfeature.BORDERS) 

1710 except AttributeError: 

1711 pass 

1712 

1713 # Add title. 

1714 axes.set_title(title, fontsize=16) 

1715 

1716 # Add colour bar. 

1717 cbar = fig.colorbar(scatter_map) 

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

1719 

1720 # Save plot. 

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

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

1723 plt.close(fig) 

1724 

1725 

1726def _plot_and_save_power_spectrum_series( 

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

1728 filename: str, 

1729 title: str, 

1730 **kwargs, 

1731): 

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

1733 

1734 Parameters 

1735 ---------- 

1736 cubes: Cube or CubeList 

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

1738 filename: str 

1739 Filename of the plot to write. 

1740 title: str 

1741 Plot title. 

1742 """ 

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

1744 ax = plt.gca() 

1745 

1746 model_colors_map = _get_model_colors_map(cubes) 

1747 

1748 for cube in iter_maybe(cubes): 

1749 # Calculate power spectrum 

1750 

1751 # Extract time coordinate and convert to datetime 

1752 time_coord = cube.coord("time") 

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

1754 

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

1756 target_time = time_points[0] 

1757 

1758 # Bind target_time inside the lambda using a default argument 

1759 time_constraint = iris.Constraint( 

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

1761 ) 

1762 

1763 cube = cube.extract(time_constraint) 

1764 

1765 if cube.ndim == 2: 

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

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

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

1769 cube_3d = cube.data 

1770 else: 

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

1772 raise ValueError( 

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

1774 ) 

1775 

1776 # Calculate spectra 

1777 ps_array = _DCT_ps(cube_3d) 

1778 

1779 ps_cube = iris.cube.Cube( 

1780 ps_array, 

1781 long_name="power_spectra", 

1782 ) 

1783 

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

1785 

1786 # Create a frequency/wavelength array for coordinate 

1787 ps_len = ps_cube.data.shape[1] 

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

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

1790 

1791 # Convert datetime to numeric time using original units 

1792 numeric_time = time_coord.units.date2num(time_points) 

1793 # Create a new DimCoord with numeric time 

1794 new_time_coord = iris.coords.DimCoord( 

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

1796 ) 

1797 

1798 # Add time and frequency coordinate to spectra cube. 

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

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

1801 

1802 # Extract data from the cube 

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

1804 power_spectrum = ps_cube.data 

1805 

1806 label = None 

1807 color = "black" 

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

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

1810 color = model_colors_map[label] 

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

1812 

1813 # Add some labels and tweak the style. 

1814 ax.set_title(title, fontsize=16) 

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

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

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

1818 

1819 # Set log-log scale 

1820 ax.set_xscale("log") 

1821 ax.set_yscale("log") 

1822 

1823 # Overlay grid-lines onto power spectrum plot. 

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

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

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

1827 

1828 # Save plot. 

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

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

1831 plt.close(fig) 

1832 

1833 

1834def _plot_and_save_postage_stamp_power_spectrum_series( 

1835 cube: iris.cube.Cube, 

1836 filename: str, 

1837 title: str, 

1838 stamp_coordinate: str, 

1839 **kwargs, 

1840): 

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

1842 

1843 Parameters 

1844 ---------- 

1845 cube: Cube 

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

1847 filename: str 

1848 Filename of the plot to write. 

1849 title: str 

1850 Plot title. 

1851 stamp_coordinate: str 

1852 Coordinate that becomes different plots. 

1853 """ 

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

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

1856 grid_rows = int(math.sqrt(nmember)) 

1857 grid_size = math.ceil(nmember / grid_rows) 

1858 

1859 fig = plt.figure( 

1860 figsize=(10, 10 * max(grid_rows / grid_size, 0.5)), facecolor="w", edgecolor="k" 

1861 ) 

1862 

1863 # Make a subplot for each member. 

1864 for member, subplot in zip( 

1865 cube.slices_over(stamp_coordinate), 

1866 range(1, grid_size * grid_rows + 1), 

1867 strict=False, 

1868 ): 

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

1870 # cartopy GeoAxes generated. 

1871 plt.subplot(grid_rows, grid_size, subplot) 

1872 

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

1874 

1875 axes = plt.gca() 

1876 axes.plot(frequency, member.data) 

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

1878 

1879 # Overall figure title. 

1880 fig.suptitle(title, fontsize=16) 

1881 

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

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

1884 plt.close(fig) 

1885 

1886 

1887def _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series( 

1888 cube: iris.cube.Cube, 

1889 filename: str, 

1890 title: str, 

1891 stamp_coordinate: str, 

1892 **kwargs, 

1893): 

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

1895 ax.set_title(title, fontsize=16) 

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

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

1898 # Loop over all slices along the stamp_coordinate 

1899 for member in cube.slices_over(stamp_coordinate): 

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

1901 ax.plot( 

1902 frequency, 

1903 member.data, 

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

1905 ) 

1906 

1907 # Add a legend 

1908 ax.legend(fontsize=16) 

1909 

1910 # Save the figure to a file 

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

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

1913 

1914 # Close the figure 

1915 plt.close(fig) 

1916 

1917 

1918def _spatial_plot( 

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

1920 cube: iris.cube.Cube, 

1921 filename: str | None, 

1922 sequence_coordinate: str, 

1923 stamp_coordinate: str, 

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

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

1926 **kwargs, 

1927): 

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

1929 

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

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

1932 is present then postage stamp plots will be produced. 

1933 

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

1935 be overplotted on the same figure. 

1936 

1937 Parameters 

1938 ---------- 

1939 method: "contourf" | "pcolormesh" 

1940 The plotting method to use. 

1941 cube: Cube 

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

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

1944 plotted sequentially and/or as postage stamp plots. 

1945 filename: str | None 

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

1947 uses the recipe name. 

1948 sequence_coordinate: str 

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

1950 This coordinate must exist in the cube. 

1951 stamp_coordinate: str 

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

1953 ``"realization"``. 

1954 overlay_cube: Cube | None, optional 

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

1956 contour_cube: Cube | None, optional 

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

1958 

1959 Raises 

1960 ------ 

1961 ValueError 

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

1963 TypeError 

1964 If the cube isn't a single cube. 

1965 """ 

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

1967 

1968 # Ensure we've got a single cube. 

1969 cube = _check_single_cube(cube) 

1970 

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

1972 if stamp_coordinate == "realization": 1972 ↛ 1977line 1972 didn't jump to line 1977 because the condition on line 1972 was always true

1973 stamp_coordinate = check_stamp_coordinate(cube) 

1974 

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

1976 # single point. 

1977 plotting_func = _plot_and_save_spatial_plot 

1978 try: 

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

1980 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1981 except iris.exceptions.CoordinateNotFoundError: 

1982 pass 

1983 

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

1985 # dimension called observation or model_obs_error 

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

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

1988 for crd in cube.coords() 

1989 ): 

1990 plotting_func = _plot_and_save_scattermap_plot 

1991 

1992 # Must have a sequence coordinate. 

1993 try: 

1994 cube.coord(sequence_coordinate) 

1995 except iris.exceptions.CoordinateNotFoundError as err: 

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

1997 

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

1999 plot_index = [] 

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

2001 

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

2003 # Set plot titles and filename 

2004 seq_coord = cube_slice.coord(sequence_coordinate) 

2005 plot_title, plot_filename = _set_title_and_filename( 

2006 seq_coord, nplot, recipe_title, filename 

2007 ) 

2008 

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

2010 overlay_slice = slice_over_maybe(overlay_cube, sequence_coordinate, iseq) 

2011 contour_slice = slice_over_maybe(contour_cube, sequence_coordinate, iseq) 

2012 

2013 # Do the actual plotting. 

2014 plotting_func( 

2015 cube_slice, 

2016 filename=plot_filename, 

2017 stamp_coordinate=stamp_coordinate, 

2018 title=plot_title, 

2019 method=method, 

2020 overlay_cube=overlay_slice, 

2021 contour_cube=contour_slice, 

2022 **kwargs, 

2023 ) 

2024 plot_index.append(plot_filename) 

2025 

2026 # Add list of plots to plot metadata. 

2027 complete_plot_index = _append_to_plot_index(plot_index) 

2028 

2029 # Make a page to display the plots. 

2030 _make_plot_html_page(complete_plot_index) 

2031 

2032 

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

2034 """Get colourmap for mask. 

2035 

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

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

2038 

2039 Parameters 

2040 ---------- 

2041 cube: Cube 

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

2043 axis: "x", "y", optional 

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

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

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

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

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

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

2050 

2051 Returns 

2052 ------- 

2053 cmap: 

2054 Matplotlib colormap. 

2055 levels: 

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

2057 should be taken as the range. 

2058 norm: 

2059 BoundaryNorm information. 

2060 """ 

2061 if "difference" not in cube.long_name: 

2062 if axis: 

2063 levels = [0, 1] 

2064 # Complete settings based on levels. 

2065 return None, levels, None 

2066 else: 

2067 # Define the levels and colors. 

2068 levels = [0, 1, 2] 

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

2070 # Create a custom color map. 

2071 cmap = mcolors.ListedColormap(colors) 

2072 # Normalize the levels. 

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

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

2075 return cmap, levels, norm 

2076 else: 

2077 if axis: 

2078 levels = [-1, 1] 

2079 return None, levels, None 

2080 else: 

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

2082 # not <=. 

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

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

2085 cmap = mcolors.ListedColormap(colors) 

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

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

2088 return cmap, levels, norm 

2089 

2090 

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

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

2093 

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

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

2096 

2097 Parameters 

2098 ---------- 

2099 cube: Cube 

2100 Cube of variable with Beaufort Scale in name. 

2101 axis: "x", "y", optional 

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

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

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

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

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

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

2108 

2109 Returns 

2110 ------- 

2111 cmap: 

2112 Matplotlib colormap. 

2113 levels: 

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

2115 should be taken as the range. 

2116 norm: 

2117 BoundaryNorm information. 

2118 """ 

2119 if "difference" not in cube.long_name: 

2120 if axis: 

2121 levels = [0, 12] 

2122 return None, levels, None 

2123 else: 

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

2125 colors = [ 

2126 "black", 

2127 (0, 0, 0.6), 

2128 "blue", 

2129 "cyan", 

2130 "green", 

2131 "yellow", 

2132 (1, 0.5, 0), 

2133 "red", 

2134 "pink", 

2135 "magenta", 

2136 "purple", 

2137 "maroon", 

2138 "white", 

2139 ] 

2140 cmap = mcolors.ListedColormap(colors) 

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

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

2143 return cmap, levels, norm 

2144 else: 

2145 if axis: 

2146 levels = [-4, 4] 

2147 return None, levels, None 

2148 else: 

2149 levels = [ 

2150 -3.5, 

2151 -2.5, 

2152 -1.5, 

2153 -0.5, 

2154 0.5, 

2155 1.5, 

2156 2.5, 

2157 3.5, 

2158 ] 

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

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

2161 return cmap, levels, norm 

2162 

2163 

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

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

2166 

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

2168 

2169 Parameters 

2170 ---------- 

2171 cube: Cube 

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

2173 cmap: Matplotlib colormap. 

2174 levels: List 

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

2176 should be taken as the range. 

2177 norm: BoundaryNorm. 

2178 

2179 Returns 

2180 ------- 

2181 cmap: Matplotlib colormap. 

2182 levels: List 

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

2184 should be taken as the range. 

2185 norm: BoundaryNorm. 

2186 """ 

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

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

2189 levels = np.array(levels) 

2190 levels -= 273 

2191 levels = levels.tolist() 

2192 else: 

2193 # Do nothing keep the existing colourbar attributes 

2194 levels = levels 

2195 cmap = cmap 

2196 norm = norm 

2197 return cmap, levels, norm 

2198 

2199 

2200def _custom_colormap_probability( 

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

2202): 

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

2204 

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

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

2207 

2208 Parameters 

2209 ---------- 

2210 cube: Cube 

2211 Cube of variable with probability in name. 

2212 axis: "x", "y", optional 

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

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

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

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

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

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

2219 

2220 Returns 

2221 ------- 

2222 cmap: 

2223 Matplotlib colormap. 

2224 levels: 

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

2226 should be taken as the range. 

2227 norm: 

2228 BoundaryNorm information. 

2229 """ 

2230 if axis: 

2231 levels = [0, 1] 

2232 return None, levels, None 

2233 else: 

2234 cmap = mcolors.ListedColormap( 

2235 [ 

2236 "#FFFFFF", 

2237 "#636363", 

2238 "#e1dada", 

2239 "#B5CAFF", 

2240 "#8FB3FF", 

2241 "#7F97FF", 

2242 "#ABCF63", 

2243 "#E8F59E", 

2244 "#FFFA14", 

2245 "#FFD121", 

2246 "#FFA30A", 

2247 ] 

2248 ) 

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

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

2251 return cmap, levels, norm 

2252 

2253 

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

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

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

2257 if ( 

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

2259 and "difference" not in cube.long_name 

2260 and "mask" not in cube.long_name 

2261 ): 

2262 # Define the levels and colors 

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

2264 colors = [ 

2265 "w", 

2266 (0, 0, 0.6), 

2267 "b", 

2268 "c", 

2269 "g", 

2270 "y", 

2271 (1, 0.5, 0), 

2272 "r", 

2273 "pink", 

2274 "m", 

2275 "purple", 

2276 "maroon", 

2277 "gray", 

2278 ] 

2279 # Create a custom colormap 

2280 cmap = mcolors.ListedColormap(colors) 

2281 # Normalize the levels 

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

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

2284 else: 

2285 # do nothing and keep existing colorbar attributes 

2286 cmap = cmap 

2287 levels = levels 

2288 norm = norm 

2289 return cmap, levels, norm 

2290 

2291 

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

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

2294 

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

2296 this function will be called. 

2297 

2298 Parameters 

2299 ---------- 

2300 cube: Cube 

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

2302 

2303 Returns 

2304 ------- 

2305 cmap: Matplotlib colormap. 

2306 levels: List 

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

2308 should be taken as the range. 

2309 norm: BoundaryNorm. 

2310 """ 

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

2312 colors = [ 

2313 "#87ceeb", 

2314 "#ffffff", 

2315 "#8ced69", 

2316 "#ffff00", 

2317 "#ffd700", 

2318 "#ffa500", 

2319 "#fe3620", 

2320 ] 

2321 # Create a custom colormap 

2322 cmap = mcolors.ListedColormap(colors) 

2323 # Normalise the levels 

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

2325 return cmap, levels, norm 

2326 

2327 

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

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

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

2331 if ( 

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

2333 and "difference" not in cube.long_name 

2334 and "mask" not in cube.long_name 

2335 ): 

2336 # Define the levels and colors (in km) 

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

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

2339 colours = [ 

2340 "#8f00d6", 

2341 "#d10000", 

2342 "#ff9700", 

2343 "#ffff00", 

2344 "#00007f", 

2345 "#6c9ccd", 

2346 "#aae8ff", 

2347 "#37a648", 

2348 "#8edc64", 

2349 "#c5ffc5", 

2350 "#dcdcdc", 

2351 "#ffffff", 

2352 ] 

2353 # Create a custom colormap 

2354 cmap = mcolors.ListedColormap(colours) 

2355 # Normalize the levels 

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

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

2358 else: 

2359 # do nothing and keep existing colorbar attributes 

2360 cmap = cmap 

2361 levels = levels 

2362 norm = norm 

2363 return cmap, levels, norm 

2364 

2365 

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

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

2368 model_names = list( 

2369 filter( 

2370 lambda x: x is not None, 

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

2372 ) 

2373 ) 

2374 if not model_names: 

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

2376 return 1 

2377 else: 

2378 return len(model_names) 

2379 

2380 

2381def _validate_cube_shape( 

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

2383) -> None: 

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

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

2386 raise ValueError( 

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

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

2389 ) 

2390 

2391 

2392def _validate_cubes_coords( 

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

2394) -> None: 

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

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

2397 raise ValueError( 

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

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

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

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

2402 ) 

2403 

2404 

2405#################### 

2406# Public functions # 

2407#################### 

2408 

2409 

2410def spatial_contour_plot( 

2411 cube: iris.cube.Cube, 

2412 filename: str = None, 

2413 sequence_coordinate: str = "time", 

2414 stamp_coordinate: str = "realization", 

2415 **kwargs, 

2416) -> iris.cube.Cube: 

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

2418 

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

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

2421 is present then postage stamp plots will be produced. 

2422 

2423 Parameters 

2424 ---------- 

2425 cube: Cube 

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

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

2428 plotted sequentially and/or as postage stamp plots. 

2429 filename: str, optional 

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

2431 to the recipe name. 

2432 sequence_coordinate: str, optional 

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

2434 This coordinate must exist in the cube. 

2435 stamp_coordinate: str, optional 

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

2437 ``"realization"``. 

2438 

2439 Returns 

2440 ------- 

2441 Cube 

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

2443 

2444 Raises 

2445 ------ 

2446 ValueError 

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

2448 TypeError 

2449 If the cube isn't a single cube. 

2450 """ 

2451 _spatial_plot( 

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

2453 ) 

2454 return cube 

2455 

2456 

2457def spatial_pcolormesh_plot( 

2458 cube: iris.cube.Cube, 

2459 filename: str = None, 

2460 sequence_coordinate: str = "time", 

2461 stamp_coordinate: str = "realization", 

2462 **kwargs, 

2463) -> iris.cube.Cube: 

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

2465 

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

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

2468 is present then postage stamp plots will be produced. 

2469 

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

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

2472 contour areas are important. 

2473 

2474 Parameters 

2475 ---------- 

2476 cube: Cube 

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

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

2479 plotted sequentially and/or as postage stamp plots. 

2480 filename: str, optional 

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

2482 to the recipe name. 

2483 sequence_coordinate: str, optional 

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

2485 This coordinate must exist in the cube. 

2486 stamp_coordinate: str, optional 

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

2488 ``"realization"``. 

2489 

2490 Returns 

2491 ------- 

2492 Cube 

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

2494 

2495 Raises 

2496 ------ 

2497 ValueError 

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

2499 TypeError 

2500 If the cube isn't a single cube. 

2501 """ 

2502 _spatial_plot( 

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

2504 ) 

2505 return cube 

2506 

2507 

2508def spatial_multi_pcolormesh_plot( 

2509 cube: iris.cube.Cube, 

2510 overlay_cube: iris.cube.Cube, 

2511 contour_cube: iris.cube.Cube, 

2512 filename: str = None, 

2513 sequence_coordinate: str = "time", 

2514 stamp_coordinate: str = "realization", 

2515 **kwargs, 

2516) -> iris.cube.Cube: 

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

2518 

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

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

2521 is present then postage stamp plots will be produced. 

2522 

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

2524 

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

2526 

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

2528 

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

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

2531 contour areas are important. 

2532 

2533 Parameters 

2534 ---------- 

2535 cube: Cube 

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

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

2538 plotted sequentially and/or as postage stamp plots. 

2539 overlay_cube: Cube 

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

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

2542 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. 

2543 contour_cube: Cube 

2544 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, 

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

2546 plotted sequentially and/or as postage stamp plots. 

2547 filename: str, optional 

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

2549 to the recipe name. 

2550 sequence_coordinate: str, optional 

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

2552 This coordinate must exist in the cube. 

2553 stamp_coordinate: str, optional 

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

2555 ``"realization"``. 

2556 

2557 Returns 

2558 ------- 

2559 Cube 

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

2561 

2562 Raises 

2563 ------ 

2564 ValueError 

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

2566 TypeError 

2567 If the cube isn't a single cube. 

2568 """ 

2569 _spatial_plot( 

2570 "pcolormesh", 

2571 cube, 

2572 filename, 

2573 sequence_coordinate, 

2574 stamp_coordinate, 

2575 overlay_cube=overlay_cube, 

2576 contour_cube=contour_cube, 

2577 ) 

2578 return cube, overlay_cube, contour_cube 

2579 

2580 

2581# TODO: Expand function to handle ensemble data. 

2582# line_coordinate: str, optional 

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

2584# ``"realization"``. 

2585def plot_line_series( 

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

2587 filename: str = None, 

2588 series_coordinate: str = "time", 

2589 # line_coordinate: str = "realization", 

2590 **kwargs, 

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

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

2593 

2594 The Cube or CubeList must be 1D. 

2595 

2596 Parameters 

2597 ---------- 

2598 iris.cube | iris.cube.CubeList 

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

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

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

2602 filename: str, optional 

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

2604 to the recipe name. 

2605 series_coordinate: str, optional 

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

2607 coordinate must exist in the cube. 

2608 

2609 Returns 

2610 ------- 

2611 iris.cube.Cube | iris.cube.CubeList 

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

2613 plotted data. 

2614 

2615 Raises 

2616 ------ 

2617 ValueError 

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

2619 TypeError 

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

2621 """ 

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

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

2624 

2625 num_models = _get_num_models(cube) 

2626 

2627 _validate_cube_shape(cube, num_models) 

2628 

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

2630 cubes = iter_maybe(cube) 

2631 coords = [] 

2632 for cube in cubes: 

2633 try: 

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

2635 except iris.exceptions.CoordinateNotFoundError as err: 

2636 raise ValueError( 

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

2638 ) from err 

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

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

2641 

2642 # Format the title and filename using plotted series coordinate 

2643 nplot = 1 

2644 seq_coord = coords[0] 

2645 plot_title, plot_filename = _set_title_and_filename( 

2646 seq_coord, nplot, recipe_title, filename 

2647 ) 

2648 

2649 # Do the actual plotting. 

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

2651 

2652 # Add list of plots to plot metadata. 

2653 plot_index = _append_to_plot_index([plot_filename]) 

2654 

2655 # Make a page to display the plots. 

2656 _make_plot_html_page(plot_index) 

2657 

2658 return cube 

2659 

2660 

2661def plot_vertical_line_series( 

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

2663 filename: str = None, 

2664 series_coordinate: str = "model_level_number", 

2665 sequence_coordinate: str = "time", 

2666 # line_coordinate: str = "realization", 

2667 **kwargs, 

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

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

2670 

2671 The Cube or CubeList must be 1D. 

2672 

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

2674 then a sequence of plots will be produced. 

2675 

2676 Parameters 

2677 ---------- 

2678 iris.cube | iris.cube.CubeList 

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

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

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

2682 filename: str, optional 

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

2684 to the recipe name. 

2685 series_coordinate: str, optional 

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

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

2688 for LFRic. Defaults to ``model_level_number``. 

2689 This coordinate must exist in the cube. 

2690 sequence_coordinate: str, optional 

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

2692 This coordinate must exist in the cube. 

2693 

2694 Returns 

2695 ------- 

2696 iris.cube.Cube | iris.cube.CubeList 

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

2698 Plotted data. 

2699 

2700 Raises 

2701 ------ 

2702 ValueError 

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

2704 TypeError 

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

2706 """ 

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

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

2709 

2710 cubes = iter_maybe(cubes) 

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

2712 all_data = [] 

2713 

2714 # Store min/max ranges for x range. 

2715 x_levels = [] 

2716 

2717 num_models = _get_num_models(cubes) 

2718 

2719 _validate_cube_shape(cubes, num_models) 

2720 

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

2722 coords = [] 

2723 for cube in cubes: 

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

2725 try: 

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

2727 except iris.exceptions.CoordinateNotFoundError as err: 

2728 raise ValueError( 

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

2730 ) from err 

2731 

2732 try: 

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

2734 cube.coord(sequence_coordinate) 

2735 except iris.exceptions.CoordinateNotFoundError as err: 

2736 raise ValueError( 

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

2738 ) from err 

2739 

2740 # Get minimum and maximum from levels information. 

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

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

2743 x_levels.append(min(levels)) 

2744 x_levels.append(max(levels)) 

2745 else: 

2746 all_data.append(cube.data) 

2747 

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

2749 # Combine all data into a single NumPy array 

2750 combined_data = np.concatenate(all_data) 

2751 

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

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

2754 # sequence and if applicable postage stamp coordinate. 

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

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

2757 else: 

2758 vmin = min(x_levels) 

2759 vmax = max(x_levels) 

2760 

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

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

2763 # necessary) 

2764 def filter_cube_iterables(cube_iterables) -> bool: 

2765 return len(cube_iterables) == len(coords) 

2766 

2767 cube_iterables = filter( 

2768 filter_cube_iterables, 

2769 ( 

2770 iris.cube.CubeList( 

2771 s 

2772 for s in itertools.chain.from_iterable( 

2773 cb.slices_over(sequence_coordinate) for cb in cubes 

2774 ) 

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

2776 ) 

2777 for point in sorted( 

2778 set( 

2779 itertools.chain.from_iterable( 

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

2781 ) 

2782 ) 

2783 ) 

2784 ), 

2785 ) 

2786 

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

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

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

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

2791 # or an iris.cube.CubeList. 

2792 plot_index = [] 

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

2794 for cubes_slice in cube_iterables: 

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

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

2797 plot_title, plot_filename = _set_title_and_filename( 

2798 seq_coord, nplot, recipe_title, filename 

2799 ) 

2800 

2801 # Do the actual plotting. 

2802 _plot_and_save_vertical_line_series( 

2803 cubes_slice, 

2804 coords, 

2805 "realization", 

2806 plot_filename, 

2807 series_coordinate, 

2808 title=plot_title, 

2809 vmin=vmin, 

2810 vmax=vmax, 

2811 ) 

2812 plot_index.append(plot_filename) 

2813 

2814 # Add list of plots to plot metadata. 

2815 complete_plot_index = _append_to_plot_index(plot_index) 

2816 

2817 # Make a page to display the plots. 

2818 _make_plot_html_page(complete_plot_index) 

2819 

2820 return cubes 

2821 

2822 

2823def qq_plot( 

2824 cubes: iris.cube.CubeList, 

2825 coordinates: list[str], 

2826 percentiles: list[float], 

2827 model_names: list[str], 

2828 filename: str = None, 

2829 one_to_one: bool = True, 

2830 **kwargs, 

2831) -> iris.cube.CubeList: 

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

2833 

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

2835 collapsed within the operator over all specified coordinates such as 

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

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

2838 

2839 Parameters 

2840 ---------- 

2841 cubes: iris.cube.CubeList 

2842 Two cubes of the same variable with different models. 

2843 coordinate: list[str] 

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

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

2846 the percentile coordinate. 

2847 percent: list[float] 

2848 A list of percentiles to appear in the plot. 

2849 model_names: list[str] 

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

2851 filename: str, optional 

2852 Filename of the plot to write. 

2853 one_to_one: bool, optional 

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

2855 

2856 Raises 

2857 ------ 

2858 ValueError 

2859 When the cubes are not compatible. 

2860 

2861 Notes 

2862 ----- 

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

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

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

2866 compares percentiles of two datasets. This plot does 

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

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

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

2870 

2871 Quantile-quantile plots are valuable for comparing against 

2872 observations and other models. Identical percentiles between the variables 

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

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

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

2876 Wilks 2011 [Wilks2011]_). 

2877 

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

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

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

2881 the extremes. 

2882 

2883 References 

2884 ---------- 

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

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

2887 """ 

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

2889 if len(cubes) != 2: 

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

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

2892 other: Cube = cubes.extract_cube( 

2893 iris.Constraint( 

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

2895 ) 

2896 ) 

2897 

2898 # Get spatial coord names. 

2899 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

2900 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

2901 

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

2903 # This is triggered if either 

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

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

2906 # errors. 

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

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

2909 # for UM and LFRic comparisons. 

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

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

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

2913 # given this dependency on regridding. 

2914 if ( 

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

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

2917 ) or ( 

2918 base.long_name 

2919 in [ 

2920 "eastward_wind_at_10m", 

2921 "northward_wind_at_10m", 

2922 "northward_wind_at_cell_centres", 

2923 "eastward_wind_at_cell_centres", 

2924 "zonal_wind_at_pressure_levels", 

2925 "meridional_wind_at_pressure_levels", 

2926 "potential_vorticity_at_pressure_levels", 

2927 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

2928 ] 

2929 ): 

2930 logging.debug( 

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

2932 ) 

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

2934 

2935 # Extract just common time points. 

2936 base, other = _extract_common_time_points(base, other) 

2937 

2938 # Equalise attributes so we can merge. 

2939 fully_equalise_attributes([base, other]) 

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

2941 

2942 # Collapse cubes. 

2943 base = collapse( 

2944 base, 

2945 coordinate=coordinates, 

2946 method="PERCENTILE", 

2947 additional_percent=percentiles, 

2948 ) 

2949 other = collapse( 

2950 other, 

2951 coordinate=coordinates, 

2952 method="PERCENTILE", 

2953 additional_percent=percentiles, 

2954 ) 

2955 

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

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

2958 title = f"{recipe_title}" 

2959 

2960 if filename is None: 

2961 filename = slugify(recipe_title) 

2962 

2963 # Add file extension. 

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

2965 

2966 # Do the actual plotting on a scatter plot 

2967 _plot_and_save_scatter_plot( 

2968 base, other, plot_filename, title, one_to_one, model_names 

2969 ) 

2970 

2971 # Add list of plots to plot metadata. 

2972 plot_index = _append_to_plot_index([plot_filename]) 

2973 

2974 # Make a page to display the plots. 

2975 _make_plot_html_page(plot_index) 

2976 

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

2978 

2979 

2980def scatter_plot( 

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

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

2983 filename: str = None, 

2984 one_to_one: bool = True, 

2985 **kwargs, 

2986) -> iris.cube.CubeList: 

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

2988 

2989 Both cubes must be 1D. 

2990 

2991 Parameters 

2992 ---------- 

2993 cube_x: Cube | CubeList 

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

2995 cube_y: Cube | CubeList 

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

2997 filename: str, optional 

2998 Filename of the plot to write. 

2999 one_to_one: bool, optional 

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

3001 

3002 Returns 

3003 ------- 

3004 cubes: CubeList 

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

3006 

3007 Raises 

3008 ------ 

3009 ValueError 

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

3011 size. 

3012 TypeError 

3013 If the cube isn't a single cube. 

3014 

3015 Notes 

3016 ----- 

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

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

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

3020 """ 

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

3022 for cube_iter in iter_maybe(cube_x): 

3023 # Check cubes are correct shape. 

3024 cube_iter = _check_single_cube(cube_iter) 

3025 if cube_iter.ndim > 1: 

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

3027 

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

3029 for cube_iter in iter_maybe(cube_y): 

3030 # Check cubes are correct shape. 

3031 cube_iter = _check_single_cube(cube_iter) 

3032 if cube_iter.ndim > 1: 

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

3034 

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

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

3037 title = f"{recipe_title}" 

3038 

3039 if filename is None: 

3040 filename = slugify(recipe_title) 

3041 

3042 # Add file extension. 

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

3044 

3045 # Do the actual plotting. 

3046 _plot_and_save_scatter_plot(cube_x, cube_y, plot_filename, title, one_to_one) 

3047 

3048 # Add list of plots to plot metadata. 

3049 plot_index = _append_to_plot_index([plot_filename]) 

3050 

3051 # Make a page to display the plots. 

3052 _make_plot_html_page(plot_index) 

3053 

3054 return iris.cube.CubeList([cube_x, cube_y]) 

3055 

3056 

3057def vector_plot( 

3058 cube_u: iris.cube.Cube, 

3059 cube_v: iris.cube.Cube, 

3060 filename: str = None, 

3061 sequence_coordinate: str = "time", 

3062 **kwargs, 

3063) -> iris.cube.CubeList: 

3064 """Plot a vector plot based on the input u and v components.""" 

3065 recipe_title = get_recipe_metadata().get("title", "Untitled") 

3066 

3067 # Cubes must have a matching sequence coordinate. 

3068 try: 

3069 # Check that the u and v cubes have the same sequence coordinate. 

3070 if cube_u.coord(sequence_coordinate) != cube_v.coord(sequence_coordinate): 3070 ↛ anywhereline 3070 didn't jump anywhere: it always raised an exception.

3071 raise ValueError("Coordinates do not match.") 

3072 except (iris.exceptions.CoordinateNotFoundError, ValueError) as err: 

3073 raise ValueError( 

3074 f"Cubes should have matching {sequence_coordinate} coordinate:\n{cube_u}\n{cube_v}" 

3075 ) from err 

3076 

3077 # Create a plot for each value of the sequence coordinate. 

3078 plot_index = [] 

3079 nplot = np.size(cube_u[0].coord(sequence_coordinate).points) 

3080 for cube_u_slice, cube_v_slice in zip( 

3081 cube_u.slices_over(sequence_coordinate), 

3082 cube_v.slices_over(sequence_coordinate), 

3083 strict=True, 

3084 ): 

3085 # Format the coordinate value in a unit appropriate way. 

3086 seq_coord = cube_u_slice.coord(sequence_coordinate) 

3087 plot_title, plot_filename = _set_title_and_filename( 

3088 seq_coord, nplot, recipe_title, filename 

3089 ) 

3090 

3091 # Do the actual plotting. 

3092 _plot_and_save_vector_plot( 

3093 cube_u_slice, 

3094 cube_v_slice, 

3095 filename=plot_filename, 

3096 title=plot_title, 

3097 method="contourf", 

3098 ) 

3099 plot_index.append(plot_filename) 

3100 

3101 # Add list of plots to plot metadata. 

3102 complete_plot_index = _append_to_plot_index(plot_index) 

3103 

3104 # Make a page to display the plots. 

3105 _make_plot_html_page(complete_plot_index) 

3106 

3107 return iris.cube.CubeList([cube_u, cube_v]) 

3108 

3109 

3110def plot_histogram_series( 

3111 cubes: iris.cube.Cube | iris.cube.CubeList, 

3112 filename: str = None, 

3113 sequence_coordinate: str = "time", 

3114 stamp_coordinate: str = "realization", 

3115 single_plot: bool = False, 

3116 **kwargs, 

3117) -> iris.cube.Cube | iris.cube.CubeList: 

3118 """Plot a histogram plot for each vertical level provided. 

3119 

3120 A histogram plot can be plotted, but if the sequence_coordinate (i.e. time) 

3121 is present then a sequence of plots will be produced using the time slider 

3122 functionality to scroll through histograms against time. If a 

3123 stamp_coordinate is present then postage stamp plots will be produced. If 

3124 stamp_coordinate and single_plot is True, all postage stamp plots will be 

3125 plotted in a single plot instead of separate postage stamp plots. 

3126 

3127 Parameters 

3128 ---------- 

3129 cubes: Cube | iris.cube.CubeList 

3130 Iris cube or CubeList of the data to plot. It should have a single dimension other 

3131 than the stamp coordinate. 

3132 The cubes should cover the same phenomenon i.e. all cubes contain temperature data. 

3133 We do not support different data such as temperature and humidity in the same CubeList for plotting. 

3134 filename: str, optional 

3135 Name of the plot to write, used as a prefix for plot sequences. Defaults 

3136 to the recipe name. 

3137 sequence_coordinate: str, optional 

3138 Coordinate about which to make a plot sequence. Defaults to ``"time"``. 

3139 This coordinate must exist in the cube and will be used for the time 

3140 slider. 

3141 stamp_coordinate: str, optional 

3142 Coordinate about which to plot postage stamp plots. Defaults to 

3143 ``"realization"``. 

3144 single_plot: bool, optional 

3145 If True, all postage stamp plots will be plotted in a single plot. If 

3146 False, each postage stamp plot will be plotted separately. Is only valid 

3147 if stamp_coordinate exists and has more than a single point. 

3148 

3149 Returns 

3150 ------- 

3151 iris.cube.Cube | iris.cube.CubeList 

3152 The original Cube or CubeList (so further operations can be applied). 

3153 Plotted data. 

3154 

3155 Raises 

3156 ------ 

3157 ValueError 

3158 If the cube doesn't have the right dimensions. 

3159 TypeError 

3160 If the cube isn't a Cube or CubeList. 

3161 """ 

3162 recipe_title = get_recipe_metadata().get("title", "Untitled") 

3163 

3164 cubes = iter_maybe(cubes) 

3165 

3166 # Internal plotting function. 

3167 plotting_func = _plot_and_save_histogram_series 

3168 

3169 num_models = _get_num_models(cubes) 

3170 

3171 _validate_cube_shape(cubes, num_models) 

3172 

3173 # If several histograms are plotted with time as sequence_coordinate for the 

3174 # time slider option. 

3175 for cube in cubes: 

3176 try: 

3177 cube.coord(sequence_coordinate) 

3178 except iris.exceptions.CoordinateNotFoundError as err: 

3179 raise ValueError( 

3180 f"Cube must have a {sequence_coordinate} coordinate." 

3181 ) from err 

3182 

3183 # Get minimum and maximum from levels information. 

3184 levels = None 

3185 for cube in cubes: 3185 ↛ 3201line 3185 didn't jump to line 3201 because the loop on line 3185 didn't complete

3186 # First check if user-specified "auto" range variable. 

3187 # This maintains the value of levels as None, so proceed. 

3188 _, levels, _ = _colorbar_map_levels(cube, axis="y") 

3189 if levels is None: 

3190 break 

3191 # If levels is changed, recheck to use the vmin,vmax or 

3192 # levels-based ranges for histogram plots. 

3193 _, levels, _ = _colorbar_map_levels(cube) 

3194 logging.debug("levels: %s", levels) 

3195 if levels is not None: 3195 ↛ 3185line 3195 didn't jump to line 3185 because the condition on line 3195 was always true

3196 vmin = min(levels) 

3197 vmax = max(levels) 

3198 logging.debug("Updated vmin, vmax: %s, %s", vmin, vmax) 

3199 break 

3200 

3201 if levels is None: 

3202 vmin = min(cb.data.min() for cb in cubes) 

3203 vmax = max(cb.data.max() for cb in cubes) 

3204 

3205 # Make postage stamp plots if stamp_coordinate exists and has more than a 

3206 # single point. If single_plot is True: 

3207 # -- all postage stamp plots will be plotted in a single plot instead of 

3208 # separate postage stamp plots. 

3209 # -- model names (hidden in cube attrs) are ignored, that is stamp plots are 

3210 # produced per single model only 

3211 if num_models == 1: 3211 ↛ 3224line 3211 didn't jump to line 3224 because the condition on line 3211 was always true

3212 if ( 3212 ↛ 3216line 3212 didn't jump to line 3216 because the condition on line 3212 was never true

3213 stamp_coordinate in [c.name() for c in cubes[0].coords()] 

3214 and cubes[0].coord(stamp_coordinate).shape[0] > 1 

3215 ): 

3216 if single_plot: 

3217 plotting_func = ( 

3218 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

3219 ) 

3220 else: 

3221 plotting_func = _plot_and_save_postage_stamp_histogram_series 

3222 cube_iterables = cubes[0].slices_over(sequence_coordinate) 

3223 else: 

3224 all_points = sorted( 

3225 set( 

3226 itertools.chain.from_iterable( 

3227 cb.coord(sequence_coordinate).points for cb in cubes 

3228 ) 

3229 ) 

3230 ) 

3231 all_slices = list( 

3232 itertools.chain.from_iterable( 

3233 cb.slices_over(sequence_coordinate) for cb in cubes 

3234 ) 

3235 ) 

3236 # Matched slices (matched by seq coord point; it may happen that 

3237 # evaluated models do not cover the same seq coord range, hence matching 

3238 # necessary) 

3239 cube_iterables = [ 

3240 iris.cube.CubeList( 

3241 s for s in all_slices if s.coord(sequence_coordinate).points[0] == point 

3242 ) 

3243 for point in all_points 

3244 ] 

3245 

3246 plot_index = [] 

3247 nplot = np.size(cube.coord(sequence_coordinate).points) 

3248 # Create a plot for each value of the sequence coordinate. Allowing for 

3249 # multiple cubes in a CubeList to be plotted in the same plot for similar 

3250 # sequence values. Passing a CubeList into the internal plotting function 

3251 # for similar values of the sequence coordinate. cube_slice can be an 

3252 # iris.cube.Cube or an iris.cube.CubeList. 

3253 for cube_slice in cube_iterables: 

3254 single_cube = cube_slice 

3255 if isinstance(cube_slice, iris.cube.CubeList): 3255 ↛ 3256line 3255 didn't jump to line 3256 because the condition on line 3255 was never true

3256 single_cube = cube_slice[0] 

3257 

3258 # Ensure valid stamp coordinate in cube dimensions 

3259 if stamp_coordinate == "realization": 3259 ↛ 3262line 3259 didn't jump to line 3262 because the condition on line 3259 was always true

3260 stamp_coordinate = check_stamp_coordinate(single_cube) 

3261 # Set plot titles and filename, based on sequence coordinate 

3262 seq_coord = single_cube.coord(sequence_coordinate) 

3263 # Use time coordinate in title and filename if single histogram output. 

3264 if sequence_coordinate == "realization" and nplot == 1: 3264 ↛ 3265line 3264 didn't jump to line 3265 because the condition on line 3264 was never true

3265 seq_coord = single_cube.coord("time") 

3266 plot_title, plot_filename = _set_title_and_filename( 

3267 seq_coord, nplot, recipe_title, filename 

3268 ) 

3269 

3270 # Do the actual plotting. 

3271 plotting_func( 

3272 cube_slice, 

3273 filename=plot_filename, 

3274 stamp_coordinate=stamp_coordinate, 

3275 title=plot_title, 

3276 vmin=vmin, 

3277 vmax=vmax, 

3278 ) 

3279 plot_index.append(plot_filename) 

3280 

3281 # Add list of plots to plot metadata. 

3282 complete_plot_index = _append_to_plot_index(plot_index) 

3283 

3284 # Make a page to display the plots. 

3285 _make_plot_html_page(complete_plot_index) 

3286 

3287 return cubes 

3288 

3289 

3290def plot_power_spectrum_series( 

3291 cubes: iris.cube.Cube | iris.cube.CubeList, 

3292 filename: str = None, 

3293 sequence_coordinate: str = "time", 

3294 stamp_coordinate: str = "realization", 

3295 single_plot: bool = False, 

3296 **kwargs, 

3297) -> iris.cube.Cube | iris.cube.CubeList: 

3298 """Plot a power spectrum plot for each vertical level provided. 

3299 

3300 A power spectrum plot can be plotted, but if the sequence_coordinate (i.e. time) 

3301 is present then a sequence of plots will be produced using the time slider 

3302 functionality to scroll through power spectrum against time. If a 

3303 stamp_coordinate is present then postage stamp plots will be produced. If 

3304 stamp_coordinate and single_plot is True, all postage stamp plots will be 

3305 plotted in a single plot instead of separate postage stamp plots. 

3306 

3307 Parameters 

3308 ---------- 

3309 cubes: Cube | iris.cube.CubeList 

3310 Iris cube or CubeList of the data to plot. It should have a single dimension other 

3311 than the stamp coordinate. 

3312 The cubes should cover the same phenomenon i.e. all cubes contain temperature data. 

3313 We do not support different data such as temperature and humidity in the same CubeList for plotting. 

3314 filename: str, optional 

3315 Name of the plot to write, used as a prefix for plot sequences. Defaults 

3316 to the recipe name. 

3317 sequence_coordinate: str, optional 

3318 Coordinate about which to make a plot sequence. Defaults to ``"time"``. 

3319 This coordinate must exist in the cube and will be used for the time 

3320 slider. 

3321 stamp_coordinate: str, optional 

3322 Coordinate about which to plot postage stamp plots. Defaults to 

3323 ``"realization"``. 

3324 single_plot: bool, optional 

3325 If True, all postage stamp plots will be plotted in a single plot. If 

3326 False, each postage stamp plot will be plotted separately. Is only valid 

3327 if stamp_coordinate exists and has more than a single point. 

3328 

3329 Returns 

3330 ------- 

3331 iris.cube.Cube | iris.cube.CubeList 

3332 The original Cube or CubeList (so further operations can be applied). 

3333 Plotted data. 

3334 

3335 Raises 

3336 ------ 

3337 ValueError 

3338 If the cube doesn't have the right dimensions. 

3339 TypeError 

3340 If the cube isn't a Cube or CubeList. 

3341 """ 

3342 recipe_title = get_recipe_metadata().get("title", "Untitled") 

3343 

3344 cubes = iter_maybe(cubes) 

3345 

3346 # Internal plotting function. 

3347 plotting_func = _plot_and_save_power_spectrum_series 

3348 

3349 num_models = _get_num_models(cubes) 

3350 

3351 _validate_cube_shape(cubes, num_models) 

3352 

3353 # If several power spectra are plotted with time as sequence_coordinate for the 

3354 # time slider option. 

3355 for cube in cubes: 

3356 try: 

3357 cube.coord(sequence_coordinate) 

3358 except iris.exceptions.CoordinateNotFoundError as err: 

3359 raise ValueError( 

3360 f"Cube must have a {sequence_coordinate} coordinate." 

3361 ) from err 

3362 

3363 # Make postage stamp plots if stamp_coordinate exists and has more than a 

3364 # single point. If single_plot is True: 

3365 # -- all postage stamp plots will be plotted in a single plot instead of 

3366 # separate postage stamp plots. 

3367 # -- model names (hidden in cube attrs) are ignored, that is stamp plots are 

3368 # produced per single model only 

3369 if num_models == 1: 3369 ↛ 3382line 3369 didn't jump to line 3382 because the condition on line 3369 was always true

3370 if ( 3370 ↛ 3374line 3370 didn't jump to line 3374 because the condition on line 3370 was never true

3371 stamp_coordinate in [c.name() for c in cubes[0].coords()] 

3372 and cubes[0].coord(stamp_coordinate).shape[0] > 1 

3373 ): 

3374 if single_plot: 

3375 plotting_func = ( 

3376 _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series 

3377 ) 

3378 else: 

3379 plotting_func = _plot_and_save_postage_stamp_power_spectrum_series 

3380 cube_iterables = cubes[0].slices_over(sequence_coordinate) 

3381 else: 

3382 all_points = sorted( 

3383 set( 

3384 itertools.chain.from_iterable( 

3385 cb.coord(sequence_coordinate).points for cb in cubes 

3386 ) 

3387 ) 

3388 ) 

3389 all_slices = list( 

3390 itertools.chain.from_iterable( 

3391 cb.slices_over(sequence_coordinate) for cb in cubes 

3392 ) 

3393 ) 

3394 # Matched slices (matched by seq coord point; it may happen that 

3395 # evaluated models do not cover the same seq coord range, hence matching 

3396 # necessary) 

3397 cube_iterables = [ 

3398 iris.cube.CubeList( 

3399 s for s in all_slices if s.coord(sequence_coordinate).points[0] == point 

3400 ) 

3401 for point in all_points 

3402 ] 

3403 

3404 plot_index = [] 

3405 nplot = np.size(cube.coord(sequence_coordinate).points) 

3406 # Create a plot for each value of the sequence coordinate. Allowing for 

3407 # multiple cubes in a CubeList to be plotted in the same plot for similar 

3408 # sequence values. Passing a CubeList into the internal plotting function 

3409 # for similar values of the sequence coordinate. cube_slice can be an 

3410 # iris.cube.Cube or an iris.cube.CubeList. 

3411 for cube_slice in cube_iterables: 

3412 single_cube = cube_slice 

3413 if isinstance(cube_slice, iris.cube.CubeList): 3413 ↛ 3414line 3413 didn't jump to line 3414 because the condition on line 3413 was never true

3414 single_cube = cube_slice[0] 

3415 

3416 # Set stamp coordinate 

3417 if stamp_coordinate == "realization": 3417 ↛ 3420line 3417 didn't jump to line 3420 because the condition on line 3417 was always true

3418 stamp_coordinate = check_stamp_coordinate(single_cube) 

3419 # Set plot title and filenames based on sequence values 

3420 seq_coord = single_cube.coord(sequence_coordinate) 

3421 plot_title, plot_filename = _set_title_and_filename( 

3422 seq_coord, nplot, recipe_title, filename 

3423 ) 

3424 

3425 # Do the actual plotting. 

3426 plotting_func( 

3427 cube_slice, 

3428 filename=plot_filename, 

3429 stamp_coordinate=stamp_coordinate, 

3430 title=plot_title, 

3431 ) 

3432 plot_index.append(plot_filename) 

3433 

3434 # Add list of plots to plot metadata. 

3435 complete_plot_index = _append_to_plot_index(plot_index) 

3436 

3437 # Make a page to display the plots. 

3438 _make_plot_html_page(complete_plot_index) 

3439 

3440 return cubes 

3441 

3442 

3443def _DCT_ps(y_3d): 

3444 """Calculate power spectra for regional domains. 

3445 

3446 Parameters 

3447 ---------- 

3448 y_3d: 3D array 

3449 3 dimensional array to calculate spectrum for. 

3450 (2D field data with 3rd dimension of time) 

3451 

3452 Returns: ps_array 

3453 Array of power spectra values calculated for input field (for each time) 

3454 

3455 Method for regional domains: 

3456 Calculate power spectra over limited area domain using Discrete Cosine Transform (DCT) 

3457 as described in Denis et al 2002 [Denis_etal_2002]_. 

3458 

3459 References 

3460 ---------- 

3461 .. [Denis_etal_2002] Bertrand Denis, Jean Côté and René Laprise (2002) 

3462 "Spectral Decomposition of Two-Dimensional Atmospheric Fields on 

3463 Limited-Area Domains Using the Discrete Cosine Transform (DCT)" 

3464 Monthly Weather Review, Vol. 130, 1812-1828 

3465 doi: https://doi.org/10.1175/1520-0493(2002)130<1812:SDOTDA>2.0.CO;2 

3466 """ 

3467 Nt, Ny, Nx = y_3d.shape 

3468 

3469 # Max coefficient 

3470 Nmin = min(Nx - 1, Ny - 1) 

3471 

3472 # Create alpha matrix (of wavenumbers) 

3473 alpha_matrix = _create_alpha_matrix(Ny, Nx) 

3474 

3475 # Prepare output array 

3476 ps_array = np.zeros((Nt, Nmin)) 

3477 

3478 # Loop over time to get spectrum for each time. 

3479 for t in range(Nt): 

3480 y_2d = y_3d[t] 

3481 

3482 # Apply 2D DCT to transform y_3d[t] from physical space to spectral space. 

3483 # fkk is a 2D array of DCT coefficients, representing the amplitudes of 

3484 # cosine basis functions at different spatial frequencies. 

3485 

3486 # normalise spectrum to allow comparison between models. 

3487 fkk = fft.dctn(y_2d, norm="ortho") 

3488 

3489 # Normalise fkk 

3490 fkk = fkk / np.sqrt(Ny * Nx) 

3491 

3492 # calculate variance of spectral coefficient 

3493 sigma_2 = fkk**2 / Nx / Ny 

3494 

3495 # Group ellipses of alphas into the same wavenumber k/Nmin 

3496 for k in range(1, Nmin + 1): 

3497 alpha = k / Nmin 

3498 alpha_p1 = (k + 1) / Nmin 

3499 

3500 # Sum up elements matching k 

3501 mask_k = np.where((alpha_matrix >= alpha) & (alpha_matrix < alpha_p1)) 

3502 ps_array[t, k - 1] = np.sum(sigma_2[mask_k]) 

3503 

3504 return ps_array 

3505 

3506 

3507def _create_alpha_matrix(Ny, Nx): 

3508 """Construct an array of 2D wavenumbers from 2D wavenumber pair. 

3509 

3510 Parameters 

3511 ---------- 

3512 Ny, Nx: 

3513 Dimensions of the 2D field for which the power spectra is calculated. Used to 

3514 create the array of 2D wavenumbers. Each Ny, Nx pair is associated with a 

3515 single-scale parameter. 

3516 

3517 Returns: alpha_matrix 

3518 normalisation of 2D wavenumber axes, transforming the spectral domain into 

3519 an elliptic coordinate system. 

3520 

3521 """ 

3522 # Create x_indices: each row is [1, 2, ..., Nx] 

3523 x_indices = np.tile(np.arange(1, Nx + 1), (Ny, 1)) 

3524 

3525 # Create y_indices: each column is [1, 2, ..., Ny] 

3526 y_indices = np.tile(np.arange(1, Ny + 1).reshape(Ny, 1), (1, Nx)) 

3527 

3528 # Compute alpha_matrix 

3529 alpha_matrix = np.sqrt((x_indices**2) / Nx**2 + (y_indices**2) / Ny**2) 

3530 

3531 return alpha_matrix