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

1024 statements  

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

24from typing import Literal 

25 

26import cartopy.crs as ccrs 

27import cartopy.feature as cfeature 

28import iris 

29import iris.coords 

30import iris.cube 

31import iris.exceptions 

32import iris.plot as iplt 

33import matplotlib as mpl 

34import matplotlib.colors as mcolors 

35import matplotlib.pyplot as plt 

36import numpy as np 

37from cartopy.mpl.geoaxes import GeoAxes 

38from iris.cube import Cube 

39from markdown_it import MarkdownIt 

40from mpl_toolkits.axes_grid1.inset_locator import inset_axes 

41 

42from CSET._common import ( 

43 filename_slugify, 

44 get_recipe_metadata, 

45 iter_maybe, 

46 render_file, 

47 slugify, 

48) 

49from CSET.operators._colormaps import ( 

50 colorbar_map_levels, 

51 get_model_colors_map, 

52) 

53from CSET.operators._utils import ( 

54 check_sequence_coordinate, 

55 check_single_cube, 

56 check_stamp_coordinate, 

57 fully_equalise_attributes, 

58 get_cube_yxcoordname, 

59 get_num_models, 

60 is_transect, 

61 slice_over_maybe, 

62 validate_cube_shape, 

63 validate_cubes_coords, 

64) 

65from CSET.operators.collapse import collapse 

66from CSET.operators.misc import _extract_common_time_points 

67from CSET.operators.regrid import regrid_onto_cube 

68 

69# Use a non-interactive plotting backend. 

70mpl.use("agg") 

71 

72 

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

74# Private helper functions # 

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

76 

77 

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

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

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

81 fcntl.flock(fp, fcntl.LOCK_EX) 

82 fp.seek(0) 

83 meta = json.load(fp) 

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

85 complete_plot_index = complete_plot_index + plot_index 

86 meta["plots"] = complete_plot_index 

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

88 os.getenv("DO_CASE_AGGREGATION") 

89 ): 

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

91 fp.seek(0) 

92 fp.truncate() 

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

94 return complete_plot_index 

95 

96 

97def _make_plot_html_page(plots: list): 

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

99 # Debug check that plots actually contains some strings. 

100 assert isinstance(plots[0], str) 

101 

102 # Load HTML template file. 

103 operator_files = importlib.resources.files() 

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

105 

106 # Get some metadata. 

107 meta = get_recipe_metadata() 

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

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

110 

111 # Prepare template variables. 

112 variables = { 

113 "title": title, 

114 "description": description, 

115 "initial_plot": plots[0], 

116 "plots": plots, 

117 "title_slug": slugify(title), 

118 } 

119 

120 # Render template. 

121 html = render_file(template_file, **variables) 

122 

123 # Save completed HTML. 

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

125 fp.write(html) 

126 

127 

128def _setup_spatial_map( 

129 cube: iris.cube.Cube, 

130 figure, 

131 cmap, 

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

133 subplot: int | None = None, 

134): 

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

136 

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

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

139 

140 Parameters 

141 ---------- 

142 cube: Cube 

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

144 figure: 

145 Matplotlib Figure object holding all plot elements. 

146 cmap: 

147 Matplotlib colormap. 

148 grid_size: (int, int), optional 

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

150 subplot: int, optional 

151 Subplot index if multiple spatial subplots in figure. 

152 

153 Returns 

154 ------- 

155 axes: 

156 Matplotlib GeoAxes definition. 

157 """ 

158 # Identify min/max plot bounds. 

159 try: 

160 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

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

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

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

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

165 

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

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

168 x1 = x1 - 180.0 

169 x2 = x2 - 180.0 

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

171 

172 # Consider map projection orientation. 

173 # Adapting orientation enables plotting across international dateline. 

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

175 if x2 > 180.0 or x1 < -180.0: 

176 central_longitude = 180.0 

177 else: 

178 central_longitude = 0.0 

179 

180 # Define spatial map projection. 

181 coord_system = cube.coord(lat_axis).coord_system 

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

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

184 projection = ccrs.RotatedPole( 

185 pole_longitude=coord_system.grid_north_pole_longitude, 

186 pole_latitude=coord_system.grid_north_pole_latitude, 

187 central_rotated_longitude=central_longitude, 

188 ) 

189 crs = projection 

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

191 # Define Transverse Mercator projection for TM inputs. 

192 projection = ccrs.TransverseMercator( 

193 central_longitude=coord_system.longitude_of_central_meridian, 

194 central_latitude=coord_system.latitude_of_projection_origin, 

195 false_easting=coord_system.false_easting, 

196 false_northing=coord_system.false_northing, 

197 scale_factor=coord_system.scale_factor_at_central_meridian, 

198 ) 

199 crs = projection 

200 else: 

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

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

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

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

205 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

206 crs = ccrs.PlateCarree() 

207 

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

209 if subplot is not None: 

210 axes = figure.add_subplot( 

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

212 ) 

213 else: 

214 axes = figure.add_subplot(projection=projection) 

215 

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

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

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

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

220 ): 

221 pass 

222 else: 

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

224 coastcol = "magenta" 

225 else: 

226 coastcol = "black" 

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

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

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

230 

231 # Add gridlines. 

232 gl = axes.gridlines( 

233 alpha=0.3, 

234 draw_labels=True, 

235 dms=False, 

236 x_inline=False, 

237 y_inline=False, 

238 ) 

239 gl.top_labels = False 

240 gl.right_labels = False 

241 if subplot: 

242 gl.bottom_labels = False 

243 gl.left_labels = False 

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

245 gl.left_labels = True 

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

247 gl.bottom_labels = True 

248 

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

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

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

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

253 

254 except ValueError: 

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

256 axes = figure.gca() 

257 pass 

258 

259 return axes 

260 

261 

262def _get_plot_resolution() -> int: 

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

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

265 

266 

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

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

269 if use_bounds and seq_coord.has_bounds(): 

270 vals = seq_coord.bounds.flatten() 

271 else: 

272 vals = seq_coord.points 

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

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

275 

276 if start == end: 

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

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

279 else: 

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

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

282 

283 if seq_coord.units == "unknown": 283 ↛ 285line 283 didn't jump to line 285 because the condition on line 283 was never true

284 # remove the "unknown" in title and filename strings if unit is unknown 

285 sequence_title = sequence_title.replace("unknown", "") 

286 sequence_fname = sequence_fname.replace("unknown", "") 

287 

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

289 if ( 

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

291 and vals[0] == 0 

292 and vals[-1] == 0 

293 ): 

294 sequence_title = "" 

295 sequence_fname = "" 

296 

297 return sequence_title, sequence_fname 

298 

299 

300def _set_title_and_filename( 

301 seq_coord: iris.coords.Coord, 

302 nplot: int, 

303 recipe_title: str, 

304 filename: str, 

305): 

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

307 

308 Parameters 

309 ---------- 

310 sequence_coordinate: iris.coords.Coord 

311 Coordinate about which to make a plot sequence. 

312 nplot: int 

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

314 recipe_title: str 

315 Default plot title, potentially to update. 

316 filename: str 

317 Input plot filename, potentially to update. 

318 

319 Returns 

320 ------- 

321 plot_title: str 

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

323 plot_filename: str 

324 Output formatted plot filename string. 

325 """ 

326 ndim = seq_coord.ndim 

327 npoints = np.size(seq_coord.points) 

328 sequence_title = "" 

329 sequence_fname = "" 

330 

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

332 # (e.g. aggregation histogram plots) 

333 if ndim > 1: 

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

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

336 sequence_fname = f"_{ncase}cases" 

337 

338 # Case 2: Single dimension input 

339 else: 

340 # Single sequence point 

341 if npoints == 1: 

342 if nplot > 1: 

343 # Default labels for sequence inputs 

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

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

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

347 else: 

348 # Aggregated attribute available where input collapsed over aggregation 

349 try: 

350 ncase = seq_coord.attributes["number_reference_times"] 

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

352 sequence_fname = f"_{ncase}cases" 

353 except KeyError: 

354 sequence_title, sequence_fname = _get_start_end_strings( 

355 seq_coord, use_bounds=seq_coord.has_bounds() 

356 ) 

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

358 else: 

359 sequence_title, sequence_fname = _get_start_end_strings( 

360 seq_coord, use_bounds=False 

361 ) 

362 

363 # Set plot title and filename 

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

365 

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

367 if filename is None: 

368 filename = slugify(recipe_title) 

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

370 else: 

371 if nplot > 1: 

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

373 else: 

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

375 

376 return plot_title, plot_filename 

377 

378 

379def _select_series_coord(cube, series_coordinate): 

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

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

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

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

384 fallbacks = [series_coordinate] + [ 

385 c for c in spacing_coordinates if c != series_coordinate 

386 ] 

387 else: 

388 fallbacks = {series_coordinate} 

389 

390 # Try each possible coordinate. 

391 for coord in fallbacks: 

392 try: 

393 return cube.coord(coord) 

394 except iris.exceptions.CoordinateNotFoundError: 

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

396 

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

398 raise iris.exceptions.CoordinateNotFoundError( 

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

400 f"or fallback options {fallbacks}" 

401 ) 

402 

403 

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

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

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

407 mtitle = "Member" 

408 else: 

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

410 

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

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

413 else: 

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

415 

416 return mtitle 

417 

418 

419def _set_axis_range(cubes): 

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

421 levels = None 

422 for cube in cubes: 

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

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

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

426 if levels is None: 

427 break 

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

429 # levels-based ranges for histogram plots. 

430 _, levels, _ = colorbar_map_levels(cube) 

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

432 if levels is not None: 

433 vmin = min(levels) 

434 vmax = max(levels) 

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

436 break 

437 

438 if levels is None: 

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

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

441 

442 return vmin, vmax 

443 

444 

445def _find_matched_slices(cubes, sequence_coordinate): 

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

447 

448 Ensures common points are compared for multiple cube inputs. 

449 """ 

450 all_points = sorted( 

451 set( 

452 itertools.chain.from_iterable( 

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

454 ) 

455 ) 

456 ) 

457 all_slices = list( 

458 itertools.chain.from_iterable( 

459 cb.slices_over(sequence_coordinate) for cb in cubes 

460 ) 

461 ) 

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

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

464 # necessary) 

465 cube_iterables = [ 

466 iris.cube.CubeList( 

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

468 ) 

469 for point in all_points 

470 ] 

471 

472 return cube_iterables 

473 

474 

475def _plot_and_save_spatial_plot( 

476 cube: iris.cube.Cube, 

477 filename: str, 

478 title: str, 

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

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

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

482 **kwargs, 

483): 

484 """Plot and save a spatial plot. 

485 

486 Parameters 

487 ---------- 

488 cube: Cube 

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

490 filename: str 

491 Filename of the plot to write. 

492 title: str 

493 Plot title. 

494 method: "contourf" | "pcolormesh" 

495 The plotting method to use. 

496 overlay_cube: Cube, optional 

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

498 contour_cube: Cube, optional 

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

500 """ 

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

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

503 

504 # Specify the color bar 

505 cmap, levels, norm = colorbar_map_levels(cube) 

506 

507 # If overplotting, set required colorbars 

508 if overlay_cube: 

509 over_cmap, over_levels, over_norm = colorbar_map_levels(overlay_cube) 

510 if contour_cube: 

511 cntr_cmap, cntr_levels, cntr_norm = colorbar_map_levels(contour_cube) 

512 

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

514 axes = _setup_spatial_map(cube, fig, cmap) 

515 

516 # Set colorscale bounds 

517 try: 

518 vmin = min(levels) 

519 vmax = max(levels) 

520 except TypeError: 

521 vmin, vmax = None, None 

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

523 if norm is not None: 

524 vmin = None 

525 vmax = None 

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

527 

528 # Plot the field. 

529 if method == "contourf": 

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

531 elif method == "pcolormesh": 

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

533 else: 

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

535 

536 # Overplot overlay field, if required 

537 if overlay_cube: 

538 try: 

539 over_vmin = min(over_levels) 

540 over_vmax = max(over_levels) 

541 except TypeError: 

542 over_vmin, over_vmax = None, None 

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

544 over_vmin = None 

545 over_vmax = None 

546 overlay = iplt.pcolormesh( 

547 overlay_cube, 

548 cmap=over_cmap, 

549 norm=over_norm, 

550 alpha=0.8, 

551 vmin=over_vmin, 

552 vmax=over_vmax, 

553 ) 

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

555 if contour_cube: 

556 contour = iplt.contour( 

557 contour_cube, 

558 colors="darkgray", 

559 levels=cntr_levels, 

560 norm=cntr_norm, 

561 alpha=0.5, 

562 linestyles="--", 

563 linewidths=1, 

564 ) 

565 plt.clabel(contour) 

566 

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

568 if is_transect(cube): 

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

570 axes.invert_yaxis() 

571 axes.set_yscale("log") 

572 axes.set_ylim(1100, 100) 

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

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

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

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

577 ): 

578 axes.set_yscale("log") 

579 

580 axes.set_title( 

581 f"{title}\n" 

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

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

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

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

586 fontsize=16, 

587 ) 

588 

589 # Inset code 

590 axins = inset_axes( 

591 axes, 

592 width="20%", 

593 height="20%", 

594 loc="upper right", 

595 axes_class=GeoAxes, 

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

597 ) 

598 

599 # Slightly transparent to reduce plot blocking. 

600 axins.patch.set_alpha(0.4) 

601 

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

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

604 

605 SLat, SLon, ELat, ELon = ( 

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

607 ) 

608 

609 # Draw line between them 

610 axins.plot( 

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

612 ) 

613 

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

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

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

617 

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

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

620 

621 # Midpoints 

622 lon_mid = (lon_min + lon_max) / 2 

623 lat_mid = (lat_min + lat_max) / 2 

624 

625 # Maximum half-range 

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

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

628 half_range = 1 

629 

630 # Set square extent 

631 axins.set_extent( 

632 [ 

633 lon_mid - half_range, 

634 lon_mid + half_range, 

635 lat_mid - half_range, 

636 lat_mid + half_range, 

637 ], 

638 crs=ccrs.PlateCarree(), 

639 ) 

640 

641 # Ensure square aspect 

642 axins.set_aspect("equal") 

643 

644 else: 

645 # Add title. 

646 axes.set_title(title, fontsize=16) 

647 

648 # Adjust padding if spatial plot or transect 

649 if is_transect(cube): 

650 yinfopad = -0.1 

651 ycbarpad = 0.1 

652 else: 

653 yinfopad = 0.01 

654 ycbarpad = 0.042 

655 

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

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

658 axes.annotate( 

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

660 xy=(0.025, yinfopad), 

661 xycoords="axes fraction", 

662 xytext=(-5, 5), 

663 textcoords="offset points", 

664 ha="left", 

665 va="bottom", 

666 size=11, 

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

668 ) 

669 

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

671 if overlay_cube: 

672 cbarB = fig.colorbar( 

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

674 ) 

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

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

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

678 cbarB.set_ticks(over_levels) 

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

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

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

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

683 

684 # Add main colour bar. 

685 cbar = fig.colorbar( 

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

687 ) 

688 

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

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

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

692 cbar.set_ticks(levels) 

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

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

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

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

697 

698 # Save plot. 

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

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

701 plt.close(fig) 

702 

703 

704def plot_dfss_contour( 

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

706 variable: str = None, 

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

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

709 

710 Both cubes must be 1D. 

711 

712 Parameters 

713 ---------- 

714 cube: Cube | CubeList 

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

716 filename: str, optional 

717 Filename of the plot to write. 

718 variable: str, optional 

719 which cube variable to plot 

720 

721 Returns 

722 ------- 

723 cubes: Cube 

724 Cube of the original cubes for further processing. 

725 

726 Notes 

727 ----- 

728 Makes a filled contour plot for dFSS/eFSS with neighbourhood lengths on the y-axis 

729 and forecast lead time on the x-axis. 

730 

731 Adds a contour line at the 0.5 contour. 

732 """ 

733 cube_copy = cube 

734 if type(cube) is iris.cube.CubeList: 

735 if not variable: 

736 logging.Warning( 

737 "CubeList given, but variable not specified. Defaulting to first cube." 

738 ) 

739 cube = cube[0] 

740 else: 

741 cube = cube.extract(variable)[0] 

742 

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

744 

745 title = cube.name() 

746 

747 if cube.attributes.locals["method"] == "centile": 

748 method = cube.attributes.locals["method"] 

749 centile = cube.attributes.locals["centile"] 

750 filename = f"{cube.name()}_{method}_{centile}.png" 

751 plot_title = f"{recipe_title} \n {title} \n method={method} | centile={centile}" 

752 

753 elif cube.attributes.locals["method"] == "threshold": 

754 method = cube.attributes.locals["method"] 

755 threshold = cube.attributes.locals["threshold"] 

756 filename = f"{cube.name()}_{method}_{threshold}.png" 

757 plot_title = ( 

758 f"{recipe_title} \n {title} \n method={method} | threshold={threshold}" 

759 ) 

760 

761 cmap = plt.colormaps["viridis"] 

762 levels = np.linspace(0, 1, 11) 

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

764 

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

766 

767 # set the contour colour for the 0.5 contour line for dfss only 

768 

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

770 

771 if cube.name() == "dfss": 

772 colors = ["white"] 

773 cmap_0p5_contour = mcolors.ListedColormap(colors) 

774 

775 iplt.contour( 

776 cube, cmap=cmap_0p5_contour, norm=norm, levels=[0.5], linestyles="dashed" 

777 ) 

778 

779 plt.xlabel(cube.dim_coords[0].name(), fontsize=14) 

780 plt.ylabel(cube.dim_coords[1].name(), fontsize=14) 

781 

782 cbar = fig.colorbar( 

783 plot, orientation="horizontal", location="bottom", pad=0.1, shrink=0.7 

784 ) 

785 

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

787 

788 # Overall figure title. 

789 

790 fig.suptitle(plot_title, fontsize=16) 

791 

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

793 

794 logging.info("Saved contour plot", filename) 

795 plt.close(fig) 

796 

797 # Add list of plots to plot metadata. 

798 plot_index = _append_to_plot_index([filename]) 

799 

800 # Make a page to display the plots. 

801 _make_plot_html_page(plot_index) 

802 

803 return cube_copy 

804 

805 

806def _plot_and_save_postage_stamp_spatial_plot( 

807 cube: iris.cube.Cube, 

808 filename: str, 

809 stamp_coordinate: str, 

810 title: str, 

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

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

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

814 **kwargs, 

815): 

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

817 

818 Parameters 

819 ---------- 

820 cube: Cube 

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

822 filename: str 

823 Filename of the plot to write. 

824 stamp_coordinate: str 

825 Coordinate that becomes different plots. 

826 method: "contourf" | "pcolormesh" 

827 The plotting method to use. 

828 overlay_cube: Cube, optional 

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

830 contour_cube: Cube, optional 

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

832 

833 Raises 

834 ------ 

835 ValueError 

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

837 """ 

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

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

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

841 grid_size = math.ceil(nmember / grid_rows) 

842 

843 fig = plt.figure( 

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

845 ) 

846 

847 # Specify the color bar 

848 cmap, levels, norm = colorbar_map_levels(cube) 

849 # If overplotting, set required colorbars 

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

851 over_cmap, over_levels, over_norm = colorbar_map_levels(overlay_cube) 

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

853 cntr_cmap, cntr_levels, cntr_norm = colorbar_map_levels(contour_cube) 

854 

855 # Make a subplot for each member. 

856 for member, subplot in zip( 

857 cube.slices_over(stamp_coordinate), 

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

859 strict=False, 

860 ): 

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

862 axes = _setup_spatial_map( 

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

864 ) 

865 if method == "contourf": 

866 # Filled contour plot of the field. 

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

868 elif method == "pcolormesh": 

869 if levels is not None: 

870 vmin = min(levels) 

871 vmax = max(levels) 

872 else: 

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

874 vmin, vmax = None, None 

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

876 # if levels are defined. 

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

878 vmin = None 

879 vmax = None 

880 # pcolormesh plot of the field. 

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

882 else: 

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

884 

885 # Overplot overlay field, if required 

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

887 try: 

888 over_vmin = min(over_levels) 

889 over_vmax = max(over_levels) 

890 except TypeError: 

891 over_vmin, over_vmax = None, None 

892 if over_norm is not None: 

893 over_vmin = None 

894 over_vmax = None 

895 iplt.pcolormesh( 

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

897 cmap=over_cmap, 

898 norm=over_norm, 

899 alpha=0.6, 

900 vmin=over_vmin, 

901 vmax=over_vmax, 

902 ) 

903 # Overplot contour field, if required 

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

905 iplt.contour( 

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

907 colors="darkgray", 

908 levels=cntr_levels, 

909 norm=cntr_norm, 

910 alpha=0.6, 

911 linestyles="--", 

912 linewidths=1, 

913 ) 

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

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

916 

917 # Put the shared colorbar in its own axes. 

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

919 colorbar = fig.colorbar( 

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

921 ) 

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

923 

924 # Overall figure title. 

925 fig.suptitle(title, fontsize=16) 

926 

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

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

929 plt.close(fig) 

930 

931 

932def _plot_and_save_line_series( 

933 cubes: iris.cube.CubeList, 

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

935 filename: str, 

936 title: str, 

937 ensemble_coord: str = None, 

938 sequence_coord: str = None, 

939 **kwargs, 

940): 

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

942 

943 Parameters 

944 ---------- 

945 cubes: Cube or CubeList 

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

947 coords: list[Coord] 

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

949 ensemble_coord: str 

950 Ensemble coordinate in the cube. 

951 sequence_coord str 

952 sequence coordinate for plotting, needed only if not an ensemble. 

953 filename: str 

954 Filename of the plot to write. 

955 title: str 

956 Plot title. 

957 """ 

958 if (ensemble_coord is None) == (sequence_coord is None): 958 ↛ 959line 958 didn't jump to line 959 because the condition on line 958 was never true

959 raise ValueError( 

960 "Exactly one of ensemble_coord and sequence_coord must be provided" 

961 ) 

962 

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

964 

965 model_colors_map = get_model_colors_map(cubes) 

966 

967 # Store min/max ranges. 

968 y_levels = [] 

969 

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

971 validate_cubes_coords(cubes, coords) 

972 

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

974 label = None 

975 color = "black" 

976 if model_colors_map: 

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

978 color = model_colors_map.get(label) 

979 if ensemble_coord is not None: 979 ↛ 1005line 979 didn't jump to line 1005 because the condition on line 979 was always true

980 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

983 iplt.plot( 

984 coord, 

985 cube_slice, 

986 color=color, 

987 marker="o", 

988 ls="-", 

989 lw=3, 

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

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

992 else label, 

993 ) 

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

995 else: 

996 iplt.plot( 

997 coord, 

998 cube_slice, 

999 color=color, 

1000 ls="-", 

1001 lw=1.5, 

1002 alpha=0.75, 

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

1004 ) 

1005 if sequence_coord is not None: 1005 ↛ 1006line 1005 didn't jump to line 1006 because the condition on line 1005 was never true

1006 for cube_slice in cube.slices_over(sequence_coord): 

1007 iplt.plot(coord, cube_slice, color=color, ls="-", lw=1.5, alpha=0.75) 

1008 

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

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

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

1012 y_levels.append(min(levels)) 

1013 y_levels.append(max(levels)) 

1014 

1015 # Get the current axes. 

1016 ax = plt.gca() 

1017 

1018 # Add some labels and tweak the style. 

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

1020 if coords[0].name() == "time": 1020 ↛ 1021line 1020 didn't jump to line 1021 because the condition on line 1020 was never true

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

1022 else: 

1023 if coords[0].units == "unknown": 1023 ↛ 1024line 1023 didn't jump to line 1024 because the condition on line 1023 was never true

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 

1028 if cubes[0].units == "unknown": 1028 ↛ 1031line 1028 didn't jump to line 1031 because the condition on line 1028 was always true

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

1030 else: 

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

1032 

1033 ax.set_title(title, fontsize=16) 

1034 

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

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

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

1038 

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

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

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

1042 # Add zero line. 

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

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

1045 logging.debug( 

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

1047 ) 

1048 else: 

1049 ax.autoscale() 

1050 

1051 # Add gridlines 

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

1053 # Ientify unique labels for legend 

1054 handles = list( 

1055 { 

1056 label: handle 

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

1058 }.values() 

1059 ) 

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

1061 

1062 # Save plot. 

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

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

1065 plt.close(fig) 

1066 

1067 

1068def _plot_and_save_line_power_spectrum_series( 

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

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

1071 ensemble_coord: str, 

1072 filename: str, 

1073 title: str, 

1074 series_coordinate: str, 

1075 **kwargs, 

1076): 

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

1078 

1079 Parameters 

1080 ---------- 

1081 cubes: Cube or CubeList 

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

1083 coords: list[Coord] 

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

1085 ensemble_coord: str 

1086 Ensemble coordinate in the cube. 

1087 filename: str 

1088 Filename of the plot to write. 

1089 title: str 

1090 Plot title. 

1091 series_coordinate: str 

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

1093 """ 

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

1095 model_colors_map = get_model_colors_map(cubes) 

1096 ax = plt.gca() 

1097 

1098 # Store min/max ranges. 

1099 y_levels = [] 

1100 

1101 line_marker = None 

1102 line_width = 1 

1103 

1104 for cube in iter_maybe(cubes): 

1105 # next 2 lines replace chunk of code. 

1106 xcoord = _select_series_coord(cube, series_coordinate) 

1107 xname = xcoord.points 

1108 

1109 yfield = cube.data # power spectrum 

1110 label = None 

1111 color = "black" 

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

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

1114 color = model_colors_map.get(label) 

1115 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

1118 ax.plot( 

1119 xname, 

1120 yfield, 

1121 color=color, 

1122 marker=line_marker, 

1123 ls="-", 

1124 lw=line_width, 

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

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

1127 else label, 

1128 ) 

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

1130 else: 

1131 ax.plot( 

1132 xname, 

1133 yfield, 

1134 color=color, 

1135 ls="-", 

1136 lw=1.5, 

1137 alpha=0.75, 

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

1139 ) 

1140 

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

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

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

1144 y_levels.append(min(levels)) 

1145 y_levels.append(max(levels)) 

1146 

1147 # Add some labels and tweak the style. 

1148 

1149 title = f"{title}" 

1150 ax.set_title(title, fontsize=16) 

1151 

1152 # Set appropriate x-axis label based on coordinate 

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

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

1155 ): 

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

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

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

1159 ): 

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

1161 else: # frequency or check units 

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

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

1164 else: 

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

1166 

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

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

1169 

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

1171 

1172 # Set log-log scale 

1173 ax.set_xscale("log") 

1174 ax.set_yscale("log") 

1175 

1176 # Add gridlines 

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

1178 # Ientify unique labels for legend 

1179 handles = list( 

1180 { 

1181 label: handle 

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

1183 }.values() 

1184 ) 

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

1186 

1187 # Save plot. 

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

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

1190 plt.close(fig) 

1191 

1192 

1193def _plot_and_save_vertical_line_series( 

1194 cubes: iris.cube.CubeList, 

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

1196 ensemble_coord: str, 

1197 filename: str, 

1198 series_coordinate: str, 

1199 title: str, 

1200 vmin: float, 

1201 vmax: float, 

1202 **kwargs, 

1203): 

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

1205 

1206 Parameters 

1207 ---------- 

1208 cubes: CubeList 

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

1210 coord: list[Coord] 

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

1212 ensemble_coord: str 

1213 Ensemble coordinate in the cube. 

1214 filename: str 

1215 Filename of the plot to write. 

1216 series_coordinate: str 

1217 Coordinate to use as vertical axis. 

1218 title: str 

1219 Plot title. 

1220 vmin: float 

1221 Minimum value for the x-axis. 

1222 vmax: float 

1223 Maximum value for the x-axis. 

1224 """ 

1225 # plot the vertical pressure axis using log scale 

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

1227 

1228 model_colors_map = get_model_colors_map(cubes) 

1229 

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

1231 validate_cubes_coords(cubes, coords) 

1232 

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

1234 label = None 

1235 color = "black" 

1236 if model_colors_map: 

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

1238 color = model_colors_map.get(label) 

1239 

1240 for cube_slice in cube.slices_over(ensemble_coord): 

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

1242 # unless single forecast. 

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

1244 iplt.plot( 

1245 cube_slice, 

1246 coord, 

1247 color=color, 

1248 marker="o", 

1249 ls="-", 

1250 lw=3, 

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

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

1253 else label, 

1254 ) 

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

1256 else: 

1257 iplt.plot( 

1258 cube_slice, 

1259 coord, 

1260 color=color, 

1261 ls="-", 

1262 lw=1.5, 

1263 alpha=0.75, 

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

1265 ) 

1266 

1267 # Get the current axis 

1268 ax = plt.gca() 

1269 

1270 # Special handling for pressure level data. 

1271 if series_coordinate == "pressure": 

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

1273 ax.invert_yaxis() 

1274 ax.set_yscale("log") 

1275 

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

1277 y_tick_labels = [ 

1278 "1000", 

1279 "850", 

1280 "700", 

1281 "500", 

1282 "300", 

1283 "200", 

1284 "100", 

1285 ] 

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

1287 

1288 # Set y-axis limits and ticks. 

1289 ax.set_ylim(1100, 100) 

1290 

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

1292 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

1298 

1299 ax.set_yticks(y_ticks) 

1300 ax.set_yticklabels(y_tick_labels) 

1301 

1302 # Set x-axis limits. 

1303 ax.set_xlim(vmin, vmax) 

1304 # Mark y=0 if present in plot. 

1305 if vmin < 0.0 and vmax > 0.0: 

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

1307 

1308 # Add some labels and tweak the style. 

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

1310 ax.set_xlabel( 

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

1312 ) 

1313 ax.set_title(title, fontsize=16) 

1314 ax.ticklabel_format(axis="x") 

1315 ax.tick_params(axis="y") 

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

1317 

1318 # Add gridlines 

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

1320 # Ientify unique labels for legend 

1321 handles = list( 

1322 { 

1323 label: handle 

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

1325 }.values() 

1326 ) 

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

1328 

1329 # Save plot. 

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

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

1332 plt.close(fig) 

1333 

1334 

1335def _plot_and_save_scatter_plot( 

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

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

1338 filename: str, 

1339 title: str, 

1340 one_to_one: bool, 

1341 model_names: list[str] = None, 

1342 **kwargs, 

1343): 

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

1345 

1346 Parameters 

1347 ---------- 

1348 cube_x: Cube | CubeList 

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

1350 cube_y: Cube | CubeList 

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

1352 filename: str 

1353 Filename of the plot to write. 

1354 title: str 

1355 Plot title. 

1356 one_to_one: bool 

1357 Whether a 1:1 line is plotted. 

1358 """ 

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

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

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

1362 # over the pairs simultaneously. 

1363 

1364 # Ensure cube_x and cube_y are iterable 

1365 cube_x_iterable = iter_maybe(cube_x) 

1366 cube_y_iterable = iter_maybe(cube_y) 

1367 

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

1369 iplt.scatter(cube_x_iter, cube_y_iter) 

1370 if one_to_one is True: 1370 ↛ 1383line 1370 didn't jump to line 1383 because the condition on line 1370 was always true

1371 plt.plot( 

1372 [ 

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

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

1375 ], 

1376 [ 

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

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

1379 ], 

1380 "k", 

1381 linestyle="--", 

1382 ) 

1383 ax = plt.gca() 

1384 

1385 # Add some labels and tweak the style. 

1386 if model_names is None: 1386 ↛ 1387line 1386 didn't jump to line 1387 because the condition on line 1386 was never true

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

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

1389 else: 

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

1391 ax.set_xlabel( 

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

1393 ) 

1394 ax.set_ylabel( 

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

1396 ) 

1397 ax.set_title(title, fontsize=16) 

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

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

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

1401 ax.autoscale() 

1402 

1403 # Save plot. 

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

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

1406 plt.close(fig) 

1407 

1408 

1409def _plot_and_save_vector_plot( 

1410 cube_u: iris.cube.Cube, 

1411 cube_v: iris.cube.Cube, 

1412 filename: str, 

1413 title: str, 

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

1415 **kwargs, 

1416): 

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

1418 

1419 Parameters 

1420 ---------- 

1421 cube_u: Cube 

1422 2 dimensional Cube of u component of the data. 

1423 cube_v: Cube 

1424 2 dimensional Cube of v component of the data. 

1425 filename: str 

1426 Filename of the plot to write. 

1427 title: str 

1428 Plot title. 

1429 """ 

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

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

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

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

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

1435 cube_vec_mag.rename( 

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

1437 ) 

1438 

1439 # Specify the color bar 

1440 cmap, levels, norm = colorbar_map_levels(cube_vec_mag) 

1441 

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

1443 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

1444 

1445 if method == "contourf": 

1446 # Filled contour plot of the field. 

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

1448 elif method == "pcolormesh": 

1449 try: 

1450 vmin = min(levels) 

1451 vmax = max(levels) 

1452 except TypeError: 

1453 vmin, vmax = None, None 

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

1455 # if levels are defined. 

1456 if norm is not None: 

1457 vmin = None 

1458 vmax = None 

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

1460 else: 

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

1462 

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

1464 if is_transect(cube_vec_mag): 

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

1466 axes.invert_yaxis() 

1467 axes.set_yscale("log") 

1468 axes.set_ylim(1100, 100) 

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

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

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

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

1473 ): 

1474 axes.set_yscale("log") 

1475 

1476 axes.set_title( 

1477 f"{title}\n" 

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

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

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

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

1482 fontsize=16, 

1483 ) 

1484 

1485 else: 

1486 # Add title. 

1487 axes.set_title(title, fontsize=16) 

1488 

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

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

1491 axes.annotate( 

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

1493 xy=(0.05, -0.05), 

1494 xycoords="axes fraction", 

1495 xytext=(-5, 5), 

1496 textcoords="offset points", 

1497 ha="right", 

1498 va="bottom", 

1499 size=11, 

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

1501 ) 

1502 

1503 # Add colour bar. 

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

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

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

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

1508 cbar.set_ticks(levels) 

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

1510 

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

1512 # with less than 30 points. 

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

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

1515 

1516 # Save plot. 

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

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

1519 plt.close(fig) 

1520 

1521 

1522def _plot_and_save_histogram_series( 

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

1524 filename: str, 

1525 title: str, 

1526 vmin: float, 

1527 vmax: float, 

1528 **kwargs, 

1529): 

1530 """Plot and save a histogram series. 

1531 

1532 Parameters 

1533 ---------- 

1534 cubes: Cube or CubeList 

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

1536 filename: str 

1537 Filename of the plot to write. 

1538 title: str 

1539 Plot title. 

1540 vmin: float 

1541 minimum for colorbar 

1542 vmax: float 

1543 maximum for colorbar 

1544 """ 

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

1546 ax = plt.gca() 

1547 

1548 model_colors_map = get_model_colors_map(cubes) 

1549 

1550 # Set default that histograms will produce probability density function 

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

1552 density = True 

1553 

1554 for cube in iter_maybe(cubes): 

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

1556 # than seeing if long names exist etc. 

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

1558 if "surface_microphysical" in title: 

1559 if "amount" in title: 

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

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

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

1563 density = False 

1564 else: 

1565 bins = 10.0 ** ( 

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

1567 ) # Suggestion from RMED toolbox. 

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

1569 ax.set_yscale("log") 

1570 vmin = bins[1] 

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

1572 ax.set_xscale("log") 

1573 elif "lightning" in title: 

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

1575 else: 

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

1577 logging.debug( 

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

1579 np.size(bins), 

1580 np.min(bins), 

1581 np.max(bins), 

1582 ) 

1583 

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

1585 # Otherwise we plot xdim histograms stacked. 

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

1587 

1588 label = None 

1589 color = "black" 

1590 if model_colors_map: 

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

1592 color = model_colors_map[label] 

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

1594 

1595 # Compute area under curve. 

1596 if "surface_microphysical" in title and "amount" in title: 

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

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

1599 x = x[1:] 

1600 y = y[1:] 

1601 

1602 ax.plot( 

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

1604 ) 

1605 

1606 # Add some labels and tweak the style. 

1607 ax.set_title(title, fontsize=16) 

1608 ax.set_xlabel( 

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

1610 ) 

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

1612 if "surface_microphysical" in title and "amount" in title: 

1613 ax.set_ylabel( 

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

1615 ) 

1616 ax.set_xlim(vmin, vmax) 

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

1618 

1619 # Overlay grid-lines onto histogram plot. 

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

1621 if model_colors_map: 

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

1623 

1624 # Save plot. 

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

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

1627 plt.close(fig) 

1628 

1629 

1630def _plot_and_save_postage_stamp_histogram_series( 

1631 cube: iris.cube.Cube, 

1632 filename: str, 

1633 title: str, 

1634 stamp_coordinate: str, 

1635 vmin: float, 

1636 vmax: float, 

1637 **kwargs, 

1638): 

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

1640 

1641 Parameters 

1642 ---------- 

1643 cube: Cube 

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

1645 filename: str 

1646 Filename of the plot to write. 

1647 title: str 

1648 Plot title. 

1649 stamp_coordinate: str 

1650 Coordinate that becomes different plots. 

1651 vmin: float 

1652 minimum for pdf x-axis 

1653 vmax: float 

1654 maximum for pdf x-axis 

1655 """ 

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

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

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

1659 grid_size = math.ceil(nmember / grid_rows) 

1660 

1661 fig = plt.figure( 

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

1663 ) 

1664 # Make a subplot for each member. 

1665 for member, subplot in zip( 

1666 cube.slices_over(stamp_coordinate), 

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

1668 strict=False, 

1669 ): 

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

1671 # cartopy GeoAxes generated. 

1672 plt.subplot(grid_rows, grid_size, subplot) 

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

1674 # Otherwise we plot xdim histograms stacked. 

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

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

1677 axes = plt.gca() 

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

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

1680 axes.set_xlim(vmin, vmax) 

1681 

1682 # Overall figure title. 

1683 fig.suptitle(title, fontsize=16) 

1684 

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

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

1687 plt.close(fig) 

1688 

1689 

1690def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1691 cube: iris.cube.Cube, 

1692 filename: str, 

1693 title: str, 

1694 stamp_coordinate: str, 

1695 vmin: float, 

1696 vmax: float, 

1697 **kwargs, 

1698): 

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

1700 ax.set_title(title, fontsize=16) 

1701 ax.set_xlim(vmin, vmax) 

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

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

1704 # Loop over all slices along the stamp_coordinate 

1705 for member in cube.slices_over(stamp_coordinate): 

1706 # Flatten the member data to 1D 

1707 member_data_1d = member.data.flatten() 

1708 # Plot the histogram using plt.hist 

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

1710 plt.hist( 

1711 member_data_1d, 

1712 density=True, 

1713 stacked=True, 

1714 label=f"{mtitle}", 

1715 ) 

1716 

1717 # Add a legend 

1718 ax.legend(fontsize=16) 

1719 

1720 # Save the figure to a file 

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

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

1723 

1724 # Close the figure 

1725 plt.close(fig) 

1726 

1727 

1728def _plot_and_save_scattermap_plot( 

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

1730): 

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

1732 

1733 Parameters 

1734 ---------- 

1735 cube: Cube 

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

1737 longitude coordinates, 

1738 filename: str 

1739 Filename of the plot to write. 

1740 title: str 

1741 Plot title. 

1742 projection: str 

1743 Mapping projection to be used by cartopy. 

1744 """ 

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

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

1747 if projection is not None: 

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

1749 # a stereographic projection over the North Pole. 

1750 if projection == "NP_Stereo": 

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

1752 else: 

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

1754 else: 

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

1756 

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

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

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

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

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

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

1763 # proportion to the area of the figure. 

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

1765 klon = None 

1766 klat = None 

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

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

1769 klat = kc 

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

1771 klon = kc 

1772 scatter_map = iplt.scatter( 

1773 cube.aux_coords[klon], 

1774 cube.aux_coords[klat], 

1775 c=cube.data[:], 

1776 s=mrk_size, 

1777 cmap="jet", 

1778 edgecolors="k", 

1779 ) 

1780 

1781 # Add coastlines and borderlines. 

1782 try: 

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

1784 axes.add_feature(cfeature.BORDERS) 

1785 except AttributeError: 

1786 pass 

1787 

1788 # Add title. 

1789 axes.set_title(title, fontsize=16) 

1790 

1791 # Add colour bar. 

1792 cbar = fig.colorbar(scatter_map) 

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

1794 

1795 # Save plot. 

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

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

1798 plt.close(fig) 

1799 

1800 

1801def _spatial_plot( 

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

1803 cube: iris.cube.Cube, 

1804 filename: str | None, 

1805 sequence_coordinate: str, 

1806 stamp_coordinate: str, 

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

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

1809 **kwargs, 

1810): 

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

1812 

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

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

1815 is present then postage stamp plots will be produced. 

1816 

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

1818 be overplotted on the same figure. 

1819 

1820 Parameters 

1821 ---------- 

1822 method: "contourf" | "pcolormesh" 

1823 The plotting method to use. 

1824 cube: Cube 

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

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

1827 plotted sequentially and/or as postage stamp plots. 

1828 filename: str | None 

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

1830 uses the recipe name. 

1831 sequence_coordinate: str 

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

1833 This coordinate must exist in the cube. 

1834 stamp_coordinate: str 

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

1836 ``"realization"``. 

1837 overlay_cube: Cube | None, optional 

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

1839 contour_cube: Cube | None, optional 

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

1841 

1842 Raises 

1843 ------ 

1844 ValueError 

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

1846 TypeError 

1847 If the cube isn't a single cube. 

1848 """ 

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

1850 

1851 # Ensure we've got a single cube. 

1852 cube = check_single_cube(cube) 

1853 

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

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

1856 stamp_coordinate = check_stamp_coordinate(cube) 

1857 

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

1859 # single point. 

1860 plotting_func = _plot_and_save_spatial_plot 

1861 try: 

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

1863 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1864 except iris.exceptions.CoordinateNotFoundError: 

1865 pass 

1866 

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

1868 # dimension called observation or model_obs_error 

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

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

1871 for crd in cube.coords() 

1872 ): 

1873 plotting_func = _plot_and_save_scattermap_plot 

1874 

1875 # Must have a sequence coordinate. 

1876 try: 

1877 cube.coord(sequence_coordinate) 

1878 except iris.exceptions.CoordinateNotFoundError as err: 

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

1880 

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

1882 plot_index = [] 

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

1884 

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

1886 # Set plot titles and filename 

1887 seq_coord = cube_slice.coord(sequence_coordinate) 

1888 plot_title, plot_filename = _set_title_and_filename( 

1889 seq_coord, nplot, recipe_title, filename 

1890 ) 

1891 

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

1893 overlay_slice = slice_over_maybe(overlay_cube, sequence_coordinate, iseq) 

1894 contour_slice = slice_over_maybe(contour_cube, sequence_coordinate, iseq) 

1895 

1896 # Do the actual plotting. 

1897 plotting_func( 

1898 cube_slice, 

1899 filename=plot_filename, 

1900 stamp_coordinate=stamp_coordinate, 

1901 title=plot_title, 

1902 method=method, 

1903 overlay_cube=overlay_slice, 

1904 contour_cube=contour_slice, 

1905 **kwargs, 

1906 ) 

1907 plot_index.append(plot_filename) 

1908 

1909 # Add list of plots to plot metadata. 

1910 complete_plot_index = _append_to_plot_index(plot_index) 

1911 

1912 # Make a page to display the plots. 

1913 _make_plot_html_page(complete_plot_index) 

1914 

1915 

1916#################### 

1917# Public functions # 

1918#################### 

1919 

1920 

1921def spatial_contour_plot( 

1922 cube: iris.cube.Cube, 

1923 filename: str = None, 

1924 sequence_coordinate: str = "time", 

1925 stamp_coordinate: str = "realization", 

1926 **kwargs, 

1927) -> iris.cube.Cube: 

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

1929 

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

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

1932 is present then postage stamp plots will be produced. 

1933 

1934 Parameters 

1935 ---------- 

1936 cube: Cube 

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

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

1939 plotted sequentially and/or as postage stamp plots. 

1940 filename: str, optional 

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

1942 to the recipe name. 

1943 sequence_coordinate: str, optional 

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

1945 This coordinate must exist in the cube. 

1946 stamp_coordinate: str, optional 

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

1948 ``"realization"``. 

1949 

1950 Returns 

1951 ------- 

1952 Cube 

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

1954 

1955 Raises 

1956 ------ 

1957 ValueError 

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

1959 TypeError 

1960 If the cube isn't a single cube. 

1961 """ 

1962 _spatial_plot( 

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

1964 ) 

1965 return cube 

1966 

1967 

1968def spatial_pcolormesh_plot( 

1969 cube: iris.cube.Cube, 

1970 filename: str = None, 

1971 sequence_coordinate: str = "time", 

1972 stamp_coordinate: str = "realization", 

1973 **kwargs, 

1974) -> iris.cube.Cube: 

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

1976 

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

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

1979 is present then postage stamp plots will be produced. 

1980 

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

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

1983 contour areas are important. 

1984 

1985 Parameters 

1986 ---------- 

1987 cube: Cube 

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

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

1990 plotted sequentially and/or as postage stamp plots. 

1991 filename: str, optional 

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

1993 to the recipe name. 

1994 sequence_coordinate: str, optional 

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

1996 This coordinate must exist in the cube. 

1997 stamp_coordinate: str, optional 

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

1999 ``"realization"``. 

2000 

2001 Returns 

2002 ------- 

2003 Cube 

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

2005 

2006 Raises 

2007 ------ 

2008 ValueError 

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

2010 TypeError 

2011 If the cube isn't a single cube. 

2012 """ 

2013 _spatial_plot( 

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

2015 ) 

2016 return cube 

2017 

2018 

2019def spatial_multi_pcolormesh_plot( 

2020 cube: iris.cube.Cube, 

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

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

2023 filename: str = None, 

2024 sequence_coordinate: str = "time", 

2025 stamp_coordinate: str = "realization", 

2026 **kwargs, 

2027) -> iris.cube.Cube: 

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

2029 

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

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

2032 is present then postage stamp plots will be produced. 

2033 

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

2035 

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

2037 

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

2039 

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

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

2042 contour areas are important. 

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 overlay_cube: Cube, optional 

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

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

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

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

2055 contour_cube: Cube, optional 

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

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

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

2059 filename: str, optional 

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

2061 to the recipe name. 

2062 sequence_coordinate: str, optional 

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

2064 This coordinate must exist in the cube. 

2065 stamp_coordinate: str, optional 

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

2067 ``"realization"``. 

2068 

2069 Returns 

2070 ------- 

2071 Cube 

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

2073 

2074 Raises 

2075 ------ 

2076 ValueError 

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

2078 TypeError 

2079 If the cube isn't a single cube. 

2080 """ 

2081 _spatial_plot( 

2082 "pcolormesh", 

2083 cube, 

2084 filename, 

2085 sequence_coordinate, 

2086 stamp_coordinate, 

2087 overlay_cube=overlay_cube, 

2088 contour_cube=contour_cube, 

2089 ) 

2090 return cube, overlay_cube, contour_cube 

2091 

2092 

2093# TODO: Expand function to handle ensemble data. 

2094# line_coordinate: str, optional 

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

2096# ``"realization"``. 

2097def plot_line_series( 

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

2099 filename: str = None, 

2100 series_coordinate: str = "time", 

2101 sequence_coordinate: str = "time", 

2102 # add the following for ensembles 

2103 stamp_coordinate: str = "realization", 

2104 single_plot: bool = False, 

2105 **kwargs, 

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

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

2108 

2109 The Cube or CubeList must be 1D. 

2110 

2111 Parameters 

2112 ---------- 

2113 iris.cube | iris.cube.CubeList 

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

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

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

2117 filename: str, optional 

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

2119 to the recipe name. 

2120 series_coordinate: str, optional 

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

2122 coordinate must exist in the cube. 

2123 

2124 Returns 

2125 ------- 

2126 iris.cube.Cube | iris.cube.CubeList 

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

2128 plotted data. 

2129 

2130 Raises 

2131 ------ 

2132 ValueError 

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

2134 TypeError 

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

2136 """ 

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

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

2139 

2140 num_models = get_num_models(cube) 

2141 

2142 validate_cube_shape(cube, num_models) 

2143 

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

2145 cubes = iter_maybe(cube) 

2146 coords = [] 

2147 for cube in cubes: 

2148 try: 

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

2150 except iris.exceptions.CoordinateNotFoundError as err: 

2151 raise ValueError( 

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

2153 ) from err 

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

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

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

2157 else: 

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

2159 

2160 plot_index = [] 

2161 

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

2163 is_spectral_plot = series_coordinate in [ 

2164 "frequency", 

2165 "physical_wavenumber", 

2166 "wavelength", 

2167 ] 

2168 

2169 if is_spectral_plot: 

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

2171 # coordinate frequency/wavenumber. 

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

2173 # time slider option. 

2174 

2175 # Internal plotting function. 

2176 plotting_func = _plot_and_save_line_power_spectrum_series 

2177 

2178 for cube in cubes: 

2179 try: 

2180 cube.coord(sequence_coordinate) 

2181 except iris.exceptions.CoordinateNotFoundError as err: 

2182 raise ValueError( 

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

2184 ) from err 

2185 

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

2187 # check for ensembles 

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

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

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

2191 ): 

2192 if single_plot: 

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

2194 plotting_func = _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series 

2195 else: 

2196 # Plot postage stamps 

2197 plotting_func = _plot_and_save_postage_stamp_power_spectrum_series 

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

2199 else: 

2200 all_points = sorted( 

2201 set( 

2202 itertools.chain.from_iterable( 

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

2204 ) 

2205 ) 

2206 ) 

2207 all_slices = list( 

2208 itertools.chain.from_iterable( 

2209 cb.slices_over(sequence_coordinate) for cb in cubes 

2210 ) 

2211 ) 

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

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

2214 # necessary) 

2215 cube_iterables = [ 

2216 iris.cube.CubeList( 

2217 s 

2218 for s in all_slices 

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

2220 ) 

2221 for point in all_points 

2222 ] 

2223 

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

2225 

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

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

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

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

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

2231 

2232 for cube_slice in cube_iterables: 

2233 # Normalize cube_slice to a list of cubes 

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

2235 cubes = list(cube_slice) 

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

2237 cubes = [cube_slice] 

2238 else: 

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

2240 

2241 # Use sequence value so multiple sequences can merge. 

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

2243 plot_title, plot_filename = _set_title_and_filename( 

2244 seq_coord, nplot, recipe_title, filename 

2245 ) 

2246 

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

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

2249 

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

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

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

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

2254 

2255 # Do the actual plotting. 

2256 plotting_func( 

2257 cube_slice, 

2258 coords, 

2259 stamp_coordinate, 

2260 plot_filename, 

2261 title, 

2262 series_coordinate, 

2263 ) 

2264 

2265 plot_index.append(plot_filename) 

2266 else: 

2267 # Format the title and filename using plotted series coordinate 

2268 nplot = 1 

2269 seq_coord = coords[0] 

2270 plot_title, plot_filename = _set_title_and_filename( 

2271 seq_coord, nplot, recipe_title, filename 

2272 ) 

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

2274 _plot_and_save_line_series( 

2275 cubes, coords, stamp_coordinate, plot_filename, plot_title 

2276 ) 

2277 

2278 plot_index.append(plot_filename) 

2279 # Do the actual plotting. 

2280 _plot_and_save_line_series( 

2281 cubes, coords, plot_filename, plot_title, ensemble_coord="realization" 

2282 ) 

2283 

2284 # append plot to list of plots 

2285 complete_plot_index = _append_to_plot_index(plot_index) 

2286 

2287 # Make a page to display the plots. 

2288 _make_plot_html_page(complete_plot_index) 

2289 

2290 return cube 

2291 

2292 

2293def plot_dfss_line_series_sequence( 

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

2295 filename: str = None, 

2296 variable: str = None, 

2297 series_coordinate: str = None, 

2298 sequence_coordinate: str = None, 

2299 **kwargs, 

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

2301 """Plot a line plot.""" 

2302 cube_copy = cube 

2303 if type(cube) is iris.cube.CubeList: 

2304 if not variable: 

2305 logging.Warning( 

2306 "CubeList given, but variable not specified. Defaulting to first cube." 

2307 ) 

2308 cube = cube[0] 

2309 else: 

2310 cube = cube.extract(variable) 

2311 

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

2313 

2314 num_models = get_num_models(cube) 

2315 

2316 validate_cube_shape(cube, num_models) 

2317 

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

2319 cubes = iter_maybe(cube) 

2320 

2321 coords = [] 

2322 for cube in cubes: 

2323 try: 

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

2325 except iris.exceptions.CoordinateNotFoundError as err: 

2326 raise ValueError( 

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

2328 ) from err 

2329 if cube.ndim > 2 or not cube.coords(sequence_coordinate): 

2330 raise ValueError( 

2331 f"Cube must be 1D or 2D with a {sequence_coordinate} coordinate." 

2332 ) 

2333 

2334 # Format the title and filename using plotted series coordinate 

2335 nplot = 1 

2336 seq_coord = coords[0] 

2337 plot_title, plot_filename = _set_title_and_filename( 

2338 seq_coord, nplot, recipe_title, filename 

2339 ) 

2340 # Do the actual plotting 

2341 

2342 for i, cubes in enumerate(cube.slices_over(sequence_coordinate)): 

2343 if cubes.coord(sequence_coordinate).units == "unknown": 

2344 sequence_point = cubes.coord(sequence_coordinate).points[0] 

2345 else: 

2346 sequence_point = cubes.coord(sequence_coordinate).units.title( 

2347 cubes.coord(sequence_coordinate).points[0] 

2348 ) 

2349 

2350 if cube.attributes.locals["method"] == "centile": 

2351 method = cube.attributes.locals["method"] 

2352 centile = cube.attributes.locals["centile"] 

2353 plot_filename_with_sequence_coord = f"{cube.name()}_{sequence_coordinate}_point_{str(i)}_{method}_{centile}_{plot_filename}" 

2354 plot_title_with_time = f"{cubes.name()} vs {series_coordinate} ({sequence_coordinate}: {sequence_point}) \n method: Centile | centile: {centile}" 

2355 elif cube.attributes.locals["method"] == "threshold": 

2356 method = cube.attributes.locals["method"] 

2357 threshold = cube.attributes.locals["threshold"] 

2358 plot_filename_with_sequence_coord = f"{cube.name()}_{sequence_coordinate}_point_{str(i)}_{method}_{threshold}_{plot_filename}" 

2359 plot_title_with_time = f"{cubes.name()} vs {series_coordinate} ({sequence_coordinate}: {sequence_point}) \n method: Threshold | threshold: {threshold}" 

2360 

2361 cubes_in = iter_maybe(cubes) 

2362 _plot_and_save_line_series( 

2363 cubes_in, 

2364 coords, 

2365 plot_filename_with_sequence_coord, 

2366 plot_title_with_time, 

2367 sequence_coord=sequence_coordinate, 

2368 ) 

2369 

2370 # Add list of plots to plot metadata. 

2371 plot_index = _append_to_plot_index([plot_filename_with_sequence_coord]) 

2372 

2373 # Make a page to display the plots. 

2374 _make_plot_html_page(plot_index) 

2375 

2376 return cube_copy 

2377 

2378 

2379def plot_vertical_line_series( 

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

2381 filename: str = None, 

2382 series_coordinate: str = "model_level_number", 

2383 sequence_coordinate: str = "time", 

2384 # line_coordinate: str = "realization", 

2385 **kwargs, 

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

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

2388 

2389 The Cube or CubeList must be 1D. 

2390 

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

2392 then a sequence of plots will be produced. 

2393 

2394 Parameters 

2395 ---------- 

2396 iris.cube | iris.cube.CubeList 

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

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

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

2400 filename: str, optional 

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

2402 to the recipe name. 

2403 series_coordinate: str, optional 

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

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

2406 for LFRic. Defaults to ``model_level_number``. 

2407 This coordinate must exist in the cube. 

2408 sequence_coordinate: str, optional 

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

2410 This coordinate must exist in the cube. 

2411 

2412 Returns 

2413 ------- 

2414 iris.cube.Cube | iris.cube.CubeList 

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

2416 Plotted data. 

2417 

2418 Raises 

2419 ------ 

2420 ValueError 

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

2422 TypeError 

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

2424 """ 

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

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

2427 

2428 cubes = iter_maybe(cubes) 

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

2430 all_data = [] 

2431 

2432 # Store min/max ranges for x range. 

2433 x_levels = [] 

2434 

2435 num_models = get_num_models(cubes) 

2436 

2437 validate_cube_shape(cubes, num_models) 

2438 

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

2440 coords = [] 

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

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

2443 try: 

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

2445 except iris.exceptions.CoordinateNotFoundError as err: 

2446 raise ValueError( 

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

2448 ) from err 

2449 

2450 try: 

2451 if cube.ndim > 1 or not cube.coords("realization"): 

2452 cube.coord(sequence_coordinate) 

2453 except iris.exceptions.CoordinateNotFoundError as err: 

2454 raise ValueError( 

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

2456 ) from err 

2457 

2458 # Get minimum and maximum from levels information. 

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

2460 if levels is not None: 

2461 x_levels.append(min(levels)) 

2462 x_levels.append(max(levels)) 

2463 else: 

2464 all_data.append(cube.data) 

2465 

2466 if len(x_levels) == 0: 

2467 # Combine all data into a single NumPy array 

2468 combined_data = np.concatenate(all_data) 

2469 

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

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

2472 # sequence and if applicable postage stamp coordinate. 

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

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

2475 else: 

2476 vmin = min(x_levels) 

2477 vmax = max(x_levels) 

2478 

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

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

2481 # necessary) 

2482 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

2483 

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

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

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

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

2488 # or an iris.cube.CubeList. 

2489 plot_index = [] 

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

2491 for cubes_slice in cube_iterables: 

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

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

2494 plot_title, plot_filename = _set_title_and_filename( 

2495 seq_coord, nplot, recipe_title, filename 

2496 ) 

2497 

2498 # Do the actual plotting. 

2499 _plot_and_save_vertical_line_series( 

2500 cubes_slice, 

2501 coords, 

2502 "realization", 

2503 plot_filename, 

2504 series_coordinate, 

2505 title=plot_title, 

2506 vmin=vmin, 

2507 vmax=vmax, 

2508 ) 

2509 plot_index.append(plot_filename) 

2510 

2511 # Add list of plots to plot metadata. 

2512 complete_plot_index = _append_to_plot_index(plot_index) 

2513 

2514 # Make a page to display the plots. 

2515 _make_plot_html_page(complete_plot_index) 

2516 

2517 return cubes 

2518 

2519 

2520def qq_plot( 

2521 cubes: iris.cube.CubeList, 

2522 coordinates: list[str], 

2523 percentiles: list[float], 

2524 model_names: list[str], 

2525 filename: str = None, 

2526 one_to_one: bool = True, 

2527 **kwargs, 

2528) -> iris.cube.CubeList: 

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

2530 

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

2532 collapsed within the operator over all specified coordinates such as 

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

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

2535 

2536 Parameters 

2537 ---------- 

2538 cubes: iris.cube.CubeList 

2539 Two cubes of the same variable with different models. 

2540 coordinate: list[str] 

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

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

2543 the percentile coordinate. 

2544 percent: list[float] 

2545 A list of percentiles to appear in the plot. 

2546 model_names: list[str] 

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

2548 filename: str, optional 

2549 Filename of the plot to write. 

2550 one_to_one: bool, optional 

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

2552 

2553 Raises 

2554 ------ 

2555 ValueError 

2556 When the cubes are not compatible. 

2557 

2558 Notes 

2559 ----- 

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

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

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

2563 compares percentiles of two datasets. This plot does 

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

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

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

2567 

2568 Quantile-quantile plots are valuable for comparing against 

2569 observations and other models. Identical percentiles between the variables 

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

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

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

2573 Wilks 2011 [Wilks2011]_). 

2574 

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

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

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

2578 the extremes. 

2579 

2580 References 

2581 ---------- 

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

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

2584 """ 

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

2586 if len(cubes) != 2: 

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

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

2589 other: Cube = cubes.extract_cube( 

2590 iris.Constraint( 

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

2592 ) 

2593 ) 

2594 

2595 # Get spatial coord names. 

2596 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

2597 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

2598 

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

2600 # This is triggered if either 

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

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

2603 # errors. 

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

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

2606 # for UM and LFRic comparisons. 

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

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

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

2610 # given this dependency on regridding. 

2611 if ( 

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

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

2614 ) or ( 

2615 base.long_name 

2616 in [ 

2617 "eastward_wind_at_10m", 

2618 "northward_wind_at_10m", 

2619 "northward_wind_at_cell_centres", 

2620 "eastward_wind_at_cell_centres", 

2621 "zonal_wind_at_pressure_levels", 

2622 "meridional_wind_at_pressure_levels", 

2623 "potential_vorticity_at_pressure_levels", 

2624 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

2625 ] 

2626 ): 

2627 logging.debug( 

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

2629 ) 

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

2631 

2632 # Extract just common time points. 

2633 base, other = _extract_common_time_points(base, other) 

2634 

2635 # Equalise attributes so we can merge. 

2636 fully_equalise_attributes([base, other]) 

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

2638 

2639 # Collapse cubes. 

2640 base = collapse( 

2641 base, 

2642 coordinate=coordinates, 

2643 method="PERCENTILE", 

2644 additional_percent=percentiles, 

2645 ) 

2646 other = collapse( 

2647 other, 

2648 coordinate=coordinates, 

2649 method="PERCENTILE", 

2650 additional_percent=percentiles, 

2651 ) 

2652 

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

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

2655 title = f"{recipe_title}" 

2656 

2657 if filename is None: 

2658 filename = slugify(recipe_title) 

2659 

2660 # Add file extension. 

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

2662 

2663 # Do the actual plotting on a scatter plot 

2664 _plot_and_save_scatter_plot( 

2665 base, other, plot_filename, title, one_to_one, model_names 

2666 ) 

2667 

2668 # Add list of plots to plot metadata. 

2669 plot_index = _append_to_plot_index([plot_filename]) 

2670 

2671 # Make a page to display the plots. 

2672 _make_plot_html_page(plot_index) 

2673 

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

2675 

2676 

2677def scatter_plot( 

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

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

2680 filename: str = None, 

2681 one_to_one: bool = True, 

2682 **kwargs, 

2683) -> iris.cube.CubeList: 

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

2685 

2686 Both cubes must be 1D. 

2687 

2688 Parameters 

2689 ---------- 

2690 cube_x: Cube | CubeList 

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

2692 cube_y: Cube | CubeList 

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

2694 filename: str, optional 

2695 Filename of the plot to write. 

2696 one_to_one: bool, optional 

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

2698 

2699 Returns 

2700 ------- 

2701 cubes: CubeList 

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

2703 

2704 Raises 

2705 ------ 

2706 ValueError 

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

2708 size. 

2709 TypeError 

2710 If the cube isn't a single cube. 

2711 

2712 Notes 

2713 ----- 

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

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

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

2717 """ 

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

2719 for cube_iter in iter_maybe(cube_x): 

2720 # Check cubes are correct shape. 

2721 cube_iter = check_single_cube(cube_iter) 

2722 if cube_iter.ndim > 1: 

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

2724 

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

2726 for cube_iter in iter_maybe(cube_y): 

2727 # Check cubes are correct shape. 

2728 cube_iter = check_single_cube(cube_iter) 

2729 if cube_iter.ndim > 1: 

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

2731 

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

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

2734 title = f"{recipe_title}" 

2735 

2736 if filename is None: 

2737 filename = slugify(recipe_title) 

2738 

2739 # Add file extension. 

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

2741 

2742 # Do the actual plotting. 

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

2744 

2745 # Add list of plots to plot metadata. 

2746 plot_index = _append_to_plot_index([plot_filename]) 

2747 

2748 # Make a page to display the plots. 

2749 _make_plot_html_page(plot_index) 

2750 

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

2752 

2753 

2754def vector_plot( 

2755 cube_u: iris.cube.Cube, 

2756 cube_v: iris.cube.Cube, 

2757 filename: str = None, 

2758 sequence_coordinate: str = "time", 

2759 **kwargs, 

2760) -> iris.cube.CubeList: 

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

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

2763 

2764 # Cubes must have a matching sequence coordinate. 

2765 try: 

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

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

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

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

2770 raise ValueError( 

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

2772 ) from err 

2773 

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

2775 plot_index = [] 

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

2777 for cube_u_slice, cube_v_slice in zip( 

2778 cube_u.slices_over(sequence_coordinate), 

2779 cube_v.slices_over(sequence_coordinate), 

2780 strict=True, 

2781 ): 

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

2783 seq_coord = cube_u_slice.coord(sequence_coordinate) 

2784 plot_title, plot_filename = _set_title_and_filename( 

2785 seq_coord, nplot, recipe_title, filename 

2786 ) 

2787 

2788 # Do the actual plotting. 

2789 _plot_and_save_vector_plot( 

2790 cube_u_slice, 

2791 cube_v_slice, 

2792 filename=plot_filename, 

2793 title=plot_title, 

2794 method="pcolormesh", 

2795 ) 

2796 plot_index.append(plot_filename) 

2797 

2798 # Add list of plots to plot metadata. 

2799 complete_plot_index = _append_to_plot_index(plot_index) 

2800 

2801 # Make a page to display the plots. 

2802 _make_plot_html_page(complete_plot_index) 

2803 

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

2805 

2806 

2807def plot_histogram_series( 

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

2809 filename: str = None, 

2810 sequence_coordinate: str = "time", 

2811 stamp_coordinate: str = "realization", 

2812 single_plot: bool = False, 

2813 **kwargs, 

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

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

2816 

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

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

2819 functionality to scroll through histograms against time. If a 

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

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

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

2823 

2824 Parameters 

2825 ---------- 

2826 cubes: Cube | iris.cube.CubeList 

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

2828 than the stamp coordinate. 

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

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

2831 filename: str, optional 

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

2833 to the recipe name. 

2834 sequence_coordinate: str, optional 

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

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

2837 slider. 

2838 stamp_coordinate: str, optional 

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

2840 ``"realization"``. 

2841 single_plot: bool, optional 

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

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

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

2845 

2846 Returns 

2847 ------- 

2848 iris.cube.Cube | iris.cube.CubeList 

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

2850 Plotted data. 

2851 

2852 Raises 

2853 ------ 

2854 ValueError 

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

2856 TypeError 

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

2858 """ 

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

2860 

2861 cubes = iter_maybe(cubes) 

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

2863 if filename is None: 

2864 filename = slugify(recipe_title) 

2865 

2866 # Internal plotting function. 

2867 plotting_func = _plot_and_save_histogram_series 

2868 

2869 num_models = get_num_models(cubes) 

2870 

2871 validate_cube_shape(cubes, num_models) 

2872 

2873 # If several histograms are plotted, check sequence_coordinate 

2874 check_sequence_coordinate(cubes, sequence_coordinate) 

2875 

2876 # Get axis minimum and maximum from levels information. 

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

2878 vmin, vmax = _set_axis_range(cubes) 

2879 

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

2881 # single point. If single_plot is True: 

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

2883 # separate postage stamp plots. 

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

2885 # produced per single model only 

2886 if num_models == 1: 

2887 if ( 

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

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

2890 ): 

2891 if single_plot: 

2892 plotting_func = ( 

2893 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

2894 ) 

2895 else: 

2896 plotting_func = _plot_and_save_postage_stamp_histogram_series 

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

2898 else: 

2899 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

2900 

2901 plot_index = [] 

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

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

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

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

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

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

2908 for cube_slice in cube_iterables: 

2909 single_cube = cube_slice 

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

2911 single_cube = cube_slice[0] 

2912 

2913 # Ensure valid stamp coordinate in cube dimensions 

2914 if stamp_coordinate == "realization": 

2915 stamp_coordinate = check_stamp_coordinate(single_cube) 

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

2917 seq_coord = single_cube.coord(sequence_coordinate) 

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

2919 if sequence_coordinate == "realization" and nplot == 1: 

2920 seq_coord = single_cube.coord("time") 

2921 plot_title, plot_filename = _set_title_and_filename( 

2922 seq_coord, nplot, recipe_title, filename 

2923 ) 

2924 

2925 # Do the actual plotting. 

2926 plotting_func( 

2927 cube_slice, 

2928 filename=plot_filename, 

2929 stamp_coordinate=stamp_coordinate, 

2930 title=plot_title, 

2931 vmin=vmin, 

2932 vmax=vmax, 

2933 ) 

2934 plot_index.append(plot_filename) 

2935 

2936 # Add list of plots to plot metadata. 

2937 complete_plot_index = _append_to_plot_index(plot_index) 

2938 

2939 # Make a page to display the plots. 

2940 _make_plot_html_page(complete_plot_index) 

2941 

2942 return cubes 

2943 

2944 

2945def _plot_and_save_postage_stamp_power_spectrum_series( 

2946 cubes: iris.cube.Cube, 

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

2948 stamp_coordinate: str, 

2949 filename: str, 

2950 title: str, 

2951 series_coordinate: str = None, 

2952 **kwargs, 

2953): 

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

2955 

2956 Parameters 

2957 ---------- 

2958 cubes: Cube or CubeList 

2959 Cube or Cubelist of the power spectrum data. 

2960 coords: list[Coord] 

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

2962 stamp_coordinate: str 

2963 Coordinate that becomes different plots. 

2964 filename: str 

2965 Filename of the plot to write. 

2966 title: str 

2967 Plot title. 

2968 series_coordinate: str, optional 

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

2970 

2971 """ 

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

2973 grid_size = int(math.ceil(math.sqrt(len(cubes.coord(stamp_coordinate).points)))) 

2974 

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

2976 model_colors_map = get_model_colors_map(cubes) 

2977 # ax = plt.gca() 

2978 # Make a subplot for each member. 

2979 for member, subplot in zip( 

2980 cubes.slices_over(stamp_coordinate), range(1, grid_size**2 + 1), strict=False 

2981 ): 

2982 ax = plt.subplot(grid_size, grid_size, subplot) 

2983 

2984 # Store min/max ranges. 

2985 y_levels = [] 

2986 

2987 line_marker = None 

2988 line_width = 1 

2989 

2990 for cube in iter_maybe(member): 

2991 xcoord = _select_series_coord(cube, series_coordinate) 

2992 xname = xcoord.points 

2993 

2994 yfield = cube.data # power spectrum 

2995 label = None 

2996 color = "black" 

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

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

2999 color = model_colors_map.get(label) 

3000 

3001 if member.coord(stamp_coordinate).points == [0]: 

3002 ax.plot( 

3003 xname, 

3004 yfield, 

3005 color=color, 

3006 marker=line_marker, 

3007 ls="-", 

3008 lw=line_width, 

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

3010 if len(cube.coord(stamp_coordinate).points) > 1 

3011 else label, 

3012 ) 

3013 # Label with member if part of an ensemble and not the control. 

3014 else: 

3015 ax.plot( 

3016 xname, 

3017 yfield, 

3018 color=color, 

3019 ls="-", 

3020 lw=1.5, 

3021 alpha=0.75, 

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

3023 ) 

3024 

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

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

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

3028 y_levels.append(min(levels)) 

3029 y_levels.append(max(levels)) 

3030 

3031 # Add some labels and tweak the style. 

3032 title = f"{title}" 

3033 ax.set_title(title, fontsize=16) 

3034 

3035 # Set appropriate x-axis label based on coordinate 

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

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

3038 ): 

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

3040 elif series_coordinate == "physical_wavenumber" or ( 3040 ↛ 3045line 3040 didn't jump to line 3045 because the condition on line 3040 was always true

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

3042 ): 

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

3044 else: # frequency or check units 

3045 if hasattr(xcoord, "units") and str(xcoord.units) == "km-1": 

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

3047 else: 

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

3049 

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

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

3052 

3053 # Set log-log scale 

3054 ax.set_xscale("log") 

3055 ax.set_yscale("log") 

3056 

3057 # Add gridlines 

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

3059 # Ientify unique labels for legend 

3060 handles = list( 

3061 { 

3062 label: handle 

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

3064 }.values() 

3065 ) 

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

3067 

3068 ax = plt.gca() 

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

3070 

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

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

3073 plt.close(fig) 

3074 

3075 

3076def _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series( 

3077 cubes: iris.cube.Cube, 

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

3079 stamp_coordinate: str, 

3080 filename: str, 

3081 title: str, 

3082 series_coordinate: str = None, 

3083 **kwargs, 

3084): 

3085 """Plot and save power spectra for ensemble members in single plot. 

3086 

3087 Parameters 

3088 ---------- 

3089 cubes: Cube or CubeList 

3090 Cube or Cubelist of the power spectrum data. 

3091 coords: list[Coord] 

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

3093 stamp_coordinate: str 

3094 Coordinate that becomes different plots. 

3095 filename: str 

3096 Filename of the plot to write. 

3097 title: str 

3098 Plot title. 

3099 series_coordinate: str, optional 

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

3101 

3102 """ 

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

3104 model_colors_map = get_model_colors_map(cubes) 

3105 

3106 line_marker = None 

3107 line_width = 1 

3108 

3109 # Compute ensemble statistics to show spread 

3110 mean_cube = cubes.collapsed(stamp_coordinate, iris.analysis.MEAN) 

3111 min_cube = cubes.collapsed(stamp_coordinate, iris.analysis.MIN) 

3112 max_cube = cubes.collapsed(stamp_coordinate, iris.analysis.MAX) 

3113 

3114 xcoord_global = mean_cube.coord(series_coordinate) 

3115 x_global = xcoord_global.points 

3116 

3117 for i, member in enumerate(cubes.slices_over(stamp_coordinate)): 

3118 xcoord = _select_series_coord(member, series_coordinate) 

3119 xname = xcoord.points 

3120 

3121 yfield = member.data # power spectrum 

3122 color = "black" 

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

3124 label = member.attributes.get("model_name") if i == 0 else None 

3125 color = model_colors_map.get(label) 

3126 

3127 if member.coord(stamp_coordinate).points == [0]: 

3128 ax.plot( 

3129 xname, 

3130 yfield, 

3131 color=color, 

3132 marker=line_marker, 

3133 ls="-", 

3134 lw=line_width, 

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

3136 if len(member.coord(stamp_coordinate).points) > 1 

3137 else label, 

3138 ) 

3139 # Label with member number if part of an ensemble and not the control. 

3140 else: 

3141 ax.plot( 

3142 xname, 

3143 yfield, 

3144 color=color, 

3145 ls="-", 

3146 lw=1.5, 

3147 alpha=0.75, 

3148 label=label, 

3149 ) 

3150 

3151 # Set appropriate x-axis label based on coordinate 

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

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

3154 ): 

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

3156 elif series_coordinate == "physical_wavenumber" or ( 3156 ↛ 3161line 3156 didn't jump to line 3161 because the condition on line 3156 was always true

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

3158 ): 

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

3160 else: # frequency or check units 

3161 if hasattr(xcoord, "units") and str(xcoord.units) == "km-1": 

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

3163 else: 

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

3165 

3166 # Add ensemble spread shading 

3167 ax.fill_between( 

3168 x_global, 

3169 min_cube.data, 

3170 max_cube.data, 

3171 color="grey", 

3172 alpha=0.3, 

3173 label="Ensemble spread", 

3174 ) 

3175 

3176 # Add ensemble mean line 

3177 ax.plot(x_global, mean_cube.data, color="black", lw=1, label="Ensemble mean") 

3178 

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

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

3181 

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

3183 # Set log-log scale 

3184 ax.set_xscale("log") 

3185 ax.set_yscale("log") 

3186 

3187 # Add gridlines 

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

3189 # Identify unique labels for legend 

3190 handles = list( 

3191 { 

3192 label: handle 

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

3194 }.values() 

3195 ) 

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

3197 

3198 # Add a legend 

3199 ax.legend(fontsize=16) 

3200 

3201 # Figure title. 

3202 ax.set_title(title, fontsize=16) 

3203 

3204 # Save the figure to a file 

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

3206 

3207 # Close the figure 

3208 plt.close(fig)