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

968 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-28 12:47 +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 

25import sys 

26from typing import Literal 

27 

28import cartopy.crs as ccrs 

29import iris 

30import iris.coords 

31import iris.cube 

32import iris.exceptions 

33import iris.plot as iplt 

34import matplotlib as mpl 

35import matplotlib.colors as mcolors 

36import matplotlib.pyplot as plt 

37import numpy as np 

38import scipy.fft as fft 

39from iris.cube import Cube 

40from markdown_it import MarkdownIt 

41 

42from CSET._common import ( 

43 combine_dicts, 

44 get_recipe_metadata, 

45 iter_maybe, 

46 render_file, 

47 slugify, 

48) 

49from CSET.operators._utils import ( 

50 fully_equalise_attributes, 

51 get_cube_yxcoordname, 

52 is_transect, 

53) 

54from CSET.operators.collapse import collapse 

55from CSET.operators.misc import _extract_common_time_points 

56from CSET.operators.regrid import regrid_onto_cube 

57 

58# Use a non-interactive plotting backend. 

59mpl.use("agg") 

60 

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

62 

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

64# Private helper functions # 

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

66 

67 

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

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

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

71 fcntl.flock(fp, fcntl.LOCK_EX) 

72 fp.seek(0) 

73 meta = json.load(fp) 

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

75 complete_plot_index = complete_plot_index + plot_index 

76 meta["plots"] = complete_plot_index 

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

78 os.getenv("DO_CASE_AGGREGATION") 

79 ): 

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

81 fp.seek(0) 

82 fp.truncate() 

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

84 return complete_plot_index 

85 

86 

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

88 """Ensure a single cube is given. 

89 

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

91 otherwise an error is raised. 

92 

93 Parameters 

94 ---------- 

95 cube: Cube | CubeList 

96 The cube to check. 

97 

98 Returns 

99 ------- 

100 cube: Cube 

101 The checked cube. 

102 

103 Raises 

104 ------ 

105 TypeError 

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

107 """ 

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

109 return cube 

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

111 if len(cube) == 1: 

112 return cube[0] 

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

114 

115 

116def _py312_importlib_resources_files_shim(): 

117 """Importlib behaviour changed in 3.12 to avoid circular dependencies. 

118 

119 This shim is needed until python 3.12 is our oldest supported version, after 

120 which it can just be replaced by directly using importlib.resources.files. 

121 """ 

122 if sys.version_info.minor >= 12: 

123 files = importlib.resources.files() 

124 else: 

125 import CSET.operators 

126 

127 files = importlib.resources.files(CSET.operators) 

128 return files 

129 

130 

131def _make_plot_html_page(plots: list): 

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

133 # Debug check that plots actually contains some strings. 

134 assert isinstance(plots[0], str) 

135 

136 # Load HTML template file. 

137 operator_files = _py312_importlib_resources_files_shim() 

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

139 

140 # Get some metadata. 

141 meta = get_recipe_metadata() 

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

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

144 

145 # Prepare template variables. 

146 variables = { 

147 "title": title, 

148 "description": description, 

149 "initial_plot": plots[0], 

150 "plots": plots, 

151 "title_slug": slugify(title), 

152 } 

153 

154 # Render template. 

155 html = render_file(template_file, **variables) 

156 

157 # Save completed HTML. 

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

159 fp.write(html) 

160 

161 

162@functools.cache 

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

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

165 

166 This is a separate function to make it cacheable. 

167 """ 

168 colorbar_file = _py312_importlib_resources_files_shim().joinpath( 

169 "_colorbar_definition.json" 

170 ) 

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

172 colorbar = json.load(fp) 

173 

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

175 override_colorbar = {} 

176 if user_colorbar_file: 

177 try: 

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

179 override_colorbar = json.load(fp) 

180 except FileNotFoundError: 

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

182 

183 # Overwrite values with the user supplied colorbar definition. 

184 colorbar = combine_dicts(colorbar, override_colorbar) 

185 return colorbar 

186 

187 

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

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

190 

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

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

193 to model_name attribute. 

194 

195 Parameters 

196 ---------- 

197 cubes: CubeList or Cube 

198 Cubes with model_name attribute 

199 

200 Returns 

201 ------- 

202 model_colors_map: 

203 Dictionary mapping model_name attribute to colors 

204 """ 

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

206 colorbar = _load_colorbar_map(user_colorbar_file) 

207 model_names = sorted( 

208 filter( 

209 lambda x: x is not None, 

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

211 ) 

212 ) 

213 if not model_names: 

214 return {} 

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

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

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

218 

219 color_list = itertools.cycle(DEFAULT_DISCRETE_COLORS) 

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

221 

222 

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

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

225 

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

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

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

229 exist for specific pressure levels to account for variables with 

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

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

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

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

234 

235 Parameters 

236 ---------- 

237 cube: Cube 

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

239 axis: "x", "y", optional 

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

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

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

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

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

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

246 

247 Returns 

248 ------- 

249 cmap: 

250 Matplotlib colormap. 

251 levels: 

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

253 should be taken as the range. 

254 norm: 

255 BoundaryNorm information. 

256 """ 

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

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

259 colorbar = _load_colorbar_map(user_colorbar_file) 

260 cmap = None 

261 

262 try: 

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

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

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

266 pressure_level = str(int(pressure_level_raw)) 

267 except iris.exceptions.CoordinateNotFoundError: 

268 pressure_level = None 

269 

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

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

272 # consistent. 

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

274 for varname in varnames: 

275 # Get the colormap for this variable. 

276 try: 

277 var_colorbar = colorbar[varname] 

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

279 varname_key = varname 

280 break 

281 except KeyError: 

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

283 

284 # Get colormap if it is a mask. 

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

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

287 return cmap, levels, norm 

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

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

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

291 return cmap, levels, norm 

292 # If probability is plotted use custom colorbar and levels 

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

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

295 return cmap, levels, norm 

296 # If aviation colour state use custom colorbar and levels 

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

298 cmap, levels, norm = _custom_colormap_aviation_colour_state(cube) 

299 return cmap, levels, norm 

300 

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

302 if not cmap: 

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

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

305 return cmap, levels, norm 

306 

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

308 if pressure_level: 

309 try: 

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

311 except KeyError: 

312 logging.debug( 

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

314 varname, 

315 pressure_level, 

316 ) 

317 

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

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

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

321 if axis: 

322 if axis == "x": 

323 try: 

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

325 except KeyError: 

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

327 if axis == "y": 

328 try: 

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

330 except KeyError: 

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

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

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

334 levels = None 

335 else: 

336 levels = [vmin, vmax] 

337 return None, levels, None 

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

339 else: 

340 try: 

341 levels = var_colorbar["levels"] 

342 # Use discrete bins when levels are specified, rather 

343 # than a smooth range. 

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

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

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

347 except KeyError: 

348 # Get the range for this variable. 

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

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

351 # Calculate levels from range. 

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

353 norm = None 

354 

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

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

357 # JSON file. 

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

359 cmap, levels, norm = _custom_colourmap_visibility_in_air( 

360 cube, cmap, levels, norm 

361 ) 

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

363 return cmap, levels, norm 

364 

365 

366def _setup_spatial_map( 

367 cube: iris.cube.Cube, 

368 figure, 

369 cmap, 

370 grid_size: int | None = None, 

371 subplot: int | None = None, 

372): 

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

374 

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

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

377 

378 Parameters 

379 ---------- 

380 cube: Cube 

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

382 figure: 

383 Matplotlib Figure object holding all plot elements. 

384 cmap: 

385 Matplotlib colormap. 

386 grid_size: int, optional 

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

388 subplot: int, optional 

389 Subplot index if multiple spatial subplots in figure. 

390 

391 Returns 

392 ------- 

393 axes: 

394 Matplotlib GeoAxes definition. 

395 """ 

396 # Identify min/max plot bounds. 

397 try: 

398 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

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

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

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

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

403 

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

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

406 x1 = x1 - 180.0 

407 x2 = x2 - 180.0 

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

409 

410 # Consider map projection orientation. 

411 # Adapting orientation enables plotting across international dateline. 

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

413 if x2 > 180.0: 

414 central_longitude = 180.0 

415 else: 

416 central_longitude = 0.0 

417 

418 # Define spatial map projection. 

419 coord_system = cube.coord(lat_axis).coord_system 

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

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

422 projection = ccrs.RotatedPole( 

423 pole_longitude=coord_system.grid_north_pole_longitude, 

424 pole_latitude=coord_system.grid_north_pole_latitude, 

425 central_rotated_longitude=0.0, 

426 ) 

427 crs = projection 

428 else: 

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

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

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

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

433 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

434 crs = ccrs.PlateCarree() 

435 

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

437 if subplot is not None: 

438 axes = figure.add_subplot( 

439 grid_size, grid_size, subplot, projection=projection 

440 ) 

441 else: 

442 axes = figure.add_subplot(projection=projection) 

443 

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

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

446 coastcol = "magenta" 

447 else: 

448 coastcol = "black" 

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

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

451 

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

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

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

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

456 

457 except ValueError: 

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

459 axes = figure.gca() 

460 pass 

461 

462 return axes 

463 

464 

465def _get_plot_resolution() -> int: 

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

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

468 

469 

470def _plot_and_save_spatial_plot( 

471 cube: iris.cube.Cube, 

472 filename: str, 

473 title: str, 

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

475 **kwargs, 

476): 

477 """Plot and save a spatial plot. 

478 

479 Parameters 

480 ---------- 

481 cube: Cube 

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

483 filename: str 

484 Filename of the plot to write. 

485 title: str 

486 Plot title. 

487 method: "contourf" | "pcolormesh" 

488 The plotting method to use. 

489 """ 

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

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

492 

493 # Specify the color bar 

494 cmap, levels, norm = _colorbar_map_levels(cube) 

495 

496 # Setup plot map projection, extent and coastlines. 

497 axes = _setup_spatial_map(cube, fig, cmap) 

498 

499 # Plot the field. 

500 if method == "contourf": 

501 # Filled contour plot of the field. 

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

503 elif method == "pcolormesh": 

504 try: 

505 vmin = min(levels) 

506 vmax = max(levels) 

507 except TypeError: 

508 vmin, vmax = None, None 

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

510 # if levels are defined. 

511 if norm is not None: 

512 vmin = None 

513 vmax = None 

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

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

516 else: 

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

518 

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

520 if is_transect(cube): 

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

522 axes.invert_yaxis() 

523 axes.set_yscale("log") 

524 axes.set_ylim(1100, 100) 

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

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

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

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

529 ): 

530 axes.set_yscale("log") 

531 

532 axes.set_title( 

533 f"{title}\n" 

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

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

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

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

538 fontsize=16, 

539 ) 

540 

541 else: 

542 # Add title. 

543 axes.set_title(title, fontsize=16) 

544 

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

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

547 axes.annotate( 

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

549 xy=(1, -0.05), 

550 xycoords="axes fraction", 

551 xytext=(-5, 5), 

552 textcoords="offset points", 

553 ha="right", 

554 va="bottom", 

555 size=11, 

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

557 ) 

558 

559 # Add colour bar. 

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

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

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

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

564 cbar.set_ticks(levels) 

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

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

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

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

569 

570 # Save plot. 

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

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

573 plt.close(fig) 

574 

575 

576def _plot_and_save_postage_stamp_spatial_plot( 

577 cube: iris.cube.Cube, 

578 filename: str, 

579 stamp_coordinate: str, 

580 title: str, 

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

582 **kwargs, 

583): 

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

585 

586 Parameters 

587 ---------- 

588 cube: Cube 

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

590 filename: str 

591 Filename of the plot to write. 

592 stamp_coordinate: str 

593 Coordinate that becomes different plots. 

594 method: "contourf" | "pcolormesh" 

595 The plotting method to use. 

596 

597 Raises 

598 ------ 

599 ValueError 

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

601 """ 

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

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

604 

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

606 

607 # Specify the color bar 

608 cmap, levels, norm = _colorbar_map_levels(cube) 

609 

610 # Make a subplot for each member. 

611 for member, subplot in zip( 

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

613 ): 

614 # Setup subplot map projection, extent and coastlines. 

615 axes = _setup_spatial_map( 

616 member, fig, cmap, grid_size=grid_size, subplot=subplot 

617 ) 

618 if method == "contourf": 

619 # Filled contour plot of the field. 

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

621 elif method == "pcolormesh": 

622 if levels is not None: 

623 vmin = min(levels) 

624 vmax = max(levels) 

625 else: 

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

627 vmin, vmax = None, None 

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

629 # if levels are defined. 

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

631 vmin = None 

632 vmax = None 

633 # pcolormesh plot of the field. 

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

635 else: 

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

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

638 axes.set_axis_off() 

639 

640 # Put the shared colorbar in its own axes. 

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

642 colorbar = fig.colorbar( 

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

644 ) 

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

646 

647 # Overall figure title. 

648 fig.suptitle(title, fontsize=16) 

649 

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

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

652 plt.close(fig) 

653 

654 

655def _plot_and_save_line_series( 

656 cubes: iris.cube.CubeList, 

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

658 ensemble_coord: str, 

659 filename: str, 

660 title: str, 

661 **kwargs, 

662): 

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

664 

665 Parameters 

666 ---------- 

667 cubes: Cube or CubeList 

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

669 coords: list[Coord] 

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

671 ensemble_coord: str 

672 Ensemble coordinate in the cube. 

673 filename: str 

674 Filename of the plot to write. 

675 title: str 

676 Plot title. 

677 """ 

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

679 

680 model_colors_map = _get_model_colors_map(cubes) 

681 

682 # Store min/max ranges. 

683 y_levels = [] 

684 

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

686 _validate_cubes_coords(cubes, coords) 

687 

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

689 label = None 

690 color = "black" 

691 if model_colors_map: 

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

693 color = model_colors_map.get(label) 

694 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

697 iplt.plot( 

698 coord, 

699 cube_slice, 

700 color=color, 

701 marker="o", 

702 ls="-", 

703 lw=3, 

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

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

706 else label, 

707 ) 

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

709 else: 

710 iplt.plot( 

711 coord, 

712 cube_slice, 

713 color=color, 

714 ls="-", 

715 lw=1.5, 

716 alpha=0.75, 

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

718 ) 

719 

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

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

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

723 y_levels.append(min(levels)) 

724 y_levels.append(max(levels)) 

725 

726 # Get the current axes. 

727 ax = plt.gca() 

728 

729 # Add some labels and tweak the style. 

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

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

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

733 ax.set_title(title, fontsize=16) 

734 

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

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

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

738 

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

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

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

742 # Add zero line. 

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

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

745 logging.debug( 

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

747 ) 

748 else: 

749 ax.autoscale() 

750 

751 # Add gridlines 

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

753 # Ientify unique labels for legend 

754 handles = list( 

755 { 

756 label: handle 

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

758 }.values() 

759 ) 

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

761 

762 # Save plot. 

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

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

765 plt.close(fig) 

766 

767 

768def _plot_and_save_vertical_line_series( 

769 cubes: iris.cube.CubeList, 

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

771 ensemble_coord: str, 

772 filename: str, 

773 series_coordinate: str, 

774 title: str, 

775 vmin: float, 

776 vmax: float, 

777 **kwargs, 

778): 

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

780 

781 Parameters 

782 ---------- 

783 cubes: CubeList 

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

785 coord: list[Coord] 

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

787 ensemble_coord: str 

788 Ensemble coordinate in the cube. 

789 filename: str 

790 Filename of the plot to write. 

791 series_coordinate: str 

792 Coordinate to use as vertical axis. 

793 title: str 

794 Plot title. 

795 vmin: float 

796 Minimum value for the x-axis. 

797 vmax: float 

798 Maximum value for the x-axis. 

799 """ 

800 # plot the vertical pressure axis using log scale 

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

802 

803 model_colors_map = _get_model_colors_map(cubes) 

804 

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

806 _validate_cubes_coords(cubes, coords) 

807 

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

809 label = None 

810 color = "black" 

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

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

813 color = model_colors_map.get(label) 

814 

815 for cube_slice in cube.slices_over(ensemble_coord): 

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

817 # unless single forecast. 

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

819 iplt.plot( 

820 cube_slice, 

821 coord, 

822 color=color, 

823 marker="o", 

824 ls="-", 

825 lw=3, 

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

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

828 else label, 

829 ) 

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

831 else: 

832 iplt.plot( 

833 cube_slice, 

834 coord, 

835 color=color, 

836 ls="-", 

837 lw=1.5, 

838 alpha=0.75, 

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

840 ) 

841 

842 # Get the current axis 

843 ax = plt.gca() 

844 

845 # Special handling for pressure level data. 

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

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

848 ax.invert_yaxis() 

849 ax.set_yscale("log") 

850 

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

852 y_tick_labels = [ 

853 "1000", 

854 "850", 

855 "700", 

856 "500", 

857 "300", 

858 "200", 

859 "100", 

860 ] 

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

862 

863 # Set y-axis limits and ticks. 

864 ax.set_ylim(1100, 100) 

865 

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

867 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

873 

874 ax.set_yticks(y_ticks) 

875 ax.set_yticklabels(y_tick_labels) 

876 

877 # Set x-axis limits. 

878 ax.set_xlim(vmin, vmax) 

879 # Mark y=0 if present in plot. 

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

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

882 

883 # Add some labels and tweak the style. 

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

885 ax.set_xlabel( 

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

887 ) 

888 ax.set_title(title, fontsize=16) 

889 ax.ticklabel_format(axis="x") 

890 ax.tick_params(axis="y") 

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

892 

893 # Add gridlines 

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

895 # Ientify unique labels for legend 

896 handles = list( 

897 { 

898 label: handle 

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

900 }.values() 

901 ) 

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

903 

904 # Save plot. 

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

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

907 plt.close(fig) 

908 

909 

910def _plot_and_save_scatter_plot( 

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

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

913 filename: str, 

914 title: str, 

915 one_to_one: bool, 

916 model_names: list[str] = None, 

917 **kwargs, 

918): 

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

920 

921 Parameters 

922 ---------- 

923 cube_x: Cube | CubeList 

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

925 cube_y: Cube | CubeList 

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

927 filename: str 

928 Filename of the plot to write. 

929 title: str 

930 Plot title. 

931 one_to_one: bool 

932 Whether a 1:1 line is plotted. 

933 """ 

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

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

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

937 # over the pairs simultaneously. 

938 

939 # Ensure cube_x and cube_y are iterable 

940 cube_x_iterable = iter_maybe(cube_x) 

941 cube_y_iterable = iter_maybe(cube_y) 

942 

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

944 iplt.scatter(cube_x_iter, cube_y_iter) 

945 if one_to_one is True: 

946 plt.plot( 

947 [ 

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

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

950 ], 

951 [ 

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

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

954 ], 

955 "k", 

956 linestyle="--", 

957 ) 

958 ax = plt.gca() 

959 

960 # Add some labels and tweak the style. 

961 if model_names is None: 

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

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

964 else: 

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

966 ax.set_xlabel( 

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

968 ) 

969 ax.set_ylabel( 

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

971 ) 

972 ax.set_title(title, fontsize=16) 

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

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

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

976 ax.autoscale() 

977 

978 # Save plot. 

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

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

981 plt.close(fig) 

982 

983 

984def _plot_and_save_vector_plot( 

985 cube_u: iris.cube.Cube, 

986 cube_v: iris.cube.Cube, 

987 filename: str, 

988 title: str, 

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

990 **kwargs, 

991): 

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

993 

994 Parameters 

995 ---------- 

996 cube_u: Cube 

997 2 dimensional Cube of u component of the data. 

998 cube_v: Cube 

999 2 dimensional Cube of v component of the data. 

1000 filename: str 

1001 Filename of the plot to write. 

1002 title: str 

1003 Plot title. 

1004 """ 

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

1006 

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

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

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

1010 

1011 # Specify the color bar 

1012 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag) 

1013 

1014 # Setup plot map projection, extent and coastlines. 

1015 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

1016 

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

1018 # Filled contour plot of the field. 

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

1020 elif method == "pcolormesh": 

1021 try: 

1022 vmin = min(levels) 

1023 vmax = max(levels) 

1024 except TypeError: 

1025 vmin, vmax = None, None 

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

1027 # if levels are defined. 

1028 if norm is not None: 

1029 vmin = None 

1030 vmax = None 

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

1032 else: 

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

1034 

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

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

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

1038 axes.invert_yaxis() 

1039 axes.set_yscale("log") 

1040 axes.set_ylim(1100, 100) 

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

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

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

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

1045 ): 

1046 axes.set_yscale("log") 

1047 

1048 axes.set_title( 

1049 f"{title}\n" 

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

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

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

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

1054 fontsize=16, 

1055 ) 

1056 

1057 else: 

1058 # Add title. 

1059 axes.set_title(title, fontsize=16) 

1060 

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

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

1063 axes.annotate( 

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

1065 xy=(1, -0.05), 

1066 xycoords="axes fraction", 

1067 xytext=(-5, 5), 

1068 textcoords="offset points", 

1069 ha="right", 

1070 va="bottom", 

1071 size=11, 

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

1073 ) 

1074 

1075 # Add colour bar. 

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

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

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

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

1080 cbar.set_ticks(levels) 

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

1082 

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

1084 # with less than 30 points. 

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

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

1087 

1088 # Save plot. 

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

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

1091 plt.close(fig) 

1092 

1093 

1094def _plot_and_save_histogram_series( 

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

1096 filename: str, 

1097 title: str, 

1098 vmin: float, 

1099 vmax: float, 

1100 **kwargs, 

1101): 

1102 """Plot and save a histogram series. 

1103 

1104 Parameters 

1105 ---------- 

1106 cubes: Cube or CubeList 

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

1108 filename: str 

1109 Filename of the plot to write. 

1110 title: str 

1111 Plot title. 

1112 vmin: float 

1113 minimum for colorbar 

1114 vmax: float 

1115 maximum for colorbar 

1116 """ 

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

1118 ax = plt.gca() 

1119 

1120 model_colors_map = _get_model_colors_map(cubes) 

1121 

1122 # Set default that histograms will produce probability density function 

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

1124 density = True 

1125 

1126 for cube in iter_maybe(cubes): 

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

1128 # than seeing if long names exist etc. 

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

1130 if "surface_microphysical" in title: 

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

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

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

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

1135 density = False 

1136 else: 

1137 bins = 10.0 ** ( 

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

1139 ) # Suggestion from RMED toolbox. 

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

1141 ax.set_yscale("log") 

1142 vmin = bins[1] 

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

1144 ax.set_xscale("log") 

1145 elif "lightning" in title: 

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

1147 else: 

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

1149 logging.debug( 

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

1151 np.size(bins), 

1152 np.min(bins), 

1153 np.max(bins), 

1154 ) 

1155 

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

1157 # Otherwise we plot xdim histograms stacked. 

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

1159 

1160 label = None 

1161 color = "black" 

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

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

1164 color = model_colors_map[label] 

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

1166 

1167 # Compute area under curve. 

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

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

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

1171 x = x[1:] 

1172 y = y[1:] 

1173 

1174 ax.plot( 

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

1176 ) 

1177 

1178 # Add some labels and tweak the style. 

1179 ax.set_title(title, fontsize=16) 

1180 ax.set_xlabel( 

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

1182 ) 

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

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

1185 ax.set_ylabel( 

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

1187 ) 

1188 ax.set_xlim(vmin, vmax) 

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

1190 

1191 # Overlay grid-lines onto histogram plot. 

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

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

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

1195 

1196 # Save plot. 

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

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

1199 plt.close(fig) 

1200 

1201 

1202def _plot_and_save_postage_stamp_histogram_series( 

1203 cube: iris.cube.Cube, 

1204 filename: str, 

1205 title: str, 

1206 stamp_coordinate: str, 

1207 vmin: float, 

1208 vmax: float, 

1209 **kwargs, 

1210): 

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

1212 

1213 Parameters 

1214 ---------- 

1215 cube: Cube 

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

1217 filename: str 

1218 Filename of the plot to write. 

1219 title: str 

1220 Plot title. 

1221 stamp_coordinate: str 

1222 Coordinate that becomes different plots. 

1223 vmin: float 

1224 minimum for pdf x-axis 

1225 vmax: float 

1226 maximum for pdf x-axis 

1227 """ 

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

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

1230 

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

1232 # Make a subplot for each member. 

1233 for member, subplot in zip( 

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

1235 ): 

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

1237 # cartopy GeoAxes generated. 

1238 plt.subplot(grid_size, grid_size, subplot) 

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

1240 # Otherwise we plot xdim histograms stacked. 

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

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

1243 ax = plt.gca() 

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

1245 ax.set_xlim(vmin, vmax) 

1246 

1247 # Overall figure title. 

1248 fig.suptitle(title, fontsize=16) 

1249 

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

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

1252 plt.close(fig) 

1253 

1254 

1255def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1256 cube: iris.cube.Cube, 

1257 filename: str, 

1258 title: str, 

1259 stamp_coordinate: str, 

1260 vmin: float, 

1261 vmax: float, 

1262 **kwargs, 

1263): 

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

1265 ax.set_title(title, fontsize=16) 

1266 ax.set_xlim(vmin, vmax) 

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

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

1269 # Loop over all slices along the stamp_coordinate 

1270 for member in cube.slices_over(stamp_coordinate): 

1271 # Flatten the member data to 1D 

1272 member_data_1d = member.data.flatten() 

1273 # Plot the histogram using plt.hist 

1274 plt.hist( 

1275 member_data_1d, 

1276 density=True, 

1277 stacked=True, 

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

1279 ) 

1280 

1281 # Add a legend 

1282 ax.legend(fontsize=16) 

1283 

1284 # Save the figure to a file 

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

1286 

1287 # Close the figure 

1288 plt.close(fig) 

1289 

1290 

1291def _plot_and_save_scattermap_plot( 

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

1293): 

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

1295 

1296 Parameters 

1297 ---------- 

1298 cube: Cube 

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

1300 longitude coordinates, 

1301 filename: str 

1302 Filename of the plot to write. 

1303 title: str 

1304 Plot title. 

1305 projection: str 

1306 Mapping projection to be used by cartopy. 

1307 """ 

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

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

1310 if projection is not None: 

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

1312 # a stereographic projection over the North Pole. 

1313 if projection == "NP_Stereo": 

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

1315 else: 

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

1317 else: 

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

1319 

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

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

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

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

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

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

1326 # proportion to the area of the figure. 

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

1328 klon = None 

1329 klat = None 

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

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

1332 klat = kc 

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

1334 klon = kc 

1335 scatter_map = iplt.scatter( 

1336 cube.aux_coords[klon], 

1337 cube.aux_coords[klat], 

1338 c=cube.data[:], 

1339 s=mrk_size, 

1340 cmap="jet", 

1341 edgecolors="k", 

1342 ) 

1343 

1344 # Add coastlines. 

1345 try: 

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

1347 except AttributeError: 

1348 pass 

1349 

1350 # Add title. 

1351 axes.set_title(title, fontsize=16) 

1352 

1353 # Add colour bar. 

1354 cbar = fig.colorbar(scatter_map) 

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

1356 

1357 # Save plot. 

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

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

1360 plt.close(fig) 

1361 

1362 

1363def _plot_and_save_power_spectrum_series( 

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

1365 filename: str, 

1366 title: str, 

1367 **kwargs, 

1368): 

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

1370 

1371 Parameters 

1372 ---------- 

1373 cubes: Cube or CubeList 

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

1375 filename: str 

1376 Filename of the plot to write. 

1377 title: str 

1378 Plot title. 

1379 """ 

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

1381 ax = plt.gca() 

1382 

1383 model_colors_map = _get_model_colors_map(cubes) 

1384 

1385 for cube in iter_maybe(cubes): 

1386 # Calculate power spectrum 

1387 

1388 # Extract time coordinate and convert to datetime 

1389 time_coord = cube.coord("time") 

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

1391 

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

1393 target_time = time_points[0] 

1394 

1395 # Bind target_time inside the lambda using a default argument 

1396 time_constraint = iris.Constraint( 

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

1398 ) 

1399 

1400 cube = cube.extract(time_constraint) 

1401 

1402 if cube.ndim == 2: 

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

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

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

1406 cube_3d = cube.data 

1407 else: 

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

1409 raise ValueError( 

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

1411 ) 

1412 

1413 # Calculate spectra 

1414 ps_array = _DCT_ps(cube_3d) 

1415 

1416 ps_cube = iris.cube.Cube( 

1417 ps_array, 

1418 long_name="power_spectra", 

1419 ) 

1420 

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

1422 

1423 # Create a frequency/wavelength array for coordinate 

1424 ps_len = ps_cube.data.shape[1] 

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

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

1427 

1428 # Convert datetime to numeric time using original units 

1429 numeric_time = time_coord.units.date2num(time_points) 

1430 # Create a new DimCoord with numeric time 

1431 new_time_coord = iris.coords.DimCoord( 

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

1433 ) 

1434 

1435 # Add time and frequency coordinate to spectra cube. 

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

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

1438 

1439 # Extract data from the cube 

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

1441 power_spectrum = ps_cube.data 

1442 

1443 label = None 

1444 color = "black" 

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

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

1447 color = model_colors_map[label] 

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

1449 

1450 # Add some labels and tweak the style. 

1451 ax.set_title(title, fontsize=16) 

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

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

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

1455 

1456 # Set log-log scale 

1457 ax.set_xscale("log") 

1458 ax.set_yscale("log") 

1459 

1460 # Overlay grid-lines onto power spectrum plot. 

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

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

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

1464 

1465 # Save plot. 

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

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

1468 plt.close(fig) 

1469 

1470 

1471def _plot_and_save_postage_stamp_power_spectrum_series( 

1472 cube: iris.cube.Cube, 

1473 filename: str, 

1474 title: str, 

1475 stamp_coordinate: str, 

1476 **kwargs, 

1477): 

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

1479 

1480 Parameters 

1481 ---------- 

1482 cube: Cube 

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

1484 filename: str 

1485 Filename of the plot to write. 

1486 title: str 

1487 Plot title. 

1488 stamp_coordinate: str 

1489 Coordinate that becomes different plots. 

1490 """ 

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

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

1493 

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

1495 # Make a subplot for each member. 

1496 for member, subplot in zip( 

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

1498 ): 

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

1500 # cartopy GeoAxes generated. 

1501 plt.subplot(grid_size, grid_size, subplot) 

1502 

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

1504 power_spectrum = member.data 

1505 

1506 ax = plt.gca() 

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

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

1509 

1510 # Overall figure title. 

1511 fig.suptitle(title, fontsize=16) 

1512 

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

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

1515 plt.close(fig) 

1516 

1517 

1518def _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series( 

1519 cube: iris.cube.Cube, 

1520 filename: str, 

1521 title: str, 

1522 stamp_coordinate: str, 

1523 **kwargs, 

1524): 

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

1526 ax.set_title(title, fontsize=16) 

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

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

1529 # Loop over all slices along the stamp_coordinate 

1530 for member in cube.slices_over(stamp_coordinate): 

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

1532 power_spectrum = member.data 

1533 ax.plot( 

1534 frequency, 

1535 power_spectrum[0], 

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

1537 ) 

1538 

1539 # Add a legend 

1540 ax.legend(fontsize=16) 

1541 

1542 # Save the figure to a file 

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

1544 

1545 # Close the figure 

1546 plt.close(fig) 

1547 

1548 

1549def _spatial_plot( 

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

1551 cube: iris.cube.Cube, 

1552 filename: str | None, 

1553 sequence_coordinate: str, 

1554 stamp_coordinate: str, 

1555 **kwargs, 

1556): 

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

1558 

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

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

1561 is present then postage stamp plots will be produced. 

1562 

1563 Parameters 

1564 ---------- 

1565 method: "contourf" | "pcolormesh" 

1566 The plotting method to use. 

1567 cube: Cube 

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

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

1570 plotted sequentially and/or as postage stamp plots. 

1571 filename: str | None 

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

1573 uses the recipe name. 

1574 sequence_coordinate: str 

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

1576 This coordinate must exist in the cube. 

1577 stamp_coordinate: str 

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

1579 ``"realization"``. 

1580 

1581 Raises 

1582 ------ 

1583 ValueError 

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

1585 TypeError 

1586 If the cube isn't a single cube. 

1587 """ 

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

1589 

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

1591 if filename is None: 

1592 filename = slugify(recipe_title) 

1593 

1594 # Ensure we've got a single cube. 

1595 cube = _check_single_cube(cube) 

1596 

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

1598 # single point. 

1599 plotting_func = _plot_and_save_spatial_plot 

1600 try: 

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

1602 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1603 except iris.exceptions.CoordinateNotFoundError: 

1604 pass 

1605 

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

1607 # dimension called observation or model_obs_error 

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

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

1610 for crd in cube.coords() 

1611 ): 

1612 plotting_func = _plot_and_save_scattermap_plot 

1613 

1614 # Must have a sequence coordinate. 

1615 try: 

1616 cube.coord(sequence_coordinate) 

1617 except iris.exceptions.CoordinateNotFoundError as err: 

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

1619 

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

1621 plot_index = [] 

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

1623 for cube_slice in cube.slices_over(sequence_coordinate): 

1624 # Use sequence value so multiple sequences can merge. 

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

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

1627 coord = cube_slice.coord(sequence_coordinate) 

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

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

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

1631 if nplot == 1 and coord.has_bounds: 

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

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

1634 # Do the actual plotting. 

1635 plotting_func( 

1636 cube_slice, 

1637 filename=plot_filename, 

1638 stamp_coordinate=stamp_coordinate, 

1639 title=title, 

1640 method=method, 

1641 **kwargs, 

1642 ) 

1643 plot_index.append(plot_filename) 

1644 

1645 # Add list of plots to plot metadata. 

1646 complete_plot_index = _append_to_plot_index(plot_index) 

1647 

1648 # Make a page to display the plots. 

1649 _make_plot_html_page(complete_plot_index) 

1650 

1651 

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

1653 """Get colourmap for mask. 

1654 

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

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

1657 

1658 Parameters 

1659 ---------- 

1660 cube: Cube 

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

1662 axis: "x", "y", optional 

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

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

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

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

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

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

1669 

1670 Returns 

1671 ------- 

1672 cmap: 

1673 Matplotlib colormap. 

1674 levels: 

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

1676 should be taken as the range. 

1677 norm: 

1678 BoundaryNorm information. 

1679 """ 

1680 if "difference" not in cube.long_name: 

1681 if axis: 

1682 levels = [0, 1] 

1683 # Complete settings based on levels. 

1684 return None, levels, None 

1685 else: 

1686 # Define the levels and colors. 

1687 levels = [0, 1, 2] 

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

1689 # Create a custom color map. 

1690 cmap = mcolors.ListedColormap(colors) 

1691 # Normalize the levels. 

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

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

1694 return cmap, levels, norm 

1695 else: 

1696 if axis: 

1697 levels = [-1, 1] 

1698 return None, levels, None 

1699 else: 

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

1701 # not <=. 

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

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

1704 cmap = mcolors.ListedColormap(colors) 

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

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

1707 return cmap, levels, norm 

1708 

1709 

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

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

1712 

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

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

1715 

1716 Parameters 

1717 ---------- 

1718 cube: Cube 

1719 Cube of variable with Beaufort Scale in name. 

1720 axis: "x", "y", optional 

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

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

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

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

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

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

1727 

1728 Returns 

1729 ------- 

1730 cmap: 

1731 Matplotlib colormap. 

1732 levels: 

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

1734 should be taken as the range. 

1735 norm: 

1736 BoundaryNorm information. 

1737 """ 

1738 if "difference" not in cube.long_name: 

1739 if axis: 

1740 levels = [0, 12] 

1741 return None, levels, None 

1742 else: 

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

1744 colors = [ 

1745 "black", 

1746 (0, 0, 0.6), 

1747 "blue", 

1748 "cyan", 

1749 "green", 

1750 "yellow", 

1751 (1, 0.5, 0), 

1752 "red", 

1753 "pink", 

1754 "magenta", 

1755 "purple", 

1756 "maroon", 

1757 "white", 

1758 ] 

1759 cmap = mcolors.ListedColormap(colors) 

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

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

1762 return cmap, levels, norm 

1763 else: 

1764 if axis: 

1765 levels = [-4, 4] 

1766 return None, levels, None 

1767 else: 

1768 levels = [ 

1769 -3.5, 

1770 -2.5, 

1771 -1.5, 

1772 -0.5, 

1773 0.5, 

1774 1.5, 

1775 2.5, 

1776 3.5, 

1777 ] 

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

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

1780 return cmap, levels, norm 

1781 

1782 

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

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

1785 

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

1787 

1788 Parameters 

1789 ---------- 

1790 cube: Cube 

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

1792 cmap: Matplotlib colormap. 

1793 levels: List 

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

1795 should be taken as the range. 

1796 norm: BoundaryNorm. 

1797 

1798 Returns 

1799 ------- 

1800 cmap: Matplotlib colormap. 

1801 levels: List 

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

1803 should be taken as the range. 

1804 norm: BoundaryNorm. 

1805 """ 

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

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

1808 levels = np.array(levels) 

1809 levels -= 273 

1810 levels = levels.tolist() 

1811 else: 

1812 # Do nothing keep the existing colourbar attributes 

1813 levels = levels 

1814 cmap = cmap 

1815 norm = norm 

1816 return cmap, levels, norm 

1817 

1818 

1819def _custom_colormap_probability( 

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

1821): 

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

1823 

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

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

1826 

1827 Parameters 

1828 ---------- 

1829 cube: Cube 

1830 Cube of variable with probability in name. 

1831 axis: "x", "y", optional 

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

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

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

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

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

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

1838 

1839 Returns 

1840 ------- 

1841 cmap: 

1842 Matplotlib colormap. 

1843 levels: 

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

1845 should be taken as the range. 

1846 norm: 

1847 BoundaryNorm information. 

1848 """ 

1849 if axis: 

1850 levels = [0, 1] 

1851 return None, levels, None 

1852 else: 

1853 cmap = mcolors.ListedColormap( 

1854 [ 

1855 "#FFFFFF", 

1856 "#636363", 

1857 "#e1dada", 

1858 "#B5CAFF", 

1859 "#8FB3FF", 

1860 "#7F97FF", 

1861 "#ABCF63", 

1862 "#E8F59E", 

1863 "#FFFA14", 

1864 "#FFD121", 

1865 "#FFA30A", 

1866 ] 

1867 ) 

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

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

1870 return cmap, levels, norm 

1871 

1872 

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

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

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

1876 if ( 

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

1878 and "difference" not in cube.long_name 

1879 and "mask" not in cube.long_name 

1880 ): 

1881 # Define the levels and colors 

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

1883 colors = [ 

1884 "w", 

1885 (0, 0, 0.6), 

1886 "b", 

1887 "c", 

1888 "g", 

1889 "y", 

1890 (1, 0.5, 0), 

1891 "r", 

1892 "pink", 

1893 "m", 

1894 "purple", 

1895 "maroon", 

1896 "gray", 

1897 ] 

1898 # Create a custom colormap 

1899 cmap = mcolors.ListedColormap(colors) 

1900 # Normalize the levels 

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

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

1903 else: 

1904 # do nothing and keep existing colorbar attributes 

1905 cmap = cmap 

1906 levels = levels 

1907 norm = norm 

1908 return cmap, levels, norm 

1909 

1910 

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

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

1913 

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

1915 this function will be called. 

1916 

1917 Parameters 

1918 ---------- 

1919 cube: Cube 

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

1921 

1922 Returns 

1923 ------- 

1924 cmap: Matplotlib colormap. 

1925 levels: List 

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

1927 should be taken as the range. 

1928 norm: BoundaryNorm. 

1929 """ 

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

1931 colors = [ 

1932 "#87ceeb", 

1933 "#ffffff", 

1934 "#8ced69", 

1935 "#ffff00", 

1936 "#ffd700", 

1937 "#ffa500", 

1938 "#fe3620", 

1939 ] 

1940 # Create a custom colormap 

1941 cmap = mcolors.ListedColormap(colors) 

1942 # Normalise the levels 

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

1944 return cmap, levels, norm 

1945 

1946 

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

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

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

1950 if ( 

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

1952 and "difference" not in cube.long_name 

1953 and "mask" not in cube.long_name 

1954 ): 

1955 # Define the levels and colors (in km) 

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

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

1958 colours = [ 

1959 "#8f00d6", 

1960 "#d10000", 

1961 "#ff9700", 

1962 "#ffff00", 

1963 "#00007f", 

1964 "#6c9ccd", 

1965 "#aae8ff", 

1966 "#37a648", 

1967 "#8edc64", 

1968 "#c5ffc5", 

1969 "#dcdcdc", 

1970 "#ffffff", 

1971 ] 

1972 # Create a custom colormap 

1973 cmap = mcolors.ListedColormap(colours) 

1974 # Normalize the levels 

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

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

1977 else: 

1978 # do nothing and keep existing colorbar attributes 

1979 cmap = cmap 

1980 levels = levels 

1981 norm = norm 

1982 return cmap, levels, norm 

1983 

1984 

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

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

1987 model_names = list( 

1988 filter( 

1989 lambda x: x is not None, 

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

1991 ) 

1992 ) 

1993 if not model_names: 

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

1995 return 1 

1996 else: 

1997 return len(model_names) 

1998 

1999 

2000def _validate_cube_shape( 

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

2002) -> None: 

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

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

2005 raise ValueError( 

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

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

2008 ) 

2009 

2010 

2011def _validate_cubes_coords( 

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

2013) -> None: 

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

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

2016 raise ValueError( 

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

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

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

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

2021 ) 

2022 

2023 

2024def _calculate_CFAD( 

2025 cube: iris.cube.Cube, vertical_coordinate: str, bin_edges: list[float] 

2026) -> iris.cube.Cube: 

2027 """Calculate a Contour Frequency by Altitude Diagram (CFAD). 

2028 

2029 Parameters 

2030 ---------- 

2031 cube: iris.cube.Cube 

2032 A cube of the data to be turned into a CFAD. It should be a minimum 

2033 of two dimensions with one being a user specified vertical coordinate. 

2034 vertical_coordinate: str 

2035 The vertical coordinate of the cube for the CFAD to be calculated over. 

2036 bin_edges: list[float] 

2037 The bin edges for the histogram. The bins need to be specified to 

2038 ensure consistency across the CFAD, otherwise it cannot be interpreted. 

2039 

2040 Notes 

2041 ----- 

2042 Contour Frequency by Altitude Diagrams (CFADs) were first designed by 

2043 Yuter and Houze (1995)[YuterandHouze95]. They are calculated by binning the 

2044 data by altitude and then by variable bins (e.g. temperature). The variable 

2045 bins are then normalised by each altitude. This essenitally creates a 

2046 normalised frequency distribution for each altitude. These are then stacked 

2047 and combined in a single plot. 

2048 

2049 References 

2050 ---------- 

2051 .. [YuterandHouze95] Yuter S.E., and Houze, R.A. (1995) "Three-Dimensional 

2052 Kinematic and Microphysical Evolution of Florida Cumulonimbus. Part II: 

2053 Frequency Distributions of Vertical Velocity, Reflectivity, and 

2054 Differential Reflectivity" Monthly Weather Review, vol. 123, 1941-1963, 

2055 doi: 10.1175/1520-0493(1995)123<1941:TDKAME>2.0.CO;2 

2056 """ 

2057 # Setup empty array for containing the CFAD data. 

2058 CFAD_values = np.zeros( 

2059 (len(cube.coord(vertical_coordinate).points), len(bin_edges) - 1) 

2060 ) 

2061 

2062 # Calculate the CFAD as a histogram summing to one for each level. 

2063 for i, level_cube in enumerate(cube.slices_over(vertical_coordinate)): 

2064 # Note setting density to True does not produce the correct 

2065 # normalization for a CFAD, where each row must sum to one. 

2066 CFAD_values[i, :] = ( 

2067 np.histogram(level_cube.data.reshape(level_cube.data.size), bins=bin_edges)[ 

2068 0 

2069 ] 

2070 / level_cube.data.size 

2071 ) 

2072 # Calculate central points for bins. 

2073 bins = (np.array(bin_edges[:-1]) + np.array(bin_edges[1:])) / 2.0 

2074 bin_bounds = np.array((bin_edges[:-1], bin_edges[1:])).T 

2075 # Now construct the coordinates for the cube. 

2076 vert_coord = cube.coord(vertical_coordinate) 

2077 bin_coord = iris.coords.DimCoord( 

2078 bins, bounds=bin_bounds, standard_name=cube.standard_name, units=cube.units 

2079 ) 

2080 # Now construct the cube that is to be output. 

2081 CFAD = iris.cube.Cube( 

2082 CFAD_values, 

2083 dim_coords_and_dims=[(vert_coord, 0), (bin_coord, 1)], 

2084 long_name=f"{cube.name()}_cfad", 

2085 units="1", 

2086 ) 

2087 CFAD.rename(f"{cube.name()}_cfad") 

2088 return CFAD 

2089 

2090 

2091#################### 

2092# Public functions # 

2093#################### 

2094 

2095 

2096def spatial_contour_plot( 

2097 cube: iris.cube.Cube, 

2098 filename: str = None, 

2099 sequence_coordinate: str = "time", 

2100 stamp_coordinate: str = "realization", 

2101 **kwargs, 

2102) -> iris.cube.Cube: 

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

2104 

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

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

2107 is present then postage stamp plots will be produced. 

2108 

2109 Parameters 

2110 ---------- 

2111 cube: Cube 

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

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

2114 plotted sequentially and/or as postage stamp plots. 

2115 filename: str, optional 

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

2117 to the recipe name. 

2118 sequence_coordinate: str, optional 

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

2120 This coordinate must exist in the cube. 

2121 stamp_coordinate: str, optional 

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

2123 ``"realization"``. 

2124 

2125 Returns 

2126 ------- 

2127 Cube 

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

2129 

2130 Raises 

2131 ------ 

2132 ValueError 

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

2134 TypeError 

2135 If the cube isn't a single cube. 

2136 """ 

2137 _spatial_plot( 

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

2139 ) 

2140 return cube 

2141 

2142 

2143def spatial_pcolormesh_plot( 

2144 cube: iris.cube.Cube, 

2145 filename: str = None, 

2146 sequence_coordinate: str = "time", 

2147 stamp_coordinate: str = "realization", 

2148 **kwargs, 

2149) -> iris.cube.Cube: 

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

2151 

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

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

2154 is present then postage stamp plots will be produced. 

2155 

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

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

2158 contour areas are important. 

2159 

2160 Parameters 

2161 ---------- 

2162 cube: Cube 

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

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

2165 plotted sequentially and/or as postage stamp plots. 

2166 filename: str, optional 

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

2168 to the recipe name. 

2169 sequence_coordinate: str, optional 

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

2171 This coordinate must exist in the cube. 

2172 stamp_coordinate: str, optional 

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

2174 ``"realization"``. 

2175 

2176 Returns 

2177 ------- 

2178 Cube 

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

2180 

2181 Raises 

2182 ------ 

2183 ValueError 

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

2185 TypeError 

2186 If the cube isn't a single cube. 

2187 """ 

2188 _spatial_plot( 

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

2190 ) 

2191 return cube 

2192 

2193 

2194# TODO: Expand function to handle ensemble data. 

2195# line_coordinate: str, optional 

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

2197# ``"realization"``. 

2198def plot_line_series( 

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

2200 filename: str = None, 

2201 series_coordinate: str = "time", 

2202 # line_coordinate: str = "realization", 

2203 **kwargs, 

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

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

2206 

2207 The Cube or CubeList must be 1D. 

2208 

2209 Parameters 

2210 ---------- 

2211 iris.cube | iris.cube.CubeList 

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

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

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

2215 filename: str, optional 

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

2217 to the recipe name. 

2218 series_coordinate: str, optional 

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

2220 coordinate must exist in the cube. 

2221 

2222 Returns 

2223 ------- 

2224 iris.cube.Cube | iris.cube.CubeList 

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

2226 plotted data. 

2227 

2228 Raises 

2229 ------ 

2230 ValueError 

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

2232 TypeError 

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

2234 """ 

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

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

2237 

2238 if filename is None: 

2239 filename = slugify(title) 

2240 

2241 # Add file extension. 

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

2243 

2244 num_models = _get_num_models(cube) 

2245 

2246 _validate_cube_shape(cube, num_models) 

2247 

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

2249 cubes = iter_maybe(cube) 

2250 coords = [] 

2251 for cube in cubes: 

2252 try: 

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

2254 except iris.exceptions.CoordinateNotFoundError as err: 

2255 raise ValueError( 

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

2257 ) from err 

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

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

2260 

2261 # Do the actual plotting. 

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

2263 

2264 # Add list of plots to plot metadata. 

2265 plot_index = _append_to_plot_index([plot_filename]) 

2266 

2267 # Make a page to display the plots. 

2268 _make_plot_html_page(plot_index) 

2269 

2270 return cube 

2271 

2272 

2273def plot_vertical_line_series( 

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

2275 filename: str = None, 

2276 series_coordinate: str = "model_level_number", 

2277 sequence_coordinate: str = "time", 

2278 # line_coordinate: str = "realization", 

2279 **kwargs, 

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

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

2282 

2283 The Cube or CubeList must be 1D. 

2284 

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

2286 then a sequence of plots will be produced. 

2287 

2288 Parameters 

2289 ---------- 

2290 iris.cube | iris.cube.CubeList 

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

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

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

2294 filename: str, optional 

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

2296 to the recipe name. 

2297 series_coordinate: str, optional 

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

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

2300 for LFRic. Defaults to ``model_level_number``. 

2301 This coordinate must exist in the cube. 

2302 sequence_coordinate: str, optional 

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

2304 This coordinate must exist in the cube. 

2305 

2306 Returns 

2307 ------- 

2308 iris.cube.Cube | iris.cube.CubeList 

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

2310 Plotted data. 

2311 

2312 Raises 

2313 ------ 

2314 ValueError 

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

2316 TypeError 

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

2318 """ 

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

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

2321 

2322 if filename is None: 

2323 filename = slugify(recipe_title) 

2324 

2325 cubes = iter_maybe(cubes) 

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

2327 all_data = [] 

2328 

2329 # Store min/max ranges for x range. 

2330 x_levels = [] 

2331 

2332 num_models = _get_num_models(cubes) 

2333 

2334 _validate_cube_shape(cubes, num_models) 

2335 

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

2337 coords = [] 

2338 for cube in cubes: 

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

2340 try: 

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

2342 except iris.exceptions.CoordinateNotFoundError as err: 

2343 raise ValueError( 

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

2345 ) from err 

2346 

2347 try: 

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

2349 cube.coord(sequence_coordinate) 

2350 except iris.exceptions.CoordinateNotFoundError as err: 

2351 raise ValueError( 

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

2353 ) from err 

2354 

2355 # Get minimum and maximum from levels information. 

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

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

2358 x_levels.append(min(levels)) 

2359 x_levels.append(max(levels)) 

2360 else: 

2361 all_data.append(cube.data) 

2362 

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

2364 # Combine all data into a single NumPy array 

2365 combined_data = np.concatenate(all_data) 

2366 

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

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

2369 # sequence and if applicable postage stamp coordinate. 

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

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

2372 else: 

2373 vmin = min(x_levels) 

2374 vmax = max(x_levels) 

2375 

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

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

2378 # necessary) 

2379 def filter_cube_iterables(cube_iterables) -> bool: 

2380 return len(cube_iterables) == len(coords) 

2381 

2382 cube_iterables = filter( 

2383 filter_cube_iterables, 

2384 ( 

2385 iris.cube.CubeList( 

2386 s 

2387 for s in itertools.chain.from_iterable( 

2388 cb.slices_over(sequence_coordinate) for cb in cubes 

2389 ) 

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

2391 ) 

2392 for point in sorted( 

2393 set( 

2394 itertools.chain.from_iterable( 

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

2396 ) 

2397 ) 

2398 ) 

2399 ), 

2400 ) 

2401 

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

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

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

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

2406 # or an iris.cube.CubeList. 

2407 plot_index = [] 

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

2409 for cubes_slice in cube_iterables: 

2410 # Use sequence value so multiple sequences can merge. 

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

2412 sequence_value = seq_coord.points[0] 

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

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

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

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

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

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

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

2420 # Do the actual plotting. 

2421 _plot_and_save_vertical_line_series( 

2422 cubes_slice, 

2423 coords, 

2424 "realization", 

2425 plot_filename, 

2426 series_coordinate, 

2427 title=title, 

2428 vmin=vmin, 

2429 vmax=vmax, 

2430 ) 

2431 plot_index.append(plot_filename) 

2432 

2433 # Add list of plots to plot metadata. 

2434 complete_plot_index = _append_to_plot_index(plot_index) 

2435 

2436 # Make a page to display the plots. 

2437 _make_plot_html_page(complete_plot_index) 

2438 

2439 return cubes 

2440 

2441 

2442def qq_plot( 

2443 cubes: iris.cube.CubeList, 

2444 coordinates: list[str], 

2445 percentiles: list[float], 

2446 model_names: list[str], 

2447 filename: str = None, 

2448 one_to_one: bool = True, 

2449 **kwargs, 

2450) -> iris.cube.CubeList: 

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

2452 

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

2454 collapsed within the operator over all specified coordinates such as 

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

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

2457 

2458 Parameters 

2459 ---------- 

2460 cubes: iris.cube.CubeList 

2461 Two cubes of the same variable with different models. 

2462 coordinate: list[str] 

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

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

2465 the percentile coordinate. 

2466 percent: list[float] 

2467 A list of percentiles to appear in the plot. 

2468 model_names: list[str] 

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

2470 filename: str, optional 

2471 Filename of the plot to write. 

2472 one_to_one: bool, optional 

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

2474 

2475 Raises 

2476 ------ 

2477 ValueError 

2478 When the cubes are not compatible. 

2479 

2480 Notes 

2481 ----- 

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

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

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

2485 compares percentiles of two datasets. This plot does 

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

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

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

2489 

2490 Quantile-quantile plots are valuable for comparing against 

2491 observations and other models. Identical percentiles between the variables 

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

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

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

2495 Wilks 2011 [Wilks2011]_). 

2496 

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

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

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

2500 the extremes. 

2501 

2502 References 

2503 ---------- 

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

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

2506 """ 

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

2508 if len(cubes) != 2: 

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

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

2511 other: Cube = cubes.extract_cube( 

2512 iris.Constraint( 

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

2514 ) 

2515 ) 

2516 

2517 # Get spatial coord names. 

2518 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

2519 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

2520 

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

2522 # This is triggered if either 

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

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

2525 # errors. 

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

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

2528 # for UM and LFRic comparisons. 

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

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

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

2532 # given this dependency on regridding. 

2533 if ( 

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

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

2536 ) or ( 

2537 base.long_name 

2538 in [ 

2539 "eastward_wind_at_10m", 

2540 "northward_wind_at_10m", 

2541 "northward_wind_at_cell_centres", 

2542 "eastward_wind_at_cell_centres", 

2543 "zonal_wind_at_pressure_levels", 

2544 "meridional_wind_at_pressure_levels", 

2545 "potential_vorticity_at_pressure_levels", 

2546 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

2547 ] 

2548 ): 

2549 logging.debug( 

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

2551 ) 

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

2553 

2554 # Extract just common time points. 

2555 base, other = _extract_common_time_points(base, other) 

2556 

2557 # Equalise attributes so we can merge. 

2558 fully_equalise_attributes([base, other]) 

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

2560 

2561 # Collapse cubes. 

2562 base = collapse( 

2563 base, 

2564 coordinate=coordinates, 

2565 method="PERCENTILE", 

2566 additional_percent=percentiles, 

2567 ) 

2568 other = collapse( 

2569 other, 

2570 coordinate=coordinates, 

2571 method="PERCENTILE", 

2572 additional_percent=percentiles, 

2573 ) 

2574 

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

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

2577 

2578 if filename is None: 

2579 filename = slugify(title) 

2580 

2581 # Add file extension. 

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

2583 

2584 # Do the actual plotting on a scatter plot 

2585 _plot_and_save_scatter_plot( 

2586 base, other, plot_filename, title, one_to_one, model_names 

2587 ) 

2588 

2589 # Add list of plots to plot metadata. 

2590 plot_index = _append_to_plot_index([plot_filename]) 

2591 

2592 # Make a page to display the plots. 

2593 _make_plot_html_page(plot_index) 

2594 

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

2596 

2597 

2598def scatter_plot( 

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

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

2601 filename: str = None, 

2602 one_to_one: bool = True, 

2603 **kwargs, 

2604) -> iris.cube.CubeList: 

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

2606 

2607 Both cubes must be 1D. 

2608 

2609 Parameters 

2610 ---------- 

2611 cube_x: Cube | CubeList 

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

2613 cube_y: Cube | CubeList 

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

2615 filename: str, optional 

2616 Filename of the plot to write. 

2617 one_to_one: bool, optional 

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

2619 

2620 Returns 

2621 ------- 

2622 cubes: CubeList 

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

2624 

2625 Raises 

2626 ------ 

2627 ValueError 

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

2629 size. 

2630 TypeError 

2631 If the cube isn't a single cube. 

2632 

2633 Notes 

2634 ----- 

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

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

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

2638 """ 

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

2640 for cube_iter in iter_maybe(cube_x): 

2641 # Check cubes are correct shape. 

2642 cube_iter = _check_single_cube(cube_iter) 

2643 if cube_iter.ndim > 1: 

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

2645 

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

2647 for cube_iter in iter_maybe(cube_y): 

2648 # Check cubes are correct shape. 

2649 cube_iter = _check_single_cube(cube_iter) 

2650 if cube_iter.ndim > 1: 

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

2652 

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

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

2655 

2656 if filename is None: 

2657 filename = slugify(title) 

2658 

2659 # Add file extension. 

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

2661 

2662 # Do the actual plotting. 

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

2664 

2665 # Add list of plots to plot metadata. 

2666 plot_index = _append_to_plot_index([plot_filename]) 

2667 

2668 # Make a page to display the plots. 

2669 _make_plot_html_page(plot_index) 

2670 

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

2672 

2673 

2674def vector_plot( 

2675 cube_u: iris.cube.Cube, 

2676 cube_v: iris.cube.Cube, 

2677 filename: str = None, 

2678 sequence_coordinate: str = "time", 

2679 **kwargs, 

2680) -> iris.cube.CubeList: 

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

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

2683 

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

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

2686 filename = slugify(recipe_title) 

2687 

2688 # Cubes must have a matching sequence coordinate. 

2689 try: 

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

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

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

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

2694 raise ValueError( 

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

2696 ) from err 

2697 

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

2699 plot_index = [] 

2700 for cube_u_slice, cube_v_slice in zip( 

2701 cube_u.slices_over(sequence_coordinate), 

2702 cube_v.slices_over(sequence_coordinate), 

2703 strict=True, 

2704 ): 

2705 # Use sequence value so multiple sequences can merge. 

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

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

2708 coord = cube_u_slice.coord(sequence_coordinate) 

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

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

2711 # Do the actual plotting. 

2712 _plot_and_save_vector_plot( 

2713 cube_u_slice, 

2714 cube_v_slice, 

2715 filename=plot_filename, 

2716 title=title, 

2717 method="contourf", 

2718 ) 

2719 plot_index.append(plot_filename) 

2720 

2721 # Add list of plots to plot metadata. 

2722 complete_plot_index = _append_to_plot_index(plot_index) 

2723 

2724 # Make a page to display the plots. 

2725 _make_plot_html_page(complete_plot_index) 

2726 

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

2728 

2729 

2730def plot_histogram_series( 

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

2732 filename: str = None, 

2733 sequence_coordinate: str = "time", 

2734 stamp_coordinate: str = "realization", 

2735 single_plot: bool = False, 

2736 **kwargs, 

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

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

2739 

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

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

2742 functionality to scroll through histograms against time. If a 

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

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

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

2746 

2747 Parameters 

2748 ---------- 

2749 cubes: Cube | iris.cube.CubeList 

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

2751 than the stamp coordinate. 

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

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

2754 filename: str, optional 

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

2756 to the recipe name. 

2757 sequence_coordinate: str, optional 

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

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

2760 slider. 

2761 stamp_coordinate: str, optional 

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

2763 ``"realization"``. 

2764 single_plot: bool, optional 

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

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

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

2768 

2769 Returns 

2770 ------- 

2771 iris.cube.Cube | iris.cube.CubeList 

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

2773 Plotted data. 

2774 

2775 Raises 

2776 ------ 

2777 ValueError 

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

2779 TypeError 

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

2781 """ 

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

2783 

2784 cubes = iter_maybe(cubes) 

2785 

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

2787 if filename is None: 

2788 filename = slugify(recipe_title) 

2789 

2790 # Internal plotting function. 

2791 plotting_func = _plot_and_save_histogram_series 

2792 

2793 num_models = _get_num_models(cubes) 

2794 

2795 _validate_cube_shape(cubes, num_models) 

2796 

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

2798 # time slider option. 

2799 for cube in cubes: 

2800 try: 

2801 cube.coord(sequence_coordinate) 

2802 except iris.exceptions.CoordinateNotFoundError as err: 

2803 raise ValueError( 

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

2805 ) from err 

2806 

2807 # Get minimum and maximum from levels information. 

2808 levels = None 

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

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

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

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

2813 if levels is None: 

2814 break 

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

2816 # levels-based ranges for histogram plots. 

2817 _, levels, _ = _colorbar_map_levels(cube) 

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

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

2820 vmin = min(levels) 

2821 vmax = max(levels) 

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

2823 break 

2824 

2825 if levels is None: 

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

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

2828 

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

2830 # single point. If single_plot is True: 

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

2832 # separate postage stamp plots. 

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

2834 # produced per single model only 

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

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

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

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

2839 ): 

2840 if single_plot: 

2841 plotting_func = ( 

2842 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

2843 ) 

2844 else: 

2845 plotting_func = _plot_and_save_postage_stamp_histogram_series 

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

2847 else: 

2848 all_points = sorted( 

2849 set( 

2850 itertools.chain.from_iterable( 

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

2852 ) 

2853 ) 

2854 ) 

2855 all_slices = list( 

2856 itertools.chain.from_iterable( 

2857 cb.slices_over(sequence_coordinate) for cb in cubes 

2858 ) 

2859 ) 

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

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

2862 # necessary) 

2863 cube_iterables = [ 

2864 iris.cube.CubeList( 

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

2866 ) 

2867 for point in all_points 

2868 ] 

2869 

2870 plot_index = [] 

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

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

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

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

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

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

2877 for cube_slice in cube_iterables: 

2878 single_cube = cube_slice 

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

2880 single_cube = cube_slice[0] 

2881 

2882 # Use sequence value so multiple sequences can merge. 

2883 sequence_value = single_cube.coord(sequence_coordinate).points[0] 

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

2885 coord = single_cube.coord(sequence_coordinate) 

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

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

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

2889 if nplot == 1 and coord.has_bounds: 2889 ↛ 2890line 2889 didn't jump to line 2890 because the condition on line 2889 was never true

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

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

2892 # Do the actual plotting. 

2893 plotting_func( 

2894 cube_slice, 

2895 filename=plot_filename, 

2896 stamp_coordinate=stamp_coordinate, 

2897 title=title, 

2898 vmin=vmin, 

2899 vmax=vmax, 

2900 ) 

2901 plot_index.append(plot_filename) 

2902 

2903 # Add list of plots to plot metadata. 

2904 complete_plot_index = _append_to_plot_index(plot_index) 

2905 

2906 # Make a page to display the plots. 

2907 _make_plot_html_page(complete_plot_index) 

2908 

2909 return cubes 

2910 

2911 

2912def plot_power_spectrum_series( 

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

2914 filename: str = None, 

2915 sequence_coordinate: str = "time", 

2916 stamp_coordinate: str = "realization", 

2917 single_plot: bool = False, 

2918 **kwargs, 

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

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

2921 

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

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

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

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

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

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

2928 

2929 Parameters 

2930 ---------- 

2931 cubes: Cube | iris.cube.CubeList 

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

2933 than the stamp coordinate. 

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

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

2936 filename: str, optional 

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

2938 to the recipe name. 

2939 sequence_coordinate: str, optional 

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

2941 This coordinate must exist in the cube and will be used for the time 

2942 slider. 

2943 stamp_coordinate: str, optional 

2944 Coordinate about which to plot postage stamp plots. Defaults to 

2945 ``"realization"``. 

2946 single_plot: bool, optional 

2947 If True, all postage stamp plots will be plotted in a single plot. If 

2948 False, each postage stamp plot will be plotted separately. Is only valid 

2949 if stamp_coordinate exists and has more than a single point. 

2950 

2951 Returns 

2952 ------- 

2953 iris.cube.Cube | iris.cube.CubeList 

2954 The original Cube or CubeList (so further operations can be applied). 

2955 Plotted data. 

2956 

2957 Raises 

2958 ------ 

2959 ValueError 

2960 If the cube doesn't have the right dimensions. 

2961 TypeError 

2962 If the cube isn't a Cube or CubeList. 

2963 """ 

2964 recipe_title = get_recipe_metadata().get("title", "Untitled") 

2965 

2966 cubes = iter_maybe(cubes) 

2967 # Ensure we have a name for the plot file. 

2968 if filename is None: 

2969 filename = slugify(recipe_title) 

2970 

2971 # Internal plotting function. 

2972 plotting_func = _plot_and_save_power_spectrum_series 

2973 

2974 num_models = _get_num_models(cubes) 

2975 

2976 _validate_cube_shape(cubes, num_models) 

2977 

2978 # If several power spectra are plotted with time as sequence_coordinate for the 

2979 # time slider option. 

2980 for cube in cubes: 

2981 try: 

2982 cube.coord(sequence_coordinate) 

2983 except iris.exceptions.CoordinateNotFoundError as err: 

2984 raise ValueError( 

2985 f"Cube must have a {sequence_coordinate} coordinate." 

2986 ) from err 

2987 

2988 # Make postage stamp plots if stamp_coordinate exists and has more than a 

2989 # single point. If single_plot is True: 

2990 # -- all postage stamp plots will be plotted in a single plot instead of 

2991 # separate postage stamp plots. 

2992 # -- model names (hidden in cube attrs) are ignored, that is stamp plots are 

2993 # produced per single model only 

2994 if num_models == 1: 2994 ↛ 3007line 2994 didn't jump to line 3007 because the condition on line 2994 was always true

2995 if ( 2995 ↛ 2999line 2995 didn't jump to line 2999 because the condition on line 2995 was never true

2996 stamp_coordinate in [c.name() for c in cubes[0].coords()] 

2997 and cubes[0].coord(stamp_coordinate).shape[0] > 1 

2998 ): 

2999 if single_plot: 

3000 plotting_func = ( 

3001 _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series 

3002 ) 

3003 else: 

3004 plotting_func = _plot_and_save_postage_stamp_power_spectrum_series 

3005 cube_iterables = cubes[0].slices_over(sequence_coordinate) 

3006 else: 

3007 all_points = sorted( 

3008 set( 

3009 itertools.chain.from_iterable( 

3010 cb.coord(sequence_coordinate).points for cb in cubes 

3011 ) 

3012 ) 

3013 ) 

3014 all_slices = list( 

3015 itertools.chain.from_iterable( 

3016 cb.slices_over(sequence_coordinate) for cb in cubes 

3017 ) 

3018 ) 

3019 # Matched slices (matched by seq coord point; it may happen that 

3020 # evaluated models do not cover the same seq coord range, hence matching 

3021 # necessary) 

3022 cube_iterables = [ 

3023 iris.cube.CubeList( 

3024 s for s in all_slices if s.coord(sequence_coordinate).points[0] == point 

3025 ) 

3026 for point in all_points 

3027 ] 

3028 

3029 plot_index = [] 

3030 nplot = np.size(cube.coord(sequence_coordinate).points) 

3031 # Create a plot for each value of the sequence coordinate. Allowing for 

3032 # multiple cubes in a CubeList to be plotted in the same plot for similar 

3033 # sequence values. Passing a CubeList into the internal plotting function 

3034 # for similar values of the sequence coordinate. cube_slice can be an 

3035 # iris.cube.Cube or an iris.cube.CubeList. 

3036 for cube_slice in cube_iterables: 

3037 single_cube = cube_slice 

3038 if isinstance(cube_slice, iris.cube.CubeList): 3038 ↛ 3039line 3038 didn't jump to line 3039 because the condition on line 3038 was never true

3039 single_cube = cube_slice[0] 

3040 

3041 # Use sequence value so multiple sequences can merge. 

3042 sequence_value = single_cube.coord(sequence_coordinate).points[0] 

3043 plot_filename = f"{filename.rsplit('.', 1)[0]}_{sequence_value}.png" 

3044 coord = single_cube.coord(sequence_coordinate) 

3045 # Format the coordinate value in a unit appropriate way. 

3046 title = f"{recipe_title}\n [{coord.units.title(coord.points[0])}]" 

3047 # Use sequence (e.g. time) bounds if plotting single non-sequence outputs 

3048 if nplot == 1 and coord.has_bounds: 3048 ↛ 3052line 3048 didn't jump to line 3052 because the condition on line 3048 was always true

3049 if np.size(coord.bounds) > 1: 

3050 title = f"{recipe_title}\n [{coord.units.title(coord.bounds[0][0])} to {coord.units.title(coord.bounds[0][1])}]" 

3051 # Do the actual plotting. 

3052 plotting_func( 

3053 cube_slice, 

3054 filename=plot_filename, 

3055 stamp_coordinate=stamp_coordinate, 

3056 title=title, 

3057 ) 

3058 plot_index.append(plot_filename) 

3059 

3060 # Add list of plots to plot metadata. 

3061 complete_plot_index = _append_to_plot_index(plot_index) 

3062 

3063 # Make a page to display the plots. 

3064 _make_plot_html_page(complete_plot_index) 

3065 

3066 return cubes 

3067 

3068 

3069def _DCT_ps(y_3d): 

3070 """Calculate power spectra for regional domains. 

3071 

3072 Parameters 

3073 ---------- 

3074 y_3d: 3D array 

3075 3 dimensional array to calculate spectrum for. 

3076 (2D field data with 3rd dimension of time) 

3077 

3078 Returns: ps_array 

3079 Array of power spectra values calculated for input field (for each time) 

3080 

3081 Method for regional domains: 

3082 Calculate power spectra over limited area domain using Discrete Cosine Transform (DCT) 

3083 as described in Denis et al 2002 [Denis_etal_2002]_. 

3084 

3085 References 

3086 ---------- 

3087 .. [Denis_etal_2002] Bertrand Denis, Jean Côté and René Laprise (2002) 

3088 "Spectral Decomposition of Two-Dimensional Atmospheric Fields on 

3089 Limited-Area Domains Using the Discrete Cosine Transform (DCT)" 

3090 Monthly Weather Review, Vol. 130, 1812-1828 

3091 doi: https://doi.org/10.1175/1520-0493(2002)130<1812:SDOTDA>2.0.CO;2 

3092 """ 

3093 Nt, Ny, Nx = y_3d.shape 

3094 

3095 # Max coefficient 

3096 Nmin = min(Nx - 1, Ny - 1) 

3097 

3098 # Create alpha matrix (of wavenumbers) 

3099 alpha_matrix = _create_alpha_matrix(Ny, Nx) 

3100 

3101 # Prepare output array 

3102 ps_array = np.zeros((Nt, Nmin)) 

3103 

3104 # Loop over time to get spectrum for each time. 

3105 for t in range(Nt): 

3106 y_2d = y_3d[t] 

3107 

3108 # Apply 2D DCT to transform y_3d[t] from physical space to spectral space. 

3109 # fkk is a 2D array of DCT coefficients, representing the amplitudes of 

3110 # cosine basis functions at different spatial frequencies. 

3111 

3112 # normalise spectrum to allow comparison between models. 

3113 fkk = fft.dctn(y_2d, norm="ortho") 

3114 

3115 # Normalise fkk 

3116 fkk = fkk / np.sqrt(Ny * Nx) 

3117 

3118 # calculate variance of spectral coefficient 

3119 sigma_2 = fkk**2 / Nx / Ny 

3120 

3121 # Group ellipses of alphas into the same wavenumber k/Nmin 

3122 for k in range(1, Nmin + 1): 

3123 alpha = k / Nmin 

3124 alpha_p1 = (k + 1) / Nmin 

3125 

3126 # Sum up elements matching k 

3127 mask_k = np.where((alpha_matrix >= alpha) & (alpha_matrix < alpha_p1)) 

3128 ps_array[t, k - 1] = np.sum(sigma_2[mask_k]) 

3129 

3130 return ps_array 

3131 

3132 

3133def _create_alpha_matrix(Ny, Nx): 

3134 """Construct an array of 2D wavenumbers from 2D wavenumber pair. 

3135 

3136 Parameters 

3137 ---------- 

3138 Ny, Nx: 

3139 Dimensions of the 2D field for which the power spectra is calculated. Used to 

3140 create the array of 2D wavenumbers. Each Ny, Nx pair is associated with a 

3141 single-scale parameter. 

3142 

3143 Returns: alpha_matrix 

3144 normalisation of 2D wavenumber axes, transforming the spectral domain into 

3145 an elliptic coordinate system. 

3146 

3147 """ 

3148 # Create x_indices: each row is [1, 2, ..., Nx] 

3149 x_indices = np.tile(np.arange(1, Nx + 1), (Ny, 1)) 

3150 

3151 # Create y_indices: each column is [1, 2, ..., Ny] 

3152 y_indices = np.tile(np.arange(1, Ny + 1).reshape(Ny, 1), (1, Nx)) 

3153 

3154 # Compute alpha_matrix 

3155 alpha_matrix = np.sqrt((x_indices**2) / Nx**2 + (y_indices**2) / Ny**2) 

3156 

3157 return alpha_matrix