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

1107 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-14 13:50 +0000

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

2# 

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

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

5# You may obtain a copy of the License at 

6# 

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

8# 

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

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

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

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

13# limitations under the License. 

14 

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

16 

17import fcntl 

18import functools 

19import importlib.resources 

20import itertools 

21import json 

22import logging 

23import math 

24import os 

25from typing import Literal 

26 

27import cartopy.crs as ccrs 

28import cartopy.feature as cfeature 

29import iris 

30import iris.coords 

31import iris.cube 

32import iris.exceptions 

33import iris.plot as iplt 

34import matplotlib as mpl 

35import matplotlib.colors as mcolors 

36import matplotlib.pyplot as plt 

37import numpy as np 

38import scipy.fft as fft 

39from cartopy.mpl.geoaxes import GeoAxes 

40from iris.cube import Cube 

41from markdown_it import MarkdownIt 

42from mpl_toolkits.axes_grid1.inset_locator import inset_axes 

43 

44from CSET._common import ( 

45 combine_dicts, 

46 filename_slugify, 

47 get_recipe_metadata, 

48 iter_maybe, 

49 render_file, 

50 slugify, 

51) 

52from CSET.operators._utils import ( 

53 check_stamp_coordinate, 

54 fully_equalise_attributes, 

55 get_cube_yxcoordname, 

56 is_transect, 

57 slice_over_maybe, 

58) 

59from CSET.operators.collapse import collapse 

60from CSET.operators.misc import _extract_common_time_points 

61from CSET.operators.regrid import regrid_onto_cube 

62 

63# Use a non-interactive plotting backend. 

64mpl.use("agg") 

65 

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

67 

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

69# Private helper functions # 

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

71 

72 

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

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

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

76 fcntl.flock(fp, fcntl.LOCK_EX) 

77 fp.seek(0) 

78 meta = json.load(fp) 

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

80 complete_plot_index = complete_plot_index + plot_index 

81 meta["plots"] = complete_plot_index 

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

83 os.getenv("DO_CASE_AGGREGATION") 

84 ): 

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

86 fp.seek(0) 

87 fp.truncate() 

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

89 return complete_plot_index 

90 

91 

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

93 """Ensure a single cube is given. 

94 

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

96 otherwise an error is raised. 

97 

98 Parameters 

99 ---------- 

100 cube: Cube | CubeList 

101 The cube to check. 

102 

103 Returns 

104 ------- 

105 cube: Cube 

106 The checked cube. 

107 

108 Raises 

109 ------ 

110 TypeError 

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

112 """ 

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

114 return cube 

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

116 if len(cube) == 1: 

117 return cube[0] 

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

119 

120 

121def _make_plot_html_page(plots: list): 

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

123 # Debug check that plots actually contains some strings. 

124 assert isinstance(plots[0], str) 

125 

126 # Load HTML template file. 

127 operator_files = importlib.resources.files() 

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

129 

130 # Get some metadata. 

131 meta = get_recipe_metadata() 

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

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

134 

135 # Prepare template variables. 

136 variables = { 

137 "title": title, 

138 "description": description, 

139 "initial_plot": plots[0], 

140 "plots": plots, 

141 "title_slug": slugify(title), 

142 } 

143 

144 # Render template. 

145 html = render_file(template_file, **variables) 

146 

147 # Save completed HTML. 

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

149 fp.write(html) 

150 

151 

152@functools.cache 

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

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

155 

156 This is a separate function to make it cacheable. 

157 """ 

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

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

160 colorbar = json.load(fp) 

161 

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

163 override_colorbar = {} 

164 if user_colorbar_file: 

165 try: 

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

167 override_colorbar = json.load(fp) 

168 except FileNotFoundError: 

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

170 

171 # Overwrite values with the user supplied colorbar definition. 

172 colorbar = combine_dicts(colorbar, override_colorbar) 

173 return colorbar 

174 

175 

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

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

178 

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

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

181 to model_name attribute. 

182 

183 Parameters 

184 ---------- 

185 cubes: CubeList or Cube 

186 Cubes with model_name attribute 

187 

188 Returns 

189 ------- 

190 model_colors_map: 

191 Dictionary mapping model_name attribute to colors 

192 """ 

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

194 colorbar = _load_colorbar_map(user_colorbar_file) 

195 model_names = sorted( 

196 filter( 

197 lambda x: x is not None, 

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

199 ) 

200 ) 

201 if not model_names: 

202 return {} 

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

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

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

206 

207 color_list = itertools.cycle(DEFAULT_DISCRETE_COLORS) 

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

209 

210 

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

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

213 

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

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

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

217 exist for specific pressure levels to account for variables with 

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

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

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

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

222 

223 Parameters 

224 ---------- 

225 cube: Cube 

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

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

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

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

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

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

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

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

234 

235 Returns 

236 ------- 

237 cmap: 

238 Matplotlib colormap. 

239 levels: 

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

241 should be taken as the range. 

242 norm: 

243 BoundaryNorm information. 

244 """ 

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

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

247 colorbar = _load_colorbar_map(user_colorbar_file) 

248 cmap = None 

249 

250 try: 

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

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

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

254 pressure_level = str(int(pressure_level_raw)) 

255 except iris.exceptions.CoordinateNotFoundError: 

256 pressure_level = None 

257 

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

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

260 # consistent. 

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

262 for varname in varnames: 

263 # Get the colormap for this variable. 

264 try: 

265 var_colorbar = colorbar[varname] 

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

267 varname_key = varname 

268 break 

269 except KeyError: 

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

271 

272 # Get colormap if it is a mask. 

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

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

275 return cmap, levels, norm 

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

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

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

279 return cmap, levels, norm 

280 # If probability is plotted use custom colorbar and levels 

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

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

283 return cmap, levels, norm 

284 # If aviation colour state use custom colorbar and levels 

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

286 cmap, levels, norm = _custom_colormap_aviation_colour_state(cube) 

287 return cmap, levels, norm 

288 

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

290 if not cmap: 

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

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

293 return cmap, levels, norm 

294 

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

296 if pressure_level: 

297 try: 

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

299 except KeyError: 

300 logging.debug( 

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

302 varname, 

303 pressure_level, 

304 ) 

305 

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

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

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

309 if axis: 

310 if axis == "x": 

311 try: 

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

313 except KeyError: 

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

315 if axis == "y": 

316 try: 

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

318 except KeyError: 

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

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

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

322 levels = None 

323 else: 

324 levels = [vmin, vmax] 

325 return None, levels, None 

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

327 else: 

328 try: 

329 levels = var_colorbar["levels"] 

330 # Use discrete bins when levels are specified, rather 

331 # than a smooth range. 

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

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

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

335 except KeyError: 

336 # Get the range for this variable. 

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

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

339 # Calculate levels from range. 

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

341 levels = None 

342 else: 

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

344 norm = None 

345 

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

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

348 # JSON file. 

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

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

351 cmap, levels, norm = _custom_colourmap_visibility_in_air( 

352 cube, cmap, levels, norm 

353 ) 

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

355 return cmap, levels, norm 

356 

357 

358def _setup_spatial_map( 

359 cube: iris.cube.Cube, 

360 figure, 

361 cmap, 

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

363 subplot: int | None = None, 

364): 

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

366 

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

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

369 

370 Parameters 

371 ---------- 

372 cube: Cube 

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

374 figure: 

375 Matplotlib Figure object holding all plot elements. 

376 cmap: 

377 Matplotlib colormap. 

378 grid_size: (int, int), optional 

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

380 subplot: int, optional 

381 Subplot index if multiple spatial subplots in figure. 

382 

383 Returns 

384 ------- 

385 axes: 

386 Matplotlib GeoAxes definition. 

387 """ 

388 # Identify min/max plot bounds. 

389 try: 

390 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

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

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

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

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

395 

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

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

398 x1 = x1 - 180.0 

399 x2 = x2 - 180.0 

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

401 

402 # Consider map projection orientation. 

403 # Adapting orientation enables plotting across international dateline. 

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

405 if x2 > 180.0 or x1 < -180.0: 

406 central_longitude = 180.0 

407 else: 

408 central_longitude = 0.0 

409 

410 # Define spatial map projection. 

411 coord_system = cube.coord(lat_axis).coord_system 

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

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

414 projection = ccrs.RotatedPole( 

415 pole_longitude=coord_system.grid_north_pole_longitude, 

416 pole_latitude=coord_system.grid_north_pole_latitude, 

417 central_rotated_longitude=central_longitude, 

418 ) 

419 crs = projection 

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

421 # Define Transverse Mercator projection for TM inputs. 

422 projection = ccrs.TransverseMercator( 

423 central_longitude=coord_system.longitude_of_central_meridian, 

424 central_latitude=coord_system.latitude_of_projection_origin, 

425 false_easting=coord_system.false_easting, 

426 false_northing=coord_system.false_northing, 

427 scale_factor=coord_system.scale_factor_at_central_meridian, 

428 ) 

429 crs = projection 

430 else: 

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

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

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

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

435 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

436 crs = ccrs.PlateCarree() 

437 

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

439 if subplot is not None: 

440 axes = figure.add_subplot( 

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

442 ) 

443 else: 

444 axes = figure.add_subplot(projection=projection) 

445 

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

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

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

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

450 ): 

451 pass 

452 else: 

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

454 coastcol = "magenta" 

455 else: 

456 coastcol = "black" 

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

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

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

460 

461 # Add gridlines. 

462 gl = axes.gridlines( 

463 alpha=0.3, 

464 draw_labels=True, 

465 dms=False, 

466 x_inline=False, 

467 y_inline=False, 

468 ) 

469 gl.top_labels = False 

470 gl.right_labels = False 

471 if subplot: 

472 gl.bottom_labels = False 

473 gl.left_labels = False 

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

475 gl.left_labels = True 

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

477 gl.bottom_labels = True 

478 

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

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

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

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

483 

484 except ValueError: 

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

486 axes = figure.gca() 

487 pass 

488 

489 return axes 

490 

491 

492def _get_plot_resolution() -> int: 

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

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

495 

496 

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

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

499 if use_bounds and seq_coord.has_bounds(): 

500 vals = seq_coord.bounds.flatten() 

501 else: 

502 vals = seq_coord.points 

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

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

505 

506 if start == end: 

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

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

509 else: 

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

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

512 

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

514 if ( 

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

516 and vals[0] == 0 

517 and vals[-1] == 0 

518 ): 

519 sequence_title = "" 

520 sequence_fname = "" 

521 

522 return sequence_title, sequence_fname 

523 

524 

525def _set_title_and_filename( 

526 seq_coord: iris.coords.Coord, 

527 nplot: int, 

528 recipe_title: str, 

529 filename: str, 

530): 

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

532 

533 Parameters 

534 ---------- 

535 sequence_coordinate: iris.coords.Coord 

536 Coordinate about which to make a plot sequence. 

537 nplot: int 

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

539 recipe_title: str 

540 Default plot title, potentially to update. 

541 filename: str 

542 Input plot filename, potentially to update. 

543 

544 Returns 

545 ------- 

546 plot_title: str 

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

548 plot_filename: str 

549 Output formatted plot filename string. 

550 """ 

551 ndim = seq_coord.ndim 

552 npoints = np.size(seq_coord.points) 

553 sequence_title = "" 

554 sequence_fname = "" 

555 

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

557 # (e.g. aggregation histogram plots) 

558 if ndim > 1: 

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

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

561 sequence_fname = f"_{ncase}cases" 

562 

563 # Case 2: Single dimension input 

564 else: 

565 # Single sequence point 

566 if npoints == 1: 

567 if nplot > 1: 

568 # Default labels for sequence inputs 

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

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

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

572 else: 

573 # Aggregated attribute available where input collapsed over aggregation 

574 try: 

575 ncase = seq_coord.attributes["number_reference_times"] 

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

577 sequence_fname = f"_{ncase}cases" 

578 except KeyError: 

579 sequence_title, sequence_fname = _get_start_end_strings( 

580 seq_coord, use_bounds=seq_coord.has_bounds() 

581 ) 

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

583 else: 

584 sequence_title, sequence_fname = _get_start_end_strings( 

585 seq_coord, use_bounds=False 

586 ) 

587 

588 # Set plot title and filename 

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

590 

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

592 if filename is None: 

593 filename = slugify(recipe_title) 

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

595 else: 

596 if nplot > 1: 

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

598 else: 

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

600 

601 return plot_title, plot_filename 

602 

603 

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

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

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

607 mtitle = "Member" 

608 else: 

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

610 

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

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

613 else: 

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

615 

616 return mtitle 

617 

618 

619def _plot_and_save_spatial_plot( 

620 cube: iris.cube.Cube, 

621 filename: str, 

622 title: str, 

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

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

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

626 **kwargs, 

627): 

628 """Plot and save a spatial plot. 

629 

630 Parameters 

631 ---------- 

632 cube: Cube 

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

634 filename: str 

635 Filename of the plot to write. 

636 title: str 

637 Plot title. 

638 method: "contourf" | "pcolormesh" 

639 The plotting method to use. 

640 overlay_cube: Cube, optional 

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

642 contour_cube: Cube, optional 

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

644 """ 

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

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

647 

648 # Specify the color bar 

649 cmap, levels, norm = _colorbar_map_levels(cube) 

650 

651 # If overplotting, set required colorbars 

652 if overlay_cube: 

653 over_cmap, over_levels, over_norm = _colorbar_map_levels(overlay_cube) 

654 if contour_cube: 

655 cntr_cmap, cntr_levels, cntr_norm = _colorbar_map_levels(contour_cube) 

656 

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

658 axes = _setup_spatial_map(cube, fig, cmap) 

659 

660 # Plot the field. 

661 if method == "contourf": 

662 # Filled contour plot of the field. 

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

664 elif method == "pcolormesh": 

665 try: 

666 vmin = min(levels) 

667 vmax = max(levels) 

668 except TypeError: 

669 vmin, vmax = None, None 

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

671 # if levels are defined. 

672 if norm is not None: 

673 vmin = None 

674 vmax = None 

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

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

677 else: 

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

679 

680 # Overplot overlay field, if required 

681 if overlay_cube: 

682 try: 

683 over_vmin = min(over_levels) 

684 over_vmax = max(over_levels) 

685 except TypeError: 

686 over_vmin, over_vmax = None, None 

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

688 over_vmin = None 

689 over_vmax = None 

690 overlay = iplt.pcolormesh( 

691 overlay_cube, 

692 cmap=over_cmap, 

693 norm=over_norm, 

694 alpha=0.8, 

695 vmin=over_vmin, 

696 vmax=over_vmax, 

697 ) 

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

699 if contour_cube: 

700 contour = iplt.contour( 

701 contour_cube, 

702 colors="darkgray", 

703 levels=cntr_levels, 

704 norm=cntr_norm, 

705 alpha=0.5, 

706 linestyles="--", 

707 linewidths=1, 

708 ) 

709 plt.clabel(contour) 

710 

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

712 if is_transect(cube): 

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

714 axes.invert_yaxis() 

715 axes.set_yscale("log") 

716 axes.set_ylim(1100, 100) 

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

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

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

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

721 ): 

722 axes.set_yscale("log") 

723 

724 axes.set_title( 

725 f"{title}\n" 

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

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

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

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

730 fontsize=16, 

731 ) 

732 

733 # Inset code 

734 axins = inset_axes( 

735 axes, 

736 width="20%", 

737 height="20%", 

738 loc="upper right", 

739 axes_class=GeoAxes, 

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

741 ) 

742 

743 # Slightly transparent to reduce plot blocking. 

744 axins.patch.set_alpha(0.4) 

745 

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

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

748 

749 SLat, SLon, ELat, ELon = ( 

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

751 ) 

752 

753 # Draw line between them 

754 axins.plot( 

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

756 ) 

757 

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

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

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

761 

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

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

764 

765 # Midpoints 

766 lon_mid = (lon_min + lon_max) / 2 

767 lat_mid = (lat_min + lat_max) / 2 

768 

769 # Maximum half-range 

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

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

772 half_range = 1 

773 

774 # Set square extent 

775 axins.set_extent( 

776 [ 

777 lon_mid - half_range, 

778 lon_mid + half_range, 

779 lat_mid - half_range, 

780 lat_mid + half_range, 

781 ], 

782 crs=ccrs.PlateCarree(), 

783 ) 

784 

785 # Ensure square aspect 

786 axins.set_aspect("equal") 

787 

788 else: 

789 # Add title. 

790 axes.set_title(title, fontsize=16) 

791 

792 # Adjust padding if spatial plot or transect 

793 if is_transect(cube): 

794 yinfopad = -0.1 

795 ycbarpad = 0.1 

796 else: 

797 yinfopad = 0.01 

798 ycbarpad = 0.042 

799 

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

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

802 axes.annotate( 

803 f"Min: {np.nanmin(cube.data):.3g} Max: {np.nanmax(cube.data):.3g} Mean: {np.nanmean(cube.data):.3g}", 

804 xy=(0.025, yinfopad), 

805 xycoords="axes fraction", 

806 xytext=(-5, 5), 

807 textcoords="offset points", 

808 ha="left", 

809 va="bottom", 

810 size=11, 

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

812 ) 

813 

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

815 if overlay_cube: 

816 cbarB = fig.colorbar( 

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

818 ) 

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

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

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

822 cbarB.set_ticks(over_levels) 

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

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

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

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

827 

828 # Add main colour bar. 

829 cbar = fig.colorbar( 

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

831 ) 

832 

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

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

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

836 cbar.set_ticks(levels) 

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

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

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

840 # Tick labels for rainfall rates from Nimrod radar data. 

841 if "rainfall rate composite" in cube.name(): 841 ↛ 842line 841 didn't jump to line 842 because the condition on line 841 was never true

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

843 # Tick labels for rain accumulations from Nimrod radar data. 

844 if "rain accumulation" in cube.name(): 844 ↛ 845line 844 didn't jump to line 845 because the condition on line 844 was never true

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

846 if "wts accumulation" in cube.name(): 846 ↛ 847line 846 didn't jump to line 847 because the condition on line 846 was never true

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

848 cbar.minorticks_off() 

849 cbar.set_ticks(tick_levels) 

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

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

852 # Tick labels for model rainfall data. 

853 if "surface_microphysical" in cube.name(): 853 ↛ 856line 853 didn't jump to line 856 because the condition on line 853 was always true

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

855 # Tick labels for Nimrod weights data. 

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

857 

858 # Save plot. 

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

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

861 plt.close(fig) 

862 

863 

864def _plot_and_save_postage_stamp_spatial_plot( 

865 cube: iris.cube.Cube, 

866 filename: str, 

867 stamp_coordinate: str, 

868 title: str, 

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

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

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

872 **kwargs, 

873): 

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

875 

876 Parameters 

877 ---------- 

878 cube: Cube 

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

880 filename: str 

881 Filename of the plot to write. 

882 stamp_coordinate: str 

883 Coordinate that becomes different plots. 

884 method: "contourf" | "pcolormesh" 

885 The plotting method to use. 

886 overlay_cube: Cube, optional 

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

888 contour_cube: Cube, optional 

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

890 

891 Raises 

892 ------ 

893 ValueError 

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

895 """ 

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

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

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

899 grid_size = math.ceil(nmember / grid_rows) 

900 

901 fig = plt.figure( 

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

903 ) 

904 

905 # Specify the color bar 

906 cmap, levels, norm = _colorbar_map_levels(cube) 

907 # If overplotting, set required colorbars 

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

909 over_cmap, over_levels, over_norm = _colorbar_map_levels(overlay_cube) 

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

911 cntr_cmap, cntr_levels, cntr_norm = _colorbar_map_levels(contour_cube) 

912 

913 # Make a subplot for each member. 

914 for member, subplot in zip( 

915 cube.slices_over(stamp_coordinate), 

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

917 strict=False, 

918 ): 

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

920 axes = _setup_spatial_map( 

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

922 ) 

923 if method == "contourf": 

924 # Filled contour plot of the field. 

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

926 elif method == "pcolormesh": 

927 if levels is not None: 

928 vmin = min(levels) 

929 vmax = max(levels) 

930 else: 

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

932 vmin, vmax = None, None 

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

934 # if levels are defined. 

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

936 vmin = None 

937 vmax = None 

938 # pcolormesh plot of the field. 

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

940 else: 

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

942 

943 # Overplot overlay field, if required 

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

945 try: 

946 over_vmin = min(over_levels) 

947 over_vmax = max(over_levels) 

948 except TypeError: 

949 over_vmin, over_vmax = None, None 

950 if over_norm is not None: 

951 over_vmin = None 

952 over_vmax = None 

953 iplt.pcolormesh( 

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

955 cmap=over_cmap, 

956 norm=over_norm, 

957 alpha=0.6, 

958 vmin=over_vmin, 

959 vmax=over_vmax, 

960 ) 

961 # Overplot contour field, if required 

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

963 iplt.contour( 

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

965 colors="darkgray", 

966 levels=cntr_levels, 

967 norm=cntr_norm, 

968 alpha=0.6, 

969 linestyles="--", 

970 linewidths=1, 

971 ) 

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

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

974 

975 # Put the shared colorbar in its own axes. 

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

977 colorbar = fig.colorbar( 

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

979 ) 

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

981 

982 # Overall figure title. 

983 fig.suptitle(title, fontsize=16) 

984 

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

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

987 plt.close(fig) 

988 

989 

990def _plot_and_save_line_series( 

991 cubes: iris.cube.CubeList, 

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

993 ensemble_coord: str, 

994 filename: str, 

995 title: str, 

996 **kwargs, 

997): 

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

999 

1000 Parameters 

1001 ---------- 

1002 cubes: Cube or CubeList 

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

1004 coords: list[Coord] 

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

1006 ensemble_coord: str 

1007 Ensemble coordinate in the cube. 

1008 filename: str 

1009 Filename of the plot to write. 

1010 title: str 

1011 Plot title. 

1012 """ 

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

1014 

1015 model_colors_map = _get_model_colors_map(cubes) 

1016 

1017 # Store min/max ranges. 

1018 y_levels = [] 

1019 

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

1021 _validate_cubes_coords(cubes, coords) 

1022 

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

1024 label = None 

1025 color = "black" 

1026 if model_colors_map: 

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

1028 color = model_colors_map.get(label) 

1029 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

1032 iplt.plot( 

1033 coord, 

1034 cube_slice, 

1035 color=color, 

1036 marker="o", 

1037 ls="-", 

1038 lw=3, 

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

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

1041 else label, 

1042 ) 

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

1044 else: 

1045 iplt.plot( 

1046 coord, 

1047 cube_slice, 

1048 color=color, 

1049 ls="-", 

1050 lw=1.5, 

1051 alpha=0.75, 

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

1053 ) 

1054 

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

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

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

1058 y_levels.append(min(levels)) 

1059 y_levels.append(max(levels)) 

1060 

1061 # Get the current axes. 

1062 ax = plt.gca() 

1063 

1064 # Add some labels and tweak the style. 

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

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

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

1068 else: 

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

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

1071 ax.set_title(title, fontsize=16) 

1072 

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

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

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

1076 

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

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

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

1080 # Add zero line. 

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

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

1083 logging.debug( 

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

1085 ) 

1086 else: 

1087 ax.autoscale() 

1088 

1089 # Add gridlines 

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

1091 # Ientify unique labels for legend 

1092 handles = list( 

1093 { 

1094 label: handle 

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

1096 }.values() 

1097 ) 

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

1099 

1100 # Save plot. 

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

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

1103 plt.close(fig) 

1104 

1105 

1106def _plot_and_save_vertical_line_series( 

1107 cubes: iris.cube.CubeList, 

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

1109 ensemble_coord: str, 

1110 filename: str, 

1111 series_coordinate: str, 

1112 title: str, 

1113 vmin: float, 

1114 vmax: float, 

1115 **kwargs, 

1116): 

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

1118 

1119 Parameters 

1120 ---------- 

1121 cubes: CubeList 

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

1123 coord: list[Coord] 

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

1125 ensemble_coord: str 

1126 Ensemble coordinate in the cube. 

1127 filename: str 

1128 Filename of the plot to write. 

1129 series_coordinate: str 

1130 Coordinate to use as vertical axis. 

1131 title: str 

1132 Plot title. 

1133 vmin: float 

1134 Minimum value for the x-axis. 

1135 vmax: float 

1136 Maximum value for the x-axis. 

1137 """ 

1138 # plot the vertical pressure axis using log scale 

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

1140 

1141 model_colors_map = _get_model_colors_map(cubes) 

1142 

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

1144 _validate_cubes_coords(cubes, coords) 

1145 

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

1147 label = None 

1148 color = "black" 

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

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

1151 color = model_colors_map.get(label) 

1152 

1153 for cube_slice in cube.slices_over(ensemble_coord): 

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

1155 # unless single forecast. 

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

1157 iplt.plot( 

1158 cube_slice, 

1159 coord, 

1160 color=color, 

1161 marker="o", 

1162 ls="-", 

1163 lw=3, 

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

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

1166 else label, 

1167 ) 

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

1169 else: 

1170 iplt.plot( 

1171 cube_slice, 

1172 coord, 

1173 color=color, 

1174 ls="-", 

1175 lw=1.5, 

1176 alpha=0.75, 

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

1178 ) 

1179 

1180 # Get the current axis 

1181 ax = plt.gca() 

1182 

1183 # Special handling for pressure level data. 

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

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

1186 ax.invert_yaxis() 

1187 ax.set_yscale("log") 

1188 

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

1190 y_tick_labels = [ 

1191 "1000", 

1192 "850", 

1193 "700", 

1194 "500", 

1195 "300", 

1196 "200", 

1197 "100", 

1198 ] 

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

1200 

1201 # Set y-axis limits and ticks. 

1202 ax.set_ylim(1100, 100) 

1203 

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

1205 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

1211 

1212 ax.set_yticks(y_ticks) 

1213 ax.set_yticklabels(y_tick_labels) 

1214 

1215 # Set x-axis limits. 

1216 ax.set_xlim(vmin, vmax) 

1217 # Mark y=0 if present in plot. 

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

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

1220 

1221 # Add some labels and tweak the style. 

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

1223 ax.set_xlabel( 

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

1225 ) 

1226 ax.set_title(title, fontsize=16) 

1227 ax.ticklabel_format(axis="x") 

1228 ax.tick_params(axis="y") 

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

1230 

1231 # Add gridlines 

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

1233 # Ientify unique labels for legend 

1234 handles = list( 

1235 { 

1236 label: handle 

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

1238 }.values() 

1239 ) 

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

1241 

1242 # Save plot. 

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

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

1245 plt.close(fig) 

1246 

1247 

1248def _plot_and_save_scatter_plot( 

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

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

1251 filename: str, 

1252 title: str, 

1253 one_to_one: bool, 

1254 model_names: list[str] = None, 

1255 **kwargs, 

1256): 

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

1258 

1259 Parameters 

1260 ---------- 

1261 cube_x: Cube | CubeList 

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

1263 cube_y: Cube | CubeList 

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

1265 filename: str 

1266 Filename of the plot to write. 

1267 title: str 

1268 Plot title. 

1269 one_to_one: bool 

1270 Whether a 1:1 line is plotted. 

1271 """ 

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

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

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

1275 # over the pairs simultaneously. 

1276 

1277 # Ensure cube_x and cube_y are iterable 

1278 cube_x_iterable = iter_maybe(cube_x) 

1279 cube_y_iterable = iter_maybe(cube_y) 

1280 

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

1282 iplt.scatter(cube_x_iter, cube_y_iter) 

1283 if one_to_one is True: 

1284 plt.plot( 

1285 [ 

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

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

1288 ], 

1289 [ 

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

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

1292 ], 

1293 "k", 

1294 linestyle="--", 

1295 ) 

1296 ax = plt.gca() 

1297 

1298 # Add some labels and tweak the style. 

1299 if model_names is None: 

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

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

1302 else: 

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

1304 ax.set_xlabel( 

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

1306 ) 

1307 ax.set_ylabel( 

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

1309 ) 

1310 ax.set_title(title, fontsize=16) 

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

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

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

1314 ax.autoscale() 

1315 

1316 # Save plot. 

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

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

1319 plt.close(fig) 

1320 

1321 

1322def _plot_and_save_vector_plot( 

1323 cube_u: iris.cube.Cube, 

1324 cube_v: iris.cube.Cube, 

1325 filename: str, 

1326 title: str, 

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

1328 **kwargs, 

1329): 

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

1331 

1332 Parameters 

1333 ---------- 

1334 cube_u: Cube 

1335 2 dimensional Cube of u component of the data. 

1336 cube_v: Cube 

1337 2 dimensional Cube of v component of the data. 

1338 filename: str 

1339 Filename of the plot to write. 

1340 title: str 

1341 Plot title. 

1342 """ 

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

1344 

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

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

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

1348 

1349 # Specify the color bar 

1350 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag) 

1351 

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

1353 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

1354 

1355 if method == "contourf": 

1356 # Filled contour plot of the field. 

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

1358 elif method == "pcolormesh": 

1359 try: 

1360 vmin = min(levels) 

1361 vmax = max(levels) 

1362 except TypeError: 

1363 vmin, vmax = None, None 

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

1365 # if levels are defined. 

1366 if norm is not None: 

1367 vmin = None 

1368 vmax = None 

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

1370 else: 

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

1372 

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

1374 if is_transect(cube_vec_mag): 

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

1376 axes.invert_yaxis() 

1377 axes.set_yscale("log") 

1378 axes.set_ylim(1100, 100) 

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

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

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

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

1383 ): 

1384 axes.set_yscale("log") 

1385 

1386 axes.set_title( 

1387 f"{title}\n" 

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

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

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

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

1392 fontsize=16, 

1393 ) 

1394 

1395 else: 

1396 # Add title. 

1397 axes.set_title(title, fontsize=16) 

1398 

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

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

1401 axes.annotate( 

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

1403 xy=(0.05, -0.05), 

1404 xycoords="axes fraction", 

1405 xytext=(-5, 5), 

1406 textcoords="offset points", 

1407 ha="right", 

1408 va="bottom", 

1409 size=11, 

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

1411 ) 

1412 

1413 # Add colour bar. 

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

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

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

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

1418 cbar.set_ticks(levels) 

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

1420 

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

1422 # with less than 30 points. 

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

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

1425 

1426 # Save plot. 

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

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

1429 plt.close(fig) 

1430 

1431 

1432def _plot_and_save_histogram_series( 

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

1434 filename: str, 

1435 title: str, 

1436 vmin: float, 

1437 vmax: float, 

1438 **kwargs, 

1439): 

1440 """Plot and save a histogram series. 

1441 

1442 Parameters 

1443 ---------- 

1444 cubes: Cube or CubeList 

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

1446 filename: str 

1447 Filename of the plot to write. 

1448 title: str 

1449 Plot title. 

1450 vmin: float 

1451 minimum for colorbar 

1452 vmax: float 

1453 maximum for colorbar 

1454 """ 

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

1456 ax = plt.gca() 

1457 

1458 model_colors_map = _get_model_colors_map(cubes) 

1459 

1460 # Set default that histograms will produce probability density function 

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

1462 density = True 

1463 

1464 for cube in iter_maybe(cubes): 

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

1466 # than seeing if long names exist etc. 

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

1468 if ( 

1469 ("surface_microphysical" in title) 

1470 or ("rain accumulation" in title) 

1471 or ("Rainfall rate Composite" in title) 

1472 or ("Nimrod_5min" in title) 

1473 ): 

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

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

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

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

1478 density = False 

1479 else: 

1480 bins = 10.0 ** ( 

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

1482 ) # Suggestion from RMED toolbox. 

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

1484 ax.set_yscale("log") 

1485 vmin = bins[1] 

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

1487 ax.set_xscale("log") 

1488 elif "lightning" in title: 

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

1490 else: 

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

1492 logging.debug( 

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

1494 np.size(bins), 

1495 np.min(bins), 

1496 np.max(bins), 

1497 ) 

1498 

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

1500 # Otherwise we plot xdim histograms stacked. 

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

1502 

1503 label = None 

1504 color = "black" 

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

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

1507 color = model_colors_map[label] 

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

1509 

1510 # Compute area under curve. 

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

1512 ("surface_microphysical" in title and "amount" in title) 

1513 or ("rain_accumulation" in title) 

1514 or ("Rainfall rate Composite" in title) 

1515 or ("Nimrod_5min" in title) 

1516 ): 

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

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

1519 x = x[1:] 

1520 y = y[1:] 

1521 

1522 ax.plot( 

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

1524 ) 

1525 

1526 # Add some labels and tweak the style. 

1527 ax.set_title(title, fontsize=16) 

1528 ax.set_xlabel( 

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

1530 ) 

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

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

1533 ("surface_microphysical" in title and "amount" in title) 

1534 or ("rain accumulation" in title) 

1535 or ("Nimrod_5min" in title) 

1536 ): 

1537 ax.set_ylabel( 

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

1539 ) 

1540 ax.set_xlim(vmin, vmax) 

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

1542 

1543 # Overlay grid-lines onto histogram plot. 

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

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

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

1547 

1548 # Save plot. 

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

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

1551 plt.close(fig) 

1552 

1553 

1554def _plot_and_save_postage_stamp_histogram_series( 

1555 cube: iris.cube.Cube, 

1556 filename: str, 

1557 title: str, 

1558 stamp_coordinate: str, 

1559 vmin: float, 

1560 vmax: float, 

1561 **kwargs, 

1562): 

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

1564 

1565 Parameters 

1566 ---------- 

1567 cube: Cube 

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

1569 filename: str 

1570 Filename of the plot to write. 

1571 title: str 

1572 Plot title. 

1573 stamp_coordinate: str 

1574 Coordinate that becomes different plots. 

1575 vmin: float 

1576 minimum for pdf x-axis 

1577 vmax: float 

1578 maximum for pdf x-axis 

1579 """ 

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

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

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

1583 grid_size = math.ceil(nmember / grid_rows) 

1584 

1585 fig = plt.figure( 

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

1587 ) 

1588 # Make a subplot for each member. 

1589 for member, subplot in zip( 

1590 cube.slices_over(stamp_coordinate), 

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

1592 strict=False, 

1593 ): 

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

1595 # cartopy GeoAxes generated. 

1596 plt.subplot(grid_rows, grid_size, subplot) 

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

1598 # Otherwise we plot xdim histograms stacked. 

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

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

1601 axes = plt.gca() 

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

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

1604 axes.set_xlim(vmin, vmax) 

1605 

1606 # Overall figure title. 

1607 fig.suptitle(title, fontsize=16) 

1608 

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

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

1611 plt.close(fig) 

1612 

1613 

1614def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1615 cube: iris.cube.Cube, 

1616 filename: str, 

1617 title: str, 

1618 stamp_coordinate: str, 

1619 vmin: float, 

1620 vmax: float, 

1621 **kwargs, 

1622): 

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

1624 ax.set_title(title, fontsize=16) 

1625 ax.set_xlim(vmin, vmax) 

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

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

1628 # Loop over all slices along the stamp_coordinate 

1629 for member in cube.slices_over(stamp_coordinate): 

1630 # Flatten the member data to 1D 

1631 member_data_1d = member.data.flatten() 

1632 # Plot the histogram using plt.hist 

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

1634 plt.hist( 

1635 member_data_1d, 

1636 density=True, 

1637 stacked=True, 

1638 label=f"{mtitle}", 

1639 ) 

1640 

1641 # Add a legend 

1642 ax.legend(fontsize=16) 

1643 

1644 # Save the figure to a file 

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

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

1647 

1648 # Close the figure 

1649 plt.close(fig) 

1650 

1651 

1652def _plot_and_save_scattermap_plot( 

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

1654): 

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

1656 

1657 Parameters 

1658 ---------- 

1659 cube: Cube 

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

1661 longitude coordinates, 

1662 filename: str 

1663 Filename of the plot to write. 

1664 title: str 

1665 Plot title. 

1666 projection: str 

1667 Mapping projection to be used by cartopy. 

1668 """ 

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

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

1671 if projection is not None: 

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

1673 # a stereographic projection over the North Pole. 

1674 if projection == "NP_Stereo": 

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

1676 else: 

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

1678 else: 

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

1680 

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

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

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

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

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

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

1687 # proportion to the area of the figure. 

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

1689 klon = None 

1690 klat = None 

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

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

1693 klat = kc 

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

1695 klon = kc 

1696 scatter_map = iplt.scatter( 

1697 cube.aux_coords[klon], 

1698 cube.aux_coords[klat], 

1699 c=cube.data[:], 

1700 s=mrk_size, 

1701 cmap="jet", 

1702 edgecolors="k", 

1703 ) 

1704 

1705 # Add coastlines and borderlines. 

1706 try: 

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

1708 axes.add_feature(cfeature.BORDERS) 

1709 except AttributeError: 

1710 pass 

1711 

1712 # Add title. 

1713 axes.set_title(title, fontsize=16) 

1714 

1715 # Add colour bar. 

1716 cbar = fig.colorbar(scatter_map) 

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

1718 

1719 # Save plot. 

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

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

1722 plt.close(fig) 

1723 

1724 

1725def _plot_and_save_power_spectrum_series( 

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

1727 filename: str, 

1728 title: str, 

1729 **kwargs, 

1730): 

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

1732 

1733 Parameters 

1734 ---------- 

1735 cubes: Cube or CubeList 

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

1737 filename: str 

1738 Filename of the plot to write. 

1739 title: str 

1740 Plot title. 

1741 """ 

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

1743 ax = plt.gca() 

1744 

1745 model_colors_map = _get_model_colors_map(cubes) 

1746 

1747 for cube in iter_maybe(cubes): 

1748 # Calculate power spectrum 

1749 

1750 # Extract time coordinate and convert to datetime 

1751 time_coord = cube.coord("time") 

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

1753 

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

1755 target_time = time_points[0] 

1756 

1757 # Bind target_time inside the lambda using a default argument 

1758 time_constraint = iris.Constraint( 

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

1760 ) 

1761 

1762 cube = cube.extract(time_constraint) 

1763 

1764 if cube.ndim == 2: 

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

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

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

1768 cube_3d = cube.data 

1769 else: 

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

1771 raise ValueError( 

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

1773 ) 

1774 

1775 # Calculate spectra 

1776 ps_array = _DCT_ps(cube_3d) 

1777 

1778 ps_cube = iris.cube.Cube( 

1779 ps_array, 

1780 long_name="power_spectra", 

1781 ) 

1782 

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

1784 

1785 # Create a frequency/wavelength array for coordinate 

1786 ps_len = ps_cube.data.shape[1] 

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

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

1789 

1790 # Convert datetime to numeric time using original units 

1791 numeric_time = time_coord.units.date2num(time_points) 

1792 # Create a new DimCoord with numeric time 

1793 new_time_coord = iris.coords.DimCoord( 

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

1795 ) 

1796 

1797 # Add time and frequency coordinate to spectra cube. 

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

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

1800 

1801 # Extract data from the cube 

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

1803 power_spectrum = ps_cube.data 

1804 

1805 label = None 

1806 color = "black" 

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

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

1809 color = model_colors_map[label] 

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

1811 

1812 # Add some labels and tweak the style. 

1813 ax.set_title(title, fontsize=16) 

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

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

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

1817 

1818 # Set log-log scale 

1819 ax.set_xscale("log") 

1820 ax.set_yscale("log") 

1821 

1822 # Overlay grid-lines onto power spectrum plot. 

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

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

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

1826 

1827 # Save plot. 

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

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

1830 plt.close(fig) 

1831 

1832 

1833def _plot_and_save_postage_stamp_power_spectrum_series( 

1834 cube: iris.cube.Cube, 

1835 filename: str, 

1836 title: str, 

1837 stamp_coordinate: str, 

1838 **kwargs, 

1839): 

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

1841 

1842 Parameters 

1843 ---------- 

1844 cube: Cube 

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

1846 filename: str 

1847 Filename of the plot to write. 

1848 title: str 

1849 Plot title. 

1850 stamp_coordinate: str 

1851 Coordinate that becomes different plots. 

1852 """ 

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

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

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

1856 grid_size = math.ceil(nmember / grid_rows) 

1857 

1858 fig = plt.figure( 

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

1860 ) 

1861 

1862 # Make a subplot for each member. 

1863 for member, subplot in zip( 

1864 cube.slices_over(stamp_coordinate), 

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

1866 strict=False, 

1867 ): 

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

1869 # cartopy GeoAxes generated. 

1870 plt.subplot(grid_rows, grid_size, subplot) 

1871 

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

1873 

1874 axes = plt.gca() 

1875 axes.plot(frequency, member.data) 

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

1877 

1878 # Overall figure title. 

1879 fig.suptitle(title, fontsize=16) 

1880 

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

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

1883 plt.close(fig) 

1884 

1885 

1886def _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series( 

1887 cube: iris.cube.Cube, 

1888 filename: str, 

1889 title: str, 

1890 stamp_coordinate: str, 

1891 **kwargs, 

1892): 

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

1894 ax.set_title(title, fontsize=16) 

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

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

1897 # Loop over all slices along the stamp_coordinate 

1898 for member in cube.slices_over(stamp_coordinate): 

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

1900 ax.plot( 

1901 frequency, 

1902 member.data, 

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

1904 ) 

1905 

1906 # Add a legend 

1907 ax.legend(fontsize=16) 

1908 

1909 # Save the figure to a file 

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

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

1912 

1913 # Close the figure 

1914 plt.close(fig) 

1915 

1916 

1917def _spatial_plot( 

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

1919 cube: iris.cube.Cube, 

1920 filename: str | None, 

1921 sequence_coordinate: str, 

1922 stamp_coordinate: str, 

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

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

1925 **kwargs, 

1926): 

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

1928 

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

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

1931 is present then postage stamp plots will be produced. 

1932 

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

1934 be overplotted on the same figure. 

1935 

1936 Parameters 

1937 ---------- 

1938 method: "contourf" | "pcolormesh" 

1939 The plotting method to use. 

1940 cube: Cube 

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

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

1943 plotted sequentially and/or as postage stamp plots. 

1944 filename: str | None 

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

1946 uses the recipe name. 

1947 sequence_coordinate: str 

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

1949 This coordinate must exist in the cube. 

1950 stamp_coordinate: str 

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

1952 ``"realization"``. 

1953 overlay_cube: Cube | None, optional 

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

1955 contour_cube: Cube | None, optional 

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

1957 

1958 Raises 

1959 ------ 

1960 ValueError 

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

1962 TypeError 

1963 If the cube isn't a single cube. 

1964 """ 

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

1966 

1967 # Ensure we've got a single cube. 

1968 cube = _check_single_cube(cube) 

1969 

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

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

1972 stamp_coordinate = check_stamp_coordinate(cube) 

1973 

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

1975 # single point. 

1976 plotting_func = _plot_and_save_spatial_plot 

1977 try: 

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

1979 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1980 except iris.exceptions.CoordinateNotFoundError: 

1981 pass 

1982 

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

1984 # dimension called observation or model_obs_error 

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

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

1987 for crd in cube.coords() 

1988 ): 

1989 plotting_func = _plot_and_save_scattermap_plot 

1990 

1991 # Must have a sequence coordinate. 

1992 try: 

1993 cube.coord(sequence_coordinate) 

1994 except iris.exceptions.CoordinateNotFoundError as err: 

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

1996 

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

1998 plot_index = [] 

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

2000 

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

2002 # Set plot titles and filename 

2003 seq_coord = cube_slice.coord(sequence_coordinate) 

2004 plot_title, plot_filename = _set_title_and_filename( 

2005 seq_coord, nplot, recipe_title, filename 

2006 ) 

2007 

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

2009 overlay_slice = slice_over_maybe(overlay_cube, sequence_coordinate, iseq) 

2010 contour_slice = slice_over_maybe(contour_cube, sequence_coordinate, iseq) 

2011 

2012 # Do the actual plotting. 

2013 plotting_func( 

2014 cube_slice, 

2015 filename=plot_filename, 

2016 stamp_coordinate=stamp_coordinate, 

2017 title=plot_title, 

2018 method=method, 

2019 overlay_cube=overlay_slice, 

2020 contour_cube=contour_slice, 

2021 **kwargs, 

2022 ) 

2023 plot_index.append(plot_filename) 

2024 

2025 # Add list of plots to plot metadata. 

2026 complete_plot_index = _append_to_plot_index(plot_index) 

2027 

2028 # Make a page to display the plots. 

2029 _make_plot_html_page(complete_plot_index) 

2030 

2031 

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

2033 """Get colourmap for mask. 

2034 

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

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

2037 

2038 Parameters 

2039 ---------- 

2040 cube: Cube 

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

2042 axis: "x", "y", optional 

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

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

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

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

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

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

2049 

2050 Returns 

2051 ------- 

2052 cmap: 

2053 Matplotlib colormap. 

2054 levels: 

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

2056 should be taken as the range. 

2057 norm: 

2058 BoundaryNorm information. 

2059 """ 

2060 if "difference" not in cube.long_name: 

2061 if axis: 

2062 levels = [0, 1] 

2063 # Complete settings based on levels. 

2064 return None, levels, None 

2065 else: 

2066 # Define the levels and colors. 

2067 levels = [0, 1, 2] 

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

2069 # Create a custom color map. 

2070 cmap = mcolors.ListedColormap(colors) 

2071 # Normalize the levels. 

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

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

2074 return cmap, levels, norm 

2075 else: 

2076 if axis: 

2077 levels = [-1, 1] 

2078 return None, levels, None 

2079 else: 

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

2081 # not <=. 

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

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

2084 cmap = mcolors.ListedColormap(colors) 

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

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

2087 return cmap, levels, norm 

2088 

2089 

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

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

2092 

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

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

2095 

2096 Parameters 

2097 ---------- 

2098 cube: Cube 

2099 Cube of variable with Beaufort Scale in name. 

2100 axis: "x", "y", optional 

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

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

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

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

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

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

2107 

2108 Returns 

2109 ------- 

2110 cmap: 

2111 Matplotlib colormap. 

2112 levels: 

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

2114 should be taken as the range. 

2115 norm: 

2116 BoundaryNorm information. 

2117 """ 

2118 if "difference" not in cube.long_name: 

2119 if axis: 

2120 levels = [0, 12] 

2121 return None, levels, None 

2122 else: 

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

2124 colors = [ 

2125 "black", 

2126 (0, 0, 0.6), 

2127 "blue", 

2128 "cyan", 

2129 "green", 

2130 "yellow", 

2131 (1, 0.5, 0), 

2132 "red", 

2133 "pink", 

2134 "magenta", 

2135 "purple", 

2136 "maroon", 

2137 "white", 

2138 ] 

2139 cmap = mcolors.ListedColormap(colors) 

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

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

2142 return cmap, levels, norm 

2143 else: 

2144 if axis: 

2145 levels = [-4, 4] 

2146 return None, levels, None 

2147 else: 

2148 levels = [ 

2149 -3.5, 

2150 -2.5, 

2151 -1.5, 

2152 -0.5, 

2153 0.5, 

2154 1.5, 

2155 2.5, 

2156 3.5, 

2157 ] 

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

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

2160 return cmap, levels, norm 

2161 

2162 

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

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

2165 

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

2167 

2168 Parameters 

2169 ---------- 

2170 cube: Cube 

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

2172 cmap: Matplotlib colormap. 

2173 levels: List 

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

2175 should be taken as the range. 

2176 norm: BoundaryNorm. 

2177 

2178 Returns 

2179 ------- 

2180 cmap: Matplotlib colormap. 

2181 levels: List 

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

2183 should be taken as the range. 

2184 norm: BoundaryNorm. 

2185 """ 

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

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

2188 levels = np.array(levels) 

2189 levels -= 273 

2190 levels = levels.tolist() 

2191 else: 

2192 # Do nothing keep the existing colourbar attributes 

2193 levels = levels 

2194 cmap = cmap 

2195 norm = norm 

2196 return cmap, levels, norm 

2197 

2198 

2199def _custom_colormap_probability( 

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

2201): 

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

2203 

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

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

2206 

2207 Parameters 

2208 ---------- 

2209 cube: Cube 

2210 Cube of variable with probability in name. 

2211 axis: "x", "y", optional 

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

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

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

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

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

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

2218 

2219 Returns 

2220 ------- 

2221 cmap: 

2222 Matplotlib colormap. 

2223 levels: 

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

2225 should be taken as the range. 

2226 norm: 

2227 BoundaryNorm information. 

2228 """ 

2229 if axis: 

2230 levels = [0, 1] 

2231 return None, levels, None 

2232 else: 

2233 cmap = mcolors.ListedColormap( 

2234 [ 

2235 "#FFFFFF", 

2236 "#636363", 

2237 "#e1dada", 

2238 "#B5CAFF", 

2239 "#8FB3FF", 

2240 "#7F97FF", 

2241 "#ABCF63", 

2242 "#E8F59E", 

2243 "#FFFA14", 

2244 "#FFD121", 

2245 "#FFA30A", 

2246 ] 

2247 ) 

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

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

2250 return cmap, levels, norm 

2251 

2252 

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

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

2255 varnames_lower = [ 

2256 n.lower() for n in (cube.long_name, cube.standard_name, cube.var_name) if n 

2257 ] 

2258 

2259 is_rainfall_var = any( 

2260 key in name 

2261 for name in varnames_lower 

2262 for key in ( 

2263 "surface_microphysical", 

2264 "rainfall rate composite", 

2265 "nimrod5min", 

2266 "nimrod_5min", 

2267 "rain_accumulation", 

2268 "rain accumulation", 

2269 ) 

2270 ) 

2271 

2272 if is_rainfall_var: 

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

2274 colors = [ 

2275 "w", 

2276 (0, 0, 0.6), 

2277 "b", 

2278 "c", 

2279 "g", 

2280 "y", 

2281 (1, 0.5, 0), 

2282 "r", 

2283 "pink", 

2284 "m", 

2285 "purple", 

2286 "maroon", 

2287 "gray", 

2288 ] 

2289 # Create a custom colormap 

2290 cmap = mcolors.ListedColormap(colors) 

2291 # Normalize the levels 

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

2293 logging.info("Using custom rainfall colourmap.") 

2294 

2295 # Set any Nan values to be plotted a light grey. 

2296 cmap.set_bad("#dcdcdc") 

2297 

2298 return cmap, levels, norm 

2299 

2300 

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

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

2303 

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

2305 this function will be called. 

2306 

2307 Parameters 

2308 ---------- 

2309 cube: Cube 

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

2311 

2312 Returns 

2313 ------- 

2314 cmap: Matplotlib colormap. 

2315 levels: List 

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

2317 should be taken as the range. 

2318 norm: BoundaryNorm. 

2319 """ 

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

2321 colors = [ 

2322 "#87ceeb", 

2323 "#ffffff", 

2324 "#8ced69", 

2325 "#ffff00", 

2326 "#ffd700", 

2327 "#ffa500", 

2328 "#fe3620", 

2329 ] 

2330 # Create a custom colormap 

2331 cmap = mcolors.ListedColormap(colors) 

2332 # Normalise the levels 

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

2334 return cmap, levels, norm 

2335 

2336 

2337def _custom_colourmap_nimrod_weights(cube: iris.cube.Cube, cmap, levels, norm): 

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

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

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

2341 any("wts" in name for name in varnames) 

2342 and "difference" not in cube.long_name 

2343 and "mask" not in cube.long_name 

2344 ): 

2345 # Define the levels and colors. Remember the Nimrod weights vary over the 

2346 # range [0,13] and should be integer values. Optimum value is 13. 

2347 levels = [ 

2348 -0.5, 

2349 0.5, 

2350 1.5, 

2351 2.5, 

2352 3.5, 

2353 4.5, 

2354 5.5, 

2355 6.5, 

2356 7.5, 

2357 8.5, 

2358 9.5, 

2359 10.5, 

2360 11.5, 

2361 12.5, 

2362 13.5, 

2363 ] 

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

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

2366 colours = [ 

2367 "#dcdcdc", 

2368 # "darkgray", 

2369 "#d10000", 

2370 "purple", 

2371 "#8f00d6", 

2372 "#ff9700", 

2373 "pink", 

2374 "#ffff00", 

2375 "#00007f", 

2376 "#6c9ccd", 

2377 "#aae8ff", 

2378 "#37a648", 

2379 "#8edc64", 

2380 "#c5ffc5", 

2381 "#ffffff", 

2382 ] 

2383 # Create a custom colormap 

2384 cmap = mcolors.ListedColormap(colours) 

2385 # Normalize the levels 

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

2387 logging.info("Change colormap for Nimrod weights colorbar.") 

2388 else: 

2389 # do nothing and keep existing colorbar attributes 

2390 cmap = cmap 

2391 levels = levels 

2392 norm = norm 

2393 return cmap, levels, norm 

2394 

2395 

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

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

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

2399 if ( 

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

2401 and "difference" not in cube.long_name 

2402 and "mask" not in cube.long_name 

2403 ): 

2404 # Define the levels and colors (in km) 

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

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

2407 colours = [ 

2408 "#8f00d6", 

2409 "#d10000", 

2410 "#ff9700", 

2411 "#ffff00", 

2412 "#00007f", 

2413 "#6c9ccd", 

2414 "#aae8ff", 

2415 "#37a648", 

2416 "#8edc64", 

2417 "#c5ffc5", 

2418 "#dcdcdc", 

2419 "#ffffff", 

2420 ] 

2421 # Create a custom colormap 

2422 cmap = mcolors.ListedColormap(colours) 

2423 # Normalize the levels 

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

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

2426 else: 

2427 # do nothing and keep existing colorbar attributes 

2428 cmap = cmap 

2429 levels = levels 

2430 norm = norm 

2431 return cmap, levels, norm 

2432 

2433 

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

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

2436 model_names = list( 

2437 filter( 

2438 lambda x: x is not None, 

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

2440 ) 

2441 ) 

2442 if not model_names: 

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

2444 return 1 

2445 else: 

2446 return len(model_names) 

2447 

2448 

2449def _validate_cube_shape( 

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

2451) -> None: 

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

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

2454 raise ValueError( 

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

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

2457 ) 

2458 

2459 

2460def _validate_cubes_coords( 

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

2462) -> None: 

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

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

2465 raise ValueError( 

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

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

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

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

2470 ) 

2471 

2472 

2473#################### 

2474# Public functions # 

2475#################### 

2476 

2477 

2478def spatial_contour_plot( 

2479 cube: iris.cube.Cube, 

2480 filename: str = None, 

2481 sequence_coordinate: str = "time", 

2482 stamp_coordinate: str = "realization", 

2483 **kwargs, 

2484) -> iris.cube.Cube: 

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

2486 

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

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

2489 is present then postage stamp plots will be produced. 

2490 

2491 Parameters 

2492 ---------- 

2493 cube: Cube 

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

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

2496 plotted sequentially and/or as postage stamp plots. 

2497 filename: str, optional 

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

2499 to the recipe name. 

2500 sequence_coordinate: str, optional 

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

2502 This coordinate must exist in the cube. 

2503 stamp_coordinate: str, optional 

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

2505 ``"realization"``. 

2506 

2507 Returns 

2508 ------- 

2509 Cube 

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

2511 

2512 Raises 

2513 ------ 

2514 ValueError 

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

2516 TypeError 

2517 If the cube isn't a single cube. 

2518 """ 

2519 _spatial_plot( 

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

2521 ) 

2522 return cube 

2523 

2524 

2525def spatial_pcolormesh_plot( 

2526 cube: iris.cube.Cube, 

2527 filename: str = None, 

2528 sequence_coordinate: str = "time", 

2529 stamp_coordinate: str = "realization", 

2530 **kwargs, 

2531) -> iris.cube.Cube: 

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

2533 

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

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

2536 is present then postage stamp plots will be produced. 

2537 

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

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

2540 contour areas are important. 

2541 

2542 Parameters 

2543 ---------- 

2544 cube: Cube 

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

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

2547 plotted sequentially and/or as postage stamp plots. 

2548 filename: str, optional 

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

2550 to the recipe name. 

2551 sequence_coordinate: str, optional 

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

2553 This coordinate must exist in the cube. 

2554 stamp_coordinate: str, optional 

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

2556 ``"realization"``. 

2557 

2558 Returns 

2559 ------- 

2560 Cube 

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

2562 

2563 Raises 

2564 ------ 

2565 ValueError 

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

2567 TypeError 

2568 If the cube isn't a single cube. 

2569 """ 

2570 _spatial_plot( 

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

2572 ) 

2573 return cube 

2574 

2575 

2576def spatial_multi_pcolormesh_plot( 

2577 cube: iris.cube.Cube, 

2578 overlay_cube: iris.cube.Cube, 

2579 contour_cube: iris.cube.Cube, 

2580 filename: str = None, 

2581 sequence_coordinate: str = "time", 

2582 stamp_coordinate: str = "realization", 

2583 **kwargs, 

2584) -> iris.cube.Cube: 

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

2586 

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

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

2589 is present then postage stamp plots will be produced. 

2590 

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

2592 

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

2594 

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

2596 

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

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

2599 contour areas are important. 

2600 

2601 Parameters 

2602 ---------- 

2603 cube: Cube 

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

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

2606 plotted sequentially and/or as postage stamp plots. 

2607 overlay_cube: Cube 

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

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

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

2611 contour_cube: Cube 

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

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

2614 plotted sequentially and/or as postage stamp plots. 

2615 filename: str, optional 

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

2617 to the recipe name. 

2618 sequence_coordinate: str, optional 

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

2620 This coordinate must exist in the cube. 

2621 stamp_coordinate: str, optional 

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

2623 ``"realization"``. 

2624 

2625 Returns 

2626 ------- 

2627 Cube 

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

2629 

2630 Raises 

2631 ------ 

2632 ValueError 

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

2634 TypeError 

2635 If the cube isn't a single cube. 

2636 """ 

2637 _spatial_plot( 

2638 "pcolormesh", 

2639 cube, 

2640 filename, 

2641 sequence_coordinate, 

2642 stamp_coordinate, 

2643 overlay_cube=overlay_cube, 

2644 contour_cube=contour_cube, 

2645 ) 

2646 return cube, overlay_cube, contour_cube 

2647 

2648 

2649# TODO: Expand function to handle ensemble data. 

2650# line_coordinate: str, optional 

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

2652# ``"realization"``. 

2653def plot_line_series( 

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

2655 filename: str = None, 

2656 series_coordinate: str = "time", 

2657 # line_coordinate: str = "realization", 

2658 **kwargs, 

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

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

2661 

2662 The Cube or CubeList must be 1D. 

2663 

2664 Parameters 

2665 ---------- 

2666 iris.cube | iris.cube.CubeList 

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

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

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

2670 filename: str, optional 

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

2672 to the recipe name. 

2673 series_coordinate: str, optional 

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

2675 coordinate must exist in the cube. 

2676 

2677 Returns 

2678 ------- 

2679 iris.cube.Cube | iris.cube.CubeList 

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

2681 Plotted data. 

2682 

2683 Raises 

2684 ------ 

2685 ValueError 

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

2687 TypeError 

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

2689 """ 

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

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

2692 

2693 num_models = _get_num_models(cube) 

2694 

2695 _validate_cube_shape(cube, num_models) 

2696 

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

2698 cubes = iter_maybe(cube) 

2699 coords = [] 

2700 for cube in cubes: 

2701 try: 

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

2703 except iris.exceptions.CoordinateNotFoundError as err: 

2704 raise ValueError( 

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

2706 ) from err 

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

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

2709 

2710 # Format the title and filename using plotted series coordinate 

2711 nplot = 1 

2712 seq_coord = coords[0] 

2713 plot_title, plot_filename = _set_title_and_filename( 

2714 seq_coord, nplot, recipe_title, filename 

2715 ) 

2716 

2717 # Do the actual plotting. 

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

2719 

2720 # Add list of plots to plot metadata. 

2721 plot_index = _append_to_plot_index([plot_filename]) 

2722 

2723 # Make a page to display the plots. 

2724 _make_plot_html_page(plot_index) 

2725 

2726 return cube 

2727 

2728 

2729def plot_vertical_line_series( 

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

2731 filename: str = None, 

2732 series_coordinate: str = "model_level_number", 

2733 sequence_coordinate: str = "time", 

2734 # line_coordinate: str = "realization", 

2735 **kwargs, 

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

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

2738 

2739 The Cube or CubeList must be 1D. 

2740 

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

2742 then a sequence of plots will be produced. 

2743 

2744 Parameters 

2745 ---------- 

2746 iris.cube | iris.cube.CubeList 

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

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

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

2750 filename: str, optional 

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

2752 to the recipe name. 

2753 series_coordinate: str, optional 

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

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

2756 for LFRic. Defaults to ``model_level_number``. 

2757 This coordinate must exist in the cube. 

2758 sequence_coordinate: str, optional 

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

2760 This coordinate must exist in the cube. 

2761 

2762 Returns 

2763 ------- 

2764 iris.cube.Cube | iris.cube.CubeList 

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

2766 Plotted data. 

2767 

2768 Raises 

2769 ------ 

2770 ValueError 

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

2772 TypeError 

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

2774 """ 

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

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

2777 

2778 cubes = iter_maybe(cubes) 

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

2780 all_data = [] 

2781 

2782 # Store min/max ranges for x range. 

2783 x_levels = [] 

2784 

2785 num_models = _get_num_models(cubes) 

2786 

2787 _validate_cube_shape(cubes, num_models) 

2788 

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

2790 coords = [] 

2791 for cube in cubes: 

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

2793 try: 

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

2795 except iris.exceptions.CoordinateNotFoundError as err: 

2796 raise ValueError( 

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

2798 ) from err 

2799 

2800 try: 

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

2802 cube.coord(sequence_coordinate) 

2803 except iris.exceptions.CoordinateNotFoundError as err: 

2804 raise ValueError( 

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

2806 ) from err 

2807 

2808 # Get minimum and maximum from levels information. 

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

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

2811 x_levels.append(min(levels)) 

2812 x_levels.append(max(levels)) 

2813 else: 

2814 all_data.append(cube.data) 

2815 

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

2817 # Combine all data into a single NumPy array 

2818 combined_data = np.concatenate(all_data) 

2819 

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

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

2822 # sequence and if applicable postage stamp coordinate. 

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

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

2825 else: 

2826 vmin = min(x_levels) 

2827 vmax = max(x_levels) 

2828 

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

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

2831 # necessary) 

2832 def filter_cube_iterables(cube_iterables) -> bool: 

2833 return len(cube_iterables) == len(coords) 

2834 

2835 cube_iterables = filter( 

2836 filter_cube_iterables, 

2837 ( 

2838 iris.cube.CubeList( 

2839 s 

2840 for s in itertools.chain.from_iterable( 

2841 cb.slices_over(sequence_coordinate) for cb in cubes 

2842 ) 

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

2844 ) 

2845 for point in sorted( 

2846 set( 

2847 itertools.chain.from_iterable( 

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

2849 ) 

2850 ) 

2851 ) 

2852 ), 

2853 ) 

2854 

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

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

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

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

2859 # or an iris.cube.CubeList. 

2860 plot_index = [] 

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

2862 for cubes_slice in cube_iterables: 

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

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

2865 plot_title, plot_filename = _set_title_and_filename( 

2866 seq_coord, nplot, recipe_title, filename 

2867 ) 

2868 

2869 # Do the actual plotting. 

2870 _plot_and_save_vertical_line_series( 

2871 cubes_slice, 

2872 coords, 

2873 "realization", 

2874 plot_filename, 

2875 series_coordinate, 

2876 title=plot_title, 

2877 vmin=vmin, 

2878 vmax=vmax, 

2879 ) 

2880 plot_index.append(plot_filename) 

2881 

2882 # Add list of plots to plot metadata. 

2883 complete_plot_index = _append_to_plot_index(plot_index) 

2884 

2885 # Make a page to display the plots. 

2886 _make_plot_html_page(complete_plot_index) 

2887 

2888 return cubes 

2889 

2890 

2891def qq_plot( 

2892 cubes: iris.cube.CubeList, 

2893 coordinates: list[str], 

2894 percentiles: list[float], 

2895 model_names: list[str], 

2896 filename: str = None, 

2897 one_to_one: bool = True, 

2898 **kwargs, 

2899) -> iris.cube.CubeList: 

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

2901 

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

2903 collapsed within the operator over all specified coordinates such as 

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

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

2906 

2907 Parameters 

2908 ---------- 

2909 cubes: iris.cube.CubeList 

2910 Two cubes of the same variable with different models. 

2911 coordinate: list[str] 

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

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

2914 the percentile coordinate. 

2915 percent: list[float] 

2916 A list of percentiles to appear in the plot. 

2917 model_names: list[str] 

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

2919 filename: str, optional 

2920 Filename of the plot to write. 

2921 one_to_one: bool, optional 

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

2923 

2924 Raises 

2925 ------ 

2926 ValueError 

2927 When the cubes are not compatible. 

2928 

2929 Notes 

2930 ----- 

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

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

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

2934 compares percentiles of two datasets. This plot does 

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

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

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

2938 

2939 Quantile-quantile plots are valuable for comparing against 

2940 observations and other models. Identical percentiles between the variables 

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

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

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

2944 Wilks 2011 [Wilks2011]_). 

2945 

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

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

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

2949 the extremes. 

2950 

2951 References 

2952 ---------- 

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

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

2955 """ 

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

2957 if len(cubes) != 2: 

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

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

2960 other: Cube = cubes.extract_cube( 

2961 iris.Constraint( 

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

2963 ) 

2964 ) 

2965 

2966 # Get spatial coord names. 

2967 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

2968 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

2969 

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

2971 # This is triggered if either 

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

2973 # are not compared directly as these can differ through rounding 

2974 # errors. 

2975 # ii) or variables are known to often sit on different grid staggering 

2976 # in different models (e.g. cell center vs cell edge), as is the case 

2977 # for UM and LFRic comparisons. 

2978 # In future greater choice of regridding method might be applied depending 

2979 # on variable type. Linear regridding can in general be appropriate for smooth 

2980 # variables. Care should be taken with interpretation of differences 

2981 # given this dependency on regridding. 

2982 if ( 

2983 base.coord(base_lat_name).shape != other.coord(other_lat_name).shape 

2984 or base.coord(base_lon_name).shape != other.coord(other_lon_name).shape 

2985 ) or ( 

2986 base.long_name 

2987 in [ 

2988 "eastward_wind_at_10m", 

2989 "northward_wind_at_10m", 

2990 "northward_wind_at_cell_centres", 

2991 "eastward_wind_at_cell_centres", 

2992 "zonal_wind_at_pressure_levels", 

2993 "meridional_wind_at_pressure_levels", 

2994 "potential_vorticity_at_pressure_levels", 

2995 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

2996 ] 

2997 ): 

2998 logging.debug( 

2999 "Linear regridding base cube to other grid to compute differences" 

3000 ) 

3001 base = regrid_onto_cube(base, other, method="Linear") 

3002 

3003 # Extract just common time points. 

3004 base, other = _extract_common_time_points(base, other) 

3005 

3006 # Equalise attributes so we can merge. 

3007 fully_equalise_attributes([base, other]) 

3008 logging.debug("Base: %s\nOther: %s", base, other) 

3009 

3010 # Collapse cubes. 

3011 base = collapse( 

3012 base, 

3013 coordinate=coordinates, 

3014 method="PERCENTILE", 

3015 additional_percent=percentiles, 

3016 ) 

3017 other = collapse( 

3018 other, 

3019 coordinate=coordinates, 

3020 method="PERCENTILE", 

3021 additional_percent=percentiles, 

3022 ) 

3023 

3024 # Ensure we have a name for the plot file. 

3025 recipe_title = get_recipe_metadata().get("title", "Untitled") 

3026 title = f"{recipe_title}" 

3027 

3028 if filename is None: 

3029 filename = slugify(recipe_title) 

3030 

3031 # Add file extension. 

3032 plot_filename = f"{filename.rsplit('.', 1)[0]}.png" 

3033 

3034 # Do the actual plotting on a scatter plot 

3035 _plot_and_save_scatter_plot( 

3036 base, other, plot_filename, title, one_to_one, model_names 

3037 ) 

3038 

3039 # Add list of plots to plot metadata. 

3040 plot_index = _append_to_plot_index([plot_filename]) 

3041 

3042 # Make a page to display the plots. 

3043 _make_plot_html_page(plot_index) 

3044 

3045 return iris.cube.CubeList([base, other]) 

3046 

3047 

3048def scatter_plot( 

3049 cube_x: iris.cube.Cube | iris.cube.CubeList, 

3050 cube_y: iris.cube.Cube | iris.cube.CubeList, 

3051 filename: str = None, 

3052 one_to_one: bool = True, 

3053 **kwargs, 

3054) -> iris.cube.CubeList: 

3055 """Plot a scatter plot between two variables. 

3056 

3057 Both cubes must be 1D. 

3058 

3059 Parameters 

3060 ---------- 

3061 cube_x: Cube | CubeList 

3062 1 dimensional Cube of the data to plot on y-axis. 

3063 cube_y: Cube | CubeList 

3064 1 dimensional Cube of the data to plot on x-axis. 

3065 filename: str, optional 

3066 Filename of the plot to write. 

3067 one_to_one: bool, optional 

3068 If True a 1:1 line is plotted; if False it is not. Default is True. 

3069 

3070 Returns 

3071 ------- 

3072 cubes: CubeList 

3073 CubeList of the original x and y cubes for further processing. 

3074 

3075 Raises 

3076 ------ 

3077 ValueError 

3078 If the cube doesn't have the right dimensions and cubes not the same 

3079 size. 

3080 TypeError 

3081 If the cube isn't a single cube. 

3082 

3083 Notes 

3084 ----- 

3085 Scatter plots are used for determining if there is a relationship between 

3086 two variables. Positive relations have a slope going from bottom left to top 

3087 right; Negative relations have a slope going from top left to bottom right. 

3088 """ 

3089 # Iterate over all cubes in cube or CubeList and plot. 

3090 for cube_iter in iter_maybe(cube_x): 

3091 # Check cubes are correct shape. 

3092 cube_iter = _check_single_cube(cube_iter) 

3093 if cube_iter.ndim > 1: 

3094 raise ValueError("cube_x must be 1D.") 

3095 

3096 # Iterate over all cubes in cube or CubeList and plot. 

3097 for cube_iter in iter_maybe(cube_y): 

3098 # Check cubes are correct shape. 

3099 cube_iter = _check_single_cube(cube_iter) 

3100 if cube_iter.ndim > 1: 

3101 raise ValueError("cube_y must be 1D.") 

3102 

3103 # Ensure we have a name for the plot file. 

3104 recipe_title = get_recipe_metadata().get("title", "Untitled") 

3105 title = f"{recipe_title}" 

3106 

3107 if filename is None: 

3108 filename = slugify(recipe_title) 

3109 

3110 # Add file extension. 

3111 plot_filename = f"{filename.rsplit('.', 1)[0]}.png" 

3112 

3113 # Do the actual plotting. 

3114 _plot_and_save_scatter_plot(cube_x, cube_y, plot_filename, title, one_to_one) 

3115 

3116 # Add list of plots to plot metadata. 

3117 plot_index = _append_to_plot_index([plot_filename]) 

3118 

3119 # Make a page to display the plots. 

3120 _make_plot_html_page(plot_index) 

3121 

3122 return iris.cube.CubeList([cube_x, cube_y]) 

3123 

3124 

3125def vector_plot( 

3126 cube_u: iris.cube.Cube, 

3127 cube_v: iris.cube.Cube, 

3128 filename: str = None, 

3129 sequence_coordinate: str = "time", 

3130 **kwargs, 

3131) -> iris.cube.CubeList: 

3132 """Plot a vector plot based on the input u and v components.""" 

3133 recipe_title = get_recipe_metadata().get("title", "Untitled") 

3134 

3135 # Cubes must have a matching sequence coordinate. 

3136 try: 

3137 # Check that the u and v cubes have the same sequence coordinate. 

3138 if cube_u.coord(sequence_coordinate) != cube_v.coord(sequence_coordinate): 3138 ↛ anywhereline 3138 didn't jump anywhere: it always raised an exception.

3139 raise ValueError("Coordinates do not match.") 

3140 except (iris.exceptions.CoordinateNotFoundError, ValueError) as err: 

3141 raise ValueError( 

3142 f"Cubes should have matching {sequence_coordinate} coordinate:\n{cube_u}\n{cube_v}" 

3143 ) from err 

3144 

3145 # Create a plot for each value of the sequence coordinate. 

3146 plot_index = [] 

3147 nplot = np.size(cube_u[0].coord(sequence_coordinate).points) 

3148 for cube_u_slice, cube_v_slice in zip( 

3149 cube_u.slices_over(sequence_coordinate), 

3150 cube_v.slices_over(sequence_coordinate), 

3151 strict=True, 

3152 ): 

3153 # Format the coordinate value in a unit appropriate way. 

3154 seq_coord = cube_u_slice.coord(sequence_coordinate) 

3155 plot_title, plot_filename = _set_title_and_filename( 

3156 seq_coord, nplot, recipe_title, filename 

3157 ) 

3158 

3159 # Do the actual plotting. 

3160 _plot_and_save_vector_plot( 

3161 cube_u_slice, 

3162 cube_v_slice, 

3163 filename=plot_filename, 

3164 title=plot_title, 

3165 method="contourf", 

3166 ) 

3167 plot_index.append(plot_filename) 

3168 

3169 # Add list of plots to plot metadata. 

3170 complete_plot_index = _append_to_plot_index(plot_index) 

3171 

3172 # Make a page to display the plots. 

3173 _make_plot_html_page(complete_plot_index) 

3174 

3175 return iris.cube.CubeList([cube_u, cube_v]) 

3176 

3177 

3178def plot_histogram_series( 

3179 cubes: iris.cube.Cube | iris.cube.CubeList, 

3180 filename: str = None, 

3181 sequence_coordinate: str = "time", 

3182 stamp_coordinate: str = "realization", 

3183 single_plot: bool = False, 

3184 **kwargs, 

3185) -> iris.cube.Cube | iris.cube.CubeList: 

3186 """Plot a histogram plot for each vertical level provided. 

3187 

3188 A histogram plot can be plotted, but if the sequence_coordinate (i.e. time) 

3189 is present then a sequence of plots will be produced using the time slider 

3190 functionality to scroll through histograms against time. If a 

3191 stamp_coordinate is present then postage stamp plots will be produced. If 

3192 stamp_coordinate and single_plot is True, all postage stamp plots will be 

3193 plotted in a single plot instead of separate postage stamp plots. 

3194 

3195 Parameters 

3196 ---------- 

3197 cubes: Cube | iris.cube.CubeList 

3198 Iris cube or CubeList of the data to plot. It should have a single dimension other 

3199 than the stamp coordinate. 

3200 The cubes should cover the same phenomenon i.e. all cubes contain temperature data. 

3201 We do not support different data such as temperature and humidity in the same CubeList for plotting. 

3202 filename: str, optional 

3203 Name of the plot to write, used as a prefix for plot sequences. Defaults 

3204 to the recipe name. 

3205 sequence_coordinate: str, optional 

3206 Coordinate about which to make a plot sequence. Defaults to ``"time"``. 

3207 This coordinate must exist in the cube and will be used for the time 

3208 slider. 

3209 stamp_coordinate: str, optional 

3210 Coordinate about which to plot postage stamp plots. Defaults to 

3211 ``"realization"``. 

3212 single_plot: bool, optional 

3213 If True, all postage stamp plots will be plotted in a single plot. If 

3214 False, each postage stamp plot will be plotted separately. Is only valid 

3215 if stamp_coordinate exists and has more than a single point. 

3216 

3217 Returns 

3218 ------- 

3219 iris.cube.Cube | iris.cube.CubeList 

3220 The original Cube or CubeList (so further operations can be applied). 

3221 Plotted data. 

3222 

3223 Raises 

3224 ------ 

3225 ValueError 

3226 If the cube doesn't have the right dimensions. 

3227 TypeError 

3228 If the cube isn't a Cube or CubeList. 

3229 """ 

3230 recipe_title = get_recipe_metadata().get("title", "Untitled") 

3231 

3232 cubes = iter_maybe(cubes) 

3233 

3234 # Internal plotting function. 

3235 plotting_func = _plot_and_save_histogram_series 

3236 

3237 num_models = _get_num_models(cubes) 

3238 

3239 _validate_cube_shape(cubes, num_models) 

3240 

3241 # If several histograms are plotted with time as sequence_coordinate for the 

3242 # time slider option. 

3243 for cube in cubes: 

3244 try: 

3245 cube.coord(sequence_coordinate) 

3246 except iris.exceptions.CoordinateNotFoundError as err: 

3247 raise ValueError( 

3248 f"Cube must have a {sequence_coordinate} coordinate." 

3249 ) from err 

3250 

3251 # Get minimum and maximum from levels information. 

3252 levels = None 

3253 for cube in cubes: 3253 ↛ 3269line 3253 didn't jump to line 3269 because the loop on line 3253 didn't complete

3254 # First check if user-specified "auto" range variable. 

3255 # This maintains the value of levels as None, so proceed. 

3256 _, levels, _ = _colorbar_map_levels(cube, axis="y") 

3257 if levels is None: 

3258 break 

3259 # If levels is changed, recheck to use the vmin,vmax or 

3260 # levels-based ranges for histogram plots. 

3261 _, levels, _ = _colorbar_map_levels(cube) 

3262 logging.debug("levels: %s", levels) 

3263 if levels is not None: 3263 ↛ 3253line 3263 didn't jump to line 3253 because the condition on line 3263 was always true

3264 vmin = min(levels) 

3265 vmax = max(levels) 

3266 logging.debug("Updated vmin, vmax: %s, %s", vmin, vmax) 

3267 break 

3268 

3269 if levels is None: 

3270 vmin = min(cb.data.min() for cb in cubes) 

3271 vmax = max(cb.data.max() for cb in cubes) 

3272 

3273 # Make postage stamp plots if stamp_coordinate exists and has more than a 

3274 # single point. If single_plot is True: 

3275 # -- all postage stamp plots will be plotted in a single plot instead of 

3276 # separate postage stamp plots. 

3277 # -- model names (hidden in cube attrs) are ignored, that is stamp plots are 

3278 # produced per single model only 

3279 if num_models == 1: 3279 ↛ 3292line 3279 didn't jump to line 3292 because the condition on line 3279 was always true

3280 if ( 3280 ↛ 3284line 3280 didn't jump to line 3284 because the condition on line 3280 was never true

3281 stamp_coordinate in [c.name() for c in cubes[0].coords()] 

3282 and cubes[0].coord(stamp_coordinate).shape[0] > 1 

3283 ): 

3284 if single_plot: 

3285 plotting_func = ( 

3286 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

3287 ) 

3288 else: 

3289 plotting_func = _plot_and_save_postage_stamp_histogram_series 

3290 cube_iterables = cubes[0].slices_over(sequence_coordinate) 

3291 else: 

3292 all_points = sorted( 

3293 set( 

3294 itertools.chain.from_iterable( 

3295 cb.coord(sequence_coordinate).points for cb in cubes 

3296 ) 

3297 ) 

3298 ) 

3299 all_slices = list( 

3300 itertools.chain.from_iterable( 

3301 cb.slices_over(sequence_coordinate) for cb in cubes 

3302 ) 

3303 ) 

3304 # Matched slices (matched by seq coord point; it may happen that 

3305 # evaluated models do not cover the same seq coord range, hence matching 

3306 # necessary) 

3307 cube_iterables = [ 

3308 iris.cube.CubeList( 

3309 s for s in all_slices if s.coord(sequence_coordinate).points[0] == point 

3310 ) 

3311 for point in all_points 

3312 ] 

3313 

3314 plot_index = [] 

3315 nplot = np.size(cube.coord(sequence_coordinate).points) 

3316 # Create a plot for each value of the sequence coordinate. Allowing for 

3317 # multiple cubes in a CubeList to be plotted in the same plot for similar 

3318 # sequence values. Passing a CubeList into the internal plotting function 

3319 # for similar values of the sequence coordinate. cube_slice can be an 

3320 # iris.cube.Cube or an iris.cube.CubeList. 

3321 for cube_slice in cube_iterables: 

3322 single_cube = cube_slice 

3323 if isinstance(cube_slice, iris.cube.CubeList): 3323 ↛ 3324line 3323 didn't jump to line 3324 because the condition on line 3323 was never true

3324 single_cube = cube_slice[0] 

3325 

3326 # Ensure valid stamp coordinate in cube dimensions 

3327 if stamp_coordinate == "realization": 3327 ↛ 3330line 3327 didn't jump to line 3330 because the condition on line 3327 was always true

3328 stamp_coordinate = check_stamp_coordinate(single_cube) 

3329 # Set plot titles and filename, based on sequence coordinate 

3330 seq_coord = single_cube.coord(sequence_coordinate) 

3331 # Use time coordinate in title and filename if single histogram output. 

3332 if sequence_coordinate == "realization" and nplot == 1: 3332 ↛ 3333line 3332 didn't jump to line 3333 because the condition on line 3332 was never true

3333 seq_coord = single_cube.coord("time") 

3334 plot_title, plot_filename = _set_title_and_filename( 

3335 seq_coord, nplot, recipe_title, filename 

3336 ) 

3337 

3338 # Do the actual plotting. 

3339 plotting_func( 

3340 cube_slice, 

3341 filename=plot_filename, 

3342 stamp_coordinate=stamp_coordinate, 

3343 title=plot_title, 

3344 vmin=vmin, 

3345 vmax=vmax, 

3346 ) 

3347 plot_index.append(plot_filename) 

3348 

3349 # Add list of plots to plot metadata. 

3350 complete_plot_index = _append_to_plot_index(plot_index) 

3351 

3352 # Make a page to display the plots. 

3353 _make_plot_html_page(complete_plot_index) 

3354 

3355 return cubes 

3356 

3357 

3358def plot_power_spectrum_series( 

3359 cubes: iris.cube.Cube | iris.cube.CubeList, 

3360 filename: str = None, 

3361 sequence_coordinate: str = "time", 

3362 stamp_coordinate: str = "realization", 

3363 single_plot: bool = False, 

3364 **kwargs, 

3365) -> iris.cube.Cube | iris.cube.CubeList: 

3366 """Plot a power spectrum plot for each vertical level provided. 

3367 

3368 A power spectrum plot can be plotted, but if the sequence_coordinate (i.e. time) 

3369 is present then a sequence of plots will be produced using the time slider 

3370 functionality to scroll through power spectrum against time. If a 

3371 stamp_coordinate is present then postage stamp plots will be produced. If 

3372 stamp_coordinate and single_plot is True, all postage stamp plots will be 

3373 plotted in a single plot instead of separate postage stamp plots. 

3374 

3375 Parameters 

3376 ---------- 

3377 cubes: Cube | iris.cube.CubeList 

3378 Iris cube or CubeList of the data to plot. It should have a single dimension other 

3379 than the stamp coordinate. 

3380 The cubes should cover the same phenomenon i.e. all cubes contain temperature data. 

3381 We do not support different data such as temperature and humidity in the same CubeList for plotting. 

3382 filename: str, optional 

3383 Name of the plot to write, used as a prefix for plot sequences. Defaults 

3384 to the recipe name. 

3385 sequence_coordinate: str, optional 

3386 Coordinate about which to make a plot sequence. Defaults to ``"time"``. 

3387 This coordinate must exist in the cube and will be used for the time 

3388 slider. 

3389 stamp_coordinate: str, optional 

3390 Coordinate about which to plot postage stamp plots. Defaults to 

3391 ``"realization"``. 

3392 single_plot: bool, optional 

3393 If True, all postage stamp plots will be plotted in a single plot. If 

3394 False, each postage stamp plot will be plotted separately. Is only valid 

3395 if stamp_coordinate exists and has more than a single point. 

3396 

3397 Returns 

3398 ------- 

3399 iris.cube.Cube | iris.cube.CubeList 

3400 The original Cube or CubeList (so further operations can be applied). 

3401 Plotted data. 

3402 

3403 Raises 

3404 ------ 

3405 ValueError 

3406 If the cube doesn't have the right dimensions. 

3407 TypeError 

3408 If the cube isn't a Cube or CubeList. 

3409 """ 

3410 recipe_title = get_recipe_metadata().get("title", "Untitled") 

3411 

3412 cubes = iter_maybe(cubes) 

3413 

3414 # Internal plotting function. 

3415 plotting_func = _plot_and_save_power_spectrum_series 

3416 

3417 num_models = _get_num_models(cubes) 

3418 

3419 _validate_cube_shape(cubes, num_models) 

3420 

3421 # If several power spectra are plotted with time as sequence_coordinate for the 

3422 # time slider option. 

3423 for cube in cubes: 

3424 try: 

3425 cube.coord(sequence_coordinate) 

3426 except iris.exceptions.CoordinateNotFoundError as err: 

3427 raise ValueError( 

3428 f"Cube must have a {sequence_coordinate} coordinate." 

3429 ) from err 

3430 

3431 # Make postage stamp plots if stamp_coordinate exists and has more than a 

3432 # single point. If single_plot is True: 

3433 # -- all postage stamp plots will be plotted in a single plot instead of 

3434 # separate postage stamp plots. 

3435 # -- model names (hidden in cube attrs) are ignored, that is stamp plots are 

3436 # produced per single model only 

3437 if num_models == 1: 3437 ↛ 3450line 3437 didn't jump to line 3450 because the condition on line 3437 was always true

3438 if ( 3438 ↛ 3442line 3438 didn't jump to line 3442 because the condition on line 3438 was never true

3439 stamp_coordinate in [c.name() for c in cubes[0].coords()] 

3440 and cubes[0].coord(stamp_coordinate).shape[0] > 1 

3441 ): 

3442 if single_plot: 

3443 plotting_func = ( 

3444 _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series 

3445 ) 

3446 else: 

3447 plotting_func = _plot_and_save_postage_stamp_power_spectrum_series 

3448 cube_iterables = cubes[0].slices_over(sequence_coordinate) 

3449 else: 

3450 all_points = sorted( 

3451 set( 

3452 itertools.chain.from_iterable( 

3453 cb.coord(sequence_coordinate).points for cb in cubes 

3454 ) 

3455 ) 

3456 ) 

3457 all_slices = list( 

3458 itertools.chain.from_iterable( 

3459 cb.slices_over(sequence_coordinate) for cb in cubes 

3460 ) 

3461 ) 

3462 # Matched slices (matched by seq coord point; it may happen that 

3463 # evaluated models do not cover the same seq coord range, hence matching 

3464 # necessary) 

3465 cube_iterables = [ 

3466 iris.cube.CubeList( 

3467 s for s in all_slices if s.coord(sequence_coordinate).points[0] == point 

3468 ) 

3469 for point in all_points 

3470 ] 

3471 

3472 plot_index = [] 

3473 nplot = np.size(cube.coord(sequence_coordinate).points) 

3474 # Create a plot for each value of the sequence coordinate. Allowing for 

3475 # multiple cubes in a CubeList to be plotted in the same plot for similar 

3476 # sequence values. Passing a CubeList into the internal plotting function 

3477 # for similar values of the sequence coordinate. cube_slice can be an 

3478 # iris.cube.Cube or an iris.cube.CubeList. 

3479 for cube_slice in cube_iterables: 

3480 single_cube = cube_slice 

3481 if isinstance(cube_slice, iris.cube.CubeList): 3481 ↛ 3482line 3481 didn't jump to line 3482 because the condition on line 3481 was never true

3482 single_cube = cube_slice[0] 

3483 

3484 # Set stamp coordinate 

3485 if stamp_coordinate == "realization": 3485 ↛ 3488line 3485 didn't jump to line 3488 because the condition on line 3485 was always true

3486 stamp_coordinate = check_stamp_coordinate(single_cube) 

3487 # Set plot title and filenames based on sequence values 

3488 seq_coord = single_cube.coord(sequence_coordinate) 

3489 plot_title, plot_filename = _set_title_and_filename( 

3490 seq_coord, nplot, recipe_title, filename 

3491 ) 

3492 

3493 # Do the actual plotting. 

3494 plotting_func( 

3495 cube_slice, 

3496 filename=plot_filename, 

3497 stamp_coordinate=stamp_coordinate, 

3498 title=plot_title, 

3499 ) 

3500 plot_index.append(plot_filename) 

3501 

3502 # Add list of plots to plot metadata. 

3503 complete_plot_index = _append_to_plot_index(plot_index) 

3504 

3505 # Make a page to display the plots. 

3506 _make_plot_html_page(complete_plot_index) 

3507 

3508 return cubes 

3509 

3510 

3511def _DCT_ps(y_3d): 

3512 """Calculate power spectra for regional domains. 

3513 

3514 Parameters 

3515 ---------- 

3516 y_3d: 3D array 

3517 3 dimensional array to calculate spectrum for. 

3518 (2D field data with 3rd dimension of time) 

3519 

3520 Returns: ps_array 

3521 Array of power spectra values calculated for input field (for each time) 

3522 

3523 Method for regional domains: 

3524 Calculate power spectra over limited area domain using Discrete Cosine Transform (DCT) 

3525 as described in Denis et al 2002 [Denis_etal_2002]_. 

3526 

3527 References 

3528 ---------- 

3529 .. [Denis_etal_2002] Bertrand Denis, Jean Côté and René Laprise (2002) 

3530 "Spectral Decomposition of Two-Dimensional Atmospheric Fields on 

3531 Limited-Area Domains Using the Discrete Cosine Transform (DCT)" 

3532 Monthly Weather Review, Vol. 130, 1812-1828 

3533 doi: https://doi.org/10.1175/1520-0493(2002)130<1812:SDOTDA>2.0.CO;2 

3534 """ 

3535 Nt, Ny, Nx = y_3d.shape 

3536 

3537 # Max coefficient 

3538 Nmin = min(Nx - 1, Ny - 1) 

3539 

3540 # Create alpha matrix (of wavenumbers) 

3541 alpha_matrix = _create_alpha_matrix(Ny, Nx) 

3542 

3543 # Prepare output array 

3544 ps_array = np.zeros((Nt, Nmin)) 

3545 

3546 # Loop over time to get spectrum for each time. 

3547 for t in range(Nt): 

3548 y_2d = y_3d[t] 

3549 

3550 # Apply 2D DCT to transform y_3d[t] from physical space to spectral space. 

3551 # fkk is a 2D array of DCT coefficients, representing the amplitudes of 

3552 # cosine basis functions at different spatial frequencies. 

3553 

3554 # normalise spectrum to allow comparison between models. 

3555 fkk = fft.dctn(y_2d, norm="ortho") 

3556 

3557 # Normalise fkk 

3558 fkk = fkk / np.sqrt(Ny * Nx) 

3559 

3560 # calculate variance of spectral coefficient 

3561 sigma_2 = fkk**2 / Nx / Ny 

3562 

3563 # Group ellipses of alphas into the same wavenumber k/Nmin 

3564 for k in range(1, Nmin + 1): 

3565 alpha = k / Nmin 

3566 alpha_p1 = (k + 1) / Nmin 

3567 

3568 # Sum up elements matching k 

3569 mask_k = np.where((alpha_matrix >= alpha) & (alpha_matrix < alpha_p1)) 

3570 ps_array[t, k - 1] = np.sum(sigma_2[mask_k]) 

3571 

3572 return ps_array 

3573 

3574 

3575def _create_alpha_matrix(Ny, Nx): 

3576 """Construct an array of 2D wavenumbers from 2D wavenumber pair. 

3577 

3578 Parameters 

3579 ---------- 

3580 Ny, Nx: 

3581 Dimensions of the 2D field for which the power spectra is calculated. Used to 

3582 create the array of 2D wavenumbers. Each Ny, Nx pair is associated with a 

3583 single-scale parameter. 

3584 

3585 Returns: alpha_matrix 

3586 normalisation of 2D wavenumber axes, transforming the spectral domain into 

3587 an elliptic coordinate system. 

3588 

3589 """ 

3590 # Create x_indices: each row is [1, 2, ..., Nx] 

3591 x_indices = np.tile(np.arange(1, Nx + 1), (Ny, 1)) 

3592 

3593 # Create y_indices: each column is [1, 2, ..., Ny] 

3594 y_indices = np.tile(np.arange(1, Ny + 1).reshape(Ny, 1), (1, Nx)) 

3595 

3596 # Compute alpha_matrix 

3597 alpha_matrix = np.sqrt((x_indices**2) / Nx**2 + (y_indices**2) / Ny**2) 

3598 

3599 return alpha_matrix