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

1147 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-17 15:05 +0000

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

2# 

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

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

5# You may obtain a copy of the License at 

6# 

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

8# 

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

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

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

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

13# limitations under the License. 

14 

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

16 

17import fcntl 

18import importlib.resources 

19import itertools 

20import json 

21import logging 

22import math 

23import os 

24import sys 

25from typing import Literal 

26 

27import cartopy.crs as ccrs 

28import cartopy.feature as cfeature 

29import iris 

30import iris.coords 

31import iris.cube 

32import iris.exceptions 

33import iris.plot as iplt 

34import matplotlib as mpl 

35import matplotlib.pyplot as plt 

36import numpy as np 

37from cartopy.mpl.geoaxes import GeoAxes 

38from iris.cube import Cube 

39from markdown_it import MarkdownIt 

40from mpl_toolkits.axes_grid1.inset_locator import inset_axes 

41 

42from CSET._common import ( 

43 filename_slugify, 

44 get_recipe_metadata, 

45 iter_maybe, 

46 render_file, 

47 slugify, 

48) 

49from CSET.operators._colormaps import ( 

50 colorbar_map_levels, 

51 get_model_colors_map, 

52) 

53from CSET.operators._utils import ( 

54 calc_array_stats, 

55 check_sequence_coordinate, 

56 check_single_cube, 

57 check_stamp_coordinate, 

58 fully_equalise_attributes, 

59 get_cube_yxcoordname, 

60 get_num_models, 

61 is_transect, 

62 slice_over_maybe, 

63 validate_cube_shape, 

64 validate_cubes_coords, 

65) 

66from CSET.operators.collapse import collapse 

67from CSET.operators.misc import _extract_common_time_points 

68from CSET.operators.regrid import regrid_onto_cube 

69 

70logger = logging.getLogger(__name__) 

71 

72# Use a non-interactive plotting backend. 

73mpl.use("agg") 

74 

75 

76############################ 

77# Private helper functions # 

78############################ 

79 

80 

81def in_sphinx_gallery(): 

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

83 return "sphinx_gallery" in sys.modules 

84 

85 

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

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

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

89 fcntl.flock(fp, fcntl.LOCK_EX) 

90 fp.seek(0) 

91 meta = json.load(fp) 

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

93 complete_plot_index = complete_plot_index + plot_index 

94 meta["plots"] = complete_plot_index 

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

96 os.getenv("DO_CASE_AGGREGATION") 

97 ): 

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

99 fp.seek(0) 

100 fp.truncate() 

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

102 return complete_plot_index 

103 

104 

105def _make_plot_html_page(plots: list): 

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

107 # Debug check that plots actually contains some strings. 

108 assert isinstance(plots[0], str) 

109 

110 # Load HTML template file. 

111 operator_files = importlib.resources.files() 

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

113 

114 # Get some metadata. 

115 meta = get_recipe_metadata() 

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

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

118 

119 # Prepare template variables. 

120 variables = { 

121 "title": title, 

122 "description": description, 

123 "initial_plot": plots[0], 

124 "plots": plots, 

125 "title_slug": slugify(title), 

126 } 

127 

128 # Render template. 

129 html = render_file(template_file, **variables) 

130 

131 # Save completed HTML. 

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

133 fp.write(html) 

134 

135 

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

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

138 

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

140 

141 Parameters 

142 ---------- 

143 figure: 

144 Matplotlib Figure object holding all plot elements. 

145 plot_type: str 

146 String identifier for plot type for logging information. 

147 filename: str 

148 Filename for saved figure. 

149 """ 

150 if not in_sphinx_gallery(): 

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

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

153 plt.close(figure) 

154 

155 

156def _setup_spatial_map( 

157 cube: iris.cube.Cube, 

158 figure, 

159 cmap, 

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

161 subplot: int | None = None, 

162): 

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

164 

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

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

167 

168 Parameters 

169 ---------- 

170 cube: Cube 

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

172 figure: 

173 Matplotlib Figure object holding all plot elements. 

174 cmap: 

175 Matplotlib colormap. 

176 grid_size: (int, int), optional 

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

178 subplot: int, optional 

179 Subplot index if multiple spatial subplots in figure. 

180 

181 Returns 

182 ------- 

183 axes: 

184 Matplotlib GeoAxes definition. 

185 """ 

186 # Identify min/max plot bounds. 

187 try: 

188 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

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

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

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

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

193 

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

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

196 xmin = xmin - 180.0 

197 xmax = xmax - 180.0 

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

199 

200 # Consider map projection orientation. 

201 # Adapting orientation enables plotting across international dateline. 

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

203 if xmax > 180.0 or xmin < -180.0: 

204 central_longitude = 180.0 

205 else: 

206 central_longitude = 0.0 

207 

208 # Define spatial map projection. 

209 coord_system = cube.coord(lat_axis).coord_system 

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

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

212 projection = ccrs.RotatedPole( 

213 pole_longitude=coord_system.grid_north_pole_longitude, 

214 pole_latitude=coord_system.grid_north_pole_latitude, 

215 central_rotated_longitude=central_longitude, 

216 ) 

217 crs = projection 

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

219 # Define Transverse Mercator projection for TM inputs. 

220 projection = ccrs.TransverseMercator( 

221 central_longitude=coord_system.longitude_of_central_meridian, 

222 central_latitude=coord_system.latitude_of_projection_origin, 

223 false_easting=coord_system.false_easting, 

224 false_northing=coord_system.false_northing, 

225 scale_factor=coord_system.scale_factor_at_central_meridian, 

226 ) 

227 crs = projection 

228 else: 

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

230 if ymin > 20.0 and ymax > 80.0: 

231 projection = ccrs.NorthPolarStereo(central_longitude=0.0) 

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

233 projection = ccrs.SouthPolarStereo(central_longitude=central_longitude) 

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

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

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

237 # projection = ccrs.NearsidePerspective( 

238 # central_longitude=180.0, 

239 # central_latitude=0, 

240 # satellite_height=35785831, 

241 # ) 

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

243 else: 

244 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

245 crs = ccrs.PlateCarree() 

246 

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

248 if subplot is not None: 

249 axes = figure.add_subplot( 

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

251 ) 

252 else: 

253 axes = figure.add_subplot(projection=projection) 

254 

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

256 # Avoid adding lines for specific fixed ancillary spatial plots 

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

258 pass 

259 else: 

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

261 coastcol = "magenta" 

262 else: 

263 coastcol = "black" 

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

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

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

267 

268 # Add gridlines. 

269 gl = axes.gridlines( 

270 alpha=0.3, 

271 draw_labels=True, 

272 dms=False, 

273 x_inline=False, 

274 y_inline=False, 

275 ) 

276 gl.top_labels = False 

277 gl.right_labels = False 

278 if subplot: 

279 gl.bottom_labels = False 

280 gl.left_labels = False 

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

282 gl.left_labels = True 

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

284 gl.bottom_labels = True 

285 

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

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

288 if isinstance( 

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

290 ): 

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

292 

293 except ValueError: 

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

295 axes = figure.gca() 

296 

297 return axes 

298 

299 

300def _get_plot_resolution() -> int: 

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

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

303 

304 

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

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

307 if use_bounds and seq_coord.has_bounds(): 

308 vals = seq_coord.bounds.flatten() 

309 else: 

310 vals = seq_coord.points 

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

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

313 

314 if start == end: 

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

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

317 else: 

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

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

320 

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

322 if ( 

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

324 and vals[0] == 0 

325 and vals[-1] == 0 

326 ): 

327 sequence_title = "" 

328 sequence_fname = "" 

329 

330 return sequence_title, sequence_fname 

331 

332 

333def _set_title_and_filename( 

334 seq_coord: iris.coords.Coord, 

335 nplot: int, 

336 recipe_title: str, 

337 filename: str, 

338 model_name: str | None = None, 

339): 

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

341 

342 Parameters 

343 ---------- 

344 sequence_coordinate: iris.coords.Coord 

345 Coordinate about which to make a plot sequence. 

346 nplot: int 

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

348 recipe_title: str 

349 Default plot title, potentially to update. 

350 filename: str 

351 Input plot filename, potentially to update. 

352 

353 Returns 

354 ------- 

355 plot_title: str 

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

357 plot_filename: str 

358 Output formatted plot filename string. 

359 """ 

360 ndim = seq_coord.ndim 

361 npoints = np.size(seq_coord.points) 

362 sequence_title = "" 

363 sequence_fname = "" 

364 

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

366 # (e.g. aggregation histogram plots) 

367 if ndim > 1: 

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

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

370 sequence_fname = f"_{ncase}cases" 

371 

372 # Case 2: Single dimension input 

373 else: 

374 # Single sequence point 

375 if npoints == 1: 

376 if nplot > 1: 

377 # Default labels for sequence inputs 

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

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

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

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

382 else: 

383 # Aggregated attribute available where input collapsed over aggregation 

384 try: 

385 ncase = seq_coord.attributes["number_reference_times"] 

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

387 sequence_fname = f"_{ncase}cases" 

388 except KeyError: 

389 sequence_title, sequence_fname = _get_start_end_strings( 

390 seq_coord, use_bounds=seq_coord.has_bounds() 

391 ) 

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

393 else: 

394 sequence_title, sequence_fname = _get_start_end_strings( 

395 seq_coord, use_bounds=False 

396 ) 

397 

398 # Set plot title and filename 

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

400 

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

402 if filename is None: 

403 filename = slugify(recipe_title) 

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

405 else: 

406 if nplot > 1: 

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

408 else: 

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

410 

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

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

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

414 

415 return plot_title, plot_filename 

416 

417 

418def _select_series_coord(cube, series_coordinate): 

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

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

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

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

423 fallbacks = [series_coordinate] + [ 

424 c for c in spacing_coordinates if c != series_coordinate 

425 ] 

426 else: 

427 fallbacks = {series_coordinate} 

428 

429 # Try each possible coordinate. 

430 for coord in fallbacks: 

431 try: 

432 return cube.coord(coord) 

433 except iris.exceptions.CoordinateNotFoundError: 

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

435 

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

437 raise iris.exceptions.CoordinateNotFoundError( 

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

439 f"or fallback options {fallbacks}" 

440 ) 

441 

442 

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

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

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

446 mtitle = "Member" 

447 else: 

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

449 

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

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

452 else: 

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

454 

455 return mtitle 

456 

457 

458def _set_axis_range(cubes): 

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

460 levels = None 

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

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

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

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

465 if levels is None: 

466 break 

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

468 # levels-based ranges for histogram plots. 

469 _, levels, _ = colorbar_map_levels(cube) 

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

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

472 vmin = min(levels) 

473 vmax = max(levels) 

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

475 break 

476 

477 if levels is None: 

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

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

480 

481 return vmin, vmax 

482 

483 

484def _find_matched_slices(cubes, sequence_coordinate): 

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

486 

487 Ensures common points are compared for multiple cube inputs. 

488 """ 

489 all_points = sorted( 

490 set( 

491 itertools.chain.from_iterable( 

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

493 ) 

494 ) 

495 ) 

496 all_slices = list( 

497 itertools.chain.from_iterable( 

498 cb.slices_over(sequence_coordinate) for cb in cubes 

499 ) 

500 ) 

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

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

503 # necessary) 

504 cube_iterables = [ 

505 iris.cube.CubeList( 

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

507 ) 

508 for point in all_points 

509 ] 

510 

511 return cube_iterables 

512 

513 

514def _plot_and_save_spatial_plot( 

515 cube: iris.cube.Cube, 

516 filename: str, 

517 title: str, 

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

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

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

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

522 **kwargs, 

523): 

524 """Plot and save a spatial plot. 

525 

526 Parameters 

527 ---------- 

528 cube: Cube 

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

530 filename: str 

531 Filename of the plot to write. 

532 title: str 

533 Plot title. 

534 method: "contourf" | "pcolormesh" | "scatter" 

535 The plotting method to use 

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

537 overlay_cube: Cube, optional 

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

539 contour_cube: Cube, optional 

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

541 point_cube: Cube, optional 

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

543 """ 

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

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

546 

547 # Specify the color bar 

548 cmap, levels, norm = colorbar_map_levels(cube) 

549 

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

551 cmap.set_under("white") 

552 

553 # If overplotting, set required colorbars 

554 if overlay_cube: 

555 over_cmap, over_levels, over_norm = colorbar_map_levels(overlay_cube) 

556 if contour_cube: 

557 cntr_cmap, cntr_levels, cntr_norm = colorbar_map_levels(contour_cube) 

558 

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

560 axes = _setup_spatial_map(cube, fig, cmap) 

561 

562 # Set colorscale bounds 

563 try: 

564 vmin = min(levels) 

565 vmax = max(levels) 

566 except TypeError: 

567 vmin, vmax = None, None 

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

569 if norm is not None: 

570 vmin = None 

571 vmax = None 

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

573 

574 # Plot the field. 

575 if method == "contourf": 

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

577 elif method == "pcolormesh": 

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

579 elif method == "scatter": 

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

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

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

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

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

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

586 # proportion to the area of the figure. 

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

588 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

589 plot = iplt.scatter( 

590 cube.coord(lon_axis), 

591 cube.coord(lat_axis), 

592 c=cube.data[:], 

593 s=mrk_size, 

594 cmap=cmap, 

595 edgecolors="k", 

596 norm=norm, 

597 vmin=vmin, 

598 vmax=vmax, 

599 ) 

600 else: 

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

602 

603 # Overplot overlay field, if required 

604 if overlay_cube: 

605 try: 

606 over_vmin = min(over_levels) 

607 over_vmax = max(over_levels) 

608 except TypeError: 

609 over_vmin, over_vmax = None, None 

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

611 over_vmin = None 

612 over_vmax = None 

613 overlay = iplt.pcolormesh( 

614 overlay_cube, 

615 cmap=over_cmap, 

616 norm=over_norm, 

617 alpha=0.8, 

618 vmin=over_vmin, 

619 vmax=over_vmax, 

620 ) 

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

622 if contour_cube: 

623 contour = iplt.contour( 

624 contour_cube, 

625 colors="darkgray", 

626 levels=cntr_levels, 

627 norm=cntr_norm, 

628 alpha=0.5, 

629 linestyles="--", 

630 linewidths=1, 

631 ) 

632 plt.clabel(contour) 

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

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

635 if point_cube: 

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

637 lat_axis, lon_axis = get_cube_yxcoordname(point_cube) 

638 lon_coord = point_cube.coord(lon_axis) 

639 lat_coord = point_cube.coord(lat_axis) 

640 valid = ~point_cube.data.mask 

641 valid_lon = iris.coords.AuxCoord( 

642 lon_coord.points[valid], 

643 standard_name=lon_coord.standard_name, 

644 units=lon_coord.units, 

645 coord_system=lon_coord.coord_system, 

646 ) 

647 valid_lat = iris.coords.AuxCoord( 

648 lat_coord.points[valid], 

649 standard_name=lat_coord.standard_name, 

650 units=lat_coord.units, 

651 coord_system=lat_coord.coord_system, 

652 ) 

653 iplt.scatter( 

654 valid_lon, 

655 valid_lat, 

656 c=point_cube.data[valid], 

657 s=mrk_size, 

658 cmap=cmap, 

659 edgecolors="k", 

660 norm=norm, 

661 vmin=vmin, 

662 vmax=vmax, 

663 ) 

664 

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

666 if is_transect(cube): 

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

668 axes.invert_yaxis() 

669 axes.set_yscale("log") 

670 axes.set_ylim(1100, 100) 

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

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

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

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

675 ): 

676 axes.set_yscale("log") 

677 

678 axes.set_title( 

679 f"{title}\n" 

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

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

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

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

684 fontsize=16, 

685 ) 

686 

687 # Inset code 

688 axins = inset_axes( 

689 axes, 

690 width="20%", 

691 height="20%", 

692 loc="upper right", 

693 axes_class=GeoAxes, 

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

695 ) 

696 

697 # Slightly transparent to reduce plot blocking. 

698 axins.patch.set_alpha(0.4) 

699 

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

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

702 

703 SLat, SLon, ELat, ELon = ( 

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

705 ) 

706 

707 # Draw line between them 

708 axins.plot( 

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

710 ) 

711 

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

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

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

715 

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

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

718 

719 # Midpoints 

720 lon_mid = (lon_min + lon_max) / 2 

721 lat_mid = (lat_min + lat_max) / 2 

722 

723 # Maximum half-range 

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

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

726 half_range = 1 

727 

728 # Set square extent 

729 axins.set_extent( 

730 [ 

731 lon_mid - half_range, 

732 lon_mid + half_range, 

733 lat_mid - half_range, 

734 lat_mid + half_range, 

735 ], 

736 crs=ccrs.PlateCarree(), 

737 ) 

738 

739 # Ensure square aspect 

740 axins.set_aspect("equal") 

741 

742 else: 

743 # Add title. 

744 axes.set_title(title, fontsize=16) 

745 

746 # Adjust padding if spatial plot or transect 

747 if is_transect(cube): 

748 yinfopad = -0.1 

749 ycbarpad = 0.1 

750 else: 

751 yinfopad = 0.01 

752 ycbarpad = 0.042 

753 

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

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

756 cube_min, cube_max, cube_mean = calc_array_stats(cube.data) 

757 axes.annotate( 

758 f"Min: {cube_min:.3g} Max: {cube_max:.3g} Mean: {cube_mean:.3g}", 

759 xy=(0.025, yinfopad), 

760 xycoords="axes fraction", 

761 xytext=(-5, 5), 

762 textcoords="offset points", 

763 ha="left", 

764 va="bottom", 

765 size=11, 

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

767 ) 

768 

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

770 if overlay_cube: 

771 cbarB = fig.colorbar( 

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

773 ) 

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

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

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

777 cbarB.set_ticks(over_levels) 

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

779 if any( 

780 var in overlay_cube.name() 

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

782 ): 

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

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

785 

786 # Add main colour bar. 

787 cbar = fig.colorbar( 

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

789 ) 

790 

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

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

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

794 cbar.set_ticks(levels) 

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

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

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

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

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

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

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

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

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

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

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

806 cbar.minorticks_off() 

807 cbar.set_ticks(tick_levels) 

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

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

810 # Tick labels for model rainfall data. 

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

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

813 # Tick labels for Nimrod weights data. 

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

815 

816 # Save plot. 

817 _save_close_figure(fig, "spatial", filename) 

818 

819 

820def _plot_and_save_postage_stamp_spatial_plot( 

821 cube: iris.cube.Cube, 

822 filename: str, 

823 stamp_coordinate: str, 

824 title: str, 

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

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

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

828 **kwargs, 

829): 

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

831 

832 Parameters 

833 ---------- 

834 cube: Cube 

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

836 filename: str 

837 Filename of the plot to write. 

838 stamp_coordinate: str 

839 Coordinate that becomes different plots. 

840 method: "contourf" | "pcolormesh" 

841 The plotting method to use. 

842 overlay_cube: Cube, optional 

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

844 contour_cube: Cube, optional 

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

846 

847 Raises 

848 ------ 

849 ValueError 

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

851 """ 

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

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

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

855 grid_size = math.ceil(nmember / grid_rows) 

856 

857 fig = plt.figure( 

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

859 ) 

860 

861 # Specify the color bar 

862 cmap, levels, norm = colorbar_map_levels(cube) 

863 # If overplotting, set required colorbars 

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

865 over_cmap, over_levels, over_norm = colorbar_map_levels(overlay_cube) 

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

867 cntr_cmap, cntr_levels, cntr_norm = colorbar_map_levels(contour_cube) 

868 

869 # Make a subplot for each member. 

870 for member, subplot in zip( 

871 cube.slices_over(stamp_coordinate), 

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

873 strict=False, 

874 ): 

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

876 axes = _setup_spatial_map( 

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

878 ) 

879 if method == "contourf": 

880 # Filled contour plot of the field. 

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

882 elif method == "pcolormesh": 

883 if levels is not None: 

884 vmin = min(levels) 

885 vmax = max(levels) 

886 else: 

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

888 vmin, vmax = None, None 

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

890 # if levels are defined. 

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

892 vmin = None 

893 vmax = None 

894 # pcolormesh plot of the field. 

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

896 else: 

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

898 

899 # Overplot overlay field, if required 

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

901 try: 

902 over_vmin = min(over_levels) 

903 over_vmax = max(over_levels) 

904 except TypeError: 

905 over_vmin, over_vmax = None, None 

906 if over_norm is not None: 

907 over_vmin = None 

908 over_vmax = None 

909 iplt.pcolormesh( 

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

911 cmap=over_cmap, 

912 norm=over_norm, 

913 alpha=0.6, 

914 vmin=over_vmin, 

915 vmax=over_vmax, 

916 ) 

917 # Overplot contour field, if required 

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

919 iplt.contour( 

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

921 colors="darkgray", 

922 levels=cntr_levels, 

923 norm=cntr_norm, 

924 alpha=0.6, 

925 linestyles="--", 

926 linewidths=1, 

927 ) 

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

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

930 

931 # Put the shared colorbar in its own axes. 

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

933 colorbar = fig.colorbar( 

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

935 ) 

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

937 

938 # Overall figure title. 

939 fig.suptitle(title, fontsize=16) 

940 

941 # Save plot. 

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

943 

944 

945def _plot_and_save_line_series( 

946 cubes: iris.cube.CubeList, 

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

948 ensemble_coord: str, 

949 filename: str, 

950 title: str, 

951 **kwargs, 

952): 

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

954 

955 Parameters 

956 ---------- 

957 cubes: Cube or CubeList 

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

959 coords: list[Coord] 

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

961 ensemble_coord: str 

962 Ensemble coordinate in the cube. 

963 filename: str 

964 Filename of the plot to write. 

965 title: str 

966 Plot title. 

967 """ 

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

969 

970 model_colors_map = get_model_colors_map(cubes) 

971 

972 # Store min/max ranges. 

973 y_levels = [] 

974 

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

976 validate_cubes_coords(cubes, coords) 

977 

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

979 label = None 

980 color = "black" 

981 if model_colors_map: 

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

983 color = model_colors_map.get(label) 

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

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

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

987 else: 

988 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

991 iplt.plot( 

992 coord, 

993 cube_slice, 

994 color=color, 

995 marker="o", 

996 ls="-", 

997 lw=3, 

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

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

1000 else label, 

1001 ) 

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

1003 else: 

1004 iplt.plot( 

1005 coord, 

1006 cube_slice, 

1007 color=color, 

1008 ls="-", 

1009 lw=1.5, 

1010 alpha=0.75, 

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

1012 ) 

1013 

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

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

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

1017 y_levels.append(min(levels)) 

1018 y_levels.append(max(levels)) 

1019 

1020 # Get the current axes. 

1021 ax = plt.gca() 

1022 

1023 # Add some labels and tweak the style. 

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

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

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

1027 else: 

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

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

1030 ax.set_title(title, fontsize=16) 

1031 

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

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

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

1035 

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

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

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

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

1040 else: 

1041 ax.autoscale() 

1042 

1043 # Add gridlines 

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

1045 # Add zero line 

1046 ymin, ymax = ax.get_ylim() 

1047 if ymin < 0.0 and ymax > 0.0: 

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

1049 # Identify unique labels for legend 

1050 handles = list( 

1051 { 

1052 label: handle 

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

1054 }.values() 

1055 ) 

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

1057 

1058 # Save plot. 

1059 _save_close_figure(fig, "line", filename) 

1060 

1061 

1062def _plot_and_save_line_power_spectrum_series( 

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

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

1065 ensemble_coord: str, 

1066 filename: str, 

1067 title: str, 

1068 series_coordinate: str, 

1069 **kwargs, 

1070): 

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

1072 

1073 Parameters 

1074 ---------- 

1075 cubes: Cube or CubeList 

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

1077 coords: list[Coord] 

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

1079 ensemble_coord: str 

1080 Ensemble coordinate in the cube. 

1081 filename: str 

1082 Filename of the plot to write. 

1083 title: str 

1084 Plot title. 

1085 series_coordinate: str 

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

1087 """ 

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

1089 model_colors_map = get_model_colors_map(cubes) 

1090 ax = plt.gca() 

1091 

1092 # Store min/max ranges. 

1093 y_levels = [] 

1094 

1095 line_marker = None 

1096 line_width = 1 

1097 

1098 for cube in iter_maybe(cubes): 

1099 # next 2 lines replace chunk of code. 

1100 xcoord = _select_series_coord(cube, series_coordinate) 

1101 xname = xcoord.points 

1102 

1103 yfield = cube.data # power spectrum 

1104 

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

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

1107 # plotting. 

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

1109 yfield = np.zeros_like(yfield) 

1110 

1111 label = None 

1112 color = "black" 

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

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

1115 color = model_colors_map.get(label) 

1116 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

1119 ax.plot( 

1120 xname, 

1121 yfield, 

1122 color=color, 

1123 marker=line_marker, 

1124 ls="-", 

1125 lw=line_width, 

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

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

1128 else label, 

1129 ) 

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

1131 else: 

1132 ax.plot( 

1133 xname, 

1134 yfield, 

1135 color=color, 

1136 ls="-", 

1137 lw=1.5, 

1138 alpha=0.75, 

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

1140 ) 

1141 

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

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

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

1145 y_levels.append(min(levels)) 

1146 y_levels.append(max(levels)) 

1147 

1148 # Add some labels and tweak the style. 

1149 

1150 title = f"{title}" 

1151 ax.set_title(title, fontsize=16) 

1152 

1153 # Set appropriate x-axis label based on coordinate 

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

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

1156 ): 

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

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

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

1160 ): 

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

1162 else: # frequency or check units 

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

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

1165 else: 

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

1167 

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

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

1170 

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

1172 

1173 # Set log-log scale 

1174 ax.set_xscale("log") 

1175 ax.set_yscale("log") 

1176 

1177 # Add gridlines 

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

1179 # Ientify unique labels for legend 

1180 handles = list( 

1181 { 

1182 label: handle 

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

1184 }.values() 

1185 ) 

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

1187 

1188 # Save plot. 

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

1190 

1191 

1192def _plot_and_save_vertical_line_series( 

1193 cubes: iris.cube.CubeList, 

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

1195 ensemble_coord: str, 

1196 filename: str, 

1197 series_coordinate: str, 

1198 title: str, 

1199 vmin: float, 

1200 vmax: float, 

1201 **kwargs, 

1202): 

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

1204 

1205 Parameters 

1206 ---------- 

1207 cubes: CubeList 

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

1209 coord: list[Coord] 

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

1211 ensemble_coord: str 

1212 Ensemble coordinate in the cube. 

1213 filename: str 

1214 Filename of the plot to write. 

1215 series_coordinate: str 

1216 Coordinate to use as vertical axis. 

1217 title: str 

1218 Plot title. 

1219 vmin: float 

1220 Minimum value for the x-axis. 

1221 vmax: float 

1222 Maximum value for the x-axis. 

1223 """ 

1224 # plot the vertical pressure axis using log scale 

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

1226 

1227 model_colors_map = get_model_colors_map(cubes) 

1228 

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

1230 validate_cubes_coords(cubes, coords) 

1231 

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

1233 label = None 

1234 color = "black" 

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

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

1237 color = model_colors_map.get(label) 

1238 

1239 for cube_slice in cube.slices_over(ensemble_coord): 

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

1241 # unless single forecast. 

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

1243 iplt.plot( 

1244 cube_slice, 

1245 coord, 

1246 color=color, 

1247 marker="o", 

1248 ls="-", 

1249 lw=3, 

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

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

1252 else label, 

1253 ) 

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

1255 else: 

1256 iplt.plot( 

1257 cube_slice, 

1258 coord, 

1259 color=color, 

1260 ls="-", 

1261 lw=1.5, 

1262 alpha=0.75, 

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

1264 ) 

1265 

1266 # Get the current axis 

1267 ax = plt.gca() 

1268 

1269 # Special handling for pressure level data. 

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

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

1272 ax.invert_yaxis() 

1273 ax.set_yscale("log") 

1274 

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

1276 y_tick_labels = [ 

1277 "1000", 

1278 "850", 

1279 "700", 

1280 "500", 

1281 "300", 

1282 "200", 

1283 "100", 

1284 ] 

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

1286 

1287 # Set y-axis limits and ticks. 

1288 ax.set_ylim(1100, 100) 

1289 

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

1291 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

1297 

1298 ax.set_yticks(y_ticks) 

1299 ax.set_yticklabels(y_tick_labels) 

1300 

1301 # Set x-axis limits. 

1302 ax.set_xlim(vmin, vmax) 

1303 # Mark y=0 if present in plot. 

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

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

1306 

1307 # Add some labels and tweak the style. 

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

1309 ax.set_xlabel( 

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

1311 ) 

1312 ax.set_title(title, fontsize=16) 

1313 ax.ticklabel_format(axis="x") 

1314 ax.tick_params(axis="y") 

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

1316 

1317 # Add gridlines 

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

1319 # Ientify unique labels for legend 

1320 handles = list( 

1321 { 

1322 label: handle 

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

1324 }.values() 

1325 ) 

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

1327 

1328 # Save plot. 

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

1330 

1331 

1332def _plot_and_save_scatter_plot( 

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

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

1335 filename: str, 

1336 title: str, 

1337 one_to_one: bool, 

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

1339 **kwargs, 

1340): 

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

1342 

1343 Parameters 

1344 ---------- 

1345 cube_x: Cube | CubeList 

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

1347 cube_y: Cube | CubeList 

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

1349 filename: str 

1350 Filename of the plot to write. 

1351 title: str 

1352 Plot title. 

1353 one_to_one: bool 

1354 Whether a 1:1 line is plotted. 

1355 """ 

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

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

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

1359 # over the pairs simultaneously. 

1360 

1361 # Ensure cube_x and cube_y are iterable 

1362 cube_x_iterable = iter_maybe(cube_x) 

1363 cube_y_iterable = iter_maybe(cube_y) 

1364 

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

1366 iplt.scatter(cube_x_iter, cube_y_iter) 

1367 if one_to_one is True: 

1368 plt.plot( 

1369 [ 

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

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

1372 ], 

1373 [ 

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

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

1376 ], 

1377 "k", 

1378 linestyle="--", 

1379 ) 

1380 ax = plt.gca() 

1381 

1382 # Add some labels and tweak the style. 

1383 if model_names is None: 

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

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

1386 else: 

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

1388 ax.set_xlabel( 

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

1390 ) 

1391 ax.set_ylabel( 

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

1393 ) 

1394 ax.set_title(title, fontsize=16) 

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

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

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

1398 ax.autoscale() 

1399 

1400 # Save plot. 

1401 _save_close_figure(fig, "scatter", filename) 

1402 

1403 

1404def _plot_and_save_vector_plot( 

1405 cube_u: iris.cube.Cube, 

1406 cube_v: iris.cube.Cube, 

1407 filename: str, 

1408 title: str, 

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

1410 **kwargs, 

1411): 

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

1413 

1414 Parameters 

1415 ---------- 

1416 cube_u: Cube 

1417 2 dimensional Cube of u component of the data. 

1418 cube_v: Cube 

1419 2 dimensional Cube of v component of the data. 

1420 filename: str 

1421 Filename of the plot to write. 

1422 title: str 

1423 Plot title. 

1424 """ 

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

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

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

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

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

1430 cube_vec_mag.rename( 

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

1432 ) 

1433 

1434 # Specify the color bar 

1435 cmap, levels, norm = colorbar_map_levels(cube_vec_mag) 

1436 

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

1438 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

1439 

1440 if method == "contourf": 

1441 # Filled contour plot of the field. 

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

1443 elif method == "pcolormesh": 

1444 try: 

1445 vmin = min(levels) 

1446 vmax = max(levels) 

1447 except TypeError: 

1448 vmin, vmax = None, None 

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

1450 # if levels are defined. 

1451 if norm is not None: 

1452 vmin = None 

1453 vmax = None 

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

1455 else: 

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

1457 

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

1459 if is_transect(cube_vec_mag): 

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

1461 axes.invert_yaxis() 

1462 axes.set_yscale("log") 

1463 axes.set_ylim(1100, 100) 

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

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

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

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

1468 ): 

1469 axes.set_yscale("log") 

1470 

1471 axes.set_title( 

1472 f"{title}\n" 

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

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

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

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

1477 fontsize=16, 

1478 ) 

1479 

1480 else: 

1481 # Add title. 

1482 axes.set_title(title, fontsize=16) 

1483 

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

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

1486 cube_min, cube_max, cube_mean = calc_array_stats(cube_vec_mag.data) 

1487 axes.annotate( 

1488 f"Min: {cube_min:.3g} Max: {cube_max:.3g} Mean: {cube_mean:.3g}", 

1489 xy=(0.05, -0.05), 

1490 xycoords="axes fraction", 

1491 xytext=(-5, 5), 

1492 textcoords="offset points", 

1493 ha="right", 

1494 va="bottom", 

1495 size=11, 

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

1497 ) 

1498 

1499 # Add colour bar. 

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

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

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

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

1504 cbar.set_ticks(levels) 

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

1506 

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

1508 # with less than 30 points. 

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

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

1511 

1512 # Save plot. 

1513 _save_close_figure(fig, "vector", filename) 

1514 

1515 

1516def _plot_and_save_histogram_series( 

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

1518 filename: str, 

1519 title: str, 

1520 vmin: float, 

1521 vmax: float, 

1522 **kwargs, 

1523): 

1524 """Plot and save a histogram series. 

1525 

1526 Parameters 

1527 ---------- 

1528 cubes: Cube or CubeList 

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

1530 filename: str 

1531 Filename of the plot to write. 

1532 title: str 

1533 Plot title. 

1534 vmin: float 

1535 minimum for colorbar 

1536 vmax: float 

1537 maximum for colorbar 

1538 """ 

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

1540 ax = plt.gca() 

1541 

1542 model_colors_map = get_model_colors_map(cubes) 

1543 

1544 # Set default that histograms will produce probability density function 

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

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

1547 density = False 

1548 else: 

1549 density = True 

1550 

1551 for cube in iter_maybe(cubes): 

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

1553 # than seeing if long names exist etc. 

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

1555 if ( 

1556 ("surface_microphysical" in title) 

1557 or ("rain accumulation" in title) 

1558 or ("Rainfall rate Composite" in title) 

1559 or ("Nimrod_5min" in title) 

1560 ): 

1561 if "amount" in title: 

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

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

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

1565 density = False 

1566 else: 

1567 bins = 10.0 ** ( 

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

1569 ) # Suggestion from RMED toolbox. 

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

1571 ax.set_yscale("log") 

1572 vmin = bins[1] 

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

1574 ax.set_xscale("log") 

1575 elif "lightning" in title: 

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

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

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

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

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

1581 # for effective radius, rather than being hard coded 

1582 # Modified from RMED toolbox 

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

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

1585 vmin = bins[1] 

1586 vmax = bins[-1] 

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

1588 # From RMED toolbox 

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

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

1591 vmin = bins[1] 

1592 vmax = bins[-1] 

1593 else: 

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

1595 logger.debug( 

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

1597 np.size(bins), 

1598 np.min(bins), 

1599 np.max(bins), 

1600 ) 

1601 

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

1603 ax.set_yscale("log") 

1604 ax.set_xscale("log") 

1605 

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

1607 # Otherwise we plot xdim histograms stacked. 

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

1609 

1610 label = None 

1611 color = "black" 

1612 if model_colors_map: 

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

1614 color = model_colors_map[label] 

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

1616 

1617 # Compute area under curve. 

1618 if ( 

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

1620 or ("rain_accumulation" in title) 

1621 or ("Rainfall rate Composite" in title) 

1622 or ("Nimrod_5min" in title) 

1623 ): 

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

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

1626 x = x[1:] 

1627 y = y[1:] 

1628 

1629 ax.plot( 

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

1631 ) 

1632 

1633 # Add some labels and tweak the style. 

1634 ax.set_title(title, fontsize=16) 

1635 ax.set_xlabel( 

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

1637 ) 

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

1639 if ( 

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

1641 or ("rain accumulation" in title) 

1642 or ("Nimrod_5min" in title) 

1643 ): 

1644 ax.set_ylabel( 

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

1646 ) 

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

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

1649 

1650 try: 

1651 ax.set_xlim(vmin, vmax) 

1652 except ValueError: 

1653 pass 

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

1655 

1656 # Overlay grid-lines onto histogram plot. 

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

1658 if model_colors_map: 

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

1660 

1661 # Save plot. 

1662 _save_close_figure(fig, "histogram", filename) 

1663 

1664 

1665def _plot_and_save_postage_stamp_histogram_series( 

1666 cube: iris.cube.Cube, 

1667 filename: str, 

1668 title: str, 

1669 stamp_coordinate: str, 

1670 vmin: float, 

1671 vmax: float, 

1672 **kwargs, 

1673): 

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

1675 

1676 Parameters 

1677 ---------- 

1678 cube: Cube 

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

1680 filename: str 

1681 Filename of the plot to write. 

1682 title: str 

1683 Plot title. 

1684 stamp_coordinate: str 

1685 Coordinate that becomes different plots. 

1686 vmin: float 

1687 minimum for pdf x-axis 

1688 vmax: float 

1689 maximum for pdf x-axis 

1690 """ 

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

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

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

1694 grid_size = math.ceil(nmember / grid_rows) 

1695 

1696 fig = plt.figure( 

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

1698 ) 

1699 # Make a subplot for each member. 

1700 for member, subplot in zip( 

1701 cube.slices_over(stamp_coordinate), 

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

1703 strict=False, 

1704 ): 

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

1706 # cartopy GeoAxes generated. 

1707 plt.subplot(grid_rows, grid_size, subplot) 

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

1709 # Otherwise we plot xdim histograms stacked. 

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

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

1712 axes = plt.gca() 

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

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

1715 axes.set_xlim(vmin, vmax) 

1716 

1717 # Overall figure title. 

1718 fig.suptitle(title, fontsize=16) 

1719 

1720 # Save plot. 

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

1722 

1723 

1724def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1725 cube: iris.cube.Cube, 

1726 filename: str, 

1727 title: str, 

1728 stamp_coordinate: str, 

1729 vmin: float, 

1730 vmax: float, 

1731 **kwargs, 

1732): 

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

1734 ax.set_title(title, fontsize=16) 

1735 ax.set_xlim(vmin, vmax) 

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

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

1738 # Loop over all slices along the stamp_coordinate 

1739 for member in cube.slices_over(stamp_coordinate): 

1740 # Flatten the member data to 1D 

1741 member_data_1d = member.data.flatten() 

1742 # Plot the histogram using plt.hist 

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

1744 plt.hist( 

1745 member_data_1d, 

1746 density=True, 

1747 stacked=True, 

1748 label=f"{mtitle}", 

1749 ) 

1750 

1751 # Add a legend 

1752 ax.legend(fontsize=16) 

1753 

1754 # Save plot. 

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

1756 

1757 

1758def _plot_and_save_scatter_series( 

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

1760 filename: str, 

1761 title: str, 

1762 vmin: float, 

1763 vmax: float, 

1764 hexbin: bool, 

1765 **kwargs, 

1766): 

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

1768 

1769 Parameters 

1770 ---------- 

1771 cubes: Cube or CubeList 

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

1773 filename: str 

1774 Filename of the plot to write. 

1775 title: str 

1776 Plot title. 

1777 vmin: float 

1778 minimum for colorbar 

1779 vmax: float 

1780 maximum for colorbar 

1781 hexbin: bool 

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

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

1784 """ 

1785 if hexbin: 

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

1787 if len(cubes) != 2: 

1788 raise ValueError( 

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

1790 ) 

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

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

1793 

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

1795 ax = plt.gca() 

1796 

1797 model_colors_map = get_model_colors_map(cubes) 

1798 

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

1800 percentiles[0] = 1 

1801 percentiles[-1] = 99 

1802 quantiles = iris.cube.CubeList() 

1803 

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

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

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

1807 nplot = 0 

1808 for cube in iter_maybe(cubes): 

1809 label = None 

1810 color = "black" 

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

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

1813 color = model_colors_map[label] 

1814 

1815 # Plot all data points 

1816 if plottype == "points": 

1817 if nplot > 0: 

1818 if hexbin: 

1819 hb = plt.hexbin( 

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

1821 cube.data.flatten(), 

1822 alpha=0.3, 

1823 gridsize=100, 

1824 mincnt=1, 

1825 ) 

1826 else: 

1827 plt.scatter( 

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

1829 cube.data.flatten(), 

1830 color=color, 

1831 marker="+", 

1832 label=None, 

1833 alpha=0.3, 

1834 ) 

1835 

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

1837 # Construct Q-Q plot 

1838 quantiles.append( 

1839 cube.collapsed( 

1840 cube.coords(dim_coords=True), 

1841 iris.analysis.PERCENTILE, 

1842 percent=percentiles, 

1843 ) 

1844 ) 

1845 if nplot > 0: 

1846 iplt.scatter( 

1847 quantiles[0], 

1848 quantiles[-1], 

1849 color=color, 

1850 marker="o", 

1851 label=label, 

1852 edgecolors="black", 

1853 ) 

1854 

1855 nplot = nplot + 1 

1856 

1857 # Add some labels and tweak the style. 

1858 ax.set_title(title, fontsize=16) 

1859 ax.set_xlabel( 

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

1861 ) 

1862 ax.set_ylabel( 

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

1864 ) 

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

1866 ax.autoscale() 

1867 

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

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

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

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

1872 lims = [ 

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

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

1875 ] 

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

1877 ax.set_aspect("equal") 

1878 ax.set_xlim(lims) 

1879 ax.set_ylim(lims) 

1880 

1881 # Overlay grid-lines onto scatter plot. 

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

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

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

1885 

1886 # Add colorbar if hexbin output 

1887 if hexbin: 

1888 cb = plt.colorbar( 

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

1890 ) 

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

1892 

1893 # Save plot. 

1894 _save_close_figure(fig, "scatter", filename) 

1895 

1896 

1897def _spatial_plot( 

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

1899 cube: iris.cube.Cube, 

1900 filename: str | None, 

1901 sequence_coordinate: str, 

1902 stamp_coordinate: str, 

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

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

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

1906 **kwargs, 

1907): 

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

1909 

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

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

1912 is present then postage stamp plots will be produced. 

1913 

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

1915 be overplotted on the same figure. 

1916 

1917 Parameters 

1918 ---------- 

1919 method: "contourf" | "pcolormesh" | "scatter" 

1920 The plotting method to use. 

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

1922 Use "scatter" for point-based data. 

1923 cube: Cube 

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

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

1926 plotted sequentially and/or as postage stamp plots. 

1927 filename: str | None 

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

1929 uses the recipe name. 

1930 sequence_coordinate: str 

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

1932 This coordinate must exist in the cube. 

1933 stamp_coordinate: str 

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

1935 ``"realization"``. 

1936 overlay_cube: Cube | None, optional 

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

1938 contour_cube: Cube | None, optional 

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

1940 point_cube: Cube | None, optional 

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

1942 

1943 Raises 

1944 ------ 

1945 ValueError 

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

1947 TypeError 

1948 If the cube isn't a single cube. 

1949 """ 

1950 # Ensure we've got a single cube. 

1951 cube = check_single_cube(cube) 

1952 

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

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

1955 

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

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

1958 stamp_coordinate = check_stamp_coordinate(cube) 

1959 

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

1961 # single point. 

1962 plotting_func = _plot_and_save_spatial_plot 

1963 try: 

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

1965 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1966 except iris.exceptions.CoordinateNotFoundError: 

1967 pass 

1968 

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

1970 # dimension called observation or model_obs_error 

1971 if any( 

1972 crd.var_name == "station" 

1973 or crd.var_name == "Station_Name" 

1974 or crd.var_name == "model_obs_error" 

1975 for crd in cube.coords() 

1976 ): 

1977 plotting_func = _plot_and_save_spatial_plot 

1978 method = "scatter" 

1979 

1980 # Must have a sequence coordinate. 

1981 try: 

1982 cube.coord(sequence_coordinate) 

1983 except iris.exceptions.CoordinateNotFoundError as err: 

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

1985 

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

1987 plot_index = [] 

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

1989 

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

1991 # Set plot titles and filename 

1992 seq_coord = cube_slice.coord(sequence_coordinate) 

1993 

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

1995 model_name = cube.attributes["model_name"] 

1996 else: 

1997 model_name = None 

1998 

1999 plot_title, plot_filename = _set_title_and_filename( 

2000 seq_coord, nplot, recipe_title, filename, model_name=model_name 

2001 ) 

2002 

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

2004 overlay_slice = slice_over_maybe(overlay_cube, sequence_coordinate, iseq) 

2005 contour_slice = slice_over_maybe(contour_cube, sequence_coordinate, iseq) 

2006 point_slice = slice_over_maybe(point_cube, sequence_coordinate, iseq) 

2007 

2008 # Do the actual plotting. 

2009 plotting_func( 

2010 cube_slice, 

2011 filename=plot_filename, 

2012 stamp_coordinate=stamp_coordinate, 

2013 title=plot_title, 

2014 method=method, 

2015 overlay_cube=overlay_slice, 

2016 contour_cube=contour_slice, 

2017 point_cube=point_slice, 

2018 **kwargs, 

2019 ) 

2020 plot_index.append(plot_filename) 

2021 

2022 # Add list of plots to plot metadata. 

2023 complete_plot_index = _append_to_plot_index(plot_index) 

2024 

2025 # Make a page to display the plots. 

2026 _make_plot_html_page(complete_plot_index) 

2027 

2028 

2029#################### 

2030# Public functions # 

2031#################### 

2032 

2033 

2034def spatial_contour_plot( 

2035 cube: iris.cube.Cube, 

2036 filename: str | None = None, 

2037 sequence_coordinate: str = "time", 

2038 stamp_coordinate: str = "realization", 

2039 **kwargs, 

2040) -> iris.cube.Cube: 

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

2042 

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

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

2045 is present then postage stamp plots will be produced. 

2046 

2047 Parameters 

2048 ---------- 

2049 cube: Cube 

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

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

2052 plotted sequentially and/or as postage stamp plots. 

2053 filename: str, optional 

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

2055 to the recipe name. 

2056 sequence_coordinate: str, optional 

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

2058 This coordinate must exist in the cube. 

2059 stamp_coordinate: str, optional 

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

2061 ``"realization"``. 

2062 

2063 Returns 

2064 ------- 

2065 Cube 

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

2067 

2068 Raises 

2069 ------ 

2070 ValueError 

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

2072 TypeError 

2073 If the cube isn't a single cube. 

2074 """ 

2075 _spatial_plot( 

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

2077 ) 

2078 return cube 

2079 

2080 

2081def spatial_pcolormesh_plot( 

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

2083 filename: str | None = None, 

2084 sequence_coordinate: str = "time", 

2085 stamp_coordinate: str = "realization", 

2086 **kwargs, 

2087) -> iris.cube.Cube: 

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

2089 

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

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

2092 is present then postage stamp plots will be produced. 

2093 

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

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

2096 contour areas are important. 

2097 

2098 Parameters 

2099 ---------- 

2100 cube: Cubes 

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

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

2103 plotted sequentially and/or as postage stamp plots. 

2104 filename: str, optional 

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

2106 to the recipe name. 

2107 sequence_coordinate: str, optional 

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

2109 This coordinate must exist in the cube. 

2110 stamp_coordinate: str, optional 

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

2112 ``"realization"``. 

2113 

2114 Returns 

2115 ------- 

2116 Cubes 

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

2118 

2119 Raises 

2120 ------ 

2121 ValueError 

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

2123 """ 

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

2125 for model_cube in cubes: 

2126 _spatial_plot( 

2127 "pcolormesh", 

2128 model_cube, 

2129 filename, 

2130 sequence_coordinate, 

2131 stamp_coordinate, 

2132 **kwargs, 

2133 ) 

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

2135 _spatial_plot( 

2136 "pcolormesh", 

2137 cubes, 

2138 filename, 

2139 sequence_coordinate, 

2140 stamp_coordinate, 

2141 **kwargs, 

2142 ) 

2143 return cubes 

2144 

2145 

2146def spatial_multi_pcolormesh_plot( 

2147 cube: iris.cube.Cube, 

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

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

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

2151 filename: str | None = None, 

2152 sequence_coordinate: str = "time", 

2153 stamp_coordinate: str = "realization", 

2154 **kwargs, 

2155) -> iris.cube.Cube: 

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

2157 

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

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

2160 is present then postage stamp plots will be produced. 

2161 

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

2163 

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

2165 

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

2167 

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

2169 

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

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

2172 contour areas are important. 

2173 

2174 Parameters 

2175 ---------- 

2176 cube: Cube 

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

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

2179 plotted sequentially and/or as postage stamp plots. 

2180 overlay_cube: Cube, optional 

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

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

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

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

2185 contour_cube: Cube, optional 

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

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

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

2189 point_cube: Cube, optional 

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

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

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

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

2194 filename: str, optional 

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

2196 to the recipe name. 

2197 sequence_coordinate: str, optional 

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

2199 This coordinate must exist in the cube. 

2200 stamp_coordinate: str, optional 

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

2202 ``"realization"``. 

2203 

2204 Returns 

2205 ------- 

2206 Cube 

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

2208 

2209 Raises 

2210 ------ 

2211 ValueError 

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

2213 TypeError 

2214 If the cube isn't a single cube. 

2215 """ 

2216 _spatial_plot( 

2217 "pcolormesh", 

2218 cube, 

2219 filename, 

2220 sequence_coordinate, 

2221 stamp_coordinate, 

2222 overlay_cube=overlay_cube, 

2223 contour_cube=contour_cube, 

2224 point_cube=point_cube, 

2225 ) 

2226 return cube, overlay_cube, contour_cube, point_cube 

2227 

2228 

2229# TODO: Expand function to handle ensemble data. 

2230# line_coordinate: str, optional 

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

2232# ``"realization"``. 

2233def plot_line_series( 

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

2235 filename: str | None = None, 

2236 series_coordinate: str = "time", 

2237 sequence_coordinate: str = "time", 

2238 # add the following for ensembles 

2239 stamp_coordinate: str = "realization", 

2240 single_plot: bool = False, 

2241 **kwargs, 

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

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

2244 

2245 The Cube or CubeList must be 1D. 

2246 

2247 Parameters 

2248 ---------- 

2249 iris.cube | iris.cube.CubeList 

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

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

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

2253 filename: str, optional 

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

2255 to the recipe name. 

2256 series_coordinate: str, optional 

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

2258 coordinate must exist in the cube. 

2259 

2260 Returns 

2261 ------- 

2262 iris.cube.Cube | iris.cube.CubeList 

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

2264 

2265 Raises 

2266 ------ 

2267 ValueError 

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

2269 TypeError 

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

2271 """ 

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

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

2274 

2275 num_models = get_num_models(cube) 

2276 

2277 validate_cube_shape(cube, num_models) 

2278 

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

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

2281 coords = [] 

2282 for model_cube in cubes: 

2283 try: 

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

2285 except iris.exceptions.CoordinateNotFoundError as err: 

2286 raise ValueError( 

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

2288 ) from err 

2289 # Count cube dimensions and exclude realization and 

2290 # forecast_reference_time if they exist. 

2291 ndim = model_cube.ndim 

2292 

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

2294 # returns coord dimension 

2295 realization_dims = model_cube.coord_dims("realization") 

2296 

2297 # Only subtract if realization is a dimension coordinate 

2298 if realization_dims: 

2299 ndim -= len(realization_dims) 

2300 

2301 if model_cube.coords("forecast_reference_time"): 

2302 frt_dims = model_cube.coord_dims("forecast_reference_time") 

2303 

2304 # Only subtract if frt is a dimension coordinate 

2305 if frt_dims: 2305 ↛ 2306line 2305 didn't jump to line 2306 because the condition on line 2305 was never true

2306 ndim -= len(frt_dims) 

2307 

2308 if ndim > 2: 

2309 raise ValueError( 

2310 "Cube must be 1D or 2D (excluding any realization or forecast_reference_time dimensions)." 

2311 ) 

2312 

2313 plot_index = [] 

2314 

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

2316 is_spectral_plot = series_coordinate in [ 

2317 "frequency", 

2318 "physical_wavenumber", 

2319 "wavelength", 

2320 ] 

2321 

2322 if is_spectral_plot: 

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

2324 # coordinate frequency/wavenumber. 

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

2326 # time slider option. 

2327 

2328 # Internal plotting function. 

2329 plotting_func = _plot_and_save_line_power_spectrum_series 

2330 

2331 for model_cube in cubes: 

2332 try: 

2333 model_cube.coord(sequence_coordinate) 

2334 except iris.exceptions.CoordinateNotFoundError as err: 

2335 raise ValueError( 

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

2337 ) from err 

2338 

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

2340 # check for ensembles 

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

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

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

2344 ): 

2345 if single_plot: 

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

2347 plotting_func = _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series 

2348 else: 

2349 # Plot postage stamps 

2350 plotting_func = _plot_and_save_postage_stamp_power_spectrum_series 

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

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

2353 else: 

2354 all_points = sorted( 

2355 set( 

2356 itertools.chain.from_iterable( 

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

2358 ) 

2359 ) 

2360 ) 

2361 all_slices = list( 

2362 itertools.chain.from_iterable( 

2363 cb.slices_over(sequence_coordinate) for cb in cubes 

2364 ) 

2365 ) 

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

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

2368 # necessary) 

2369 cube_iterables = [ 

2370 iris.cube.CubeList( 

2371 s 

2372 for s in all_slices 

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

2374 ) 

2375 for point in all_points 

2376 ] 

2377 nplot = len(all_points) 

2378 

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

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

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

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

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

2384 

2385 for cube_slice in cube_iterables: 

2386 # Normalize cube_slice to a list of cubes 

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

2388 cubes = list(cube_slice) 

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

2390 cubes = [cube_slice] 

2391 else: 

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

2393 

2394 # Use sequence value so multiple sequences can merge. 

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

2396 plot_title, plot_filename = _set_title_and_filename( 

2397 seq_coord, nplot, recipe_title, filename 

2398 ) 

2399 

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

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

2402 

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

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

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

2406 

2407 # Do the actual plotting. 

2408 plotting_func( 

2409 cube_slice, 

2410 coords, 

2411 stamp_coordinate, 

2412 plot_filename, 

2413 title, 

2414 series_coordinate, 

2415 ) 

2416 

2417 plot_index.append(plot_filename) 

2418 else: 

2419 # Format the title and filename using plotted series coordinate 

2420 nplot = 1 

2421 seq_coord = coords[0] 

2422 plot_title, plot_filename = _set_title_and_filename( 

2423 seq_coord, nplot, recipe_title, filename 

2424 ) 

2425 

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

2427 if ( 

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

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

2430 ): 

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

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

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

2434 station_plotname = plot_filename.replace( 

2435 ".png", "_" + station_name + ".png" 

2436 ) 

2437 _plot_and_save_line_series( 

2438 station_cubes, 

2439 coords, 

2440 "realization", 

2441 station_plotname, 

2442 f"{plot_title} {station_name}", 

2443 ) 

2444 plot_index.append(station_plotname) 

2445 

2446 else: 

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

2448 _plot_and_save_line_series( 

2449 cubes, coords, stamp_coordinate, plot_filename, plot_title 

2450 ) 

2451 

2452 plot_index.append(plot_filename) 

2453 

2454 # append plot to list of plots 

2455 complete_plot_index = _append_to_plot_index(plot_index) 

2456 

2457 # Make a page to display the plots. 

2458 _make_plot_html_page(complete_plot_index) 

2459 

2460 return cube 

2461 

2462 

2463def plot_vertical_line_series( 

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

2465 filename: str | None = None, 

2466 series_coordinate: str = "model_level_number", 

2467 sequence_coordinate: str = "time", 

2468 # line_coordinate: str = "realization", 

2469 **kwargs, 

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

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

2472 

2473 The Cube or CubeList must be 1D. 

2474 

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

2476 then a sequence of plots will be produced. 

2477 

2478 Parameters 

2479 ---------- 

2480 iris.cube | iris.cube.CubeList 

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

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

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

2484 filename: str, optional 

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

2486 to the recipe name. 

2487 series_coordinate: str, optional 

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

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

2490 for LFRic. Defaults to ``model_level_number``. 

2491 This coordinate must exist in the cube. 

2492 sequence_coordinate: str, optional 

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

2494 This coordinate must exist in the cube. 

2495 

2496 Returns 

2497 ------- 

2498 iris.cube.Cube | iris.cube.CubeList 

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

2500 Plotted data. 

2501 

2502 Raises 

2503 ------ 

2504 ValueError 

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

2506 TypeError 

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

2508 """ 

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

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

2511 

2512 cubes = iter_maybe(cubes) 

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

2514 all_data = [] 

2515 

2516 # Store min/max ranges for x range. 

2517 x_levels = [] 

2518 

2519 num_models = get_num_models(cubes) 

2520 

2521 validate_cube_shape(cubes, num_models) 

2522 

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

2524 coords = [] 

2525 for cube in cubes: 

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

2527 try: 

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

2529 except iris.exceptions.CoordinateNotFoundError as err: 

2530 raise ValueError( 

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

2532 ) from err 

2533 

2534 try: 

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

2536 cube.coord(sequence_coordinate) 

2537 except iris.exceptions.CoordinateNotFoundError as err: 

2538 raise ValueError( 

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

2540 ) from err 

2541 

2542 # Get minimum and maximum from levels information. 

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

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

2545 x_levels.append(min(levels)) 

2546 x_levels.append(max(levels)) 

2547 else: 

2548 all_data.append(cube.data) 

2549 

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

2551 # Combine all data into a single NumPy array 

2552 combined_data = np.concatenate(all_data) 

2553 

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

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

2556 # sequence and if applicable postage stamp coordinate. 

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

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

2559 else: 

2560 vmin = min(x_levels) 

2561 vmax = max(x_levels) 

2562 

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

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

2565 sequence_coords = [ 

2566 cube.coord(sequence_coordinate) 

2567 for cube in cubes 

2568 if cube.coords(sequence_coordinate) 

2569 ] 

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

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

2572 ) 

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

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

2575 ) 

2576 

2577 plot_index = [] 

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

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

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

2581 # necessary) 

2582 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

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

2584 for cubes_slice in cube_iterables: 

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

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

2587 plot_title, plot_filename = _set_title_and_filename( 

2588 seq_coord, nplot, recipe_title, filename 

2589 ) 

2590 

2591 # Do the actual plotting. 

2592 _plot_and_save_vertical_line_series( 

2593 cubes_slice, 

2594 coords, 

2595 "realization", 

2596 plot_filename, 

2597 series_coordinate, 

2598 title=plot_title, 

2599 vmin=vmin, 

2600 vmax=vmax, 

2601 ) 

2602 plot_index.append(plot_filename) 

2603 elif has_scalar_sequence_coord: 

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

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

2606 plot_title, plot_filename = _set_title_and_filename( 

2607 sequence_coords[0], 1, recipe_title, filename 

2608 ) 

2609 

2610 _plot_and_save_vertical_line_series( 

2611 cubes, 

2612 coords, 

2613 "realization", 

2614 plot_filename, 

2615 series_coordinate, 

2616 title=plot_title, 

2617 vmin=vmin, 

2618 vmax=vmax, 

2619 ) 

2620 plot_index.append(plot_filename) 

2621 else: 

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

2623 plot_title = recipe_title 

2624 if filename: 

2625 plot_filename = filename 

2626 else: 

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

2628 

2629 _plot_and_save_vertical_line_series( 

2630 cubes, 

2631 coords, 

2632 "realization", 

2633 plot_filename, 

2634 series_coordinate, 

2635 title=plot_title, 

2636 vmin=vmin, 

2637 vmax=vmax, 

2638 ) 

2639 plot_index.append(plot_filename) 

2640 

2641 # Add list of plots to plot metadata. 

2642 complete_plot_index = _append_to_plot_index(plot_index) 

2643 

2644 # Make a page to display the plots. 

2645 _make_plot_html_page(complete_plot_index) 

2646 

2647 return cubes 

2648 

2649 

2650def qq_plot( 

2651 cubes: iris.cube.CubeList, 

2652 coordinates: list[str], 

2653 percentiles: list[float], 

2654 model_names: list[str], 

2655 filename: str | None = None, 

2656 one_to_one: bool = True, 

2657 **kwargs, 

2658) -> iris.cube.CubeList: 

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

2660 

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

2662 collapsed within the operator over all specified coordinates such as 

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

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

2665 

2666 Parameters 

2667 ---------- 

2668 cubes: iris.cube.CubeList 

2669 Two cubes of the same variable with different models. 

2670 coordinate: list[str] 

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

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

2673 the percentile coordinate. 

2674 percent: list[float] 

2675 A list of percentiles to appear in the plot. 

2676 model_names: list[str] 

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

2678 filename: str, optional 

2679 Filename of the plot to write. 

2680 one_to_one: bool, optional 

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

2682 

2683 Raises 

2684 ------ 

2685 ValueError 

2686 When the cubes are not compatible. 

2687 

2688 Notes 

2689 ----- 

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

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

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

2693 compares percentiles of two datasets. This plot does 

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

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

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

2697 

2698 Quantile-quantile plots are valuable for comparing against 

2699 observations and other models. Identical percentiles between the variables 

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

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

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

2703 Wilks 2011 [Wilks2011]_). 

2704 

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

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

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

2708 the extremes. 

2709 

2710 """ 

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

2712 if len(cubes) != 2: 

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

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

2715 other: Cube = cubes.extract_cube( 

2716 iris.Constraint( 

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

2718 ) 

2719 ) 

2720 

2721 # Get spatial coord names. 

2722 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

2723 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

2724 

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

2726 # This is triggered if either 

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

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

2729 # errors. 

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

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

2732 # for UM and LFRic comparisons. 

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

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

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

2736 # given this dependency on regridding. 

2737 if ( 

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

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

2740 ) or ( 

2741 base.long_name 

2742 in [ 

2743 "eastward_wind_at_10m", 

2744 "northward_wind_at_10m", 

2745 "northward_wind_at_cell_centres", 

2746 "eastward_wind_at_cell_centres", 

2747 "zonal_wind_at_pressure_levels", 

2748 "meridional_wind_at_pressure_levels", 

2749 "potential_vorticity_at_pressure_levels", 

2750 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

2751 ] 

2752 ): 

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

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

2755 

2756 # Extract just common time points. 

2757 base, other = _extract_common_time_points(base, other) 

2758 

2759 # Equalise attributes so we can merge. 

2760 fully_equalise_attributes([base, other]) 

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

2762 

2763 # Collapse cubes. 

2764 base = collapse( 

2765 base, 

2766 coordinate=coordinates, 

2767 method="PERCENTILE", 

2768 additional_percent=percentiles, 

2769 ) 

2770 other = collapse( 

2771 other, 

2772 coordinate=coordinates, 

2773 method="PERCENTILE", 

2774 additional_percent=percentiles, 

2775 ) 

2776 

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

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

2779 title = f"{recipe_title}" 

2780 

2781 if filename is None: 

2782 filename = slugify(recipe_title) 

2783 

2784 # Add file extension. 

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

2786 

2787 # Do the actual plotting on a scatter plot 

2788 _plot_and_save_scatter_plot( 

2789 base, other, plot_filename, title, one_to_one, model_names 

2790 ) 

2791 

2792 # Add list of plots to plot metadata. 

2793 plot_index = _append_to_plot_index([plot_filename]) 

2794 

2795 # Make a page to display the plots. 

2796 _make_plot_html_page(plot_index) 

2797 

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

2799 

2800 

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

2802 """ 

2803 Plot a Hinton style triangle/scorecard plot. 

2804 

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

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

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

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

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

2810 

2811 Parameters 

2812 ---------- 

2813 change: np.ndarray 

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

2815 size/direction. 

2816 signif: np.ndarray 

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

2818 xaxis_labels: list 

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

2820 along with magnitude if not None). 

2821 yaxis_labels: list 

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

2823 along with magnitude if not None). 

2824 magnitude: np.ndarray | None 

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

2826 the user wishes to display under each respective triangle. 

2827 

2828 Returns 

2829 ------- 

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

2831 """ 

2832 # Setup colors of triangles 

2833 color_pos = "#7CAE00" 

2834 color_neg = "#7B68EE" 

2835 

2836 # Setup cell/text size ratios 

2837 figsize = None 

2838 cell_size_in = 0.35 

2839 text_row_ratio = 0.25 

2840 

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

2842 change = np.asarray(change) 

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

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

2845 magnitude = np.asarray(magnitude) 

2846 

2847 # Get the number of x and y elements 

2848 ny, nx = change.shape 

2849 

2850 # Build non-uniform y coordinates 

2851 tri_height = 1.0 

2852 txt_height = text_row_ratio 

2853 

2854 tri_y = [] 

2855 txt_y = [] 

2856 y_edges = [0.0] 

2857 

2858 y = 0.0 

2859 for _j in range(ny): 

2860 tri_y.append(y + tri_height / 2) 

2861 y += tri_height 

2862 y_edges.append(y) 

2863 

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

2865 txt_y.append(y + txt_height / 2) 

2866 y += txt_height 

2867 y_edges.append(y) 

2868 

2869 total_height = y 

2870 

2871 # Dynamic figure size 

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

2873 width = nx * cell_size_in 

2874 height = total_height * cell_size_in + 2 

2875 figsize = (width, height) 

2876 

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

2878 

2879 # Setup axes and grid. 

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

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

2882 ax.set_ylim(0, total_height) 

2883 

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

2885 ax.set_xticklabels(xaxis_labels, rotation=90) 

2886 

2887 ax.set_yticks(tri_y) 

2888 ax.set_yticklabels(yaxis_labels) 

2889 

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

2891 ax.set_yticks(y_edges, minor=True) 

2892 

2893 ax.set_axisbelow(True) 

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

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

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

2897 

2898 ax.invert_yaxis() 

2899 

2900 # Compute marker scaling (fixed overlap) 

2901 fig.canvas.draw() 

2902 

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

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

2905 

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

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

2908 cell_pixels = min(cell_w, cell_h) 

2909 

2910 max_marker_size = (0.6 * cell_pixels) ** 2 

2911 

2912 text_fontsize = cell_pixels * 0.15 

2913 

2914 # Plot triangles + text 

2915 for j in range(ny): 

2916 for i in range(nx): 

2917 val = change[j, i] 

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

2919 continue 

2920 

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

2922 continue 

2923 

2924 sig = signif[j, i] 

2925 size = max_marker_size * abs(val) 

2926 

2927 # Triangle style 

2928 if val >= 0: 

2929 marker = "^" 

2930 color = color_pos 

2931 else: 

2932 marker = "v" 

2933 color = color_neg 

2934 

2935 if sig: 

2936 edgecolor = "black" 

2937 linewidth = 0.6 

2938 else: 

2939 edgecolor = "none" 

2940 linewidth = 0.0 

2941 

2942 # Triangle 

2943 ax.scatter( 

2944 i, 

2945 tri_y[j], 

2946 s=size, 

2947 marker=marker, 

2948 c=color, 

2949 edgecolors=edgecolor, 

2950 linewidths=linewidth, 

2951 zorder=3, 

2952 clip_on=True, # ensures no rendering bleed 

2953 ) 

2954 

2955 # Text row 

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

2957 mag_val = magnitude[j, i] 

2958 

2959 if not np.isnan(mag_val): 

2960 ax.text( 

2961 i, 

2962 txt_y[j], 

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

2964 ha="center", 

2965 va="center", 

2966 fontsize=text_fontsize, 

2967 color="black", 

2968 zorder=4, 

2969 ) 

2970 

2971 plt.tight_layout() 

2972 return fig, ax 

2973 

2974 

2975def scatter_plot( 

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

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

2978 filename: str | None = None, 

2979 one_to_one: bool = True, 

2980 **kwargs, 

2981) -> iris.cube.CubeList: 

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

2983 

2984 Both cubes must be 1D. 

2985 

2986 Parameters 

2987 ---------- 

2988 cube_x: Cube | CubeList 

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

2990 cube_y: Cube | CubeList 

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

2992 filename: str, optional 

2993 Filename of the plot to write. 

2994 one_to_one: bool, optional 

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

2996 

2997 Returns 

2998 ------- 

2999 cubes: CubeList 

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

3001 

3002 Raises 

3003 ------ 

3004 ValueError 

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

3006 size. 

3007 TypeError 

3008 If the cube isn't a single cube. 

3009 

3010 Notes 

3011 ----- 

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

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

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

3015 """ 

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

3017 for cube_iter in iter_maybe(cube_x): 

3018 # Check cubes are correct shape. 

3019 cube_iter = check_single_cube(cube_iter) 

3020 if cube_iter.ndim > 1: 

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

3022 

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

3024 for cube_iter in iter_maybe(cube_y): 

3025 # Check cubes are correct shape. 

3026 cube_iter = check_single_cube(cube_iter) 

3027 if cube_iter.ndim > 1: 

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

3029 

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

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

3032 title = f"{recipe_title}" 

3033 

3034 if filename is None: 

3035 filename = slugify(recipe_title) 

3036 

3037 # Add file extension. 

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

3039 

3040 # Do the actual plotting. 

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

3042 

3043 # Add list of plots to plot metadata. 

3044 plot_index = _append_to_plot_index([plot_filename]) 

3045 

3046 # Make a page to display the plots. 

3047 _make_plot_html_page(plot_index) 

3048 

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

3050 

3051 

3052def vector_plot( 

3053 cube_u: iris.cube.Cube, 

3054 cube_v: iris.cube.Cube, 

3055 filename: str | None = None, 

3056 sequence_coordinate: str = "time", 

3057 **kwargs, 

3058) -> iris.cube.CubeList: 

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

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

3061 

3062 # Cubes must have a matching sequence coordinate. 

3063 try: 

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

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

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

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

3068 raise ValueError( 

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

3070 ) from err 

3071 

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

3073 plot_index = [] 

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

3075 for cube_u_slice, cube_v_slice in zip( 

3076 cube_u.slices_over(sequence_coordinate), 

3077 cube_v.slices_over(sequence_coordinate), 

3078 strict=True, 

3079 ): 

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

3081 seq_coord = cube_u_slice.coord(sequence_coordinate) 

3082 plot_title, plot_filename = _set_title_and_filename( 

3083 seq_coord, nplot, recipe_title, filename 

3084 ) 

3085 

3086 # Do the actual plotting. 

3087 _plot_and_save_vector_plot( 

3088 cube_u_slice, 

3089 cube_v_slice, 

3090 filename=plot_filename, 

3091 title=plot_title, 

3092 method="pcolormesh", 

3093 ) 

3094 plot_index.append(plot_filename) 

3095 

3096 # Add list of plots to plot metadata. 

3097 complete_plot_index = _append_to_plot_index(plot_index) 

3098 

3099 # Make a page to display the plots. 

3100 _make_plot_html_page(complete_plot_index) 

3101 

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

3103 

3104 

3105def plot_histogram_series( 

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

3107 filename: str | None = None, 

3108 sequence_coordinate: str = "time", 

3109 stamp_coordinate: str = "realization", 

3110 single_plot: bool = False, 

3111 **kwargs, 

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

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

3114 

3115 A histogram plot can be plotted, but if the sequence_coordinate (i.e. time) 

3116 is present then a sequence of plots will be produced using the time slider 

3117 functionality to scroll through histograms against time. If a 

3118 stamp_coordinate is present then postage stamp plots will be produced. If 

3119 stamp_coordinate and single_plot is True, all postage stamp plots will be 

3120 plotted in a single plot instead of separate postage stamp plots. 

3121 

3122 Parameters 

3123 ---------- 

3124 cubes: Cube | iris.cube.CubeList 

3125 Iris cube or CubeList of the data to plot. It should have a single dimension other 

3126 than the stamp coordinate. 

3127 The cubes should cover the same phenomenon i.e. all cubes contain temperature data. 

3128 We do not support different data such as temperature and humidity in the same CubeList for plotting. 

3129 filename: str, optional 

3130 Name of the plot to write, used as a prefix for plot sequences. Defaults 

3131 to the recipe name. 

3132 sequence_coordinate: str, optional 

3133 Coordinate about which to make a plot sequence. Defaults to ``"time"``. 

3134 This coordinate must exist in the cube and will be used for the time 

3135 slider. 

3136 stamp_coordinate: str, optional 

3137 Coordinate about which to plot postage stamp plots. Defaults to 

3138 ``"realization"``. 

3139 single_plot: bool, optional 

3140 If True, all postage stamp plots will be plotted in a single plot. If 

3141 False, each postage stamp plot will be plotted separately. Is only valid 

3142 if stamp_coordinate exists and has more than a single point. 

3143 

3144 Returns 

3145 ------- 

3146 iris.cube.Cube | iris.cube.CubeList 

3147 The original Cube or CubeList (so further operations can be applied). 

3148 Plotted data. 

3149 

3150 Raises 

3151 ------ 

3152 ValueError 

3153 If the cube doesn't have the right dimensions. 

3154 TypeError 

3155 If the cube isn't a Cube or CubeList. 

3156 """ 

3157 recipe_title = get_recipe_metadata().get("title", "Histogram") 

3158 

3159 cubes = iter_maybe(cubes) 

3160 

3161 # Internal plotting function. 

3162 plotting_func = _plot_and_save_histogram_series 

3163 

3164 num_models = get_num_models(cubes) 

3165 

3166 validate_cube_shape(cubes, num_models) 

3167 

3168 # If several histograms are plotted, check sequence_coordinate 

3169 check_sequence_coordinate(cubes, sequence_coordinate) 

3170 

3171 # Get axis minimum and maximum from levels information. 

3172 # If no levels set, derive minima and maxima from data in CubeList. 

3173 vmin, vmax = _set_axis_range(cubes) 

3174 

3175 # Make postage stamp plots if stamp_coordinate exists and has more than a 

3176 # single point. If single_plot is True: 

3177 # -- all postage stamp plots will be plotted in a single plot instead of 

3178 # separate postage stamp plots. 

3179 # -- model names (hidden in cube attrs) are ignored, that is stamp plots are 

3180 # produced per single model only 

3181 if num_models == 1: 

3182 if ( 3182 ↛ 3186line 3182 didn't jump to line 3186 because the condition on line 3182 was never true

3183 stamp_coordinate in [c.name() for c in cubes[0].coords()] 

3184 and cubes[0].coord(stamp_coordinate).shape[0] > 1 

3185 ): 

3186 if single_plot: 

3187 plotting_func = ( 

3188 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

3189 ) 

3190 else: 

3191 plotting_func = _plot_and_save_postage_stamp_histogram_series 

3192 cube_iterables = cubes[0].slices_over(sequence_coordinate) 

3193 else: 

3194 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

3195 

3196 plot_index = [] 

3197 nplot = np.size(cubes[0].coord(sequence_coordinate).points) 

3198 # Create a plot for each value of the sequence coordinate. Allowing for 

3199 # multiple cubes in a CubeList to be plotted in the same plot for similar 

3200 # sequence values. Passing a CubeList into the internal plotting function 

3201 # for similar values of the sequence coordinate. cube_slice can be an 

3202 # iris.cube.Cube or an iris.cube.CubeList. 

3203 for cube_slice in cube_iterables: 

3204 single_cube = cube_slice 

3205 if isinstance(cube_slice, iris.cube.CubeList): 

3206 single_cube = cube_slice[0] 

3207 

3208 # Ensure valid stamp coordinate in cube dimensions 

3209 if stamp_coordinate == "realization": 3209 ↛ 3212line 3209 didn't jump to line 3212 because the condition on line 3209 was always true

3210 stamp_coordinate = check_stamp_coordinate(single_cube) 

3211 # Set plot titles and filename, based on sequence coordinate 

3212 seq_coord = single_cube.coord(sequence_coordinate) 

3213 # Use time coordinate in title and filename if single histogram output. 

3214 if sequence_coordinate == "realization" and nplot == 1: 3214 ↛ 3215line 3214 didn't jump to line 3215 because the condition on line 3214 was never true

3215 seq_coord = single_cube.coord("time") 

3216 # Use station name in title and filename if model vs obs comparison 

3217 if sequence_coordinate == "station": 3217 ↛ 3218line 3217 didn't jump to line 3218 because the condition on line 3217 was never true

3218 seq_coord = single_cube.coord("Station_Name") 

3219 

3220 plot_title, plot_filename = _set_title_and_filename( 

3221 seq_coord, nplot, recipe_title, filename 

3222 ) 

3223 

3224 # Do the actual plotting. 

3225 plotting_func( 

3226 cube_slice, 

3227 filename=plot_filename, 

3228 stamp_coordinate=stamp_coordinate, 

3229 title=plot_title, 

3230 vmin=vmin, 

3231 vmax=vmax, 

3232 ) 

3233 plot_index.append(plot_filename) 

3234 

3235 # Add list of plots to plot metadata. 

3236 complete_plot_index = _append_to_plot_index(plot_index) 

3237 

3238 # Make a page to display the plots. 

3239 _make_plot_html_page(complete_plot_index) 

3240 

3241 return cubes 

3242 

3243 

3244def plot_scatter_series( 

3245 cubes: iris.cube.Cube | iris.cube.CubeList, 

3246 filename: str | None = None, 

3247 sequence_coordinate: str = "time", 

3248 stamp_coordinate: str = "realization", 

3249 hexbin: bool = False, 

3250 **kwargs, 

3251) -> iris.cube.Cube | iris.cube.CubeList: 

3252 """Plot a scatter plot for each sequence coordinate provided. 

3253 

3254 A scatter plot can be plotted, but if the sequence_coordinate (i.e. time) 

3255 is present then a sequence of plots will be produced using the time slider 

3256 functionality to scroll through scatter against time. If a 

3257 stamp_coordinate is present then postage stamp plots will be produced. If 

3258 stamp_coordinate and single_plot is True, all postage stamp plots will be 

3259 plotted in a single plot instead of separate postage stamp plots. 

3260 

3261 Parameters 

3262 ---------- 

3263 cubes: Cube | iris.cube.CubeList 

3264 Iris cube or CubeList of the data to plot. It should have a single dimension other 

3265 than the stamp coordinate. 

3266 The cubes should cover the same phenomenon i.e. all cubes contain temperature data. 

3267 We do not support different data such as temperature and humidity in the same CubeList for plotting. 

3268 filename: str, optional 

3269 Name of the plot to write, used as a prefix for plot sequences. Defaults 

3270 to the recipe name. 

3271 sequence_coordinate: str, optional 

3272 Coordinate about which to make a plot sequence. Defaults to ``"time"``. 

3273 This coordinate must exist in the cube and will be used for the time 

3274 slider. 

3275 stamp_coordinate: str, optional 

3276 Coordinate about which to plot postage stamp plots. Defaults to 

3277 ``"realization"``. 

3278 hexbin: bool, optional 

3279 If True, generate hexbin comparison plot. 

3280 If False, generate point-by-point scatter plot. 

3281 

3282 Returns 

3283 ------- 

3284 iris.cube.Cube | iris.cube.CubeList 

3285 The original Cube or CubeList (so further operations can be applied). 

3286 Plotted data. 

3287 

3288 Raises 

3289 ------ 

3290 ValueError 

3291 If the cube doesn't have the right dimensions. 

3292 TypeError 

3293 If the cube isn't a Cube or CubeList. 

3294 """ 

3295 recipe_title = get_recipe_metadata().get("title", "Scatter") 

3296 

3297 cubes = iter_maybe(cubes) 

3298 

3299 # Internal plotting function. 

3300 plotting_func = _plot_and_save_scatter_series 

3301 

3302 num_models = get_num_models(cubes) 

3303 

3304 validate_cube_shape(cubes, num_models) 

3305 

3306 check_sequence_coordinate(cubes, sequence_coordinate) 

3307 

3308 vmin, vmax = _set_axis_range(cubes) 

3309 

3310 # Require >1 models to compare on scatter plot 

3311 if num_models > 1: 

3312 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

3313 else: 

3314 raise ValueError( 

3315 "Scatter plot series requires multiple number of models in input data." 

3316 ) 

3317 

3318 plot_index = [] 

3319 nplot = np.size(cubes[0].coord(sequence_coordinate).points) 

3320 # Create a plot for each value of the sequence coordinate. Allowing for 

3321 # multiple cubes in a CubeList to be plotted in the same plot for similar 

3322 # sequence values. Passing a CubeList into the internal plotting function 

3323 # for similar values of the sequence coordinate. cube_slice can be an 

3324 # iris.cube.Cube or an iris.cube.CubeList. 

3325 for cube_slice in cube_iterables: 

3326 single_cube = cube_slice 

3327 if isinstance(cube_slice, iris.cube.CubeList): 3327 ↛ 3331line 3327 didn't jump to line 3331 because the condition on line 3327 was always true

3328 single_cube = cube_slice[0] 

3329 

3330 # Ensure valid stamp coordinate in cube dimensions 

3331 if stamp_coordinate == "realization": 3331 ↛ 3334line 3331 didn't jump to line 3334 because the condition on line 3331 was always true

3332 stamp_coordinate = check_stamp_coordinate(single_cube) 

3333 # Set plot titles and filename, based on sequence coordinate 

3334 seq_coord = single_cube.coord(sequence_coordinate) 

3335 # Use time coordinate in title and filename if single histogram output. 

3336 if sequence_coordinate == "realization" and nplot == 1: 

3337 seq_coord = single_cube.coord("time") 

3338 # Use station name in title and filename if model vs obs comparison 

3339 if sequence_coordinate == "station": 

3340 seq_coord = single_cube.coord("Station_Name") 

3341 

3342 plot_title, plot_filename = _set_title_and_filename( 

3343 seq_coord, nplot, recipe_title, filename 

3344 ) 

3345 

3346 # Do the actual plotting. 

3347 plotting_func( 

3348 cube_slice, 

3349 filename=plot_filename, 

3350 stamp_coordinate=stamp_coordinate, 

3351 title=plot_title, 

3352 vmin=vmin, 

3353 vmax=vmax, 

3354 hexbin=hexbin, 

3355 ) 

3356 plot_index.append(plot_filename) 

3357 

3358 # Add list of plots to plot metadata. 

3359 complete_plot_index = _append_to_plot_index(plot_index) 

3360 

3361 # Make a page to display the plots. 

3362 _make_plot_html_page(complete_plot_index) 

3363 

3364 return cubes 

3365 

3366 

3367def _plot_and_save_postage_stamp_power_spectrum_series( 

3368 cubes: iris.cube.Cube, 

3369 coords: list[iris.coords.Coord], 

3370 stamp_coordinate: str, 

3371 filename: str, 

3372 title: str, 

3373 series_coordinate: str | None = None, 

3374 **kwargs, 

3375): 

3376 """Plot and save postage (ensemble members) stamps for a power spectrum series. 

3377 

3378 Parameters 

3379 ---------- 

3380 cubes: Cube or CubeList 

3381 Cube or Cubelist of the power spectrum data. 

3382 coords: list[Coord] 

3383 Coordinates to plot on the x-axis, one per cube. 

3384 stamp_coordinate: str 

3385 Coordinate that becomes different plots. 

3386 filename: str 

3387 Filename of the plot to write. 

3388 title: str 

3389 Plot title. 

3390 series_coordinate: str, optional 

3391 Coordinate being plotted on x-axis. In case of spectra frequency, physical_wavenumber, or wavelength. 

3392 

3393 """ 

3394 # Use the smallest square grid that will fit the members. 

3395 grid_size = math.ceil(math.sqrt(len(cubes.coord(stamp_coordinate).points))) 

3396 

3397 fig = plt.figure(figsize=(10, 10), facecolor="w", edgecolor="k") 

3398 model_colors_map = get_model_colors_map(cubes) 

3399 # ax = plt.gca() 

3400 # Make a subplot for each member. 

3401 for member, subplot in zip( 

3402 cubes.slices_over(stamp_coordinate), range(1, grid_size**2 + 1), strict=False 

3403 ): 

3404 ax = plt.subplot(grid_size, grid_size, subplot) 

3405 

3406 # Store min/max ranges. 

3407 y_levels = [] 

3408 

3409 line_marker = None 

3410 line_width = 1 

3411 

3412 for cube in iter_maybe(member): 

3413 xcoord = _select_series_coord(cube, series_coordinate) 

3414 xname = xcoord.points 

3415 

3416 yfield = cube.data # power spectrum 

3417 label = None 

3418 color = "black" 

3419 if model_colors_map: 3419 ↛ 3420line 3419 didn't jump to line 3420 because the condition on line 3419 was never true

3420 label = cube.attributes.get("model_name") 

3421 color = model_colors_map.get(label) 

3422 

3423 if member.coord(stamp_coordinate).points == [0]: 

3424 ax.plot( 

3425 xname, 

3426 yfield, 

3427 color=color, 

3428 marker=line_marker, 

3429 ls="-", 

3430 lw=line_width, 

3431 label=f"{label} (control)" 

3432 if len(cube.coord(stamp_coordinate).points) > 1 

3433 else label, 

3434 ) 

3435 # Label with member if part of an ensemble and not the control. 

3436 else: 

3437 ax.plot( 

3438 xname, 

3439 yfield, 

3440 color=color, 

3441 ls="-", 

3442 lw=1.5, 

3443 alpha=0.75, 

3444 label=f"{label} (member)", 

3445 ) 

3446 

3447 # Calculate the global min/max if multiple cubes are given. 

3448 _, levels, _ = colorbar_map_levels(cube, axis="y") 

3449 if levels is not None: 3449 ↛ 3450line 3449 didn't jump to line 3450 because the condition on line 3449 was never true

3450 y_levels.append(min(levels)) 

3451 y_levels.append(max(levels)) 

3452 

3453 # Add some labels and tweak the style. 

3454 title = f"{title}" 

3455 ax.set_title(title, fontsize=16) 

3456 

3457 # Set appropriate x-axis label based on coordinate 

3458 if series_coordinate == "wavelength" or ( 3458 ↛ 3461line 3458 didn't jump to line 3461 because the condition on line 3458 was never true

3459 hasattr(xcoord, "long_name") and xcoord.long_name == "wavelength" 

3460 ): 

3461 ax.set_xlabel("Wavelength (km)", fontsize=14) 

3462 elif series_coordinate == "physical_wavenumber" or ( 3462 ↛ 3467line 3462 didn't jump to line 3467 because the condition on line 3462 was always true

3463 hasattr(xcoord, "long_name") and xcoord.long_name == "physical_wavenumber" 

3464 ): 

3465 ax.set_xlabel("Wavenumber (km⁻¹)", fontsize=14) 

3466 else: # frequency or check units 

3467 if hasattr(xcoord, "units") and str(xcoord.units) == "km-1": 

3468 ax.set_xlabel("Wavenumber (km⁻¹)", fontsize=14) 

3469 else: 

3470 ax.set_xlabel("Wavenumber", fontsize=14) 

3471 

3472 ax.set_ylabel("Power Spectral Density", fontsize=14) 

3473 ax.tick_params(axis="both", labelsize=12) 

3474 

3475 # Set log-log scale 

3476 ax.set_xscale("log") 

3477 ax.set_yscale("log") 

3478 

3479 # Add gridlines 

3480 ax.grid(linestyle="--", color="grey", linewidth=1) 

3481 # Ientify unique labels for legend 

3482 handles = list( 

3483 { 

3484 label: handle 

3485 for (handle, label) in zip(*ax.get_legend_handles_labels(), strict=True) 

3486 }.values() 

3487 ) 

3488 ax.legend(handles=handles, loc="best", ncol=1, frameon=True, fontsize=16) 

3489 

3490 ax = plt.gca() 

3491 ax.set_title(f"Member #{member.coord(stamp_coordinate).points[0]}") 

3492 

3493 # Save plot. 

3494 _save_close_figure(fig, "histogram postage stamp", filename) 

3495 

3496 

3497def _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series( 

3498 cubes: iris.cube.Cube, 

3499 coords: list[iris.coords.Coord], 

3500 stamp_coordinate: str, 

3501 filename: str, 

3502 title: str, 

3503 series_coordinate: str | None = None, 

3504 **kwargs, 

3505): 

3506 """Plot and save power spectra for ensemble members in single plot. 

3507 

3508 Parameters 

3509 ---------- 

3510 cubes: Cube or CubeList 

3511 Cube or Cubelist of the power spectrum data. 

3512 coords: list[Coord] 

3513 Coordinates to plot on the x-axis, one per cube. 

3514 stamp_coordinate: str 

3515 Coordinate that becomes different plots. 

3516 filename: str 

3517 Filename of the plot to write. 

3518 title: str 

3519 Plot title. 

3520 series_coordinate: str, optional 

3521 Coordinate being plotted on x-axis. In case of spectra frequency, physical_wavenumber, or wavelength. 

3522 

3523 """ 

3524 fig, ax = plt.subplots(figsize=(10, 10), facecolor="w", edgecolor="k") 

3525 model_colors_map = get_model_colors_map(cubes) 

3526 

3527 line_marker = None 

3528 line_width = 1 

3529 

3530 # Compute ensemble statistics to show spread 

3531 mean_cube = cubes.collapsed(stamp_coordinate, iris.analysis.MEAN) 

3532 min_cube = cubes.collapsed(stamp_coordinate, iris.analysis.MIN) 

3533 max_cube = cubes.collapsed(stamp_coordinate, iris.analysis.MAX) 

3534 

3535 xcoord_global = mean_cube.coord(series_coordinate) 

3536 x_global = xcoord_global.points 

3537 

3538 for i, member in enumerate(cubes.slices_over(stamp_coordinate)): 

3539 xcoord = _select_series_coord(member, series_coordinate) 

3540 xname = xcoord.points 

3541 

3542 yfield = member.data # power spectrum 

3543 color = "black" 

3544 if model_colors_map: 3544 ↛ 3548line 3544 didn't jump to line 3548 because the condition on line 3544 was always true

3545 label = member.attributes.get("model_name") if i == 0 else None 

3546 color = model_colors_map.get(label) 

3547 

3548 if member.coord(stamp_coordinate).points == [0]: 

3549 ax.plot( 

3550 xname, 

3551 yfield, 

3552 color=color, 

3553 marker=line_marker, 

3554 ls="-", 

3555 lw=line_width, 

3556 label=f"{label} (control)" 

3557 if len(member.coord(stamp_coordinate).points) > 1 

3558 else label, 

3559 ) 

3560 # Label with member number if part of an ensemble and not the control. 

3561 else: 

3562 ax.plot( 

3563 xname, 

3564 yfield, 

3565 color=color, 

3566 ls="-", 

3567 lw=1.5, 

3568 alpha=0.75, 

3569 label=label, 

3570 ) 

3571 

3572 # Set appropriate x-axis label based on coordinate 

3573 if series_coordinate == "wavelength" or ( 3573 ↛ 3576line 3573 didn't jump to line 3576 because the condition on line 3573 was never true

3574 hasattr(xcoord, "long_name") and xcoord.long_name == "wavelength" 

3575 ): 

3576 ax.set_xlabel("Wavelength (km)", fontsize=14) 

3577 elif series_coordinate == "physical_wavenumber" or ( 3577 ↛ 3582line 3577 didn't jump to line 3582 because the condition on line 3577 was always true

3578 hasattr(xcoord, "long_name") and xcoord.long_name == "physical_wavenumber" 

3579 ): 

3580 ax.set_xlabel("Wavenumber (km⁻¹)", fontsize=14) 

3581 else: # frequency or check units 

3582 if hasattr(xcoord, "units") and str(xcoord.units) == "km-1": 

3583 ax.set_xlabel("Wavenumber (km⁻¹)", fontsize=14) 

3584 else: 

3585 ax.set_xlabel("Wavenumber", fontsize=14) 

3586 

3587 # Add ensemble spread shading 

3588 ax.fill_between( 

3589 x_global, 

3590 min_cube.data, 

3591 max_cube.data, 

3592 color="grey", 

3593 alpha=0.3, 

3594 label="Ensemble spread", 

3595 ) 

3596 

3597 # Add ensemble mean line 

3598 ax.plot(x_global, mean_cube.data, color="black", lw=1, label="Ensemble mean") 

3599 

3600 ax.set_ylabel("Power Spectral Density", fontsize=14) 

3601 ax.tick_params(axis="both", labelsize=12) 

3602 

3603 # Set y limits to global min and max, autoscale if colorbar doesn't exist. 

3604 # Set log-log scale 

3605 ax.set_xscale("log") 

3606 ax.set_yscale("log") 

3607 

3608 # Add gridlines 

3609 ax.grid(linestyle="--", color="grey", linewidth=1) 

3610 # Identify unique labels for legend 

3611 handles = list( 

3612 { 

3613 label: handle 

3614 for (handle, label) in zip(*ax.get_legend_handles_labels(), strict=True) 

3615 }.values() 

3616 ) 

3617 ax.legend(handles=handles, loc="best", ncol=1, frameon=True, fontsize=16) 

3618 

3619 # Figure title. 

3620 ax.set_title(title, fontsize=16) 

3621 

3622 # Save plot. 

3623 _save_close_figure(fig, "power spectra postage stamp", filename)