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

962 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-08 17:20 +0000

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

2# 

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

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

5# You may obtain a copy of the License at 

6# 

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

8# 

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

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

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

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

13# limitations under the License. 

14 

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

16 

17import fcntl 

18import importlib.resources 

19import itertools 

20import json 

21import logging 

22import math 

23import os 

24from typing import Literal 

25 

26import cartopy.crs as ccrs 

27import cartopy.feature as cfeature 

28import iris 

29import iris.coords 

30import iris.cube 

31import iris.exceptions 

32import iris.plot as iplt 

33import matplotlib as mpl 

34import matplotlib.pyplot as plt 

35import numpy as np 

36import scipy.fft as fft 

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._plot_colormaps import ( 

50 _colorbar_map_levels, 

51 _get_model_colors_map, 

52) 

53from CSET.operators._utils import ( 

54 check_stamp_coordinate, 

55 fully_equalise_attributes, 

56 get_cube_yxcoordname, 

57 is_transect, 

58 slice_over_maybe, 

59) 

60from CSET.operators.collapse import collapse 

61from CSET.operators.misc import _extract_common_time_points 

62from CSET.operators.regrid import regrid_onto_cube 

63 

64# Use a non-interactive plotting backend. 

65mpl.use("agg") 

66 

67############################ 

68# Private helper functions # 

69############################ 

70 

71 

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

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

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

75 fcntl.flock(fp, fcntl.LOCK_EX) 

76 fp.seek(0) 

77 meta = json.load(fp) 

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

79 complete_plot_index = complete_plot_index + plot_index 

80 meta["plots"] = complete_plot_index 

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

82 os.getenv("DO_CASE_AGGREGATION") 

83 ): 

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

85 fp.seek(0) 

86 fp.truncate() 

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

88 return complete_plot_index 

89 

90 

91def _check_single_cube(cube: iris.cube.Cube | iris.cube.CubeList) -> iris.cube.Cube: 

92 """Ensure a single cube is given. 

93 

94 If a CubeList of length one is given that the contained cube is returned, 

95 otherwise an error is raised. 

96 

97 Parameters 

98 ---------- 

99 cube: Cube | CubeList 

100 The cube to check. 

101 

102 Returns 

103 ------- 

104 cube: Cube 

105 The checked cube. 

106 

107 Raises 

108 ------ 

109 TypeError 

110 If the input cube is not a Cube or CubeList of a single Cube. 

111 """ 

112 if isinstance(cube, iris.cube.Cube): 

113 return cube 

114 if isinstance(cube, iris.cube.CubeList): 

115 if len(cube) == 1: 

116 return cube[0] 

117 raise TypeError("Must have a single cube", cube) 

118 

119 

120def _make_plot_html_page(plots: list): 

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

122 # Debug check that plots actually contains some strings. 

123 assert isinstance(plots[0], str) 

124 

125 # Load HTML template file. 

126 operator_files = importlib.resources.files() 

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

128 

129 # Get some metadata. 

130 meta = get_recipe_metadata() 

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

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

133 

134 # Prepare template variables. 

135 variables = { 

136 "title": title, 

137 "description": description, 

138 "initial_plot": plots[0], 

139 "plots": plots, 

140 "title_slug": slugify(title), 

141 } 

142 

143 # Render template. 

144 html = render_file(template_file, **variables) 

145 

146 # Save completed HTML. 

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

148 fp.write(html) 

149 

150 

151def _setup_spatial_map( 

152 cube: iris.cube.Cube, 

153 figure, 

154 cmap, 

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

156 subplot: int | None = None, 

157): 

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

159 

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

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

162 

163 Parameters 

164 ---------- 

165 cube: Cube 

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

167 figure: 

168 Matplotlib Figure object holding all plot elements. 

169 cmap: 

170 Matplotlib colormap. 

171 grid_size: (int, int), optional 

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

173 subplot: int, optional 

174 Subplot index if multiple spatial subplots in figure. 

175 

176 Returns 

177 ------- 

178 axes: 

179 Matplotlib GeoAxes definition. 

180 """ 

181 # Identify min/max plot bounds. 

182 try: 

183 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

184 x1 = np.nanmin(cube.coord(lon_axis).points) 

185 x2 = np.nanmax(cube.coord(lon_axis).points) 

186 y1 = np.nanmin(cube.coord(lat_axis).points) 

187 y2 = np.nanmax(cube.coord(lat_axis).points) 

188 

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

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

191 x1 = x1 - 180.0 

192 x2 = x2 - 180.0 

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

194 

195 # Consider map projection orientation. 

196 # Adapting orientation enables plotting across international dateline. 

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

198 if x2 > 180.0 or x1 < -180.0: 

199 central_longitude = 180.0 

200 else: 

201 central_longitude = 0.0 

202 

203 # Define spatial map projection. 

204 coord_system = cube.coord(lat_axis).coord_system 

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

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

207 projection = ccrs.RotatedPole( 

208 pole_longitude=coord_system.grid_north_pole_longitude, 

209 pole_latitude=coord_system.grid_north_pole_latitude, 

210 central_rotated_longitude=central_longitude, 

211 ) 

212 crs = projection 

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

214 # Define Transverse Mercator projection for TM inputs. 

215 projection = ccrs.TransverseMercator( 

216 central_longitude=coord_system.longitude_of_central_meridian, 

217 central_latitude=coord_system.latitude_of_projection_origin, 

218 false_easting=coord_system.false_easting, 

219 false_northing=coord_system.false_northing, 

220 scale_factor=coord_system.scale_factor_at_central_meridian, 

221 ) 

222 crs = projection 

223 else: 

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

225 if y1 > 20.0 and y2 > 80.0: 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true

226 projection = ccrs.NorthPolarStereo(central_longitude=0.0) 

227 crs = ccrs.PlateCarree() 

228 elif y1 < -80.0 and y2 < -20.0: 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true

229 projection = ccrs.SouthPolarStereo(central_longitude=0.0) 

230 crs = ccrs.PlateCarree() 

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

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

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

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

235 else: 

236 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

237 crs = ccrs.PlateCarree() 

238 

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

240 if subplot is not None: 

241 axes = figure.add_subplot( 

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

243 ) 

244 else: 

245 axes = figure.add_subplot(projection=projection) 

246 

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

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

249 if (cube.ndim > 1 and iris.util.is_masked(cube.data)) or any( 249 ↛ 252line 249 didn't jump to line 252 because the condition on line 249 was never true

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

251 ): 

252 pass 

253 else: 

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

255 coastcol = "magenta" 

256 else: 

257 coastcol = "black" 

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

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

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

261 

262 # Add gridlines. 

263 gl = axes.gridlines( 

264 alpha=0.3, 

265 draw_labels=True, 

266 dms=False, 

267 x_inline=False, 

268 y_inline=False, 

269 ) 

270 gl.top_labels = False 

271 gl.right_labels = False 

272 if subplot: 

273 gl.bottom_labels = False 

274 gl.left_labels = False 

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

276 gl.left_labels = True 

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

278 gl.bottom_labels = True 

279 

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

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

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

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

284 

285 except ValueError: 

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

287 axes = figure.gca() 

288 pass 

289 

290 return axes 

291 

292 

293def _get_plot_resolution() -> int: 

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

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

296 

297 

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

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

300 if use_bounds and seq_coord.has_bounds(): 

301 vals = seq_coord.bounds.flatten() 

302 else: 

303 vals = seq_coord.points 

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

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

306 

307 if start == end: 

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

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

310 else: 

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

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

313 

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

315 if ( 

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

317 and vals[0] == 0 

318 and vals[-1] == 0 

319 ): 

320 sequence_title = "" 

321 sequence_fname = "" 

322 

323 return sequence_title, sequence_fname 

324 

325 

326def _set_title_and_filename( 

327 seq_coord: iris.coords.Coord, 

328 nplot: int, 

329 recipe_title: str, 

330 filename: str, 

331): 

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

333 

334 Parameters 

335 ---------- 

336 sequence_coordinate: iris.coords.Coord 

337 Coordinate about which to make a plot sequence. 

338 nplot: int 

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

340 recipe_title: str 

341 Default plot title, potentially to update. 

342 filename: str 

343 Input plot filename, potentially to update. 

344 

345 Returns 

346 ------- 

347 plot_title: str 

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

349 plot_filename: str 

350 Output formatted plot filename string. 

351 """ 

352 ndim = seq_coord.ndim 

353 npoints = np.size(seq_coord.points) 

354 sequence_title = "" 

355 sequence_fname = "" 

356 

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

358 # (e.g. aggregation histogram plots) 

359 if ndim > 1: 

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

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

362 sequence_fname = f"_{ncase}cases" 

363 

364 # Case 2: Single dimension input 

365 else: 

366 # Single sequence point 

367 if npoints == 1: 

368 if nplot > 1: 

369 # Default labels for sequence inputs 

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

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

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

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

374 else: 

375 # Aggregated attribute available where input collapsed over aggregation 

376 try: 

377 ncase = seq_coord.attributes["number_reference_times"] 

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

379 sequence_fname = f"_{ncase}cases" 

380 except KeyError: 

381 sequence_title, sequence_fname = _get_start_end_strings( 

382 seq_coord, use_bounds=seq_coord.has_bounds() 

383 ) 

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

385 else: 

386 sequence_title, sequence_fname = _get_start_end_strings( 

387 seq_coord, use_bounds=False 

388 ) 

389 

390 # Set plot title and filename 

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

392 

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

394 if filename is None: 

395 filename = slugify(recipe_title) 

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

397 else: 

398 if nplot > 1: 

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

400 else: 

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

402 

403 return plot_title, plot_filename 

404 

405 

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

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

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

409 mtitle = "Member" 

410 else: 

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

412 

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

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

415 else: 

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

417 

418 return mtitle 

419 

420 

421def _plot_and_save_spatial_plot( 

422 cube: iris.cube.Cube, 

423 filename: str, 

424 title: str, 

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

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

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

428 scatter_cube: iris.cube.Cube | None = None, 

429 **kwargs, 

430): 

431 """Plot and save a spatial plot. 

432 

433 Parameters 

434 ---------- 

435 cube: Cube 

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

437 filename: str 

438 Filename of the plot to write. 

439 title: str 

440 Plot title. 

441 method: "contourf" | "pcolormesh" | "scatter" 

442 The plotting method to use. 

443 overlay_cube: Cube, optional 

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

445 contour_cube: Cube, optional 

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

447 scatter_cube: Cube, optional 

448 Optional 1 (station list) or 2 dimensional (lat and lon) Cube of data to overplot as map of scatter points over base cube 

449 """ 

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

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

452 

453 # Specify the color bar 

454 cmap, levels, norm = _colorbar_map_levels(cube) 

455 

456 # If overplotting, set required colorbars 

457 if overlay_cube: 

458 over_cmap, over_levels, over_norm = _colorbar_map_levels(overlay_cube) 

459 if contour_cube: 

460 cntr_cmap, cntr_levels, cntr_norm = _colorbar_map_levels(contour_cube) 

461 

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

463 axes = _setup_spatial_map(cube, fig, cmap) 

464 

465 # Plot the field. 

466 try: 

467 vmin = min(levels) 

468 vmax = max(levels) 

469 except TypeError: 

470 vmin, vmax = None, None 

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

472 if norm is not None: 

473 vmin = None 

474 vmax = None 

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

476 if method == "contourf": 

477 # Filled contour plot of the field. 

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

479 elif method == "pcolormesh": 

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

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

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

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

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

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

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

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

488 # proportion to the area of the figure. 

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

490 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

491 plot = iplt.scatter( 

492 cube.coord(lon_axis), 

493 cube.coord(lat_axis), 

494 c=cube.data[:], 

495 s=mrk_size, 

496 cmap=cmap, 

497 edgecolors="k", 

498 norm=norm, 

499 vmin=vmin, 

500 vmax=vmax, 

501 ) 

502 else: 

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

504 

505 # Overplot overlay field, if required 

506 if overlay_cube: 

507 try: 

508 over_vmin = min(over_levels) 

509 over_vmax = max(over_levels) 

510 except TypeError: 

511 over_vmin, over_vmax = None, None 

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

513 over_vmin = None 

514 over_vmax = None 

515 overlay = iplt.pcolormesh( 

516 overlay_cube, 

517 cmap=over_cmap, 

518 norm=over_norm, 

519 alpha=0.8, 

520 vmin=over_vmin, 

521 vmax=over_vmax, 

522 ) 

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

524 if contour_cube: 

525 contour = iplt.contour( 

526 contour_cube, 

527 colors="darkgray", 

528 levels=cntr_levels, 

529 norm=cntr_norm, 

530 alpha=0.5, 

531 linestyles="--", 

532 linewidths=1, 

533 ) 

534 plt.clabel(contour) 

535 # Overplot valid elements of scatter field, if required 

536 if scatter_cube: 536 ↛ 537line 536 didn't jump to line 537 because the condition on line 536 was never true

537 mrk_size = int(np.sqrt(2500000.0 / len(scatter_cube.data))) 

538 lat_axis, lon_axis = get_cube_yxcoordname(scatter_cube) 

539 lon_coord = scatter_cube.coord(lon_axis) 

540 lat_coord = scatter_cube.coord(lat_axis) 

541 valid = ~scatter_cube.data.mask 

542 valid_lon = iris.coords.AuxCoord( 

543 lon_coord.points[valid], 

544 standard_name=lon_coord.standard_name, 

545 units=lon_coord.units, 

546 coord_system=lon_coord.coord_system, 

547 ) 

548 valid_lat = iris.coords.AuxCoord( 

549 lat_coord.points[valid], 

550 standard_name=lat_coord.standard_name, 

551 units=lat_coord.units, 

552 coord_system=lat_coord.coord_system, 

553 ) 

554 iplt.scatter( 

555 valid_lon, 

556 valid_lat, 

557 c=scatter_cube.data[valid], 

558 s=mrk_size, 

559 cmap=cmap, 

560 edgecolors="k", 

561 norm=norm, 

562 vmin=vmin, 

563 vmax=vmax, 

564 ) 

565 

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

567 if is_transect(cube): 

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

569 axes.invert_yaxis() 

570 axes.set_yscale("log") 

571 axes.set_ylim(1100, 100) 

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

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

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

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

576 ): 

577 axes.set_yscale("log") 

578 

579 axes.set_title( 

580 f"{title}\n" 

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

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

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

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

585 fontsize=16, 

586 ) 

587 

588 # Inset code 

589 axins = inset_axes( 

590 axes, 

591 width="20%", 

592 height="20%", 

593 loc="upper right", 

594 axes_class=GeoAxes, 

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

596 ) 

597 

598 # Slightly transparent to reduce plot blocking. 

599 axins.patch.set_alpha(0.4) 

600 

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

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

603 

604 SLat, SLon, ELat, ELon = ( 

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

606 ) 

607 

608 # Draw line between them 

609 axins.plot( 

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

611 ) 

612 

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

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

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

616 

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

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

619 

620 # Midpoints 

621 lon_mid = (lon_min + lon_max) / 2 

622 lat_mid = (lat_min + lat_max) / 2 

623 

624 # Maximum half-range 

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

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

627 half_range = 1 

628 

629 # Set square extent 

630 axins.set_extent( 

631 [ 

632 lon_mid - half_range, 

633 lon_mid + half_range, 

634 lat_mid - half_range, 

635 lat_mid + half_range, 

636 ], 

637 crs=ccrs.PlateCarree(), 

638 ) 

639 

640 # Ensure square aspect 

641 axins.set_aspect("equal") 

642 

643 else: 

644 # Add title. 

645 axes.set_title(title, fontsize=16) 

646 

647 # Adjust padding if spatial plot or transect 

648 if is_transect(cube): 

649 yinfopad = -0.1 

650 ycbarpad = 0.1 

651 else: 

652 yinfopad = 0.01 

653 ycbarpad = 0.042 

654 

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

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

657 axes.annotate( 

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

659 xy=(0.025, yinfopad), 

660 xycoords="axes fraction", 

661 xytext=(-5, 5), 

662 textcoords="offset points", 

663 ha="left", 

664 va="bottom", 

665 size=11, 

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

667 ) 

668 

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

670 if overlay_cube: 

671 cbarB = fig.colorbar( 

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

673 ) 

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

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

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

677 cbarB.set_ticks(over_levels) 

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

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

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

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

682 

683 # Add main colour bar. 

684 cbar = fig.colorbar( 

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

686 ) 

687 

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

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

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

691 cbar.set_ticks(levels) 

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

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

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

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

696 

697 # Save plot. 

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

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

700 plt.close(fig) 

701 

702 

703def _plot_and_save_postage_stamp_spatial_plot( 

704 cube: iris.cube.Cube, 

705 filename: str, 

706 stamp_coordinate: str, 

707 title: str, 

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

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

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

711 **kwargs, 

712): 

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

714 

715 Parameters 

716 ---------- 

717 cube: Cube 

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

719 filename: str 

720 Filename of the plot to write. 

721 stamp_coordinate: str 

722 Coordinate that becomes different plots. 

723 method: "contourf" | "pcolormesh" 

724 The plotting method to use. 

725 overlay_cube: Cube, optional 

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

727 contour_cube: Cube, optional 

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

729 

730 Raises 

731 ------ 

732 ValueError 

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

734 """ 

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

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

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

738 grid_size = math.ceil(nmember / grid_rows) 

739 

740 fig = plt.figure( 

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

742 ) 

743 

744 # Specify the color bar 

745 cmap, levels, norm = _colorbar_map_levels(cube) 

746 # If overplotting, set required colorbars 

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

748 over_cmap, over_levels, over_norm = _colorbar_map_levels(overlay_cube) 

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

750 cntr_cmap, cntr_levels, cntr_norm = _colorbar_map_levels(contour_cube) 

751 

752 # Make a subplot for each member. 

753 for member, subplot in zip( 

754 cube.slices_over(stamp_coordinate), 

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

756 strict=False, 

757 ): 

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

759 axes = _setup_spatial_map( 

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

761 ) 

762 if method == "contourf": 

763 # Filled contour plot of the field. 

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

765 elif method == "pcolormesh": 

766 if levels is not None: 

767 vmin = min(levels) 

768 vmax = max(levels) 

769 else: 

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

771 vmin, vmax = None, None 

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

773 # if levels are defined. 

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

775 vmin = None 

776 vmax = None 

777 # pcolormesh plot of the field. 

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

779 else: 

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

781 

782 # Overplot overlay field, if required 

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

784 try: 

785 over_vmin = min(over_levels) 

786 over_vmax = max(over_levels) 

787 except TypeError: 

788 over_vmin, over_vmax = None, None 

789 if over_norm is not None: 

790 over_vmin = None 

791 over_vmax = None 

792 iplt.pcolormesh( 

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

794 cmap=over_cmap, 

795 norm=over_norm, 

796 alpha=0.6, 

797 vmin=over_vmin, 

798 vmax=over_vmax, 

799 ) 

800 # Overplot contour field, if required 

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

802 iplt.contour( 

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

804 colors="darkgray", 

805 levels=cntr_levels, 

806 norm=cntr_norm, 

807 alpha=0.6, 

808 linestyles="--", 

809 linewidths=1, 

810 ) 

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

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

813 

814 # Put the shared colorbar in its own axes. 

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

816 colorbar = fig.colorbar( 

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

818 ) 

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

820 

821 # Overall figure title. 

822 fig.suptitle(title, fontsize=16) 

823 

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

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

826 plt.close(fig) 

827 

828 

829def _plot_and_save_line_series( 

830 cubes: iris.cube.CubeList, 

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

832 ensemble_coord: str, 

833 filename: str, 

834 title: str, 

835 **kwargs, 

836): 

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

838 

839 Parameters 

840 ---------- 

841 cubes: Cube or CubeList 

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

843 coords: list[Coord] 

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

845 ensemble_coord: str 

846 Ensemble coordinate in the cube. 

847 filename: str 

848 Filename of the plot to write. 

849 title: str 

850 Plot title. 

851 """ 

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

853 

854 model_colors_map = _get_model_colors_map(cubes) 

855 

856 # Store min/max ranges. 

857 y_levels = [] 

858 

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

860 _validate_cubes_coords(cubes, coords) 

861 

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

863 label = None 

864 color = "black" 

865 if model_colors_map: 

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

867 color = model_colors_map.get(label) 

868 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

871 iplt.plot( 

872 coord, 

873 cube_slice, 

874 color=color, 

875 marker="o", 

876 ls="-", 

877 lw=3, 

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

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

880 else label, 

881 ) 

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

883 else: 

884 iplt.plot( 

885 coord, 

886 cube_slice, 

887 color=color, 

888 ls="-", 

889 lw=1.5, 

890 alpha=0.75, 

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

892 ) 

893 

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

895 _, levels, _ = _colorbar_map_levels(cube, axis="y") 

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

897 y_levels.append(min(levels)) 

898 y_levels.append(max(levels)) 

899 

900 # Get the current axes. 

901 ax = plt.gca() 

902 

903 # Add some labels and tweak the style. 

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

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

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

907 else: 

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

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

910 ax.set_title(title, fontsize=16) 

911 

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

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

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

915 

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

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

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

919 # Add zero line. 

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

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

922 logging.debug( 

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

924 ) 

925 else: 

926 ax.autoscale() 

927 

928 # Add gridlines 

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

930 # Ientify unique labels for legend 

931 handles = list( 

932 { 

933 label: handle 

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

935 }.values() 

936 ) 

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

938 

939 # Save plot. 

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

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

942 plt.close(fig) 

943 

944 

945def _plot_and_save_vertical_line_series( 

946 cubes: iris.cube.CubeList, 

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

948 ensemble_coord: str, 

949 filename: str, 

950 series_coordinate: str, 

951 title: str, 

952 vmin: float, 

953 vmax: float, 

954 **kwargs, 

955): 

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

957 

958 Parameters 

959 ---------- 

960 cubes: CubeList 

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

962 coord: list[Coord] 

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

964 ensemble_coord: str 

965 Ensemble coordinate in the cube. 

966 filename: str 

967 Filename of the plot to write. 

968 series_coordinate: str 

969 Coordinate to use as vertical axis. 

970 title: str 

971 Plot title. 

972 vmin: float 

973 Minimum value for the x-axis. 

974 vmax: float 

975 Maximum value for the x-axis. 

976 """ 

977 # plot the vertical pressure axis using log scale 

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

979 

980 model_colors_map = _get_model_colors_map(cubes) 

981 

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

983 _validate_cubes_coords(cubes, coords) 

984 

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

986 label = None 

987 color = "black" 

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

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

990 color = model_colors_map.get(label) 

991 

992 for cube_slice in cube.slices_over(ensemble_coord): 

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

994 # unless single forecast. 

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

996 iplt.plot( 

997 cube_slice, 

998 coord, 

999 color=color, 

1000 marker="o", 

1001 ls="-", 

1002 lw=3, 

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

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

1005 else label, 

1006 ) 

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

1008 else: 

1009 iplt.plot( 

1010 cube_slice, 

1011 coord, 

1012 color=color, 

1013 ls="-", 

1014 lw=1.5, 

1015 alpha=0.75, 

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

1017 ) 

1018 

1019 # Get the current axis 

1020 ax = plt.gca() 

1021 

1022 # Special handling for pressure level data. 

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

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

1025 ax.invert_yaxis() 

1026 ax.set_yscale("log") 

1027 

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

1029 y_tick_labels = [ 

1030 "1000", 

1031 "850", 

1032 "700", 

1033 "500", 

1034 "300", 

1035 "200", 

1036 "100", 

1037 ] 

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

1039 

1040 # Set y-axis limits and ticks. 

1041 ax.set_ylim(1100, 100) 

1042 

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

1044 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

1050 

1051 ax.set_yticks(y_ticks) 

1052 ax.set_yticklabels(y_tick_labels) 

1053 

1054 # Set x-axis limits. 

1055 ax.set_xlim(vmin, vmax) 

1056 # Mark y=0 if present in plot. 

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

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

1059 

1060 # Add some labels and tweak the style. 

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

1062 ax.set_xlabel( 

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

1064 ) 

1065 ax.set_title(title, fontsize=16) 

1066 ax.ticklabel_format(axis="x") 

1067 ax.tick_params(axis="y") 

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

1069 

1070 # Add gridlines 

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

1072 # Ientify unique labels for legend 

1073 handles = list( 

1074 { 

1075 label: handle 

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

1077 }.values() 

1078 ) 

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

1080 

1081 # Save plot. 

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

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

1084 plt.close(fig) 

1085 

1086 

1087def _plot_and_save_scatter_plot( 

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

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

1090 filename: str, 

1091 title: str, 

1092 one_to_one: bool, 

1093 model_names: list[str] = None, 

1094 **kwargs, 

1095): 

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

1097 

1098 Parameters 

1099 ---------- 

1100 cube_x: Cube | CubeList 

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

1102 cube_y: Cube | CubeList 

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

1104 filename: str 

1105 Filename of the plot to write. 

1106 title: str 

1107 Plot title. 

1108 one_to_one: bool 

1109 Whether a 1:1 line is plotted. 

1110 """ 

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

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

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

1114 # over the pairs simultaneously. 

1115 

1116 # Ensure cube_x and cube_y are iterable 

1117 cube_x_iterable = iter_maybe(cube_x) 

1118 cube_y_iterable = iter_maybe(cube_y) 

1119 

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

1121 iplt.scatter(cube_x_iter, cube_y_iter) 

1122 if one_to_one is True: 

1123 plt.plot( 

1124 [ 

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

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

1127 ], 

1128 [ 

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

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

1131 ], 

1132 "k", 

1133 linestyle="--", 

1134 ) 

1135 ax = plt.gca() 

1136 

1137 # Add some labels and tweak the style. 

1138 if model_names is None: 

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

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

1141 else: 

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

1143 ax.set_xlabel( 

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

1145 ) 

1146 ax.set_ylabel( 

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

1148 ) 

1149 ax.set_title(title, fontsize=16) 

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

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

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

1153 ax.autoscale() 

1154 

1155 # Save plot. 

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

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

1158 plt.close(fig) 

1159 

1160 

1161def _plot_and_save_vector_plot( 

1162 cube_u: iris.cube.Cube, 

1163 cube_v: iris.cube.Cube, 

1164 filename: str, 

1165 title: str, 

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

1167 **kwargs, 

1168): 

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

1170 

1171 Parameters 

1172 ---------- 

1173 cube_u: Cube 

1174 2 dimensional Cube of u component of the data. 

1175 cube_v: Cube 

1176 2 dimensional Cube of v component of the data. 

1177 filename: str 

1178 Filename of the plot to write. 

1179 title: str 

1180 Plot title. 

1181 """ 

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

1183 

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

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

1186 cube_vec_mag.rename(f"{cube_u.name()}_{cube_v.name()}_magnitude") 

1187 

1188 # Specify the color bar 

1189 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag) 

1190 

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

1192 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

1193 

1194 if method == "contourf": 

1195 # Filled contour plot of the field. 

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

1197 elif method == "pcolormesh": 

1198 try: 

1199 vmin = min(levels) 

1200 vmax = max(levels) 

1201 except TypeError: 

1202 vmin, vmax = None, None 

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

1204 # if levels are defined. 

1205 if norm is not None: 

1206 vmin = None 

1207 vmax = None 

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

1209 else: 

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

1211 

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

1213 if is_transect(cube_vec_mag): 

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

1215 axes.invert_yaxis() 

1216 axes.set_yscale("log") 

1217 axes.set_ylim(1100, 100) 

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

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

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

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

1222 ): 

1223 axes.set_yscale("log") 

1224 

1225 axes.set_title( 

1226 f"{title}\n" 

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

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

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

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

1231 fontsize=16, 

1232 ) 

1233 

1234 else: 

1235 # Add title. 

1236 axes.set_title(title, fontsize=16) 

1237 

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

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

1240 axes.annotate( 

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

1242 xy=(0.05, -0.05), 

1243 xycoords="axes fraction", 

1244 xytext=(-5, 5), 

1245 textcoords="offset points", 

1246 ha="right", 

1247 va="bottom", 

1248 size=11, 

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

1250 ) 

1251 

1252 # Add colour bar. 

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

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

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

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

1257 cbar.set_ticks(levels) 

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

1259 

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

1261 # with less than 30 points. 

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

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

1264 

1265 # Save plot. 

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

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

1268 plt.close(fig) 

1269 

1270 

1271def _plot_and_save_histogram_series( 

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

1273 filename: str, 

1274 title: str, 

1275 vmin: float, 

1276 vmax: float, 

1277 **kwargs, 

1278): 

1279 """Plot and save a histogram series. 

1280 

1281 Parameters 

1282 ---------- 

1283 cubes: Cube or CubeList 

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

1285 filename: str 

1286 Filename of the plot to write. 

1287 title: str 

1288 Plot title. 

1289 vmin: float 

1290 minimum for colorbar 

1291 vmax: float 

1292 maximum for colorbar 

1293 """ 

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

1295 ax = plt.gca() 

1296 

1297 model_colors_map = _get_model_colors_map(cubes) 

1298 

1299 # Set default that histograms will produce probability density function 

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

1301 density = True 

1302 

1303 for cube in iter_maybe(cubes): 

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

1305 # than seeing if long names exist etc. 

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

1307 if "surface_microphysical" in title: 

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

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

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

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

1312 density = False 

1313 else: 

1314 bins = 10.0 ** ( 

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

1316 ) # Suggestion from RMED toolbox. 

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

1318 ax.set_yscale("log") 

1319 vmin = bins[1] 

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

1321 ax.set_xscale("log") 

1322 elif "lightning" in title: 

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

1324 else: 

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

1326 logging.debug( 

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

1328 np.size(bins), 

1329 np.min(bins), 

1330 np.max(bins), 

1331 ) 

1332 

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

1334 # Otherwise we plot xdim histograms stacked. 

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

1336 

1337 label = None 

1338 color = "black" 

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

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

1341 color = model_colors_map[label] 

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

1343 

1344 # Compute area under curve. 

1345 if "surface_microphysical" in title and "amount" in title: 1345 ↛ 1346line 1345 didn't jump to line 1346 because the condition on line 1345 was never true

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

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

1348 x = x[1:] 

1349 y = y[1:] 

1350 

1351 ax.plot( 

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

1353 ) 

1354 

1355 # Add some labels and tweak the style. 

1356 ax.set_title(title, fontsize=16) 

1357 ax.set_xlabel( 

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

1359 ) 

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

1361 if "surface_microphysical" in title and "amount" in title: 1361 ↛ 1362line 1361 didn't jump to line 1362 because the condition on line 1361 was never true

1362 ax.set_ylabel( 

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

1364 ) 

1365 ax.set_xlim(vmin, vmax) 

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

1367 

1368 # Overlay grid-lines onto histogram plot. 

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

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

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

1372 

1373 # Save plot. 

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

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

1376 plt.close(fig) 

1377 

1378 

1379def _plot_and_save_postage_stamp_histogram_series( 

1380 cube: iris.cube.Cube, 

1381 filename: str, 

1382 title: str, 

1383 stamp_coordinate: str, 

1384 vmin: float, 

1385 vmax: float, 

1386 **kwargs, 

1387): 

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

1389 

1390 Parameters 

1391 ---------- 

1392 cube: Cube 

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

1394 filename: str 

1395 Filename of the plot to write. 

1396 title: str 

1397 Plot title. 

1398 stamp_coordinate: str 

1399 Coordinate that becomes different plots. 

1400 vmin: float 

1401 minimum for pdf x-axis 

1402 vmax: float 

1403 maximum for pdf x-axis 

1404 """ 

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

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

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

1408 grid_size = math.ceil(nmember / grid_rows) 

1409 

1410 fig = plt.figure( 

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

1412 ) 

1413 # Make a subplot for each member. 

1414 for member, subplot in zip( 

1415 cube.slices_over(stamp_coordinate), 

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

1417 strict=False, 

1418 ): 

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

1420 # cartopy GeoAxes generated. 

1421 plt.subplot(grid_rows, grid_size, subplot) 

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

1423 # Otherwise we plot xdim histograms stacked. 

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

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

1426 axes = plt.gca() 

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

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

1429 axes.set_xlim(vmin, vmax) 

1430 

1431 # Overall figure title. 

1432 fig.suptitle(title, fontsize=16) 

1433 

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

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

1436 plt.close(fig) 

1437 

1438 

1439def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1440 cube: iris.cube.Cube, 

1441 filename: str, 

1442 title: str, 

1443 stamp_coordinate: str, 

1444 vmin: float, 

1445 vmax: float, 

1446 **kwargs, 

1447): 

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

1449 ax.set_title(title, fontsize=16) 

1450 ax.set_xlim(vmin, vmax) 

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

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

1453 # Loop over all slices along the stamp_coordinate 

1454 for member in cube.slices_over(stamp_coordinate): 

1455 # Flatten the member data to 1D 

1456 member_data_1d = member.data.flatten() 

1457 # Plot the histogram using plt.hist 

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

1459 plt.hist( 

1460 member_data_1d, 

1461 density=True, 

1462 stacked=True, 

1463 label=f"{mtitle}", 

1464 ) 

1465 

1466 # Add a legend 

1467 ax.legend(fontsize=16) 

1468 

1469 # Save the figure to a file 

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

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

1472 

1473 # Close the figure 

1474 plt.close(fig) 

1475 

1476 

1477def _plot_and_save_scatter_series( 

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

1479 filename: str, 

1480 title: str, 

1481 vmin: float, 

1482 vmax: float, 

1483 **kwargs, 

1484): 

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

1486 

1487 Parameters 

1488 ---------- 

1489 cubes: Cube or CubeList 

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

1491 filename: str 

1492 Filename of the plot to write. 

1493 title: str 

1494 Plot title. 

1495 vmin: float 

1496 minimum for colorbar 

1497 vmax: float 

1498 maximum for colorbar 

1499 """ 

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

1501 ax = plt.gca() 

1502 

1503 model_colors_map = _get_model_colors_map(cubes) 

1504 

1505 ## TEST CUBELIST len >= 2## 

1506 

1507 for cube in iter_maybe(cubes[1:]): 

1508 label = None 

1509 color = "black" 

1510 if model_colors_map: 

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

1512 color = model_colors_map[label] 

1513 

1514 iplt.scatter(cubes[0], cube, color=color, marker="o", label=label) 

1515 

1516 # Add some labels and tweak the style. 

1517 ax.set_title(title, fontsize=16) 

1518 ax.set_xlabel( 

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

1520 ) 

1521 ax.set_ylabel( 

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

1523 ) 

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

1525 ax.autoscale() 

1526 

1527 lims = [ 

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

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

1530 ] 

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

1532 ax.set_aspect("equal") 

1533 ax.set_xlim(lims) 

1534 ax.set_ylim(lims) 

1535 

1536 # Overlay grid-lines onto histogram plot. 

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

1538 if model_colors_map: 

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

1540 

1541 # Save plot. 

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

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

1544 plt.close(fig) 

1545 

1546 

1547def _plot_and_save_power_spectrum_series( 

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

1549 filename: str, 

1550 title: str, 

1551 **kwargs, 

1552): 

1553 """Plot and save a power spectrum series. 

1554 

1555 Parameters 

1556 ---------- 

1557 cubes: Cube or CubeList 

1558 2 dimensional Cube or CubeList of the data to plot as power spectrum. 

1559 filename: str 

1560 Filename of the plot to write. 

1561 title: str 

1562 Plot title. 

1563 """ 

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

1565 ax = plt.gca() 

1566 

1567 model_colors_map = _get_model_colors_map(cubes) 

1568 

1569 for cube in iter_maybe(cubes): 

1570 # Calculate power spectrum 

1571 

1572 # Extract time coordinate and convert to datetime 

1573 time_coord = cube.coord("time") 

1574 time_points = time_coord.units.num2date(time_coord.points) 

1575 

1576 # Choose one time point (e.g., the first one) 

1577 target_time = time_points[0] 

1578 

1579 # Bind target_time inside the lambda using a default argument 

1580 time_constraint = iris.Constraint( 

1581 time=lambda cell, target_time=target_time: cell.point == target_time 

1582 ) 

1583 

1584 cube = cube.extract(time_constraint) 

1585 

1586 if cube.ndim == 2: 

1587 cube_3d = cube.data[np.newaxis, :, :] 

1588 logging.debug("Adding in new axis for a 2 dimensional cube.") 

1589 elif cube.ndim == 3: 1589 ↛ 1590line 1589 didn't jump to line 1590 because the condition on line 1589 was never true

1590 cube_3d = cube.data 

1591 else: 

1592 raise ValueError("Cube dimensions unsuitable for power spectra code") 

1593 raise ValueError( 

1594 f"Cube is {cube.ndim} dimensional. Cube should be 2 or 3 dimensional." 

1595 ) 

1596 

1597 # Calculate spectra 

1598 ps_array = _DCT_ps(cube_3d) 

1599 

1600 ps_cube = iris.cube.Cube( 

1601 ps_array, 

1602 long_name="power_spectra", 

1603 ) 

1604 

1605 ps_cube.attributes["model_name"] = cube.attributes.get("model_name") 

1606 

1607 # Create a frequency/wavelength array for coordinate 

1608 ps_len = ps_cube.data.shape[1] 

1609 freqs = np.arange(1, ps_len + 1) 

1610 freq_coord = iris.coords.DimCoord(freqs, long_name="frequency", units="1") 

1611 

1612 # Convert datetime to numeric time using original units 

1613 numeric_time = time_coord.units.date2num(time_points) 

1614 # Create a new DimCoord with numeric time 

1615 new_time_coord = iris.coords.DimCoord( 

1616 numeric_time, standard_name="time", units=time_coord.units 

1617 ) 

1618 

1619 # Add time and frequency coordinate to spectra cube. 

1620 ps_cube.add_dim_coord(new_time_coord.copy(), 0) 

1621 ps_cube.add_dim_coord(freq_coord.copy(), 1) 

1622 

1623 # Extract data from the cube 

1624 frequency = ps_cube.coord("frequency").points 

1625 power_spectrum = ps_cube.data 

1626 

1627 label = None 

1628 color = "black" 

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

1630 label = ps_cube.attributes.get("model_name") 

1631 color = model_colors_map[label] 

1632 ax.plot(frequency, power_spectrum[0], color=color, label=label) 

1633 

1634 # Add some labels and tweak the style. 

1635 ax.set_title(title, fontsize=16) 

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

1637 ax.set_ylabel("Power", fontsize=14) 

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

1639 

1640 # Set log-log scale 

1641 ax.set_xscale("log") 

1642 ax.set_yscale("log") 

1643 

1644 # Overlay grid-lines onto power spectrum plot. 

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

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

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

1648 

1649 # Save plot. 

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

1651 logging.info("Saved power spectrum plot to %s", filename) 

1652 plt.close(fig) 

1653 

1654 

1655def _plot_and_save_postage_stamp_power_spectrum_series( 

1656 cube: iris.cube.Cube, 

1657 filename: str, 

1658 title: str, 

1659 stamp_coordinate: str, 

1660 **kwargs, 

1661): 

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

1663 

1664 Parameters 

1665 ---------- 

1666 cube: Cube 

1667 2 dimensional Cube of the data to plot as power spectrum. 

1668 filename: str 

1669 Filename of the plot to write. 

1670 title: str 

1671 Plot title. 

1672 stamp_coordinate: str 

1673 Coordinate that becomes different plots. 

1674 """ 

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

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

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

1678 grid_size = math.ceil(nmember / grid_rows) 

1679 

1680 fig = plt.figure( 

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

1682 ) 

1683 

1684 # Make a subplot for each member. 

1685 for member, subplot in zip( 

1686 cube.slices_over(stamp_coordinate), 

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

1688 strict=False, 

1689 ): 

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

1691 # cartopy GeoAxes generated. 

1692 plt.subplot(grid_rows, grid_size, subplot) 

1693 

1694 frequency = member.coord("frequency").points 

1695 

1696 axes = plt.gca() 

1697 axes.plot(frequency, member.data) 

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

1699 

1700 # Overall figure title. 

1701 fig.suptitle(title, fontsize=16) 

1702 

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

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

1705 plt.close(fig) 

1706 

1707 

1708def _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series( 

1709 cube: iris.cube.Cube, 

1710 filename: str, 

1711 title: str, 

1712 stamp_coordinate: str, 

1713 **kwargs, 

1714): 

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

1716 ax.set_title(title, fontsize=16) 

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

1718 ax.set_ylabel("Power", fontsize=14) 

1719 # Loop over all slices along the stamp_coordinate 

1720 for member in cube.slices_over(stamp_coordinate): 

1721 frequency = member.coord("frequency").points 

1722 ax.plot( 

1723 frequency, 

1724 member.data, 

1725 label=f"Member #{member.coord(stamp_coordinate).points[0]}", 

1726 ) 

1727 

1728 # Add a legend 

1729 ax.legend(fontsize=16) 

1730 

1731 # Save the figure to a file 

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

1733 logging.info("Saved power spectra plot to %s", filename) 

1734 

1735 # Close the figure 

1736 plt.close(fig) 

1737 

1738 

1739def _spatial_plot( 

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

1741 cube: iris.cube.Cube, 

1742 filename: str | None, 

1743 sequence_coordinate: str, 

1744 stamp_coordinate: str, 

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

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

1747 scatter_cube: iris.cube.Cube | None = None, 

1748 **kwargs, 

1749): 

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

1751 

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

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

1754 is present then postage stamp plots will be produced. 

1755 

1756 If an overlay_cube and/or contour_cube and/or scatter_cube are specified, 

1757 multiple variables or outputs can be overplotted on the same figure. 

1758 

1759 Parameters 

1760 ---------- 

1761 method: "contourf" | "pcolormesh" 

1762 The plotting method to use. 

1763 cube: Cube 

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

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

1766 plotted sequentially and/or as postage stamp plots. 

1767 filename: str | None 

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

1769 uses the recipe name. 

1770 sequence_coordinate: str 

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

1772 This coordinate must exist in the cube. 

1773 stamp_coordinate: str 

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

1775 ``"realization"``. 

1776 overlay_cube: Cube | None, optional 

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

1778 contour_cube: Cube | None, optional 

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

1780 scatter_cube: Cube | None, optional 

1781 Optional 1 dimensional (station) or 2 dimensional (lat and lon) Cube of data to overplot as points on scatter map over base cube 

1782 

1783 Raises 

1784 ------ 

1785 ValueError 

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

1787 TypeError 

1788 If the cube isn't a single cube. 

1789 """ 

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

1791 

1792 # Ensure we've got a single cube. 

1793 cube = _check_single_cube(cube) 

1794 

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

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

1797 stamp_coordinate = check_stamp_coordinate(cube) 

1798 

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

1800 # single point. 

1801 plotting_func = _plot_and_save_spatial_plot 

1802 try: 

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

1804 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1805 except iris.exceptions.CoordinateNotFoundError: 

1806 pass 

1807 

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

1809 # dimension called observation or model_obs_error 

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

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

1812 for crd in cube.coords() 

1813 ): 

1814 plotting_func = _plot_and_save_spatial_plot 

1815 method = "scatter" 

1816 

1817 # Must have a sequence coordinate. 

1818 try: 

1819 cube.coord(sequence_coordinate) 

1820 except iris.exceptions.CoordinateNotFoundError as err: 

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

1822 

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

1824 plot_index = [] 

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

1826 

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

1828 # Set plot titles and filename 

1829 seq_coord = cube_slice.coord(sequence_coordinate) 

1830 plot_title, plot_filename = _set_title_and_filename( 

1831 seq_coord, nplot, recipe_title, filename 

1832 ) 

1833 

1834 # Extract sequence slice for overlay cubes if required. 

1835 overlay_slice = slice_over_maybe(overlay_cube, sequence_coordinate, iseq) 

1836 contour_slice = slice_over_maybe(contour_cube, sequence_coordinate, iseq) 

1837 scatter_slice = slice_over_maybe(scatter_cube, sequence_coordinate, iseq) 

1838 

1839 # Do the actual plotting. 

1840 plotting_func( 

1841 cube_slice, 

1842 filename=plot_filename, 

1843 stamp_coordinate=stamp_coordinate, 

1844 title=plot_title, 

1845 method=method, 

1846 overlay_cube=overlay_slice, 

1847 contour_cube=contour_slice, 

1848 scatter_cube=scatter_slice, 

1849 **kwargs, 

1850 ) 

1851 plot_index.append(plot_filename) 

1852 

1853 # Add list of plots to plot metadata. 

1854 complete_plot_index = _append_to_plot_index(plot_index) 

1855 

1856 # Make a page to display the plots. 

1857 _make_plot_html_page(complete_plot_index) 

1858 

1859 

1860def _get_num_models(cube: iris.cube.Cube | iris.cube.CubeList) -> int: 

1861 """Return number of models based on cube attributes.""" 

1862 model_names = list( 

1863 filter( 

1864 lambda x: x is not None, 

1865 {cb.attributes.get("model_name", None) for cb in iter_maybe(cube)}, 

1866 ) 

1867 ) 

1868 if not model_names: 

1869 logging.debug("Missing model names. Will assume single model.") 

1870 return 1 

1871 else: 

1872 return len(model_names) 

1873 

1874 

1875def _validate_cube_shape( 

1876 cube: iris.cube.Cube | iris.cube.CubeList, num_models: int 

1877) -> None: 

1878 """Check all cubes have a model name.""" 

1879 if isinstance(cube, iris.cube.CubeList) and len(cube) != num_models: 1879 ↛ 1880line 1879 didn't jump to line 1880 because the condition on line 1879 was never true

1880 raise ValueError( 

1881 f"The number of model names ({num_models}) should equal the number " 

1882 f"of cubes ({len(cube)})." 

1883 ) 

1884 

1885 

1886def _validate_cubes_coords( 

1887 cubes: iris.cube.CubeList, coords: list[iris.coords.Coord] 

1888) -> None: 

1889 """Check same number of cubes as sequence coordinate for zip functions.""" 

1890 if len(cubes) != len(coords): 1890 ↛ 1891line 1890 didn't jump to line 1891 because the condition on line 1890 was never true

1891 raise ValueError( 

1892 f"The number of CubeList entries ({len(cubes)}) should equal the number " 

1893 f"of sequence coordinates ({len(coords)})." 

1894 f"Check that number of time entries in input data are consistent if " 

1895 f"performing time-averaging steps prior to plotting outputs." 

1896 ) 

1897 

1898 

1899#################### 

1900# Public functions # 

1901#################### 

1902 

1903 

1904def spatial_contour_plot( 

1905 cube: iris.cube.Cube, 

1906 filename: str = None, 

1907 sequence_coordinate: str = "time", 

1908 stamp_coordinate: str = "realization", 

1909 **kwargs, 

1910) -> iris.cube.Cube: 

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

1912 

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

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

1915 is present then postage stamp plots will be produced. 

1916 

1917 Parameters 

1918 ---------- 

1919 cube: Cube 

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

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

1922 plotted sequentially and/or as postage stamp plots. 

1923 filename: str, optional 

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

1925 to the recipe name. 

1926 sequence_coordinate: str, optional 

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

1928 This coordinate must exist in the cube. 

1929 stamp_coordinate: str, optional 

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

1931 ``"realization"``. 

1932 

1933 Returns 

1934 ------- 

1935 Cube 

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

1937 

1938 Raises 

1939 ------ 

1940 ValueError 

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

1942 TypeError 

1943 If the cube isn't a single cube. 

1944 """ 

1945 _spatial_plot( 

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

1947 ) 

1948 return cube 

1949 

1950 

1951def spatial_pcolormesh_plot( 

1952 cube: iris.cube.Cube, 

1953 filename: str = None, 

1954 sequence_coordinate: str = "time", 

1955 stamp_coordinate: str = "realization", 

1956 **kwargs, 

1957) -> iris.cube.Cube: 

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

1959 

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

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

1962 is present then postage stamp plots will be produced. 

1963 

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

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

1966 contour areas are important. 

1967 

1968 Parameters 

1969 ---------- 

1970 cube: Cube 

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

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

1973 plotted sequentially and/or as postage stamp plots. 

1974 filename: str, optional 

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

1976 to the recipe name. 

1977 sequence_coordinate: str, optional 

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

1979 This coordinate must exist in the cube. 

1980 stamp_coordinate: str, optional 

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

1982 ``"realization"``. 

1983 

1984 Returns 

1985 ------- 

1986 Cube 

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

1988 

1989 Raises 

1990 ------ 

1991 ValueError 

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

1993 TypeError 

1994 If the cube isn't a single cube. 

1995 """ 

1996 _spatial_plot( 

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

1998 ) 

1999 return cube 

2000 

2001 

2002def spatial_multi_pcolormesh_plot( 

2003 cube: iris.cube.Cube, 

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

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

2006 scatter_cube: iris.cube.Cube | None = None, 

2007 filename: str = None, 

2008 sequence_coordinate: str = "time", 

2009 stamp_coordinate: str = "realization", 

2010 **kwargs, 

2011) -> iris.cube.Cube: 

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

2013 

2014 A 2D basis cube 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 If specified, a masked overlay_cube can be overplotted on top of the base cube. 

2019 

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

2021 

2022 If specified, a scattermap of scatter_cube can be overplotted. 

2023 

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

2025 

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

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

2028 contour areas are important. 

2029 

2030 Parameters 

2031 ---------- 

2032 cube: Cube 

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

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

2035 plotted sequentially and/or as postage stamp plots. 

2036 overlay_cube: Cube 

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

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

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

2040 contour_cube: Cube 

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

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

2043 plotted sequentially and/or as postage stamp plots. 

2044 scatter_cube: Cube 

2045 Iris cube of the data to plot as a scatter map overlay on top of basis cube, overlay_cube and contour_cube. It should have two spatial dimensions, 

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

2047 observation stations with lat/lon coordinates) and may also have a another 

2048 two dimension to be plotted sequentially and/or as postage stamp plots. 

2049 filename: str, optional 

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

2051 to the recipe name. 

2052 sequence_coordinate: str, optional 

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

2054 This coordinate must exist in the cube. 

2055 stamp_coordinate: str, optional 

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

2057 ``"realization"``. 

2058 

2059 Returns 

2060 ------- 

2061 Cube 

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

2063 

2064 Raises 

2065 ------ 

2066 ValueError 

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

2068 TypeError 

2069 If the cube isn't a single cube. 

2070 """ 

2071 _spatial_plot( 

2072 "pcolormesh", 

2073 cube, 

2074 filename, 

2075 sequence_coordinate, 

2076 stamp_coordinate, 

2077 overlay_cube=overlay_cube, 

2078 contour_cube=contour_cube, 

2079 scatter_cube=scatter_cube, 

2080 ) 

2081 return cube, overlay_cube, contour_cube, scatter_cube 

2082 

2083 

2084# TODO: Expand function to handle ensemble data. 

2085# line_coordinate: str, optional 

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

2087# ``"realization"``. 

2088def plot_line_series( 

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

2090 filename: str = None, 

2091 series_coordinate: str = "time", 

2092 # line_coordinate: str = "realization", 

2093 **kwargs, 

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

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

2096 

2097 The Cube or CubeList must be 1D. 

2098 

2099 Parameters 

2100 ---------- 

2101 iris.cube | iris.cube.CubeList 

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

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

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

2105 filename: str, optional 

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

2107 to the recipe name. 

2108 series_coordinate: str, optional 

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

2110 coordinate must exist in the cube. 

2111 

2112 Returns 

2113 ------- 

2114 iris.cube.Cube | iris.cube.CubeList 

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

2116 plotted data. 

2117 

2118 Raises 

2119 ------ 

2120 ValueError 

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

2122 TypeError 

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

2124 """ 

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

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

2127 

2128 num_models = _get_num_models(cube) 

2129 

2130 _validate_cube_shape(cube, num_models) 

2131 

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

2133 cubes = iter_maybe(cube) 

2134 coords = [] 

2135 for cube in cubes: 

2136 try: 

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

2138 except iris.exceptions.CoordinateNotFoundError as err: 

2139 raise ValueError( 

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

2141 ) from err 

2142 if cube.ndim > 2 or not cube.coords("realization"): 

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

2144 

2145 # Format the title and filename using plotted series coordinate 

2146 nplot = 1 

2147 seq_coord = coords[0] 

2148 plot_title, plot_filename = _set_title_and_filename( 

2149 seq_coord, nplot, recipe_title, filename 

2150 ) 

2151 

2152 # Do the actual plotting. 

2153 

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

2155 if "station" in [c.name() for c in cubes[0].coords()]: 2155 ↛ 2156line 2155 didn't jump to line 2156 because the condition on line 2155 was never true

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

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

2158 station_name = station_cubes[0].coord("Station identifier").points[0] 

2159 _plot_and_save_line_series( 

2160 station_cubes, 

2161 coords, 

2162 "realization", 

2163 plot_filename.replace(".png", "_" + station_name + ".png"), 

2164 f"{plot_title} {station_name}", 

2165 ) 

2166 

2167 # Default line plotting 

2168 else: 

2169 _plot_and_save_line_series( 

2170 cubes, coords, "realization", plot_filename, plot_title 

2171 ) 

2172 

2173 # Add list of plots to plot metadata. 

2174 plot_index = _append_to_plot_index([plot_filename]) 

2175 

2176 # Make a page to display the plots. 

2177 _make_plot_html_page(plot_index) 

2178 

2179 return cube 

2180 

2181 

2182def plot_vertical_line_series( 

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

2184 filename: str = None, 

2185 series_coordinate: str = "model_level_number", 

2186 sequence_coordinate: str = "time", 

2187 # line_coordinate: str = "realization", 

2188 **kwargs, 

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

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

2191 

2192 The Cube or CubeList must be 1D. 

2193 

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

2195 then a sequence of plots will be produced. 

2196 

2197 Parameters 

2198 ---------- 

2199 iris.cube | iris.cube.CubeList 

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

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

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

2203 filename: str, optional 

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

2205 to the recipe name. 

2206 series_coordinate: str, optional 

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

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

2209 for LFRic. Defaults to ``model_level_number``. 

2210 This coordinate must exist in the cube. 

2211 sequence_coordinate: str, optional 

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

2213 This coordinate must exist in the cube. 

2214 

2215 Returns 

2216 ------- 

2217 iris.cube.Cube | iris.cube.CubeList 

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

2219 Plotted data. 

2220 

2221 Raises 

2222 ------ 

2223 ValueError 

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

2225 TypeError 

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

2227 """ 

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

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

2230 

2231 cubes = iter_maybe(cubes) 

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

2233 all_data = [] 

2234 

2235 # Store min/max ranges for x range. 

2236 x_levels = [] 

2237 

2238 num_models = _get_num_models(cubes) 

2239 

2240 _validate_cube_shape(cubes, num_models) 

2241 

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

2243 coords = [] 

2244 for cube in cubes: 

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

2246 try: 

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

2248 except iris.exceptions.CoordinateNotFoundError as err: 

2249 raise ValueError( 

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

2251 ) from err 

2252 

2253 try: 

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

2255 cube.coord(sequence_coordinate) 

2256 except iris.exceptions.CoordinateNotFoundError as err: 

2257 raise ValueError( 

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

2259 ) from err 

2260 

2261 # Get minimum and maximum from levels information. 

2262 _, levels, _ = _colorbar_map_levels(cube, axis="x") 

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

2264 x_levels.append(min(levels)) 

2265 x_levels.append(max(levels)) 

2266 else: 

2267 all_data.append(cube.data) 

2268 

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

2270 # Combine all data into a single NumPy array 

2271 combined_data = np.concatenate(all_data) 

2272 

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

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

2275 # sequence and if applicable postage stamp coordinate. 

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

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

2278 else: 

2279 vmin = min(x_levels) 

2280 vmax = max(x_levels) 

2281 

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

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

2284 # necessary) 

2285 def filter_cube_iterables(cube_iterables) -> bool: 

2286 return len(cube_iterables) == len(coords) 

2287 

2288 cube_iterables = filter( 

2289 filter_cube_iterables, 

2290 ( 

2291 iris.cube.CubeList( 

2292 s 

2293 for s in itertools.chain.from_iterable( 

2294 cb.slices_over(sequence_coordinate) for cb in cubes 

2295 ) 

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

2297 ) 

2298 for point in sorted( 

2299 set( 

2300 itertools.chain.from_iterable( 

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

2302 ) 

2303 ) 

2304 ) 

2305 ), 

2306 ) 

2307 

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

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

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

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

2312 # or an iris.cube.CubeList. 

2313 plot_index = [] 

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

2315 for cubes_slice in cube_iterables: 

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

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

2318 plot_title, plot_filename = _set_title_and_filename( 

2319 seq_coord, nplot, recipe_title, filename 

2320 ) 

2321 

2322 # Do the actual plotting. 

2323 _plot_and_save_vertical_line_series( 

2324 cubes_slice, 

2325 coords, 

2326 "realization", 

2327 plot_filename, 

2328 series_coordinate, 

2329 title=plot_title, 

2330 vmin=vmin, 

2331 vmax=vmax, 

2332 ) 

2333 plot_index.append(plot_filename) 

2334 

2335 # Add list of plots to plot metadata. 

2336 complete_plot_index = _append_to_plot_index(plot_index) 

2337 

2338 # Make a page to display the plots. 

2339 _make_plot_html_page(complete_plot_index) 

2340 

2341 return cubes 

2342 

2343 

2344def qq_plot( 

2345 cubes: iris.cube.CubeList, 

2346 coordinates: list[str], 

2347 percentiles: list[float], 

2348 model_names: list[str], 

2349 filename: str = None, 

2350 one_to_one: bool = True, 

2351 **kwargs, 

2352) -> iris.cube.CubeList: 

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

2354 

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

2356 collapsed within the operator over all specified coordinates such as 

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

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

2359 

2360 Parameters 

2361 ---------- 

2362 cubes: iris.cube.CubeList 

2363 Two cubes of the same variable with different models. 

2364 coordinate: list[str] 

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

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

2367 the percentile coordinate. 

2368 percent: list[float] 

2369 A list of percentiles to appear in the plot. 

2370 model_names: list[str] 

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

2372 filename: str, optional 

2373 Filename of the plot to write. 

2374 one_to_one: bool, optional 

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

2376 

2377 Raises 

2378 ------ 

2379 ValueError 

2380 When the cubes are not compatible. 

2381 

2382 Notes 

2383 ----- 

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

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

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

2387 compares percentiles of two datasets. This plot does 

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

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

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

2391 

2392 Quantile-quantile plots are valuable for comparing against 

2393 observations and other models. Identical percentiles between the variables 

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

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

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

2397 Wilks 2011 [Wilks2011]_). 

2398 

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

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

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

2402 the extremes. 

2403 

2404 References 

2405 ---------- 

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

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

2408 """ 

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

2410 if len(cubes) != 2: 

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

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

2413 other: Cube = cubes.extract_cube( 

2414 iris.Constraint( 

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

2416 ) 

2417 ) 

2418 

2419 # Get spatial coord names. 

2420 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

2421 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

2422 

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

2424 # This is triggered if either 

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

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

2427 # errors. 

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

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

2430 # for UM and LFRic comparisons. 

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

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

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

2434 # given this dependency on regridding. 

2435 if ( 

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

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

2438 ) or ( 

2439 base.long_name 

2440 in [ 

2441 "eastward_wind_at_10m", 

2442 "northward_wind_at_10m", 

2443 "northward_wind_at_cell_centres", 

2444 "eastward_wind_at_cell_centres", 

2445 "zonal_wind_at_pressure_levels", 

2446 "meridional_wind_at_pressure_levels", 

2447 "potential_vorticity_at_pressure_levels", 

2448 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

2449 ] 

2450 ): 

2451 logging.debug( 

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

2453 ) 

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

2455 

2456 # Extract just common time points. 

2457 base, other = _extract_common_time_points(base, other) 

2458 

2459 # Equalise attributes so we can merge. 

2460 fully_equalise_attributes([base, other]) 

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

2462 

2463 # Collapse cubes. 

2464 base = collapse( 

2465 base, 

2466 coordinate=coordinates, 

2467 method="PERCENTILE", 

2468 additional_percent=percentiles, 

2469 ) 

2470 other = collapse( 

2471 other, 

2472 coordinate=coordinates, 

2473 method="PERCENTILE", 

2474 additional_percent=percentiles, 

2475 ) 

2476 

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

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

2479 title = f"{recipe_title}" 

2480 

2481 if filename is None: 

2482 filename = slugify(recipe_title) 

2483 

2484 # Add file extension. 

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

2486 

2487 # Do the actual plotting on a scatter plot 

2488 _plot_and_save_scatter_plot( 

2489 base, other, plot_filename, title, one_to_one, model_names 

2490 ) 

2491 

2492 # Add list of plots to plot metadata. 

2493 plot_index = _append_to_plot_index([plot_filename]) 

2494 

2495 # Make a page to display the plots. 

2496 _make_plot_html_page(plot_index) 

2497 

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

2499 

2500 

2501def scatter_plot( 

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

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

2504 filename: str = None, 

2505 one_to_one: bool = True, 

2506 **kwargs, 

2507) -> iris.cube.CubeList: 

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

2509 

2510 Both cubes must be 1D. 

2511 

2512 Parameters 

2513 ---------- 

2514 cube_x: Cube | CubeList 

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

2516 cube_y: Cube | CubeList 

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

2518 filename: str, optional 

2519 Filename of the plot to write. 

2520 one_to_one: bool, optional 

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

2522 

2523 Returns 

2524 ------- 

2525 cubes: CubeList 

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

2527 

2528 Raises 

2529 ------ 

2530 ValueError 

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

2532 size. 

2533 TypeError 

2534 If the cube isn't a single cube. 

2535 

2536 Notes 

2537 ----- 

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

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

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

2541 """ 

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

2543 for cube_iter in iter_maybe(cube_x): 

2544 # Check cubes are correct shape. 

2545 cube_iter = _check_single_cube(cube_iter) 

2546 if cube_iter.ndim > 1: 

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

2548 

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

2550 for cube_iter in iter_maybe(cube_y): 

2551 # Check cubes are correct shape. 

2552 cube_iter = _check_single_cube(cube_iter) 

2553 if cube_iter.ndim > 1: 

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

2555 

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

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

2558 title = f"{recipe_title}" 

2559 

2560 if filename is None: 

2561 filename = slugify(recipe_title) 

2562 

2563 # Add file extension. 

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

2565 

2566 # Do the actual plotting. 

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

2568 

2569 # Add list of plots to plot metadata. 

2570 plot_index = _append_to_plot_index([plot_filename]) 

2571 

2572 # Make a page to display the plots. 

2573 _make_plot_html_page(plot_index) 

2574 

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

2576 

2577 

2578def vector_plot( 

2579 cube_u: iris.cube.Cube, 

2580 cube_v: iris.cube.Cube, 

2581 filename: str = None, 

2582 sequence_coordinate: str = "time", 

2583 **kwargs, 

2584) -> iris.cube.CubeList: 

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

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

2587 

2588 # Cubes must have a matching sequence coordinate. 

2589 try: 

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

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

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

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

2594 raise ValueError( 

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

2596 ) from err 

2597 

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

2599 plot_index = [] 

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

2601 for cube_u_slice, cube_v_slice in zip( 

2602 cube_u.slices_over(sequence_coordinate), 

2603 cube_v.slices_over(sequence_coordinate), 

2604 strict=True, 

2605 ): 

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

2607 seq_coord = cube_u_slice.coord(sequence_coordinate) 

2608 plot_title, plot_filename = _set_title_and_filename( 

2609 seq_coord, nplot, recipe_title, filename 

2610 ) 

2611 

2612 # Do the actual plotting. 

2613 _plot_and_save_vector_plot( 

2614 cube_u_slice, 

2615 cube_v_slice, 

2616 filename=plot_filename, 

2617 title=plot_title, 

2618 method="contourf", 

2619 ) 

2620 plot_index.append(plot_filename) 

2621 

2622 # Add list of plots to plot metadata. 

2623 complete_plot_index = _append_to_plot_index(plot_index) 

2624 

2625 # Make a page to display the plots. 

2626 _make_plot_html_page(complete_plot_index) 

2627 

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

2629 

2630 

2631def _check_sequence(cubes, sequence_coordinate): 

2632 # If several histograms are plotted with time as sequence_coordinate for the 

2633 # time slider option. 

2634 for cube in cubes: 

2635 try: 

2636 cube.coord(sequence_coordinate) 

2637 except iris.exceptions.CoordinateNotFoundError as err: 

2638 raise ValueError( 

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

2640 ) from err 

2641 

2642 

2643def _set_axis_range(cubes): 

2644 # Get minimum and maximum from levels information. 

2645 levels = None 

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

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

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

2649 _, levels, _ = _colorbar_map_levels(cube, axis="y") 

2650 if levels is None: 

2651 break 

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

2653 # levels-based ranges for histogram plots. 

2654 _, levels, _ = _colorbar_map_levels(cube) 

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

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

2657 vmin = min(levels) 

2658 vmax = max(levels) 

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

2660 break 

2661 

2662 if levels is None: 

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

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

2665 

2666 return vmin, vmax 

2667 

2668 

2669def _find_matched_slices(cubes, sequence_coordinate): 

2670 

2671 all_points = sorted( 

2672 set( 

2673 itertools.chain.from_iterable( 

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

2675 ) 

2676 ) 

2677 ) 

2678 all_slices = list( 

2679 itertools.chain.from_iterable( 

2680 cb.slices_over(sequence_coordinate) for cb in cubes 

2681 ) 

2682 ) 

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

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

2685 # necessary) 

2686 cube_iterables = [ 

2687 iris.cube.CubeList( 

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

2689 ) 

2690 for point in all_points 

2691 ] 

2692 

2693 return cube_iterables 

2694 

2695 

2696def plot_histogram_series( 

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

2698 filename: str = None, 

2699 sequence_coordinate: str = "time", 

2700 stamp_coordinate: str = "realization", 

2701 single_plot: bool = False, 

2702 **kwargs, 

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

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

2705 

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

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

2708 functionality to scroll through histograms against time. If a 

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

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

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

2712 

2713 Parameters 

2714 ---------- 

2715 cubes: Cube | iris.cube.CubeList 

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

2717 than the stamp coordinate. 

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

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

2720 filename: str, optional 

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

2722 to the recipe name. 

2723 sequence_coordinate: str, optional 

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

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

2726 slider. 

2727 stamp_coordinate: str, optional 

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

2729 ``"realization"``. 

2730 single_plot: bool, optional 

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

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

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

2734 

2735 Returns 

2736 ------- 

2737 iris.cube.Cube | iris.cube.CubeList 

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

2739 Plotted data. 

2740 

2741 Raises 

2742 ------ 

2743 ValueError 

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

2745 TypeError 

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

2747 """ 

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

2749 

2750 cubes = iter_maybe(cubes) 

2751 

2752 # Internal plotting function. 

2753 plotting_func = _plot_and_save_histogram_series 

2754 

2755 num_models = _get_num_models(cubes) 

2756 

2757 _validate_cube_shape(cubes, num_models) 

2758 

2759 _check_sequence(cubes, sequence_coordinate) 

2760 

2761 vmin, vmax = _set_axis_range(cubes) 

2762 

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

2764 # single point. If single_plot is True: 

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

2766 # separate postage stamp plots. 

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

2768 # produced per single model only 

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

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

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

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

2773 ): 

2774 if single_plot: 

2775 plotting_func = ( 

2776 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

2777 ) 

2778 else: 

2779 plotting_func = _plot_and_save_postage_stamp_histogram_series 

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

2781 else: 

2782 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

2783 

2784 plot_index = [] 

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

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

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

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

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

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

2791 for cube_slice in cube_iterables: 

2792 single_cube = cube_slice 

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

2794 single_cube = cube_slice[0] 

2795 

2796 # Ensure valid stamp coordinate in cube dimensions 

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

2798 stamp_coordinate = check_stamp_coordinate(single_cube) 

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

2800 seq_coord = single_cube.coord(sequence_coordinate) 

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

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

2803 seq_coord = single_cube.coord("time") 

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

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

2806 seq_coord = single_cube.coord("Station identifier") 

2807 

2808 plot_title, plot_filename = _set_title_and_filename( 

2809 seq_coord, nplot, recipe_title, filename 

2810 ) 

2811 

2812 # Do the actual plotting. 

2813 plotting_func( 

2814 cube_slice, 

2815 filename=plot_filename, 

2816 stamp_coordinate=stamp_coordinate, 

2817 title=plot_title, 

2818 vmin=vmin, 

2819 vmax=vmax, 

2820 ) 

2821 plot_index.append(plot_filename) 

2822 

2823 # Add list of plots to plot metadata. 

2824 complete_plot_index = _append_to_plot_index(plot_index) 

2825 

2826 # Make a page to display the plots. 

2827 _make_plot_html_page(complete_plot_index) 

2828 

2829 return cubes 

2830 

2831 

2832def plot_scatter_series( 

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

2834 filename: str = None, 

2835 sequence_coordinate: str = "time", 

2836 stamp_coordinate: str = "realization", 

2837 single_plot: bool = False, 

2838 scatter: bool = False, 

2839 **kwargs, 

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

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

2842 

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

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

2845 functionality to scroll through scatter against time. If a 

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

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

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

2849 

2850 Parameters 

2851 ---------- 

2852 cubes: Cube | iris.cube.CubeList 

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

2854 than the stamp coordinate. 

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

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

2857 filename: str, optional 

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

2859 to the recipe name. 

2860 sequence_coordinate: str, optional 

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

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

2863 slider. 

2864 stamp_coordinate: str, optional 

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

2866 ``"realization"``. 

2867 single_plot: bool, optional 

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

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

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

2871 

2872 Returns 

2873 ------- 

2874 iris.cube.Cube | iris.cube.CubeList 

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

2876 Plotted data. 

2877 

2878 Raises 

2879 ------ 

2880 ValueError 

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

2882 TypeError 

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

2884 """ 

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

2886 

2887 cubes = iter_maybe(cubes) 

2888 

2889 # Internal plotting function. 

2890 plotting_func = _plot_and_save_scatter_series 

2891 

2892 num_models = _get_num_models(cubes) 

2893 

2894 _validate_cube_shape(cubes, num_models) 

2895 

2896 _check_sequence(cubes, sequence_coordinate) 

2897 

2898 vmin, vmax = _set_axis_range(cubes) 

2899 

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

2901 # single point. If single_plot is True: 

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

2903 # separate postage stamp plots. 

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

2905 # produced per single model only 

2906 if num_models == 1: 

2907 if ( 

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

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

2910 ): 

2911 if single_plot: 

2912 plotting_func = ( 

2913 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

2914 ) 

2915 else: 

2916 plotting_func = _plot_and_save_postage_stamp_histogram_series 

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

2918 else: 

2919 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

2920 

2921 plot_index = [] 

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

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

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

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

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

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

2928 for cube_slice in cube_iterables: 

2929 single_cube = cube_slice 

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

2931 single_cube = cube_slice[0] 

2932 

2933 # Ensure valid stamp coordinate in cube dimensions 

2934 if stamp_coordinate == "realization": 

2935 stamp_coordinate = check_stamp_coordinate(single_cube) 

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

2937 seq_coord = single_cube.coord(sequence_coordinate) 

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

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

2940 seq_coord = single_cube.coord("time") 

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

2942 if sequence_coordinate == "station": 

2943 seq_coord = single_cube.coord("Station identifier") 

2944 

2945 plot_title, plot_filename = _set_title_and_filename( 

2946 seq_coord, nplot, recipe_title, filename 

2947 ) 

2948 

2949 # Do the actual plotting. 

2950 plotting_func( 

2951 cube_slice, 

2952 filename=plot_filename, 

2953 stamp_coordinate=stamp_coordinate, 

2954 title=plot_title, 

2955 vmin=vmin, 

2956 vmax=vmax, 

2957 ) 

2958 plot_index.append(plot_filename) 

2959 

2960 # Add list of plots to plot metadata. 

2961 complete_plot_index = _append_to_plot_index(plot_index) 

2962 

2963 # Make a page to display the plots. 

2964 _make_plot_html_page(complete_plot_index) 

2965 

2966 return cubes 

2967 

2968 

2969def plot_power_spectrum_series( 

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

2971 filename: str = None, 

2972 sequence_coordinate: str = "time", 

2973 stamp_coordinate: str = "realization", 

2974 single_plot: bool = False, 

2975 **kwargs, 

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

2977 """Plot a power spectrum plot for each vertical level provided. 

2978 

2979 A power spectrum plot can be plotted, but if the sequence_coordinate (i.e. time) 

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

2981 functionality to scroll through power spectrum against time. If a 

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

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

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

2985 

2986 Parameters 

2987 ---------- 

2988 cubes: Cube | iris.cube.CubeList 

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

2990 than the stamp coordinate. 

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

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

2993 filename: str, optional 

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

2995 to the recipe name. 

2996 sequence_coordinate: str, optional 

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

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

2999 slider. 

3000 stamp_coordinate: str, optional 

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

3002 ``"realization"``. 

3003 single_plot: bool, optional 

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

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

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

3007 

3008 Returns 

3009 ------- 

3010 iris.cube.Cube | iris.cube.CubeList 

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

3012 Plotted data. 

3013 

3014 Raises 

3015 ------ 

3016 ValueError 

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

3018 TypeError 

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

3020 """ 

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

3022 

3023 cubes = iter_maybe(cubes) 

3024 

3025 # Internal plotting function. 

3026 plotting_func = _plot_and_save_power_spectrum_series 

3027 

3028 num_models = _get_num_models(cubes) 

3029 

3030 _validate_cube_shape(cubes, num_models) 

3031 

3032 _check_sequence(cubes, sequence_coordinate) 

3033 

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

3035 # single point. If single_plot is True: 

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

3037 # separate postage stamp plots. 

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

3039 # produced per single model only 

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

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

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

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

3044 ): 

3045 if single_plot: 

3046 plotting_func = ( 

3047 _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series 

3048 ) 

3049 else: 

3050 plotting_func = _plot_and_save_postage_stamp_power_spectrum_series 

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

3052 else: 

3053 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

3054 

3055 plot_index = [] 

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

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

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

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

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

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

3062 for cube_slice in cube_iterables: 

3063 single_cube = cube_slice 

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

3065 single_cube = cube_slice[0] 

3066 

3067 # Set stamp coordinate 

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

3069 stamp_coordinate = check_stamp_coordinate(single_cube) 

3070 # Set plot title and filenames based on sequence values 

3071 seq_coord = single_cube.coord(sequence_coordinate) 

3072 plot_title, plot_filename = _set_title_and_filename( 

3073 seq_coord, nplot, recipe_title, filename 

3074 ) 

3075 

3076 # Do the actual plotting. 

3077 plotting_func( 

3078 cube_slice, 

3079 filename=plot_filename, 

3080 stamp_coordinate=stamp_coordinate, 

3081 title=plot_title, 

3082 ) 

3083 plot_index.append(plot_filename) 

3084 

3085 # Add list of plots to plot metadata. 

3086 complete_plot_index = _append_to_plot_index(plot_index) 

3087 

3088 # Make a page to display the plots. 

3089 _make_plot_html_page(complete_plot_index) 

3090 

3091 return cubes 

3092 

3093 

3094def _DCT_ps(y_3d): 

3095 """Calculate power spectra for regional domains. 

3096 

3097 Parameters 

3098 ---------- 

3099 y_3d: 3D array 

3100 3 dimensional array to calculate spectrum for. 

3101 (2D field data with 3rd dimension of time) 

3102 

3103 Returns: ps_array 

3104 Array of power spectra values calculated for input field (for each time) 

3105 

3106 Method for regional domains: 

3107 Calculate power spectra over limited area domain using Discrete Cosine Transform (DCT) 

3108 as described in Denis et al 2002 [Denis_etal_2002]_. 

3109 

3110 References 

3111 ---------- 

3112 .. [Denis_etal_2002] Bertrand Denis, Jean Côté and René Laprise (2002) 

3113 "Spectral Decomposition of Two-Dimensional Atmospheric Fields on 

3114 Limited-Area Domains Using the Discrete Cosine Transform (DCT)" 

3115 Monthly Weather Review, Vol. 130, 1812-1828 

3116 doi: https://doi.org/10.1175/1520-0493(2002)130<1812:SDOTDA>2.0.CO;2 

3117 """ 

3118 Nt, Ny, Nx = y_3d.shape 

3119 

3120 # Max coefficient 

3121 Nmin = min(Nx - 1, Ny - 1) 

3122 

3123 # Create alpha matrix (of wavenumbers) 

3124 alpha_matrix = _create_alpha_matrix(Ny, Nx) 

3125 

3126 # Prepare output array 

3127 ps_array = np.zeros((Nt, Nmin)) 

3128 

3129 # Loop over time to get spectrum for each time. 

3130 for t in range(Nt): 

3131 y_2d = y_3d[t] 

3132 

3133 # Apply 2D DCT to transform y_3d[t] from physical space to spectral space. 

3134 # fkk is a 2D array of DCT coefficients, representing the amplitudes of 

3135 # cosine basis functions at different spatial frequencies. 

3136 

3137 # normalise spectrum to allow comparison between models. 

3138 fkk = fft.dctn(y_2d, norm="ortho") 

3139 

3140 # Normalise fkk 

3141 fkk = fkk / np.sqrt(Ny * Nx) 

3142 

3143 # calculate variance of spectral coefficient 

3144 sigma_2 = fkk**2 / Nx / Ny 

3145 

3146 # Group ellipses of alphas into the same wavenumber k/Nmin 

3147 for k in range(1, Nmin + 1): 

3148 alpha = k / Nmin 

3149 alpha_p1 = (k + 1) / Nmin 

3150 

3151 # Sum up elements matching k 

3152 mask_k = np.where((alpha_matrix >= alpha) & (alpha_matrix < alpha_p1)) 

3153 ps_array[t, k - 1] = np.sum(sigma_2[mask_k]) 

3154 

3155 return ps_array 

3156 

3157 

3158def _create_alpha_matrix(Ny, Nx): 

3159 """Construct an array of 2D wavenumbers from 2D wavenumber pair. 

3160 

3161 Parameters 

3162 ---------- 

3163 Ny, Nx: 

3164 Dimensions of the 2D field for which the power spectra is calculated. Used to 

3165 create the array of 2D wavenumbers. Each Ny, Nx pair is associated with a 

3166 single-scale parameter. 

3167 

3168 Returns: alpha_matrix 

3169 normalisation of 2D wavenumber axes, transforming the spectral domain into 

3170 an elliptic coordinate system. 

3171 

3172 """ 

3173 # Create x_indices: each row is [1, 2, ..., Nx] 

3174 x_indices = np.tile(np.arange(1, Nx + 1), (Ny, 1)) 

3175 

3176 # Create y_indices: each column is [1, 2, ..., Ny] 

3177 y_indices = np.tile(np.arange(1, Ny + 1).reshape(Ny, 1), (1, Nx)) 

3178 

3179 # Compute alpha_matrix 

3180 alpha_matrix = np.sqrt((x_indices**2) / Nx**2 + (y_indices**2) / Ny**2) 

3181 

3182 return alpha_matrix