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

1169 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-22 15:41 +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(cb.data.min() for cb in cubes) 

479 vmax = max(cb.data.max() 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 overplotting, set required colorbars 

551 if overlay_cube: 

552 over_cmap, over_levels, over_norm = colorbar_map_levels(overlay_cube) 

553 if contour_cube: 

554 cntr_cmap, cntr_levels, cntr_norm = colorbar_map_levels(contour_cube) 

555 

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

557 axes = _setup_spatial_map(cube, fig, cmap) 

558 

559 # Set colorscale bounds 

560 try: 

561 vmin = min(levels) 

562 vmax = max(levels) 

563 except TypeError: 

564 vmin, vmax = None, None 

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

566 if norm is not None: 

567 vmin = None 

568 vmax = None 

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

570 

571 # Plot the field. 

572 if method == "contourf": 

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

574 elif method == "pcolormesh": 

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

576 elif method == "scatter": 

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

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

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

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

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

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

583 # proportion to the area of the figure. 

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

585 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

586 plot = iplt.scatter( 

587 cube.coord(lon_axis), 

588 cube.coord(lat_axis), 

589 c=cube.data[:], 

590 s=mrk_size, 

591 cmap=cmap, 

592 edgecolors="k", 

593 norm=norm, 

594 vmin=vmin, 

595 vmax=vmax, 

596 ) 

597 else: 

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

599 

600 # Overplot overlay field, if required 

601 if overlay_cube: 

602 try: 

603 over_vmin = min(over_levels) 

604 over_vmax = max(over_levels) 

605 except TypeError: 

606 over_vmin, over_vmax = None, None 

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

608 over_vmin = None 

609 over_vmax = None 

610 overlay = iplt.pcolormesh( 

611 overlay_cube, 

612 cmap=over_cmap, 

613 norm=over_norm, 

614 alpha=0.8, 

615 vmin=over_vmin, 

616 vmax=over_vmax, 

617 ) 

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

619 if contour_cube: 

620 contour = iplt.contour( 

621 contour_cube, 

622 colors="darkgray", 

623 levels=cntr_levels, 

624 norm=cntr_norm, 

625 alpha=0.5, 

626 linestyles="--", 

627 linewidths=1, 

628 ) 

629 plt.clabel(contour) 

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

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

632 if point_cube: 

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

634 lat_axis, lon_axis = get_cube_yxcoordname(point_cube) 

635 lon_coord = point_cube.coord(lon_axis) 

636 lat_coord = point_cube.coord(lat_axis) 

637 valid = ~point_cube.data.mask 

638 valid_lon = iris.coords.AuxCoord( 

639 lon_coord.points[valid], 

640 standard_name=lon_coord.standard_name, 

641 units=lon_coord.units, 

642 coord_system=lon_coord.coord_system, 

643 ) 

644 valid_lat = iris.coords.AuxCoord( 

645 lat_coord.points[valid], 

646 standard_name=lat_coord.standard_name, 

647 units=lat_coord.units, 

648 coord_system=lat_coord.coord_system, 

649 ) 

650 iplt.scatter( 

651 valid_lon, 

652 valid_lat, 

653 c=point_cube.data[valid], 

654 s=mrk_size, 

655 cmap=cmap, 

656 edgecolors="k", 

657 norm=norm, 

658 vmin=vmin, 

659 vmax=vmax, 

660 ) 

661 

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

663 if is_transect(cube): 

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

665 axes.invert_yaxis() 

666 axes.set_yscale("log") 

667 axes.set_ylim(1100, 100) 

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

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

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

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

672 ): 

673 axes.set_yscale("log") 

674 

675 axes.set_title( 

676 f"{title}\n" 

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

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

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

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

681 fontsize=16, 

682 ) 

683 

684 # Inset code 

685 axins = inset_axes( 

686 axes, 

687 width="20%", 

688 height="20%", 

689 loc="upper right", 

690 axes_class=GeoAxes, 

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

692 ) 

693 

694 # Slightly transparent to reduce plot blocking. 

695 axins.patch.set_alpha(0.4) 

696 

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

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

699 

700 SLat, SLon, ELat, ELon = ( 

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

702 ) 

703 

704 # Draw line between them 

705 axins.plot( 

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

707 ) 

708 

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

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

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

712 

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

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

715 

716 # Midpoints 

717 lon_mid = (lon_min + lon_max) / 2 

718 lat_mid = (lat_min + lat_max) / 2 

719 

720 # Maximum half-range 

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

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

723 half_range = 1 

724 

725 # Set square extent 

726 axins.set_extent( 

727 [ 

728 lon_mid - half_range, 

729 lon_mid + half_range, 

730 lat_mid - half_range, 

731 lat_mid + half_range, 

732 ], 

733 crs=ccrs.PlateCarree(), 

734 ) 

735 

736 # Ensure square aspect 

737 axins.set_aspect("equal") 

738 

739 else: 

740 # Add title. 

741 axes.set_title(title, fontsize=16) 

742 

743 # Adjust padding if spatial plot or transect 

744 if is_transect(cube): 

745 yinfopad = -0.1 

746 ycbarpad = 0.1 

747 else: 

748 yinfopad = 0.01 

749 ycbarpad = 0.042 

750 

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

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

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

754 axes.annotate( 

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

756 xy=(0.025, yinfopad), 

757 xycoords="axes fraction", 

758 xytext=(-5, 5), 

759 textcoords="offset points", 

760 ha="left", 

761 va="bottom", 

762 size=11, 

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

764 ) 

765 

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

767 if overlay_cube: 

768 cbarB = fig.colorbar( 

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

770 ) 

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

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

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

774 cbarB.set_ticks(over_levels) 

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

776 if any( 

777 var in overlay_cube.name() 

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

779 ): 

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

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

782 

783 # Add main colour bar. 

784 cbar = fig.colorbar( 

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

786 ) 

787 

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

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

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

791 cbar.set_ticks(levels) 

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

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

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

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

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

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

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

799 if "rain accumulation" 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 if "wts accumulation" in cube.name(): 801 ↛ 802line 801 didn't jump to line 802 because the condition on line 801 was never true

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

803 cbar.minorticks_off() 

804 cbar.set_ticks(tick_levels) 

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

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

807 # Tick labels for model rainfall data. 

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

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

810 # Tick labels for Nimrod weights data. 

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

812 

813 # Save plot. 

814 _save_close_figure(fig, "spatial", filename) 

815 

816 

817def _plot_and_save_postage_stamp_spatial_plot( 

818 cube: iris.cube.Cube, 

819 filename: str, 

820 stamp_coordinate: str, 

821 title: str, 

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

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

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

825 **kwargs, 

826): 

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

828 

829 Parameters 

830 ---------- 

831 cube: Cube 

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

833 filename: str 

834 Filename of the plot to write. 

835 stamp_coordinate: str 

836 Coordinate that becomes different plots. 

837 method: "contourf" | "pcolormesh" 

838 The plotting method to use. 

839 overlay_cube: Cube, optional 

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

841 contour_cube: Cube, optional 

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

843 

844 Raises 

845 ------ 

846 ValueError 

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

848 """ 

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

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

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

852 grid_size = math.ceil(nmember / grid_rows) 

853 

854 fig = plt.figure( 

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

856 ) 

857 

858 # Specify the color bar 

859 cmap, levels, norm = colorbar_map_levels(cube) 

860 # If overplotting, set required colorbars 

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

862 over_cmap, over_levels, over_norm = colorbar_map_levels(overlay_cube) 

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

864 cntr_cmap, cntr_levels, cntr_norm = colorbar_map_levels(contour_cube) 

865 

866 # Make a subplot for each member. 

867 for member, subplot in zip( 

868 cube.slices_over(stamp_coordinate), 

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

870 strict=False, 

871 ): 

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

873 axes = _setup_spatial_map( 

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

875 ) 

876 if method == "contourf": 

877 # Filled contour plot of the field. 

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

879 elif method == "pcolormesh": 

880 if levels is not None: 

881 vmin = min(levels) 

882 vmax = max(levels) 

883 else: 

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

885 vmin, vmax = None, None 

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

887 # if levels are defined. 

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

889 vmin = None 

890 vmax = None 

891 # pcolormesh plot of the field. 

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

893 else: 

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

895 

896 # Overplot overlay field, if required 

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

898 try: 

899 over_vmin = min(over_levels) 

900 over_vmax = max(over_levels) 

901 except TypeError: 

902 over_vmin, over_vmax = None, None 

903 if over_norm is not None: 

904 over_vmin = None 

905 over_vmax = None 

906 iplt.pcolormesh( 

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

908 cmap=over_cmap, 

909 norm=over_norm, 

910 alpha=0.6, 

911 vmin=over_vmin, 

912 vmax=over_vmax, 

913 ) 

914 # Overplot contour field, if required 

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

916 iplt.contour( 

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

918 colors="darkgray", 

919 levels=cntr_levels, 

920 norm=cntr_norm, 

921 alpha=0.6, 

922 linestyles="--", 

923 linewidths=1, 

924 ) 

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

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

927 

928 # Put the shared colorbar in its own axes. 

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

930 colorbar = fig.colorbar( 

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

932 ) 

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

934 

935 # Overall figure title. 

936 fig.suptitle(title, fontsize=16) 

937 

938 # Save plot. 

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

940 

941 

942def _plot_and_save_line_series( 

943 cubes: iris.cube.CubeList, 

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

945 ensemble_coord: str, 

946 filename: str, 

947 title: str, 

948 **kwargs, 

949): 

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

951 

952 Parameters 

953 ---------- 

954 cubes: Cube or CubeList 

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

956 coords: list[Coord] 

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

958 ensemble_coord: str 

959 Ensemble coordinate in the cube. 

960 filename: str 

961 Filename of the plot to write. 

962 title: str 

963 Plot title. 

964 """ 

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

966 

967 model_colors_map = get_model_colors_map(cubes) 

968 

969 # Store min/max ranges. 

970 y_levels = [] 

971 

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

973 validate_cubes_coords(cubes, coords) 

974 

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

976 label = None 

977 color = "black" 

978 if model_colors_map: 

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

980 color = model_colors_map.get(label) 

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

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

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

984 else: 

985 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

988 iplt.plot( 

989 coord, 

990 cube_slice, 

991 color=color, 

992 marker="o", 

993 ls="-", 

994 lw=3, 

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

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

997 else label, 

998 ) 

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

1000 else: 

1001 iplt.plot( 

1002 coord, 

1003 cube_slice, 

1004 color=color, 

1005 ls="-", 

1006 lw=1.5, 

1007 alpha=0.75, 

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

1009 ) 

1010 

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

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

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

1014 y_levels.append(min(levels)) 

1015 y_levels.append(max(levels)) 

1016 

1017 # Get the current axes. 

1018 ax = plt.gca() 

1019 

1020 # Add some labels and tweak the style. 

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

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

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

1024 else: 

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

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

1027 ax.set_title(title, fontsize=16) 

1028 

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

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

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

1032 

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

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

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

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

1037 else: 

1038 ax.autoscale() 

1039 

1040 # Add gridlines 

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

1042 # Add zero line 

1043 ymin, ymax = ax.get_ylim() 

1044 if ymin < 0.0 and ymax > 0.0: 

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

1046 # Identify unique labels for legend 

1047 handles = list( 

1048 { 

1049 label: handle 

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

1051 }.values() 

1052 ) 

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

1054 

1055 # Save plot. 

1056 _save_close_figure(fig, "line", filename) 

1057 

1058 

1059def _plot_and_save_line_power_spectrum_series( 

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

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

1062 ensemble_coord: str, 

1063 filename: str, 

1064 title: str, 

1065 series_coordinate: str, 

1066 **kwargs, 

1067): 

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

1069 

1070 Parameters 

1071 ---------- 

1072 cubes: Cube or CubeList 

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

1074 coords: list[Coord] 

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

1076 ensemble_coord: str 

1077 Ensemble coordinate in the cube. 

1078 filename: str 

1079 Filename of the plot to write. 

1080 title: str 

1081 Plot title. 

1082 series_coordinate: str 

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

1084 """ 

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

1086 model_colors_map = get_model_colors_map(cubes) 

1087 ax = plt.gca() 

1088 

1089 # Store min/max ranges. 

1090 y_levels = [] 

1091 

1092 line_marker = None 

1093 line_width = 1 

1094 

1095 for cube in iter_maybe(cubes): 

1096 # next 2 lines replace chunk of code. 

1097 xcoord = _select_series_coord(cube, series_coordinate) 

1098 xname = xcoord.points 

1099 

1100 yfield = cube.data # power spectrum 

1101 

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

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

1104 # plotting. 

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

1106 yfield = np.zeros_like(yfield) 

1107 

1108 label = None 

1109 color = "black" 

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

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

1112 color = model_colors_map.get(label) 

1113 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

1116 ax.plot( 

1117 xname, 

1118 yfield, 

1119 color=color, 

1120 marker=line_marker, 

1121 ls="-", 

1122 lw=line_width, 

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

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

1125 else label, 

1126 ) 

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

1128 else: 

1129 ax.plot( 

1130 xname, 

1131 yfield, 

1132 color=color, 

1133 ls="-", 

1134 lw=1.5, 

1135 alpha=0.75, 

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

1137 ) 

1138 

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

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

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

1142 y_levels.append(min(levels)) 

1143 y_levels.append(max(levels)) 

1144 

1145 # Add some labels and tweak the style. 

1146 

1147 title = f"{title}" 

1148 ax.set_title(title, fontsize=16) 

1149 

1150 # Set appropriate x-axis label based on coordinate 

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

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

1153 ): 

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

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

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

1157 ): 

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

1159 else: # frequency or check units 

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

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

1162 else: 

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

1164 

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

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

1167 

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

1169 

1170 # Set log-log scale 

1171 ax.set_xscale("log") 

1172 ax.set_yscale("log") 

1173 

1174 # Add gridlines 

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

1176 # Ientify unique labels for legend 

1177 handles = list( 

1178 { 

1179 label: handle 

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

1181 }.values() 

1182 ) 

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

1184 

1185 # Save plot. 

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

1187 

1188 

1189def _plot_and_save_vertical_line_series( 

1190 cubes: iris.cube.CubeList, 

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

1192 ensemble_coord: str, 

1193 filename: str, 

1194 series_coordinate: str, 

1195 title: str, 

1196 vmin: float, 

1197 vmax: float, 

1198 **kwargs, 

1199): 

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

1201 

1202 Parameters 

1203 ---------- 

1204 cubes: CubeList 

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

1206 coord: list[Coord] 

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

1208 ensemble_coord: str 

1209 Ensemble coordinate in the cube. 

1210 filename: str 

1211 Filename of the plot to write. 

1212 series_coordinate: str 

1213 Coordinate to use as vertical axis. 

1214 title: str 

1215 Plot title. 

1216 vmin: float 

1217 Minimum value for the x-axis. 

1218 vmax: float 

1219 Maximum value for the x-axis. 

1220 """ 

1221 # plot the vertical pressure axis using log scale 

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

1223 

1224 model_colors_map = get_model_colors_map(cubes) 

1225 

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

1227 validate_cubes_coords(cubes, coords) 

1228 

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

1230 label = None 

1231 color = "black" 

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

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

1234 color = model_colors_map.get(label) 

1235 

1236 for cube_slice in cube.slices_over(ensemble_coord): 

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

1238 # unless single forecast. 

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

1240 iplt.plot( 

1241 cube_slice, 

1242 coord, 

1243 color=color, 

1244 marker="o", 

1245 ls="-", 

1246 lw=3, 

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

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

1249 else label, 

1250 ) 

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

1252 else: 

1253 iplt.plot( 

1254 cube_slice, 

1255 coord, 

1256 color=color, 

1257 ls="-", 

1258 lw=1.5, 

1259 alpha=0.75, 

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

1261 ) 

1262 

1263 # Get the current axis 

1264 ax = plt.gca() 

1265 

1266 # Special handling for pressure level data. 

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

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

1269 ax.invert_yaxis() 

1270 ax.set_yscale("log") 

1271 

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

1273 y_tick_labels = [ 

1274 "1000", 

1275 "850", 

1276 "700", 

1277 "500", 

1278 "300", 

1279 "200", 

1280 "100", 

1281 ] 

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

1283 

1284 # Set y-axis limits and ticks. 

1285 ax.set_ylim(1100, 100) 

1286 

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

1288 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

1294 

1295 ax.set_yticks(y_ticks) 

1296 ax.set_yticklabels(y_tick_labels) 

1297 

1298 # Set x-axis limits. 

1299 ax.set_xlim(vmin, vmax) 

1300 # Mark y=0 if present in plot. 

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

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

1303 

1304 # Add some labels and tweak the style. 

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

1306 ax.set_xlabel( 

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

1308 ) 

1309 ax.set_title(title, fontsize=16) 

1310 ax.ticklabel_format(axis="x") 

1311 ax.tick_params(axis="y") 

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

1313 

1314 # Add gridlines 

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

1316 # Ientify unique labels for legend 

1317 handles = list( 

1318 { 

1319 label: handle 

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

1321 }.values() 

1322 ) 

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

1324 

1325 # Save plot. 

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

1327 

1328 

1329def _plot_and_save_scatter_plot( 

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

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

1332 filename: str, 

1333 title: str, 

1334 one_to_one: bool, 

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

1336 **kwargs, 

1337): 

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

1339 

1340 Parameters 

1341 ---------- 

1342 cube_x: Cube | CubeList 

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

1344 cube_y: Cube | CubeList 

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

1346 filename: str 

1347 Filename of the plot to write. 

1348 title: str 

1349 Plot title. 

1350 one_to_one: bool 

1351 Whether a 1:1 line is plotted. 

1352 """ 

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

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

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

1356 # over the pairs simultaneously. 

1357 

1358 # Ensure cube_x and cube_y are iterable 

1359 cube_x_iterable = iter_maybe(cube_x) 

1360 cube_y_iterable = iter_maybe(cube_y) 

1361 

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

1363 iplt.scatter(cube_x_iter, cube_y_iter) 

1364 if one_to_one is True: 

1365 plt.plot( 

1366 [ 

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

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

1369 ], 

1370 [ 

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

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

1373 ], 

1374 "k", 

1375 linestyle="--", 

1376 ) 

1377 ax = plt.gca() 

1378 

1379 # Add some labels and tweak the style. 

1380 if model_names is None: 

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

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

1383 else: 

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

1385 ax.set_xlabel( 

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

1387 ) 

1388 ax.set_ylabel( 

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

1390 ) 

1391 ax.set_title(title, fontsize=16) 

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

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

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

1395 ax.autoscale() 

1396 

1397 # Save plot. 

1398 _save_close_figure(fig, "scatter", filename) 

1399 

1400 

1401def _plot_and_save_vector_plot( 

1402 cube_u: iris.cube.Cube, 

1403 cube_v: iris.cube.Cube, 

1404 filename: str, 

1405 title: str, 

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

1407 **kwargs, 

1408): 

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

1410 

1411 Parameters 

1412 ---------- 

1413 cube_u: Cube 

1414 2 dimensional Cube of u component of the data. 

1415 cube_v: Cube 

1416 2 dimensional Cube of v component of the data. 

1417 filename: str 

1418 Filename of the plot to write. 

1419 title: str 

1420 Plot title. 

1421 """ 

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

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

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

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

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

1427 cube_vec_mag.rename( 

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

1429 ) 

1430 

1431 # Specify the color bar 

1432 cmap, levels, norm = colorbar_map_levels(cube_vec_mag) 

1433 

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

1435 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

1436 

1437 if method == "contourf": 

1438 # Filled contour plot of the field. 

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

1440 elif method == "pcolormesh": 

1441 try: 

1442 vmin = min(levels) 

1443 vmax = max(levels) 

1444 except TypeError: 

1445 vmin, vmax = None, None 

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

1447 # if levels are defined. 

1448 if norm is not None: 

1449 vmin = None 

1450 vmax = None 

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

1452 else: 

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

1454 

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

1456 if is_transect(cube_vec_mag): 

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

1458 axes.invert_yaxis() 

1459 axes.set_yscale("log") 

1460 axes.set_ylim(1100, 100) 

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

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

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

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

1465 ): 

1466 axes.set_yscale("log") 

1467 

1468 axes.set_title( 

1469 f"{title}\n" 

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

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

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

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

1474 fontsize=16, 

1475 ) 

1476 

1477 else: 

1478 # Add title. 

1479 axes.set_title(title, fontsize=16) 

1480 

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

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

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

1484 axes.annotate( 

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

1486 xy=(0.05, -0.05), 

1487 xycoords="axes fraction", 

1488 xytext=(-5, 5), 

1489 textcoords="offset points", 

1490 ha="right", 

1491 va="bottom", 

1492 size=11, 

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

1494 ) 

1495 

1496 # Add colour bar. 

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

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

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

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

1501 cbar.set_ticks(levels) 

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

1503 

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

1505 # with less than 30 points. 

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

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

1508 

1509 # Save plot. 

1510 _save_close_figure(fig, "vector", filename) 

1511 

1512 

1513def _plot_and_save_histogram_series( 

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

1515 filename: str, 

1516 title: str, 

1517 vmin: float, 

1518 vmax: float, 

1519 **kwargs, 

1520): 

1521 """Plot and save a histogram series. 

1522 

1523 Parameters 

1524 ---------- 

1525 cubes: Cube or CubeList 

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

1527 filename: str 

1528 Filename of the plot to write. 

1529 title: str 

1530 Plot title. 

1531 vmin: float 

1532 minimum for colorbar 

1533 vmax: float 

1534 maximum for colorbar 

1535 """ 

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

1537 ax = plt.gca() 

1538 

1539 model_colors_map = get_model_colors_map(cubes) 

1540 

1541 # Set default that histograms will produce probability density function 

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

1543 density = True 

1544 

1545 for cube in iter_maybe(cubes): 

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

1547 # than seeing if long names exist etc. 

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

1549 if ( 

1550 ("surface_microphysical" in title) 

1551 or ("rain accumulation" in title) 

1552 or ("Rainfall rate Composite" in title) 

1553 or ("Nimrod_5min" in title) 

1554 ): 

1555 if "amount" in title: 

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

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

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

1559 density = False 

1560 else: 

1561 bins = 10.0 ** ( 

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

1563 ) # Suggestion from RMED toolbox. 

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

1565 ax.set_yscale("log") 

1566 vmin = bins[1] 

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

1568 ax.set_xscale("log") 

1569 elif "lightning" in title: 

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

1571 else: 

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

1573 logger.debug( 

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

1575 np.size(bins), 

1576 np.min(bins), 

1577 np.max(bins), 

1578 ) 

1579 

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

1581 # Otherwise we plot xdim histograms stacked. 

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

1583 

1584 label = None 

1585 color = "black" 

1586 if model_colors_map: 

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

1588 color = model_colors_map[label] 

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

1590 

1591 # Compute area under curve. 

1592 if ( 

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

1594 or ("rain_accumulation" in title) 

1595 or ("Rainfall rate Composite" in title) 

1596 or ("Nimrod_5min" in title) 

1597 ): 

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

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

1600 x = x[1:] 

1601 y = y[1:] 

1602 

1603 ax.plot( 

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

1605 ) 

1606 

1607 # Add some labels and tweak the style. 

1608 ax.set_title(title, fontsize=16) 

1609 ax.set_xlabel( 

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

1611 ) 

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

1613 if ( 

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

1615 or ("rain accumulation" in title) 

1616 or ("Nimrod_5min" in title) 

1617 ): 

1618 ax.set_ylabel( 

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

1620 ) 

1621 try: 

1622 ax.set_xlim(vmin, vmax) 

1623 except ValueError: 

1624 pass 

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

1626 

1627 # Overlay grid-lines onto histogram plot. 

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

1629 if model_colors_map: 

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

1631 

1632 # Save plot. 

1633 _save_close_figure(fig, "histogram", filename) 

1634 

1635 

1636def _plot_and_save_postage_stamp_histogram_series( 

1637 cube: iris.cube.Cube, 

1638 filename: str, 

1639 title: str, 

1640 stamp_coordinate: str, 

1641 vmin: float, 

1642 vmax: float, 

1643 **kwargs, 

1644): 

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

1646 

1647 Parameters 

1648 ---------- 

1649 cube: Cube 

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

1651 filename: str 

1652 Filename of the plot to write. 

1653 title: str 

1654 Plot title. 

1655 stamp_coordinate: str 

1656 Coordinate that becomes different plots. 

1657 vmin: float 

1658 minimum for pdf x-axis 

1659 vmax: float 

1660 maximum for pdf x-axis 

1661 """ 

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

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

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

1665 grid_size = math.ceil(nmember / grid_rows) 

1666 

1667 fig = plt.figure( 

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

1669 ) 

1670 # Make a subplot for each member. 

1671 for member, subplot in zip( 

1672 cube.slices_over(stamp_coordinate), 

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

1674 strict=False, 

1675 ): 

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

1677 # cartopy GeoAxes generated. 

1678 plt.subplot(grid_rows, grid_size, subplot) 

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

1680 # Otherwise we plot xdim histograms stacked. 

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

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

1683 axes = plt.gca() 

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

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

1686 axes.set_xlim(vmin, vmax) 

1687 

1688 # Overall figure title. 

1689 fig.suptitle(title, fontsize=16) 

1690 

1691 # Save plot. 

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

1693 

1694 

1695def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1696 cube: iris.cube.Cube, 

1697 filename: str, 

1698 title: str, 

1699 stamp_coordinate: str, 

1700 vmin: float, 

1701 vmax: float, 

1702 **kwargs, 

1703): 

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

1705 ax.set_title(title, fontsize=16) 

1706 ax.set_xlim(vmin, vmax) 

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

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

1709 # Loop over all slices along the stamp_coordinate 

1710 for member in cube.slices_over(stamp_coordinate): 

1711 # Flatten the member data to 1D 

1712 member_data_1d = member.data.flatten() 

1713 # Plot the histogram using plt.hist 

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

1715 plt.hist( 

1716 member_data_1d, 

1717 density=True, 

1718 stacked=True, 

1719 label=f"{mtitle}", 

1720 ) 

1721 

1722 # Add a legend 

1723 ax.legend(fontsize=16) 

1724 

1725 # Save plot. 

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

1727 

1728 

1729def _plot_and_save_scatter_series( 

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

1731 filename: str, 

1732 title: str, 

1733 vmin: float, 

1734 vmax: float, 

1735 hexbin: bool, 

1736 **kwargs, 

1737): 

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

1739 

1740 Parameters 

1741 ---------- 

1742 cubes: Cube or CubeList 

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

1744 filename: str 

1745 Filename of the plot to write. 

1746 title: str 

1747 Plot title. 

1748 vmin: float 

1749 minimum for colorbar 

1750 vmax: float 

1751 maximum for colorbar 

1752 hexbin: bool 

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

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

1755 """ 

1756 if hexbin: 

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

1758 if len(cubes) != 2: 

1759 raise ValueError( 

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

1761 ) 

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

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

1764 

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

1766 ax = plt.gca() 

1767 

1768 model_colors_map = get_model_colors_map(cubes) 

1769 

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

1771 percentiles[0] = 1 

1772 percentiles[-1] = 99 

1773 quantiles = iris.cube.CubeList() 

1774 

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

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

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

1778 nplot = 0 

1779 for cube in iter_maybe(cubes): 

1780 label = None 

1781 color = "black" 

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

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

1784 color = model_colors_map[label] 

1785 

1786 # Plot all data points 

1787 if plottype == "points": 

1788 if nplot > 0: 

1789 if hexbin: 

1790 hb = plt.hexbin( 

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

1792 cube.data.flatten(), 

1793 alpha=0.3, 

1794 gridsize=100, 

1795 mincnt=1, 

1796 ) 

1797 else: 

1798 plt.scatter( 

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

1800 cube.data.flatten(), 

1801 color=color, 

1802 marker="+", 

1803 label=None, 

1804 alpha=0.3, 

1805 ) 

1806 

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

1808 # Construct Q-Q plot 

1809 quantiles.append( 

1810 cube.collapsed( 

1811 cube.coords(dim_coords=True), 

1812 iris.analysis.PERCENTILE, 

1813 percent=percentiles, 

1814 ) 

1815 ) 

1816 if nplot > 0: 

1817 iplt.scatter( 

1818 quantiles[0], 

1819 quantiles[-1], 

1820 color=color, 

1821 marker="o", 

1822 label=label, 

1823 edgecolors="black", 

1824 ) 

1825 

1826 nplot = nplot + 1 

1827 

1828 # Add some labels and tweak the style. 

1829 ax.set_title(title, fontsize=16) 

1830 ax.set_xlabel( 

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

1832 ) 

1833 ax.set_ylabel( 

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

1835 ) 

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

1837 ax.autoscale() 

1838 

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

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

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

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

1843 lims = [ 

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

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

1846 ] 

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

1848 ax.set_aspect("equal") 

1849 ax.set_xlim(lims) 

1850 ax.set_ylim(lims) 

1851 

1852 # Overlay grid-lines onto scatter plot. 

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

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

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

1856 

1857 # Add colorbar if hexbin output 

1858 if hexbin: 

1859 cb = plt.colorbar( 

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

1861 ) 

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

1863 

1864 # Save plot. 

1865 _save_close_figure(fig, "scatter", filename) 

1866 

1867 

1868def _spatial_plot( 

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

1870 cube: iris.cube.Cube, 

1871 filename: str | None, 

1872 sequence_coordinate: str, 

1873 stamp_coordinate: str, 

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

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

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

1877 **kwargs, 

1878): 

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

1880 

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

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

1883 is present then postage stamp plots will be produced. 

1884 

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

1886 be overplotted on the same figure. 

1887 

1888 Parameters 

1889 ---------- 

1890 method: "contourf" | "pcolormesh" | "scatter" 

1891 The plotting method to use. 

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

1893 Use "scatter" for point-based data. 

1894 cube: Cube 

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

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

1897 plotted sequentially and/or as postage stamp plots. 

1898 filename: str | None 

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

1900 uses the recipe name. 

1901 sequence_coordinate: str 

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

1903 This coordinate must exist in the cube. 

1904 stamp_coordinate: str 

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

1906 ``"realization"``. 

1907 overlay_cube: Cube | None, optional 

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

1909 contour_cube: Cube | None, optional 

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

1911 point_cube: Cube | None, optional 

1912 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 

1913 

1914 Raises 

1915 ------ 

1916 ValueError 

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

1918 TypeError 

1919 If the cube isn't a single cube. 

1920 """ 

1921 # Ensure we've got a single cube. 

1922 cube = check_single_cube(cube) 

1923 

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

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

1926 

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

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

1929 stamp_coordinate = check_stamp_coordinate(cube) 

1930 

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

1932 # single point. 

1933 plotting_func = _plot_and_save_spatial_plot 

1934 try: 

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

1936 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1937 except iris.exceptions.CoordinateNotFoundError: 

1938 pass 

1939 

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

1941 # dimension called observation or model_obs_error 

1942 if any( 

1943 crd.var_name == "station" 

1944 or crd.var_name == "Station_Name" 

1945 or crd.var_name == "model_obs_error" 

1946 for crd in cube.coords() 

1947 ): 

1948 plotting_func = _plot_and_save_spatial_plot 

1949 method = "scatter" 

1950 

1951 # Must have a sequence coordinate. 

1952 try: 

1953 cube.coord(sequence_coordinate) 

1954 except iris.exceptions.CoordinateNotFoundError as err: 

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

1956 

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

1958 plot_index = [] 

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

1960 

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

1962 # Set plot titles and filename 

1963 seq_coord = cube_slice.coord(sequence_coordinate) 

1964 

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

1966 model_name = cube.attributes["model_name"] 

1967 else: 

1968 model_name = None 

1969 

1970 plot_title, plot_filename = _set_title_and_filename( 

1971 seq_coord, nplot, recipe_title, filename, model_name=model_name 

1972 ) 

1973 

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

1975 overlay_slice = slice_over_maybe(overlay_cube, sequence_coordinate, iseq) 

1976 contour_slice = slice_over_maybe(contour_cube, sequence_coordinate, iseq) 

1977 point_slice = slice_over_maybe(point_cube, sequence_coordinate, iseq) 

1978 

1979 # Do the actual plotting. 

1980 plotting_func( 

1981 cube_slice, 

1982 filename=plot_filename, 

1983 stamp_coordinate=stamp_coordinate, 

1984 title=plot_title, 

1985 method=method, 

1986 overlay_cube=overlay_slice, 

1987 contour_cube=contour_slice, 

1988 point_cube=point_slice, 

1989 **kwargs, 

1990 ) 

1991 plot_index.append(plot_filename) 

1992 

1993 # Add list of plots to plot metadata. 

1994 complete_plot_index = _append_to_plot_index(plot_index) 

1995 

1996 # Make a page to display the plots. 

1997 _make_plot_html_page(complete_plot_index) 

1998 

1999 

2000#################### 

2001# Public functions # 

2002#################### 

2003 

2004 

2005def spatial_contour_plot( 

2006 cube: iris.cube.Cube, 

2007 filename: str | None = None, 

2008 sequence_coordinate: str = "time", 

2009 stamp_coordinate: str = "realization", 

2010 **kwargs, 

2011) -> iris.cube.Cube: 

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

2013 

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

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

2016 is present then postage stamp plots will be produced. 

2017 

2018 Parameters 

2019 ---------- 

2020 cube: Cube 

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

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

2023 plotted sequentially and/or as postage stamp plots. 

2024 filename: str, optional 

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

2026 to the recipe name. 

2027 sequence_coordinate: str, optional 

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

2029 This coordinate must exist in the cube. 

2030 stamp_coordinate: str, optional 

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

2032 ``"realization"``. 

2033 

2034 Returns 

2035 ------- 

2036 Cube 

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

2038 

2039 Raises 

2040 ------ 

2041 ValueError 

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

2043 TypeError 

2044 If the cube isn't a single cube. 

2045 """ 

2046 _spatial_plot( 

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

2048 ) 

2049 return cube 

2050 

2051 

2052def spatial_pcolormesh_plot( 

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

2054 filename: str | None = None, 

2055 sequence_coordinate: str = "time", 

2056 stamp_coordinate: str = "realization", 

2057 **kwargs, 

2058) -> iris.cube.Cube: 

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

2060 

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

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

2063 is present then postage stamp plots will be produced. 

2064 

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

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

2067 contour areas are important. 

2068 

2069 Parameters 

2070 ---------- 

2071 cube: Cubes 

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

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

2074 plotted sequentially and/or as postage stamp plots. 

2075 filename: str, optional 

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

2077 to the recipe name. 

2078 sequence_coordinate: str, optional 

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

2080 This coordinate must exist in the cube. 

2081 stamp_coordinate: str, optional 

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

2083 ``"realization"``. 

2084 

2085 Returns 

2086 ------- 

2087 Cubes 

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

2089 

2090 Raises 

2091 ------ 

2092 ValueError 

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

2094 """ 

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

2096 for model_cube in cubes: 

2097 _spatial_plot( 

2098 "pcolormesh", 

2099 model_cube, 

2100 filename, 

2101 sequence_coordinate, 

2102 stamp_coordinate, 

2103 **kwargs, 

2104 ) 

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

2106 _spatial_plot( 

2107 "pcolormesh", 

2108 cubes, 

2109 filename, 

2110 sequence_coordinate, 

2111 stamp_coordinate, 

2112 **kwargs, 

2113 ) 

2114 return cubes 

2115 

2116 

2117def spatial_multi_pcolormesh_plot( 

2118 cube: iris.cube.Cube, 

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

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

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

2122 filename: str | None = None, 

2123 sequence_coordinate: str = "time", 

2124 stamp_coordinate: str = "realization", 

2125 **kwargs, 

2126) -> iris.cube.Cube: 

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

2128 

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

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

2131 is present then postage stamp plots will be produced. 

2132 

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

2134 

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

2136 

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

2138 

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

2140 

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

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

2143 contour areas are important. 

2144 

2145 Parameters 

2146 ---------- 

2147 cube: Cube 

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

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

2150 plotted sequentially and/or as postage stamp plots. 

2151 overlay_cube: Cube, optional 

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

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

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

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

2156 contour_cube: Cube, optional 

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

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

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

2160 point_cube: Cube, optional 

2161 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 

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

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

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

2165 filename: str, optional 

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

2167 to the recipe name. 

2168 sequence_coordinate: str, optional 

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

2170 This coordinate must exist in the cube. 

2171 stamp_coordinate: str, optional 

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

2173 ``"realization"``. 

2174 

2175 Returns 

2176 ------- 

2177 Cube 

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

2179 

2180 Raises 

2181 ------ 

2182 ValueError 

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

2184 TypeError 

2185 If the cube isn't a single cube. 

2186 """ 

2187 _spatial_plot( 

2188 "pcolormesh", 

2189 cube, 

2190 filename, 

2191 sequence_coordinate, 

2192 stamp_coordinate, 

2193 overlay_cube=overlay_cube, 

2194 contour_cube=contour_cube, 

2195 point_cube=point_cube, 

2196 ) 

2197 return cube, overlay_cube, contour_cube, point_cube 

2198 

2199 

2200# TODO: Expand function to handle ensemble data. 

2201# line_coordinate: str, optional 

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

2203# ``"realization"``. 

2204def plot_line_series( 

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

2206 filename: str | None = None, 

2207 series_coordinate: str = "time", 

2208 sequence_coordinate: str = "time", 

2209 # add the following for ensembles 

2210 stamp_coordinate: str = "realization", 

2211 single_plot: bool = False, 

2212 **kwargs, 

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

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

2215 

2216 The Cube or CubeList must be 1D. 

2217 

2218 Parameters 

2219 ---------- 

2220 iris.cube | iris.cube.CubeList 

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

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

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

2224 filename: str, optional 

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

2226 to the recipe name. 

2227 series_coordinate: str, optional 

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

2229 coordinate must exist in the cube. 

2230 

2231 Returns 

2232 ------- 

2233 iris.cube.Cube | iris.cube.CubeList 

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

2235 

2236 Raises 

2237 ------ 

2238 ValueError 

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

2240 TypeError 

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

2242 """ 

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

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

2245 

2246 num_models = get_num_models(cube) 

2247 

2248 validate_cube_shape(cube, num_models) 

2249 

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

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

2252 

2253 coords = [] 

2254 for model_cube in cubes: 

2255 try: 

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

2257 except iris.exceptions.CoordinateNotFoundError as err: 

2258 raise ValueError( 

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

2260 ) from err 

2261 # Count cube dimensions and exclude realization and 

2262 # forecast_reference_time if they exist. 

2263 ndim = model_cube.ndim 

2264 

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

2266 # returns coord dimension 

2267 realization_dims = model_cube.coord_dims("realization") 

2268 

2269 # Only subtract if realization is a dimension coordinate 

2270 if realization_dims: 

2271 ndim -= len(realization_dims) 

2272 

2273 if model_cube.coords("forecast_reference_time"): 

2274 frt_dims = model_cube.coord_dims("forecast_reference_time") 

2275 

2276 # Only subtract if frt is a dimension coordinate 

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

2278 ndim -= len(frt_dims) 

2279 

2280 if ndim > 2: 

2281 raise ValueError( 

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

2283 ) 

2284 

2285 plot_index = [] 

2286 

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

2288 is_spectral_plot = series_coordinate in [ 

2289 "frequency", 

2290 "physical_wavenumber", 

2291 "wavelength", 

2292 ] 

2293 

2294 if is_spectral_plot: 

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

2296 # coordinate frequency/wavenumber. 

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

2298 # time slider option. 

2299 

2300 # Internal plotting function. 

2301 plotting_func = _plot_and_save_line_power_spectrum_series 

2302 

2303 for model_cube in cubes: 

2304 try: 

2305 model_cube.coord(sequence_coordinate) 

2306 except iris.exceptions.CoordinateNotFoundError as err: 

2307 raise ValueError( 

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

2309 ) from err 

2310 

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

2312 # check for ensembles 

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

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

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

2316 ): 

2317 if single_plot: 

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

2319 plotting_func = _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series 

2320 else: 

2321 # Plot postage stamps 

2322 plotting_func = _plot_and_save_postage_stamp_power_spectrum_series 

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

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

2325 else: 

2326 all_points = sorted( 

2327 set( 

2328 itertools.chain.from_iterable( 

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

2330 ) 

2331 ) 

2332 ) 

2333 all_slices = list( 

2334 itertools.chain.from_iterable( 

2335 cb.slices_over(sequence_coordinate) for cb in cubes 

2336 ) 

2337 ) 

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

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

2340 # necessary) 

2341 cube_iterables = [ 

2342 iris.cube.CubeList( 

2343 s 

2344 for s in all_slices 

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

2346 ) 

2347 for point in all_points 

2348 ] 

2349 nplot = len(all_points) 

2350 

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

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

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

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

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

2356 

2357 for cube_slice in cube_iterables: 

2358 # Normalize cube_slice to a list of cubes 

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

2360 cubes = list(cube_slice) 

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

2362 cubes = [cube_slice] 

2363 else: 

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

2365 

2366 # Use sequence value so multiple sequences can merge. 

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

2368 plot_title, plot_filename = _set_title_and_filename( 

2369 seq_coord, nplot, recipe_title, filename 

2370 ) 

2371 

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

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

2374 

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

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

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

2378 

2379 # Do the actual plotting. 

2380 plotting_func( 

2381 cube_slice, 

2382 coords, 

2383 stamp_coordinate, 

2384 plot_filename, 

2385 title, 

2386 series_coordinate, 

2387 ) 

2388 

2389 plot_index.append(plot_filename) 

2390 else: 

2391 # Format the title and filename using plotted series coordinate 

2392 nplot = 1 

2393 seq_coord = coords[0] 

2394 plot_title, plot_filename = _set_title_and_filename( 

2395 seq_coord, nplot, recipe_title, filename 

2396 ) 

2397 

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

2399 if ( 

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

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

2402 ): 

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

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

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

2406 station_plotname = plot_filename.replace( 

2407 ".png", "_" + station_name + ".png" 

2408 ) 

2409 _plot_and_save_line_series( 

2410 station_cubes, 

2411 coords, 

2412 "realization", 

2413 station_plotname, 

2414 f"{plot_title} {station_name}", 

2415 ) 

2416 plot_index.append(station_plotname) 

2417 

2418 else: 

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

2420 _plot_and_save_line_series( 

2421 cubes, coords, stamp_coordinate, plot_filename, plot_title 

2422 ) 

2423 

2424 plot_index.append(plot_filename) 

2425 

2426 # append plot to list of plots 

2427 complete_plot_index = _append_to_plot_index(plot_index) 

2428 

2429 # Make a page to display the plots. 

2430 _make_plot_html_page(complete_plot_index) 

2431 

2432 return cube 

2433 

2434 

2435def plot_vertical_line_series( 

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

2437 filename: str | None = None, 

2438 series_coordinate: str = "model_level_number", 

2439 sequence_coordinate: str = "time", 

2440 # line_coordinate: str = "realization", 

2441 **kwargs, 

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

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

2444 

2445 The Cube or CubeList must be 1D. 

2446 

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

2448 then a sequence of plots will be produced. 

2449 

2450 Parameters 

2451 ---------- 

2452 iris.cube | iris.cube.CubeList 

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

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

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

2456 filename: str, optional 

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

2458 to the recipe name. 

2459 series_coordinate: str, optional 

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

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

2462 for LFRic. Defaults to ``model_level_number``. 

2463 This coordinate must exist in the cube. 

2464 sequence_coordinate: str, optional 

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

2466 This coordinate must exist in the cube. 

2467 

2468 Returns 

2469 ------- 

2470 iris.cube.Cube | iris.cube.CubeList 

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

2472 Plotted data. 

2473 

2474 Raises 

2475 ------ 

2476 ValueError 

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

2478 TypeError 

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

2480 """ 

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

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

2483 

2484 cubes = iter_maybe(cubes) 

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

2486 all_data = [] 

2487 

2488 # Store min/max ranges for x range. 

2489 x_levels = [] 

2490 

2491 num_models = get_num_models(cubes) 

2492 

2493 validate_cube_shape(cubes, num_models) 

2494 

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

2496 coords = [] 

2497 for cube in cubes: 

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

2499 try: 

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

2501 except iris.exceptions.CoordinateNotFoundError as err: 

2502 raise ValueError( 

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

2504 ) from err 

2505 

2506 try: 

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

2508 cube.coord(sequence_coordinate) 

2509 except iris.exceptions.CoordinateNotFoundError as err: 

2510 raise ValueError( 

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

2512 ) from err 

2513 

2514 # Get minimum and maximum from levels information. 

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

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

2517 x_levels.append(min(levels)) 

2518 x_levels.append(max(levels)) 

2519 else: 

2520 all_data.append(cube.data) 

2521 

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

2523 # Combine all data into a single NumPy array 

2524 combined_data = np.concatenate(all_data) 

2525 

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

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

2528 # sequence and if applicable postage stamp coordinate. 

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

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

2531 else: 

2532 vmin = min(x_levels) 

2533 vmax = max(x_levels) 

2534 

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

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

2537 sequence_coords = [ 

2538 cube.coord(sequence_coordinate) 

2539 for cube in cubes 

2540 if cube.coords(sequence_coordinate) 

2541 ] 

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

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

2544 ) 

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

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

2547 ) 

2548 

2549 plot_index = [] 

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

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

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

2553 # necessary) 

2554 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

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

2556 for cubes_slice in cube_iterables: 

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

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

2559 plot_title, plot_filename = _set_title_and_filename( 

2560 seq_coord, nplot, recipe_title, filename 

2561 ) 

2562 

2563 # Do the actual plotting. 

2564 _plot_and_save_vertical_line_series( 

2565 cubes_slice, 

2566 coords, 

2567 "realization", 

2568 plot_filename, 

2569 series_coordinate, 

2570 title=plot_title, 

2571 vmin=vmin, 

2572 vmax=vmax, 

2573 ) 

2574 plot_index.append(plot_filename) 

2575 elif has_scalar_sequence_coord: 

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

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

2578 plot_title, plot_filename = _set_title_and_filename( 

2579 sequence_coords[0], 1, recipe_title, filename 

2580 ) 

2581 

2582 _plot_and_save_vertical_line_series( 

2583 cubes, 

2584 coords, 

2585 "realization", 

2586 plot_filename, 

2587 series_coordinate, 

2588 title=plot_title, 

2589 vmin=vmin, 

2590 vmax=vmax, 

2591 ) 

2592 plot_index.append(plot_filename) 

2593 else: 

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

2595 plot_title = recipe_title 

2596 if filename: 

2597 plot_filename = filename 

2598 else: 

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

2600 

2601 _plot_and_save_vertical_line_series( 

2602 cubes, 

2603 coords, 

2604 "realization", 

2605 plot_filename, 

2606 series_coordinate, 

2607 title=plot_title, 

2608 vmin=vmin, 

2609 vmax=vmax, 

2610 ) 

2611 plot_index.append(plot_filename) 

2612 

2613 # Add list of plots to plot metadata. 

2614 complete_plot_index = _append_to_plot_index(plot_index) 

2615 

2616 # Make a page to display the plots. 

2617 _make_plot_html_page(complete_plot_index) 

2618 

2619 return cubes 

2620 

2621 

2622def qq_plot( 

2623 cubes: iris.cube.CubeList, 

2624 coordinates: list[str], 

2625 percentiles: list[float], 

2626 model_names: list[str], 

2627 filename: str | None = None, 

2628 one_to_one: bool = True, 

2629 **kwargs, 

2630) -> iris.cube.CubeList: 

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

2632 

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

2634 collapsed within the operator over all specified coordinates such as 

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

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

2637 

2638 Parameters 

2639 ---------- 

2640 cubes: iris.cube.CubeList 

2641 Two cubes of the same variable with different models. 

2642 coordinate: list[str] 

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

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

2645 the percentile coordinate. 

2646 percent: list[float] 

2647 A list of percentiles to appear in the plot. 

2648 model_names: list[str] 

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

2650 filename: str, optional 

2651 Filename of the plot to write. 

2652 one_to_one: bool, optional 

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

2654 

2655 Raises 

2656 ------ 

2657 ValueError 

2658 When the cubes are not compatible. 

2659 

2660 Notes 

2661 ----- 

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

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

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

2665 compares percentiles of two datasets. This plot does 

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

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

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

2669 

2670 Quantile-quantile plots are valuable for comparing against 

2671 observations and other models. Identical percentiles between the variables 

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

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

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

2675 Wilks 2011 [Wilks2011]_). 

2676 

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

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

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

2680 the extremes. 

2681 

2682 """ 

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

2684 if len(cubes) != 2: 

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

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

2687 other: Cube = cubes.extract_cube( 

2688 iris.Constraint( 

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

2690 ) 

2691 ) 

2692 

2693 # Get spatial coord names. 

2694 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

2695 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

2696 

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

2698 # This is triggered if either 

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

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

2701 # errors. 

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

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

2704 # for UM and LFRic comparisons. 

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

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

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

2708 # given this dependency on regridding. 

2709 if ( 

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

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

2712 ) or ( 

2713 base.long_name 

2714 in [ 

2715 "eastward_wind_at_10m", 

2716 "northward_wind_at_10m", 

2717 "northward_wind_at_cell_centres", 

2718 "eastward_wind_at_cell_centres", 

2719 "zonal_wind_at_pressure_levels", 

2720 "meridional_wind_at_pressure_levels", 

2721 "potential_vorticity_at_pressure_levels", 

2722 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

2723 ] 

2724 ): 

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

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

2727 

2728 # Extract just common time points. 

2729 base, other = _extract_common_time_points(base, other) 

2730 

2731 # Equalise attributes so we can merge. 

2732 fully_equalise_attributes([base, other]) 

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

2734 

2735 # Collapse cubes. 

2736 base = collapse( 

2737 base, 

2738 coordinate=coordinates, 

2739 method="PERCENTILE", 

2740 additional_percent=percentiles, 

2741 ) 

2742 other = collapse( 

2743 other, 

2744 coordinate=coordinates, 

2745 method="PERCENTILE", 

2746 additional_percent=percentiles, 

2747 ) 

2748 

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

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

2751 title = f"{recipe_title}" 

2752 

2753 if filename is None: 

2754 filename = slugify(recipe_title) 

2755 

2756 # Add file extension. 

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

2758 

2759 # Do the actual plotting on a scatter plot 

2760 _plot_and_save_scatter_plot( 

2761 base, other, plot_filename, title, one_to_one, model_names 

2762 ) 

2763 

2764 # Add list of plots to plot metadata. 

2765 plot_index = _append_to_plot_index([plot_filename]) 

2766 

2767 # Make a page to display the plots. 

2768 _make_plot_html_page(plot_index) 

2769 

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

2771 

2772 

2773def hinton_plot( 

2774 cubes: iris.cube.CubeList, base_name: str, other_name: str, magnitude: bool = False 

2775) -> None: 

2776 """ 

2777 Plot a Hinton style triangle/scorecard plot. 

2778 

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

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

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

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

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

2784 

2785 Parameters 

2786 ---------- 

2787 cubes: iris.cube.CubeList 

2788 A iris cubelist, containing at least two cubes of a skill metric to plot (model vs obs). Can 

2789 include multiple variables, in which the plot will automatically scale for. If cubes containing 

2790 a name significance_<var> exist, containing a bool array, then it will also plot whether each 

2791 triangle is significant by using a thick black outline. Each cube should be 1D, with 

2792 forecast_period as the only dimension. 

2793 base_name: str 

2794 The name of the base model to use as the control in the Hinton plot, as a string. 

2795 other_name: str 

2796 The name of the other model to use as the test in the Hinton plot, as a string. 

2797 magnitude: bool 

2798 Option bool when if True, then plot the numerical value difference in two values under each 

2799 triangle. 

2800 """ 

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

2802 recipe_title = get_recipe_metadata().get("title", "Hinton") 

2803 title = f"{recipe_title}" 

2804 filename = slugify(recipe_title) 

2805 

2806 # Check that all cubes only have one dimension called forecast_period 

2807 for cube in cubes: 

2808 if len(cube.dim_coords) > 1: 

2809 raise ValueError(f"Should only have one dimension coord, {cube}") 

2810 if cube.dim_coords[0].name() != "forecast_period": 

2811 raise ValueError( 

2812 f"Single coord should be forecast_period, not {cube.dim_coords[0].name()}" 

2813 ) 

2814 

2815 # Separate out base cubes and other cubes. 

2816 base_cubes = iris.cube.CubeList() 

2817 other_cubes = iris.cube.CubeList() 

2818 for c in cubes: 

2819 if c.attributes["model_name"] == base_name: 

2820 base_cubes.append(c) 

2821 elif c.attributes["model_name"] == other_name: 

2822 other_cubes.append(c) 

2823 

2824 # base cubes should be the same length as other cubes, otherwise one is missing a variable. 

2825 if len(base_cubes) != len(other_cubes): 

2826 raise ValueError( 

2827 f"base cubes {base_cubes} are not same number as {other_cubes}" 

2828 ) 

2829 

2830 # Find common variable names in the two groups. 

2831 base_vars = {cube.long_name for cube in base_cubes if cube.long_name is not None} 

2832 other_vars = {cube.long_name for cube in other_cubes if cube.long_name is not None} 

2833 common_vars = sorted(base_vars & other_vars) 

2834 

2835 # Iterate over each variable (row) 

2836 rows = [] 

2837 for var in common_vars: 

2838 # Extract cube with matching variable name 

2839 base_cube = next( 

2840 (c for c in base_cubes if c.long_name == var), 

2841 None, 

2842 ) 

2843 

2844 other_cube = next( 

2845 (c for c in other_cubes if c.long_name == var), 

2846 None, 

2847 ) 

2848 

2849 # If we can't find a variable in both cubes, then skip 

2850 if base_cube is None or other_cube is None: 2850 ↛ 2851line 2850 didn't jump to line 2851 because the condition on line 2850 was never true

2851 continue 

2852 

2853 # Compute difference (1D array) 

2854 # We can already make assumption both on same forecast_periods as checked 

2855 # prior to computing metric. 

2856 diff = other_cube.data - base_cube.data 

2857 

2858 # See if there is a significance cube present, if not, set as None. 

2859 sig_cube = next( 

2860 (cube for cube in cubes if cube.long_name == f"significance_{var}"), 

2861 None, 

2862 ) 

2863 

2864 # Append row information. 

2865 rows.append( 

2866 { 

2867 "name": var, 

2868 "forecast_periods": base_cube.coord("forecast_period").points, 

2869 "change": diff, 

2870 "significance": sig_cube.data.astype(bool) 

2871 if sig_cube is not None 

2872 else None, 

2873 } 

2874 ) 

2875 

2876 # For each row, compute standardised anomalies 

2877 for row in rows: 

2878 change = np.asarray(row["change"]) 

2879 

2880 anoms = change - np.mean(change) 

2881 

2882 scale = np.max(np.abs(anoms)) 

2883 

2884 if scale > 0: 

2885 scaled = anoms / scale 

2886 else: 

2887 scaled = np.zeros_like(anoms) 

2888 

2889 row["anoms"] = anoms 

2890 row["scaled"] = scaled 

2891 

2892 # Setup colors of triangles 

2893 color_pos = "#7CAE00" 

2894 color_neg = "#7B68EE" 

2895 

2896 # Setup cell/text size ratios 

2897 figsize = None 

2898 cell_size_in = 1.5 

2899 text_row_ratio = 0.25 

2900 

2901 # Get the number of x and y elements 

2902 ny = len(rows) 

2903 nx = max(len(row["forecast_periods"]) for row in rows) 

2904 

2905 # Build non-uniform y coordinates 

2906 tri_height = 1.0 

2907 txt_height = text_row_ratio 

2908 

2909 tri_y = [] 

2910 txt_y = [] 

2911 y_edges = [0.0] 

2912 

2913 y = 0.0 

2914 for _j in range(ny): 

2915 tri_y.append(y + tri_height / 2) 

2916 y += tri_height 

2917 y_edges.append(y) 

2918 

2919 if magnitude: 

2920 txt_y.append(y + txt_height / 2) 

2921 y += txt_height 

2922 y_edges.append(y) 

2923 

2924 total_height = y 

2925 

2926 # Dynamic figure size 

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

2928 width = nx * cell_size_in 

2929 height = total_height * cell_size_in + 2 

2930 figsize = (width, height) 

2931 

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

2933 

2934 # Setup axes and grid. 

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

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

2937 ax.set_ylim(0, total_height) 

2938 

2939 longest_row = max(rows, key=lambda row: len(row["forecast_periods"])) 

2940 

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

2942 ax.set_xticklabels( 

2943 longest_row["forecast_periods"], 

2944 rotation=90, 

2945 ) 

2946 

2947 ax.set_yticks(tri_y) 

2948 ax.set_yticklabels([row["name"] for row in rows]) 

2949 

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

2951 ax.set_yticks(y_edges, minor=True) 

2952 

2953 ax.set_axisbelow(True) 

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

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

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

2957 

2958 ax.invert_yaxis() 

2959 

2960 # Compute marker scaling (fixed overlap) 

2961 fig.canvas.draw() 

2962 

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

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

2965 

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

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

2968 cell_pixels = min(cell_w, cell_h) 

2969 

2970 max_marker_size = (0.6 * cell_pixels) ** 2 

2971 

2972 text_fontsize = cell_pixels * 0.15 

2973 

2974 # Plot triangles + text 

2975 for j, row in enumerate(rows): 

2976 scaled = row["scaled"] 

2977 anoms = row["anoms"] 

2978 signif = row["significance"] 

2979 

2980 for i in range(len(scaled)): 

2981 val = scaled[i] 

2982 

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

2984 continue 

2985 

2986 if abs(val) < 0.01: 

2987 continue 

2988 

2989 size = max_marker_size * abs(val) 

2990 

2991 if val >= 0: 

2992 marker = "^" 

2993 color = color_pos 

2994 else: 

2995 marker = "v" 

2996 color = color_neg 

2997 

2998 if signif is not None: 

2999 sig = bool(signif[i]) 

3000 edgecolor = "black" if sig else "none" 

3001 linewidth = 0.6 if sig else 0.0 

3002 else: 

3003 edgecolor = "none" 

3004 linewidth = 0.0 

3005 

3006 ax.scatter( 

3007 i, 

3008 tri_y[j], 

3009 s=size, 

3010 marker=marker, 

3011 c=color, 

3012 edgecolors=edgecolor, 

3013 linewidths=linewidth, 

3014 zorder=3, 

3015 clip_on=True, 

3016 ) 

3017 

3018 # Text row 

3019 if magnitude: 

3020 mag_val = anoms[i] 

3021 

3022 if not np.isnan(mag_val): 3022 ↛ 2980line 3022 didn't jump to line 2980 because the condition on line 3022 was always true

3023 ax.text( 

3024 i, 

3025 txt_y[j], 

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

3027 ha="center", 

3028 va="center", 

3029 fontsize=text_fontsize, 

3030 color="black", 

3031 zorder=4, 

3032 ) 

3033 

3034 ax.set_title(title) 

3035 plt.tight_layout() 

3036 

3037 # Save plot. 

3038 _save_close_figure(fig, "hinton", filename) 

3039 

3040 # Add file extension. 

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

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 

3050def scatter_plot( 

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

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

3053 filename: str | None = None, 

3054 one_to_one: bool = True, 

3055 **kwargs, 

3056) -> iris.cube.CubeList: 

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

3058 

3059 Both cubes must be 1D. 

3060 

3061 Parameters 

3062 ---------- 

3063 cube_x: Cube | CubeList 

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

3065 cube_y: Cube | CubeList 

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

3067 filename: str, optional 

3068 Filename of the plot to write. 

3069 one_to_one: bool, optional 

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

3071 

3072 Returns 

3073 ------- 

3074 cubes: CubeList 

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

3076 

3077 Raises 

3078 ------ 

3079 ValueError 

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

3081 size. 

3082 TypeError 

3083 If the cube isn't a single cube. 

3084 

3085 Notes 

3086 ----- 

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

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

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

3090 """ 

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

3092 for cube_iter in iter_maybe(cube_x): 

3093 # Check cubes are correct shape. 

3094 cube_iter = check_single_cube(cube_iter) 

3095 if cube_iter.ndim > 1: 

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

3097 

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

3099 for cube_iter in iter_maybe(cube_y): 

3100 # Check cubes are correct shape. 

3101 cube_iter = check_single_cube(cube_iter) 

3102 if cube_iter.ndim > 1: 

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

3104 

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

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

3107 title = f"{recipe_title}" 

3108 

3109 if filename is None: 

3110 filename = slugify(recipe_title) 

3111 

3112 # Add file extension. 

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

3114 

3115 # Do the actual plotting. 

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

3117 

3118 # Add list of plots to plot metadata. 

3119 plot_index = _append_to_plot_index([plot_filename]) 

3120 

3121 # Make a page to display the plots. 

3122 _make_plot_html_page(plot_index) 

3123 

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

3125 

3126 

3127def vector_plot( 

3128 cube_u: iris.cube.Cube, 

3129 cube_v: iris.cube.Cube, 

3130 filename: str | None = None, 

3131 sequence_coordinate: str = "time", 

3132 **kwargs, 

3133) -> iris.cube.CubeList: 

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

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

3136 

3137 # Cubes must have a matching sequence coordinate. 

3138 try: 

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

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

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

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

3143 raise ValueError( 

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

3145 ) from err 

3146 

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

3148 plot_index = [] 

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

3150 for cube_u_slice, cube_v_slice in zip( 

3151 cube_u.slices_over(sequence_coordinate), 

3152 cube_v.slices_over(sequence_coordinate), 

3153 strict=True, 

3154 ): 

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

3156 seq_coord = cube_u_slice.coord(sequence_coordinate) 

3157 plot_title, plot_filename = _set_title_and_filename( 

3158 seq_coord, nplot, recipe_title, filename 

3159 ) 

3160 

3161 # Do the actual plotting. 

3162 _plot_and_save_vector_plot( 

3163 cube_u_slice, 

3164 cube_v_slice, 

3165 filename=plot_filename, 

3166 title=plot_title, 

3167 method="pcolormesh", 

3168 ) 

3169 plot_index.append(plot_filename) 

3170 

3171 # Add list of plots to plot metadata. 

3172 complete_plot_index = _append_to_plot_index(plot_index) 

3173 

3174 # Make a page to display the plots. 

3175 _make_plot_html_page(complete_plot_index) 

3176 

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

3178 

3179 

3180def plot_histogram_series( 

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

3182 filename: str | None = None, 

3183 sequence_coordinate: str = "time", 

3184 stamp_coordinate: str = "realization", 

3185 single_plot: bool = False, 

3186 **kwargs, 

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

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

3189 

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

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

3192 functionality to scroll through histograms against time. If a 

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

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

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

3196 

3197 Parameters 

3198 ---------- 

3199 cubes: Cube | iris.cube.CubeList 

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

3201 than the stamp coordinate. 

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

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

3204 filename: str, optional 

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

3206 to the recipe name. 

3207 sequence_coordinate: str, optional 

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

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

3210 slider. 

3211 stamp_coordinate: str, optional 

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

3213 ``"realization"``. 

3214 single_plot: bool, optional 

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

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

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

3218 

3219 Returns 

3220 ------- 

3221 iris.cube.Cube | iris.cube.CubeList 

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

3223 Plotted data. 

3224 

3225 Raises 

3226 ------ 

3227 ValueError 

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

3229 TypeError 

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

3231 """ 

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

3233 

3234 cubes = iter_maybe(cubes) 

3235 

3236 # Internal plotting function. 

3237 plotting_func = _plot_and_save_histogram_series 

3238 

3239 num_models = get_num_models(cubes) 

3240 

3241 validate_cube_shape(cubes, num_models) 

3242 

3243 # If several histograms are plotted, check sequence_coordinate 

3244 check_sequence_coordinate(cubes, sequence_coordinate) 

3245 

3246 # Get axis minimum and maximum from levels information. 

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

3248 vmin, vmax = _set_axis_range(cubes) 

3249 

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

3251 # single point. If single_plot is True: 

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

3253 # separate postage stamp plots. 

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

3255 # produced per single model only 

3256 if num_models == 1: 

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

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

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

3260 ): 

3261 if single_plot: 

3262 plotting_func = ( 

3263 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

3264 ) 

3265 else: 

3266 plotting_func = _plot_and_save_postage_stamp_histogram_series 

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

3268 else: 

3269 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

3270 

3271 plot_index = [] 

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

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

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

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

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

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

3278 for cube_slice in cube_iterables: 

3279 single_cube = cube_slice 

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

3281 single_cube = cube_slice[0] 

3282 

3283 # Ensure valid stamp coordinate in cube dimensions 

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

3285 stamp_coordinate = check_stamp_coordinate(single_cube) 

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

3287 seq_coord = single_cube.coord(sequence_coordinate) 

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

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

3290 seq_coord = single_cube.coord("time") 

3291 # Use station name in title and filename if model vs obs comparison 

3292 if sequence_coordinate == "station": 3292 ↛ 3293line 3292 didn't jump to line 3293 because the condition on line 3292 was never true

3293 seq_coord = single_cube.coord("Station_Name") 

3294 

3295 plot_title, plot_filename = _set_title_and_filename( 

3296 seq_coord, nplot, recipe_title, filename 

3297 ) 

3298 

3299 # Do the actual plotting. 

3300 plotting_func( 

3301 cube_slice, 

3302 filename=plot_filename, 

3303 stamp_coordinate=stamp_coordinate, 

3304 title=plot_title, 

3305 vmin=vmin, 

3306 vmax=vmax, 

3307 ) 

3308 plot_index.append(plot_filename) 

3309 

3310 # Add list of plots to plot metadata. 

3311 complete_plot_index = _append_to_plot_index(plot_index) 

3312 

3313 # Make a page to display the plots. 

3314 _make_plot_html_page(complete_plot_index) 

3315 

3316 return cubes 

3317 

3318 

3319def plot_scatter_series( 

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

3321 filename: str | None = None, 

3322 sequence_coordinate: str = "time", 

3323 stamp_coordinate: str = "realization", 

3324 hexbin: bool = False, 

3325 **kwargs, 

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

3327 """Plot a scatter plot for each sequence coordinate provided. 

3328 

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

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

3331 functionality to scroll through scatter against time. If a 

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

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

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

3335 

3336 Parameters 

3337 ---------- 

3338 cubes: Cube | iris.cube.CubeList 

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

3340 than the stamp coordinate. 

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

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

3343 filename: str, optional 

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

3345 to the recipe name. 

3346 sequence_coordinate: str, optional 

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

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

3349 slider. 

3350 stamp_coordinate: str, optional 

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

3352 ``"realization"``. 

3353 hexbin: bool, optional 

3354 If True, generate hexbin comparison plot. 

3355 If False, generate point-by-point scatter plot. 

3356 

3357 Returns 

3358 ------- 

3359 iris.cube.Cube | iris.cube.CubeList 

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

3361 Plotted data. 

3362 

3363 Raises 

3364 ------ 

3365 ValueError 

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

3367 TypeError 

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

3369 """ 

3370 recipe_title = get_recipe_metadata().get("title", "Scatter") 

3371 

3372 cubes = iter_maybe(cubes) 

3373 

3374 # Internal plotting function. 

3375 plotting_func = _plot_and_save_scatter_series 

3376 

3377 num_models = get_num_models(cubes) 

3378 

3379 validate_cube_shape(cubes, num_models) 

3380 

3381 check_sequence_coordinate(cubes, sequence_coordinate) 

3382 

3383 vmin, vmax = _set_axis_range(cubes) 

3384 

3385 # Require >1 models to compare on scatter plot 

3386 if num_models > 1: 

3387 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

3388 else: 

3389 raise ValueError( 

3390 "Scatter plot series requires multiple number of models in input data." 

3391 ) 

3392 

3393 plot_index = [] 

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

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

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

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

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

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

3400 for cube_slice in cube_iterables: 

3401 single_cube = cube_slice 

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

3403 single_cube = cube_slice[0] 

3404 

3405 # Ensure valid stamp coordinate in cube dimensions 

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

3407 stamp_coordinate = check_stamp_coordinate(single_cube) 

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

3409 seq_coord = single_cube.coord(sequence_coordinate) 

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

3411 if sequence_coordinate == "realization" and nplot == 1: 

3412 seq_coord = single_cube.coord("time") 

3413 # Use station name in title and filename if model vs obs comparison 

3414 if sequence_coordinate == "station": 

3415 seq_coord = single_cube.coord("Station_Name") 

3416 

3417 plot_title, plot_filename = _set_title_and_filename( 

3418 seq_coord, nplot, recipe_title, filename 

3419 ) 

3420 

3421 # Do the actual plotting. 

3422 plotting_func( 

3423 cube_slice, 

3424 filename=plot_filename, 

3425 stamp_coordinate=stamp_coordinate, 

3426 title=plot_title, 

3427 vmin=vmin, 

3428 vmax=vmax, 

3429 hexbin=hexbin, 

3430 ) 

3431 plot_index.append(plot_filename) 

3432 

3433 # Add list of plots to plot metadata. 

3434 complete_plot_index = _append_to_plot_index(plot_index) 

3435 

3436 # Make a page to display the plots. 

3437 _make_plot_html_page(complete_plot_index) 

3438 

3439 return cubes 

3440 

3441 

3442def _plot_and_save_postage_stamp_power_spectrum_series( 

3443 cubes: iris.cube.Cube, 

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

3445 stamp_coordinate: str, 

3446 filename: str, 

3447 title: str, 

3448 series_coordinate: str | None = None, 

3449 **kwargs, 

3450): 

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

3452 

3453 Parameters 

3454 ---------- 

3455 cubes: Cube or CubeList 

3456 Cube or Cubelist of the power spectrum data. 

3457 coords: list[Coord] 

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

3459 stamp_coordinate: str 

3460 Coordinate that becomes different plots. 

3461 filename: str 

3462 Filename of the plot to write. 

3463 title: str 

3464 Plot title. 

3465 series_coordinate: str, optional 

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

3467 

3468 """ 

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

3470 grid_size = math.ceil(math.sqrt(len(cubes.coord(stamp_coordinate).points))) 

3471 

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

3473 model_colors_map = get_model_colors_map(cubes) 

3474 # ax = plt.gca() 

3475 # Make a subplot for each member. 

3476 for member, subplot in zip( 

3477 cubes.slices_over(stamp_coordinate), range(1, grid_size**2 + 1), strict=False 

3478 ): 

3479 ax = plt.subplot(grid_size, grid_size, subplot) 

3480 

3481 # Store min/max ranges. 

3482 y_levels = [] 

3483 

3484 line_marker = None 

3485 line_width = 1 

3486 

3487 for cube in iter_maybe(member): 

3488 xcoord = _select_series_coord(cube, series_coordinate) 

3489 xname = xcoord.points 

3490 

3491 yfield = cube.data # power spectrum 

3492 label = None 

3493 color = "black" 

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

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

3496 color = model_colors_map.get(label) 

3497 

3498 if member.coord(stamp_coordinate).points == [0]: 

3499 ax.plot( 

3500 xname, 

3501 yfield, 

3502 color=color, 

3503 marker=line_marker, 

3504 ls="-", 

3505 lw=line_width, 

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

3507 if len(cube.coord(stamp_coordinate).points) > 1 

3508 else label, 

3509 ) 

3510 # Label with member if part of an ensemble and not the control. 

3511 else: 

3512 ax.plot( 

3513 xname, 

3514 yfield, 

3515 color=color, 

3516 ls="-", 

3517 lw=1.5, 

3518 alpha=0.75, 

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

3520 ) 

3521 

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

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

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

3525 y_levels.append(min(levels)) 

3526 y_levels.append(max(levels)) 

3527 

3528 # Add some labels and tweak the style. 

3529 title = f"{title}" 

3530 ax.set_title(title, fontsize=16) 

3531 

3532 # Set appropriate x-axis label based on coordinate 

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

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

3535 ): 

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

3537 elif series_coordinate == "physical_wavenumber" or ( 3537 ↛ 3542line 3537 didn't jump to line 3542 because the condition on line 3537 was always true

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

3539 ): 

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

3541 else: # frequency or check units 

3542 if hasattr(xcoord, "units") and str(xcoord.units) == "km-1": 

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

3544 else: 

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

3546 

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

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

3549 

3550 # Set log-log scale 

3551 ax.set_xscale("log") 

3552 ax.set_yscale("log") 

3553 

3554 # Add gridlines 

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

3556 # Ientify unique labels for legend 

3557 handles = list( 

3558 { 

3559 label: handle 

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

3561 }.values() 

3562 ) 

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

3564 

3565 ax = plt.gca() 

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

3567 

3568 # Save plot. 

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

3570 

3571 

3572def _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series( 

3573 cubes: iris.cube.Cube, 

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

3575 stamp_coordinate: str, 

3576 filename: str, 

3577 title: str, 

3578 series_coordinate: str | None = None, 

3579 **kwargs, 

3580): 

3581 """Plot and save power spectra for ensemble members in single plot. 

3582 

3583 Parameters 

3584 ---------- 

3585 cubes: Cube or CubeList 

3586 Cube or Cubelist of the power spectrum data. 

3587 coords: list[Coord] 

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

3589 stamp_coordinate: str 

3590 Coordinate that becomes different plots. 

3591 filename: str 

3592 Filename of the plot to write. 

3593 title: str 

3594 Plot title. 

3595 series_coordinate: str, optional 

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

3597 

3598 """ 

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

3600 model_colors_map = get_model_colors_map(cubes) 

3601 

3602 line_marker = None 

3603 line_width = 1 

3604 

3605 # Compute ensemble statistics to show spread 

3606 mean_cube = cubes.collapsed(stamp_coordinate, iris.analysis.MEAN) 

3607 min_cube = cubes.collapsed(stamp_coordinate, iris.analysis.MIN) 

3608 max_cube = cubes.collapsed(stamp_coordinate, iris.analysis.MAX) 

3609 

3610 xcoord_global = mean_cube.coord(series_coordinate) 

3611 x_global = xcoord_global.points 

3612 

3613 for i, member in enumerate(cubes.slices_over(stamp_coordinate)): 

3614 xcoord = _select_series_coord(member, series_coordinate) 

3615 xname = xcoord.points 

3616 

3617 yfield = member.data # power spectrum 

3618 color = "black" 

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

3620 label = member.attributes.get("model_name") if i == 0 else None 

3621 color = model_colors_map.get(label) 

3622 

3623 if member.coord(stamp_coordinate).points == [0]: 

3624 ax.plot( 

3625 xname, 

3626 yfield, 

3627 color=color, 

3628 marker=line_marker, 

3629 ls="-", 

3630 lw=line_width, 

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

3632 if len(member.coord(stamp_coordinate).points) > 1 

3633 else label, 

3634 ) 

3635 # Label with member number if part of an ensemble and not the control. 

3636 else: 

3637 ax.plot( 

3638 xname, 

3639 yfield, 

3640 color=color, 

3641 ls="-", 

3642 lw=1.5, 

3643 alpha=0.75, 

3644 label=label, 

3645 ) 

3646 

3647 # Set appropriate x-axis label based on coordinate 

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

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

3650 ): 

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

3652 elif series_coordinate == "physical_wavenumber" or ( 3652 ↛ 3657line 3652 didn't jump to line 3657 because the condition on line 3652 was always true

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

3654 ): 

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

3656 else: # frequency or check units 

3657 if hasattr(xcoord, "units") and str(xcoord.units) == "km-1": 

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

3659 else: 

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

3661 

3662 # Add ensemble spread shading 

3663 ax.fill_between( 

3664 x_global, 

3665 min_cube.data, 

3666 max_cube.data, 

3667 color="grey", 

3668 alpha=0.3, 

3669 label="Ensemble spread", 

3670 ) 

3671 

3672 # Add ensemble mean line 

3673 ax.plot(x_global, mean_cube.data, color="black", lw=1, label="Ensemble mean") 

3674 

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

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

3677 

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

3679 # Set log-log scale 

3680 ax.set_xscale("log") 

3681 ax.set_yscale("log") 

3682 

3683 # Add gridlines 

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

3685 # Identify unique labels for legend 

3686 handles = list( 

3687 { 

3688 label: handle 

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

3690 }.values() 

3691 ) 

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

3693 

3694 # Figure title. 

3695 ax.set_title(title, fontsize=16) 

3696 

3697 # Save plot. 

3698 _save_close_figure(fig, "power spectra postage stamp", filename)