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

1141 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-27 13:04 +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 

69logger = logging.getLogger(__name__) 

70 

71# Use a non-interactive plotting backend. 

72mpl.use("agg") 

73 

74 

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

76# Private helper functions # 

77############################ 

78 

79 

80def in_sphinx_gallery(): 

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

82 return "sphinx_gallery" in sys.modules 

83 

84 

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

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

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

88 fcntl.flock(fp, fcntl.LOCK_EX) 

89 fp.seek(0) 

90 meta = json.load(fp) 

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

92 complete_plot_index = complete_plot_index + plot_index 

93 meta["plots"] = complete_plot_index 

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

95 os.getenv("DO_CASE_AGGREGATION") 

96 ): 

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

98 fp.seek(0) 

99 fp.truncate() 

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

101 return complete_plot_index 

102 

103 

104def _make_plot_html_page(plots: list): 

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

106 # Debug check that plots actually contains some strings. 

107 assert isinstance(plots[0], str) 

108 

109 # Load HTML template file. 

110 operator_files = importlib.resources.files() 

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

112 

113 # Get some metadata. 

114 meta = get_recipe_metadata() 

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

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

117 

118 # Prepare template variables. 

119 variables = { 

120 "title": title, 

121 "description": description, 

122 "initial_plot": plots[0], 

123 "plots": plots, 

124 "title_slug": slugify(title), 

125 } 

126 

127 # Render template. 

128 html = render_file(template_file, **variables) 

129 

130 # Save completed HTML. 

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

132 fp.write(html) 

133 

134 

135def _save_close_figure(figure, plot_type: str, filename: str): 

136 """Save generated plot figure file and close figure. 

137 

138 If running documentation gallery generation, avoid saving to file. 

139 

140 Parameters 

141 ---------- 

142 figure: 

143 Matplotlib Figure object holding all plot elements. 

144 plot_type: str 

145 String identifier for plot type for logging information. 

146 filename: str 

147 Filename for saved figure. 

148 """ 

149 if not in_sphinx_gallery(): 

150 figure.savefig(filename, bbox_inches="tight", dpi=_get_plot_resolution()) 

151 logger.info("Saved %s plot to %s", plot_type, filename) 

152 plt.close(figure) 

153 

154 

155def _setup_spatial_map( 

156 cube: iris.cube.Cube, 

157 figure, 

158 cmap, 

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

160 subplot: int | None = None, 

161): 

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

163 

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

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

166 

167 Parameters 

168 ---------- 

169 cube: Cube 

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

171 figure: 

172 Matplotlib Figure object holding all plot elements. 

173 cmap: 

174 Matplotlib colormap. 

175 grid_size: (int, int), optional 

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

177 subplot: int, optional 

178 Subplot index if multiple spatial subplots in figure. 

179 

180 Returns 

181 ------- 

182 axes: 

183 Matplotlib GeoAxes definition. 

184 """ 

185 # Identify min/max plot bounds. 

186 try: 

187 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

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

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

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

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

192 

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

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

195 xmin = xmin - 180.0 

196 xmax = xmax - 180.0 

197 logger.debug("Adjusting plot bounds to fit global extent.") 

198 

199 # Consider map projection orientation. 

200 # Adapting orientation enables plotting across international dateline. 

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

202 if xmax > 180.0 or xmin < -180.0: 

203 central_longitude = 180.0 

204 else: 

205 central_longitude = 0.0 

206 

207 # Define spatial map projection. 

208 coord_system = cube.coord(lat_axis).coord_system 

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

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

211 projection = ccrs.RotatedPole( 

212 pole_longitude=coord_system.grid_north_pole_longitude, 

213 pole_latitude=coord_system.grid_north_pole_latitude, 

214 central_rotated_longitude=central_longitude, 

215 ) 

216 crs = projection 

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

218 # Define Transverse Mercator projection for TM inputs. 

219 projection = ccrs.TransverseMercator( 

220 central_longitude=coord_system.longitude_of_central_meridian, 

221 central_latitude=coord_system.latitude_of_projection_origin, 

222 false_easting=coord_system.false_easting, 

223 false_northing=coord_system.false_northing, 

224 scale_factor=coord_system.scale_factor_at_central_meridian, 

225 ) 

226 crs = projection 

227 else: 

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

229 if ymin > 20.0 and ymax > 80.0: 

230 projection = ccrs.NorthPolarStereo(central_longitude=0.0) 

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

232 projection = ccrs.SouthPolarStereo(central_longitude=central_longitude) 

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

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

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

236 # projection = ccrs.NearsidePerspective( 

237 # central_longitude=180.0, 

238 # central_latitude=0, 

239 # satellite_height=35785831, 

240 # ) 

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

242 else: 

243 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

244 crs = ccrs.PlateCarree() 

245 

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

247 if subplot is not None: 

248 axes = figure.add_subplot( 

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

250 ) 

251 else: 

252 axes = figure.add_subplot(projection=projection) 

253 

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

255 # Avoid adding lines for specific fixed ancillary spatial plots 

256 if any(name in cube.name() for name in ("land_", "orography", "altitude")): 

257 pass 

258 else: 

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

260 coastcol = "magenta" 

261 else: 

262 coastcol = "black" 

263 logger.debug("Plotting coastlines and borderlines in colour %s.", coastcol) 

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

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

266 

267 # Add gridlines. 

268 gl = axes.gridlines( 

269 alpha=0.3, 

270 draw_labels=True, 

271 dms=False, 

272 x_inline=False, 

273 y_inline=False, 

274 ) 

275 gl.top_labels = False 

276 gl.right_labels = False 

277 if subplot: 

278 gl.bottom_labels = False 

279 gl.left_labels = False 

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

281 gl.left_labels = True 

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

283 gl.bottom_labels = True 

284 

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

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

287 if isinstance( 

288 coord_system, (iris.coord_systems.GeogCS, iris.coord_systems.RotatedGeogCS) 

289 ): 

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

291 

292 except ValueError: 

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

294 axes = figure.gca() 

295 

296 return axes 

297 

298 

299def _get_plot_resolution() -> int: 

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

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

302 

303 

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

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

306 if use_bounds and seq_coord.has_bounds(): 

307 vals = seq_coord.bounds.flatten() 

308 else: 

309 vals = seq_coord.points 

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

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

312 

313 if start == end: 

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

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

316 else: 

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

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

319 

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

321 if ( 

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

323 and vals[0] == 0 

324 and vals[-1] == 0 

325 ): 

326 sequence_title = "" 

327 sequence_fname = "" 

328 

329 return sequence_title, sequence_fname 

330 

331 

332def _set_title_and_filename( 

333 seq_coord: iris.coords.Coord, 

334 nplot: int, 

335 recipe_title: str, 

336 filename: str, 

337 model_name: str | None = None, 

338): 

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

340 

341 Parameters 

342 ---------- 

343 sequence_coordinate: iris.coords.Coord 

344 Coordinate about which to make a plot sequence. 

345 nplot: int 

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

347 recipe_title: str 

348 Default plot title, potentially to update. 

349 filename: str 

350 Input plot filename, potentially to update. 

351 

352 Returns 

353 ------- 

354 plot_title: str 

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

356 plot_filename: str 

357 Output formatted plot filename string. 

358 """ 

359 ndim = seq_coord.ndim 

360 npoints = np.size(seq_coord.points) 

361 sequence_title = "" 

362 sequence_fname = "" 

363 

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

365 # (e.g. aggregation histogram plots) 

366 if ndim > 1: 

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

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

369 sequence_fname = f"_{ncase}cases" 

370 

371 # Case 2: Single dimension input 

372 else: 

373 # Single sequence point 

374 if npoints == 1: 

375 if nplot > 1: 

376 # Default labels for sequence inputs 

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

378 sequence_value = sequence_value.replace(" unknown", "") 

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

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

381 else: 

382 # Aggregated attribute available where input collapsed over aggregation 

383 try: 

384 ncase = seq_coord.attributes["number_reference_times"] 

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

386 sequence_fname = f"_{ncase}cases" 

387 except KeyError: 

388 sequence_title, sequence_fname = _get_start_end_strings( 

389 seq_coord, use_bounds=seq_coord.has_bounds() 

390 ) 

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

392 else: 

393 sequence_title, sequence_fname = _get_start_end_strings( 

394 seq_coord, use_bounds=False 

395 ) 

396 

397 # Set plot title and filename 

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

399 

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

401 if filename is None: 

402 filename = slugify(recipe_title) 

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

404 else: 

405 if nplot > 1: 

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

407 else: 

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

409 

410 if model_name: 410 ↛ 411line 410 didn't jump to line 411 because the condition on line 410 was never true

411 plot_filename = f"{model_name}_{plot_filename}" 

412 plot_title = f"{model_name}_{plot_title}" 

413 

414 return plot_title, plot_filename 

415 

416 

417def _select_series_coord(cube, series_coordinate): 

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

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

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

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

422 fallbacks = [series_coordinate] + [ 

423 c for c in spacing_coordinates if c != series_coordinate 

424 ] 

425 else: 

426 fallbacks = {series_coordinate} 

427 

428 # Try each possible coordinate. 

429 for coord in fallbacks: 

430 try: 

431 return cube.coord(coord) 

432 except iris.exceptions.CoordinateNotFoundError: 

433 logger.debug("Coordinate %s not found.", coord) 

434 

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

436 raise iris.exceptions.CoordinateNotFoundError( 

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

438 f"or fallback options {fallbacks}" 

439 ) 

440 

441 

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

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

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

445 mtitle = "Member" 

446 else: 

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

448 

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

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

451 else: 

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

453 

454 return mtitle 

455 

456 

457def _set_axis_range(cubes): 

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

459 levels = None 

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

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

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

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

464 if levels is None: 

465 break 

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

467 # levels-based ranges for histogram plots. 

468 _, levels, _ = colorbar_map_levels(cube) 

469 logger.debug("levels: %s", levels) 

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

471 vmin = min(levels) 

472 vmax = max(levels) 

473 logger.debug("Updated vmin, vmax: %s, %s", vmin, vmax) 

474 break 

475 

476 if levels is None: 

477 vmin = min(np.nanmin(cb.data) for cb in cubes) 

478 vmax = max(np.nanmax(cb.data) for cb in cubes) 

479 

480 return vmin, vmax 

481 

482 

483def _find_matched_slices(cubes, sequence_coordinate): 

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

485 

486 Ensures common points are compared for multiple cube inputs. 

487 """ 

488 all_points = sorted( 

489 set( 

490 itertools.chain.from_iterable( 

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

492 ) 

493 ) 

494 ) 

495 all_slices = list( 

496 itertools.chain.from_iterable( 

497 cb.slices_over(sequence_coordinate) for cb in cubes 

498 ) 

499 ) 

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

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

502 # necessary) 

503 cube_iterables = [ 

504 iris.cube.CubeList( 

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

506 ) 

507 for point in all_points 

508 ] 

509 

510 return cube_iterables 

511 

512 

513def _plot_and_save_spatial_plot( 

514 cube: iris.cube.Cube, 

515 filename: str, 

516 title: str, 

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

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

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

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

521 **kwargs, 

522): 

523 """Plot and save a spatial plot. 

524 

525 Parameters 

526 ---------- 

527 cube: Cube 

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

529 filename: str 

530 Filename of the plot to write. 

531 title: str 

532 Plot title. 

533 method: "contourf" | "pcolormesh" | "scatter" 

534 The plotting method to use 

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

536 overlay_cube: Cube, optional 

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

538 contour_cube: Cube, optional 

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

540 point_cube: Cube, optional 

541 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 

542 """ 

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

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

545 

546 # Specify the color bar 

547 cmap, levels, norm = colorbar_map_levels(cube) 

548 

549 if "feature" in cube.long_name: 549 ↛ 550line 549 didn't jump to line 550 because the condition on line 549 was never true

550 cmap.set_under("white") 

551 

552 # If overplotting, set required colorbars 

553 if overlay_cube: 

554 over_cmap, over_levels, over_norm = colorbar_map_levels(overlay_cube) 

555 if contour_cube: 

556 cntr_cmap, cntr_levels, cntr_norm = colorbar_map_levels(contour_cube) 

557 

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

559 axes = _setup_spatial_map(cube, fig, cmap) 

560 

561 # Set colorscale bounds 

562 try: 

563 vmin = min(levels) 

564 vmax = max(levels) 

565 except TypeError: 

566 vmin, vmax = None, None 

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

568 if norm is not None: 

569 vmin = None 

570 vmax = None 

571 logger.debug("Plotting using defined levels.") 

572 

573 # Plot the field. 

574 if method == "contourf": 

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

576 elif method == "pcolormesh": 

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

578 elif method == "scatter": 

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

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

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

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

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

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

585 # proportion to the area of the figure. 

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

587 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

588 plot = iplt.scatter( 

589 cube.coord(lon_axis), 

590 cube.coord(lat_axis), 

591 c=cube.data[:], 

592 s=mrk_size, 

593 cmap=cmap, 

594 edgecolors="k", 

595 norm=norm, 

596 vmin=vmin, 

597 vmax=vmax, 

598 ) 

599 else: 

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

601 

602 # Overplot overlay field, if required 

603 if overlay_cube: 

604 try: 

605 over_vmin = min(over_levels) 

606 over_vmax = max(over_levels) 

607 except TypeError: 

608 over_vmin, over_vmax = None, None 

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

610 over_vmin = None 

611 over_vmax = None 

612 overlay = iplt.pcolormesh( 

613 overlay_cube, 

614 cmap=over_cmap, 

615 norm=over_norm, 

616 alpha=0.8, 

617 vmin=over_vmin, 

618 vmax=over_vmax, 

619 ) 

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

621 if contour_cube: 

622 contour = iplt.contour( 

623 contour_cube, 

624 colors="darkgray", 

625 levels=cntr_levels, 

626 norm=cntr_norm, 

627 alpha=0.5, 

628 linestyles="--", 

629 linewidths=1, 

630 ) 

631 plt.clabel(contour) 

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

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

634 if point_cube: 

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

636 lat_axis, lon_axis = get_cube_yxcoordname(point_cube) 

637 lon_coord = point_cube.coord(lon_axis) 

638 lat_coord = point_cube.coord(lat_axis) 

639 valid = ~point_cube.data.mask 

640 valid_lon = iris.coords.AuxCoord( 

641 lon_coord.points[valid], 

642 standard_name=lon_coord.standard_name, 

643 units=lon_coord.units, 

644 coord_system=lon_coord.coord_system, 

645 ) 

646 valid_lat = iris.coords.AuxCoord( 

647 lat_coord.points[valid], 

648 standard_name=lat_coord.standard_name, 

649 units=lat_coord.units, 

650 coord_system=lat_coord.coord_system, 

651 ) 

652 iplt.scatter( 

653 valid_lon, 

654 valid_lat, 

655 c=point_cube.data[valid], 

656 s=mrk_size, 

657 cmap=cmap, 

658 edgecolors="k", 

659 norm=norm, 

660 vmin=vmin, 

661 vmax=vmax, 

662 ) 

663 

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

665 if is_transect(cube): 

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

667 axes.invert_yaxis() 

668 axes.set_yscale("log") 

669 axes.set_ylim(1100, 100) 

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

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

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

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

674 ): 

675 axes.set_yscale("log") 

676 

677 axes.set_title( 

678 f"{title}\n" 

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

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

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

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

683 fontsize=16, 

684 ) 

685 

686 # Inset code 

687 axins = inset_axes( 

688 axes, 

689 width="20%", 

690 height="20%", 

691 loc="upper right", 

692 axes_class=GeoAxes, 

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

694 ) 

695 

696 # Slightly transparent to reduce plot blocking. 

697 axins.patch.set_alpha(0.4) 

698 

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

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

701 

702 SLat, SLon, ELat, ELon = ( 

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

704 ) 

705 

706 # Draw line between them 

707 axins.plot( 

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

709 ) 

710 

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

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

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

714 

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

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

717 

718 # Midpoints 

719 lon_mid = (lon_min + lon_max) / 2 

720 lat_mid = (lat_min + lat_max) / 2 

721 

722 # Maximum half-range 

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

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

725 half_range = 1 

726 

727 # Set square extent 

728 axins.set_extent( 

729 [ 

730 lon_mid - half_range, 

731 lon_mid + half_range, 

732 lat_mid - half_range, 

733 lat_mid + half_range, 

734 ], 

735 crs=ccrs.PlateCarree(), 

736 ) 

737 

738 # Ensure square aspect 

739 axins.set_aspect("equal") 

740 

741 else: 

742 # Add title. 

743 axes.set_title(title, fontsize=16) 

744 

745 # Adjust padding if spatial plot or transect 

746 if is_transect(cube): 

747 yinfopad = -0.1 

748 ycbarpad = 0.1 

749 else: 

750 yinfopad = 0.01 

751 ycbarpad = 0.042 

752 

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

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

755 axes.annotate( 

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

757 xy=(0.025, yinfopad), 

758 xycoords="axes fraction", 

759 xytext=(-5, 5), 

760 textcoords="offset points", 

761 ha="left", 

762 va="bottom", 

763 size=11, 

764 bbox={"boxstyle": "round", "fc": "#cccccc", "ec": "#808080", "alpha": 0.9}, 

765 ) 

766 

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

768 if overlay_cube: 

769 cbarB = fig.colorbar( 

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

771 ) 

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

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

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

775 cbarB.set_ticks(over_levels) 

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

777 if any( 

778 var in overlay_cube.name() 

779 for var in ("rainfall", "snowfall", "visibility") 

780 ): 

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

782 logger.debug("Set secondary colorbar ticks and labels.") 

783 

784 # Add main colour bar. 

785 cbar = fig.colorbar( 

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

787 ) 

788 

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

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

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

792 cbar.set_ticks(levels) 

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

794 if any(var in cube.name() for var in ("rainfall", "snowfall", "visibility")): 794 ↛ 797line 794 didn't jump to line 797 because the condition on line 794 was always true

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

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

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

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

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

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

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

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

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

804 cbar.minorticks_off() 

805 cbar.set_ticks(tick_levels) 

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

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

808 # Tick labels for model rainfall data. 

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

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

811 # Tick labels for Nimrod weights data. 

812 logger.debug("Set colorbar ticks and labels.") 

813 

814 # Save plot. 

815 _save_close_figure(fig, "spatial", filename) 

816 

817 

818def _plot_and_save_postage_stamp_spatial_plot( 

819 cube: iris.cube.Cube, 

820 filename: str, 

821 stamp_coordinate: str, 

822 title: str, 

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

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

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

826 **kwargs, 

827): 

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

829 

830 Parameters 

831 ---------- 

832 cube: Cube 

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

834 filename: str 

835 Filename of the plot to write. 

836 stamp_coordinate: str 

837 Coordinate that becomes different plots. 

838 method: "contourf" | "pcolormesh" 

839 The plotting method to use. 

840 overlay_cube: Cube, optional 

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

842 contour_cube: Cube, optional 

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

844 

845 Raises 

846 ------ 

847 ValueError 

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

849 """ 

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

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

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

853 grid_size = math.ceil(nmember / grid_rows) 

854 

855 fig = plt.figure( 

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

857 ) 

858 

859 # Specify the color bar 

860 cmap, levels, norm = colorbar_map_levels(cube) 

861 # If overplotting, set required colorbars 

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

863 over_cmap, over_levels, over_norm = colorbar_map_levels(overlay_cube) 

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

865 cntr_cmap, cntr_levels, cntr_norm = colorbar_map_levels(contour_cube) 

866 

867 # Make a subplot for each member. 

868 for member, subplot in zip( 

869 cube.slices_over(stamp_coordinate), 

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

871 strict=False, 

872 ): 

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

874 axes = _setup_spatial_map( 

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

876 ) 

877 if method == "contourf": 

878 # Filled contour plot of the field. 

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

880 elif method == "pcolormesh": 

881 if levels is not None: 

882 vmin = min(levels) 

883 vmax = max(levels) 

884 else: 

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

886 vmin, vmax = None, None 

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

888 # if levels are defined. 

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

890 vmin = None 

891 vmax = None 

892 # pcolormesh plot of the field. 

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

894 else: 

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

896 

897 # Overplot overlay field, if required 

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

899 try: 

900 over_vmin = min(over_levels) 

901 over_vmax = max(over_levels) 

902 except TypeError: 

903 over_vmin, over_vmax = None, None 

904 if over_norm is not None: 

905 over_vmin = None 

906 over_vmax = None 

907 iplt.pcolormesh( 

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

909 cmap=over_cmap, 

910 norm=over_norm, 

911 alpha=0.6, 

912 vmin=over_vmin, 

913 vmax=over_vmax, 

914 ) 

915 # Overplot contour field, if required 

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

917 iplt.contour( 

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

919 colors="darkgray", 

920 levels=cntr_levels, 

921 norm=cntr_norm, 

922 alpha=0.6, 

923 linestyles="--", 

924 linewidths=1, 

925 ) 

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

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

928 

929 # Put the shared colorbar in its own axes. 

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

931 colorbar = fig.colorbar( 

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

933 ) 

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

935 

936 # Overall figure title. 

937 fig.suptitle(title, fontsize=16) 

938 

939 # Save plot. 

940 _save_close_figure(fig, "contour postate stamp", filename) 

941 

942 

943def _plot_and_save_line_series( 

944 cubes: iris.cube.CubeList, 

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

946 ensemble_coord: str, 

947 filename: str, 

948 title: str, 

949 **kwargs, 

950): 

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

952 

953 Parameters 

954 ---------- 

955 cubes: Cube or CubeList 

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

957 coords: list[Coord] 

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

959 ensemble_coord: str 

960 Ensemble coordinate in the cube. 

961 filename: str 

962 Filename of the plot to write. 

963 title: str 

964 Plot title. 

965 """ 

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

967 

968 model_colors_map = get_model_colors_map(cubes) 

969 

970 # Store min/max ranges. 

971 y_levels = [] 

972 

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

974 validate_cubes_coords(cubes, coords) 

975 

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

977 label = None 

978 color = "black" 

979 if model_colors_map: 

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

981 color = model_colors_map.get(label) 

982 if not cube.coords(ensemble_coord): 982 ↛ 984line 982 didn't jump to line 984 because the condition on line 982 was never true

983 # No ensemble coordinate — plot the cube directly as a single line. 

984 iplt.plot(coord, cube, color=color, marker="o", ls="-", lw=3, label=label) 

985 else: 

986 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

989 iplt.plot( 

990 coord, 

991 cube_slice, 

992 color=color, 

993 marker="o", 

994 ls="-", 

995 lw=3, 

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

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

998 else label, 

999 ) 

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

1001 else: 

1002 iplt.plot( 

1003 coord, 

1004 cube_slice, 

1005 color=color, 

1006 ls="-", 

1007 lw=1.5, 

1008 alpha=0.75, 

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

1010 ) 

1011 

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

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

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

1015 y_levels.append(min(levels)) 

1016 y_levels.append(max(levels)) 

1017 

1018 # Get the current axes. 

1019 ax = plt.gca() 

1020 

1021 # Add some labels and tweak the style. 

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

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

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

1025 else: 

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

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

1028 ax.set_title(title, fontsize=16) 

1029 

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

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

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

1033 

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

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

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

1037 logger.debug("Line plot with y-axis limits %s-%s", min(y_levels), max(y_levels)) 

1038 else: 

1039 ax.autoscale() 

1040 

1041 # Add gridlines 

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

1043 # Add zero line 

1044 ymin, ymax = ax.get_ylim() 

1045 if ymin < 0.0 and ymax > 0.0: 

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

1047 # Identify unique labels for legend 

1048 handles = list( 

1049 { 

1050 label: handle 

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

1052 }.values() 

1053 ) 

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

1055 

1056 # Save plot. 

1057 _save_close_figure(fig, "line", filename) 

1058 

1059 

1060def _plot_and_save_line_power_spectrum_series( 

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

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

1063 ensemble_coord: str, 

1064 filename: str, 

1065 title: str, 

1066 series_coordinate: str, 

1067 **kwargs, 

1068): 

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

1070 

1071 Parameters 

1072 ---------- 

1073 cubes: Cube or CubeList 

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

1075 coords: list[Coord] 

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

1077 ensemble_coord: str 

1078 Ensemble coordinate in the cube. 

1079 filename: str 

1080 Filename of the plot to write. 

1081 title: str 

1082 Plot title. 

1083 series_coordinate: str 

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

1085 """ 

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

1087 model_colors_map = get_model_colors_map(cubes) 

1088 ax = plt.gca() 

1089 

1090 # Store min/max ranges. 

1091 y_levels = [] 

1092 

1093 line_marker = None 

1094 line_width = 1 

1095 

1096 for cube in iter_maybe(cubes): 

1097 # next 2 lines replace chunk of code. 

1098 xcoord = _select_series_coord(cube, series_coordinate) 

1099 xname = xcoord.points 

1100 

1101 yfield = cube.data # power spectrum 

1102 

1103 # If data from power spectra is all np.nans (like T+0h rainfall field which 

1104 # might be full of zeros), then set yfield to zeros so it doesn't crash the 

1105 # plotting. 

1106 if np.all(np.isnan(yfield)): 

1107 yfield = np.zeros_like(yfield) 

1108 

1109 label = None 

1110 color = "black" 

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

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

1113 color = model_colors_map.get(label) 

1114 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

1117 ax.plot( 

1118 xname, 

1119 yfield, 

1120 color=color, 

1121 marker=line_marker, 

1122 ls="-", 

1123 lw=line_width, 

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

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

1126 else label, 

1127 ) 

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

1129 else: 

1130 ax.plot( 

1131 xname, 

1132 yfield, 

1133 color=color, 

1134 ls="-", 

1135 lw=1.5, 

1136 alpha=0.75, 

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

1138 ) 

1139 

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

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

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

1143 y_levels.append(min(levels)) 

1144 y_levels.append(max(levels)) 

1145 

1146 # Add some labels and tweak the style. 

1147 

1148 title = f"{title}" 

1149 ax.set_title(title, fontsize=16) 

1150 

1151 # Set appropriate x-axis label based on coordinate 

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

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

1154 ): 

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

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

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

1158 ): 

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

1160 else: # frequency or check units 

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

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

1163 else: 

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

1165 

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

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

1168 

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

1170 

1171 # Set log-log scale 

1172 ax.set_xscale("log") 

1173 ax.set_yscale("log") 

1174 

1175 # Add gridlines 

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

1177 # Ientify unique labels for legend 

1178 handles = list( 

1179 { 

1180 label: handle 

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

1182 }.values() 

1183 ) 

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

1185 

1186 # Save plot. 

1187 _save_close_figure(fig, "line power spectrum", filename) 

1188 

1189 

1190def _plot_and_save_vertical_line_series( 

1191 cubes: iris.cube.CubeList, 

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

1193 ensemble_coord: str, 

1194 filename: str, 

1195 series_coordinate: str, 

1196 title: str, 

1197 vmin: float, 

1198 vmax: float, 

1199 **kwargs, 

1200): 

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

1202 

1203 Parameters 

1204 ---------- 

1205 cubes: CubeList 

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

1207 coord: list[Coord] 

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

1209 ensemble_coord: str 

1210 Ensemble coordinate in the cube. 

1211 filename: str 

1212 Filename of the plot to write. 

1213 series_coordinate: str 

1214 Coordinate to use as vertical axis. 

1215 title: str 

1216 Plot title. 

1217 vmin: float 

1218 Minimum value for the x-axis. 

1219 vmax: float 

1220 Maximum value for the x-axis. 

1221 """ 

1222 # plot the vertical pressure axis using log scale 

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

1224 

1225 model_colors_map = get_model_colors_map(cubes) 

1226 

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

1228 validate_cubes_coords(cubes, coords) 

1229 

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

1231 label = None 

1232 color = "black" 

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

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

1235 color = model_colors_map.get(label) 

1236 

1237 for cube_slice in cube.slices_over(ensemble_coord): 

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

1239 # unless single forecast. 

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

1241 iplt.plot( 

1242 cube_slice, 

1243 coord, 

1244 color=color, 

1245 marker="o", 

1246 ls="-", 

1247 lw=3, 

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

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

1250 else label, 

1251 ) 

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

1253 else: 

1254 iplt.plot( 

1255 cube_slice, 

1256 coord, 

1257 color=color, 

1258 ls="-", 

1259 lw=1.5, 

1260 alpha=0.75, 

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

1262 ) 

1263 

1264 # Get the current axis 

1265 ax = plt.gca() 

1266 

1267 # Special handling for pressure level data. 

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

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

1270 ax.invert_yaxis() 

1271 ax.set_yscale("log") 

1272 

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

1274 y_tick_labels = [ 

1275 "1000", 

1276 "850", 

1277 "700", 

1278 "500", 

1279 "300", 

1280 "200", 

1281 "100", 

1282 ] 

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

1284 

1285 # Set y-axis limits and ticks. 

1286 ax.set_ylim(1100, 100) 

1287 

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

1289 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

1295 

1296 ax.set_yticks(y_ticks) 

1297 ax.set_yticklabels(y_tick_labels) 

1298 

1299 # Set x-axis limits. 

1300 ax.set_xlim(vmin, vmax) 

1301 # Mark y=0 if present in plot. 

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

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

1304 

1305 # Add some labels and tweak the style. 

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

1307 ax.set_xlabel( 

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

1309 ) 

1310 ax.set_title(title, fontsize=16) 

1311 ax.ticklabel_format(axis="x") 

1312 ax.tick_params(axis="y") 

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

1314 

1315 # Add gridlines 

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

1317 # Ientify unique labels for legend 

1318 handles = list( 

1319 { 

1320 label: handle 

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

1322 }.values() 

1323 ) 

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

1325 

1326 # Save plot. 

1327 _save_close_figure(fig, "vertical line", filename) 

1328 

1329 

1330def _plot_and_save_scatter_plot( 

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

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

1333 filename: str, 

1334 title: str, 

1335 one_to_one: bool, 

1336 model_names: list[str] | None = None, 

1337 **kwargs, 

1338): 

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

1340 

1341 Parameters 

1342 ---------- 

1343 cube_x: Cube | CubeList 

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

1345 cube_y: Cube | CubeList 

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

1347 filename: str 

1348 Filename of the plot to write. 

1349 title: str 

1350 Plot title. 

1351 one_to_one: bool 

1352 Whether a 1:1 line is plotted. 

1353 """ 

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

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

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

1357 # over the pairs simultaneously. 

1358 

1359 # Ensure cube_x and cube_y are iterable 

1360 cube_x_iterable = iter_maybe(cube_x) 

1361 cube_y_iterable = iter_maybe(cube_y) 

1362 

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

1364 iplt.scatter(cube_x_iter, cube_y_iter) 

1365 if one_to_one is True: 

1366 plt.plot( 

1367 [ 

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

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

1370 ], 

1371 [ 

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

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

1374 ], 

1375 "k", 

1376 linestyle="--", 

1377 ) 

1378 ax = plt.gca() 

1379 

1380 # Add some labels and tweak the style. 

1381 if model_names is None: 

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

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

1384 else: 

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

1386 ax.set_xlabel( 

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

1388 ) 

1389 ax.set_ylabel( 

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

1391 ) 

1392 ax.set_title(title, fontsize=16) 

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

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

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

1396 ax.autoscale() 

1397 

1398 # Save plot. 

1399 _save_close_figure(fig, "scatter", filename) 

1400 

1401 

1402def _plot_and_save_vector_plot( 

1403 cube_u: iris.cube.Cube, 

1404 cube_v: iris.cube.Cube, 

1405 filename: str, 

1406 title: str, 

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

1408 **kwargs, 

1409): 

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

1411 

1412 Parameters 

1413 ---------- 

1414 cube_u: Cube 

1415 2 dimensional Cube of u component of the data. 

1416 cube_v: Cube 

1417 2 dimensional Cube of v component of the data. 

1418 filename: str 

1419 Filename of the plot to write. 

1420 title: str 

1421 Plot title. 

1422 """ 

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

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

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

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

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

1428 cube_vec_mag.rename( 

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

1430 ) 

1431 

1432 # Specify the color bar 

1433 cmap, levels, norm = colorbar_map_levels(cube_vec_mag) 

1434 

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

1436 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

1437 

1438 if method == "contourf": 

1439 # Filled contour plot of the field. 

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

1441 elif method == "pcolormesh": 

1442 try: 

1443 vmin = min(levels) 

1444 vmax = max(levels) 

1445 except TypeError: 

1446 vmin, vmax = None, None 

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

1448 # if levels are defined. 

1449 if norm is not None: 

1450 vmin = None 

1451 vmax = None 

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

1453 else: 

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

1455 

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

1457 if is_transect(cube_vec_mag): 

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

1459 axes.invert_yaxis() 

1460 axes.set_yscale("log") 

1461 axes.set_ylim(1100, 100) 

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

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

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

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

1466 ): 

1467 axes.set_yscale("log") 

1468 

1469 axes.set_title( 

1470 f"{title}\n" 

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

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

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

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

1475 fontsize=16, 

1476 ) 

1477 

1478 else: 

1479 # Add title. 

1480 axes.set_title(title, fontsize=16) 

1481 

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

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

1484 axes.annotate( 

1485 f"Min: {np.nanmin(cube_vec_mag.data.filled(np.nan)):.3g} Max: {np.nanmax(cube_vec_mag.data.filled(np.nan)):.3g} Mean: {np.nanmean(cube_vec_mag.data.filled(np.nan)):.3g}", 

1486 xy=(0.05, -0.05), 

1487 xycoords="axes fraction", 

1488 xytext=(-5, 5), 

1489 textcoords="offset points", 

1490 ha="right", 

1491 va="bottom", 

1492 size=11, 

1493 bbox={"boxstyle": "round", "fc": "#cccccc", "ec": "#808080", "alpha": 0.9}, 

1494 ) 

1495 

1496 # Add colour bar. 

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

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

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

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

1501 cbar.set_ticks(levels) 

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

1503 

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

1505 # with less than 30 points. 

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

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

1508 

1509 # Save plot. 

1510 _save_close_figure(fig, "vector", filename) 

1511 

1512 

1513def _plot_and_save_histogram_series( 

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

1515 filename: str, 

1516 title: str, 

1517 vmin: float, 

1518 vmax: float, 

1519 **kwargs, 

1520): 

1521 """Plot and save a histogram series. 

1522 

1523 Parameters 

1524 ---------- 

1525 cubes: Cube or CubeList 

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

1527 filename: str 

1528 Filename of the plot to write. 

1529 title: str 

1530 Plot title. 

1531 vmin: float 

1532 minimum for colorbar 

1533 vmax: float 

1534 maximum for colorbar 

1535 """ 

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

1537 ax = plt.gca() 

1538 

1539 model_colors_map = get_model_colors_map(cubes) 

1540 

1541 # Set default that histograms will produce probability density function 

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

1543 if "feature" in cubes[0].long_name: 1543 ↛ 1544line 1543 didn't jump to line 1544 because the condition on line 1543 was never true

1544 density = False 

1545 else: 

1546 density = True 

1547 

1548 for cube in iter_maybe(cubes): 

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

1550 # than seeing if long names exist etc. 

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

1552 if ( 

1553 ("surface_microphysical" in title) 

1554 or ("rain accumulation" in title) 

1555 or ("Rainfall rate Composite" in title) 

1556 or ("Nimrod_5min" in title) 

1557 ): 

1558 if "amount" in title: 

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

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

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

1562 density = False 

1563 else: 

1564 bins = 10.0 ** ( 

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

1566 ) # Suggestion from RMED toolbox. 

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

1568 ax.set_yscale("log") 

1569 vmin = bins[1] 

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

1571 ax.set_xscale("log") 

1572 elif "lightning" in title: 

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

1574 elif "feature_size" in cube.long_name: 1574 ↛ 1575line 1574 didn't jump to line 1575 because the condition on line 1574 was never true

1575 bins = np.linspace(0, 500, 51) 

1576 elif "feature_effective_radius" in cube.long_name: 1576 ↛ 1580line 1576 didn't jump to line 1580 because the condition on line 1576 was never true

1577 # TODO: use grid_spacing attribute in cubes to find min bin size 

1578 # for effective radius, rather than being hard coded 

1579 # Modified from RMED toolbox 

1580 bins = 10 ** (np.arange(0, 5.28, 0.12)) 

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

1582 vmin = bins[1] 

1583 vmax = bins[-1] 

1584 elif "feature_mean" in cube.long_name or "feature_max" in cube.long_name: 1584 ↛ 1586line 1584 didn't jump to line 1586 because the condition on line 1584 was never true

1585 # From RMED toolbox 

1586 bins = 10 ** (np.arange(-1, 2.7, 0.12)) 

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

1588 vmin = bins[1] 

1589 vmax = bins[-1] 

1590 else: 

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

1592 logger.debug( 

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

1594 np.size(bins), 

1595 np.min(bins), 

1596 np.max(bins), 

1597 ) 

1598 

1599 if "feature" in cube.long_name: 1599 ↛ 1600line 1599 didn't jump to line 1600 because the condition on line 1599 was never true

1600 ax.set_yscale("log") 

1601 ax.set_xscale("log") 

1602 

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

1604 # Otherwise we plot xdim histograms stacked. 

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

1606 

1607 label = None 

1608 color = "black" 

1609 if model_colors_map: 

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

1611 color = model_colors_map[label] 

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

1613 

1614 # Compute area under curve. 

1615 if ( 

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

1617 or ("rain_accumulation" in title) 

1618 or ("Rainfall rate Composite" in title) 

1619 or ("Nimrod_5min" in title) 

1620 ): 

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

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

1623 x = x[1:] 

1624 y = y[1:] 

1625 

1626 ax.plot( 

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

1628 ) 

1629 

1630 # Add some labels and tweak the style. 

1631 ax.set_title(title, fontsize=16) 

1632 ax.set_xlabel( 

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

1634 ) 

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

1636 if ( 

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

1638 or ("rain accumulation" in title) 

1639 or ("Nimrod_5min" in title) 

1640 ): 

1641 ax.set_ylabel( 

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

1643 ) 

1644 if "feature" in cubes[0].long_name: 1644 ↛ 1645line 1644 didn't jump to line 1645 because the condition on line 1644 was never true

1645 ax.set_ylabel("Frequency", fontsize=14) 

1646 

1647 try: 

1648 ax.set_xlim(vmin, vmax) 

1649 except ValueError: 

1650 pass 

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

1652 

1653 # Overlay grid-lines onto histogram plot. 

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

1655 if model_colors_map: 

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

1657 

1658 # Save plot. 

1659 _save_close_figure(fig, "histogram", filename) 

1660 

1661 

1662def _plot_and_save_postage_stamp_histogram_series( 

1663 cube: iris.cube.Cube, 

1664 filename: str, 

1665 title: str, 

1666 stamp_coordinate: str, 

1667 vmin: float, 

1668 vmax: float, 

1669 **kwargs, 

1670): 

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

1672 

1673 Parameters 

1674 ---------- 

1675 cube: Cube 

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

1677 filename: str 

1678 Filename of the plot to write. 

1679 title: str 

1680 Plot title. 

1681 stamp_coordinate: str 

1682 Coordinate that becomes different plots. 

1683 vmin: float 

1684 minimum for pdf x-axis 

1685 vmax: float 

1686 maximum for pdf x-axis 

1687 """ 

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

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

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

1691 grid_size = math.ceil(nmember / grid_rows) 

1692 

1693 fig = plt.figure( 

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

1695 ) 

1696 # Make a subplot for each member. 

1697 for member, subplot in zip( 

1698 cube.slices_over(stamp_coordinate), 

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

1700 strict=False, 

1701 ): 

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

1703 # cartopy GeoAxes generated. 

1704 plt.subplot(grid_rows, grid_size, subplot) 

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

1706 # Otherwise we plot xdim histograms stacked. 

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

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

1709 axes = plt.gca() 

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

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

1712 axes.set_xlim(vmin, vmax) 

1713 

1714 # Overall figure title. 

1715 fig.suptitle(title, fontsize=16) 

1716 

1717 # Save plot. 

1718 _save_close_figure(fig, "histogram postage stamp", filename) 

1719 

1720 

1721def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1722 cube: iris.cube.Cube, 

1723 filename: str, 

1724 title: str, 

1725 stamp_coordinate: str, 

1726 vmin: float, 

1727 vmax: float, 

1728 **kwargs, 

1729): 

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

1731 ax.set_title(title, fontsize=16) 

1732 ax.set_xlim(vmin, vmax) 

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

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

1735 # Loop over all slices along the stamp_coordinate 

1736 for member in cube.slices_over(stamp_coordinate): 

1737 # Flatten the member data to 1D 

1738 member_data_1d = member.data.flatten() 

1739 # Plot the histogram using plt.hist 

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

1741 plt.hist( 

1742 member_data_1d, 

1743 density=True, 

1744 stacked=True, 

1745 label=f"{mtitle}", 

1746 ) 

1747 

1748 # Add a legend 

1749 ax.legend(fontsize=16) 

1750 

1751 # Save plot. 

1752 _save_close_figure(fig, "histogram postage stamp", filename) 

1753 

1754 

1755def _plot_and_save_scatter_series( 

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

1757 filename: str, 

1758 title: str, 

1759 vmin: float, 

1760 vmax: float, 

1761 hexbin: bool, 

1762 **kwargs, 

1763): 

1764 """Plot and save a scatter plot series. 

1765 

1766 Parameters 

1767 ---------- 

1768 cubes: Cube or CubeList 

1769 2 dimensional Cube or CubeList of the data to plot as scatter. 

1770 filename: str 

1771 Filename of the plot to write. 

1772 title: str 

1773 Plot title. 

1774 vmin: float 

1775 minimum for colorbar 

1776 vmax: float 

1777 maximum for colorbar 

1778 hexbin: bool 

1779 Flag to set output scatter generated as a hexbin frequency distribution plot of 2 cubes on single plot. 

1780 Else scatter of all points, with potential to overplot many comparisons on same plot. 

1781 """ 

1782 if hexbin: 

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

1784 if len(cubes) != 2: 

1785 raise ValueError( 

1786 "Cubes should contain exactly 2 cubes for hexbin plotting." 

1787 ) 

1788 title = title.replace("scatter", "hexbin") 

1789 filename = filename.replace("scatter", "hexbin") 

1790 

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

1792 ax = plt.gca() 

1793 

1794 model_colors_map = get_model_colors_map(cubes) 

1795 

1796 percentiles = np.arange(0, 100, 5) 

1797 percentiles[0] = 1 

1798 percentiles[-1] = 99 

1799 quantiles = iris.cube.CubeList() 

1800 

1801 # Loop through all output cubes for both data points and overplotting quantiles. 

1802 # Set indexing of nplot to avoid plotting 1:1 scatter of cubes[0] vs cubes[0] 

1803 for plottype in ["points", "quantiles"]: 

1804 nplot = 0 

1805 for cube in iter_maybe(cubes): 

1806 label = None 

1807 color = "black" 

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

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

1810 color = model_colors_map[label] 

1811 

1812 # Plot all data points 

1813 if plottype == "points": 

1814 if nplot > 0: 

1815 if hexbin: 

1816 hb = plt.hexbin( 

1817 cubes[0].data.flatten(), 

1818 cube.data.flatten(), 

1819 alpha=0.3, 

1820 gridsize=100, 

1821 mincnt=1, 

1822 ) 

1823 else: 

1824 plt.scatter( 

1825 cubes[0].data.flatten(), 

1826 cube.data.flatten(), 

1827 color=color, 

1828 marker="+", 

1829 label=None, 

1830 alpha=0.3, 

1831 ) 

1832 

1833 elif plottype == "quantiles": 1833 ↛ 1852line 1833 didn't jump to line 1852 because the condition on line 1833 was always true

1834 # Construct Q-Q plot 

1835 quantiles.append( 

1836 cube.collapsed( 

1837 cube.coords(dim_coords=True), 

1838 iris.analysis.PERCENTILE, 

1839 percent=percentiles, 

1840 ) 

1841 ) 

1842 if nplot > 0: 

1843 iplt.scatter( 

1844 quantiles[0], 

1845 quantiles[-1], 

1846 color=color, 

1847 marker="o", 

1848 label=label, 

1849 edgecolors="black", 

1850 ) 

1851 

1852 nplot = nplot + 1 

1853 

1854 # Add some labels and tweak the style. 

1855 ax.set_title(title, fontsize=16) 

1856 ax.set_xlabel( 

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

1858 ) 

1859 ax.set_ylabel( 

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

1861 ) 

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

1863 ax.autoscale() 

1864 

1865 # Set 1:1 line and equal axes if scatter plot of common cube names 

1866 nameA = iter_maybe(cubes)[0].name() 

1867 nameB = iter_maybe(cubes)[1].name() 

1868 if any(part in nameB.split("_") for part in nameA.split("_")): 1868 ↛ 1879line 1868 didn't jump to line 1879 because the condition on line 1868 was always true

1869 lims = [ 

1870 np.min([ax.get_xlim(), ax.get_ylim()]), # min of both axes 

1871 np.max([ax.get_xlim(), ax.get_ylim()]), # max of both axes 

1872 ] 

1873 ax.plot(lims, lims, "k-", alpha=0.75, zorder=0) 

1874 ax.set_aspect("equal") 

1875 ax.set_xlim(lims) 

1876 ax.set_ylim(lims) 

1877 

1878 # Overlay grid-lines onto scatter plot. 

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

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

1881 ax.legend(loc="upper left", ncol=1, frameon=True, fontsize=16) 

1882 

1883 # Add colorbar if hexbin output 

1884 if hexbin: 

1885 cb = plt.colorbar( 

1886 hb, orientation="horizontal", location="bottom", pad=0.08, shrink=0.7 

1887 ) 

1888 cb.set_label("Number of data points", size=12) 

1889 

1890 # Save plot. 

1891 _save_close_figure(fig, "scatter", filename) 

1892 

1893 

1894def _spatial_plot( 

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

1896 cube: iris.cube.Cube, 

1897 filename: str | None, 

1898 sequence_coordinate: str, 

1899 stamp_coordinate: str, 

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

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

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

1903 **kwargs, 

1904): 

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

1906 

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

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

1909 is present then postage stamp plots will be produced. 

1910 

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

1912 be overplotted on the same figure. 

1913 

1914 Parameters 

1915 ---------- 

1916 method: "contourf" | "pcolormesh" | "scatter" 

1917 The plotting method to use. 

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

1919 Use "scatter" for point-based data. 

1920 cube: Cube 

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

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

1923 plotted sequentially and/or as postage stamp plots. 

1924 filename: str | None 

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

1926 uses the recipe name. 

1927 sequence_coordinate: str 

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

1929 This coordinate must exist in the cube. 

1930 stamp_coordinate: str 

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

1932 ``"realization"``. 

1933 overlay_cube: Cube | None, optional 

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

1935 contour_cube: Cube | None, optional 

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

1937 point_cube: Cube | None, optional 

1938 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 

1939 

1940 Raises 

1941 ------ 

1942 ValueError 

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

1944 TypeError 

1945 If the cube isn't a single cube. 

1946 """ 

1947 # Ensure we've got a single cube. 

1948 cube = check_single_cube(cube) 

1949 

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

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

1952 

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

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

1955 stamp_coordinate = check_stamp_coordinate(cube) 

1956 

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

1958 # single point. 

1959 plotting_func = _plot_and_save_spatial_plot 

1960 try: 

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

1962 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1963 except iris.exceptions.CoordinateNotFoundError: 

1964 pass 

1965 

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

1967 # dimension called observation or model_obs_error 

1968 if any( 

1969 crd.var_name == "station" 

1970 or crd.var_name == "Station_Name" 

1971 or crd.var_name == "model_obs_error" 

1972 for crd in cube.coords() 

1973 ): 

1974 plotting_func = _plot_and_save_spatial_plot 

1975 method = "scatter" 

1976 

1977 # Must have a sequence coordinate. 

1978 try: 

1979 cube.coord(sequence_coordinate) 

1980 except iris.exceptions.CoordinateNotFoundError as err: 

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

1982 

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

1984 plot_index = [] 

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

1986 

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

1988 # Set plot titles and filename 

1989 seq_coord = cube_slice.coord(sequence_coordinate) 

1990 

1991 if "model_name" in cube.attributes: 1991 ↛ 1992line 1991 didn't jump to line 1992 because the condition on line 1991 was never true

1992 model_name = cube.attributes["model_name"] 

1993 else: 

1994 model_name = None 

1995 

1996 plot_title, plot_filename = _set_title_and_filename( 

1997 seq_coord, nplot, recipe_title, filename, model_name=model_name 

1998 ) 

1999 

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

2001 overlay_slice = slice_over_maybe(overlay_cube, sequence_coordinate, iseq) 

2002 contour_slice = slice_over_maybe(contour_cube, sequence_coordinate, iseq) 

2003 point_slice = slice_over_maybe(point_cube, sequence_coordinate, iseq) 

2004 

2005 # Do the actual plotting. 

2006 plotting_func( 

2007 cube_slice, 

2008 filename=plot_filename, 

2009 stamp_coordinate=stamp_coordinate, 

2010 title=plot_title, 

2011 method=method, 

2012 overlay_cube=overlay_slice, 

2013 contour_cube=contour_slice, 

2014 point_cube=point_slice, 

2015 **kwargs, 

2016 ) 

2017 plot_index.append(plot_filename) 

2018 

2019 # Add list of plots to plot metadata. 

2020 complete_plot_index = _append_to_plot_index(plot_index) 

2021 

2022 # Make a page to display the plots. 

2023 _make_plot_html_page(complete_plot_index) 

2024 

2025 

2026#################### 

2027# Public functions # 

2028#################### 

2029 

2030 

2031def spatial_contour_plot( 

2032 cube: iris.cube.Cube, 

2033 filename: str | None = None, 

2034 sequence_coordinate: str = "time", 

2035 stamp_coordinate: str = "realization", 

2036 **kwargs, 

2037) -> iris.cube.Cube: 

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

2039 

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

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

2042 is present then postage stamp plots will be produced. 

2043 

2044 Parameters 

2045 ---------- 

2046 cube: Cube 

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

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

2049 plotted sequentially and/or as postage stamp plots. 

2050 filename: str, optional 

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

2052 to the recipe name. 

2053 sequence_coordinate: str, optional 

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

2055 This coordinate must exist in the cube. 

2056 stamp_coordinate: str, optional 

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

2058 ``"realization"``. 

2059 

2060 Returns 

2061 ------- 

2062 Cube 

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

2064 

2065 Raises 

2066 ------ 

2067 ValueError 

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

2069 TypeError 

2070 If the cube isn't a single cube. 

2071 """ 

2072 _spatial_plot( 

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

2074 ) 

2075 return cube 

2076 

2077 

2078def spatial_pcolormesh_plot( 

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

2080 filename: str | None = None, 

2081 sequence_coordinate: str = "time", 

2082 stamp_coordinate: str = "realization", 

2083 **kwargs, 

2084) -> iris.cube.Cube: 

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

2086 

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

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

2089 is present then postage stamp plots will be produced. 

2090 

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

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

2093 contour areas are important. 

2094 

2095 Parameters 

2096 ---------- 

2097 cube: Cubes 

2098 Iris cube or cubelist of the data to plot. Each cube should have two spatial dimensions, 

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

2100 plotted sequentially and/or as postage stamp plots. 

2101 filename: str, optional 

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

2103 to the recipe name. 

2104 sequence_coordinate: str, optional 

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

2106 This coordinate must exist in the cube. 

2107 stamp_coordinate: str, optional 

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

2109 ``"realization"``. 

2110 

2111 Returns 

2112 ------- 

2113 Cubes 

2114 The original cube/cubelist (so further operations can be applied). 

2115 

2116 Raises 

2117 ------ 

2118 ValueError 

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

2120 """ 

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

2122 for model_cube in cubes: 

2123 _spatial_plot( 

2124 "pcolormesh", 

2125 model_cube, 

2126 filename, 

2127 sequence_coordinate, 

2128 stamp_coordinate, 

2129 **kwargs, 

2130 ) 

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

2132 _spatial_plot( 

2133 "pcolormesh", 

2134 cubes, 

2135 filename, 

2136 sequence_coordinate, 

2137 stamp_coordinate, 

2138 **kwargs, 

2139 ) 

2140 return cubes 

2141 

2142 

2143def spatial_multi_pcolormesh_plot( 

2144 cube: iris.cube.Cube, 

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

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

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

2148 filename: str | None = None, 

2149 sequence_coordinate: str = "time", 

2150 stamp_coordinate: str = "realization", 

2151 **kwargs, 

2152) -> iris.cube.Cube: 

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

2154 

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

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

2157 is present then postage stamp plots will be produced. 

2158 

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

2160 

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

2162 

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

2164 

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

2166 

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

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

2169 contour areas are important. 

2170 

2171 Parameters 

2172 ---------- 

2173 cube: Cube 

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

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

2176 plotted sequentially and/or as postage stamp plots. 

2177 overlay_cube: Cube, optional 

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

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

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

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

2182 contour_cube: Cube, optional 

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

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

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

2186 point_cube: Cube, optional 

2187 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 

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

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

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

2191 filename: str, optional 

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

2193 to the recipe name. 

2194 sequence_coordinate: str, optional 

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

2196 This coordinate must exist in the cube. 

2197 stamp_coordinate: str, optional 

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

2199 ``"realization"``. 

2200 

2201 Returns 

2202 ------- 

2203 Cube 

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

2205 

2206 Raises 

2207 ------ 

2208 ValueError 

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

2210 TypeError 

2211 If the cube isn't a single cube. 

2212 """ 

2213 _spatial_plot( 

2214 "pcolormesh", 

2215 cube, 

2216 filename, 

2217 sequence_coordinate, 

2218 stamp_coordinate, 

2219 overlay_cube=overlay_cube, 

2220 contour_cube=contour_cube, 

2221 point_cube=point_cube, 

2222 ) 

2223 return cube, overlay_cube, contour_cube, point_cube 

2224 

2225 

2226# TODO: Expand function to handle ensemble data. 

2227# line_coordinate: str, optional 

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

2229# ``"realization"``. 

2230def plot_line_series( 

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

2232 filename: str | None = None, 

2233 series_coordinate: str = "time", 

2234 sequence_coordinate: str = "time", 

2235 # add the following for ensembles 

2236 stamp_coordinate: str = "realization", 

2237 single_plot: bool = False, 

2238 **kwargs, 

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

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

2241 

2242 The Cube or CubeList must be 1D. 

2243 

2244 Parameters 

2245 ---------- 

2246 iris.cube | iris.cube.CubeList 

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

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

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

2250 filename: str, optional 

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

2252 to the recipe name. 

2253 series_coordinate: str, optional 

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

2255 coordinate must exist in the cube. 

2256 

2257 Returns 

2258 ------- 

2259 iris.cube.Cube | iris.cube.CubeList 

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

2261 

2262 Raises 

2263 ------ 

2264 ValueError 

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

2266 TypeError 

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

2268 """ 

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

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

2271 

2272 num_models = get_num_models(cube) 

2273 

2274 validate_cube_shape(cube, num_models) 

2275 

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

2277 cubes = iris.cube.CubeList(iter_maybe(cube)) 

2278 coords = [] 

2279 for model_cube in cubes: 

2280 try: 

2281 coords.append(model_cube.coord(series_coordinate)) 

2282 except iris.exceptions.CoordinateNotFoundError as err: 

2283 raise ValueError( 

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

2285 ) from err 

2286 # Count dimensions excluding realization 

2287 ndim = model_cube.ndim 

2288 

2289 if model_cube.coords("realization"): 2289 ↛ 2296line 2289 didn't jump to line 2296 because the condition on line 2289 was always true

2290 realization_dims = model_cube.coord_dims("realization") 

2291 

2292 # Only subtract if realization is a dimension coordinate 

2293 if realization_dims: 

2294 ndim -= len(realization_dims) 

2295 

2296 if ndim > 2: 

2297 raise ValueError( 

2298 "Cube must be 1D or 2D (excluding any realization dimension)." 

2299 ) 

2300 

2301 plot_index = [] 

2302 

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

2304 is_spectral_plot = series_coordinate in [ 

2305 "frequency", 

2306 "physical_wavenumber", 

2307 "wavelength", 

2308 ] 

2309 

2310 if is_spectral_plot: 

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

2312 # coordinate frequency/wavenumber. 

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

2314 # time slider option. 

2315 

2316 # Internal plotting function. 

2317 plotting_func = _plot_and_save_line_power_spectrum_series 

2318 

2319 for model_cube in cubes: 

2320 try: 

2321 model_cube.coord(sequence_coordinate) 

2322 except iris.exceptions.CoordinateNotFoundError as err: 

2323 raise ValueError( 

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

2325 ) from err 

2326 

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

2328 # check for ensembles 

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

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

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

2332 ): 

2333 if single_plot: 

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

2335 plotting_func = _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series 

2336 else: 

2337 # Plot postage stamps 

2338 plotting_func = _plot_and_save_postage_stamp_power_spectrum_series 

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

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

2341 else: 

2342 all_points = sorted( 

2343 set( 

2344 itertools.chain.from_iterable( 

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

2346 ) 

2347 ) 

2348 ) 

2349 all_slices = list( 

2350 itertools.chain.from_iterable( 

2351 cb.slices_over(sequence_coordinate) for cb in cubes 

2352 ) 

2353 ) 

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

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

2356 # necessary) 

2357 cube_iterables = [ 

2358 iris.cube.CubeList( 

2359 s 

2360 for s in all_slices 

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

2362 ) 

2363 for point in all_points 

2364 ] 

2365 nplot = len(all_points) 

2366 

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

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

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

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

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

2372 

2373 for cube_slice in cube_iterables: 

2374 # Normalize cube_slice to a list of cubes 

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

2376 cubes = list(cube_slice) 

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

2378 cubes = [cube_slice] 

2379 else: 

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

2381 

2382 # Use sequence value so multiple sequences can merge. 

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

2384 plot_title, plot_filename = _set_title_and_filename( 

2385 seq_coord, nplot, recipe_title, filename 

2386 ) 

2387 

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

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

2390 

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

2392 if nplot == 1 and seq_coord.has_bounds and np.size(seq_coord.bounds) > 1: 2392 ↛ 2393line 2392 didn't jump to line 2393 because the condition on line 2392 was never true

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

2394 

2395 # Do the actual plotting. 

2396 plotting_func( 

2397 cube_slice, 

2398 coords, 

2399 stamp_coordinate, 

2400 plot_filename, 

2401 title, 

2402 series_coordinate, 

2403 ) 

2404 

2405 plot_index.append(plot_filename) 

2406 else: 

2407 # Format the title and filename using plotted series coordinate 

2408 nplot = 1 

2409 seq_coord = coords[0] 

2410 plot_title, plot_filename = _set_title_and_filename( 

2411 seq_coord, nplot, recipe_title, filename 

2412 ) 

2413 

2414 # Treat cubes with station coordinate as point observation timeseries, looping over available points 

2415 if ( 

2416 "station" in [c.name() for c in cubes[0].coords()] 

2417 and len(cubes[0].coord("station").points) > 1 

2418 ): 

2419 for station in cubes[0].coord("station").points: 

2420 station_cubes = cubes.extract(iris.Constraint(station=station)) 

2421 station_name = station_cubes[0].coord("Station_Name").points[0] 

2422 station_plotname = plot_filename.replace( 

2423 ".png", "_" + station_name + ".png" 

2424 ) 

2425 _plot_and_save_line_series( 

2426 station_cubes, 

2427 coords, 

2428 "realization", 

2429 station_plotname, 

2430 f"{plot_title} {station_name}", 

2431 ) 

2432 plot_index.append(station_plotname) 

2433 

2434 else: 

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

2436 _plot_and_save_line_series( 

2437 cubes, coords, stamp_coordinate, plot_filename, plot_title 

2438 ) 

2439 

2440 plot_index.append(plot_filename) 

2441 

2442 # append plot to list of plots 

2443 complete_plot_index = _append_to_plot_index(plot_index) 

2444 

2445 # Make a page to display the plots. 

2446 _make_plot_html_page(complete_plot_index) 

2447 

2448 return cube 

2449 

2450 

2451def plot_vertical_line_series( 

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

2453 filename: str | None = None, 

2454 series_coordinate: str = "model_level_number", 

2455 sequence_coordinate: str = "time", 

2456 # line_coordinate: str = "realization", 

2457 **kwargs, 

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

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

2460 

2461 The Cube or CubeList must be 1D. 

2462 

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

2464 then a sequence of plots will be produced. 

2465 

2466 Parameters 

2467 ---------- 

2468 iris.cube | iris.cube.CubeList 

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

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

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

2472 filename: str, optional 

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

2474 to the recipe name. 

2475 series_coordinate: str, optional 

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

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

2478 for LFRic. Defaults to ``model_level_number``. 

2479 This coordinate must exist in the cube. 

2480 sequence_coordinate: str, optional 

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

2482 This coordinate must exist in the cube. 

2483 

2484 Returns 

2485 ------- 

2486 iris.cube.Cube | iris.cube.CubeList 

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

2488 Plotted data. 

2489 

2490 Raises 

2491 ------ 

2492 ValueError 

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

2494 TypeError 

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

2496 """ 

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

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

2499 

2500 cubes = iter_maybe(cubes) 

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

2502 all_data = [] 

2503 

2504 # Store min/max ranges for x range. 

2505 x_levels = [] 

2506 

2507 num_models = get_num_models(cubes) 

2508 

2509 validate_cube_shape(cubes, num_models) 

2510 

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

2512 coords = [] 

2513 for cube in cubes: 

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

2515 try: 

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

2517 except iris.exceptions.CoordinateNotFoundError as err: 

2518 raise ValueError( 

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

2520 ) from err 

2521 

2522 try: 

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

2524 cube.coord(sequence_coordinate) 

2525 except iris.exceptions.CoordinateNotFoundError as err: 

2526 raise ValueError( 

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

2528 ) from err 

2529 

2530 # Get minimum and maximum from levels information. 

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

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

2533 x_levels.append(min(levels)) 

2534 x_levels.append(max(levels)) 

2535 else: 

2536 all_data.append(cube.data) 

2537 

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

2539 # Combine all data into a single NumPy array 

2540 combined_data = np.concatenate(all_data) 

2541 

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

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

2544 # sequence and if applicable postage stamp coordinate. 

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

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

2547 else: 

2548 vmin = min(x_levels) 

2549 vmax = max(x_levels) 

2550 

2551 # Check if the cube has a sequence coordinate (e.g. time). If not, plot 

2552 # a single profile directly without iterating over a sequence. 

2553 sequence_coords = [ 

2554 cube.coord(sequence_coordinate) 

2555 for cube in cubes 

2556 if cube.coords(sequence_coordinate) 

2557 ] 

2558 has_sequence_coord = len(sequence_coords) == len(cubes) and all( 

2559 np.size(coord.points) > 1 for coord in sequence_coords 

2560 ) 

2561 has_scalar_sequence_coord = len(sequence_coords) == len(cubes) and all( 

2562 np.size(coord.points) == 1 for coord in sequence_coords 

2563 ) 

2564 

2565 plot_index = [] 

2566 if has_sequence_coord: 2566 ↛ 2591line 2566 didn't jump to line 2591 because the condition on line 2566 was always true

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

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

2569 # necessary) 

2570 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

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

2572 for cubes_slice in cube_iterables: 

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

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

2575 plot_title, plot_filename = _set_title_and_filename( 

2576 seq_coord, nplot, recipe_title, filename 

2577 ) 

2578 

2579 # Do the actual plotting. 

2580 _plot_and_save_vertical_line_series( 

2581 cubes_slice, 

2582 coords, 

2583 "realization", 

2584 plot_filename, 

2585 series_coordinate, 

2586 title=plot_title, 

2587 vmin=vmin, 

2588 vmax=vmax, 

2589 ) 

2590 plot_index.append(plot_filename) 

2591 elif has_scalar_sequence_coord: 

2592 # Scalar sequence coordinate (typically aggregated time bounds): 

2593 # make one plot and include sequence period in title/filename. 

2594 plot_title, plot_filename = _set_title_and_filename( 

2595 sequence_coords[0], 1, recipe_title, filename 

2596 ) 

2597 

2598 _plot_and_save_vertical_line_series( 

2599 cubes, 

2600 coords, 

2601 "realization", 

2602 plot_filename, 

2603 series_coordinate, 

2604 title=plot_title, 

2605 vmin=vmin, 

2606 vmax=vmax, 

2607 ) 

2608 plot_index.append(plot_filename) 

2609 else: 

2610 # 1D case: no sequence coordinate, plot a single profile. 

2611 plot_title = recipe_title 

2612 if filename: 

2613 plot_filename = filename 

2614 else: 

2615 plot_filename = f"{slugify(plot_title)}.png" 

2616 

2617 _plot_and_save_vertical_line_series( 

2618 cubes, 

2619 coords, 

2620 "realization", 

2621 plot_filename, 

2622 series_coordinate, 

2623 title=plot_title, 

2624 vmin=vmin, 

2625 vmax=vmax, 

2626 ) 

2627 plot_index.append(plot_filename) 

2628 

2629 # Add list of plots to plot metadata. 

2630 complete_plot_index = _append_to_plot_index(plot_index) 

2631 

2632 # Make a page to display the plots. 

2633 _make_plot_html_page(complete_plot_index) 

2634 

2635 return cubes 

2636 

2637 

2638def qq_plot( 

2639 cubes: iris.cube.CubeList, 

2640 coordinates: list[str], 

2641 percentiles: list[float], 

2642 model_names: list[str], 

2643 filename: str | None = None, 

2644 one_to_one: bool = True, 

2645 **kwargs, 

2646) -> iris.cube.CubeList: 

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

2648 

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

2650 collapsed within the operator over all specified coordinates such as 

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

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

2653 

2654 Parameters 

2655 ---------- 

2656 cubes: iris.cube.CubeList 

2657 Two cubes of the same variable with different models. 

2658 coordinate: list[str] 

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

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

2661 the percentile coordinate. 

2662 percent: list[float] 

2663 A list of percentiles to appear in the plot. 

2664 model_names: list[str] 

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

2666 filename: str, optional 

2667 Filename of the plot to write. 

2668 one_to_one: bool, optional 

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

2670 

2671 Raises 

2672 ------ 

2673 ValueError 

2674 When the cubes are not compatible. 

2675 

2676 Notes 

2677 ----- 

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

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

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

2681 compares percentiles of two datasets. This plot does 

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

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

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

2685 

2686 Quantile-quantile plots are valuable for comparing against 

2687 observations and other models. Identical percentiles between the variables 

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

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

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

2691 Wilks 2011 [Wilks2011]_). 

2692 

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

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

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

2696 the extremes. 

2697 

2698 """ 

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

2700 if len(cubes) != 2: 

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

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

2703 other: Cube = cubes.extract_cube( 

2704 iris.Constraint( 

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

2706 ) 

2707 ) 

2708 

2709 # Get spatial coord names. 

2710 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

2711 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

2712 

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

2714 # This is triggered if either 

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

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

2717 # errors. 

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

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

2720 # for UM and LFRic comparisons. 

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

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

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

2724 # given this dependency on regridding. 

2725 if ( 

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

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

2728 ) or ( 

2729 base.long_name 

2730 in [ 

2731 "eastward_wind_at_10m", 

2732 "northward_wind_at_10m", 

2733 "northward_wind_at_cell_centres", 

2734 "eastward_wind_at_cell_centres", 

2735 "zonal_wind_at_pressure_levels", 

2736 "meridional_wind_at_pressure_levels", 

2737 "potential_vorticity_at_pressure_levels", 

2738 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

2739 ] 

2740 ): 

2741 logger.debug("Linear regridding base cube to other grid to compute differences") 

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

2743 

2744 # Extract just common time points. 

2745 base, other = _extract_common_time_points(base, other) 

2746 

2747 # Equalise attributes so we can merge. 

2748 fully_equalise_attributes([base, other]) 

2749 logger.debug("Base: %s\nOther: %s", base, other) 

2750 

2751 # Collapse cubes. 

2752 base = collapse( 

2753 base, 

2754 coordinate=coordinates, 

2755 method="PERCENTILE", 

2756 additional_percent=percentiles, 

2757 ) 

2758 other = collapse( 

2759 other, 

2760 coordinate=coordinates, 

2761 method="PERCENTILE", 

2762 additional_percent=percentiles, 

2763 ) 

2764 

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

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

2767 title = f"{recipe_title}" 

2768 

2769 if filename is None: 

2770 filename = slugify(recipe_title) 

2771 

2772 # Add file extension. 

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

2774 

2775 # Do the actual plotting on a scatter plot 

2776 _plot_and_save_scatter_plot( 

2777 base, other, plot_filename, title, one_to_one, model_names 

2778 ) 

2779 

2780 # Add list of plots to plot metadata. 

2781 plot_index = _append_to_plot_index([plot_filename]) 

2782 

2783 # Make a page to display the plots. 

2784 _make_plot_html_page(plot_index) 

2785 

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

2787 

2788 

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

2790 """ 

2791 Plot a Hinton style triangle/scorecard plot. 

2792 

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

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

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

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

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

2798 

2799 Parameters 

2800 ---------- 

2801 change: np.ndarray 

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

2803 size/direction. 

2804 signif: np.ndarray 

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

2806 xaxis_labels: list 

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

2808 along with magnitude if not None). 

2809 yaxis_labels: list 

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

2811 along with magnitude if not None). 

2812 magnitude: np.ndarray | None 

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

2814 the user wishes to display under each respective triangle. 

2815 

2816 Returns 

2817 ------- 

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

2819 """ 

2820 # Setup colors of triangles 

2821 color_pos = "#7CAE00" 

2822 color_neg = "#7B68EE" 

2823 

2824 # Setup cell/text size ratios 

2825 figsize = None 

2826 cell_size_in = 0.35 

2827 text_row_ratio = 0.25 

2828 

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

2830 change = np.asarray(change) 

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

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

2833 magnitude = np.asarray(magnitude) 

2834 

2835 # Get the number of x and y elements 

2836 ny, nx = change.shape 

2837 

2838 # Build non-uniform y coordinates 

2839 tri_height = 1.0 

2840 txt_height = text_row_ratio 

2841 

2842 tri_y = [] 

2843 txt_y = [] 

2844 y_edges = [0.0] 

2845 

2846 y = 0.0 

2847 for _j in range(ny): 

2848 tri_y.append(y + tri_height / 2) 

2849 y += tri_height 

2850 y_edges.append(y) 

2851 

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

2853 txt_y.append(y + txt_height / 2) 

2854 y += txt_height 

2855 y_edges.append(y) 

2856 

2857 total_height = y 

2858 

2859 # Dynamic figure size 

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

2861 width = nx * cell_size_in 

2862 height = total_height * cell_size_in + 2 

2863 figsize = (width, height) 

2864 

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

2866 

2867 # Setup axes and grid. 

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

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

2870 ax.set_ylim(0, total_height) 

2871 

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

2873 ax.set_xticklabels(xaxis_labels, rotation=90) 

2874 

2875 ax.set_yticks(tri_y) 

2876 ax.set_yticklabels(yaxis_labels) 

2877 

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

2879 ax.set_yticks(y_edges, minor=True) 

2880 

2881 ax.set_axisbelow(True) 

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

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

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

2885 

2886 ax.invert_yaxis() 

2887 

2888 # Compute marker scaling (fixed overlap) 

2889 fig.canvas.draw() 

2890 

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

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

2893 

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

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

2896 cell_pixels = min(cell_w, cell_h) 

2897 

2898 max_marker_size = (0.6 * cell_pixels) ** 2 

2899 

2900 text_fontsize = cell_pixels * 0.15 

2901 

2902 # Plot triangles + text 

2903 for j in range(ny): 

2904 for i in range(nx): 

2905 val = change[j, i] 

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

2907 continue 

2908 

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

2910 continue 

2911 

2912 sig = signif[j, i] 

2913 size = max_marker_size * abs(val) 

2914 

2915 # Triangle style 

2916 if val >= 0: 

2917 marker = "^" 

2918 color = color_pos 

2919 else: 

2920 marker = "v" 

2921 color = color_neg 

2922 

2923 if sig: 

2924 edgecolor = "black" 

2925 linewidth = 0.6 

2926 else: 

2927 edgecolor = "none" 

2928 linewidth = 0.0 

2929 

2930 # Triangle 

2931 ax.scatter( 

2932 i, 

2933 tri_y[j], 

2934 s=size, 

2935 marker=marker, 

2936 c=color, 

2937 edgecolors=edgecolor, 

2938 linewidths=linewidth, 

2939 zorder=3, 

2940 clip_on=True, # ensures no rendering bleed 

2941 ) 

2942 

2943 # Text row 

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

2945 mag_val = magnitude[j, i] 

2946 

2947 if not np.isnan(mag_val): 

2948 ax.text( 

2949 i, 

2950 txt_y[j], 

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

2952 ha="center", 

2953 va="center", 

2954 fontsize=text_fontsize, 

2955 color="black", 

2956 zorder=4, 

2957 ) 

2958 

2959 plt.tight_layout() 

2960 return fig, ax 

2961 

2962 

2963def scatter_plot( 

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

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

2966 filename: str | None = None, 

2967 one_to_one: bool = True, 

2968 **kwargs, 

2969) -> iris.cube.CubeList: 

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

2971 

2972 Both cubes must be 1D. 

2973 

2974 Parameters 

2975 ---------- 

2976 cube_x: Cube | CubeList 

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

2978 cube_y: Cube | CubeList 

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

2980 filename: str, optional 

2981 Filename of the plot to write. 

2982 one_to_one: bool, optional 

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

2984 

2985 Returns 

2986 ------- 

2987 cubes: CubeList 

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

2989 

2990 Raises 

2991 ------ 

2992 ValueError 

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

2994 size. 

2995 TypeError 

2996 If the cube isn't a single cube. 

2997 

2998 Notes 

2999 ----- 

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

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

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

3003 """ 

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

3005 for cube_iter in iter_maybe(cube_x): 

3006 # Check cubes are correct shape. 

3007 cube_iter = check_single_cube(cube_iter) 

3008 if cube_iter.ndim > 1: 

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

3010 

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

3012 for cube_iter in iter_maybe(cube_y): 

3013 # Check cubes are correct shape. 

3014 cube_iter = check_single_cube(cube_iter) 

3015 if cube_iter.ndim > 1: 

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

3017 

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

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

3020 title = f"{recipe_title}" 

3021 

3022 if filename is None: 

3023 filename = slugify(recipe_title) 

3024 

3025 # Add file extension. 

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

3027 

3028 # Do the actual plotting. 

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

3030 

3031 # Add list of plots to plot metadata. 

3032 plot_index = _append_to_plot_index([plot_filename]) 

3033 

3034 # Make a page to display the plots. 

3035 _make_plot_html_page(plot_index) 

3036 

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

3038 

3039 

3040def vector_plot( 

3041 cube_u: iris.cube.Cube, 

3042 cube_v: iris.cube.Cube, 

3043 filename: str | None = None, 

3044 sequence_coordinate: str = "time", 

3045 **kwargs, 

3046) -> iris.cube.CubeList: 

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

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

3049 

3050 # Cubes must have a matching sequence coordinate. 

3051 try: 

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

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

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

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

3056 raise ValueError( 

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

3058 ) from err 

3059 

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

3061 plot_index = [] 

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

3063 for cube_u_slice, cube_v_slice in zip( 

3064 cube_u.slices_over(sequence_coordinate), 

3065 cube_v.slices_over(sequence_coordinate), 

3066 strict=True, 

3067 ): 

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

3069 seq_coord = cube_u_slice.coord(sequence_coordinate) 

3070 plot_title, plot_filename = _set_title_and_filename( 

3071 seq_coord, nplot, recipe_title, filename 

3072 ) 

3073 

3074 # Do the actual plotting. 

3075 _plot_and_save_vector_plot( 

3076 cube_u_slice, 

3077 cube_v_slice, 

3078 filename=plot_filename, 

3079 title=plot_title, 

3080 method="pcolormesh", 

3081 ) 

3082 plot_index.append(plot_filename) 

3083 

3084 # Add list of plots to plot metadata. 

3085 complete_plot_index = _append_to_plot_index(plot_index) 

3086 

3087 # Make a page to display the plots. 

3088 _make_plot_html_page(complete_plot_index) 

3089 

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

3091 

3092 

3093def plot_histogram_series( 

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

3095 filename: str | None = None, 

3096 sequence_coordinate: str = "time", 

3097 stamp_coordinate: str = "realization", 

3098 single_plot: bool = False, 

3099 **kwargs, 

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

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

3102 

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

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

3105 functionality to scroll through histograms against time. If a 

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

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

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

3109 

3110 Parameters 

3111 ---------- 

3112 cubes: Cube | iris.cube.CubeList 

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

3114 than the stamp coordinate. 

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

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

3117 filename: str, optional 

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

3119 to the recipe name. 

3120 sequence_coordinate: str, optional 

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

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

3123 slider. 

3124 stamp_coordinate: str, optional 

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

3126 ``"realization"``. 

3127 single_plot: bool, optional 

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

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

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

3131 

3132 Returns 

3133 ------- 

3134 iris.cube.Cube | iris.cube.CubeList 

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

3136 Plotted data. 

3137 

3138 Raises 

3139 ------ 

3140 ValueError 

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

3142 TypeError 

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

3144 """ 

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

3146 

3147 cubes = iter_maybe(cubes) 

3148 

3149 # Internal plotting function. 

3150 plotting_func = _plot_and_save_histogram_series 

3151 

3152 num_models = get_num_models(cubes) 

3153 

3154 validate_cube_shape(cubes, num_models) 

3155 

3156 # If several histograms are plotted, check sequence_coordinate 

3157 check_sequence_coordinate(cubes, sequence_coordinate) 

3158 

3159 # Get axis minimum and maximum from levels information. 

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

3161 vmin, vmax = _set_axis_range(cubes) 

3162 

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

3164 # single point. If single_plot is True: 

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

3166 # separate postage stamp plots. 

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

3168 # produced per single model only 

3169 if num_models == 1: 

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

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

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

3173 ): 

3174 if single_plot: 

3175 plotting_func = ( 

3176 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

3177 ) 

3178 else: 

3179 plotting_func = _plot_and_save_postage_stamp_histogram_series 

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

3181 else: 

3182 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

3183 

3184 plot_index = [] 

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

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

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

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

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

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

3191 for cube_slice in cube_iterables: 

3192 single_cube = cube_slice 

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

3194 single_cube = cube_slice[0] 

3195 

3196 # Ensure valid stamp coordinate in cube dimensions 

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

3198 stamp_coordinate = check_stamp_coordinate(single_cube) 

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

3200 seq_coord = single_cube.coord(sequence_coordinate) 

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

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

3203 seq_coord = single_cube.coord("time") 

3204 # Use station name in title and filename if model vs obs comparison 

3205 if sequence_coordinate == "station": 3205 ↛ 3206line 3205 didn't jump to line 3206 because the condition on line 3205 was never true

3206 seq_coord = single_cube.coord("Station_Name") 

3207 

3208 plot_title, plot_filename = _set_title_and_filename( 

3209 seq_coord, nplot, recipe_title, filename 

3210 ) 

3211 

3212 # Do the actual plotting. 

3213 plotting_func( 

3214 cube_slice, 

3215 filename=plot_filename, 

3216 stamp_coordinate=stamp_coordinate, 

3217 title=plot_title, 

3218 vmin=vmin, 

3219 vmax=vmax, 

3220 ) 

3221 plot_index.append(plot_filename) 

3222 

3223 # Add list of plots to plot metadata. 

3224 complete_plot_index = _append_to_plot_index(plot_index) 

3225 

3226 # Make a page to display the plots. 

3227 _make_plot_html_page(complete_plot_index) 

3228 

3229 return cubes 

3230 

3231 

3232def plot_scatter_series( 

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

3234 filename: str | None = None, 

3235 sequence_coordinate: str = "time", 

3236 stamp_coordinate: str = "realization", 

3237 hexbin: bool = False, 

3238 **kwargs, 

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

3240 """Plot a scatter plot for each sequence coordinate provided. 

3241 

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

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

3244 functionality to scroll through scatter against time. If a 

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

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

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

3248 

3249 Parameters 

3250 ---------- 

3251 cubes: Cube | iris.cube.CubeList 

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

3253 than the stamp coordinate. 

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

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

3256 filename: str, optional 

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

3258 to the recipe name. 

3259 sequence_coordinate: str, optional 

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

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

3262 slider. 

3263 stamp_coordinate: str, optional 

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

3265 ``"realization"``. 

3266 hexbin: bool, optional 

3267 If True, generate hexbin comparison plot. 

3268 If False, generate point-by-point scatter plot. 

3269 

3270 Returns 

3271 ------- 

3272 iris.cube.Cube | iris.cube.CubeList 

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

3274 Plotted data. 

3275 

3276 Raises 

3277 ------ 

3278 ValueError 

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

3280 TypeError 

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

3282 """ 

3283 recipe_title = get_recipe_metadata().get("title", "Scatter") 

3284 

3285 cubes = iter_maybe(cubes) 

3286 

3287 # Internal plotting function. 

3288 plotting_func = _plot_and_save_scatter_series 

3289 

3290 num_models = get_num_models(cubes) 

3291 

3292 validate_cube_shape(cubes, num_models) 

3293 

3294 check_sequence_coordinate(cubes, sequence_coordinate) 

3295 

3296 vmin, vmax = _set_axis_range(cubes) 

3297 

3298 # Require >1 models to compare on scatter plot 

3299 if num_models > 1: 

3300 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

3301 else: 

3302 raise ValueError( 

3303 "Scatter plot series requires multiple number of models in input data." 

3304 ) 

3305 

3306 plot_index = [] 

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

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

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

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

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

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

3313 for cube_slice in cube_iterables: 

3314 single_cube = cube_slice 

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

3316 single_cube = cube_slice[0] 

3317 

3318 # Ensure valid stamp coordinate in cube dimensions 

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

3320 stamp_coordinate = check_stamp_coordinate(single_cube) 

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

3322 seq_coord = single_cube.coord(sequence_coordinate) 

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

3324 if sequence_coordinate == "realization" and nplot == 1: 

3325 seq_coord = single_cube.coord("time") 

3326 # Use station name in title and filename if model vs obs comparison 

3327 if sequence_coordinate == "station": 

3328 seq_coord = single_cube.coord("Station_Name") 

3329 

3330 plot_title, plot_filename = _set_title_and_filename( 

3331 seq_coord, nplot, recipe_title, filename 

3332 ) 

3333 

3334 # Do the actual plotting. 

3335 plotting_func( 

3336 cube_slice, 

3337 filename=plot_filename, 

3338 stamp_coordinate=stamp_coordinate, 

3339 title=plot_title, 

3340 vmin=vmin, 

3341 vmax=vmax, 

3342 hexbin=hexbin, 

3343 ) 

3344 plot_index.append(plot_filename) 

3345 

3346 # Add list of plots to plot metadata. 

3347 complete_plot_index = _append_to_plot_index(plot_index) 

3348 

3349 # Make a page to display the plots. 

3350 _make_plot_html_page(complete_plot_index) 

3351 

3352 return cubes 

3353 

3354 

3355def _plot_and_save_postage_stamp_power_spectrum_series( 

3356 cubes: iris.cube.Cube, 

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

3358 stamp_coordinate: str, 

3359 filename: str, 

3360 title: str, 

3361 series_coordinate: str | None = None, 

3362 **kwargs, 

3363): 

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

3365 

3366 Parameters 

3367 ---------- 

3368 cubes: Cube or CubeList 

3369 Cube or Cubelist of the power spectrum data. 

3370 coords: list[Coord] 

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

3372 stamp_coordinate: str 

3373 Coordinate that becomes different plots. 

3374 filename: str 

3375 Filename of the plot to write. 

3376 title: str 

3377 Plot title. 

3378 series_coordinate: str, optional 

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

3380 

3381 """ 

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

3383 grid_size = math.ceil(math.sqrt(len(cubes.coord(stamp_coordinate).points))) 

3384 

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

3386 model_colors_map = get_model_colors_map(cubes) 

3387 # ax = plt.gca() 

3388 # Make a subplot for each member. 

3389 for member, subplot in zip( 

3390 cubes.slices_over(stamp_coordinate), range(1, grid_size**2 + 1), strict=False 

3391 ): 

3392 ax = plt.subplot(grid_size, grid_size, subplot) 

3393 

3394 # Store min/max ranges. 

3395 y_levels = [] 

3396 

3397 line_marker = None 

3398 line_width = 1 

3399 

3400 for cube in iter_maybe(member): 

3401 xcoord = _select_series_coord(cube, series_coordinate) 

3402 xname = xcoord.points 

3403 

3404 yfield = cube.data # power spectrum 

3405 label = None 

3406 color = "black" 

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

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

3409 color = model_colors_map.get(label) 

3410 

3411 if member.coord(stamp_coordinate).points == [0]: 

3412 ax.plot( 

3413 xname, 

3414 yfield, 

3415 color=color, 

3416 marker=line_marker, 

3417 ls="-", 

3418 lw=line_width, 

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

3420 if len(cube.coord(stamp_coordinate).points) > 1 

3421 else label, 

3422 ) 

3423 # Label with member if part of an ensemble and not the control. 

3424 else: 

3425 ax.plot( 

3426 xname, 

3427 yfield, 

3428 color=color, 

3429 ls="-", 

3430 lw=1.5, 

3431 alpha=0.75, 

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

3433 ) 

3434 

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

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

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

3438 y_levels.append(min(levels)) 

3439 y_levels.append(max(levels)) 

3440 

3441 # Add some labels and tweak the style. 

3442 title = f"{title}" 

3443 ax.set_title(title, fontsize=16) 

3444 

3445 # Set appropriate x-axis label based on coordinate 

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

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

3448 ): 

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

3450 elif series_coordinate == "physical_wavenumber" or ( 3450 ↛ 3455line 3450 didn't jump to line 3455 because the condition on line 3450 was always true

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

3452 ): 

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

3454 else: # frequency or check units 

3455 if hasattr(xcoord, "units") and str(xcoord.units) == "km-1": 

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

3457 else: 

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

3459 

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

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

3462 

3463 # Set log-log scale 

3464 ax.set_xscale("log") 

3465 ax.set_yscale("log") 

3466 

3467 # Add gridlines 

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

3469 # Ientify unique labels for legend 

3470 handles = list( 

3471 { 

3472 label: handle 

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

3474 }.values() 

3475 ) 

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

3477 

3478 ax = plt.gca() 

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

3480 

3481 # Save plot. 

3482 _save_close_figure(fig, "histogram postage stamp", filename) 

3483 

3484 

3485def _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series( 

3486 cubes: iris.cube.Cube, 

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

3488 stamp_coordinate: str, 

3489 filename: str, 

3490 title: str, 

3491 series_coordinate: str | None = None, 

3492 **kwargs, 

3493): 

3494 """Plot and save power spectra for ensemble members in single plot. 

3495 

3496 Parameters 

3497 ---------- 

3498 cubes: Cube or CubeList 

3499 Cube or Cubelist of the power spectrum data. 

3500 coords: list[Coord] 

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

3502 stamp_coordinate: str 

3503 Coordinate that becomes different plots. 

3504 filename: str 

3505 Filename of the plot to write. 

3506 title: str 

3507 Plot title. 

3508 series_coordinate: str, optional 

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

3510 

3511 """ 

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

3513 model_colors_map = get_model_colors_map(cubes) 

3514 

3515 line_marker = None 

3516 line_width = 1 

3517 

3518 # Compute ensemble statistics to show spread 

3519 mean_cube = cubes.collapsed(stamp_coordinate, iris.analysis.MEAN) 

3520 min_cube = cubes.collapsed(stamp_coordinate, iris.analysis.MIN) 

3521 max_cube = cubes.collapsed(stamp_coordinate, iris.analysis.MAX) 

3522 

3523 xcoord_global = mean_cube.coord(series_coordinate) 

3524 x_global = xcoord_global.points 

3525 

3526 for i, member in enumerate(cubes.slices_over(stamp_coordinate)): 

3527 xcoord = _select_series_coord(member, series_coordinate) 

3528 xname = xcoord.points 

3529 

3530 yfield = member.data # power spectrum 

3531 color = "black" 

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

3533 label = member.attributes.get("model_name") if i == 0 else None 

3534 color = model_colors_map.get(label) 

3535 

3536 if member.coord(stamp_coordinate).points == [0]: 

3537 ax.plot( 

3538 xname, 

3539 yfield, 

3540 color=color, 

3541 marker=line_marker, 

3542 ls="-", 

3543 lw=line_width, 

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

3545 if len(member.coord(stamp_coordinate).points) > 1 

3546 else label, 

3547 ) 

3548 # Label with member number if part of an ensemble and not the control. 

3549 else: 

3550 ax.plot( 

3551 xname, 

3552 yfield, 

3553 color=color, 

3554 ls="-", 

3555 lw=1.5, 

3556 alpha=0.75, 

3557 label=label, 

3558 ) 

3559 

3560 # Set appropriate x-axis label based on coordinate 

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

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

3563 ): 

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

3565 elif series_coordinate == "physical_wavenumber" or ( 3565 ↛ 3570line 3565 didn't jump to line 3570 because the condition on line 3565 was always true

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

3567 ): 

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

3569 else: # frequency or check units 

3570 if hasattr(xcoord, "units") and str(xcoord.units) == "km-1": 

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

3572 else: 

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

3574 

3575 # Add ensemble spread shading 

3576 ax.fill_between( 

3577 x_global, 

3578 min_cube.data, 

3579 max_cube.data, 

3580 color="grey", 

3581 alpha=0.3, 

3582 label="Ensemble spread", 

3583 ) 

3584 

3585 # Add ensemble mean line 

3586 ax.plot(x_global, mean_cube.data, color="black", lw=1, label="Ensemble mean") 

3587 

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

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

3590 

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

3592 # Set log-log scale 

3593 ax.set_xscale("log") 

3594 ax.set_yscale("log") 

3595 

3596 # Add gridlines 

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

3598 # Identify unique labels for legend 

3599 handles = list( 

3600 { 

3601 label: handle 

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

3603 }.values() 

3604 ) 

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

3606 

3607 # Figure title. 

3608 ax.set_title(title, fontsize=16) 

3609 

3610 # Save plot. 

3611 _save_close_figure(fig, "power spectra postage stamp", filename)