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

1085 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-13 07:38 +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_precipitation(cube, cmap, levels, norm) 

350 cmap, levels, norm = _custom_colourmap_visibility_in_air( 

351 cube, cmap, levels, norm 

352 ) 

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

354 return cmap, levels, norm 

355 

356 

357def _setup_spatial_map( 

358 cube: iris.cube.Cube, 

359 figure, 

360 cmap, 

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

362 subplot: int | None = None, 

363): 

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

365 

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

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

368 

369 Parameters 

370 ---------- 

371 cube: Cube 

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

373 figure: 

374 Matplotlib Figure object holding all plot elements. 

375 cmap: 

376 Matplotlib colormap. 

377 grid_size: (int, int), optional 

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

379 subplot: int, optional 

380 Subplot index if multiple spatial subplots in figure. 

381 

382 Returns 

383 ------- 

384 axes: 

385 Matplotlib GeoAxes definition. 

386 """ 

387 # Identify min/max plot bounds. 

388 try: 

389 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

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

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

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

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

394 

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

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

397 x1 = x1 - 180.0 

398 x2 = x2 - 180.0 

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

400 

401 # Consider map projection orientation. 

402 # Adapting orientation enables plotting across international dateline. 

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

404 if x2 > 180.0 or x1 < -180.0: 

405 central_longitude = 180.0 

406 else: 

407 central_longitude = 0.0 

408 

409 # Define spatial map projection. 

410 coord_system = cube.coord(lat_axis).coord_system 

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

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

413 projection = ccrs.RotatedPole( 

414 pole_longitude=coord_system.grid_north_pole_longitude, 

415 pole_latitude=coord_system.grid_north_pole_latitude, 

416 central_rotated_longitude=central_longitude, 

417 ) 

418 crs = projection 

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

420 # Define Transverse Mercator projection for TM inputs. 

421 projection = ccrs.TransverseMercator( 

422 central_longitude=coord_system.longitude_of_central_meridian, 

423 central_latitude=coord_system.latitude_of_projection_origin, 

424 false_easting=coord_system.false_easting, 

425 false_northing=coord_system.false_northing, 

426 scale_factor=coord_system.scale_factor_at_central_meridian, 

427 ) 

428 crs = projection 

429 else: 

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

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

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

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

434 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

435 crs = ccrs.PlateCarree() 

436 

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

438 if subplot is not None: 

439 axes = figure.add_subplot( 

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

441 ) 

442 else: 

443 axes = figure.add_subplot(projection=projection) 

444 

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

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

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

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

449 ): 

450 pass 

451 else: 

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

453 coastcol = "magenta" 

454 else: 

455 coastcol = "black" 

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

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

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

459 

460 # Add gridlines. 

461 gl = axes.gridlines( 

462 alpha=0.3, 

463 draw_labels=True, 

464 dms=False, 

465 x_inline=False, 

466 y_inline=False, 

467 ) 

468 gl.top_labels = False 

469 gl.right_labels = False 

470 if subplot: 

471 gl.bottom_labels = False 

472 gl.left_labels = False 

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

474 gl.left_labels = True 

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

476 gl.bottom_labels = True 

477 

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

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

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

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

482 

483 except ValueError: 

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

485 axes = figure.gca() 

486 pass 

487 

488 return axes 

489 

490 

491def _get_plot_resolution() -> int: 

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

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

494 

495 

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

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

498 if use_bounds and seq_coord.has_bounds(): 

499 vals = seq_coord.bounds.flatten() 

500 else: 

501 vals = seq_coord.points 

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

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

504 

505 if start == end: 

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

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

508 else: 

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

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

511 

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

513 if ( 

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

515 and vals[0] == 0 

516 and vals[-1] == 0 

517 ): 

518 sequence_title = "" 

519 sequence_fname = "" 

520 

521 return sequence_title, sequence_fname 

522 

523 

524def _set_title_and_filename( 

525 seq_coord: iris.coords.Coord, 

526 nplot: int, 

527 recipe_title: str, 

528 filename: str, 

529): 

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

531 

532 Parameters 

533 ---------- 

534 sequence_coordinate: iris.coords.Coord 

535 Coordinate about which to make a plot sequence. 

536 nplot: int 

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

538 recipe_title: str 

539 Default plot title, potentially to update. 

540 filename: str 

541 Input plot filename, potentially to update. 

542 

543 Returns 

544 ------- 

545 plot_title: str 

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

547 plot_filename: str 

548 Output formatted plot filename string. 

549 """ 

550 ndim = seq_coord.ndim 

551 npoints = np.size(seq_coord.points) 

552 sequence_title = "" 

553 sequence_fname = "" 

554 

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

556 # (e.g. aggregation histogram plots) 

557 if ndim > 1: 

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

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

560 sequence_fname = f"_{ncase}cases" 

561 

562 # Case 2: Single dimension input 

563 else: 

564 # Single sequence point 

565 if npoints == 1: 

566 if nplot > 1: 

567 # Default labels for sequence inputs 

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

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

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

571 else: 

572 # Aggregated attribute available where input collapsed over aggregation 

573 try: 

574 ncase = seq_coord.attributes["number_reference_times"] 

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

576 sequence_fname = f"_{ncase}cases" 

577 except KeyError: 

578 sequence_title, sequence_fname = _get_start_end_strings( 

579 seq_coord, use_bounds=seq_coord.has_bounds() 

580 ) 

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

582 else: 

583 sequence_title, sequence_fname = _get_start_end_strings( 

584 seq_coord, use_bounds=False 

585 ) 

586 

587 # Set plot title and filename 

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

589 

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

591 if filename is None: 

592 filename = slugify(recipe_title) 

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

594 else: 

595 if nplot > 1: 

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

597 else: 

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

599 

600 return plot_title, plot_filename 

601 

602 

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

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

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

606 mtitle = "Member" 

607 else: 

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

609 

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

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

612 else: 

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

614 

615 return mtitle 

616 

617 

618def _plot_and_save_spatial_plot( 

619 cube: iris.cube.Cube, 

620 filename: str, 

621 title: str, 

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

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

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

625 **kwargs, 

626): 

627 """Plot and save a spatial plot. 

628 

629 Parameters 

630 ---------- 

631 cube: Cube 

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

633 filename: str 

634 Filename of the plot to write. 

635 title: str 

636 Plot title. 

637 method: "contourf" | "pcolormesh" 

638 The plotting method to use. 

639 overlay_cube: Cube, optional 

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

641 contour_cube: Cube, optional 

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

643 """ 

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

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

646 

647 # Specify the color bar 

648 cmap, levels, norm = _colorbar_map_levels(cube) 

649 

650 # If overplotting, set required colorbars 

651 if overlay_cube: 

652 over_cmap, over_levels, over_norm = _colorbar_map_levels(overlay_cube) 

653 if contour_cube: 

654 cntr_cmap, cntr_levels, cntr_norm = _colorbar_map_levels(contour_cube) 

655 

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

657 axes = _setup_spatial_map(cube, fig, cmap) 

658 

659 # Plot the field. 

660 if method == "contourf": 

661 # Filled contour plot of the field. 

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

663 elif method == "pcolormesh": 

664 try: 

665 vmin = min(levels) 

666 vmax = max(levels) 

667 except TypeError: 

668 vmin, vmax = None, None 

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

670 # if levels are defined. 

671 if norm is not None: 

672 vmin = None 

673 vmax = None 

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

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

676 else: 

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

678 

679 # Overplot overlay field, if required 

680 if overlay_cube: 

681 try: 

682 over_vmin = min(over_levels) 

683 over_vmax = max(over_levels) 

684 except TypeError: 

685 over_vmin, over_vmax = None, None 

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

687 over_vmin = None 

688 over_vmax = None 

689 overlay = iplt.pcolormesh( 

690 overlay_cube, 

691 cmap=over_cmap, 

692 norm=over_norm, 

693 alpha=0.8, 

694 vmin=over_vmin, 

695 vmax=over_vmax, 

696 ) 

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

698 if contour_cube: 

699 contour = iplt.contour( 

700 contour_cube, 

701 colors="darkgray", 

702 levels=cntr_levels, 

703 norm=cntr_norm, 

704 alpha=0.5, 

705 linestyles="--", 

706 linewidths=1, 

707 ) 

708 plt.clabel(contour) 

709 

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

711 if is_transect(cube): 

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

713 axes.invert_yaxis() 

714 axes.set_yscale("log") 

715 axes.set_ylim(1100, 100) 

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

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

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

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

720 ): 

721 axes.set_yscale("log") 

722 

723 axes.set_title( 

724 f"{title}\n" 

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

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

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

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

729 fontsize=16, 

730 ) 

731 

732 # Inset code 

733 axins = inset_axes( 

734 axes, 

735 width="20%", 

736 height="20%", 

737 loc="upper right", 

738 axes_class=GeoAxes, 

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

740 ) 

741 

742 # Slightly transparent to reduce plot blocking. 

743 axins.patch.set_alpha(0.4) 

744 

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

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

747 

748 SLat, SLon, ELat, ELon = ( 

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

750 ) 

751 

752 # Draw line between them 

753 axins.plot( 

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

755 ) 

756 

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

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

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

760 

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

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

763 

764 # Midpoints 

765 lon_mid = (lon_min + lon_max) / 2 

766 lat_mid = (lat_min + lat_max) / 2 

767 

768 # Maximum half-range 

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

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

771 half_range = 1 

772 

773 # Set square extent 

774 axins.set_extent( 

775 [ 

776 lon_mid - half_range, 

777 lon_mid + half_range, 

778 lat_mid - half_range, 

779 lat_mid + half_range, 

780 ], 

781 crs=ccrs.PlateCarree(), 

782 ) 

783 

784 # Ensure square aspect 

785 axins.set_aspect("equal") 

786 

787 else: 

788 # Add title. 

789 axes.set_title(title, fontsize=16) 

790 

791 # Adjust padding if spatial plot or transect 

792 if is_transect(cube): 

793 yinfopad = -0.1 

794 ycbarpad = 0.1 

795 else: 

796 yinfopad = 0.01 

797 ycbarpad = 0.042 

798 

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

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

801 axes.annotate( 

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

803 xy=(0.025, yinfopad), 

804 xycoords="axes fraction", 

805 xytext=(-5, 5), 

806 textcoords="offset points", 

807 ha="left", 

808 va="bottom", 

809 size=11, 

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

811 ) 

812 

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

814 if overlay_cube: 

815 cbarB = fig.colorbar( 

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

817 ) 

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

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

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

821 cbarB.set_ticks(over_levels) 

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

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

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

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

826 

827 # Add main colour bar. 

828 cbar = fig.colorbar( 

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

830 ) 

831 

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

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

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

835 cbar.set_ticks(levels) 

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

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

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

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

840 

841 # Save plot. 

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

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

844 plt.close(fig) 

845 

846 

847def _plot_and_save_postage_stamp_spatial_plot( 

848 cube: iris.cube.Cube, 

849 filename: str, 

850 stamp_coordinate: str, 

851 title: str, 

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

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

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

855 **kwargs, 

856): 

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

858 

859 Parameters 

860 ---------- 

861 cube: Cube 

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

863 filename: str 

864 Filename of the plot to write. 

865 stamp_coordinate: str 

866 Coordinate that becomes different plots. 

867 method: "contourf" | "pcolormesh" 

868 The plotting method to use. 

869 overlay_cube: Cube, optional 

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

871 contour_cube: Cube, optional 

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

873 

874 Raises 

875 ------ 

876 ValueError 

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

878 """ 

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

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

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

882 grid_size = math.ceil(nmember / grid_rows) 

883 

884 fig = plt.figure( 

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

886 ) 

887 

888 # Specify the color bar 

889 cmap, levels, norm = _colorbar_map_levels(cube) 

890 # If overplotting, set required colorbars 

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

892 over_cmap, over_levels, over_norm = _colorbar_map_levels(overlay_cube) 

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

894 cntr_cmap, cntr_levels, cntr_norm = _colorbar_map_levels(contour_cube) 

895 

896 # Make a subplot for each member. 

897 for member, subplot in zip( 

898 cube.slices_over(stamp_coordinate), 

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

900 strict=False, 

901 ): 

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

903 axes = _setup_spatial_map( 

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

905 ) 

906 if method == "contourf": 

907 # Filled contour plot of the field. 

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

909 elif method == "pcolormesh": 

910 if levels is not None: 

911 vmin = min(levels) 

912 vmax = max(levels) 

913 else: 

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

915 vmin, vmax = None, None 

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

917 # if levels are defined. 

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

919 vmin = None 

920 vmax = None 

921 # pcolormesh plot of the field. 

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

923 else: 

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

925 

926 # Overplot overlay field, if required 

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

928 try: 

929 over_vmin = min(over_levels) 

930 over_vmax = max(over_levels) 

931 except TypeError: 

932 over_vmin, over_vmax = None, None 

933 if over_norm is not None: 

934 over_vmin = None 

935 over_vmax = None 

936 iplt.pcolormesh( 

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

938 cmap=over_cmap, 

939 norm=over_norm, 

940 alpha=0.6, 

941 vmin=over_vmin, 

942 vmax=over_vmax, 

943 ) 

944 # Overplot contour field, if required 

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

946 iplt.contour( 

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

948 colors="darkgray", 

949 levels=cntr_levels, 

950 norm=cntr_norm, 

951 alpha=0.6, 

952 linestyles="--", 

953 linewidths=1, 

954 ) 

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

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

957 

958 # Put the shared colorbar in its own axes. 

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

960 colorbar = fig.colorbar( 

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

962 ) 

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

964 

965 # Overall figure title. 

966 fig.suptitle(title, fontsize=16) 

967 

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

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

970 plt.close(fig) 

971 

972 

973def _plot_and_save_line_series( 

974 cubes: iris.cube.CubeList, 

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

976 ensemble_coord: str, 

977 filename: str, 

978 title: str, 

979 **kwargs, 

980): 

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

982 

983 Parameters 

984 ---------- 

985 cubes: Cube or CubeList 

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

987 coords: list[Coord] 

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

989 ensemble_coord: str 

990 Ensemble coordinate in the cube. 

991 filename: str 

992 Filename of the plot to write. 

993 title: str 

994 Plot title. 

995 """ 

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

997 

998 model_colors_map = _get_model_colors_map(cubes) 

999 

1000 # Store min/max ranges. 

1001 y_levels = [] 

1002 

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

1004 _validate_cubes_coords(cubes, coords) 

1005 

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

1007 label = None 

1008 color = "black" 

1009 if model_colors_map: 

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

1011 color = model_colors_map.get(label) 

1012 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

1015 iplt.plot( 

1016 coord, 

1017 cube_slice, 

1018 color=color, 

1019 marker="o", 

1020 ls="-", 

1021 lw=3, 

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

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

1024 else label, 

1025 ) 

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

1027 else: 

1028 iplt.plot( 

1029 coord, 

1030 cube_slice, 

1031 color=color, 

1032 ls="-", 

1033 lw=1.5, 

1034 alpha=0.75, 

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

1036 ) 

1037 

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

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

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

1041 y_levels.append(min(levels)) 

1042 y_levels.append(max(levels)) 

1043 

1044 # Get the current axes. 

1045 ax = plt.gca() 

1046 

1047 # Add some labels and tweak the style. 

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

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

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

1051 else: 

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

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

1054 ax.set_title(title, fontsize=16) 

1055 

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

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

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

1059 

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

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

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

1063 # Add zero line. 

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

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

1066 logging.debug( 

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

1068 ) 

1069 else: 

1070 ax.autoscale() 

1071 

1072 # Add gridlines 

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

1074 # Ientify unique labels for legend 

1075 handles = list( 

1076 { 

1077 label: handle 

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

1079 }.values() 

1080 ) 

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

1082 

1083 # Save plot. 

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

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

1086 plt.close(fig) 

1087 

1088 

1089def _plot_and_save_vertical_line_series( 

1090 cubes: iris.cube.CubeList, 

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

1092 ensemble_coord: str, 

1093 filename: str, 

1094 series_coordinate: str, 

1095 title: str, 

1096 vmin: float, 

1097 vmax: float, 

1098 **kwargs, 

1099): 

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

1101 

1102 Parameters 

1103 ---------- 

1104 cubes: CubeList 

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

1106 coord: list[Coord] 

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

1108 ensemble_coord: str 

1109 Ensemble coordinate in the cube. 

1110 filename: str 

1111 Filename of the plot to write. 

1112 series_coordinate: str 

1113 Coordinate to use as vertical axis. 

1114 title: str 

1115 Plot title. 

1116 vmin: float 

1117 Minimum value for the x-axis. 

1118 vmax: float 

1119 Maximum value for the x-axis. 

1120 """ 

1121 # plot the vertical pressure axis using log scale 

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

1123 

1124 model_colors_map = _get_model_colors_map(cubes) 

1125 

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

1127 _validate_cubes_coords(cubes, coords) 

1128 

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

1130 label = None 

1131 color = "black" 

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

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

1134 color = model_colors_map.get(label) 

1135 

1136 for cube_slice in cube.slices_over(ensemble_coord): 

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

1138 # unless single forecast. 

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

1140 iplt.plot( 

1141 cube_slice, 

1142 coord, 

1143 color=color, 

1144 marker="o", 

1145 ls="-", 

1146 lw=3, 

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

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

1149 else label, 

1150 ) 

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

1152 else: 

1153 iplt.plot( 

1154 cube_slice, 

1155 coord, 

1156 color=color, 

1157 ls="-", 

1158 lw=1.5, 

1159 alpha=0.75, 

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

1161 ) 

1162 

1163 # Get the current axis 

1164 ax = plt.gca() 

1165 

1166 # Special handling for pressure level data. 

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

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

1169 ax.invert_yaxis() 

1170 ax.set_yscale("log") 

1171 

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

1173 y_tick_labels = [ 

1174 "1000", 

1175 "850", 

1176 "700", 

1177 "500", 

1178 "300", 

1179 "200", 

1180 "100", 

1181 ] 

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

1183 

1184 # Set y-axis limits and ticks. 

1185 ax.set_ylim(1100, 100) 

1186 

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

1188 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

1194 

1195 ax.set_yticks(y_ticks) 

1196 ax.set_yticklabels(y_tick_labels) 

1197 

1198 # Set x-axis limits. 

1199 ax.set_xlim(vmin, vmax) 

1200 # Mark y=0 if present in plot. 

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

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

1203 

1204 # Add some labels and tweak the style. 

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

1206 ax.set_xlabel( 

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

1208 ) 

1209 ax.set_title(title, fontsize=16) 

1210 ax.ticklabel_format(axis="x") 

1211 ax.tick_params(axis="y") 

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

1213 

1214 # Add gridlines 

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

1216 # Ientify unique labels for legend 

1217 handles = list( 

1218 { 

1219 label: handle 

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

1221 }.values() 

1222 ) 

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

1224 

1225 # Save plot. 

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

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

1228 plt.close(fig) 

1229 

1230 

1231def _plot_and_save_scatter_plot( 

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

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

1234 filename: str, 

1235 title: str, 

1236 one_to_one: bool, 

1237 model_names: list[str] = None, 

1238 **kwargs, 

1239): 

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

1241 

1242 Parameters 

1243 ---------- 

1244 cube_x: Cube | CubeList 

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

1246 cube_y: Cube | CubeList 

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

1248 filename: str 

1249 Filename of the plot to write. 

1250 title: str 

1251 Plot title. 

1252 one_to_one: bool 

1253 Whether a 1:1 line is plotted. 

1254 """ 

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

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

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

1258 # over the pairs simultaneously. 

1259 

1260 # Ensure cube_x and cube_y are iterable 

1261 cube_x_iterable = iter_maybe(cube_x) 

1262 cube_y_iterable = iter_maybe(cube_y) 

1263 

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

1265 iplt.scatter(cube_x_iter, cube_y_iter) 

1266 if one_to_one is True: 

1267 plt.plot( 

1268 [ 

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

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

1271 ], 

1272 [ 

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

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

1275 ], 

1276 "k", 

1277 linestyle="--", 

1278 ) 

1279 ax = plt.gca() 

1280 

1281 # Add some labels and tweak the style. 

1282 if model_names is None: 

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

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

1285 else: 

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

1287 ax.set_xlabel( 

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

1289 ) 

1290 ax.set_ylabel( 

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

1292 ) 

1293 ax.set_title(title, fontsize=16) 

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

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

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

1297 ax.autoscale() 

1298 

1299 # Save plot. 

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

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

1302 plt.close(fig) 

1303 

1304 

1305def _plot_and_save_vector_plot( 

1306 cube_u: iris.cube.Cube, 

1307 cube_v: iris.cube.Cube, 

1308 filename: str, 

1309 title: str, 

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

1311 **kwargs, 

1312): 

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

1314 

1315 Parameters 

1316 ---------- 

1317 cube_u: Cube 

1318 2 dimensional Cube of u component of the data. 

1319 cube_v: Cube 

1320 2 dimensional Cube of v component of the data. 

1321 filename: str 

1322 Filename of the plot to write. 

1323 title: str 

1324 Plot title. 

1325 """ 

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

1327 

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

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

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

1331 

1332 # Specify the color bar 

1333 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag) 

1334 

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

1336 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

1337 

1338 if method == "contourf": 

1339 # Filled contour plot of the field. 

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

1341 elif method == "pcolormesh": 

1342 try: 

1343 vmin = min(levels) 

1344 vmax = max(levels) 

1345 except TypeError: 

1346 vmin, vmax = None, None 

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

1348 # if levels are defined. 

1349 if norm is not None: 

1350 vmin = None 

1351 vmax = None 

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

1353 else: 

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

1355 

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

1357 if is_transect(cube_vec_mag): 

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

1359 axes.invert_yaxis() 

1360 axes.set_yscale("log") 

1361 axes.set_ylim(1100, 100) 

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

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

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

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

1366 ): 

1367 axes.set_yscale("log") 

1368 

1369 axes.set_title( 

1370 f"{title}\n" 

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

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

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

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

1375 fontsize=16, 

1376 ) 

1377 

1378 else: 

1379 # Add title. 

1380 axes.set_title(title, fontsize=16) 

1381 

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

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

1384 axes.annotate( 

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

1386 xy=(0.05, -0.05), 

1387 xycoords="axes fraction", 

1388 xytext=(-5, 5), 

1389 textcoords="offset points", 

1390 ha="right", 

1391 va="bottom", 

1392 size=11, 

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

1394 ) 

1395 

1396 # Add colour bar. 

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

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

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

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

1401 cbar.set_ticks(levels) 

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

1403 

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

1405 # with less than 30 points. 

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

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

1408 

1409 # Save plot. 

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

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

1412 plt.close(fig) 

1413 

1414 

1415def _plot_and_save_histogram_series( 

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

1417 filename: str, 

1418 title: str, 

1419 vmin: float, 

1420 vmax: float, 

1421 **kwargs, 

1422): 

1423 """Plot and save a histogram series. 

1424 

1425 Parameters 

1426 ---------- 

1427 cubes: Cube or CubeList 

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

1429 filename: str 

1430 Filename of the plot to write. 

1431 title: str 

1432 Plot title. 

1433 vmin: float 

1434 minimum for colorbar 

1435 vmax: float 

1436 maximum for colorbar 

1437 """ 

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

1439 ax = plt.gca() 

1440 

1441 model_colors_map = _get_model_colors_map(cubes) 

1442 

1443 # Set default that histograms will produce probability density function 

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

1445 density = True 

1446 

1447 for cube in iter_maybe(cubes): 

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

1449 # than seeing if long names exist etc. 

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

1451 if "surface_microphysical" in title: 

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

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

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

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

1456 density = False 

1457 else: 

1458 bins = 10.0 ** ( 

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

1460 ) # Suggestion from RMED toolbox. 

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

1462 ax.set_yscale("log") 

1463 vmin = bins[1] 

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

1465 ax.set_xscale("log") 

1466 elif "lightning" in title: 

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

1468 else: 

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

1470 logging.debug( 

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

1472 np.size(bins), 

1473 np.min(bins), 

1474 np.max(bins), 

1475 ) 

1476 

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

1478 # Otherwise we plot xdim histograms stacked. 

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

1480 

1481 label = None 

1482 color = "black" 

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

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

1485 color = model_colors_map[label] 

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

1487 

1488 # Compute area under curve. 

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

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

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

1492 x = x[1:] 

1493 y = y[1:] 

1494 

1495 ax.plot( 

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

1497 ) 

1498 

1499 # Add some labels and tweak the style. 

1500 ax.set_title(title, fontsize=16) 

1501 ax.set_xlabel( 

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

1503 ) 

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

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

1506 ax.set_ylabel( 

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

1508 ) 

1509 try: 

1510 ax.set_xlim(vmin, vmax) 

1511 except ValueError: 

1512 pass 

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

1514 

1515 # Overlay grid-lines onto histogram plot. 

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

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

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

1519 

1520 # Save plot. 

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

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

1523 plt.close(fig) 

1524 

1525 

1526def _plot_and_save_postage_stamp_histogram_series( 

1527 cube: iris.cube.Cube, 

1528 filename: str, 

1529 title: str, 

1530 stamp_coordinate: str, 

1531 vmin: float, 

1532 vmax: float, 

1533 **kwargs, 

1534): 

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

1536 

1537 Parameters 

1538 ---------- 

1539 cube: Cube 

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

1541 filename: str 

1542 Filename of the plot to write. 

1543 title: str 

1544 Plot title. 

1545 stamp_coordinate: str 

1546 Coordinate that becomes different plots. 

1547 vmin: float 

1548 minimum for pdf x-axis 

1549 vmax: float 

1550 maximum for pdf x-axis 

1551 """ 

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

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

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

1555 grid_size = math.ceil(nmember / grid_rows) 

1556 

1557 fig = plt.figure( 

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

1559 ) 

1560 # Make a subplot for each member. 

1561 for member, subplot in zip( 

1562 cube.slices_over(stamp_coordinate), 

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

1564 strict=False, 

1565 ): 

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

1567 # cartopy GeoAxes generated. 

1568 plt.subplot(grid_rows, grid_size, subplot) 

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

1570 # Otherwise we plot xdim histograms stacked. 

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

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

1573 axes = plt.gca() 

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

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

1576 axes.set_xlim(vmin, vmax) 

1577 

1578 # Overall figure title. 

1579 fig.suptitle(title, fontsize=16) 

1580 

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

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

1583 plt.close(fig) 

1584 

1585 

1586def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1587 cube: iris.cube.Cube, 

1588 filename: str, 

1589 title: str, 

1590 stamp_coordinate: str, 

1591 vmin: float, 

1592 vmax: float, 

1593 **kwargs, 

1594): 

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

1596 ax.set_title(title, fontsize=16) 

1597 ax.set_xlim(vmin, vmax) 

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

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

1600 # Loop over all slices along the stamp_coordinate 

1601 for member in cube.slices_over(stamp_coordinate): 

1602 # Flatten the member data to 1D 

1603 member_data_1d = member.data.flatten() 

1604 # Plot the histogram using plt.hist 

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

1606 plt.hist( 

1607 member_data_1d, 

1608 density=True, 

1609 stacked=True, 

1610 label=f"{mtitle}", 

1611 ) 

1612 

1613 # Add a legend 

1614 ax.legend(fontsize=16) 

1615 

1616 # Save the figure to a file 

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

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

1619 

1620 # Close the figure 

1621 plt.close(fig) 

1622 

1623 

1624def _plot_and_save_scattermap_plot( 

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

1626): 

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

1628 

1629 Parameters 

1630 ---------- 

1631 cube: Cube 

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

1633 longitude coordinates, 

1634 filename: str 

1635 Filename of the plot to write. 

1636 title: str 

1637 Plot title. 

1638 projection: str 

1639 Mapping projection to be used by cartopy. 

1640 """ 

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

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

1643 if projection is not None: 

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

1645 # a stereographic projection over the North Pole. 

1646 if projection == "NP_Stereo": 

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

1648 else: 

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

1650 else: 

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

1652 

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

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

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

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

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

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

1659 # proportion to the area of the figure. 

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

1661 klon = None 

1662 klat = None 

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

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

1665 klat = kc 

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

1667 klon = kc 

1668 scatter_map = iplt.scatter( 

1669 cube.aux_coords[klon], 

1670 cube.aux_coords[klat], 

1671 c=cube.data[:], 

1672 s=mrk_size, 

1673 cmap="jet", 

1674 edgecolors="k", 

1675 ) 

1676 

1677 # Add coastlines and borderlines. 

1678 try: 

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

1680 axes.add_feature(cfeature.BORDERS) 

1681 except AttributeError: 

1682 pass 

1683 

1684 # Add title. 

1685 axes.set_title(title, fontsize=16) 

1686 

1687 # Add colour bar. 

1688 cbar = fig.colorbar(scatter_map) 

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

1690 

1691 # Save plot. 

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

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

1694 plt.close(fig) 

1695 

1696 

1697def _plot_and_save_power_spectrum_series( 

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

1699 filename: str, 

1700 title: str, 

1701 **kwargs, 

1702): 

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

1704 

1705 Parameters 

1706 ---------- 

1707 cubes: Cube or CubeList 

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

1709 filename: str 

1710 Filename of the plot to write. 

1711 title: str 

1712 Plot title. 

1713 """ 

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

1715 ax = plt.gca() 

1716 

1717 model_colors_map = _get_model_colors_map(cubes) 

1718 

1719 for cube in iter_maybe(cubes): 

1720 # Calculate power spectrum 

1721 

1722 # Extract time coordinate and convert to datetime 

1723 time_coord = cube.coord("time") 

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

1725 

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

1727 target_time = time_points[0] 

1728 

1729 # Bind target_time inside the lambda using a default argument 

1730 time_constraint = iris.Constraint( 

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

1732 ) 

1733 

1734 cube = cube.extract(time_constraint) 

1735 

1736 if cube.ndim == 2: 

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

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

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

1740 cube_3d = cube.data 

1741 else: 

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

1743 raise ValueError( 

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

1745 ) 

1746 

1747 # Calculate spectra 

1748 ps_array = _DCT_ps(cube_3d) 

1749 

1750 ps_cube = iris.cube.Cube( 

1751 ps_array, 

1752 long_name="power_spectra", 

1753 ) 

1754 

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

1756 

1757 # Create a frequency/wavelength array for coordinate 

1758 ps_len = ps_cube.data.shape[1] 

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

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

1761 

1762 # Convert datetime to numeric time using original units 

1763 numeric_time = time_coord.units.date2num(time_points) 

1764 # Create a new DimCoord with numeric time 

1765 new_time_coord = iris.coords.DimCoord( 

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

1767 ) 

1768 

1769 # Add time and frequency coordinate to spectra cube. 

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

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

1772 

1773 # Extract data from the cube 

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

1775 power_spectrum = ps_cube.data 

1776 

1777 label = None 

1778 color = "black" 

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

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

1781 color = model_colors_map[label] 

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

1783 

1784 # Add some labels and tweak the style. 

1785 ax.set_title(title, fontsize=16) 

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

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

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

1789 

1790 # Set log-log scale 

1791 ax.set_xscale("log") 

1792 ax.set_yscale("log") 

1793 

1794 # Overlay grid-lines onto power spectrum plot. 

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

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

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

1798 

1799 # Save plot. 

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

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

1802 plt.close(fig) 

1803 

1804 

1805def _plot_and_save_postage_stamp_power_spectrum_series( 

1806 cube: iris.cube.Cube, 

1807 filename: str, 

1808 title: str, 

1809 stamp_coordinate: str, 

1810 **kwargs, 

1811): 

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

1813 

1814 Parameters 

1815 ---------- 

1816 cube: Cube 

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

1818 filename: str 

1819 Filename of the plot to write. 

1820 title: str 

1821 Plot title. 

1822 stamp_coordinate: str 

1823 Coordinate that becomes different plots. 

1824 """ 

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

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

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

1828 grid_size = math.ceil(nmember / grid_rows) 

1829 

1830 fig = plt.figure( 

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

1832 ) 

1833 

1834 # Make a subplot for each member. 

1835 for member, subplot in zip( 

1836 cube.slices_over(stamp_coordinate), 

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

1838 strict=False, 

1839 ): 

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

1841 # cartopy GeoAxes generated. 

1842 plt.subplot(grid_rows, grid_size, subplot) 

1843 

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

1845 

1846 axes = plt.gca() 

1847 axes.plot(frequency, member.data) 

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

1849 

1850 # Overall figure title. 

1851 fig.suptitle(title, fontsize=16) 

1852 

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

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

1855 plt.close(fig) 

1856 

1857 

1858def _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series( 

1859 cube: iris.cube.Cube, 

1860 filename: str, 

1861 title: str, 

1862 stamp_coordinate: str, 

1863 **kwargs, 

1864): 

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

1866 ax.set_title(title, fontsize=16) 

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

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

1869 # Loop over all slices along the stamp_coordinate 

1870 for member in cube.slices_over(stamp_coordinate): 

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

1872 ax.plot( 

1873 frequency, 

1874 member.data, 

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

1876 ) 

1877 

1878 # Add a legend 

1879 ax.legend(fontsize=16) 

1880 

1881 # Save the figure to a file 

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

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

1884 

1885 # Close the figure 

1886 plt.close(fig) 

1887 

1888 

1889def _spatial_plot( 

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

1891 cube: iris.cube.Cube, 

1892 filename: str | None, 

1893 sequence_coordinate: str, 

1894 stamp_coordinate: str, 

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

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

1897 **kwargs, 

1898): 

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

1900 

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

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

1903 is present then postage stamp plots will be produced. 

1904 

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

1906 be overplotted on the same figure. 

1907 

1908 Parameters 

1909 ---------- 

1910 method: "contourf" | "pcolormesh" 

1911 The plotting method to use. 

1912 cube: Cube 

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

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

1915 plotted sequentially and/or as postage stamp plots. 

1916 filename: str | None 

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

1918 uses the recipe name. 

1919 sequence_coordinate: str 

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

1921 This coordinate must exist in the cube. 

1922 stamp_coordinate: str 

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

1924 ``"realization"``. 

1925 overlay_cube: Cube | None, optional 

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

1927 contour_cube: Cube | None, optional 

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

1929 

1930 Raises 

1931 ------ 

1932 ValueError 

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

1934 TypeError 

1935 If the cube isn't a single cube. 

1936 """ 

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

1938 

1939 # Ensure we've got a single cube. 

1940 cube = _check_single_cube(cube) 

1941 

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

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

1944 stamp_coordinate = check_stamp_coordinate(cube) 

1945 

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

1947 # single point. 

1948 plotting_func = _plot_and_save_spatial_plot 

1949 try: 

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

1951 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1952 except iris.exceptions.CoordinateNotFoundError: 

1953 pass 

1954 

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

1956 # dimension called observation or model_obs_error 

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

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

1959 for crd in cube.coords() 

1960 ): 

1961 plotting_func = _plot_and_save_scattermap_plot 

1962 

1963 # Must have a sequence coordinate. 

1964 try: 

1965 cube.coord(sequence_coordinate) 

1966 except iris.exceptions.CoordinateNotFoundError as err: 

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

1968 

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

1970 plot_index = [] 

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

1972 

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

1974 # Set plot titles and filename 

1975 seq_coord = cube_slice.coord(sequence_coordinate) 

1976 plot_title, plot_filename = _set_title_and_filename( 

1977 seq_coord, nplot, recipe_title, filename 

1978 ) 

1979 

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

1981 overlay_slice = slice_over_maybe(overlay_cube, sequence_coordinate, iseq) 

1982 contour_slice = slice_over_maybe(contour_cube, sequence_coordinate, iseq) 

1983 

1984 # Do the actual plotting. 

1985 plotting_func( 

1986 cube_slice, 

1987 filename=plot_filename, 

1988 stamp_coordinate=stamp_coordinate, 

1989 title=plot_title, 

1990 method=method, 

1991 overlay_cube=overlay_slice, 

1992 contour_cube=contour_slice, 

1993 **kwargs, 

1994 ) 

1995 plot_index.append(plot_filename) 

1996 

1997 # Add list of plots to plot metadata. 

1998 complete_plot_index = _append_to_plot_index(plot_index) 

1999 

2000 # Make a page to display the plots. 

2001 _make_plot_html_page(complete_plot_index) 

2002 

2003 

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

2005 """Get colourmap for mask. 

2006 

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

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

2009 

2010 Parameters 

2011 ---------- 

2012 cube: Cube 

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

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

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

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

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

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

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

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

2021 

2022 Returns 

2023 ------- 

2024 cmap: 

2025 Matplotlib colormap. 

2026 levels: 

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

2028 should be taken as the range. 

2029 norm: 

2030 BoundaryNorm information. 

2031 """ 

2032 if "difference" not in cube.long_name: 

2033 if axis: 

2034 levels = [0, 1] 

2035 # Complete settings based on levels. 

2036 return None, levels, None 

2037 else: 

2038 # Define the levels and colors. 

2039 levels = [0, 1, 2] 

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

2041 # Create a custom color map. 

2042 cmap = mcolors.ListedColormap(colors) 

2043 # Normalize the levels. 

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

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

2046 return cmap, levels, norm 

2047 else: 

2048 if axis: 

2049 levels = [-1, 1] 

2050 return None, levels, None 

2051 else: 

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

2053 # not <=. 

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

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

2056 cmap = mcolors.ListedColormap(colors) 

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

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

2059 return cmap, levels, norm 

2060 

2061 

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

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

2064 

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

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

2067 

2068 Parameters 

2069 ---------- 

2070 cube: Cube 

2071 Cube of variable with Beaufort Scale in name. 

2072 axis: "x", "y", optional 

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

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

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

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

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

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

2079 

2080 Returns 

2081 ------- 

2082 cmap: 

2083 Matplotlib colormap. 

2084 levels: 

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

2086 should be taken as the range. 

2087 norm: 

2088 BoundaryNorm information. 

2089 """ 

2090 if "difference" not in cube.long_name: 

2091 if axis: 

2092 levels = [0, 12] 

2093 return None, levels, None 

2094 else: 

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

2096 colors = [ 

2097 "black", 

2098 (0, 0, 0.6), 

2099 "blue", 

2100 "cyan", 

2101 "green", 

2102 "yellow", 

2103 (1, 0.5, 0), 

2104 "red", 

2105 "pink", 

2106 "magenta", 

2107 "purple", 

2108 "maroon", 

2109 "white", 

2110 ] 

2111 cmap = mcolors.ListedColormap(colors) 

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

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

2114 return cmap, levels, norm 

2115 else: 

2116 if axis: 

2117 levels = [-4, 4] 

2118 return None, levels, None 

2119 else: 

2120 levels = [ 

2121 -3.5, 

2122 -2.5, 

2123 -1.5, 

2124 -0.5, 

2125 0.5, 

2126 1.5, 

2127 2.5, 

2128 3.5, 

2129 ] 

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

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

2132 return cmap, levels, norm 

2133 

2134 

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

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

2137 

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

2139 

2140 Parameters 

2141 ---------- 

2142 cube: Cube 

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

2144 cmap: Matplotlib colormap. 

2145 levels: List 

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

2147 should be taken as the range. 

2148 norm: BoundaryNorm. 

2149 

2150 Returns 

2151 ------- 

2152 cmap: Matplotlib colormap. 

2153 levels: List 

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

2155 should be taken as the range. 

2156 norm: BoundaryNorm. 

2157 """ 

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

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

2160 levels = np.array(levels) 

2161 levels -= 273 

2162 levels = levels.tolist() 

2163 else: 

2164 # Do nothing keep the existing colourbar attributes 

2165 levels = levels 

2166 cmap = cmap 

2167 norm = norm 

2168 return cmap, levels, norm 

2169 

2170 

2171def _custom_colormap_probability( 

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

2173): 

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

2175 

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

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

2178 

2179 Parameters 

2180 ---------- 

2181 cube: Cube 

2182 Cube of variable with probability in name. 

2183 axis: "x", "y", optional 

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

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

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

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

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

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

2190 

2191 Returns 

2192 ------- 

2193 cmap: 

2194 Matplotlib colormap. 

2195 levels: 

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

2197 should be taken as the range. 

2198 norm: 

2199 BoundaryNorm information. 

2200 """ 

2201 if axis: 

2202 levels = [0, 1] 

2203 return None, levels, None 

2204 else: 

2205 cmap = mcolors.ListedColormap( 

2206 [ 

2207 "#FFFFFF", 

2208 "#636363", 

2209 "#e1dada", 

2210 "#B5CAFF", 

2211 "#8FB3FF", 

2212 "#7F97FF", 

2213 "#ABCF63", 

2214 "#E8F59E", 

2215 "#FFFA14", 

2216 "#FFD121", 

2217 "#FFA30A", 

2218 ] 

2219 ) 

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

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

2222 return cmap, levels, norm 

2223 

2224 

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

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

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

2228 if ( 

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

2230 and "difference" not in cube.long_name 

2231 and "mask" not in cube.long_name 

2232 ): 

2233 # Define the levels and colors 

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

2235 colors = [ 

2236 "w", 

2237 (0, 0, 0.6), 

2238 "b", 

2239 "c", 

2240 "g", 

2241 "y", 

2242 (1, 0.5, 0), 

2243 "r", 

2244 "pink", 

2245 "m", 

2246 "purple", 

2247 "maroon", 

2248 "gray", 

2249 ] 

2250 # Create a custom colormap 

2251 cmap = mcolors.ListedColormap(colors) 

2252 # Normalize the levels 

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

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

2255 else: 

2256 # do nothing and keep existing colorbar attributes 

2257 cmap = cmap 

2258 levels = levels 

2259 norm = norm 

2260 return cmap, levels, norm 

2261 

2262 

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

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

2265 

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

2267 this function will be called. 

2268 

2269 Parameters 

2270 ---------- 

2271 cube: Cube 

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

2273 

2274 Returns 

2275 ------- 

2276 cmap: Matplotlib colormap. 

2277 levels: List 

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

2279 should be taken as the range. 

2280 norm: BoundaryNorm. 

2281 """ 

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

2283 colors = [ 

2284 "#87ceeb", 

2285 "#ffffff", 

2286 "#8ced69", 

2287 "#ffff00", 

2288 "#ffd700", 

2289 "#ffa500", 

2290 "#fe3620", 

2291 ] 

2292 # Create a custom colormap 

2293 cmap = mcolors.ListedColormap(colors) 

2294 # Normalise the levels 

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

2296 return cmap, levels, norm 

2297 

2298 

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

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

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

2302 if ( 

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

2304 and "difference" not in cube.long_name 

2305 and "mask" not in cube.long_name 

2306 ): 

2307 # Define the levels and colors (in km) 

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

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

2310 colours = [ 

2311 "#8f00d6", 

2312 "#d10000", 

2313 "#ff9700", 

2314 "#ffff00", 

2315 "#00007f", 

2316 "#6c9ccd", 

2317 "#aae8ff", 

2318 "#37a648", 

2319 "#8edc64", 

2320 "#c5ffc5", 

2321 "#dcdcdc", 

2322 "#ffffff", 

2323 ] 

2324 # Create a custom colormap 

2325 cmap = mcolors.ListedColormap(colours) 

2326 # Normalize the levels 

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

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

2329 else: 

2330 # do nothing and keep existing colorbar attributes 

2331 cmap = cmap 

2332 levels = levels 

2333 norm = norm 

2334 return cmap, levels, norm 

2335 

2336 

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

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

2339 model_names = list( 

2340 filter( 

2341 lambda x: x is not None, 

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

2343 ) 

2344 ) 

2345 if not model_names: 

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

2347 return 1 

2348 else: 

2349 return len(model_names) 

2350 

2351 

2352def _validate_cube_shape( 

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

2354) -> None: 

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

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

2357 raise ValueError( 

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

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

2360 ) 

2361 

2362 

2363def _validate_cubes_coords( 

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

2365) -> None: 

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

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

2368 raise ValueError( 

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

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

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

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

2373 ) 

2374 

2375 

2376#################### 

2377# Public functions # 

2378#################### 

2379 

2380 

2381def spatial_contour_plot( 

2382 cube: iris.cube.Cube, 

2383 filename: str = None, 

2384 sequence_coordinate: str = "time", 

2385 stamp_coordinate: str = "realization", 

2386 **kwargs, 

2387) -> iris.cube.Cube: 

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

2389 

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

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

2392 is present then postage stamp plots will be produced. 

2393 

2394 Parameters 

2395 ---------- 

2396 cube: Cube 

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

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

2399 plotted sequentially and/or as postage stamp plots. 

2400 filename: str, optional 

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

2402 to the recipe name. 

2403 sequence_coordinate: str, optional 

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

2405 This coordinate must exist in the cube. 

2406 stamp_coordinate: str, optional 

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

2408 ``"realization"``. 

2409 

2410 Returns 

2411 ------- 

2412 Cube 

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

2414 

2415 Raises 

2416 ------ 

2417 ValueError 

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

2419 TypeError 

2420 If the cube isn't a single cube. 

2421 """ 

2422 _spatial_plot( 

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

2424 ) 

2425 return cube 

2426 

2427 

2428def spatial_pcolormesh_plot( 

2429 cube: iris.cube.Cube, 

2430 filename: str = None, 

2431 sequence_coordinate: str = "time", 

2432 stamp_coordinate: str = "realization", 

2433 **kwargs, 

2434) -> iris.cube.Cube: 

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

2436 

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

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

2439 is present then postage stamp plots will be produced. 

2440 

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

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

2443 contour areas are important. 

2444 

2445 Parameters 

2446 ---------- 

2447 cube: Cube 

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

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

2450 plotted sequentially and/or as postage stamp plots. 

2451 filename: str, optional 

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

2453 to the recipe name. 

2454 sequence_coordinate: str, optional 

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

2456 This coordinate must exist in the cube. 

2457 stamp_coordinate: str, optional 

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

2459 ``"realization"``. 

2460 

2461 Returns 

2462 ------- 

2463 Cube 

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

2465 

2466 Raises 

2467 ------ 

2468 ValueError 

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

2470 TypeError 

2471 If the cube isn't a single cube. 

2472 """ 

2473 _spatial_plot( 

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

2475 ) 

2476 return cube 

2477 

2478 

2479def spatial_multi_pcolormesh_plot( 

2480 cube: iris.cube.Cube, 

2481 overlay_cube: iris.cube.Cube, 

2482 contour_cube: iris.cube.Cube, 

2483 filename: str = None, 

2484 sequence_coordinate: str = "time", 

2485 stamp_coordinate: str = "realization", 

2486 **kwargs, 

2487) -> iris.cube.Cube: 

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

2489 

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

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

2492 is present then postage stamp plots will be produced. 

2493 

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

2495 

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

2497 

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

2499 

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

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

2502 contour areas are important. 

2503 

2504 Parameters 

2505 ---------- 

2506 cube: Cube 

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

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

2509 plotted sequentially and/or as postage stamp plots. 

2510 overlay_cube: Cube 

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

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

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

2514 contour_cube: Cube 

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

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

2517 plotted sequentially and/or as postage stamp plots. 

2518 filename: str, optional 

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

2520 to the recipe name. 

2521 sequence_coordinate: str, optional 

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

2523 This coordinate must exist in the cube. 

2524 stamp_coordinate: str, optional 

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

2526 ``"realization"``. 

2527 

2528 Returns 

2529 ------- 

2530 Cube 

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

2532 

2533 Raises 

2534 ------ 

2535 ValueError 

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

2537 TypeError 

2538 If the cube isn't a single cube. 

2539 """ 

2540 _spatial_plot( 

2541 "pcolormesh", 

2542 cube, 

2543 filename, 

2544 sequence_coordinate, 

2545 stamp_coordinate, 

2546 overlay_cube=overlay_cube, 

2547 contour_cube=contour_cube, 

2548 ) 

2549 return cube, overlay_cube, contour_cube 

2550 

2551 

2552# TODO: Expand function to handle ensemble data. 

2553# line_coordinate: str, optional 

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

2555# ``"realization"``. 

2556def plot_line_series( 

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

2558 filename: str = None, 

2559 series_coordinate: str = "time", 

2560 # line_coordinate: str = "realization", 

2561 **kwargs, 

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

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

2564 

2565 The Cube or CubeList must be 1D. 

2566 

2567 Parameters 

2568 ---------- 

2569 iris.cube | iris.cube.CubeList 

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

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

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

2573 filename: str, optional 

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

2575 to the recipe name. 

2576 series_coordinate: str, optional 

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

2578 coordinate must exist in the cube. 

2579 

2580 Returns 

2581 ------- 

2582 iris.cube.Cube | iris.cube.CubeList 

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

2584 plotted data. 

2585 

2586 Raises 

2587 ------ 

2588 ValueError 

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

2590 TypeError 

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

2592 """ 

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

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

2595 

2596 num_models = _get_num_models(cube) 

2597 

2598 _validate_cube_shape(cube, num_models) 

2599 

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

2601 cubes = iter_maybe(cube) 

2602 coords = [] 

2603 for cube in cubes: 

2604 try: 

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

2606 except iris.exceptions.CoordinateNotFoundError as err: 

2607 raise ValueError( 

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

2609 ) from err 

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

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

2612 

2613 # Format the title and filename using plotted series coordinate 

2614 nplot = 1 

2615 seq_coord = coords[0] 

2616 plot_title, plot_filename = _set_title_and_filename( 

2617 seq_coord, nplot, recipe_title, filename 

2618 ) 

2619 

2620 # Do the actual plotting. 

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

2622 

2623 # Add list of plots to plot metadata. 

2624 plot_index = _append_to_plot_index([plot_filename]) 

2625 

2626 # Make a page to display the plots. 

2627 _make_plot_html_page(plot_index) 

2628 

2629 return cube 

2630 

2631 

2632def plot_vertical_line_series( 

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

2634 filename: str = None, 

2635 series_coordinate: str = "model_level_number", 

2636 sequence_coordinate: str = "time", 

2637 # line_coordinate: str = "realization", 

2638 **kwargs, 

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

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

2641 

2642 The Cube or CubeList must be 1D. 

2643 

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

2645 then a sequence of plots will be produced. 

2646 

2647 Parameters 

2648 ---------- 

2649 iris.cube | iris.cube.CubeList 

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

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

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

2653 filename: str, optional 

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

2655 to the recipe name. 

2656 series_coordinate: str, optional 

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

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

2659 for LFRic. Defaults to ``model_level_number``. 

2660 This coordinate must exist in the cube. 

2661 sequence_coordinate: str, optional 

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

2663 This coordinate must exist in the cube. 

2664 

2665 Returns 

2666 ------- 

2667 iris.cube.Cube | iris.cube.CubeList 

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

2669 Plotted data. 

2670 

2671 Raises 

2672 ------ 

2673 ValueError 

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

2675 TypeError 

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

2677 """ 

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

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

2680 

2681 cubes = iter_maybe(cubes) 

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

2683 all_data = [] 

2684 

2685 # Store min/max ranges for x range. 

2686 x_levels = [] 

2687 

2688 num_models = _get_num_models(cubes) 

2689 

2690 _validate_cube_shape(cubes, num_models) 

2691 

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

2693 coords = [] 

2694 for cube in cubes: 

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

2696 try: 

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

2698 except iris.exceptions.CoordinateNotFoundError as err: 

2699 raise ValueError( 

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

2701 ) from err 

2702 

2703 try: 

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

2705 cube.coord(sequence_coordinate) 

2706 except iris.exceptions.CoordinateNotFoundError as err: 

2707 raise ValueError( 

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

2709 ) from err 

2710 

2711 # Get minimum and maximum from levels information. 

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

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

2714 x_levels.append(min(levels)) 

2715 x_levels.append(max(levels)) 

2716 else: 

2717 all_data.append(cube.data) 

2718 

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

2720 # Combine all data into a single NumPy array 

2721 combined_data = np.concatenate(all_data) 

2722 

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

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

2725 # sequence and if applicable postage stamp coordinate. 

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

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

2728 else: 

2729 vmin = min(x_levels) 

2730 vmax = max(x_levels) 

2731 

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

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

2734 # necessary) 

2735 def filter_cube_iterables(cube_iterables) -> bool: 

2736 return len(cube_iterables) == len(coords) 

2737 

2738 cube_iterables = filter( 

2739 filter_cube_iterables, 

2740 ( 

2741 iris.cube.CubeList( 

2742 s 

2743 for s in itertools.chain.from_iterable( 

2744 cb.slices_over(sequence_coordinate) for cb in cubes 

2745 ) 

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

2747 ) 

2748 for point in sorted( 

2749 set( 

2750 itertools.chain.from_iterable( 

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

2752 ) 

2753 ) 

2754 ) 

2755 ), 

2756 ) 

2757 

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

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

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

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

2762 # or an iris.cube.CubeList. 

2763 plot_index = [] 

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

2765 for cubes_slice in cube_iterables: 

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

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

2768 plot_title, plot_filename = _set_title_and_filename( 

2769 seq_coord, nplot, recipe_title, filename 

2770 ) 

2771 

2772 # Do the actual plotting. 

2773 _plot_and_save_vertical_line_series( 

2774 cubes_slice, 

2775 coords, 

2776 "realization", 

2777 plot_filename, 

2778 series_coordinate, 

2779 title=plot_title, 

2780 vmin=vmin, 

2781 vmax=vmax, 

2782 ) 

2783 plot_index.append(plot_filename) 

2784 

2785 # Add list of plots to plot metadata. 

2786 complete_plot_index = _append_to_plot_index(plot_index) 

2787 

2788 # Make a page to display the plots. 

2789 _make_plot_html_page(complete_plot_index) 

2790 

2791 return cubes 

2792 

2793 

2794def qq_plot( 

2795 cubes: iris.cube.CubeList, 

2796 coordinates: list[str], 

2797 percentiles: list[float], 

2798 model_names: list[str], 

2799 filename: str = None, 

2800 one_to_one: bool = True, 

2801 **kwargs, 

2802) -> iris.cube.CubeList: 

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

2804 

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

2806 collapsed within the operator over all specified coordinates such as 

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

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

2809 

2810 Parameters 

2811 ---------- 

2812 cubes: iris.cube.CubeList 

2813 Two cubes of the same variable with different models. 

2814 coordinate: list[str] 

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

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

2817 the percentile coordinate. 

2818 percent: list[float] 

2819 A list of percentiles to appear in the plot. 

2820 model_names: list[str] 

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

2822 filename: str, optional 

2823 Filename of the plot to write. 

2824 one_to_one: bool, optional 

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

2826 

2827 Raises 

2828 ------ 

2829 ValueError 

2830 When the cubes are not compatible. 

2831 

2832 Notes 

2833 ----- 

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

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

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

2837 compares percentiles of two datasets. This plot does 

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

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

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

2841 

2842 Quantile-quantile plots are valuable for comparing against 

2843 observations and other models. Identical percentiles between the variables 

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

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

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

2847 Wilks 2011 [Wilks2011]_). 

2848 

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

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

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

2852 the extremes. 

2853 

2854 References 

2855 ---------- 

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

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

2858 """ 

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

2860 if len(cubes) != 2: 

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

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

2863 other: Cube = cubes.extract_cube( 

2864 iris.Constraint( 

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

2866 ) 

2867 ) 

2868 

2869 # Get spatial coord names. 

2870 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

2871 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

2872 

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

2874 # This is triggered if either 

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

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

2877 # errors. 

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

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

2880 # for UM and LFRic comparisons. 

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

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

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

2884 # given this dependency on regridding. 

2885 if ( 

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

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

2888 ) or ( 

2889 base.long_name 

2890 in [ 

2891 "eastward_wind_at_10m", 

2892 "northward_wind_at_10m", 

2893 "northward_wind_at_cell_centres", 

2894 "eastward_wind_at_cell_centres", 

2895 "zonal_wind_at_pressure_levels", 

2896 "meridional_wind_at_pressure_levels", 

2897 "potential_vorticity_at_pressure_levels", 

2898 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

2899 ] 

2900 ): 

2901 logging.debug( 

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

2903 ) 

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

2905 

2906 # Extract just common time points. 

2907 base, other = _extract_common_time_points(base, other) 

2908 

2909 # Equalise attributes so we can merge. 

2910 fully_equalise_attributes([base, other]) 

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

2912 

2913 # Collapse cubes. 

2914 base = collapse( 

2915 base, 

2916 coordinate=coordinates, 

2917 method="PERCENTILE", 

2918 additional_percent=percentiles, 

2919 ) 

2920 other = collapse( 

2921 other, 

2922 coordinate=coordinates, 

2923 method="PERCENTILE", 

2924 additional_percent=percentiles, 

2925 ) 

2926 

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

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

2929 title = f"{recipe_title}" 

2930 

2931 if filename is None: 

2932 filename = slugify(recipe_title) 

2933 

2934 # Add file extension. 

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

2936 

2937 # Do the actual plotting on a scatter plot 

2938 _plot_and_save_scatter_plot( 

2939 base, other, plot_filename, title, one_to_one, model_names 

2940 ) 

2941 

2942 # Add list of plots to plot metadata. 

2943 plot_index = _append_to_plot_index([plot_filename]) 

2944 

2945 # Make a page to display the plots. 

2946 _make_plot_html_page(plot_index) 

2947 

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

2949 

2950 

2951def scatter_plot( 

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

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

2954 filename: str = None, 

2955 one_to_one: bool = True, 

2956 **kwargs, 

2957) -> iris.cube.CubeList: 

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

2959 

2960 Both cubes must be 1D. 

2961 

2962 Parameters 

2963 ---------- 

2964 cube_x: Cube | CubeList 

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

2966 cube_y: Cube | CubeList 

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

2968 filename: str, optional 

2969 Filename of the plot to write. 

2970 one_to_one: bool, optional 

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

2972 

2973 Returns 

2974 ------- 

2975 cubes: CubeList 

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

2977 

2978 Raises 

2979 ------ 

2980 ValueError 

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

2982 size. 

2983 TypeError 

2984 If the cube isn't a single cube. 

2985 

2986 Notes 

2987 ----- 

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

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

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

2991 """ 

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

2993 for cube_iter in iter_maybe(cube_x): 

2994 # Check cubes are correct shape. 

2995 cube_iter = _check_single_cube(cube_iter) 

2996 if cube_iter.ndim > 1: 

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

2998 

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

3000 for cube_iter in iter_maybe(cube_y): 

3001 # Check cubes are correct shape. 

3002 cube_iter = _check_single_cube(cube_iter) 

3003 if cube_iter.ndim > 1: 

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

3005 

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

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

3008 title = f"{recipe_title}" 

3009 

3010 if filename is None: 

3011 filename = slugify(recipe_title) 

3012 

3013 # Add file extension. 

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

3015 

3016 # Do the actual plotting. 

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

3018 

3019 # Add list of plots to plot metadata. 

3020 plot_index = _append_to_plot_index([plot_filename]) 

3021 

3022 # Make a page to display the plots. 

3023 _make_plot_html_page(plot_index) 

3024 

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

3026 

3027 

3028def vector_plot( 

3029 cube_u: iris.cube.Cube, 

3030 cube_v: iris.cube.Cube, 

3031 filename: str = None, 

3032 sequence_coordinate: str = "time", 

3033 **kwargs, 

3034) -> iris.cube.CubeList: 

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

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

3037 

3038 # Cubes must have a matching sequence coordinate. 

3039 try: 

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

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

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

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

3044 raise ValueError( 

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

3046 ) from err 

3047 

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

3049 plot_index = [] 

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

3051 for cube_u_slice, cube_v_slice in zip( 

3052 cube_u.slices_over(sequence_coordinate), 

3053 cube_v.slices_over(sequence_coordinate), 

3054 strict=True, 

3055 ): 

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

3057 seq_coord = cube_u_slice.coord(sequence_coordinate) 

3058 plot_title, plot_filename = _set_title_and_filename( 

3059 seq_coord, nplot, recipe_title, filename 

3060 ) 

3061 

3062 # Do the actual plotting. 

3063 _plot_and_save_vector_plot( 

3064 cube_u_slice, 

3065 cube_v_slice, 

3066 filename=plot_filename, 

3067 title=plot_title, 

3068 method="contourf", 

3069 ) 

3070 plot_index.append(plot_filename) 

3071 

3072 # Add list of plots to plot metadata. 

3073 complete_plot_index = _append_to_plot_index(plot_index) 

3074 

3075 # Make a page to display the plots. 

3076 _make_plot_html_page(complete_plot_index) 

3077 

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

3079 

3080 

3081def plot_histogram_series( 

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

3083 filename: str = None, 

3084 sequence_coordinate: str = "time", 

3085 stamp_coordinate: str = "realization", 

3086 single_plot: bool = False, 

3087 **kwargs, 

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

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

3090 

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

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

3093 functionality to scroll through histograms against time. If a 

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

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

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

3097 

3098 Parameters 

3099 ---------- 

3100 cubes: Cube | iris.cube.CubeList 

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

3102 than the stamp coordinate. 

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

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

3105 filename: str, optional 

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

3107 to the recipe name. 

3108 sequence_coordinate: str, optional 

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

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

3111 slider. 

3112 stamp_coordinate: str, optional 

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

3114 ``"realization"``. 

3115 single_plot: bool, optional 

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

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

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

3119 

3120 Returns 

3121 ------- 

3122 iris.cube.Cube | iris.cube.CubeList 

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

3124 Plotted data. 

3125 

3126 Raises 

3127 ------ 

3128 ValueError 

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

3130 TypeError 

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

3132 """ 

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

3134 

3135 cubes = iter_maybe(cubes) 

3136 

3137 # Internal plotting function. 

3138 plotting_func = _plot_and_save_histogram_series 

3139 

3140 num_models = _get_num_models(cubes) 

3141 

3142 _validate_cube_shape(cubes, num_models) 

3143 

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

3145 # time slider option. 

3146 for cube in cubes: 

3147 try: 

3148 cube.coord(sequence_coordinate) 

3149 except iris.exceptions.CoordinateNotFoundError as err: 

3150 raise ValueError( 

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

3152 ) from err 

3153 

3154 # Get minimum and maximum from levels information. 

3155 levels = None 

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

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

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

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

3160 if levels is None: 

3161 break 

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

3163 # levels-based ranges for histogram plots. 

3164 _, levels, _ = _colorbar_map_levels(cube) 

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

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

3167 vmin = min(levels) 

3168 vmax = max(levels) 

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

3170 break 

3171 

3172 if levels is None: 

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

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

3175 

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

3177 # single point. If single_plot is True: 

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

3179 # separate postage stamp plots. 

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

3181 # produced per single model only 

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

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

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

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

3186 ): 

3187 if single_plot: 

3188 plotting_func = ( 

3189 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

3190 ) 

3191 else: 

3192 plotting_func = _plot_and_save_postage_stamp_histogram_series 

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

3194 else: 

3195 all_points = sorted( 

3196 set( 

3197 itertools.chain.from_iterable( 

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

3199 ) 

3200 ) 

3201 ) 

3202 all_slices = list( 

3203 itertools.chain.from_iterable( 

3204 cb.slices_over(sequence_coordinate) for cb in cubes 

3205 ) 

3206 ) 

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

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

3209 # necessary) 

3210 cube_iterables = [ 

3211 iris.cube.CubeList( 

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

3213 ) 

3214 for point in all_points 

3215 ] 

3216 

3217 plot_index = [] 

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

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

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

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

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

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

3224 for cube_slice in cube_iterables: 

3225 single_cube = cube_slice 

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

3227 single_cube = cube_slice[0] 

3228 

3229 # Ensure valid stamp coordinate in cube dimensions 

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

3231 stamp_coordinate = check_stamp_coordinate(single_cube) 

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

3233 seq_coord = single_cube.coord(sequence_coordinate) 

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

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

3236 seq_coord = single_cube.coord("time") 

3237 plot_title, plot_filename = _set_title_and_filename( 

3238 seq_coord, nplot, recipe_title, filename 

3239 ) 

3240 

3241 # Do the actual plotting. 

3242 plotting_func( 

3243 cube_slice, 

3244 filename=plot_filename, 

3245 stamp_coordinate=stamp_coordinate, 

3246 title=plot_title, 

3247 vmin=vmin, 

3248 vmax=vmax, 

3249 ) 

3250 plot_index.append(plot_filename) 

3251 

3252 # Add list of plots to plot metadata. 

3253 complete_plot_index = _append_to_plot_index(plot_index) 

3254 

3255 # Make a page to display the plots. 

3256 _make_plot_html_page(complete_plot_index) 

3257 

3258 return cubes 

3259 

3260 

3261def plot_power_spectrum_series( 

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

3263 filename: str = None, 

3264 sequence_coordinate: str = "time", 

3265 stamp_coordinate: str = "realization", 

3266 single_plot: bool = False, 

3267 **kwargs, 

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

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

3270 

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

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

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

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

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

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

3277 

3278 Parameters 

3279 ---------- 

3280 cubes: Cube | iris.cube.CubeList 

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

3282 than the stamp coordinate. 

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

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

3285 filename: str, optional 

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

3287 to the recipe name. 

3288 sequence_coordinate: str, optional 

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

3290 This coordinate must exist in the cube and will be used for the time 

3291 slider. 

3292 stamp_coordinate: str, optional 

3293 Coordinate about which to plot postage stamp plots. Defaults to 

3294 ``"realization"``. 

3295 single_plot: bool, optional 

3296 If True, all postage stamp plots will be plotted in a single plot. If 

3297 False, each postage stamp plot will be plotted separately. Is only valid 

3298 if stamp_coordinate exists and has more than a single point. 

3299 

3300 Returns 

3301 ------- 

3302 iris.cube.Cube | iris.cube.CubeList 

3303 The original Cube or CubeList (so further operations can be applied). 

3304 Plotted data. 

3305 

3306 Raises 

3307 ------ 

3308 ValueError 

3309 If the cube doesn't have the right dimensions. 

3310 TypeError 

3311 If the cube isn't a Cube or CubeList. 

3312 """ 

3313 recipe_title = get_recipe_metadata().get("title", "Untitled") 

3314 

3315 cubes = iter_maybe(cubes) 

3316 

3317 # Internal plotting function. 

3318 plotting_func = _plot_and_save_power_spectrum_series 

3319 

3320 num_models = _get_num_models(cubes) 

3321 

3322 _validate_cube_shape(cubes, num_models) 

3323 

3324 # If several power spectra are plotted with time as sequence_coordinate for the 

3325 # time slider option. 

3326 for cube in cubes: 

3327 try: 

3328 cube.coord(sequence_coordinate) 

3329 except iris.exceptions.CoordinateNotFoundError as err: 

3330 raise ValueError( 

3331 f"Cube must have a {sequence_coordinate} coordinate." 

3332 ) from err 

3333 

3334 # Make postage stamp plots if stamp_coordinate exists and has more than a 

3335 # single point. If single_plot is True: 

3336 # -- all postage stamp plots will be plotted in a single plot instead of 

3337 # separate postage stamp plots. 

3338 # -- model names (hidden in cube attrs) are ignored, that is stamp plots are 

3339 # produced per single model only 

3340 if num_models == 1: 3340 ↛ 3353line 3340 didn't jump to line 3353 because the condition on line 3340 was always true

3341 if ( 3341 ↛ 3345line 3341 didn't jump to line 3345 because the condition on line 3341 was never true

3342 stamp_coordinate in [c.name() for c in cubes[0].coords()] 

3343 and cubes[0].coord(stamp_coordinate).shape[0] > 1 

3344 ): 

3345 if single_plot: 

3346 plotting_func = ( 

3347 _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series 

3348 ) 

3349 else: 

3350 plotting_func = _plot_and_save_postage_stamp_power_spectrum_series 

3351 cube_iterables = cubes[0].slices_over(sequence_coordinate) 

3352 else: 

3353 all_points = sorted( 

3354 set( 

3355 itertools.chain.from_iterable( 

3356 cb.coord(sequence_coordinate).points for cb in cubes 

3357 ) 

3358 ) 

3359 ) 

3360 all_slices = list( 

3361 itertools.chain.from_iterable( 

3362 cb.slices_over(sequence_coordinate) for cb in cubes 

3363 ) 

3364 ) 

3365 # Matched slices (matched by seq coord point; it may happen that 

3366 # evaluated models do not cover the same seq coord range, hence matching 

3367 # necessary) 

3368 cube_iterables = [ 

3369 iris.cube.CubeList( 

3370 s for s in all_slices if s.coord(sequence_coordinate).points[0] == point 

3371 ) 

3372 for point in all_points 

3373 ] 

3374 

3375 plot_index = [] 

3376 nplot = np.size(cube.coord(sequence_coordinate).points) 

3377 # Create a plot for each value of the sequence coordinate. Allowing for 

3378 # multiple cubes in a CubeList to be plotted in the same plot for similar 

3379 # sequence values. Passing a CubeList into the internal plotting function 

3380 # for similar values of the sequence coordinate. cube_slice can be an 

3381 # iris.cube.Cube or an iris.cube.CubeList. 

3382 for cube_slice in cube_iterables: 

3383 single_cube = cube_slice 

3384 if isinstance(cube_slice, iris.cube.CubeList): 3384 ↛ 3385line 3384 didn't jump to line 3385 because the condition on line 3384 was never true

3385 single_cube = cube_slice[0] 

3386 

3387 # Set stamp coordinate 

3388 if stamp_coordinate == "realization": 3388 ↛ 3391line 3388 didn't jump to line 3391 because the condition on line 3388 was always true

3389 stamp_coordinate = check_stamp_coordinate(single_cube) 

3390 # Set plot title and filenames based on sequence values 

3391 seq_coord = single_cube.coord(sequence_coordinate) 

3392 plot_title, plot_filename = _set_title_and_filename( 

3393 seq_coord, nplot, recipe_title, filename 

3394 ) 

3395 

3396 # Do the actual plotting. 

3397 plotting_func( 

3398 cube_slice, 

3399 filename=plot_filename, 

3400 stamp_coordinate=stamp_coordinate, 

3401 title=plot_title, 

3402 ) 

3403 plot_index.append(plot_filename) 

3404 

3405 # Add list of plots to plot metadata. 

3406 complete_plot_index = _append_to_plot_index(plot_index) 

3407 

3408 # Make a page to display the plots. 

3409 _make_plot_html_page(complete_plot_index) 

3410 

3411 return cubes 

3412 

3413 

3414def _DCT_ps(y_3d): 

3415 """Calculate power spectra for regional domains. 

3416 

3417 Parameters 

3418 ---------- 

3419 y_3d: 3D array 

3420 3 dimensional array to calculate spectrum for. 

3421 (2D field data with 3rd dimension of time) 

3422 

3423 Returns: ps_array 

3424 Array of power spectra values calculated for input field (for each time) 

3425 

3426 Method for regional domains: 

3427 Calculate power spectra over limited area domain using Discrete Cosine Transform (DCT) 

3428 as described in Denis et al 2002 [Denis_etal_2002]_. 

3429 

3430 References 

3431 ---------- 

3432 .. [Denis_etal_2002] Bertrand Denis, Jean Côté and René Laprise (2002) 

3433 "Spectral Decomposition of Two-Dimensional Atmospheric Fields on 

3434 Limited-Area Domains Using the Discrete Cosine Transform (DCT)" 

3435 Monthly Weather Review, Vol. 130, 1812-1828 

3436 doi: https://doi.org/10.1175/1520-0493(2002)130<1812:SDOTDA>2.0.CO;2 

3437 """ 

3438 Nt, Ny, Nx = y_3d.shape 

3439 

3440 # Max coefficient 

3441 Nmin = min(Nx - 1, Ny - 1) 

3442 

3443 # Create alpha matrix (of wavenumbers) 

3444 alpha_matrix = _create_alpha_matrix(Ny, Nx) 

3445 

3446 # Prepare output array 

3447 ps_array = np.zeros((Nt, Nmin)) 

3448 

3449 # Loop over time to get spectrum for each time. 

3450 for t in range(Nt): 

3451 y_2d = y_3d[t] 

3452 

3453 # Apply 2D DCT to transform y_3d[t] from physical space to spectral space. 

3454 # fkk is a 2D array of DCT coefficients, representing the amplitudes of 

3455 # cosine basis functions at different spatial frequencies. 

3456 

3457 # normalise spectrum to allow comparison between models. 

3458 fkk = fft.dctn(y_2d, norm="ortho") 

3459 

3460 # Normalise fkk 

3461 fkk = fkk / np.sqrt(Ny * Nx) 

3462 

3463 # calculate variance of spectral coefficient 

3464 sigma_2 = fkk**2 / Nx / Ny 

3465 

3466 # Group ellipses of alphas into the same wavenumber k/Nmin 

3467 for k in range(1, Nmin + 1): 

3468 alpha = k / Nmin 

3469 alpha_p1 = (k + 1) / Nmin 

3470 

3471 # Sum up elements matching k 

3472 mask_k = np.where((alpha_matrix >= alpha) & (alpha_matrix < alpha_p1)) 

3473 ps_array[t, k - 1] = np.sum(sigma_2[mask_k]) 

3474 

3475 return ps_array 

3476 

3477 

3478def _create_alpha_matrix(Ny, Nx): 

3479 """Construct an array of 2D wavenumbers from 2D wavenumber pair. 

3480 

3481 Parameters 

3482 ---------- 

3483 Ny, Nx: 

3484 Dimensions of the 2D field for which the power spectra is calculated. Used to 

3485 create the array of 2D wavenumbers. Each Ny, Nx pair is associated with a 

3486 single-scale parameter. 

3487 

3488 Returns: alpha_matrix 

3489 normalisation of 2D wavenumber axes, transforming the spectral domain into 

3490 an elliptic coordinate system. 

3491 

3492 """ 

3493 # Create x_indices: each row is [1, 2, ..., Nx] 

3494 x_indices = np.tile(np.arange(1, Nx + 1), (Ny, 1)) 

3495 

3496 # Create y_indices: each column is [1, 2, ..., Ny] 

3497 y_indices = np.tile(np.arange(1, Ny + 1).reshape(Ny, 1), (1, Nx)) 

3498 

3499 # Compute alpha_matrix 

3500 alpha_matrix = np.sqrt((x_indices**2) / Nx**2 + (y_indices**2) / Ny**2) 

3501 

3502 return alpha_matrix