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

1026 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-24 13:03 +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 importlib.resources 

19import itertools 

20import json 

21import logging 

22import math 

23import os 

24import sys 

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.pyplot as plt 

36import numpy as np 

37from cartopy.mpl.geoaxes import GeoAxes 

38from iris.cube import Cube 

39from markdown_it import MarkdownIt 

40from mpl_toolkits.axes_grid1.inset_locator import inset_axes 

41 

42from CSET._common import ( 

43 filename_slugify, 

44 get_recipe_metadata, 

45 iter_maybe, 

46 render_file, 

47 slugify, 

48) 

49from CSET.operators._colormaps import ( 

50 colorbar_map_levels, 

51 get_model_colors_map, 

52) 

53from CSET.operators._utils import ( 

54 check_sequence_coordinate, 

55 check_single_cube, 

56 check_stamp_coordinate, 

57 fully_equalise_attributes, 

58 get_cube_yxcoordname, 

59 get_num_models, 

60 is_transect, 

61 slice_over_maybe, 

62 validate_cube_shape, 

63 validate_cubes_coords, 

64) 

65from CSET.operators.collapse import collapse 

66from CSET.operators.misc import _extract_common_time_points 

67from CSET.operators.regrid import regrid_onto_cube 

68 

69# Use a non-interactive plotting backend. 

70mpl.use("agg") 

71 

72 

73############################ 

74# Private helper functions # 

75############################ 

76 

77 

78def in_sphinx_gallery(): 

79 """Test if running plot code in sphinx-gallery context.""" 

80 return "sphinx_gallery" in sys.modules 

81 

82 

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

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

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

86 fcntl.flock(fp, fcntl.LOCK_EX) 

87 fp.seek(0) 

88 meta = json.load(fp) 

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

90 complete_plot_index = complete_plot_index + plot_index 

91 meta["plots"] = complete_plot_index 

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

93 os.getenv("DO_CASE_AGGREGATION") 

94 ): 

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

96 fp.seek(0) 

97 fp.truncate() 

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

99 return complete_plot_index 

100 

101 

102def _make_plot_html_page(plots: list): 

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

104 # Debug check that plots actually contains some strings. 

105 assert isinstance(plots[0], str) 

106 

107 # Load HTML template file. 

108 operator_files = importlib.resources.files() 

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

110 

111 # Get some metadata. 

112 meta = get_recipe_metadata() 

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

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

115 

116 # Prepare template variables. 

117 variables = { 

118 "title": title, 

119 "description": description, 

120 "initial_plot": plots[0], 

121 "plots": plots, 

122 "title_slug": slugify(title), 

123 } 

124 

125 # Render template. 

126 html = render_file(template_file, **variables) 

127 

128 # Save completed HTML. 

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

130 fp.write(html) 

131 

132 

133def _setup_spatial_map( 

134 cube: iris.cube.Cube, 

135 figure, 

136 cmap, 

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

138 subplot: int | None = None, 

139): 

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

141 

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

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

144 

145 Parameters 

146 ---------- 

147 cube: Cube 

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

149 figure: 

150 Matplotlib Figure object holding all plot elements. 

151 cmap: 

152 Matplotlib colormap. 

153 grid_size: (int, int), optional 

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

155 subplot: int, optional 

156 Subplot index if multiple spatial subplots in figure. 

157 

158 Returns 

159 ------- 

160 axes: 

161 Matplotlib GeoAxes definition. 

162 """ 

163 # Identify min/max plot bounds. 

164 try: 

165 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

166 xmin = np.nanmin(cube.coord(lon_axis).points) 

167 xmax = np.nanmax(cube.coord(lon_axis).points) 

168 ymin = np.nanmin(cube.coord(lat_axis).points) 

169 ymax = np.nanmax(cube.coord(lat_axis).points) 

170 

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

172 if np.abs(xmax - xmin) > 180.0: 

173 xmin = xmin - 180.0 

174 xmax = xmax - 180.0 

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

176 

177 # Consider map projection orientation. 

178 # Adapting orientation enables plotting across international dateline. 

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

180 if xmax > 180.0 or xmin < -180.0: 

181 central_longitude = 180.0 

182 else: 

183 central_longitude = 0.0 

184 

185 # Define spatial map projection. 

186 coord_system = cube.coord(lat_axis).coord_system 

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

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

189 projection = ccrs.RotatedPole( 

190 pole_longitude=coord_system.grid_north_pole_longitude, 

191 pole_latitude=coord_system.grid_north_pole_latitude, 

192 central_rotated_longitude=central_longitude, 

193 ) 

194 crs = projection 

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

196 # Define Transverse Mercator projection for TM inputs. 

197 projection = ccrs.TransverseMercator( 

198 central_longitude=coord_system.longitude_of_central_meridian, 

199 central_latitude=coord_system.latitude_of_projection_origin, 

200 false_easting=coord_system.false_easting, 

201 false_northing=coord_system.false_northing, 

202 scale_factor=coord_system.scale_factor_at_central_meridian, 

203 ) 

204 crs = projection 

205 else: 

206 # Assume polar projection for regional grids encompassing N. Pole 

207 if ymin > 20.0 and ymax > 80.0: 

208 projection = ccrs.NorthPolarStereo(central_longitude=0.0) 

209 elif ymin < -80.0 and ymax < -20.0: 

210 projection = ccrs.SouthPolarStereo(central_longitude=central_longitude) 

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

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

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

214 # projection = ccrs.NearsidePerspective( 

215 # central_longitude=180.0, 

216 # central_latitude=0, 

217 # satellite_height=35785831, 

218 # ) 

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

220 else: 

221 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

222 crs = ccrs.PlateCarree() 

223 

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

225 if subplot is not None: 

226 axes = figure.add_subplot( 

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

228 ) 

229 else: 

230 axes = figure.add_subplot(projection=projection) 

231 

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

233 # Avoid adding lines for 2D masked data or specific fixed ancillary spatial plots. 

234 if (cube.ndim > 1 and iris.util.is_masked(cube.data)) or any( 

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

236 ): 

237 pass 

238 else: 

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

240 coastcol = "magenta" 

241 else: 

242 coastcol = "black" 

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

244 axes.coastlines(resolution="10m", color=coastcol, alpha=0.8) 

245 axes.add_feature(cfeature.BORDERS, edgecolor=coastcol, alpha=0.3) 

246 

247 # Add gridlines. 

248 gl = axes.gridlines( 

249 alpha=0.3, 

250 draw_labels=True, 

251 dms=False, 

252 x_inline=False, 

253 y_inline=False, 

254 ) 

255 gl.top_labels = False 

256 gl.right_labels = False 

257 if subplot: 

258 gl.bottom_labels = False 

259 gl.left_labels = False 

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

261 gl.left_labels = True 

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

263 gl.bottom_labels = True 

264 

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

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

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

268 axes.set_extent([xmin, xmax, ymin, ymax], crs=crs) 

269 

270 except ValueError: 

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

272 axes = figure.gca() 

273 pass 

274 

275 return axes 

276 

277 

278def _get_plot_resolution() -> int: 

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

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

281 

282 

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

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

285 if use_bounds and seq_coord.has_bounds(): 

286 vals = seq_coord.bounds.flatten() 

287 else: 

288 vals = seq_coord.points 

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

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

291 

292 if start == end: 

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

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

295 else: 

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

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

298 

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

300 if ( 

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

302 and vals[0] == 0 

303 and vals[-1] == 0 

304 ): 

305 sequence_title = "" 

306 sequence_fname = "" 

307 

308 return sequence_title, sequence_fname 

309 

310 

311def _set_title_and_filename( 

312 seq_coord: iris.coords.Coord, 

313 nplot: int, 

314 recipe_title: str, 

315 filename: str, 

316): 

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

318 

319 Parameters 

320 ---------- 

321 sequence_coordinate: iris.coords.Coord 

322 Coordinate about which to make a plot sequence. 

323 nplot: int 

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

325 recipe_title: str 

326 Default plot title, potentially to update. 

327 filename: str 

328 Input plot filename, potentially to update. 

329 

330 Returns 

331 ------- 

332 plot_title: str 

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

334 plot_filename: str 

335 Output formatted plot filename string. 

336 """ 

337 ndim = seq_coord.ndim 

338 npoints = np.size(seq_coord.points) 

339 sequence_title = "" 

340 sequence_fname = "" 

341 

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

343 # (e.g. aggregation histogram plots) 

344 if ndim > 1: 

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

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

347 sequence_fname = f"_{ncase}cases" 

348 

349 # Case 2: Single dimension input 

350 else: 

351 # Single sequence point 

352 if npoints == 1: 

353 if nplot > 1: 

354 # Default labels for sequence inputs 

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

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

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

358 else: 

359 # Aggregated attribute available where input collapsed over aggregation 

360 try: 

361 ncase = seq_coord.attributes["number_reference_times"] 

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

363 sequence_fname = f"_{ncase}cases" 

364 except KeyError: 

365 sequence_title, sequence_fname = _get_start_end_strings( 

366 seq_coord, use_bounds=seq_coord.has_bounds() 

367 ) 

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

369 else: 

370 sequence_title, sequence_fname = _get_start_end_strings( 

371 seq_coord, use_bounds=False 

372 ) 

373 

374 # Set plot title and filename 

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

376 

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

378 if filename is None: 

379 filename = slugify(recipe_title) 

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

381 else: 

382 if nplot > 1: 

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

384 else: 

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

386 

387 return plot_title, plot_filename 

388 

389 

390def _select_series_coord(cube, series_coordinate): 

391 """Determine the grid coordinates to use to calculate grid spacing.""" 

392 spacing_coordinates = ("frequency", "physical_wavenumber", "wavelength") 

393 if series_coordinate in spacing_coordinates: 393 ↛ 399line 393 didn't jump to line 399 because the condition on line 393 was always true

394 # Try the requested coordinate first then the fallbacks in order. 

395 fallbacks = [series_coordinate] + [ 

396 c for c in spacing_coordinates if c != series_coordinate 

397 ] 

398 else: 

399 fallbacks = {series_coordinate} 

400 

401 # Try each possible coordinate. 

402 for coord in fallbacks: 

403 try: 

404 return cube.coord(coord) 

405 except iris.exceptions.CoordinateNotFoundError: 

406 logging.debug("Coordinate %s not found.", coord) 

407 

408 # If we get here, none of the fallback options were found. 

409 raise iris.exceptions.CoordinateNotFoundError( 

410 f"No valid coordinate found for '{series_coordinate}' " 

411 f"or fallback options {fallbacks}" 

412 ) 

413 

414 

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

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

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

418 mtitle = "Member" 

419 else: 

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

421 

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

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

424 else: 

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

426 

427 return mtitle 

428 

429 

430def _set_axis_range(cubes): 

431 """Get minimum and maximum from levels information.""" 

432 levels = None 

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

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

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

436 _, levels, _ = colorbar_map_levels(cube, axis="y") 

437 if levels is None: 

438 break 

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

440 # levels-based ranges for histogram plots. 

441 _, levels, _ = colorbar_map_levels(cube) 

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

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

444 vmin = min(levels) 

445 vmax = max(levels) 

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

447 break 

448 

449 if levels is None: 

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

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

452 

453 return vmin, vmax 

454 

455 

456def _find_matched_slices(cubes, sequence_coordinate): 

457 """Identify matched cubes in CubeList by sequence_coordinate values. 

458 

459 Ensures common points are compared for multiple cube inputs. 

460 """ 

461 all_points = sorted( 

462 set( 

463 itertools.chain.from_iterable( 

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

465 ) 

466 ) 

467 ) 

468 all_slices = list( 

469 itertools.chain.from_iterable( 

470 cb.slices_over(sequence_coordinate) for cb in cubes 

471 ) 

472 ) 

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

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

475 # necessary) 

476 cube_iterables = [ 

477 iris.cube.CubeList( 

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

479 ) 

480 for point in all_points 

481 ] 

482 

483 return cube_iterables 

484 

485 

486def _plot_and_save_spatial_plot( 

487 cube: iris.cube.Cube, 

488 filename: str, 

489 title: str, 

490 method: Literal["contourf", "pcolormesh", "scatter"], 

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

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

493 point_cube: iris.cube.Cube | None = None, 

494 **kwargs, 

495): 

496 """Plot and save a spatial plot. 

497 

498 Parameters 

499 ---------- 

500 cube: Cube 

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

502 filename: str 

503 Filename of the plot to write. 

504 title: str 

505 Plot title. 

506 method: "contourf" | "pcolormesh" | "scatter" 

507 The plotting method to use 

508 Select choice of "contourf" or "pcolormesh" for gridded data. Use "scatter" for point-based data. 

509 overlay_cube: Cube, optional 

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

511 contour_cube: Cube, optional 

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

513 point_cube: Cube, optional 

514 Optional 1 dimensional (e.g. list of points) or 2 dimensional (lat and lon) Cube of data to overplot as map of scatter points over base cube 

515 """ 

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

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

518 

519 # Specify the color bar 

520 cmap, levels, norm = colorbar_map_levels(cube) 

521 

522 # If overplotting, set required colorbars 

523 if overlay_cube: 

524 over_cmap, over_levels, over_norm = colorbar_map_levels(overlay_cube) 

525 if contour_cube: 

526 cntr_cmap, cntr_levels, cntr_norm = colorbar_map_levels(contour_cube) 

527 

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

529 axes = _setup_spatial_map(cube, fig, cmap) 

530 

531 # Set colorscale bounds 

532 try: 

533 vmin = min(levels) 

534 vmax = max(levels) 

535 except TypeError: 

536 vmin, vmax = None, None 

537 # Ensure to use norm and not vmin/vmax if levels are defined. 

538 if norm is not None: 

539 vmin = None 

540 vmax = None 

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

542 

543 # Plot the field. 

544 if method == "contourf": 

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

546 elif method == "pcolormesh": 

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

548 elif method == "scatter": 548 ↛ 556line 548 didn't jump to line 556 because the condition on line 548 was never true

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

550 # symbols that decrease in size as the number of data points 

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

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

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

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

555 # proportion to the area of the figure. 

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

557 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

558 plot = iplt.scatter( 

559 cube.coord(lon_axis), 

560 cube.coord(lat_axis), 

561 c=cube.data[:], 

562 s=mrk_size, 

563 cmap=cmap, 

564 edgecolors="k", 

565 norm=norm, 

566 vmin=vmin, 

567 vmax=vmax, 

568 ) 

569 else: 

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

571 

572 # Overplot overlay field, if required 

573 if overlay_cube: 

574 try: 

575 over_vmin = min(over_levels) 

576 over_vmax = max(over_levels) 

577 except TypeError: 

578 over_vmin, over_vmax = None, None 

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

580 over_vmin = None 

581 over_vmax = None 

582 overlay = iplt.pcolormesh( 

583 overlay_cube, 

584 cmap=over_cmap, 

585 norm=over_norm, 

586 alpha=0.8, 

587 vmin=over_vmin, 

588 vmax=over_vmax, 

589 ) 

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

591 if contour_cube: 

592 contour = iplt.contour( 

593 contour_cube, 

594 colors="darkgray", 

595 levels=cntr_levels, 

596 norm=cntr_norm, 

597 alpha=0.5, 

598 linestyles="--", 

599 linewidths=1, 

600 ) 

601 plt.clabel(contour) 

602 # Overplot valid elements of point-based field, if required. 

603 # Check for non-masked points only to avoid plotting missing data. 

604 if point_cube: 604 ↛ 605line 604 didn't jump to line 605 because the condition on line 604 was never true

605 mrk_size = int(np.sqrt(2500000.0 / len(point_cube.data))) 

606 lat_axis, lon_axis = get_cube_yxcoordname(point_cube) 

607 lon_coord = point_cube.coord(lon_axis) 

608 lat_coord = point_cube.coord(lat_axis) 

609 valid = ~point_cube.data.mask 

610 valid_lon = iris.coords.AuxCoord( 

611 lon_coord.points[valid], 

612 standard_name=lon_coord.standard_name, 

613 units=lon_coord.units, 

614 coord_system=lon_coord.coord_system, 

615 ) 

616 valid_lat = iris.coords.AuxCoord( 

617 lat_coord.points[valid], 

618 standard_name=lat_coord.standard_name, 

619 units=lat_coord.units, 

620 coord_system=lat_coord.coord_system, 

621 ) 

622 iplt.scatter( 

623 valid_lon, 

624 valid_lat, 

625 c=point_cube.data[valid], 

626 s=mrk_size, 

627 cmap=cmap, 

628 edgecolors="k", 

629 norm=norm, 

630 vmin=vmin, 

631 vmax=vmax, 

632 ) 

633 

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

635 if is_transect(cube): 

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

637 axes.invert_yaxis() 

638 axes.set_yscale("log") 

639 axes.set_ylim(1100, 100) 

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

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

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

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

644 ): 

645 axes.set_yscale("log") 

646 

647 axes.set_title( 

648 f"{title}\n" 

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

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

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

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

653 fontsize=16, 

654 ) 

655 

656 # Inset code 

657 axins = inset_axes( 

658 axes, 

659 width="20%", 

660 height="20%", 

661 loc="upper right", 

662 axes_class=GeoAxes, 

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

664 ) 

665 

666 # Slightly transparent to reduce plot blocking. 

667 axins.patch.set_alpha(0.4) 

668 

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

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

671 

672 SLat, SLon, ELat, ELon = ( 

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

674 ) 

675 

676 # Draw line between them 

677 axins.plot( 

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

679 ) 

680 

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

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

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

684 

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

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

687 

688 # Midpoints 

689 lon_mid = (lon_min + lon_max) / 2 

690 lat_mid = (lat_min + lat_max) / 2 

691 

692 # Maximum half-range 

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

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

695 half_range = 1 

696 

697 # Set square extent 

698 axins.set_extent( 

699 [ 

700 lon_mid - half_range, 

701 lon_mid + half_range, 

702 lat_mid - half_range, 

703 lat_mid + half_range, 

704 ], 

705 crs=ccrs.PlateCarree(), 

706 ) 

707 

708 # Ensure square aspect 

709 axins.set_aspect("equal") 

710 

711 else: 

712 # Add title. 

713 axes.set_title(title, fontsize=16) 

714 

715 # Adjust padding if spatial plot or transect 

716 if is_transect(cube): 

717 yinfopad = -0.1 

718 ycbarpad = 0.1 

719 else: 

720 yinfopad = 0.01 

721 ycbarpad = 0.042 

722 

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

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

725 axes.annotate( 

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

727 xy=(0.025, yinfopad), 

728 xycoords="axes fraction", 

729 xytext=(-5, 5), 

730 textcoords="offset points", 

731 ha="left", 

732 va="bottom", 

733 size=11, 

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

735 ) 

736 

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

738 if overlay_cube: 

739 cbarB = fig.colorbar( 

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

741 ) 

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

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

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

745 cbarB.set_ticks(over_levels) 

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

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

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

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

750 

751 # Add main colour bar. 

752 cbar = fig.colorbar( 

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

754 ) 

755 

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

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

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

759 cbar.set_ticks(levels) 

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

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

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

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

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

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

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

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

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

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

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

771 cbar.minorticks_off() 

772 cbar.set_ticks(tick_levels) 

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

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

775 # Tick labels for model rainfall data. 

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

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

778 # Tick labels for Nimrod weights data. 

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

780 

781 # Save plot. 

782 if not in_sphinx_gallery(): 782 ↛ exitline 782 didn't return from function '_plot_and_save_spatial_plot' because the condition on line 782 was always true

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

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

785 plt.close(fig) 

786 

787 

788def _plot_and_save_postage_stamp_spatial_plot( 

789 cube: iris.cube.Cube, 

790 filename: str, 

791 stamp_coordinate: str, 

792 title: str, 

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

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

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

796 **kwargs, 

797): 

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

799 

800 Parameters 

801 ---------- 

802 cube: Cube 

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

804 filename: str 

805 Filename of the plot to write. 

806 stamp_coordinate: str 

807 Coordinate that becomes different plots. 

808 method: "contourf" | "pcolormesh" 

809 The plotting method to use. 

810 overlay_cube: Cube, optional 

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

812 contour_cube: Cube, optional 

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

814 

815 Raises 

816 ------ 

817 ValueError 

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

819 """ 

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

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

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

823 grid_size = math.ceil(nmember / grid_rows) 

824 

825 fig = plt.figure( 

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

827 ) 

828 

829 # Specify the color bar 

830 cmap, levels, norm = colorbar_map_levels(cube) 

831 # If overplotting, set required colorbars 

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

833 over_cmap, over_levels, over_norm = colorbar_map_levels(overlay_cube) 

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

835 cntr_cmap, cntr_levels, cntr_norm = colorbar_map_levels(contour_cube) 

836 

837 # Make a subplot for each member. 

838 for member, subplot in zip( 

839 cube.slices_over(stamp_coordinate), 

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

841 strict=False, 

842 ): 

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

844 axes = _setup_spatial_map( 

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

846 ) 

847 if method == "contourf": 

848 # Filled contour plot of the field. 

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

850 elif method == "pcolormesh": 

851 if levels is not None: 

852 vmin = min(levels) 

853 vmax = max(levels) 

854 else: 

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

856 vmin, vmax = None, None 

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

858 # if levels are defined. 

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

860 vmin = None 

861 vmax = None 

862 # pcolormesh plot of the field. 

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

864 else: 

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

866 

867 # Overplot overlay field, if required 

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

869 try: 

870 over_vmin = min(over_levels) 

871 over_vmax = max(over_levels) 

872 except TypeError: 

873 over_vmin, over_vmax = None, None 

874 if over_norm is not None: 

875 over_vmin = None 

876 over_vmax = None 

877 iplt.pcolormesh( 

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

879 cmap=over_cmap, 

880 norm=over_norm, 

881 alpha=0.6, 

882 vmin=over_vmin, 

883 vmax=over_vmax, 

884 ) 

885 # Overplot contour field, if required 

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

887 iplt.contour( 

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

889 colors="darkgray", 

890 levels=cntr_levels, 

891 norm=cntr_norm, 

892 alpha=0.6, 

893 linestyles="--", 

894 linewidths=1, 

895 ) 

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

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

898 

899 # Put the shared colorbar in its own axes. 

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

901 colorbar = fig.colorbar( 

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

903 ) 

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

905 

906 # Overall figure title. 

907 fig.suptitle(title, fontsize=16) 

908 if not in_sphinx_gallery(): 908 ↛ exitline 908 didn't return from function '_plot_and_save_postage_stamp_spatial_plot' because the condition on line 908 was always true

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

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

911 plt.close(fig) 

912 

913 

914def _plot_and_save_line_series( 

915 cubes: iris.cube.CubeList, 

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

917 ensemble_coord: str, 

918 filename: str, 

919 title: str, 

920 **kwargs, 

921): 

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

923 

924 Parameters 

925 ---------- 

926 cubes: Cube or CubeList 

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

928 coords: list[Coord] 

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

930 ensemble_coord: str 

931 Ensemble coordinate in the cube. 

932 filename: str 

933 Filename of the plot to write. 

934 title: str 

935 Plot title. 

936 """ 

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

938 

939 model_colors_map = get_model_colors_map(cubes) 

940 

941 # Store min/max ranges. 

942 y_levels = [] 

943 

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

945 validate_cubes_coords(cubes, coords) 

946 

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

948 label = None 

949 color = "black" 

950 if model_colors_map: 

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

952 color = model_colors_map.get(label) 

953 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

956 iplt.plot( 

957 coord, 

958 cube_slice, 

959 color=color, 

960 marker="o", 

961 ls="-", 

962 lw=3, 

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

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

965 else label, 

966 ) 

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

968 else: 

969 iplt.plot( 

970 coord, 

971 cube_slice, 

972 color=color, 

973 ls="-", 

974 lw=1.5, 

975 alpha=0.75, 

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

977 ) 

978 

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

980 _, levels, _ = colorbar_map_levels(cube, axis="y") 

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

982 y_levels.append(min(levels)) 

983 y_levels.append(max(levels)) 

984 

985 # Get the current axes. 

986 ax = plt.gca() 

987 

988 # Add some labels and tweak the style. 

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

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

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

992 else: 

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

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

995 ax.set_title(title, fontsize=16) 

996 

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

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

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

1000 

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

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

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

1004 # Add zero line. 

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

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

1007 logging.debug( 

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

1009 ) 

1010 else: 

1011 ax.autoscale() 

1012 

1013 # Add gridlines 

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

1015 # Ientify unique labels for legend 

1016 handles = list( 

1017 { 

1018 label: handle 

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

1020 }.values() 

1021 ) 

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

1023 

1024 # Save plot. 

1025 if not in_sphinx_gallery(): 1025 ↛ exitline 1025 didn't return from function '_plot_and_save_line_series' because the condition on line 1025 was always true

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

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

1028 plt.close(fig) 

1029 

1030 

1031def _plot_and_save_line_power_spectrum_series( 

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

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

1034 ensemble_coord: str, 

1035 filename: str, 

1036 title: str, 

1037 series_coordinate: str, 

1038 **kwargs, 

1039): 

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

1041 

1042 Parameters 

1043 ---------- 

1044 cubes: Cube or CubeList 

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

1046 coords: list[Coord] 

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

1048 ensemble_coord: str 

1049 Ensemble coordinate in the cube. 

1050 filename: str 

1051 Filename of the plot to write. 

1052 title: str 

1053 Plot title. 

1054 series_coordinate: str 

1055 Coordinate being plotted on x-axis. In case of spectra frequency, physical_wavenumber, or wavelength. 

1056 """ 

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

1058 model_colors_map = get_model_colors_map(cubes) 

1059 ax = plt.gca() 

1060 

1061 # Store min/max ranges. 

1062 y_levels = [] 

1063 

1064 line_marker = None 

1065 line_width = 1 

1066 

1067 for cube in iter_maybe(cubes): 

1068 # next 2 lines replace chunk of code. 

1069 xcoord = _select_series_coord(cube, series_coordinate) 

1070 xname = xcoord.points 

1071 

1072 yfield = cube.data # power spectrum 

1073 label = None 

1074 color = "black" 

1075 if model_colors_map: 1075 ↛ 1078line 1075 didn't jump to line 1078 because the condition on line 1075 was always true

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

1077 color = model_colors_map.get(label) 

1078 for cube_slice in cube.slices_over(ensemble_coord): 

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

1080 if cube_slice.coord(ensemble_coord).points == [0]: 1080 ↛ 1094line 1080 didn't jump to line 1094 because the condition on line 1080 was always true

1081 ax.plot( 

1082 xname, 

1083 yfield, 

1084 color=color, 

1085 marker=line_marker, 

1086 ls="-", 

1087 lw=line_width, 

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

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

1090 else label, 

1091 ) 

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

1093 else: 

1094 ax.plot( 

1095 xname, 

1096 yfield, 

1097 color=color, 

1098 ls="-", 

1099 lw=1.5, 

1100 alpha=0.75, 

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

1102 ) 

1103 

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

1105 _, levels, _ = colorbar_map_levels(cube, axis="y") 

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

1107 y_levels.append(min(levels)) 

1108 y_levels.append(max(levels)) 

1109 

1110 # Add some labels and tweak the style. 

1111 

1112 title = f"{title}" 

1113 ax.set_title(title, fontsize=16) 

1114 

1115 # Set appropriate x-axis label based on coordinate 

1116 if series_coordinate == "wavelength" or ( 1116 ↛ 1119line 1116 didn't jump to line 1119 because the condition on line 1116 was never true

1117 hasattr(xcoord, "long_name") and xcoord.long_name == "wavelength" 

1118 ): 

1119 ax.set_xlabel("Wavelength (km)", fontsize=14) 

1120 elif series_coordinate == "physical_wavenumber" or ( 1120 ↛ 1123line 1120 didn't jump to line 1123 because the condition on line 1120 was never true

1121 hasattr(xcoord, "long_name") and xcoord.long_name == "physical_wavenumber" 

1122 ): 

1123 ax.set_xlabel("Wavenumber (km⁻¹)", fontsize=14) 

1124 else: # frequency or check units 

1125 if hasattr(xcoord, "units") and str(xcoord.units) == "km-1": 1125 ↛ 1126line 1125 didn't jump to line 1126 because the condition on line 1125 was never true

1126 ax.set_xlabel("Wavenumber (km⁻¹)", fontsize=14) 

1127 else: 

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

1129 

1130 ax.set_ylabel("Power Spectral Density", fontsize=14) 

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

1132 

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

1134 

1135 # Set log-log scale 

1136 ax.set_xscale("log") 

1137 ax.set_yscale("log") 

1138 

1139 # Add gridlines 

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

1141 # Ientify unique labels for legend 

1142 handles = list( 

1143 { 

1144 label: handle 

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

1146 }.values() 

1147 ) 

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

1149 

1150 # Save plot. 

1151 if not in_sphinx_gallery(): 1151 ↛ exitline 1151 didn't return from function '_plot_and_save_line_power_spectrum_series' because the condition on line 1151 was always true

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

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

1154 plt.close(fig) 

1155 

1156 

1157def _plot_and_save_vertical_line_series( 

1158 cubes: iris.cube.CubeList, 

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

1160 ensemble_coord: str, 

1161 filename: str, 

1162 series_coordinate: str, 

1163 title: str, 

1164 vmin: float, 

1165 vmax: float, 

1166 **kwargs, 

1167): 

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

1169 

1170 Parameters 

1171 ---------- 

1172 cubes: CubeList 

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

1174 coord: list[Coord] 

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

1176 ensemble_coord: str 

1177 Ensemble coordinate in the cube. 

1178 filename: str 

1179 Filename of the plot to write. 

1180 series_coordinate: str 

1181 Coordinate to use as vertical axis. 

1182 title: str 

1183 Plot title. 

1184 vmin: float 

1185 Minimum value for the x-axis. 

1186 vmax: float 

1187 Maximum value for the x-axis. 

1188 """ 

1189 # plot the vertical pressure axis using log scale 

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

1191 

1192 model_colors_map = get_model_colors_map(cubes) 

1193 

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

1195 validate_cubes_coords(cubes, coords) 

1196 

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

1198 label = None 

1199 color = "black" 

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

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

1202 color = model_colors_map.get(label) 

1203 

1204 for cube_slice in cube.slices_over(ensemble_coord): 

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

1206 # unless single forecast. 

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

1208 iplt.plot( 

1209 cube_slice, 

1210 coord, 

1211 color=color, 

1212 marker="o", 

1213 ls="-", 

1214 lw=3, 

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

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

1217 else label, 

1218 ) 

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

1220 else: 

1221 iplt.plot( 

1222 cube_slice, 

1223 coord, 

1224 color=color, 

1225 ls="-", 

1226 lw=1.5, 

1227 alpha=0.75, 

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

1229 ) 

1230 

1231 # Get the current axis 

1232 ax = plt.gca() 

1233 

1234 # Special handling for pressure level data. 

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

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

1237 ax.invert_yaxis() 

1238 ax.set_yscale("log") 

1239 

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

1241 y_tick_labels = [ 

1242 "1000", 

1243 "850", 

1244 "700", 

1245 "500", 

1246 "300", 

1247 "200", 

1248 "100", 

1249 ] 

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

1251 

1252 # Set y-axis limits and ticks. 

1253 ax.set_ylim(1100, 100) 

1254 

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

1256 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

1262 

1263 ax.set_yticks(y_ticks) 

1264 ax.set_yticklabels(y_tick_labels) 

1265 

1266 # Set x-axis limits. 

1267 ax.set_xlim(vmin, vmax) 

1268 # Mark y=0 if present in plot. 

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

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

1271 

1272 # Add some labels and tweak the style. 

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

1274 ax.set_xlabel( 

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

1276 ) 

1277 ax.set_title(title, fontsize=16) 

1278 ax.ticklabel_format(axis="x") 

1279 ax.tick_params(axis="y") 

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

1281 

1282 # Add gridlines 

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

1284 # Ientify unique labels for legend 

1285 handles = list( 

1286 { 

1287 label: handle 

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

1289 }.values() 

1290 ) 

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

1292 

1293 # Save plot. 

1294 if not in_sphinx_gallery(): 1294 ↛ exitline 1294 didn't return from function '_plot_and_save_vertical_line_series' because the condition on line 1294 was always true

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

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

1297 plt.close(fig) 

1298 

1299 

1300def _plot_and_save_scatter_plot( 

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

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

1303 filename: str, 

1304 title: str, 

1305 one_to_one: bool, 

1306 model_names: list[str] = None, 

1307 **kwargs, 

1308): 

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

1310 

1311 Parameters 

1312 ---------- 

1313 cube_x: Cube | CubeList 

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

1315 cube_y: Cube | CubeList 

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

1317 filename: str 

1318 Filename of the plot to write. 

1319 title: str 

1320 Plot title. 

1321 one_to_one: bool 

1322 Whether a 1:1 line is plotted. 

1323 """ 

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

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

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

1327 # over the pairs simultaneously. 

1328 

1329 # Ensure cube_x and cube_y are iterable 

1330 cube_x_iterable = iter_maybe(cube_x) 

1331 cube_y_iterable = iter_maybe(cube_y) 

1332 

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

1334 iplt.scatter(cube_x_iter, cube_y_iter) 

1335 if one_to_one is True: 

1336 plt.plot( 

1337 [ 

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

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

1340 ], 

1341 [ 

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

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

1344 ], 

1345 "k", 

1346 linestyle="--", 

1347 ) 

1348 ax = plt.gca() 

1349 

1350 # Add some labels and tweak the style. 

1351 if model_names is None: 

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

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

1354 else: 

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

1356 ax.set_xlabel( 

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

1358 ) 

1359 ax.set_ylabel( 

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

1361 ) 

1362 ax.set_title(title, fontsize=16) 

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

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

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

1366 ax.autoscale() 

1367 

1368 # Save plot. 

1369 if not in_sphinx_gallery(): 1369 ↛ exitline 1369 didn't return from function '_plot_and_save_scatter_plot' because the condition on line 1369 was always true

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

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

1372 plt.close(fig) 

1373 

1374 

1375def _plot_and_save_vector_plot( 

1376 cube_u: iris.cube.Cube, 

1377 cube_v: iris.cube.Cube, 

1378 filename: str, 

1379 title: str, 

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

1381 **kwargs, 

1382): 

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

1384 

1385 Parameters 

1386 ---------- 

1387 cube_u: Cube 

1388 2 dimensional Cube of u component of the data. 

1389 cube_v: Cube 

1390 2 dimensional Cube of v component of the data. 

1391 filename: str 

1392 Filename of the plot to write. 

1393 title: str 

1394 Plot title. 

1395 """ 

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

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

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

1399 cube_vec_mag.rename(f"{cube_u.long_name}_{cube_v.long_name}_magnitude") 

1400 if "eastward_wind" in cube_u.long_name and "northward_wind" in cube_v.long_name: 

1401 cube_vec_mag.rename( 

1402 "wind_speed" + cube_u.long_name.replace("eastward_wind", "") 

1403 ) 

1404 

1405 # Specify the color bar 

1406 cmap, levels, norm = colorbar_map_levels(cube_vec_mag) 

1407 

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

1409 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

1410 

1411 if method == "contourf": 

1412 # Filled contour plot of the field. 

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

1414 elif method == "pcolormesh": 

1415 try: 

1416 vmin = min(levels) 

1417 vmax = max(levels) 

1418 except TypeError: 

1419 vmin, vmax = None, None 

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

1421 # if levels are defined. 

1422 if norm is not None: 

1423 vmin = None 

1424 vmax = None 

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

1426 else: 

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

1428 

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

1430 if is_transect(cube_vec_mag): 

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

1432 axes.invert_yaxis() 

1433 axes.set_yscale("log") 

1434 axes.set_ylim(1100, 100) 

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

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

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

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

1439 ): 

1440 axes.set_yscale("log") 

1441 

1442 axes.set_title( 

1443 f"{title}\n" 

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

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

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

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

1448 fontsize=16, 

1449 ) 

1450 

1451 else: 

1452 # Add title. 

1453 axes.set_title(title, fontsize=16) 

1454 

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

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

1457 axes.annotate( 

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

1459 xy=(0.05, -0.05), 

1460 xycoords="axes fraction", 

1461 xytext=(-5, 5), 

1462 textcoords="offset points", 

1463 ha="right", 

1464 va="bottom", 

1465 size=11, 

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

1467 ) 

1468 

1469 # Add colour bar. 

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

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

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

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

1474 cbar.set_ticks(levels) 

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

1476 

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

1478 # with less than 30 points. 

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

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

1481 

1482 # Save plot. 

1483 if not in_sphinx_gallery(): 

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

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

1486 plt.close(fig) 

1487 

1488 

1489def _plot_and_save_histogram_series( 

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

1491 filename: str, 

1492 title: str, 

1493 vmin: float, 

1494 vmax: float, 

1495 **kwargs, 

1496): 

1497 """Plot and save a histogram series. 

1498 

1499 Parameters 

1500 ---------- 

1501 cubes: Cube or CubeList 

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

1503 filename: str 

1504 Filename of the plot to write. 

1505 title: str 

1506 Plot title. 

1507 vmin: float 

1508 minimum for colorbar 

1509 vmax: float 

1510 maximum for colorbar 

1511 """ 

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

1513 ax = plt.gca() 

1514 

1515 model_colors_map = get_model_colors_map(cubes) 

1516 

1517 # Set default that histograms will produce probability density function 

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

1519 density = True 

1520 

1521 for cube in iter_maybe(cubes): 

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

1523 # than seeing if long names exist etc. 

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

1525 if ( 

1526 ("surface_microphysical" in title) 

1527 or ("rain accumulation" in title) 

1528 or ("Rainfall rate Composite" in title) 

1529 or ("Nimrod_5min" in title) 

1530 ): 

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

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

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

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

1535 density = False 

1536 else: 

1537 bins = 10.0 ** ( 

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

1539 ) # Suggestion from RMED toolbox. 

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

1541 ax.set_yscale("log") 

1542 vmin = bins[1] 

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

1544 ax.set_xscale("log") 

1545 elif "lightning" in title: 

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

1547 else: 

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

1549 logging.debug( 

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

1551 np.size(bins), 

1552 np.min(bins), 

1553 np.max(bins), 

1554 ) 

1555 

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

1557 # Otherwise we plot xdim histograms stacked. 

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

1559 

1560 label = None 

1561 color = "black" 

1562 if model_colors_map: 

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

1564 color = model_colors_map[label] 

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

1566 

1567 # Compute area under curve. 

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

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

1570 or ("rain_accumulation" in title) 

1571 or ("Rainfall rate Composite" in title) 

1572 or ("Nimrod_5min" in title) 

1573 ): 

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

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

1576 x = x[1:] 

1577 y = y[1:] 

1578 

1579 ax.plot( 

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

1581 ) 

1582 

1583 # Add some labels and tweak the style. 

1584 ax.set_title(title, fontsize=16) 

1585 ax.set_xlabel( 

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

1587 ) 

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

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

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

1591 or ("rain accumulation" in title) 

1592 or ("Nimrod_5min" in title) 

1593 ): 

1594 ax.set_ylabel( 

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

1596 ) 

1597 ax.set_xlim(vmin, vmax) 

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

1599 

1600 # Overlay grid-lines onto histogram plot. 

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

1602 if model_colors_map: 

1603 ax.legend(loc="best", ncol=1, frameon=True, fontsize=16) 

1604 

1605 # Save plot. 

1606 if not in_sphinx_gallery(): 1606 ↛ exitline 1606 didn't return from function '_plot_and_save_histogram_series' because the condition on line 1606 was always true

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

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

1609 plt.close(fig) 

1610 

1611 

1612def _plot_and_save_postage_stamp_histogram_series( 

1613 cube: iris.cube.Cube, 

1614 filename: str, 

1615 title: str, 

1616 stamp_coordinate: str, 

1617 vmin: float, 

1618 vmax: float, 

1619 **kwargs, 

1620): 

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

1622 

1623 Parameters 

1624 ---------- 

1625 cube: Cube 

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

1627 filename: str 

1628 Filename of the plot to write. 

1629 title: str 

1630 Plot title. 

1631 stamp_coordinate: str 

1632 Coordinate that becomes different plots. 

1633 vmin: float 

1634 minimum for pdf x-axis 

1635 vmax: float 

1636 maximum for pdf x-axis 

1637 """ 

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

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

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

1641 grid_size = math.ceil(nmember / grid_rows) 

1642 

1643 fig = plt.figure( 

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

1645 ) 

1646 # Make a subplot for each member. 

1647 for member, subplot in zip( 

1648 cube.slices_over(stamp_coordinate), 

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

1650 strict=False, 

1651 ): 

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

1653 # cartopy GeoAxes generated. 

1654 plt.subplot(grid_rows, grid_size, subplot) 

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

1656 # Otherwise we plot xdim histograms stacked. 

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

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

1659 axes = plt.gca() 

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

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

1662 axes.set_xlim(vmin, vmax) 

1663 

1664 # Overall figure title. 

1665 fig.suptitle(title, fontsize=16) 

1666 if not in_sphinx_gallery(): 1666 ↛ exitline 1666 didn't return from function '_plot_and_save_postage_stamp_histogram_series' because the condition on line 1666 was always true

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

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

1669 plt.close(fig) 

1670 

1671 

1672def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1673 cube: iris.cube.Cube, 

1674 filename: str, 

1675 title: str, 

1676 stamp_coordinate: str, 

1677 vmin: float, 

1678 vmax: float, 

1679 **kwargs, 

1680): 

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

1682 ax.set_title(title, fontsize=16) 

1683 ax.set_xlim(vmin, vmax) 

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

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

1686 # Loop over all slices along the stamp_coordinate 

1687 for member in cube.slices_over(stamp_coordinate): 

1688 # Flatten the member data to 1D 

1689 member_data_1d = member.data.flatten() 

1690 # Plot the histogram using plt.hist 

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

1692 plt.hist( 

1693 member_data_1d, 

1694 density=True, 

1695 stacked=True, 

1696 label=f"{mtitle}", 

1697 ) 

1698 

1699 # Add a legend 

1700 ax.legend(fontsize=16) 

1701 

1702 # Save the figure to a file 

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

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

1705 

1706 # Close the figure 

1707 plt.close(fig) 

1708 

1709 

1710def _spatial_plot( 

1711 method: Literal["contourf", "pcolormesh", "scatter"], 

1712 cube: iris.cube.Cube, 

1713 filename: str | None, 

1714 sequence_coordinate: str, 

1715 stamp_coordinate: str, 

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

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

1718 point_cube: iris.cube.Cube | None = None, 

1719 **kwargs, 

1720): 

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

1722 

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

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

1725 is present then postage stamp plots will be produced. 

1726 

1727 If any optional overlay_cube, contour_cube or point_cube are specified, multiple data layers can 

1728 be overplotted on the same figure. 

1729 

1730 Parameters 

1731 ---------- 

1732 method: "contourf" | "pcolormesh" | "scatter" 

1733 The plotting method to use. 

1734 Select choice of "contourf" or "pcolormesh" for gridded data. 

1735 Use "scatter" for point-based data. 

1736 cube: Cube 

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

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

1739 plotted sequentially and/or as postage stamp plots. 

1740 filename: str | None 

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

1742 uses the recipe name. 

1743 sequence_coordinate: str 

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

1745 This coordinate must exist in the cube. 

1746 stamp_coordinate: str 

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

1748 ``"realization"``. 

1749 overlay_cube: Cube | None, optional 

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

1751 contour_cube: Cube | None, optional 

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

1753 point_cube: Cube | None, optional 

1754 Optional 1 dimensional (e.g. list of points) or 2 dimensional (lat and lon) Cube of data to overplot as map of scatter points over base cube 

1755 

1756 Raises 

1757 ------ 

1758 ValueError 

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

1760 TypeError 

1761 If the cube isn't a single cube. 

1762 """ 

1763 # Ensure we've got a single cube. 

1764 cube = check_single_cube(cube) 

1765 

1766 # Set title based on recipe metadata or use cube name 

1767 recipe_title = get_recipe_metadata().get("title", cube.name()) 

1768 

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

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

1771 stamp_coordinate = check_stamp_coordinate(cube) 

1772 

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

1774 # single point. 

1775 plotting_func = _plot_and_save_spatial_plot 

1776 try: 

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

1778 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1779 except iris.exceptions.CoordinateNotFoundError: 

1780 pass 

1781 

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

1783 # dimension called observation or model_obs_error 

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

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

1786 for crd in cube.coords() 

1787 ): 

1788 plotting_func = _plot_and_save_spatial_plot 

1789 method = "scatter" 

1790 

1791 # Must have a sequence coordinate. 

1792 try: 

1793 cube.coord(sequence_coordinate) 

1794 except iris.exceptions.CoordinateNotFoundError as err: 

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

1796 

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

1798 plot_index = [] 

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

1800 

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

1802 # Set plot titles and filename 

1803 seq_coord = cube_slice.coord(sequence_coordinate) 

1804 plot_title, plot_filename = _set_title_and_filename( 

1805 seq_coord, nplot, recipe_title, filename 

1806 ) 

1807 

1808 # Extract sequence slice for overlay_cube, contour_cube and point_cube if required. 

1809 overlay_slice = slice_over_maybe(overlay_cube, sequence_coordinate, iseq) 

1810 contour_slice = slice_over_maybe(contour_cube, sequence_coordinate, iseq) 

1811 point_slice = slice_over_maybe(point_cube, sequence_coordinate, iseq) 

1812 

1813 # Do the actual plotting. 

1814 plotting_func( 

1815 cube_slice, 

1816 filename=plot_filename, 

1817 stamp_coordinate=stamp_coordinate, 

1818 title=plot_title, 

1819 method=method, 

1820 overlay_cube=overlay_slice, 

1821 contour_cube=contour_slice, 

1822 point_cube=point_slice, 

1823 **kwargs, 

1824 ) 

1825 plot_index.append(plot_filename) 

1826 

1827 # Add list of plots to plot metadata. 

1828 complete_plot_index = _append_to_plot_index(plot_index) 

1829 

1830 # Make a page to display the plots. 

1831 _make_plot_html_page(complete_plot_index) 

1832 

1833 

1834#################### 

1835# Public functions # 

1836#################### 

1837 

1838 

1839def spatial_contour_plot( 

1840 cube: iris.cube.Cube, 

1841 filename: str = None, 

1842 sequence_coordinate: str = "time", 

1843 stamp_coordinate: str = "realization", 

1844 **kwargs, 

1845) -> iris.cube.Cube: 

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

1847 

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

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

1850 is present then postage stamp plots will be produced. 

1851 

1852 Parameters 

1853 ---------- 

1854 cube: Cube 

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

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

1857 plotted sequentially and/or as postage stamp plots. 

1858 filename: str, optional 

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

1860 to the recipe name. 

1861 sequence_coordinate: str, optional 

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

1863 This coordinate must exist in the cube. 

1864 stamp_coordinate: str, optional 

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

1866 ``"realization"``. 

1867 

1868 Returns 

1869 ------- 

1870 Cube 

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

1872 

1873 Raises 

1874 ------ 

1875 ValueError 

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

1877 TypeError 

1878 If the cube isn't a single cube. 

1879 """ 

1880 _spatial_plot( 

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

1882 ) 

1883 return cube 

1884 

1885 

1886def spatial_pcolormesh_plot( 

1887 cube: iris.cube.Cube, 

1888 filename: str = None, 

1889 sequence_coordinate: str = "time", 

1890 stamp_coordinate: str = "realization", 

1891 **kwargs, 

1892) -> iris.cube.Cube: 

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

1894 

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

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

1897 is present then postage stamp plots will be produced. 

1898 

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

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

1901 contour areas are important. 

1902 

1903 Parameters 

1904 ---------- 

1905 cube: Cube 

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

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

1908 plotted sequentially and/or as postage stamp plots. 

1909 filename: str, optional 

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

1911 to the recipe name. 

1912 sequence_coordinate: str, optional 

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

1914 This coordinate must exist in the cube. 

1915 stamp_coordinate: str, optional 

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

1917 ``"realization"``. 

1918 

1919 Returns 

1920 ------- 

1921 Cube 

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

1923 

1924 Raises 

1925 ------ 

1926 ValueError 

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

1928 TypeError 

1929 If the cube isn't a single cube. 

1930 """ 

1931 _spatial_plot( 

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

1933 ) 

1934 return cube 

1935 

1936 

1937def spatial_multi_pcolormesh_plot( 

1938 cube: iris.cube.Cube, 

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

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

1941 point_cube: iris.cube.Cube | None = None, 

1942 filename: str | None = None, 

1943 sequence_coordinate: str = "time", 

1944 stamp_coordinate: str = "realization", 

1945 **kwargs, 

1946) -> iris.cube.Cube: 

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

1948 

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

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

1951 is present then postage stamp plots will be produced. 

1952 

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

1954 

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

1956 

1957 If specified, a spatial scatter map of point_cube can be overplotted. 

1958 

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

1960 

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

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

1963 contour areas are important. 

1964 

1965 Parameters 

1966 ---------- 

1967 cube: Cube 

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

1969 such as lat and lon, and may also have two additional dimensions to be 

1970 plotted sequentially and/or as postage stamp plots. 

1971 overlay_cube: Cube, optional 

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

1973 such as lat and lon, and may also have two additional dimensions to be 

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

1975 If not provided, output plot generated without overlay cube. 

1976 contour_cube: Cube, optional 

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

1978 such as lat and lon, and may also have two additional dimensions to be 

1979 plotted sequentially and/or as postage stamp plots. If not provided, output plot generated without contours. 

1980 point_cube: Cube, optional 

1981 Iris cube of the data to plot as a scatter map overlay on top of basis cube (overlay_cube and/or contour_cube). It should have two 

1982 spatial dimensions, such as lat and lon, but these can describe a 1-D cube (e.g. list of 

1983 observation stations with lat/lon coordinates) and may also have two additional dimensions to be plotted sequentially and/or as 

1984 postage stamp plots. If not provided, output plot generated without point-based layer. 

1985 filename: str, optional 

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

1987 to the recipe name. 

1988 sequence_coordinate: str, optional 

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

1990 This coordinate must exist in the cube. 

1991 stamp_coordinate: str, optional 

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

1993 ``"realization"``. 

1994 

1995 Returns 

1996 ------- 

1997 Cube 

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

1999 

2000 Raises 

2001 ------ 

2002 ValueError 

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

2004 TypeError 

2005 If the cube isn't a single cube. 

2006 """ 

2007 _spatial_plot( 

2008 "pcolormesh", 

2009 cube, 

2010 filename, 

2011 sequence_coordinate, 

2012 stamp_coordinate, 

2013 overlay_cube=overlay_cube, 

2014 contour_cube=contour_cube, 

2015 point_cube=point_cube, 

2016 ) 

2017 return cube, overlay_cube, contour_cube, point_cube 

2018 

2019 

2020# TODO: Expand function to handle ensemble data. 

2021# line_coordinate: str, optional 

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

2023# ``"realization"``. 

2024def plot_line_series( 

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

2026 filename: str = None, 

2027 series_coordinate: str = "time", 

2028 sequence_coordinate: str = "time", 

2029 # add the following for ensembles 

2030 stamp_coordinate: str = "realization", 

2031 single_plot: bool = False, 

2032 **kwargs, 

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

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

2035 

2036 The Cube or CubeList must be 1D. 

2037 

2038 Parameters 

2039 ---------- 

2040 iris.cube | iris.cube.CubeList 

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

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

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

2044 filename: str, optional 

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

2046 to the recipe name. 

2047 series_coordinate: str, optional 

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

2049 coordinate must exist in the cube. 

2050 

2051 Returns 

2052 ------- 

2053 iris.cube.Cube | iris.cube.CubeList 

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

2055 

2056 Raises 

2057 ------ 

2058 ValueError 

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

2060 TypeError 

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

2062 """ 

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

2064 recipe_title = get_recipe_metadata().get("title", iter_maybe(cube)[0].name()) 

2065 

2066 num_models = get_num_models(cube) 

2067 

2068 validate_cube_shape(cube, num_models) 

2069 

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

2071 cubes = iter_maybe(cube) 

2072 coords = [] 

2073 for cube in cubes: 

2074 try: 

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

2076 except iris.exceptions.CoordinateNotFoundError as err: 

2077 raise ValueError( 

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

2079 ) from err 

2080 if cube.coords("realization"): 2080 ↛ 2084line 2080 didn't jump to line 2084 because the condition on line 2080 was always true

2081 if cube.ndim > 3: 2081 ↛ 2082line 2081 didn't jump to line 2082 because the condition on line 2081 was never true

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

2083 else: 

2084 raise ValueError("Cube must have a realization coordinate.") 

2085 

2086 plot_index = [] 

2087 

2088 # Check if this is a spectral plot by looking for spectral coordinates 

2089 is_spectral_plot = series_coordinate in [ 

2090 "frequency", 

2091 "physical_wavenumber", 

2092 "wavelength", 

2093 ] 

2094 

2095 if is_spectral_plot: 

2096 # If series coordinate is frequency, physical_wavenumber or wavelength, for example power spectra with series 

2097 # coordinate frequency/wavenumber. 

2098 # If several power spectra are plotted with time as sequence_coordinate for the 

2099 # time slider option. 

2100 

2101 # Internal plotting function. 

2102 plotting_func = _plot_and_save_line_power_spectrum_series 

2103 

2104 for cube in cubes: 

2105 try: 

2106 cube.coord(sequence_coordinate) 

2107 except iris.exceptions.CoordinateNotFoundError as err: 

2108 raise ValueError( 

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

2110 ) from err 

2111 

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

2113 # check for ensembles 

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

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

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

2117 ): 

2118 if single_plot: 

2119 # Plot spectra, mean and ensemble spread on 1 plot 

2120 plotting_func = _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series 

2121 else: 

2122 # Plot postage stamps 

2123 plotting_func = _plot_and_save_postage_stamp_power_spectrum_series 

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

2125 else: 

2126 all_points = sorted( 

2127 set( 

2128 itertools.chain.from_iterable( 

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

2130 ) 

2131 ) 

2132 ) 

2133 all_slices = list( 

2134 itertools.chain.from_iterable( 

2135 cb.slices_over(sequence_coordinate) for cb in cubes 

2136 ) 

2137 ) 

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

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

2140 # necessary) 

2141 cube_iterables = [ 

2142 iris.cube.CubeList( 

2143 s 

2144 for s in all_slices 

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

2146 ) 

2147 for point in all_points 

2148 ] 

2149 

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

2151 

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

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

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

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

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

2157 

2158 for cube_slice in cube_iterables: 

2159 # Normalize cube_slice to a list of cubes 

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

2161 cubes = list(cube_slice) 

2162 elif isinstance(cube_slice, iris.cube.Cube): 2162 ↛ 2165line 2162 didn't jump to line 2165 because the condition on line 2162 was always true

2163 cubes = [cube_slice] 

2164 else: 

2165 raise TypeError(f"Expected Cube or CubeList, got {type(cube_slice)}") 

2166 

2167 # Use sequence value so multiple sequences can merge. 

2168 seq_coord = cube_slice[0].coord(sequence_coordinate) 

2169 plot_title, plot_filename = _set_title_and_filename( 

2170 seq_coord, nplot, recipe_title, filename 

2171 ) 

2172 

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

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

2175 

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

2177 if nplot == 1 and seq_coord.has_bounds: 2177 ↛ 2182line 2177 didn't jump to line 2182 because the condition on line 2177 was always true

2178 if np.size(seq_coord.bounds) > 1: 2178 ↛ 2179line 2178 didn't jump to line 2179 because the condition on line 2178 was never true

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

2180 

2181 # Do the actual plotting. 

2182 plotting_func( 

2183 cube_slice, 

2184 coords, 

2185 stamp_coordinate, 

2186 plot_filename, 

2187 title, 

2188 series_coordinate, 

2189 ) 

2190 

2191 plot_index.append(plot_filename) 

2192 else: 

2193 # Format the title and filename using plotted series coordinate 

2194 nplot = 1 

2195 seq_coord = coords[0] 

2196 plot_title, plot_filename = _set_title_and_filename( 

2197 seq_coord, nplot, recipe_title, filename 

2198 ) 

2199 # Do the actual plotting for all other series coordinate options. 

2200 _plot_and_save_line_series( 

2201 cubes, coords, stamp_coordinate, plot_filename, plot_title 

2202 ) 

2203 

2204 plot_index.append(plot_filename) 

2205 

2206 # append plot to list of plots 

2207 complete_plot_index = _append_to_plot_index(plot_index) 

2208 

2209 # Make a page to display the plots. 

2210 _make_plot_html_page(complete_plot_index) 

2211 

2212 return cube 

2213 

2214 

2215def plot_vertical_line_series( 

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

2217 filename: str = None, 

2218 series_coordinate: str = "model_level_number", 

2219 sequence_coordinate: str = "time", 

2220 # line_coordinate: str = "realization", 

2221 **kwargs, 

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

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

2224 

2225 The Cube or CubeList must be 1D. 

2226 

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

2228 then a sequence of plots will be produced. 

2229 

2230 Parameters 

2231 ---------- 

2232 iris.cube | iris.cube.CubeList 

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

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

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

2236 filename: str, optional 

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

2238 to the recipe name. 

2239 series_coordinate: str, optional 

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

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

2242 for LFRic. Defaults to ``model_level_number``. 

2243 This coordinate must exist in the cube. 

2244 sequence_coordinate: str, optional 

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

2246 This coordinate must exist in the cube. 

2247 

2248 Returns 

2249 ------- 

2250 iris.cube.Cube | iris.cube.CubeList 

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

2252 Plotted data. 

2253 

2254 Raises 

2255 ------ 

2256 ValueError 

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

2258 TypeError 

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

2260 """ 

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

2262 recipe_title = get_recipe_metadata().get("title", iter_maybe(cubes)[0].name()) 

2263 

2264 cubes = iter_maybe(cubes) 

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

2266 all_data = [] 

2267 

2268 # Store min/max ranges for x range. 

2269 x_levels = [] 

2270 

2271 num_models = get_num_models(cubes) 

2272 

2273 validate_cube_shape(cubes, num_models) 

2274 

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

2276 coords = [] 

2277 for cube in cubes: 

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

2279 try: 

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

2281 except iris.exceptions.CoordinateNotFoundError as err: 

2282 raise ValueError( 

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

2284 ) from err 

2285 

2286 try: 

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

2288 cube.coord(sequence_coordinate) 

2289 except iris.exceptions.CoordinateNotFoundError as err: 

2290 raise ValueError( 

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

2292 ) from err 

2293 

2294 # Get minimum and maximum from levels information. 

2295 _, levels, _ = colorbar_map_levels(cube, axis="x") 

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

2297 x_levels.append(min(levels)) 

2298 x_levels.append(max(levels)) 

2299 else: 

2300 all_data.append(cube.data) 

2301 

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

2303 # Combine all data into a single NumPy array 

2304 combined_data = np.concatenate(all_data) 

2305 

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

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

2308 # sequence and if applicable postage stamp coordinate. 

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

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

2311 else: 

2312 vmin = min(x_levels) 

2313 vmax = max(x_levels) 

2314 

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

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

2317 # necessary) 

2318 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

2319 

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

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

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

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

2324 # or an iris.cube.CubeList. 

2325 plot_index = [] 

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

2327 for cubes_slice in cube_iterables: 

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

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

2330 plot_title, plot_filename = _set_title_and_filename( 

2331 seq_coord, nplot, recipe_title, filename 

2332 ) 

2333 

2334 # Do the actual plotting. 

2335 _plot_and_save_vertical_line_series( 

2336 cubes_slice, 

2337 coords, 

2338 "realization", 

2339 plot_filename, 

2340 series_coordinate, 

2341 title=plot_title, 

2342 vmin=vmin, 

2343 vmax=vmax, 

2344 ) 

2345 plot_index.append(plot_filename) 

2346 

2347 # Add list of plots to plot metadata. 

2348 complete_plot_index = _append_to_plot_index(plot_index) 

2349 

2350 # Make a page to display the plots. 

2351 _make_plot_html_page(complete_plot_index) 

2352 

2353 return cubes 

2354 

2355 

2356def qq_plot( 

2357 cubes: iris.cube.CubeList, 

2358 coordinates: list[str], 

2359 percentiles: list[float], 

2360 model_names: list[str], 

2361 filename: str = None, 

2362 one_to_one: bool = True, 

2363 **kwargs, 

2364) -> iris.cube.CubeList: 

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

2366 

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

2368 collapsed within the operator over all specified coordinates such as 

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

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

2371 

2372 Parameters 

2373 ---------- 

2374 cubes: iris.cube.CubeList 

2375 Two cubes of the same variable with different models. 

2376 coordinate: list[str] 

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

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

2379 the percentile coordinate. 

2380 percent: list[float] 

2381 A list of percentiles to appear in the plot. 

2382 model_names: list[str] 

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

2384 filename: str, optional 

2385 Filename of the plot to write. 

2386 one_to_one: bool, optional 

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

2388 

2389 Raises 

2390 ------ 

2391 ValueError 

2392 When the cubes are not compatible. 

2393 

2394 Notes 

2395 ----- 

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

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

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

2399 compares percentiles of two datasets. This plot does 

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

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

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

2403 

2404 Quantile-quantile plots are valuable for comparing against 

2405 observations and other models. Identical percentiles between the variables 

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

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

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

2409 Wilks 2011 [Wilks2011]_). 

2410 

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

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

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

2414 the extremes. 

2415 

2416 References 

2417 ---------- 

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

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

2420 """ 

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

2422 if len(cubes) != 2: 

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

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

2425 other: Cube = cubes.extract_cube( 

2426 iris.Constraint( 

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

2428 ) 

2429 ) 

2430 

2431 # Get spatial coord names. 

2432 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

2433 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

2434 

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

2436 # This is triggered if either 

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

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

2439 # errors. 

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

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

2442 # for UM and LFRic comparisons. 

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

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

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

2446 # given this dependency on regridding. 

2447 if ( 

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

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

2450 ) or ( 

2451 base.long_name 

2452 in [ 

2453 "eastward_wind_at_10m", 

2454 "northward_wind_at_10m", 

2455 "northward_wind_at_cell_centres", 

2456 "eastward_wind_at_cell_centres", 

2457 "zonal_wind_at_pressure_levels", 

2458 "meridional_wind_at_pressure_levels", 

2459 "potential_vorticity_at_pressure_levels", 

2460 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

2461 ] 

2462 ): 

2463 logging.debug( 

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

2465 ) 

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

2467 

2468 # Extract just common time points. 

2469 base, other = _extract_common_time_points(base, other) 

2470 

2471 # Equalise attributes so we can merge. 

2472 fully_equalise_attributes([base, other]) 

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

2474 

2475 # Collapse cubes. 

2476 base = collapse( 

2477 base, 

2478 coordinate=coordinates, 

2479 method="PERCENTILE", 

2480 additional_percent=percentiles, 

2481 ) 

2482 other = collapse( 

2483 other, 

2484 coordinate=coordinates, 

2485 method="PERCENTILE", 

2486 additional_percent=percentiles, 

2487 ) 

2488 

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

2490 recipe_title = get_recipe_metadata().get("title", "QQ_plot") 

2491 title = f"{recipe_title}" 

2492 

2493 if filename is None: 

2494 filename = slugify(recipe_title) 

2495 

2496 # Add file extension. 

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

2498 

2499 # Do the actual plotting on a scatter plot 

2500 _plot_and_save_scatter_plot( 

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

2502 ) 

2503 

2504 # Add list of plots to plot metadata. 

2505 plot_index = _append_to_plot_index([plot_filename]) 

2506 

2507 # Make a page to display the plots. 

2508 _make_plot_html_page(plot_index) 

2509 

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

2511 

2512 

2513def hinton_plot(change, signif, xaxis_labels, yaxis_labels, magnitude=None): 

2514 """ 

2515 Plot a Hinton style triangle/scorecard plot. 

2516 

2517 This plot type can be useful for summarising high level information, such as comparing 

2518 how 'skillful' two models are when verified against observations for a variety of metrics, 

2519 as a function of lead-time. A few parameters of the plot style are fixed in function rather 

2520 than customisable by the user as input arguments; many have been designed to automatically 

2521 scale the plot depending on the number of x and y components. 

2522 

2523 Parameters 

2524 ---------- 

2525 change: np.ndarray 

2526 A 2d numpy array containing the values (scaled to 1 to -1) that determine the triangle 

2527 size/direction. 

2528 signif: np.ndarray 

2529 A 2d numpy array containing 0s and 1s to determine if triangle is significant or not. 

2530 xaxis_labels: list 

2531 List of labels for the xaxis (must match the second dimension length of signif and change, 

2532 along with magnitude if not None). 

2533 yaxis_labels: list 

2534 List of labels for the yaxis (must match the first dimension length of signif and change, 

2535 along with magnitude if not None). 

2536 magnitude: np.ndarray | None 

2537 Optional 2D array, matching the shape of change, signif, which contains numerical values 

2538 the user wishes to display under each respective triangle. 

2539 

2540 Returns 

2541 ------- 

2542 matplotlib axes object to either display or do further modifications to. 

2543 """ 

2544 # Setup colors of triangles 

2545 color_pos = "#7CAE00" 

2546 color_neg = "#7B68EE" 

2547 

2548 # Setup cell/text size ratios 

2549 figsize = None 

2550 cell_size_in = 0.35 

2551 text_row_ratio = 0.25 

2552 

2553 # Ensure arrays, and change to bool for sig. 

2554 change = np.asarray(change) 

2555 signif = np.asarray(signif).astype(bool) 

2556 if magnitude is not None: 2556 ↛ 2557line 2556 didn't jump to line 2557 because the condition on line 2556 was never true

2557 magnitude = np.asarray(magnitude) 

2558 

2559 # Get the number of x and y elements 

2560 ny, nx = change.shape 

2561 

2562 # Build non-uniform y coordinates 

2563 tri_height = 1.0 

2564 txt_height = text_row_ratio 

2565 

2566 tri_y = [] 

2567 txt_y = [] 

2568 y_edges = [0.0] 

2569 

2570 y = 0.0 

2571 for _j in range(ny): 

2572 tri_y.append(y + tri_height / 2) 

2573 y += tri_height 

2574 y_edges.append(y) 

2575 

2576 if magnitude is not None: 2576 ↛ 2577line 2576 didn't jump to line 2577 because the condition on line 2576 was never true

2577 txt_y.append(y + txt_height / 2) 

2578 y += txt_height 

2579 y_edges.append(y) 

2580 

2581 total_height = y 

2582 

2583 # Dynamic figure size 

2584 if figsize is None: 2584 ↛ 2589line 2584 didn't jump to line 2589 because the condition on line 2584 was always true

2585 width = nx * cell_size_in 

2586 height = total_height * cell_size_in + 2 

2587 figsize = (width, height) 

2588 

2589 fig, ax = plt.subplots(figsize=figsize) 

2590 

2591 # Setup axes and grid. 

2592 ax.set_aspect("equal", adjustable="box") 

2593 ax.set_xlim(-0.5, nx - 0.5) 

2594 ax.set_ylim(0, total_height) 

2595 

2596 ax.set_xticks(np.arange(nx)) 

2597 ax.set_xticklabels(xaxis_labels, rotation=90) 

2598 

2599 ax.set_yticks(tri_y) 

2600 ax.set_yticklabels(yaxis_labels) 

2601 

2602 ax.set_xticks(np.arange(-0.5, nx, 1), minor=True) 

2603 ax.set_yticks(y_edges, minor=True) 

2604 

2605 ax.set_axisbelow(True) 

2606 ax.grid(which="minor", linestyle=":", linewidth=0.3, color="0.7") 

2607 ax.grid(False, which="major") 

2608 ax.tick_params(which="minor", length=0) 

2609 

2610 ax.invert_yaxis() 

2611 

2612 # Compute marker scaling (fixed overlap) 

2613 fig.canvas.draw() 

2614 

2615 bbox = ax.get_window_extent().transformed(fig.dpi_scale_trans.inverted()) 

2616 width_in, height_in = bbox.width, bbox.height 

2617 

2618 cell_w = (width_in * fig.dpi) / nx 

2619 cell_h = (height_in * fig.dpi) / total_height 

2620 cell_pixels = min(cell_w, cell_h) 

2621 

2622 max_marker_size = (0.6 * cell_pixels) ** 2 

2623 

2624 text_fontsize = cell_pixels * 0.15 

2625 

2626 # Plot triangles + text 

2627 for j in range(ny): 

2628 for i in range(nx): 

2629 val = change[j, i] 

2630 if np.isnan(val): 2630 ↛ 2631line 2630 didn't jump to line 2631 because the condition on line 2630 was never true

2631 continue 

2632 

2633 if abs(val) < 0.01: 2633 ↛ 2634line 2633 didn't jump to line 2634 because the condition on line 2633 was never true

2634 continue 

2635 

2636 sig = signif[j, i] 

2637 size = max_marker_size * abs(val) 

2638 

2639 # Triangle style 

2640 if val >= 0: 

2641 marker = "^" 

2642 color = color_pos 

2643 else: 

2644 marker = "v" 

2645 color = color_neg 

2646 

2647 if sig: 

2648 edgecolor = "black" 

2649 linewidth = 0.6 

2650 else: 

2651 edgecolor = "none" 

2652 linewidth = 0.0 

2653 

2654 # Triangle 

2655 ax.scatter( 

2656 i, 

2657 tri_y[j], 

2658 s=size, 

2659 marker=marker, 

2660 c=color, 

2661 edgecolors=edgecolor, 

2662 linewidths=linewidth, 

2663 zorder=3, 

2664 clip_on=True, # ensures no rendering bleed 

2665 ) 

2666 

2667 # Text row 

2668 if magnitude is not None: 2668 ↛ 2669line 2668 didn't jump to line 2669 because the condition on line 2668 was never true

2669 mag_val = magnitude[j, i] 

2670 

2671 if not np.isnan(mag_val): 

2672 ax.text( 

2673 i, 

2674 txt_y[j], 

2675 f"{mag_val:.1f}", 

2676 ha="center", 

2677 va="center", 

2678 fontsize=text_fontsize, 

2679 color="black", 

2680 zorder=4, 

2681 ) 

2682 

2683 plt.tight_layout() 

2684 return fig, ax 

2685 

2686 

2687def scatter_plot( 

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

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

2690 filename: str = None, 

2691 one_to_one: bool = True, 

2692 **kwargs, 

2693) -> iris.cube.CubeList: 

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

2695 

2696 Both cubes must be 1D. 

2697 

2698 Parameters 

2699 ---------- 

2700 cube_x: Cube | CubeList 

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

2702 cube_y: Cube | CubeList 

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

2704 filename: str, optional 

2705 Filename of the plot to write. 

2706 one_to_one: bool, optional 

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

2708 

2709 Returns 

2710 ------- 

2711 cubes: CubeList 

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

2713 

2714 Raises 

2715 ------ 

2716 ValueError 

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

2718 size. 

2719 TypeError 

2720 If the cube isn't a single cube. 

2721 

2722 Notes 

2723 ----- 

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

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

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

2727 """ 

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

2729 for cube_iter in iter_maybe(cube_x): 

2730 # Check cubes are correct shape. 

2731 cube_iter = check_single_cube(cube_iter) 

2732 if cube_iter.ndim > 1: 

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

2734 

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

2736 for cube_iter in iter_maybe(cube_y): 

2737 # Check cubes are correct shape. 

2738 cube_iter = check_single_cube(cube_iter) 

2739 if cube_iter.ndim > 1: 

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

2741 

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

2743 recipe_title = get_recipe_metadata().get("title", "Scatter_plot") 

2744 title = f"{recipe_title}" 

2745 

2746 if filename is None: 

2747 filename = slugify(recipe_title) 

2748 

2749 # Add file extension. 

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

2751 

2752 # Do the actual plotting. 

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

2754 

2755 # Add list of plots to plot metadata. 

2756 plot_index = _append_to_plot_index([plot_filename]) 

2757 

2758 # Make a page to display the plots. 

2759 _make_plot_html_page(plot_index) 

2760 

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

2762 

2763 

2764def vector_plot( 

2765 cube_u: iris.cube.Cube, 

2766 cube_v: iris.cube.Cube, 

2767 filename: str = None, 

2768 sequence_coordinate: str = "time", 

2769 **kwargs, 

2770) -> iris.cube.CubeList: 

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

2772 recipe_title = get_recipe_metadata().get("title", "Vector_plot") 

2773 

2774 # Cubes must have a matching sequence coordinate. 

2775 try: 

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

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

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

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

2780 raise ValueError( 

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

2782 ) from err 

2783 

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

2785 plot_index = [] 

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

2787 for cube_u_slice, cube_v_slice in zip( 

2788 cube_u.slices_over(sequence_coordinate), 

2789 cube_v.slices_over(sequence_coordinate), 

2790 strict=True, 

2791 ): 

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

2793 seq_coord = cube_u_slice.coord(sequence_coordinate) 

2794 plot_title, plot_filename = _set_title_and_filename( 

2795 seq_coord, nplot, recipe_title, filename 

2796 ) 

2797 

2798 # Do the actual plotting. 

2799 _plot_and_save_vector_plot( 

2800 cube_u_slice, 

2801 cube_v_slice, 

2802 filename=plot_filename, 

2803 title=plot_title, 

2804 method="pcolormesh", 

2805 ) 

2806 plot_index.append(plot_filename) 

2807 

2808 # Add list of plots to plot metadata. 

2809 complete_plot_index = _append_to_plot_index(plot_index) 

2810 

2811 # Make a page to display the plots. 

2812 _make_plot_html_page(complete_plot_index) 

2813 

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

2815 

2816 

2817def plot_histogram_series( 

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

2819 filename: str = None, 

2820 sequence_coordinate: str = "time", 

2821 stamp_coordinate: str = "realization", 

2822 single_plot: bool = False, 

2823 **kwargs, 

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

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

2826 

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

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

2829 functionality to scroll through histograms against time. If a 

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

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

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

2833 

2834 Parameters 

2835 ---------- 

2836 cubes: Cube | iris.cube.CubeList 

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

2838 than the stamp coordinate. 

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

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

2841 filename: str, optional 

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

2843 to the recipe name. 

2844 sequence_coordinate: str, optional 

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

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

2847 slider. 

2848 stamp_coordinate: str, optional 

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

2850 ``"realization"``. 

2851 single_plot: bool, optional 

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

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

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

2855 

2856 Returns 

2857 ------- 

2858 iris.cube.Cube | iris.cube.CubeList 

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

2860 Plotted data. 

2861 

2862 Raises 

2863 ------ 

2864 ValueError 

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

2866 TypeError 

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

2868 """ 

2869 recipe_title = get_recipe_metadata().get("title", "Histogram") 

2870 

2871 cubes = iter_maybe(cubes) 

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

2873 if filename is None: 

2874 filename = slugify(recipe_title) 

2875 

2876 # Internal plotting function. 

2877 plotting_func = _plot_and_save_histogram_series 

2878 

2879 num_models = get_num_models(cubes) 

2880 

2881 validate_cube_shape(cubes, num_models) 

2882 

2883 # If several histograms are plotted, check sequence_coordinate 

2884 check_sequence_coordinate(cubes, sequence_coordinate) 

2885 

2886 # Get axis minimum and maximum from levels information. 

2887 # If no levels set, derive minima and maxima from data in CubeList. 

2888 vmin, vmax = _set_axis_range(cubes) 

2889 

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

2891 # single point. If single_plot is True: 

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

2893 # separate postage stamp plots. 

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

2895 # produced per single model only 

2896 if num_models == 1: 

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

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

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

2900 ): 

2901 if single_plot: 

2902 plotting_func = ( 

2903 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

2904 ) 

2905 else: 

2906 plotting_func = _plot_and_save_postage_stamp_histogram_series 

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

2908 else: 

2909 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

2910 

2911 plot_index = [] 

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

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

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

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

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

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

2918 for cube_slice in cube_iterables: 

2919 single_cube = cube_slice 

2920 if isinstance(cube_slice, iris.cube.CubeList): 

2921 single_cube = cube_slice[0] 

2922 

2923 # Ensure valid stamp coordinate in cube dimensions 

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

2925 stamp_coordinate = check_stamp_coordinate(single_cube) 

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

2927 seq_coord = single_cube.coord(sequence_coordinate) 

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

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

2930 seq_coord = single_cube.coord("time") 

2931 plot_title, plot_filename = _set_title_and_filename( 

2932 seq_coord, nplot, recipe_title, filename 

2933 ) 

2934 

2935 # Do the actual plotting. 

2936 plotting_func( 

2937 cube_slice, 

2938 filename=plot_filename, 

2939 stamp_coordinate=stamp_coordinate, 

2940 title=plot_title, 

2941 vmin=vmin, 

2942 vmax=vmax, 

2943 ) 

2944 plot_index.append(plot_filename) 

2945 

2946 # Add list of plots to plot metadata. 

2947 complete_plot_index = _append_to_plot_index(plot_index) 

2948 

2949 # Make a page to display the plots. 

2950 _make_plot_html_page(complete_plot_index) 

2951 

2952 return cubes 

2953 

2954 

2955def _plot_and_save_postage_stamp_power_spectrum_series( 

2956 cubes: iris.cube.Cube, 

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

2958 stamp_coordinate: str, 

2959 filename: str, 

2960 title: str, 

2961 series_coordinate: str | None = None, 

2962 **kwargs, 

2963): 

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

2965 

2966 Parameters 

2967 ---------- 

2968 cubes: Cube or CubeList 

2969 Cube or Cubelist of the power spectrum data. 

2970 coords: list[Coord] 

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

2972 stamp_coordinate: str 

2973 Coordinate that becomes different plots. 

2974 filename: str 

2975 Filename of the plot to write. 

2976 title: str 

2977 Plot title. 

2978 series_coordinate: str, optional 

2979 Coordinate being plotted on x-axis. In case of spectra frequency, physical_wavenumber, or wavelength. 

2980 

2981 """ 

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

2983 grid_size = int(math.ceil(math.sqrt(len(cubes.coord(stamp_coordinate).points)))) 

2984 

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

2986 model_colors_map = get_model_colors_map(cubes) 

2987 # ax = plt.gca() 

2988 # Make a subplot for each member. 

2989 for member, subplot in zip( 

2990 cubes.slices_over(stamp_coordinate), range(1, grid_size**2 + 1), strict=False 

2991 ): 

2992 ax = plt.subplot(grid_size, grid_size, subplot) 

2993 

2994 # Store min/max ranges. 

2995 y_levels = [] 

2996 

2997 line_marker = None 

2998 line_width = 1 

2999 

3000 for cube in iter_maybe(member): 

3001 xcoord = _select_series_coord(cube, series_coordinate) 

3002 xname = xcoord.points 

3003 

3004 yfield = cube.data # power spectrum 

3005 label = None 

3006 color = "black" 

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

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

3009 color = model_colors_map.get(label) 

3010 

3011 if member.coord(stamp_coordinate).points == [0]: 

3012 ax.plot( 

3013 xname, 

3014 yfield, 

3015 color=color, 

3016 marker=line_marker, 

3017 ls="-", 

3018 lw=line_width, 

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

3020 if len(cube.coord(stamp_coordinate).points) > 1 

3021 else label, 

3022 ) 

3023 # Label with member if part of an ensemble and not the control. 

3024 else: 

3025 ax.plot( 

3026 xname, 

3027 yfield, 

3028 color=color, 

3029 ls="-", 

3030 lw=1.5, 

3031 alpha=0.75, 

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

3033 ) 

3034 

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

3036 _, levels, _ = colorbar_map_levels(cube, axis="y") 

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

3038 y_levels.append(min(levels)) 

3039 y_levels.append(max(levels)) 

3040 

3041 # Add some labels and tweak the style. 

3042 title = f"{title}" 

3043 ax.set_title(title, fontsize=16) 

3044 

3045 # Set appropriate x-axis label based on coordinate 

3046 if series_coordinate == "wavelength" or ( 3046 ↛ 3049line 3046 didn't jump to line 3049 because the condition on line 3046 was never true

3047 hasattr(xcoord, "long_name") and xcoord.long_name == "wavelength" 

3048 ): 

3049 ax.set_xlabel("Wavelength (km)", fontsize=14) 

3050 elif series_coordinate == "physical_wavenumber" or ( 3050 ↛ 3055line 3050 didn't jump to line 3055 because the condition on line 3050 was always true

3051 hasattr(xcoord, "long_name") and xcoord.long_name == "physical_wavenumber" 

3052 ): 

3053 ax.set_xlabel("Wavenumber (km⁻¹)", fontsize=14) 

3054 else: # frequency or check units 

3055 if hasattr(xcoord, "units") and str(xcoord.units) == "km-1": 

3056 ax.set_xlabel("Wavenumber (km⁻¹)", fontsize=14) 

3057 else: 

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

3059 

3060 ax.set_ylabel("Power Spectral Density", fontsize=14) 

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

3062 

3063 # Set log-log scale 

3064 ax.set_xscale("log") 

3065 ax.set_yscale("log") 

3066 

3067 # Add gridlines 

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

3069 # Ientify unique labels for legend 

3070 handles = list( 

3071 { 

3072 label: handle 

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

3074 }.values() 

3075 ) 

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

3077 

3078 ax = plt.gca() 

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

3080 

3081 if not in_sphinx_gallery(): 3081 ↛ exitline 3081 didn't return from function '_plot_and_save_postage_stamp_power_spectrum_series' because the condition on line 3081 was always true

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

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

3084 plt.close(fig) 

3085 

3086 

3087def _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series( 

3088 cubes: iris.cube.Cube, 

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

3090 stamp_coordinate: str, 

3091 filename: str, 

3092 title: str, 

3093 series_coordinate: str = None, 

3094 **kwargs, 

3095): 

3096 """Plot and save power spectra for ensemble members in single plot. 

3097 

3098 Parameters 

3099 ---------- 

3100 cubes: Cube or CubeList 

3101 Cube or Cubelist of the power spectrum data. 

3102 coords: list[Coord] 

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

3104 stamp_coordinate: str 

3105 Coordinate that becomes different plots. 

3106 filename: str 

3107 Filename of the plot to write. 

3108 title: str 

3109 Plot title. 

3110 series_coordinate: str, optional 

3111 Coordinate being plotted on x-axis. In case of spectra frequency, physical_wavenumber, or wavelength. 

3112 

3113 """ 

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

3115 model_colors_map = get_model_colors_map(cubes) 

3116 

3117 line_marker = None 

3118 line_width = 1 

3119 

3120 # Compute ensemble statistics to show spread 

3121 mean_cube = cubes.collapsed(stamp_coordinate, iris.analysis.MEAN) 

3122 min_cube = cubes.collapsed(stamp_coordinate, iris.analysis.MIN) 

3123 max_cube = cubes.collapsed(stamp_coordinate, iris.analysis.MAX) 

3124 

3125 xcoord_global = mean_cube.coord(series_coordinate) 

3126 x_global = xcoord_global.points 

3127 

3128 for i, member in enumerate(cubes.slices_over(stamp_coordinate)): 

3129 xcoord = _select_series_coord(member, series_coordinate) 

3130 xname = xcoord.points 

3131 

3132 yfield = member.data # power spectrum 

3133 color = "black" 

3134 if model_colors_map: 3134 ↛ 3138line 3134 didn't jump to line 3138 because the condition on line 3134 was always true

3135 label = member.attributes.get("model_name") if i == 0 else None 

3136 color = model_colors_map.get(label) 

3137 

3138 if member.coord(stamp_coordinate).points == [0]: 

3139 ax.plot( 

3140 xname, 

3141 yfield, 

3142 color=color, 

3143 marker=line_marker, 

3144 ls="-", 

3145 lw=line_width, 

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

3147 if len(member.coord(stamp_coordinate).points) > 1 

3148 else label, 

3149 ) 

3150 # Label with member number if part of an ensemble and not the control. 

3151 else: 

3152 ax.plot( 

3153 xname, 

3154 yfield, 

3155 color=color, 

3156 ls="-", 

3157 lw=1.5, 

3158 alpha=0.75, 

3159 label=label, 

3160 ) 

3161 

3162 # Set appropriate x-axis label based on coordinate 

3163 if series_coordinate == "wavelength" or ( 3163 ↛ 3166line 3163 didn't jump to line 3166 because the condition on line 3163 was never true

3164 hasattr(xcoord, "long_name") and xcoord.long_name == "wavelength" 

3165 ): 

3166 ax.set_xlabel("Wavelength (km)", fontsize=14) 

3167 elif series_coordinate == "physical_wavenumber" or ( 3167 ↛ 3172line 3167 didn't jump to line 3172 because the condition on line 3167 was always true

3168 hasattr(xcoord, "long_name") and xcoord.long_name == "physical_wavenumber" 

3169 ): 

3170 ax.set_xlabel("Wavenumber (km⁻¹)", fontsize=14) 

3171 else: # frequency or check units 

3172 if hasattr(xcoord, "units") and str(xcoord.units) == "km-1": 

3173 ax.set_xlabel("Wavenumber (km⁻¹)", fontsize=14) 

3174 else: 

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

3176 

3177 # Add ensemble spread shading 

3178 ax.fill_between( 

3179 x_global, 

3180 min_cube.data, 

3181 max_cube.data, 

3182 color="grey", 

3183 alpha=0.3, 

3184 label="Ensemble spread", 

3185 ) 

3186 

3187 # Add ensemble mean line 

3188 ax.plot(x_global, mean_cube.data, color="black", lw=1, label="Ensemble mean") 

3189 

3190 ax.set_ylabel("Power Spectral Density", fontsize=14) 

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

3192 

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

3194 # Set log-log scale 

3195 ax.set_xscale("log") 

3196 ax.set_yscale("log") 

3197 

3198 # Add gridlines 

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

3200 # Identify unique labels for legend 

3201 handles = list( 

3202 { 

3203 label: handle 

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

3205 }.values() 

3206 ) 

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

3208 

3209 # Figure title. 

3210 ax.set_title(title, fontsize=16) 

3211 

3212 # Save the figure to a file 

3213 if not in_sphinx_gallery(): 3213 ↛ exitline 3213 didn't return from function '_plot_and_save_postage_stamps_in_single_plot_power_spectrum_series' because the condition on line 3213 was always true

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

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

3216 plt.close(fig)