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

950 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-02 15:01 +0000

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

2# 

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

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

5# You may obtain a copy of the License at 

6# 

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

8# 

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

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

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

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

13# limitations under the License. 

14 

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

16 

17import fcntl 

18import functools 

19import importlib.resources 

20import itertools 

21import json 

22import logging 

23import math 

24import os 

25from typing import Literal 

26 

27import cartopy.crs as ccrs 

28import iris 

29import iris.coords 

30import iris.cube 

31import iris.exceptions 

32import iris.plot as iplt 

33import matplotlib as mpl 

34import matplotlib.colors as mcolors 

35import matplotlib.pyplot as plt 

36import numpy as np 

37import scipy.fft as fft 

38from iris.cube import Cube 

39from markdown_it import MarkdownIt 

40 

41from CSET._common import ( 

42 combine_dicts, 

43 get_recipe_metadata, 

44 iter_maybe, 

45 render_file, 

46 slugify, 

47) 

48from CSET.operators._utils import ( 

49 fully_equalise_attributes, 

50 get_cube_yxcoordname, 

51 is_transect, 

52) 

53from CSET.operators.collapse import collapse 

54from CSET.operators.misc import _extract_common_time_points 

55from CSET.operators.regrid import regrid_onto_cube 

56 

57# Use a non-interactive plotting backend. 

58mpl.use("agg") 

59 

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

61 

62############################ 

63# Private helper functions # 

64############################ 

65 

66 

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

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

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

70 fcntl.flock(fp, fcntl.LOCK_EX) 

71 fp.seek(0) 

72 meta = json.load(fp) 

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

74 complete_plot_index = complete_plot_index + plot_index 

75 meta["plots"] = complete_plot_index 

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

77 os.getenv("DO_CASE_AGGREGATION") 

78 ): 

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

80 fp.seek(0) 

81 fp.truncate() 

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

83 return complete_plot_index 

84 

85 

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

87 """Ensure a single cube is given. 

88 

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

90 otherwise an error is raised. 

91 

92 Parameters 

93 ---------- 

94 cube: Cube | CubeList 

95 The cube to check. 

96 

97 Returns 

98 ------- 

99 cube: Cube 

100 The checked cube. 

101 

102 Raises 

103 ------ 

104 TypeError 

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

106 """ 

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

108 return cube 

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

110 if len(cube) == 1: 

111 return cube[0] 

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

113 

114 

115def _make_plot_html_page(plots: list): 

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

117 # Debug check that plots actually contains some strings. 

118 assert isinstance(plots[0], str) 

119 

120 # Load HTML template file. 

121 operator_files = importlib.resources.files() 

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

123 

124 # Get some metadata. 

125 meta = get_recipe_metadata() 

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

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

128 

129 # Prepare template variables. 

130 variables = { 

131 "title": title, 

132 "description": description, 

133 "initial_plot": plots[0], 

134 "plots": plots, 

135 "title_slug": slugify(title), 

136 } 

137 

138 # Render template. 

139 html = render_file(template_file, **variables) 

140 

141 # Save completed HTML. 

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

143 fp.write(html) 

144 

145 

146@functools.cache 

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

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

149 

150 This is a separate function to make it cacheable. 

151 """ 

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

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

154 colorbar = json.load(fp) 

155 

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

157 override_colorbar = {} 

158 if user_colorbar_file: 

159 try: 

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

161 override_colorbar = json.load(fp) 

162 except FileNotFoundError: 

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

164 

165 # Overwrite values with the user supplied colorbar definition. 

166 colorbar = combine_dicts(colorbar, override_colorbar) 

167 return colorbar 

168 

169 

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

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

172 

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

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

175 to model_name attribute. 

176 

177 Parameters 

178 ---------- 

179 cubes: CubeList or Cube 

180 Cubes with model_name attribute 

181 

182 Returns 

183 ------- 

184 model_colors_map: 

185 Dictionary mapping model_name attribute to colors 

186 """ 

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

188 colorbar = _load_colorbar_map(user_colorbar_file) 

189 model_names = sorted( 

190 filter( 

191 lambda x: x is not None, 

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

193 ) 

194 ) 

195 if not model_names: 

196 return {} 

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

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

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

200 

201 color_list = itertools.cycle(DEFAULT_DISCRETE_COLORS) 

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

203 

204 

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

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

207 

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

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

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

211 exist for specific pressure levels to account for variables with 

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

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

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

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

216 

217 Parameters 

218 ---------- 

219 cube: Cube 

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

221 axis: "x", "y", optional 

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

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

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

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

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

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

228 

229 Returns 

230 ------- 

231 cmap: 

232 Matplotlib colormap. 

233 levels: 

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

235 should be taken as the range. 

236 norm: 

237 BoundaryNorm information. 

238 """ 

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

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

241 colorbar = _load_colorbar_map(user_colorbar_file) 

242 cmap = None 

243 

244 try: 

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

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

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

248 pressure_level = str(int(pressure_level_raw)) 

249 except iris.exceptions.CoordinateNotFoundError: 

250 pressure_level = None 

251 

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

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

254 # consistent. 

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

256 for varname in varnames: 

257 # Get the colormap for this variable. 

258 try: 

259 var_colorbar = colorbar[varname] 

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

261 varname_key = varname 

262 break 

263 except KeyError: 

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

265 

266 # Get colormap if it is a mask. 

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

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

269 return cmap, levels, norm 

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

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

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

273 return cmap, levels, norm 

274 # If probability is plotted use custom colorbar and levels 

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

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

277 return cmap, levels, norm 

278 # If aviation colour state use custom colorbar and levels 

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

280 cmap, levels, norm = _custom_colormap_aviation_colour_state(cube) 

281 return cmap, levels, norm 

282 

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

284 if not cmap: 

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

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

287 return cmap, levels, norm 

288 

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

290 if pressure_level: 

291 try: 

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

293 except KeyError: 

294 logging.debug( 

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

296 varname, 

297 pressure_level, 

298 ) 

299 

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

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

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

303 if axis: 

304 if axis == "x": 

305 try: 

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

307 except KeyError: 

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

309 if axis == "y": 

310 try: 

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

312 except KeyError: 

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

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

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

316 levels = None 

317 else: 

318 levels = [vmin, vmax] 

319 return None, levels, None 

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

321 else: 

322 try: 

323 levels = var_colorbar["levels"] 

324 # Use discrete bins when levels are specified, rather 

325 # than a smooth range. 

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

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

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

329 except KeyError: 

330 # Get the range for this variable. 

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

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

333 # Calculate levels from range. 

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

335 norm = None 

336 

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

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

339 # JSON file. 

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

341 cmap, levels, norm = _custom_colourmap_visibility_in_air( 

342 cube, cmap, levels, norm 

343 ) 

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

345 return cmap, levels, norm 

346 

347 

348def _setup_spatial_map( 

349 cube: iris.cube.Cube, 

350 figure, 

351 cmap, 

352 grid_size: int | None = None, 

353 subplot: int | None = None, 

354): 

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

356 

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

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

359 

360 Parameters 

361 ---------- 

362 cube: Cube 

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

364 figure: 

365 Matplotlib Figure object holding all plot elements. 

366 cmap: 

367 Matplotlib colormap. 

368 grid_size: int, optional 

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

370 subplot: int, optional 

371 Subplot index if multiple spatial subplots in figure. 

372 

373 Returns 

374 ------- 

375 axes: 

376 Matplotlib GeoAxes definition. 

377 """ 

378 # Identify min/max plot bounds. 

379 try: 

380 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

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

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

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

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

385 

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

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

388 x1 = x1 - 180.0 

389 x2 = x2 - 180.0 

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

391 

392 # Consider map projection orientation. 

393 # Adapting orientation enables plotting across international dateline. 

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

395 if x2 > 180.0 or x1 < -180.0: 

396 central_longitude = 180.0 

397 else: 

398 central_longitude = 0.0 

399 

400 # Define spatial map projection. 

401 coord_system = cube.coord(lat_axis).coord_system 

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

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

404 projection = ccrs.RotatedPole( 

405 pole_longitude=coord_system.grid_north_pole_longitude, 

406 pole_latitude=coord_system.grid_north_pole_latitude, 

407 central_rotated_longitude=central_longitude, 

408 ) 

409 crs = projection 

410 else: 

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

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

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

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

415 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

416 crs = ccrs.PlateCarree() 

417 

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

419 if subplot is not None: 

420 axes = figure.add_subplot( 

421 grid_size, grid_size, subplot, projection=projection 

422 ) 

423 else: 

424 axes = figure.add_subplot(projection=projection) 

425 

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

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

428 coastcol = "magenta" 

429 else: 

430 coastcol = "black" 

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

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

433 

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

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

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

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

438 

439 except ValueError: 

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

441 axes = figure.gca() 

442 pass 

443 

444 return axes 

445 

446 

447def _get_plot_resolution() -> int: 

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

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

450 

451 

452def _plot_and_save_spatial_plot( 

453 cube: iris.cube.Cube, 

454 filename: str, 

455 title: str, 

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

457 **kwargs, 

458): 

459 """Plot and save a spatial plot. 

460 

461 Parameters 

462 ---------- 

463 cube: Cube 

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

465 filename: str 

466 Filename of the plot to write. 

467 title: str 

468 Plot title. 

469 method: "contourf" | "pcolormesh" 

470 The plotting method to use. 

471 """ 

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

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

474 

475 # Specify the color bar 

476 cmap, levels, norm = _colorbar_map_levels(cube) 

477 

478 # Setup plot map projection, extent and coastlines. 

479 axes = _setup_spatial_map(cube, fig, cmap) 

480 

481 # Plot the field. 

482 if method == "contourf": 

483 # Filled contour plot of the field. 

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

485 elif method == "pcolormesh": 

486 try: 

487 vmin = min(levels) 

488 vmax = max(levels) 

489 except TypeError: 

490 vmin, vmax = None, None 

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

492 # if levels are defined. 

493 if norm is not None: 

494 vmin = None 

495 vmax = None 

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

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

498 else: 

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

500 

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

502 if is_transect(cube): 

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

504 axes.invert_yaxis() 

505 axes.set_yscale("log") 

506 axes.set_ylim(1100, 100) 

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

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

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

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

511 ): 

512 axes.set_yscale("log") 

513 

514 axes.set_title( 

515 f"{title}\n" 

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

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

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

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

520 fontsize=16, 

521 ) 

522 

523 else: 

524 # Add title. 

525 axes.set_title(title, fontsize=16) 

526 

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

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

529 axes.annotate( 

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

531 xy=(1, -0.05), 

532 xycoords="axes fraction", 

533 xytext=(-5, 5), 

534 textcoords="offset points", 

535 ha="right", 

536 va="bottom", 

537 size=11, 

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

539 ) 

540 

541 # Add colour bar. 

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

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

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

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

546 cbar.set_ticks(levels) 

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

548 if "visibility" in cube.name(): 548 ↛ 549line 548 didn't jump to line 549 because the condition on line 548 was never true

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

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

551 

552 # Save plot. 

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

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

555 plt.close(fig) 

556 

557 

558def _plot_and_save_postage_stamp_spatial_plot( 

559 cube: iris.cube.Cube, 

560 filename: str, 

561 stamp_coordinate: str, 

562 title: str, 

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

564 **kwargs, 

565): 

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

567 

568 Parameters 

569 ---------- 

570 cube: Cube 

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

572 filename: str 

573 Filename of the plot to write. 

574 stamp_coordinate: str 

575 Coordinate that becomes different plots. 

576 method: "contourf" | "pcolormesh" 

577 The plotting method to use. 

578 

579 Raises 

580 ------ 

581 ValueError 

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

583 """ 

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

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

586 

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

588 

589 # Specify the color bar 

590 cmap, levels, norm = _colorbar_map_levels(cube) 

591 

592 # Make a subplot for each member. 

593 for member, subplot in zip( 

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

595 ): 

596 # Setup subplot map projection, extent and coastlines. 

597 axes = _setup_spatial_map( 

598 member, fig, cmap, grid_size=grid_size, subplot=subplot 

599 ) 

600 if method == "contourf": 

601 # Filled contour plot of the field. 

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

603 elif method == "pcolormesh": 

604 if levels is not None: 

605 vmin = min(levels) 

606 vmax = max(levels) 

607 else: 

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

609 vmin, vmax = None, None 

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

611 # if levels are defined. 

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

613 vmin = None 

614 vmax = None 

615 # pcolormesh plot of the field. 

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

617 else: 

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

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

620 axes.set_axis_off() 

621 

622 # Put the shared colorbar in its own axes. 

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

624 colorbar = fig.colorbar( 

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

626 ) 

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

628 

629 # Overall figure title. 

630 fig.suptitle(title, fontsize=16) 

631 

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

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

634 plt.close(fig) 

635 

636 

637def _plot_and_save_line_series( 

638 cubes: iris.cube.CubeList, 

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

640 ensemble_coord: str, 

641 filename: str, 

642 title: str, 

643 **kwargs, 

644): 

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

646 

647 Parameters 

648 ---------- 

649 cubes: Cube or CubeList 

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

651 coords: list[Coord] 

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

653 ensemble_coord: str 

654 Ensemble coordinate in the cube. 

655 filename: str 

656 Filename of the plot to write. 

657 title: str 

658 Plot title. 

659 """ 

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

661 

662 model_colors_map = _get_model_colors_map(cubes) 

663 

664 # Store min/max ranges. 

665 y_levels = [] 

666 

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

668 _validate_cubes_coords(cubes, coords) 

669 

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

671 label = None 

672 color = "black" 

673 if model_colors_map: 

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

675 color = model_colors_map.get(label) 

676 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

679 iplt.plot( 

680 coord, 

681 cube_slice, 

682 color=color, 

683 marker="o", 

684 ls="-", 

685 lw=3, 

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

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

688 else label, 

689 ) 

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

691 else: 

692 iplt.plot( 

693 coord, 

694 cube_slice, 

695 color=color, 

696 ls="-", 

697 lw=1.5, 

698 alpha=0.75, 

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

700 ) 

701 

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

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

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

705 y_levels.append(min(levels)) 

706 y_levels.append(max(levels)) 

707 

708 # Get the current axes. 

709 ax = plt.gca() 

710 

711 # Add some labels and tweak the style. 

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

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

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

715 ax.set_title(title, fontsize=16) 

716 

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

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

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

720 

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

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

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

724 # Add zero line. 

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

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

727 logging.debug( 

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

729 ) 

730 else: 

731 ax.autoscale() 

732 

733 # Add gridlines 

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

735 # Ientify unique labels for legend 

736 handles = list( 

737 { 

738 label: handle 

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

740 }.values() 

741 ) 

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

743 

744 # Save plot. 

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

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

747 plt.close(fig) 

748 

749 

750def _plot_and_save_vertical_line_series( 

751 cubes: iris.cube.CubeList, 

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

753 ensemble_coord: str, 

754 filename: str, 

755 series_coordinate: str, 

756 title: str, 

757 vmin: float, 

758 vmax: float, 

759 **kwargs, 

760): 

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

762 

763 Parameters 

764 ---------- 

765 cubes: CubeList 

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

767 coord: list[Coord] 

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

769 ensemble_coord: str 

770 Ensemble coordinate in the cube. 

771 filename: str 

772 Filename of the plot to write. 

773 series_coordinate: str 

774 Coordinate to use as vertical axis. 

775 title: str 

776 Plot title. 

777 vmin: float 

778 Minimum value for the x-axis. 

779 vmax: float 

780 Maximum value for the x-axis. 

781 """ 

782 # plot the vertical pressure axis using log scale 

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

784 

785 model_colors_map = _get_model_colors_map(cubes) 

786 

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

788 _validate_cubes_coords(cubes, coords) 

789 

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

791 label = None 

792 color = "black" 

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

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

795 color = model_colors_map.get(label) 

796 

797 for cube_slice in cube.slices_over(ensemble_coord): 

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

799 # unless single forecast. 

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

801 iplt.plot( 

802 cube_slice, 

803 coord, 

804 color=color, 

805 marker="o", 

806 ls="-", 

807 lw=3, 

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

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

810 else label, 

811 ) 

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

813 else: 

814 iplt.plot( 

815 cube_slice, 

816 coord, 

817 color=color, 

818 ls="-", 

819 lw=1.5, 

820 alpha=0.75, 

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

822 ) 

823 

824 # Get the current axis 

825 ax = plt.gca() 

826 

827 # Special handling for pressure level data. 

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

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

830 ax.invert_yaxis() 

831 ax.set_yscale("log") 

832 

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

834 y_tick_labels = [ 

835 "1000", 

836 "850", 

837 "700", 

838 "500", 

839 "300", 

840 "200", 

841 "100", 

842 ] 

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

844 

845 # Set y-axis limits and ticks. 

846 ax.set_ylim(1100, 100) 

847 

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

849 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

855 

856 ax.set_yticks(y_ticks) 

857 ax.set_yticklabels(y_tick_labels) 

858 

859 # Set x-axis limits. 

860 ax.set_xlim(vmin, vmax) 

861 # Mark y=0 if present in plot. 

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

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

864 

865 # Add some labels and tweak the style. 

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

867 ax.set_xlabel( 

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

869 ) 

870 ax.set_title(title, fontsize=16) 

871 ax.ticklabel_format(axis="x") 

872 ax.tick_params(axis="y") 

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

874 

875 # Add gridlines 

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

877 # Ientify unique labels for legend 

878 handles = list( 

879 { 

880 label: handle 

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

882 }.values() 

883 ) 

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

885 

886 # Save plot. 

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

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

889 plt.close(fig) 

890 

891 

892def _plot_and_save_scatter_plot( 

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

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

895 filename: str, 

896 title: str, 

897 one_to_one: bool, 

898 model_names: list[str] = None, 

899 **kwargs, 

900): 

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

902 

903 Parameters 

904 ---------- 

905 cube_x: Cube | CubeList 

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

907 cube_y: Cube | CubeList 

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

909 filename: str 

910 Filename of the plot to write. 

911 title: str 

912 Plot title. 

913 one_to_one: bool 

914 Whether a 1:1 line is plotted. 

915 """ 

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

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

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

919 # over the pairs simultaneously. 

920 

921 # Ensure cube_x and cube_y are iterable 

922 cube_x_iterable = iter_maybe(cube_x) 

923 cube_y_iterable = iter_maybe(cube_y) 

924 

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

926 iplt.scatter(cube_x_iter, cube_y_iter) 

927 if one_to_one is True: 

928 plt.plot( 

929 [ 

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

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

932 ], 

933 [ 

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

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

936 ], 

937 "k", 

938 linestyle="--", 

939 ) 

940 ax = plt.gca() 

941 

942 # Add some labels and tweak the style. 

943 if model_names is None: 

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

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

946 else: 

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

948 ax.set_xlabel( 

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

950 ) 

951 ax.set_ylabel( 

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

953 ) 

954 ax.set_title(title, fontsize=16) 

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

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

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

958 ax.autoscale() 

959 

960 # Save plot. 

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

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

963 plt.close(fig) 

964 

965 

966def _plot_and_save_vector_plot( 

967 cube_u: iris.cube.Cube, 

968 cube_v: iris.cube.Cube, 

969 filename: str, 

970 title: str, 

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

972 **kwargs, 

973): 

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

975 

976 Parameters 

977 ---------- 

978 cube_u: Cube 

979 2 dimensional Cube of u component of the data. 

980 cube_v: Cube 

981 2 dimensional Cube of v component of the data. 

982 filename: str 

983 Filename of the plot to write. 

984 title: str 

985 Plot title. 

986 """ 

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

988 

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

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

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

992 

993 # Specify the color bar 

994 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag) 

995 

996 # Setup plot map projection, extent and coastlines. 

997 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

998 

999 if method == "contourf": 999 ↛ 1002line 999 didn't jump to line 1002 because the condition on line 999 was always true

1000 # Filled contour plot of the field. 

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

1002 elif method == "pcolormesh": 

1003 try: 

1004 vmin = min(levels) 

1005 vmax = max(levels) 

1006 except TypeError: 

1007 vmin, vmax = None, None 

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

1009 # if levels are defined. 

1010 if norm is not None: 

1011 vmin = None 

1012 vmax = None 

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

1014 else: 

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

1016 

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

1018 if is_transect(cube_vec_mag): 1018 ↛ 1019line 1018 didn't jump to line 1019 because the condition on line 1018 was never true

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

1020 axes.invert_yaxis() 

1021 axes.set_yscale("log") 

1022 axes.set_ylim(1100, 100) 

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

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

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

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

1027 ): 

1028 axes.set_yscale("log") 

1029 

1030 axes.set_title( 

1031 f"{title}\n" 

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

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

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

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

1036 fontsize=16, 

1037 ) 

1038 

1039 else: 

1040 # Add title. 

1041 axes.set_title(title, fontsize=16) 

1042 

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

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

1045 axes.annotate( 

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

1047 xy=(1, -0.05), 

1048 xycoords="axes fraction", 

1049 xytext=(-5, 5), 

1050 textcoords="offset points", 

1051 ha="right", 

1052 va="bottom", 

1053 size=11, 

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

1055 ) 

1056 

1057 # Add colour bar. 

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

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

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

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

1062 cbar.set_ticks(levels) 

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

1064 

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

1066 # with less than 30 points. 

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

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

1069 

1070 # Save plot. 

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

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

1073 plt.close(fig) 

1074 

1075 

1076def _plot_and_save_histogram_series( 

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

1078 filename: str, 

1079 title: str, 

1080 vmin: float, 

1081 vmax: float, 

1082 **kwargs, 

1083): 

1084 """Plot and save a histogram series. 

1085 

1086 Parameters 

1087 ---------- 

1088 cubes: Cube or CubeList 

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

1090 filename: str 

1091 Filename of the plot to write. 

1092 title: str 

1093 Plot title. 

1094 vmin: float 

1095 minimum for colorbar 

1096 vmax: float 

1097 maximum for colorbar 

1098 """ 

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

1100 ax = plt.gca() 

1101 

1102 model_colors_map = _get_model_colors_map(cubes) 

1103 

1104 # Set default that histograms will produce probability density function 

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

1106 density = True 

1107 

1108 for cube in iter_maybe(cubes): 

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

1110 # than seeing if long names exist etc. 

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

1112 if "surface_microphysical" in title: 

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

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

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

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

1117 density = False 

1118 else: 

1119 bins = 10.0 ** ( 

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

1121 ) # Suggestion from RMED toolbox. 

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

1123 ax.set_yscale("log") 

1124 vmin = bins[1] 

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

1126 ax.set_xscale("log") 

1127 elif "lightning" in title: 

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

1129 else: 

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

1131 logging.debug( 

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

1133 np.size(bins), 

1134 np.min(bins), 

1135 np.max(bins), 

1136 ) 

1137 

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

1139 # Otherwise we plot xdim histograms stacked. 

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

1141 

1142 label = None 

1143 color = "black" 

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

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

1146 color = model_colors_map[label] 

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

1148 

1149 # Compute area under curve. 

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

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

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

1153 x = x[1:] 

1154 y = y[1:] 

1155 

1156 ax.plot( 

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

1158 ) 

1159 

1160 # Add some labels and tweak the style. 

1161 ax.set_title(title, fontsize=16) 

1162 ax.set_xlabel( 

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

1164 ) 

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

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

1167 ax.set_ylabel( 

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

1169 ) 

1170 ax.set_xlim(vmin, vmax) 

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

1172 

1173 # Overlay grid-lines onto histogram plot. 

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

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

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

1177 

1178 # Save plot. 

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

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

1181 plt.close(fig) 

1182 

1183 

1184def _plot_and_save_postage_stamp_histogram_series( 

1185 cube: iris.cube.Cube, 

1186 filename: str, 

1187 title: str, 

1188 stamp_coordinate: str, 

1189 vmin: float, 

1190 vmax: float, 

1191 **kwargs, 

1192): 

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

1194 

1195 Parameters 

1196 ---------- 

1197 cube: Cube 

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

1199 filename: str 

1200 Filename of the plot to write. 

1201 title: str 

1202 Plot title. 

1203 stamp_coordinate: str 

1204 Coordinate that becomes different plots. 

1205 vmin: float 

1206 minimum for pdf x-axis 

1207 vmax: float 

1208 maximum for pdf x-axis 

1209 """ 

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

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

1212 

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

1214 # Make a subplot for each member. 

1215 for member, subplot in zip( 

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

1217 ): 

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

1219 # cartopy GeoAxes generated. 

1220 plt.subplot(grid_size, grid_size, subplot) 

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

1222 # Otherwise we plot xdim histograms stacked. 

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

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

1225 ax = plt.gca() 

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

1227 ax.set_xlim(vmin, vmax) 

1228 

1229 # Overall figure title. 

1230 fig.suptitle(title, fontsize=16) 

1231 

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

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

1234 plt.close(fig) 

1235 

1236 

1237def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1238 cube: iris.cube.Cube, 

1239 filename: str, 

1240 title: str, 

1241 stamp_coordinate: str, 

1242 vmin: float, 

1243 vmax: float, 

1244 **kwargs, 

1245): 

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

1247 ax.set_title(title, fontsize=16) 

1248 ax.set_xlim(vmin, vmax) 

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

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

1251 # Loop over all slices along the stamp_coordinate 

1252 for member in cube.slices_over(stamp_coordinate): 

1253 # Flatten the member data to 1D 

1254 member_data_1d = member.data.flatten() 

1255 # Plot the histogram using plt.hist 

1256 plt.hist( 

1257 member_data_1d, 

1258 density=True, 

1259 stacked=True, 

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

1261 ) 

1262 

1263 # Add a legend 

1264 ax.legend(fontsize=16) 

1265 

1266 # Save the figure to a file 

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

1268 

1269 # Close the figure 

1270 plt.close(fig) 

1271 

1272 

1273def _plot_and_save_scattermap_plot( 

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

1275): 

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

1277 

1278 Parameters 

1279 ---------- 

1280 cube: Cube 

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

1282 longitude coordinates, 

1283 filename: str 

1284 Filename of the plot to write. 

1285 title: str 

1286 Plot title. 

1287 projection: str 

1288 Mapping projection to be used by cartopy. 

1289 """ 

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

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

1292 if projection is not None: 

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

1294 # a stereographic projection over the North Pole. 

1295 if projection == "NP_Stereo": 

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

1297 else: 

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

1299 else: 

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

1301 

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

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

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

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

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

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

1308 # proportion to the area of the figure. 

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

1310 klon = None 

1311 klat = None 

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

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

1314 klat = kc 

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

1316 klon = kc 

1317 scatter_map = iplt.scatter( 

1318 cube.aux_coords[klon], 

1319 cube.aux_coords[klat], 

1320 c=cube.data[:], 

1321 s=mrk_size, 

1322 cmap="jet", 

1323 edgecolors="k", 

1324 ) 

1325 

1326 # Add coastlines. 

1327 try: 

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

1329 except AttributeError: 

1330 pass 

1331 

1332 # Add title. 

1333 axes.set_title(title, fontsize=16) 

1334 

1335 # Add colour bar. 

1336 cbar = fig.colorbar(scatter_map) 

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

1338 

1339 # Save plot. 

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

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

1342 plt.close(fig) 

1343 

1344 

1345def _plot_and_save_power_spectrum_series( 

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

1347 filename: str, 

1348 title: str, 

1349 **kwargs, 

1350): 

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

1352 

1353 Parameters 

1354 ---------- 

1355 cubes: Cube or CubeList 

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

1357 filename: str 

1358 Filename of the plot to write. 

1359 title: str 

1360 Plot title. 

1361 """ 

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

1363 ax = plt.gca() 

1364 

1365 model_colors_map = _get_model_colors_map(cubes) 

1366 

1367 for cube in iter_maybe(cubes): 

1368 # Calculate power spectrum 

1369 

1370 # Extract time coordinate and convert to datetime 

1371 time_coord = cube.coord("time") 

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

1373 

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

1375 target_time = time_points[0] 

1376 

1377 # Bind target_time inside the lambda using a default argument 

1378 time_constraint = iris.Constraint( 

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

1380 ) 

1381 

1382 cube = cube.extract(time_constraint) 

1383 

1384 if cube.ndim == 2: 

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

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

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

1388 cube_3d = cube.data 

1389 else: 

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

1391 raise ValueError( 

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

1393 ) 

1394 

1395 # Calculate spectra 

1396 ps_array = _DCT_ps(cube_3d) 

1397 

1398 ps_cube = iris.cube.Cube( 

1399 ps_array, 

1400 long_name="power_spectra", 

1401 ) 

1402 

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

1404 

1405 # Create a frequency/wavelength array for coordinate 

1406 ps_len = ps_cube.data.shape[1] 

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

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

1409 

1410 # Convert datetime to numeric time using original units 

1411 numeric_time = time_coord.units.date2num(time_points) 

1412 # Create a new DimCoord with numeric time 

1413 new_time_coord = iris.coords.DimCoord( 

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

1415 ) 

1416 

1417 # Add time and frequency coordinate to spectra cube. 

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

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

1420 

1421 # Extract data from the cube 

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

1423 power_spectrum = ps_cube.data 

1424 

1425 label = None 

1426 color = "black" 

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

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

1429 color = model_colors_map[label] 

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

1431 

1432 # Add some labels and tweak the style. 

1433 ax.set_title(title, fontsize=16) 

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

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

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

1437 

1438 # Set log-log scale 

1439 ax.set_xscale("log") 

1440 ax.set_yscale("log") 

1441 

1442 # Overlay grid-lines onto power spectrum plot. 

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

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

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

1446 

1447 # Save plot. 

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

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

1450 plt.close(fig) 

1451 

1452 

1453def _plot_and_save_postage_stamp_power_spectrum_series( 

1454 cube: iris.cube.Cube, 

1455 filename: str, 

1456 title: str, 

1457 stamp_coordinate: str, 

1458 **kwargs, 

1459): 

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

1461 

1462 Parameters 

1463 ---------- 

1464 cube: Cube 

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

1466 filename: str 

1467 Filename of the plot to write. 

1468 title: str 

1469 Plot title. 

1470 stamp_coordinate: str 

1471 Coordinate that becomes different plots. 

1472 """ 

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

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

1475 

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

1477 # Make a subplot for each member. 

1478 for member, subplot in zip( 

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

1480 ): 

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

1482 # cartopy GeoAxes generated. 

1483 plt.subplot(grid_size, grid_size, subplot) 

1484 

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

1486 power_spectrum = member.data 

1487 

1488 ax = plt.gca() 

1489 ax.plot(frequency, power_spectrum[0]) 

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

1491 

1492 # Overall figure title. 

1493 fig.suptitle(title, fontsize=16) 

1494 

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

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

1497 plt.close(fig) 

1498 

1499 

1500def _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series( 

1501 cube: iris.cube.Cube, 

1502 filename: str, 

1503 title: str, 

1504 stamp_coordinate: str, 

1505 **kwargs, 

1506): 

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

1508 ax.set_title(title, fontsize=16) 

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

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

1511 # Loop over all slices along the stamp_coordinate 

1512 for member in cube.slices_over(stamp_coordinate): 

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

1514 power_spectrum = member.data 

1515 ax.plot( 

1516 frequency, 

1517 power_spectrum[0], 

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

1519 ) 

1520 

1521 # Add a legend 

1522 ax.legend(fontsize=16) 

1523 

1524 # Save the figure to a file 

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

1526 

1527 # Close the figure 

1528 plt.close(fig) 

1529 

1530 

1531def _spatial_plot( 

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

1533 cube: iris.cube.Cube, 

1534 filename: str | None, 

1535 sequence_coordinate: str, 

1536 stamp_coordinate: str, 

1537 **kwargs, 

1538): 

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

1540 

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

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

1543 is present then postage stamp plots will be produced. 

1544 

1545 Parameters 

1546 ---------- 

1547 method: "contourf" | "pcolormesh" 

1548 The plotting method to use. 

1549 cube: Cube 

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

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

1552 plotted sequentially and/or as postage stamp plots. 

1553 filename: str | None 

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

1555 uses the recipe name. 

1556 sequence_coordinate: str 

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

1558 This coordinate must exist in the cube. 

1559 stamp_coordinate: str 

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

1561 ``"realization"``. 

1562 

1563 Raises 

1564 ------ 

1565 ValueError 

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

1567 TypeError 

1568 If the cube isn't a single cube. 

1569 """ 

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

1571 

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

1573 if filename is None: 

1574 filename = slugify(recipe_title) 

1575 

1576 # Ensure we've got a single cube. 

1577 cube = _check_single_cube(cube) 

1578 

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

1580 # single point. 

1581 plotting_func = _plot_and_save_spatial_plot 

1582 try: 

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

1584 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1585 except iris.exceptions.CoordinateNotFoundError: 

1586 pass 

1587 

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

1589 # dimension called observation or model_obs_error 

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

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

1592 for crd in cube.coords() 

1593 ): 

1594 plotting_func = _plot_and_save_scattermap_plot 

1595 

1596 # Must have a sequence coordinate. 

1597 try: 

1598 cube.coord(sequence_coordinate) 

1599 except iris.exceptions.CoordinateNotFoundError as err: 

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

1601 

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

1603 plot_index = [] 

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

1605 for cube_slice in cube.slices_over(sequence_coordinate): 

1606 # Use sequence value so multiple sequences can merge. 

1607 sequence_value = cube_slice.coord(sequence_coordinate).points[0] 

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

1609 coord = cube_slice.coord(sequence_coordinate) 

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

1611 title = f"{recipe_title}\n [{coord.units.title(coord.points[0])}]" 

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

1613 if nplot == 1 and coord.has_bounds: 

1614 if np.size(coord.bounds) > 1: 

1615 title = f"{recipe_title}\n [{coord.units.title(coord.bounds[0][0])} to {coord.units.title(coord.bounds[0][1])}]" 

1616 # Do the actual plotting. 

1617 plotting_func( 

1618 cube_slice, 

1619 filename=plot_filename, 

1620 stamp_coordinate=stamp_coordinate, 

1621 title=title, 

1622 method=method, 

1623 **kwargs, 

1624 ) 

1625 plot_index.append(plot_filename) 

1626 

1627 # Add list of plots to plot metadata. 

1628 complete_plot_index = _append_to_plot_index(plot_index) 

1629 

1630 # Make a page to display the plots. 

1631 _make_plot_html_page(complete_plot_index) 

1632 

1633 

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

1635 """Get colourmap for mask. 

1636 

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

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

1639 

1640 Parameters 

1641 ---------- 

1642 cube: Cube 

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

1644 axis: "x", "y", optional 

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

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

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

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

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

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

1651 

1652 Returns 

1653 ------- 

1654 cmap: 

1655 Matplotlib colormap. 

1656 levels: 

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

1658 should be taken as the range. 

1659 norm: 

1660 BoundaryNorm information. 

1661 """ 

1662 if "difference" not in cube.long_name: 

1663 if axis: 

1664 levels = [0, 1] 

1665 # Complete settings based on levels. 

1666 return None, levels, None 

1667 else: 

1668 # Define the levels and colors. 

1669 levels = [0, 1, 2] 

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

1671 # Create a custom color map. 

1672 cmap = mcolors.ListedColormap(colors) 

1673 # Normalize the levels. 

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

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

1676 return cmap, levels, norm 

1677 else: 

1678 if axis: 

1679 levels = [-1, 1] 

1680 return None, levels, None 

1681 else: 

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

1683 # not <=. 

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

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

1686 cmap = mcolors.ListedColormap(colors) 

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

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

1689 return cmap, levels, norm 

1690 

1691 

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

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

1694 

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

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

1697 

1698 Parameters 

1699 ---------- 

1700 cube: Cube 

1701 Cube of variable with Beaufort Scale in name. 

1702 axis: "x", "y", optional 

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

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

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

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

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

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

1709 

1710 Returns 

1711 ------- 

1712 cmap: 

1713 Matplotlib colormap. 

1714 levels: 

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

1716 should be taken as the range. 

1717 norm: 

1718 BoundaryNorm information. 

1719 """ 

1720 if "difference" not in cube.long_name: 

1721 if axis: 

1722 levels = [0, 12] 

1723 return None, levels, None 

1724 else: 

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

1726 colors = [ 

1727 "black", 

1728 (0, 0, 0.6), 

1729 "blue", 

1730 "cyan", 

1731 "green", 

1732 "yellow", 

1733 (1, 0.5, 0), 

1734 "red", 

1735 "pink", 

1736 "magenta", 

1737 "purple", 

1738 "maroon", 

1739 "white", 

1740 ] 

1741 cmap = mcolors.ListedColormap(colors) 

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

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

1744 return cmap, levels, norm 

1745 else: 

1746 if axis: 

1747 levels = [-4, 4] 

1748 return None, levels, None 

1749 else: 

1750 levels = [ 

1751 -3.5, 

1752 -2.5, 

1753 -1.5, 

1754 -0.5, 

1755 0.5, 

1756 1.5, 

1757 2.5, 

1758 3.5, 

1759 ] 

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

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

1762 return cmap, levels, norm 

1763 

1764 

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

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

1767 

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

1769 

1770 Parameters 

1771 ---------- 

1772 cube: Cube 

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

1774 cmap: Matplotlib colormap. 

1775 levels: List 

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

1777 should be taken as the range. 

1778 norm: BoundaryNorm. 

1779 

1780 Returns 

1781 ------- 

1782 cmap: Matplotlib colormap. 

1783 levels: List 

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

1785 should be taken as the range. 

1786 norm: BoundaryNorm. 

1787 """ 

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

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

1790 levels = np.array(levels) 

1791 levels -= 273 

1792 levels = levels.tolist() 

1793 else: 

1794 # Do nothing keep the existing colourbar attributes 

1795 levels = levels 

1796 cmap = cmap 

1797 norm = norm 

1798 return cmap, levels, norm 

1799 

1800 

1801def _custom_colormap_probability( 

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

1803): 

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

1805 

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

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

1808 

1809 Parameters 

1810 ---------- 

1811 cube: Cube 

1812 Cube of variable with probability in name. 

1813 axis: "x", "y", optional 

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

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

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

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

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

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

1820 

1821 Returns 

1822 ------- 

1823 cmap: 

1824 Matplotlib colormap. 

1825 levels: 

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

1827 should be taken as the range. 

1828 norm: 

1829 BoundaryNorm information. 

1830 """ 

1831 if axis: 

1832 levels = [0, 1] 

1833 return None, levels, None 

1834 else: 

1835 cmap = mcolors.ListedColormap( 

1836 [ 

1837 "#FFFFFF", 

1838 "#636363", 

1839 "#e1dada", 

1840 "#B5CAFF", 

1841 "#8FB3FF", 

1842 "#7F97FF", 

1843 "#ABCF63", 

1844 "#E8F59E", 

1845 "#FFFA14", 

1846 "#FFD121", 

1847 "#FFA30A", 

1848 ] 

1849 ) 

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

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

1852 return cmap, levels, norm 

1853 

1854 

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

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

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

1858 if ( 

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

1860 and "difference" not in cube.long_name 

1861 and "mask" not in cube.long_name 

1862 ): 

1863 # Define the levels and colors 

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

1865 colors = [ 

1866 "w", 

1867 (0, 0, 0.6), 

1868 "b", 

1869 "c", 

1870 "g", 

1871 "y", 

1872 (1, 0.5, 0), 

1873 "r", 

1874 "pink", 

1875 "m", 

1876 "purple", 

1877 "maroon", 

1878 "gray", 

1879 ] 

1880 # Create a custom colormap 

1881 cmap = mcolors.ListedColormap(colors) 

1882 # Normalize the levels 

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

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

1885 else: 

1886 # do nothing and keep existing colorbar attributes 

1887 cmap = cmap 

1888 levels = levels 

1889 norm = norm 

1890 return cmap, levels, norm 

1891 

1892 

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

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

1895 

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

1897 this function will be called. 

1898 

1899 Parameters 

1900 ---------- 

1901 cube: Cube 

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

1903 

1904 Returns 

1905 ------- 

1906 cmap: Matplotlib colormap. 

1907 levels: List 

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

1909 should be taken as the range. 

1910 norm: BoundaryNorm. 

1911 """ 

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

1913 colors = [ 

1914 "#87ceeb", 

1915 "#ffffff", 

1916 "#8ced69", 

1917 "#ffff00", 

1918 "#ffd700", 

1919 "#ffa500", 

1920 "#fe3620", 

1921 ] 

1922 # Create a custom colormap 

1923 cmap = mcolors.ListedColormap(colors) 

1924 # Normalise the levels 

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

1926 return cmap, levels, norm 

1927 

1928 

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

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

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

1932 if ( 

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

1934 and "difference" not in cube.long_name 

1935 and "mask" not in cube.long_name 

1936 ): 

1937 # Define the levels and colors (in km) 

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

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

1940 colours = [ 

1941 "#8f00d6", 

1942 "#d10000", 

1943 "#ff9700", 

1944 "#ffff00", 

1945 "#00007f", 

1946 "#6c9ccd", 

1947 "#aae8ff", 

1948 "#37a648", 

1949 "#8edc64", 

1950 "#c5ffc5", 

1951 "#dcdcdc", 

1952 "#ffffff", 

1953 ] 

1954 # Create a custom colormap 

1955 cmap = mcolors.ListedColormap(colours) 

1956 # Normalize the levels 

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

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

1959 else: 

1960 # do nothing and keep existing colorbar attributes 

1961 cmap = cmap 

1962 levels = levels 

1963 norm = norm 

1964 return cmap, levels, norm 

1965 

1966 

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

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

1969 model_names = list( 

1970 filter( 

1971 lambda x: x is not None, 

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

1973 ) 

1974 ) 

1975 if not model_names: 

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

1977 return 1 

1978 else: 

1979 return len(model_names) 

1980 

1981 

1982def _validate_cube_shape( 

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

1984) -> None: 

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

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

1987 raise ValueError( 

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

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

1990 ) 

1991 

1992 

1993def _validate_cubes_coords( 

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

1995) -> None: 

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

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

1998 raise ValueError( 

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

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

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

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

2003 ) 

2004 

2005 

2006#################### 

2007# Public functions # 

2008#################### 

2009 

2010 

2011def spatial_contour_plot( 

2012 cube: iris.cube.Cube, 

2013 filename: str = None, 

2014 sequence_coordinate: str = "time", 

2015 stamp_coordinate: str = "realization", 

2016 **kwargs, 

2017) -> iris.cube.Cube: 

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

2019 

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

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

2022 is present then postage stamp plots will be produced. 

2023 

2024 Parameters 

2025 ---------- 

2026 cube: Cube 

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

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

2029 plotted sequentially and/or as postage stamp plots. 

2030 filename: str, optional 

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

2032 to the recipe name. 

2033 sequence_coordinate: str, optional 

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

2035 This coordinate must exist in the cube. 

2036 stamp_coordinate: str, optional 

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

2038 ``"realization"``. 

2039 

2040 Returns 

2041 ------- 

2042 Cube 

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

2044 

2045 Raises 

2046 ------ 

2047 ValueError 

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

2049 TypeError 

2050 If the cube isn't a single cube. 

2051 """ 

2052 _spatial_plot( 

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

2054 ) 

2055 return cube 

2056 

2057 

2058def spatial_pcolormesh_plot( 

2059 cube: iris.cube.Cube, 

2060 filename: str = None, 

2061 sequence_coordinate: str = "time", 

2062 stamp_coordinate: str = "realization", 

2063 **kwargs, 

2064) -> iris.cube.Cube: 

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

2066 

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

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

2069 is present then postage stamp plots will be produced. 

2070 

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

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

2073 contour areas are important. 

2074 

2075 Parameters 

2076 ---------- 

2077 cube: Cube 

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

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

2080 plotted sequentially and/or as postage stamp plots. 

2081 filename: str, optional 

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

2083 to the recipe name. 

2084 sequence_coordinate: str, optional 

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

2086 This coordinate must exist in the cube. 

2087 stamp_coordinate: str, optional 

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

2089 ``"realization"``. 

2090 

2091 Returns 

2092 ------- 

2093 Cube 

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

2095 

2096 Raises 

2097 ------ 

2098 ValueError 

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

2100 TypeError 

2101 If the cube isn't a single cube. 

2102 """ 

2103 _spatial_plot( 

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

2105 ) 

2106 return cube 

2107 

2108 

2109# TODO: Expand function to handle ensemble data. 

2110# line_coordinate: str, optional 

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

2112# ``"realization"``. 

2113def plot_line_series( 

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

2115 filename: str = None, 

2116 series_coordinate: str = "time", 

2117 # line_coordinate: str = "realization", 

2118 **kwargs, 

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

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

2121 

2122 The Cube or CubeList must be 1D. 

2123 

2124 Parameters 

2125 ---------- 

2126 iris.cube | iris.cube.CubeList 

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

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

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

2130 filename: str, optional 

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

2132 to the recipe name. 

2133 series_coordinate: str, optional 

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

2135 coordinate must exist in the cube. 

2136 

2137 Returns 

2138 ------- 

2139 iris.cube.Cube | iris.cube.CubeList 

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

2141 plotted data. 

2142 

2143 Raises 

2144 ------ 

2145 ValueError 

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

2147 TypeError 

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

2149 """ 

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

2151 title = get_recipe_metadata().get("title", "Untitled") 

2152 

2153 if filename is None: 

2154 filename = slugify(title) 

2155 

2156 # Add file extension. 

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

2158 

2159 num_models = _get_num_models(cube) 

2160 

2161 _validate_cube_shape(cube, num_models) 

2162 

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

2164 cubes = iter_maybe(cube) 

2165 coords = [] 

2166 for cube in cubes: 

2167 try: 

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

2169 except iris.exceptions.CoordinateNotFoundError as err: 

2170 raise ValueError( 

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

2172 ) from err 

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

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

2175 

2176 # Do the actual plotting. 

2177 _plot_and_save_line_series(cubes, coords, "realization", plot_filename, title) 

2178 

2179 # Add list of plots to plot metadata. 

2180 plot_index = _append_to_plot_index([plot_filename]) 

2181 

2182 # Make a page to display the plots. 

2183 _make_plot_html_page(plot_index) 

2184 

2185 return cube 

2186 

2187 

2188def plot_vertical_line_series( 

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

2190 filename: str = None, 

2191 series_coordinate: str = "model_level_number", 

2192 sequence_coordinate: str = "time", 

2193 # line_coordinate: str = "realization", 

2194 **kwargs, 

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

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

2197 

2198 The Cube or CubeList must be 1D. 

2199 

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

2201 then a sequence of plots will be produced. 

2202 

2203 Parameters 

2204 ---------- 

2205 iris.cube | iris.cube.CubeList 

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

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

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

2209 filename: str, optional 

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

2211 to the recipe name. 

2212 series_coordinate: str, optional 

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

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

2215 for LFRic. Defaults to ``model_level_number``. 

2216 This coordinate must exist in the cube. 

2217 sequence_coordinate: str, optional 

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

2219 This coordinate must exist in the cube. 

2220 

2221 Returns 

2222 ------- 

2223 iris.cube.Cube | iris.cube.CubeList 

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

2225 Plotted data. 

2226 

2227 Raises 

2228 ------ 

2229 ValueError 

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

2231 TypeError 

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

2233 """ 

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

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

2236 

2237 if filename is None: 

2238 filename = slugify(recipe_title) 

2239 

2240 cubes = iter_maybe(cubes) 

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

2242 all_data = [] 

2243 

2244 # Store min/max ranges for x range. 

2245 x_levels = [] 

2246 

2247 num_models = _get_num_models(cubes) 

2248 

2249 _validate_cube_shape(cubes, num_models) 

2250 

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

2252 coords = [] 

2253 for cube in cubes: 

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

2255 try: 

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

2257 except iris.exceptions.CoordinateNotFoundError as err: 

2258 raise ValueError( 

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

2260 ) from err 

2261 

2262 try: 

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

2264 cube.coord(sequence_coordinate) 

2265 except iris.exceptions.CoordinateNotFoundError as err: 

2266 raise ValueError( 

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

2268 ) from err 

2269 

2270 # Get minimum and maximum from levels information. 

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

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

2273 x_levels.append(min(levels)) 

2274 x_levels.append(max(levels)) 

2275 else: 

2276 all_data.append(cube.data) 

2277 

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

2279 # Combine all data into a single NumPy array 

2280 combined_data = np.concatenate(all_data) 

2281 

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

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

2284 # sequence and if applicable postage stamp coordinate. 

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

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

2287 else: 

2288 vmin = min(x_levels) 

2289 vmax = max(x_levels) 

2290 

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

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

2293 # necessary) 

2294 def filter_cube_iterables(cube_iterables) -> bool: 

2295 return len(cube_iterables) == len(coords) 

2296 

2297 cube_iterables = filter( 

2298 filter_cube_iterables, 

2299 ( 

2300 iris.cube.CubeList( 

2301 s 

2302 for s in itertools.chain.from_iterable( 

2303 cb.slices_over(sequence_coordinate) for cb in cubes 

2304 ) 

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

2306 ) 

2307 for point in sorted( 

2308 set( 

2309 itertools.chain.from_iterable( 

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

2311 ) 

2312 ) 

2313 ) 

2314 ), 

2315 ) 

2316 

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

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

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

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

2321 # or an iris.cube.CubeList. 

2322 plot_index = [] 

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

2324 for cubes_slice in cube_iterables: 

2325 # Use sequence value so multiple sequences can merge. 

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

2327 sequence_value = seq_coord.points[0] 

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

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

2330 title = f"{recipe_title}\n [{seq_coord.units.title(sequence_value)}]" 

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

2332 if nplot == 1 and seq_coord.has_bounds: 2332 ↛ 2333line 2332 didn't jump to line 2333 because the condition on line 2332 was never true

2333 if np.size(seq_coord.bounds) > 1: 

2334 title = f"{recipe_title}\n [{seq_coord.units.title(seq_coord.bounds[0][0])} to {seq_coord.units.title(seq_coord.bounds[0][1])}]" 

2335 # Do the actual plotting. 

2336 _plot_and_save_vertical_line_series( 

2337 cubes_slice, 

2338 coords, 

2339 "realization", 

2340 plot_filename, 

2341 series_coordinate, 

2342 title=title, 

2343 vmin=vmin, 

2344 vmax=vmax, 

2345 ) 

2346 plot_index.append(plot_filename) 

2347 

2348 # Add list of plots to plot metadata. 

2349 complete_plot_index = _append_to_plot_index(plot_index) 

2350 

2351 # Make a page to display the plots. 

2352 _make_plot_html_page(complete_plot_index) 

2353 

2354 return cubes 

2355 

2356 

2357def qq_plot( 

2358 cubes: iris.cube.CubeList, 

2359 coordinates: list[str], 

2360 percentiles: list[float], 

2361 model_names: list[str], 

2362 filename: str = None, 

2363 one_to_one: bool = True, 

2364 **kwargs, 

2365) -> iris.cube.CubeList: 

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

2367 

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

2369 collapsed within the operator over all specified coordinates such as 

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

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

2372 

2373 Parameters 

2374 ---------- 

2375 cubes: iris.cube.CubeList 

2376 Two cubes of the same variable with different models. 

2377 coordinate: list[str] 

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

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

2380 the percentile coordinate. 

2381 percent: list[float] 

2382 A list of percentiles to appear in the plot. 

2383 model_names: list[str] 

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

2385 filename: str, optional 

2386 Filename of the plot to write. 

2387 one_to_one: bool, optional 

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

2389 

2390 Raises 

2391 ------ 

2392 ValueError 

2393 When the cubes are not compatible. 

2394 

2395 Notes 

2396 ----- 

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

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

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

2400 compares percentiles of two datasets. This plot does 

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

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

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

2404 

2405 Quantile-quantile plots are valuable for comparing against 

2406 observations and other models. Identical percentiles between the variables 

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

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

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

2410 Wilks 2011 [Wilks2011]_). 

2411 

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

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

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

2415 the extremes. 

2416 

2417 References 

2418 ---------- 

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

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

2421 """ 

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

2423 if len(cubes) != 2: 

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

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

2426 other: Cube = cubes.extract_cube( 

2427 iris.Constraint( 

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

2429 ) 

2430 ) 

2431 

2432 # Get spatial coord names. 

2433 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

2434 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

2435 

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

2437 # This is triggered if either 

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

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

2440 # errors. 

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

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

2443 # for UM and LFRic comparisons. 

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

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

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

2447 # given this dependency on regridding. 

2448 if ( 

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

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

2451 ) or ( 

2452 base.long_name 

2453 in [ 

2454 "eastward_wind_at_10m", 

2455 "northward_wind_at_10m", 

2456 "northward_wind_at_cell_centres", 

2457 "eastward_wind_at_cell_centres", 

2458 "zonal_wind_at_pressure_levels", 

2459 "meridional_wind_at_pressure_levels", 

2460 "potential_vorticity_at_pressure_levels", 

2461 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

2462 ] 

2463 ): 

2464 logging.debug( 

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

2466 ) 

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

2468 

2469 # Extract just common time points. 

2470 base, other = _extract_common_time_points(base, other) 

2471 

2472 # Equalise attributes so we can merge. 

2473 fully_equalise_attributes([base, other]) 

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

2475 

2476 # Collapse cubes. 

2477 base = collapse( 

2478 base, 

2479 coordinate=coordinates, 

2480 method="PERCENTILE", 

2481 additional_percent=percentiles, 

2482 ) 

2483 other = collapse( 

2484 other, 

2485 coordinate=coordinates, 

2486 method="PERCENTILE", 

2487 additional_percent=percentiles, 

2488 ) 

2489 

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

2491 title = get_recipe_metadata().get("title", "Untitled") 

2492 

2493 if filename is None: 

2494 filename = slugify(title) 

2495 

2496 # Add file extension. 

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

2498 

2499 # Do the actual plotting on a scatter plot 

2500 _plot_and_save_scatter_plot( 

2501 base, other, plot_filename, title, one_to_one, model_names 

2502 ) 

2503 

2504 # Add list of plots to plot metadata. 

2505 plot_index = _append_to_plot_index([plot_filename]) 

2506 

2507 # Make a page to display the plots. 

2508 _make_plot_html_page(plot_index) 

2509 

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

2511 

2512 

2513def scatter_plot( 

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

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

2516 filename: str = None, 

2517 one_to_one: bool = True, 

2518 **kwargs, 

2519) -> iris.cube.CubeList: 

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

2521 

2522 Both cubes must be 1D. 

2523 

2524 Parameters 

2525 ---------- 

2526 cube_x: Cube | CubeList 

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

2528 cube_y: Cube | CubeList 

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

2530 filename: str, optional 

2531 Filename of the plot to write. 

2532 one_to_one: bool, optional 

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

2534 

2535 Returns 

2536 ------- 

2537 cubes: CubeList 

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

2539 

2540 Raises 

2541 ------ 

2542 ValueError 

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

2544 size. 

2545 TypeError 

2546 If the cube isn't a single cube. 

2547 

2548 Notes 

2549 ----- 

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

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

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

2553 """ 

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

2555 for cube_iter in iter_maybe(cube_x): 

2556 # Check cubes are correct shape. 

2557 cube_iter = _check_single_cube(cube_iter) 

2558 if cube_iter.ndim > 1: 

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

2560 

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

2562 for cube_iter in iter_maybe(cube_y): 

2563 # Check cubes are correct shape. 

2564 cube_iter = _check_single_cube(cube_iter) 

2565 if cube_iter.ndim > 1: 

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

2567 

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

2569 title = get_recipe_metadata().get("title", "Untitled") 

2570 

2571 if filename is None: 

2572 filename = slugify(title) 

2573 

2574 # Add file extension. 

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

2576 

2577 # Do the actual plotting. 

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

2579 

2580 # Add list of plots to plot metadata. 

2581 plot_index = _append_to_plot_index([plot_filename]) 

2582 

2583 # Make a page to display the plots. 

2584 _make_plot_html_page(plot_index) 

2585 

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

2587 

2588 

2589def vector_plot( 

2590 cube_u: iris.cube.Cube, 

2591 cube_v: iris.cube.Cube, 

2592 filename: str = None, 

2593 sequence_coordinate: str = "time", 

2594 **kwargs, 

2595) -> iris.cube.CubeList: 

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

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

2598 

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

2600 if filename is None: 2600 ↛ 2601line 2600 didn't jump to line 2601 because the condition on line 2600 was never true

2601 filename = slugify(recipe_title) 

2602 

2603 # Cubes must have a matching sequence coordinate. 

2604 try: 

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

2606 if cube_u.coord(sequence_coordinate) != cube_v.coord(sequence_coordinate): 2606 ↛ 2607line 2606 didn't jump to line 2607 because the condition on line 2606 was never true

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

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

2609 raise ValueError( 

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

2611 ) from err 

2612 

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

2614 plot_index = [] 

2615 for cube_u_slice, cube_v_slice in zip( 

2616 cube_u.slices_over(sequence_coordinate), 

2617 cube_v.slices_over(sequence_coordinate), 

2618 strict=True, 

2619 ): 

2620 # Use sequence value so multiple sequences can merge. 

2621 sequence_value = cube_u_slice.coord(sequence_coordinate).points[0] 

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

2623 coord = cube_u_slice.coord(sequence_coordinate) 

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

2625 title = f"{recipe_title}\n{coord.units.title(coord.points[0])}" 

2626 # Do the actual plotting. 

2627 _plot_and_save_vector_plot( 

2628 cube_u_slice, 

2629 cube_v_slice, 

2630 filename=plot_filename, 

2631 title=title, 

2632 method="contourf", 

2633 ) 

2634 plot_index.append(plot_filename) 

2635 

2636 # Add list of plots to plot metadata. 

2637 complete_plot_index = _append_to_plot_index(plot_index) 

2638 

2639 # Make a page to display the plots. 

2640 _make_plot_html_page(complete_plot_index) 

2641 

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

2643 

2644 

2645def plot_histogram_series( 

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

2647 filename: str = None, 

2648 sequence_coordinate: str = "time", 

2649 stamp_coordinate: str = "realization", 

2650 single_plot: bool = False, 

2651 **kwargs, 

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

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

2654 

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

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

2657 functionality to scroll through histograms against time. If a 

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

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

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

2661 

2662 Parameters 

2663 ---------- 

2664 cubes: Cube | iris.cube.CubeList 

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

2666 than the stamp coordinate. 

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

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

2669 filename: str, optional 

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

2671 to the recipe name. 

2672 sequence_coordinate: str, optional 

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

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

2675 slider. 

2676 stamp_coordinate: str, optional 

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

2678 ``"realization"``. 

2679 single_plot: bool, optional 

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

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

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

2683 

2684 Returns 

2685 ------- 

2686 iris.cube.Cube | iris.cube.CubeList 

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

2688 Plotted data. 

2689 

2690 Raises 

2691 ------ 

2692 ValueError 

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

2694 TypeError 

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

2696 """ 

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

2698 

2699 cubes = iter_maybe(cubes) 

2700 

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

2702 if filename is None: 

2703 filename = slugify(recipe_title) 

2704 

2705 # Internal plotting function. 

2706 plotting_func = _plot_and_save_histogram_series 

2707 

2708 num_models = _get_num_models(cubes) 

2709 

2710 _validate_cube_shape(cubes, num_models) 

2711 

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

2713 # time slider option. 

2714 for cube in cubes: 

2715 try: 

2716 cube.coord(sequence_coordinate) 

2717 except iris.exceptions.CoordinateNotFoundError as err: 

2718 raise ValueError( 

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

2720 ) from err 

2721 

2722 # Get minimum and maximum from levels information. 

2723 levels = None 

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

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

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

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

2728 if levels is None: 

2729 break 

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

2731 # levels-based ranges for histogram plots. 

2732 _, levels, _ = _colorbar_map_levels(cube) 

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

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

2735 vmin = min(levels) 

2736 vmax = max(levels) 

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

2738 break 

2739 

2740 if levels is None: 

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

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

2743 

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

2745 # single point. If single_plot is True: 

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

2747 # separate postage stamp plots. 

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

2749 # produced per single model only 

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

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

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

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

2754 ): 

2755 if single_plot: 

2756 plotting_func = ( 

2757 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

2758 ) 

2759 else: 

2760 plotting_func = _plot_and_save_postage_stamp_histogram_series 

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

2762 else: 

2763 all_points = sorted( 

2764 set( 

2765 itertools.chain.from_iterable( 

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

2767 ) 

2768 ) 

2769 ) 

2770 all_slices = list( 

2771 itertools.chain.from_iterable( 

2772 cb.slices_over(sequence_coordinate) for cb in cubes 

2773 ) 

2774 ) 

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

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

2777 # necessary) 

2778 cube_iterables = [ 

2779 iris.cube.CubeList( 

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

2781 ) 

2782 for point in all_points 

2783 ] 

2784 

2785 plot_index = [] 

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

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

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

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

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

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

2792 for cube_slice in cube_iterables: 

2793 single_cube = cube_slice 

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

2795 single_cube = cube_slice[0] 

2796 

2797 # Use sequence value so multiple sequences can merge. 

2798 sequence_value = single_cube.coord(sequence_coordinate).points[0] 

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

2800 coord = single_cube.coord(sequence_coordinate) 

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

2802 title = f"{recipe_title}\n [{coord.units.title(coord.points[0])}]" 

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

2804 if nplot == 1 and coord.has_bounds: 2804 ↛ 2805line 2804 didn't jump to line 2805 because the condition on line 2804 was never true

2805 if np.size(coord.bounds) > 1: 

2806 title = f"{recipe_title}\n [{coord.units.title(coord.bounds[0][0])} to {coord.units.title(coord.bounds[0][1])}]" 

2807 # Do the actual plotting. 

2808 plotting_func( 

2809 cube_slice, 

2810 filename=plot_filename, 

2811 stamp_coordinate=stamp_coordinate, 

2812 title=title, 

2813 vmin=vmin, 

2814 vmax=vmax, 

2815 ) 

2816 plot_index.append(plot_filename) 

2817 

2818 # Add list of plots to plot metadata. 

2819 complete_plot_index = _append_to_plot_index(plot_index) 

2820 

2821 # Make a page to display the plots. 

2822 _make_plot_html_page(complete_plot_index) 

2823 

2824 return cubes 

2825 

2826 

2827def plot_power_spectrum_series( 

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

2829 filename: str = None, 

2830 sequence_coordinate: str = "time", 

2831 stamp_coordinate: str = "realization", 

2832 single_plot: bool = False, 

2833 **kwargs, 

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

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

2836 

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

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

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

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

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

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

2843 

2844 Parameters 

2845 ---------- 

2846 cubes: Cube | iris.cube.CubeList 

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

2848 than the stamp coordinate. 

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

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

2851 filename: str, optional 

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

2853 to the recipe name. 

2854 sequence_coordinate: str, optional 

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

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

2857 slider. 

2858 stamp_coordinate: str, optional 

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

2860 ``"realization"``. 

2861 single_plot: bool, optional 

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

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

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

2865 

2866 Returns 

2867 ------- 

2868 iris.cube.Cube | iris.cube.CubeList 

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

2870 Plotted data. 

2871 

2872 Raises 

2873 ------ 

2874 ValueError 

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

2876 TypeError 

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

2878 """ 

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

2880 

2881 cubes = iter_maybe(cubes) 

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

2883 if filename is None: 

2884 filename = slugify(recipe_title) 

2885 

2886 # Internal plotting function. 

2887 plotting_func = _plot_and_save_power_spectrum_series 

2888 

2889 num_models = _get_num_models(cubes) 

2890 

2891 _validate_cube_shape(cubes, num_models) 

2892 

2893 # If several power spectra are plotted with time as sequence_coordinate for the 

2894 # time slider option. 

2895 for cube in cubes: 

2896 try: 

2897 cube.coord(sequence_coordinate) 

2898 except iris.exceptions.CoordinateNotFoundError as err: 

2899 raise ValueError( 

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

2901 ) from err 

2902 

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

2904 # single point. If single_plot is True: 

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

2906 # separate postage stamp plots. 

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

2908 # produced per single model only 

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

2910 if ( 2910 ↛ 2914line 2910 didn't jump to line 2914 because the condition on line 2910 was never true

2911 stamp_coordinate in [c.name() for c in cubes[0].coords()] 

2912 and cubes[0].coord(stamp_coordinate).shape[0] > 1 

2913 ): 

2914 if single_plot: 

2915 plotting_func = ( 

2916 _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series 

2917 ) 

2918 else: 

2919 plotting_func = _plot_and_save_postage_stamp_power_spectrum_series 

2920 cube_iterables = cubes[0].slices_over(sequence_coordinate) 

2921 else: 

2922 all_points = sorted( 

2923 set( 

2924 itertools.chain.from_iterable( 

2925 cb.coord(sequence_coordinate).points for cb in cubes 

2926 ) 

2927 ) 

2928 ) 

2929 all_slices = list( 

2930 itertools.chain.from_iterable( 

2931 cb.slices_over(sequence_coordinate) for cb in cubes 

2932 ) 

2933 ) 

2934 # Matched slices (matched by seq coord point; it may happen that 

2935 # evaluated models do not cover the same seq coord range, hence matching 

2936 # necessary) 

2937 cube_iterables = [ 

2938 iris.cube.CubeList( 

2939 s for s in all_slices if s.coord(sequence_coordinate).points[0] == point 

2940 ) 

2941 for point in all_points 

2942 ] 

2943 

2944 plot_index = [] 

2945 nplot = np.size(cube.coord(sequence_coordinate).points) 

2946 # Create a plot for each value of the sequence coordinate. Allowing for 

2947 # multiple cubes in a CubeList to be plotted in the same plot for similar 

2948 # sequence values. Passing a CubeList into the internal plotting function 

2949 # for similar values of the sequence coordinate. cube_slice can be an 

2950 # iris.cube.Cube or an iris.cube.CubeList. 

2951 for cube_slice in cube_iterables: 

2952 single_cube = cube_slice 

2953 if isinstance(cube_slice, iris.cube.CubeList): 2953 ↛ 2954line 2953 didn't jump to line 2954 because the condition on line 2953 was never true

2954 single_cube = cube_slice[0] 

2955 

2956 # Use sequence value so multiple sequences can merge. 

2957 sequence_value = single_cube.coord(sequence_coordinate).points[0] 

2958 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png" 

2959 coord = single_cube.coord(sequence_coordinate) 

2960 # Format the coordinate value in a unit appropriate way. 

2961 title = f"{recipe_title}\n [{coord.units.title(coord.points[0])}]" 

2962 # Use sequence (e.g. time) bounds if plotting single non-sequence outputs 

2963 if nplot == 1 and coord.has_bounds: 2963 ↛ 2967line 2963 didn't jump to line 2967 because the condition on line 2963 was always true

2964 if np.size(coord.bounds) > 1: 

2965 title = f"{recipe_title}\n [{coord.units.title(coord.bounds[0][0])} to {coord.units.title(coord.bounds[0][1])}]" 

2966 # Do the actual plotting. 

2967 plotting_func( 

2968 cube_slice, 

2969 filename=plot_filename, 

2970 stamp_coordinate=stamp_coordinate, 

2971 title=title, 

2972 ) 

2973 plot_index.append(plot_filename) 

2974 

2975 # Add list of plots to plot metadata. 

2976 complete_plot_index = _append_to_plot_index(plot_index) 

2977 

2978 # Make a page to display the plots. 

2979 _make_plot_html_page(complete_plot_index) 

2980 

2981 return cubes 

2982 

2983 

2984def _DCT_ps(y_3d): 

2985 """Calculate power spectra for regional domains. 

2986 

2987 Parameters 

2988 ---------- 

2989 y_3d: 3D array 

2990 3 dimensional array to calculate spectrum for. 

2991 (2D field data with 3rd dimension of time) 

2992 

2993 Returns: ps_array 

2994 Array of power spectra values calculated for input field (for each time) 

2995 

2996 Method for regional domains: 

2997 Calculate power spectra over limited area domain using Discrete Cosine Transform (DCT) 

2998 as described in Denis et al 2002 [Denis_etal_2002]_. 

2999 

3000 References 

3001 ---------- 

3002 .. [Denis_etal_2002] Bertrand Denis, Jean Côté and René Laprise (2002) 

3003 "Spectral Decomposition of Two-Dimensional Atmospheric Fields on 

3004 Limited-Area Domains Using the Discrete Cosine Transform (DCT)" 

3005 Monthly Weather Review, Vol. 130, 1812-1828 

3006 doi: https://doi.org/10.1175/1520-0493(2002)130<1812:SDOTDA>2.0.CO;2 

3007 """ 

3008 Nt, Ny, Nx = y_3d.shape 

3009 

3010 # Max coefficient 

3011 Nmin = min(Nx - 1, Ny - 1) 

3012 

3013 # Create alpha matrix (of wavenumbers) 

3014 alpha_matrix = _create_alpha_matrix(Ny, Nx) 

3015 

3016 # Prepare output array 

3017 ps_array = np.zeros((Nt, Nmin)) 

3018 

3019 # Loop over time to get spectrum for each time. 

3020 for t in range(Nt): 

3021 y_2d = y_3d[t] 

3022 

3023 # Apply 2D DCT to transform y_3d[t] from physical space to spectral space. 

3024 # fkk is a 2D array of DCT coefficients, representing the amplitudes of 

3025 # cosine basis functions at different spatial frequencies. 

3026 

3027 # normalise spectrum to allow comparison between models. 

3028 fkk = fft.dctn(y_2d, norm="ortho") 

3029 

3030 # Normalise fkk 

3031 fkk = fkk / np.sqrt(Ny * Nx) 

3032 

3033 # calculate variance of spectral coefficient 

3034 sigma_2 = fkk**2 / Nx / Ny 

3035 

3036 # Group ellipses of alphas into the same wavenumber k/Nmin 

3037 for k in range(1, Nmin + 1): 

3038 alpha = k / Nmin 

3039 alpha_p1 = (k + 1) / Nmin 

3040 

3041 # Sum up elements matching k 

3042 mask_k = np.where((alpha_matrix >= alpha) & (alpha_matrix < alpha_p1)) 

3043 ps_array[t, k - 1] = np.sum(sigma_2[mask_k]) 

3044 

3045 return ps_array 

3046 

3047 

3048def _create_alpha_matrix(Ny, Nx): 

3049 """Construct an array of 2D wavenumbers from 2D wavenumber pair. 

3050 

3051 Parameters 

3052 ---------- 

3053 Ny, Nx: 

3054 Dimensions of the 2D field for which the power spectra is calculated. Used to 

3055 create the array of 2D wavenumbers. Each Ny, Nx pair is associated with a 

3056 single-scale parameter. 

3057 

3058 Returns: alpha_matrix 

3059 normalisation of 2D wavenumber axes, transforming the spectral domain into 

3060 an elliptic coordinate system. 

3061 

3062 """ 

3063 # Create x_indices: each row is [1, 2, ..., Nx] 

3064 x_indices = np.tile(np.arange(1, Nx + 1), (Ny, 1)) 

3065 

3066 # Create y_indices: each column is [1, 2, ..., Ny] 

3067 y_indices = np.tile(np.arange(1, Ny + 1).reshape(Ny, 1), (1, Nx)) 

3068 

3069 # Compute alpha_matrix 

3070 alpha_matrix = np.sqrt((x_indices**2) / Nx**2 + (y_indices**2) / Ny**2) 

3071 

3072 return alpha_matrix