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

851 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-17 15:44 +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 

36from cartopy.mpl.geoaxes import GeoAxes 

37from iris.cube import Cube 

38from markdown_it import MarkdownIt 

39from mpl_toolkits.axes_grid1.inset_locator import inset_axes 

40 

41from CSET._common import ( 

42 filename_slugify, 

43 get_recipe_metadata, 

44 iter_maybe, 

45 render_file, 

46 slugify, 

47) 

48from CSET.operators._colormaps import ( 

49 colorbar_map_levels, 

50 get_model_colors_map, 

51) 

52from CSET.operators._spectra import DCT_ps 

53from CSET.operators._utils import ( 

54 check_sequence_coordinate, 

55 check_single_cube, 

56 check_stamp_coordinate, 

57 fully_equalise_attributes, 

58 get_cube_yxcoordname, 

59 get_num_models, 

60 is_transect, 

61 slice_over_maybe, 

62 validate_cube_shape, 

63 validate_cubes_coords, 

64) 

65from CSET.operators.collapse import collapse 

66from CSET.operators.misc import _extract_common_time_points 

67from CSET.operators.regrid import regrid_onto_cube 

68 

69# Use a non-interactive plotting backend. 

70mpl.use("agg") 

71 

72 

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

74# Private helper functions # 

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

76 

77 

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

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

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

81 fcntl.flock(fp, fcntl.LOCK_EX) 

82 fp.seek(0) 

83 meta = json.load(fp) 

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

85 complete_plot_index = complete_plot_index + plot_index 

86 meta["plots"] = complete_plot_index 

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

88 os.getenv("DO_CASE_AGGREGATION") 

89 ): 

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

91 fp.seek(0) 

92 fp.truncate() 

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

94 return complete_plot_index 

95 

96 

97def _make_plot_html_page(plots: list): 

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

99 # Debug check that plots actually contains some strings. 

100 assert isinstance(plots[0], str) 

101 

102 # Load HTML template file. 

103 operator_files = importlib.resources.files() 

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

105 

106 # Get some metadata. 

107 meta = get_recipe_metadata() 

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

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

110 

111 # Prepare template variables. 

112 variables = { 

113 "title": title, 

114 "description": description, 

115 "initial_plot": plots[0], 

116 "plots": plots, 

117 "title_slug": slugify(title), 

118 } 

119 

120 # Render template. 

121 html = render_file(template_file, **variables) 

122 

123 # Save completed HTML. 

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

125 fp.write(html) 

126 

127 

128def _setup_spatial_map( 

129 cube: iris.cube.Cube, 

130 figure, 

131 cmap, 

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

133 subplot: int | None = None, 

134): 

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

136 

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

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

139 

140 Parameters 

141 ---------- 

142 cube: Cube 

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

144 figure: 

145 Matplotlib Figure object holding all plot elements. 

146 cmap: 

147 Matplotlib colormap. 

148 grid_size: (int, int), optional 

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

150 subplot: int, optional 

151 Subplot index if multiple spatial subplots in figure. 

152 

153 Returns 

154 ------- 

155 axes: 

156 Matplotlib GeoAxes definition. 

157 """ 

158 # Identify min/max plot bounds. 

159 try: 

160 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

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

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

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

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

165 

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

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

168 x1 = x1 - 180.0 

169 x2 = x2 - 180.0 

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

171 

172 # Consider map projection orientation. 

173 # Adapting orientation enables plotting across international dateline. 

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

175 if x2 > 180.0 or x1 < -180.0: 

176 central_longitude = 180.0 

177 else: 

178 central_longitude = 0.0 

179 

180 # Define spatial map projection. 

181 coord_system = cube.coord(lat_axis).coord_system 

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

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

184 projection = ccrs.RotatedPole( 

185 pole_longitude=coord_system.grid_north_pole_longitude, 

186 pole_latitude=coord_system.grid_north_pole_latitude, 

187 central_rotated_longitude=central_longitude, 

188 ) 

189 crs = projection 

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

191 # Define Transverse Mercator projection for TM inputs. 

192 projection = ccrs.TransverseMercator( 

193 central_longitude=coord_system.longitude_of_central_meridian, 

194 central_latitude=coord_system.latitude_of_projection_origin, 

195 false_easting=coord_system.false_easting, 

196 false_northing=coord_system.false_northing, 

197 scale_factor=coord_system.scale_factor_at_central_meridian, 

198 ) 

199 crs = projection 

200 else: 

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

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

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

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

205 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

206 crs = ccrs.PlateCarree() 

207 

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

209 if subplot is not None: 

210 axes = figure.add_subplot( 

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

212 ) 

213 else: 

214 axes = figure.add_subplot(projection=projection) 

215 

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

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

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

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

220 ): 

221 pass 

222 else: 

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

224 coastcol = "magenta" 

225 else: 

226 coastcol = "black" 

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

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

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

230 

231 # Add gridlines. 

232 gl = axes.gridlines( 

233 alpha=0.3, 

234 draw_labels=True, 

235 dms=False, 

236 x_inline=False, 

237 y_inline=False, 

238 ) 

239 gl.top_labels = False 

240 gl.right_labels = False 

241 if subplot: 

242 gl.bottom_labels = False 

243 gl.left_labels = False 

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

245 gl.left_labels = True 

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

247 gl.bottom_labels = True 

248 

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

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

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

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

253 

254 except ValueError: 

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

256 axes = figure.gca() 

257 pass 

258 

259 return axes 

260 

261 

262def _get_plot_resolution() -> int: 

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

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

265 

266 

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

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

269 if use_bounds and seq_coord.has_bounds(): 

270 vals = seq_coord.bounds.flatten() 

271 else: 

272 vals = seq_coord.points 

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

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

275 

276 if start == end: 

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

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

279 else: 

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

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

282 

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

284 if ( 

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

286 and vals[0] == 0 

287 and vals[-1] == 0 

288 ): 

289 sequence_title = "" 

290 sequence_fname = "" 

291 

292 return sequence_title, sequence_fname 

293 

294 

295def _set_title_and_filename( 

296 seq_coord: iris.coords.Coord, 

297 nplot: int, 

298 recipe_title: str, 

299 filename: str, 

300): 

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

302 

303 Parameters 

304 ---------- 

305 sequence_coordinate: iris.coords.Coord 

306 Coordinate about which to make a plot sequence. 

307 nplot: int 

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

309 recipe_title: str 

310 Default plot title, potentially to update. 

311 filename: str 

312 Input plot filename, potentially to update. 

313 

314 Returns 

315 ------- 

316 plot_title: str 

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

318 plot_filename: str 

319 Output formatted plot filename string. 

320 """ 

321 ndim = seq_coord.ndim 

322 npoints = np.size(seq_coord.points) 

323 sequence_title = "" 

324 sequence_fname = "" 

325 

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

327 # (e.g. aggregation histogram plots) 

328 if ndim > 1: 

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

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

331 sequence_fname = f"_{ncase}cases" 

332 

333 # Case 2: Single dimension input 

334 else: 

335 # Single sequence point 

336 if npoints == 1: 

337 if nplot > 1: 

338 # Default labels for sequence inputs 

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

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

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

342 else: 

343 # Aggregated attribute available where input collapsed over aggregation 

344 try: 

345 ncase = seq_coord.attributes["number_reference_times"] 

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

347 sequence_fname = f"_{ncase}cases" 

348 except KeyError: 

349 sequence_title, sequence_fname = _get_start_end_strings( 

350 seq_coord, use_bounds=seq_coord.has_bounds() 

351 ) 

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

353 else: 

354 sequence_title, sequence_fname = _get_start_end_strings( 

355 seq_coord, use_bounds=False 

356 ) 

357 

358 # Set plot title and filename 

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

360 

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

362 if filename is None: 

363 filename = slugify(recipe_title) 

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

365 else: 

366 if nplot > 1: 

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

368 else: 

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

370 

371 return plot_title, plot_filename 

372 

373 

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

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

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

377 mtitle = "Member" 

378 else: 

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

380 

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

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

383 else: 

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

385 

386 return mtitle 

387 

388 

389def _set_axis_range(cubes): 

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

391 levels = None 

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

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

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

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

396 if levels is None: 

397 break 

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

399 # levels-based ranges for histogram plots. 

400 _, levels, _ = colorbar_map_levels(cube) 

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

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

403 vmin = min(levels) 

404 vmax = max(levels) 

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

406 break 

407 

408 if levels is None: 

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

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

411 

412 return vmin, vmax 

413 

414 

415def _find_matched_slices(cubes, sequence_coordinate): 

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

417 

418 Ensures common points are compared for multiple cube inputs. 

419 """ 

420 all_points = sorted( 

421 set( 

422 itertools.chain.from_iterable( 

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

424 ) 

425 ) 

426 ) 

427 all_slices = list( 

428 itertools.chain.from_iterable( 

429 cb.slices_over(sequence_coordinate) for cb in cubes 

430 ) 

431 ) 

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

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

434 # necessary) 

435 cube_iterables = [ 

436 iris.cube.CubeList( 

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

438 ) 

439 for point in all_points 

440 ] 

441 

442 return cube_iterables 

443 

444 

445def _plot_and_save_spatial_plot( 

446 cube: iris.cube.Cube, 

447 filename: str, 

448 title: str, 

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

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

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

452 **kwargs, 

453): 

454 """Plot and save a spatial plot. 

455 

456 Parameters 

457 ---------- 

458 cube: Cube 

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

460 filename: str 

461 Filename of the plot to write. 

462 title: str 

463 Plot title. 

464 method: "contourf" | "pcolormesh" 

465 The plotting method to use. 

466 overlay_cube: Cube, optional 

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

468 contour_cube: Cube, optional 

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

470 """ 

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

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

473 

474 # Specify the color bar 

475 cmap, levels, norm = colorbar_map_levels(cube) 

476 

477 # If overplotting, set required colorbars 

478 if overlay_cube: 

479 over_cmap, over_levels, over_norm = colorbar_map_levels(overlay_cube) 

480 if contour_cube: 

481 cntr_cmap, cntr_levels, cntr_norm = colorbar_map_levels(contour_cube) 

482 

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

484 axes = _setup_spatial_map(cube, fig, cmap) 

485 

486 # Set colorscale bounds 

487 try: 

488 vmin = min(levels) 

489 vmax = max(levels) 

490 except TypeError: 

491 vmin, vmax = None, None 

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

493 if norm is not None: 

494 vmin = None 

495 vmax = None 

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

497 

498 # Plot the field. 

499 if method == "contourf": 

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

501 elif method == "pcolormesh": 

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

503 else: 

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

505 

506 # Overplot overlay field, if required 

507 if overlay_cube: 

508 try: 

509 over_vmin = min(over_levels) 

510 over_vmax = max(over_levels) 

511 except TypeError: 

512 over_vmin, over_vmax = None, None 

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

514 over_vmin = None 

515 over_vmax = None 

516 overlay = iplt.pcolormesh( 

517 overlay_cube, 

518 cmap=over_cmap, 

519 norm=over_norm, 

520 alpha=0.8, 

521 vmin=over_vmin, 

522 vmax=over_vmax, 

523 ) 

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

525 if contour_cube: 

526 contour = iplt.contour( 

527 contour_cube, 

528 colors="darkgray", 

529 levels=cntr_levels, 

530 norm=cntr_norm, 

531 alpha=0.5, 

532 linestyles="--", 

533 linewidths=1, 

534 ) 

535 plt.clabel(contour) 

536 

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

538 if is_transect(cube): 

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

540 axes.invert_yaxis() 

541 axes.set_yscale("log") 

542 axes.set_ylim(1100, 100) 

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

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

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

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

547 ): 

548 axes.set_yscale("log") 

549 

550 axes.set_title( 

551 f"{title}\n" 

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

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

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

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

556 fontsize=16, 

557 ) 

558 

559 # Inset code 

560 axins = inset_axes( 

561 axes, 

562 width="20%", 

563 height="20%", 

564 loc="upper right", 

565 axes_class=GeoAxes, 

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

567 ) 

568 

569 # Slightly transparent to reduce plot blocking. 

570 axins.patch.set_alpha(0.4) 

571 

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

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

574 

575 SLat, SLon, ELat, ELon = ( 

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

577 ) 

578 

579 # Draw line between them 

580 axins.plot( 

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

582 ) 

583 

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

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

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

587 

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

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

590 

591 # Midpoints 

592 lon_mid = (lon_min + lon_max) / 2 

593 lat_mid = (lat_min + lat_max) / 2 

594 

595 # Maximum half-range 

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

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

598 half_range = 1 

599 

600 # Set square extent 

601 axins.set_extent( 

602 [ 

603 lon_mid - half_range, 

604 lon_mid + half_range, 

605 lat_mid - half_range, 

606 lat_mid + half_range, 

607 ], 

608 crs=ccrs.PlateCarree(), 

609 ) 

610 

611 # Ensure square aspect 

612 axins.set_aspect("equal") 

613 

614 else: 

615 # Add title. 

616 axes.set_title(title, fontsize=16) 

617 

618 # Adjust padding if spatial plot or transect 

619 if is_transect(cube): 

620 yinfopad = -0.1 

621 ycbarpad = 0.1 

622 else: 

623 yinfopad = 0.01 

624 ycbarpad = 0.042 

625 

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

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

628 axes.annotate( 

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

630 xy=(0.025, yinfopad), 

631 xycoords="axes fraction", 

632 xytext=(-5, 5), 

633 textcoords="offset points", 

634 ha="left", 

635 va="bottom", 

636 size=11, 

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

638 ) 

639 

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

641 if overlay_cube: 

642 cbarB = fig.colorbar( 

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

644 ) 

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

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

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

648 cbarB.set_ticks(over_levels) 

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

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

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

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

653 

654 # Add main colour bar. 

655 cbar = fig.colorbar( 

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

657 ) 

658 

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

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

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

662 cbar.set_ticks(levels) 

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

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

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

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

667 

668 # Save plot. 

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

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

671 plt.close(fig) 

672 

673 

674def _plot_and_save_postage_stamp_spatial_plot( 

675 cube: iris.cube.Cube, 

676 filename: str, 

677 stamp_coordinate: str, 

678 title: str, 

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

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

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

682 **kwargs, 

683): 

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

685 

686 Parameters 

687 ---------- 

688 cube: Cube 

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

690 filename: str 

691 Filename of the plot to write. 

692 stamp_coordinate: str 

693 Coordinate that becomes different plots. 

694 method: "contourf" | "pcolormesh" 

695 The plotting method to use. 

696 overlay_cube: Cube, optional 

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

698 contour_cube: Cube, optional 

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

700 

701 Raises 

702 ------ 

703 ValueError 

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

705 """ 

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

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

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

709 grid_size = math.ceil(nmember / grid_rows) 

710 

711 fig = plt.figure( 

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

713 ) 

714 

715 # Specify the color bar 

716 cmap, levels, norm = colorbar_map_levels(cube) 

717 # If overplotting, set required colorbars 

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

719 over_cmap, over_levels, over_norm = colorbar_map_levels(overlay_cube) 

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

721 cntr_cmap, cntr_levels, cntr_norm = colorbar_map_levels(contour_cube) 

722 

723 # Make a subplot for each member. 

724 for member, subplot in zip( 

725 cube.slices_over(stamp_coordinate), 

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

727 strict=False, 

728 ): 

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

730 axes = _setup_spatial_map( 

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

732 ) 

733 if method == "contourf": 

734 # Filled contour plot of the field. 

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

736 elif method == "pcolormesh": 

737 if levels is not None: 

738 vmin = min(levels) 

739 vmax = max(levels) 

740 else: 

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

742 vmin, vmax = None, None 

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

744 # if levels are defined. 

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

746 vmin = None 

747 vmax = None 

748 # pcolormesh plot of the field. 

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

750 else: 

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

752 

753 # Overplot overlay field, if required 

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

755 try: 

756 over_vmin = min(over_levels) 

757 over_vmax = max(over_levels) 

758 except TypeError: 

759 over_vmin, over_vmax = None, None 

760 if over_norm is not None: 

761 over_vmin = None 

762 over_vmax = None 

763 iplt.pcolormesh( 

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

765 cmap=over_cmap, 

766 norm=over_norm, 

767 alpha=0.6, 

768 vmin=over_vmin, 

769 vmax=over_vmax, 

770 ) 

771 # Overplot contour field, if required 

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

773 iplt.contour( 

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

775 colors="darkgray", 

776 levels=cntr_levels, 

777 norm=cntr_norm, 

778 alpha=0.6, 

779 linestyles="--", 

780 linewidths=1, 

781 ) 

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

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

784 

785 # Put the shared colorbar in its own axes. 

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

787 colorbar = fig.colorbar( 

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

789 ) 

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

791 

792 # Overall figure title. 

793 fig.suptitle(title, fontsize=16) 

794 

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

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

797 plt.close(fig) 

798 

799 

800def _plot_and_save_line_series( 

801 cubes: iris.cube.CubeList, 

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

803 ensemble_coord: str, 

804 filename: str, 

805 title: str, 

806 **kwargs, 

807): 

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

809 

810 Parameters 

811 ---------- 

812 cubes: Cube or CubeList 

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

814 coords: list[Coord] 

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

816 ensemble_coord: str 

817 Ensemble coordinate in the cube. 

818 filename: str 

819 Filename of the plot to write. 

820 title: str 

821 Plot title. 

822 """ 

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

824 

825 model_colors_map = get_model_colors_map(cubes) 

826 

827 # Store min/max ranges. 

828 y_levels = [] 

829 

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

831 validate_cubes_coords(cubes, coords) 

832 

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

834 label = None 

835 color = "black" 

836 if model_colors_map: 

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

838 color = model_colors_map.get(label) 

839 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

842 iplt.plot( 

843 coord, 

844 cube_slice, 

845 color=color, 

846 marker="o", 

847 ls="-", 

848 lw=3, 

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

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

851 else label, 

852 ) 

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

854 else: 

855 iplt.plot( 

856 coord, 

857 cube_slice, 

858 color=color, 

859 ls="-", 

860 lw=1.5, 

861 alpha=0.75, 

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

863 ) 

864 

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

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

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

868 y_levels.append(min(levels)) 

869 y_levels.append(max(levels)) 

870 

871 # Get the current axes. 

872 ax = plt.gca() 

873 

874 # Add some labels and tweak the style. 

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

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

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

878 else: 

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

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

881 ax.set_title(title, fontsize=16) 

882 

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

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

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

886 

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

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

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

890 # Add zero line. 

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

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

893 logging.debug( 

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

895 ) 

896 else: 

897 ax.autoscale() 

898 

899 # Add gridlines 

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

901 # Ientify unique labels for legend 

902 handles = list( 

903 { 

904 label: handle 

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

906 }.values() 

907 ) 

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

909 

910 # Save plot. 

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

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

913 plt.close(fig) 

914 

915 

916def _plot_and_save_vertical_line_series( 

917 cubes: iris.cube.CubeList, 

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

919 ensemble_coord: str, 

920 filename: str, 

921 series_coordinate: str, 

922 title: str, 

923 vmin: float, 

924 vmax: float, 

925 **kwargs, 

926): 

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

928 

929 Parameters 

930 ---------- 

931 cubes: CubeList 

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

933 coord: list[Coord] 

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

935 ensemble_coord: str 

936 Ensemble coordinate in the cube. 

937 filename: str 

938 Filename of the plot to write. 

939 series_coordinate: str 

940 Coordinate to use as vertical axis. 

941 title: str 

942 Plot title. 

943 vmin: float 

944 Minimum value for the x-axis. 

945 vmax: float 

946 Maximum value for the x-axis. 

947 """ 

948 # plot the vertical pressure axis using log scale 

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

950 

951 model_colors_map = get_model_colors_map(cubes) 

952 

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

954 validate_cubes_coords(cubes, coords) 

955 

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

957 label = None 

958 color = "black" 

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

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

961 color = model_colors_map.get(label) 

962 

963 for cube_slice in cube.slices_over(ensemble_coord): 

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

965 # unless single forecast. 

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

967 iplt.plot( 

968 cube_slice, 

969 coord, 

970 color=color, 

971 marker="o", 

972 ls="-", 

973 lw=3, 

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

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

976 else label, 

977 ) 

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

979 else: 

980 iplt.plot( 

981 cube_slice, 

982 coord, 

983 color=color, 

984 ls="-", 

985 lw=1.5, 

986 alpha=0.75, 

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

988 ) 

989 

990 # Get the current axis 

991 ax = plt.gca() 

992 

993 # Special handling for pressure level data. 

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

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

996 ax.invert_yaxis() 

997 ax.set_yscale("log") 

998 

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

1000 y_tick_labels = [ 

1001 "1000", 

1002 "850", 

1003 "700", 

1004 "500", 

1005 "300", 

1006 "200", 

1007 "100", 

1008 ] 

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

1010 

1011 # Set y-axis limits and ticks. 

1012 ax.set_ylim(1100, 100) 

1013 

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

1015 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

1021 

1022 ax.set_yticks(y_ticks) 

1023 ax.set_yticklabels(y_tick_labels) 

1024 

1025 # Set x-axis limits. 

1026 ax.set_xlim(vmin, vmax) 

1027 # Mark y=0 if present in plot. 

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

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

1030 

1031 # Add some labels and tweak the style. 

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

1033 ax.set_xlabel( 

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

1035 ) 

1036 ax.set_title(title, fontsize=16) 

1037 ax.ticklabel_format(axis="x") 

1038 ax.tick_params(axis="y") 

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

1040 

1041 # Add gridlines 

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

1043 # Ientify unique labels for legend 

1044 handles = list( 

1045 { 

1046 label: handle 

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

1048 }.values() 

1049 ) 

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

1051 

1052 # Save plot. 

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

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

1055 plt.close(fig) 

1056 

1057 

1058def _plot_and_save_scatter_plot( 

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

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

1061 filename: str, 

1062 title: str, 

1063 one_to_one: bool, 

1064 model_names: list[str] = None, 

1065 **kwargs, 

1066): 

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

1068 

1069 Parameters 

1070 ---------- 

1071 cube_x: Cube | CubeList 

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

1073 cube_y: Cube | CubeList 

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

1075 filename: str 

1076 Filename of the plot to write. 

1077 title: str 

1078 Plot title. 

1079 one_to_one: bool 

1080 Whether a 1:1 line is plotted. 

1081 """ 

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

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

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

1085 # over the pairs simultaneously. 

1086 

1087 # Ensure cube_x and cube_y are iterable 

1088 cube_x_iterable = iter_maybe(cube_x) 

1089 cube_y_iterable = iter_maybe(cube_y) 

1090 

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

1092 iplt.scatter(cube_x_iter, cube_y_iter) 

1093 if one_to_one is True: 

1094 plt.plot( 

1095 [ 

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

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

1098 ], 

1099 [ 

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

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

1102 ], 

1103 "k", 

1104 linestyle="--", 

1105 ) 

1106 ax = plt.gca() 

1107 

1108 # Add some labels and tweak the style. 

1109 if model_names is None: 

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

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

1112 else: 

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

1114 ax.set_xlabel( 

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

1116 ) 

1117 ax.set_ylabel( 

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

1119 ) 

1120 ax.set_title(title, fontsize=16) 

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

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

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

1124 ax.autoscale() 

1125 

1126 # Save plot. 

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

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

1129 plt.close(fig) 

1130 

1131 

1132def _plot_and_save_vector_plot( 

1133 cube_u: iris.cube.Cube, 

1134 cube_v: iris.cube.Cube, 

1135 filename: str, 

1136 title: str, 

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

1138 **kwargs, 

1139): 

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

1141 

1142 Parameters 

1143 ---------- 

1144 cube_u: Cube 

1145 2 dimensional Cube of u component of the data. 

1146 cube_v: Cube 

1147 2 dimensional Cube of v component of the data. 

1148 filename: str 

1149 Filename of the plot to write. 

1150 title: str 

1151 Plot title. 

1152 """ 

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

1154 

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

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

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

1158 

1159 # Specify the color bar 

1160 cmap, levels, norm = colorbar_map_levels(cube_vec_mag) 

1161 

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

1163 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

1164 

1165 if method == "contourf": 

1166 # Filled contour plot of the field. 

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

1168 elif method == "pcolormesh": 

1169 try: 

1170 vmin = min(levels) 

1171 vmax = max(levels) 

1172 except TypeError: 

1173 vmin, vmax = None, None 

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

1175 # if levels are defined. 

1176 if norm is not None: 

1177 vmin = None 

1178 vmax = None 

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

1180 else: 

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

1182 

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

1184 if is_transect(cube_vec_mag): 

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

1186 axes.invert_yaxis() 

1187 axes.set_yscale("log") 

1188 axes.set_ylim(1100, 100) 

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

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

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

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

1193 ): 

1194 axes.set_yscale("log") 

1195 

1196 axes.set_title( 

1197 f"{title}\n" 

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

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

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

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

1202 fontsize=16, 

1203 ) 

1204 

1205 else: 

1206 # Add title. 

1207 axes.set_title(title, fontsize=16) 

1208 

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

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

1211 axes.annotate( 

1212 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}", 

1213 xy=(0.05, -0.05), 

1214 xycoords="axes fraction", 

1215 xytext=(-5, 5), 

1216 textcoords="offset points", 

1217 ha="right", 

1218 va="bottom", 

1219 size=11, 

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

1221 ) 

1222 

1223 # Add colour bar. 

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

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

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

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

1228 cbar.set_ticks(levels) 

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

1230 

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

1232 # with less than 30 points. 

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

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

1235 

1236 # Save plot. 

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

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

1239 plt.close(fig) 

1240 

1241 

1242def _plot_and_save_histogram_series( 

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

1244 filename: str, 

1245 title: str, 

1246 vmin: float, 

1247 vmax: float, 

1248 **kwargs, 

1249): 

1250 """Plot and save a histogram series. 

1251 

1252 Parameters 

1253 ---------- 

1254 cubes: Cube or CubeList 

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

1256 filename: str 

1257 Filename of the plot to write. 

1258 title: str 

1259 Plot title. 

1260 vmin: float 

1261 minimum for colorbar 

1262 vmax: float 

1263 maximum for colorbar 

1264 """ 

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

1266 ax = plt.gca() 

1267 

1268 model_colors_map = get_model_colors_map(cubes) 

1269 

1270 # Set default that histograms will produce probability density function 

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

1272 density = True 

1273 

1274 for cube in iter_maybe(cubes): 

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

1276 # than seeing if long names exist etc. 

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

1278 if "surface_microphysical" in title: 

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

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

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

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

1283 density = False 

1284 else: 

1285 bins = 10.0 ** ( 

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

1287 ) # Suggestion from RMED toolbox. 

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

1289 ax.set_yscale("log") 

1290 vmin = bins[1] 

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

1292 ax.set_xscale("log") 

1293 elif "lightning" in title: 

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

1295 else: 

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

1297 logging.debug( 

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

1299 np.size(bins), 

1300 np.min(bins), 

1301 np.max(bins), 

1302 ) 

1303 

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

1305 # Otherwise we plot xdim histograms stacked. 

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

1307 

1308 label = None 

1309 color = "black" 

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

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

1312 color = model_colors_map[label] 

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

1314 

1315 # Compute area under curve. 

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

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

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

1319 x = x[1:] 

1320 y = y[1:] 

1321 

1322 ax.plot( 

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

1324 ) 

1325 

1326 # Add some labels and tweak the style. 

1327 ax.set_title(title, fontsize=16) 

1328 ax.set_xlabel( 

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

1330 ) 

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

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

1333 ax.set_ylabel( 

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

1335 ) 

1336 ax.set_xlim(vmin, vmax) 

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

1338 

1339 # Overlay grid-lines onto histogram plot. 

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

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

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

1343 

1344 # Save plot. 

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

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

1347 plt.close(fig) 

1348 

1349 

1350def _plot_and_save_postage_stamp_histogram_series( 

1351 cube: iris.cube.Cube, 

1352 filename: str, 

1353 title: str, 

1354 stamp_coordinate: str, 

1355 vmin: float, 

1356 vmax: float, 

1357 **kwargs, 

1358): 

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

1360 

1361 Parameters 

1362 ---------- 

1363 cube: Cube 

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

1365 filename: str 

1366 Filename of the plot to write. 

1367 title: str 

1368 Plot title. 

1369 stamp_coordinate: str 

1370 Coordinate that becomes different plots. 

1371 vmin: float 

1372 minimum for pdf x-axis 

1373 vmax: float 

1374 maximum for pdf x-axis 

1375 """ 

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

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

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

1379 grid_size = math.ceil(nmember / grid_rows) 

1380 

1381 fig = plt.figure( 

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

1383 ) 

1384 # Make a subplot for each member. 

1385 for member, subplot in zip( 

1386 cube.slices_over(stamp_coordinate), 

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

1388 strict=False, 

1389 ): 

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

1391 # cartopy GeoAxes generated. 

1392 plt.subplot(grid_rows, grid_size, subplot) 

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

1394 # Otherwise we plot xdim histograms stacked. 

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

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

1397 axes = plt.gca() 

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

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

1400 axes.set_xlim(vmin, vmax) 

1401 

1402 # Overall figure title. 

1403 fig.suptitle(title, fontsize=16) 

1404 

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

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

1407 plt.close(fig) 

1408 

1409 

1410def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1411 cube: iris.cube.Cube, 

1412 filename: str, 

1413 title: str, 

1414 stamp_coordinate: str, 

1415 vmin: float, 

1416 vmax: float, 

1417 **kwargs, 

1418): 

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

1420 ax.set_title(title, fontsize=16) 

1421 ax.set_xlim(vmin, vmax) 

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

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

1424 # Loop over all slices along the stamp_coordinate 

1425 for member in cube.slices_over(stamp_coordinate): 

1426 # Flatten the member data to 1D 

1427 member_data_1d = member.data.flatten() 

1428 # Plot the histogram using plt.hist 

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

1430 plt.hist( 

1431 member_data_1d, 

1432 density=True, 

1433 stacked=True, 

1434 label=f"{mtitle}", 

1435 ) 

1436 

1437 # Add a legend 

1438 ax.legend(fontsize=16) 

1439 

1440 # Save the figure to a file 

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

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

1443 

1444 # Close the figure 

1445 plt.close(fig) 

1446 

1447 

1448def _plot_and_save_scattermap_plot( 

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

1450): 

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

1452 

1453 Parameters 

1454 ---------- 

1455 cube: Cube 

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

1457 longitude coordinates, 

1458 filename: str 

1459 Filename of the plot to write. 

1460 title: str 

1461 Plot title. 

1462 projection: str 

1463 Mapping projection to be used by cartopy. 

1464 """ 

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

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

1467 if projection is not None: 

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

1469 # a stereographic projection over the North Pole. 

1470 if projection == "NP_Stereo": 

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

1472 else: 

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

1474 else: 

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

1476 

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

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

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

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

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

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

1483 # proportion to the area of the figure. 

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

1485 klon = None 

1486 klat = None 

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

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

1489 klat = kc 

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

1491 klon = kc 

1492 scatter_map = iplt.scatter( 

1493 cube.aux_coords[klon], 

1494 cube.aux_coords[klat], 

1495 c=cube.data[:], 

1496 s=mrk_size, 

1497 cmap="jet", 

1498 edgecolors="k", 

1499 ) 

1500 

1501 # Add coastlines and borderlines. 

1502 try: 

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

1504 axes.add_feature(cfeature.BORDERS) 

1505 except AttributeError: 

1506 pass 

1507 

1508 # Add title. 

1509 axes.set_title(title, fontsize=16) 

1510 

1511 # Add colour bar. 

1512 cbar = fig.colorbar(scatter_map) 

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

1514 

1515 # Save plot. 

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

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

1518 plt.close(fig) 

1519 

1520 

1521def _plot_and_save_power_spectrum_series( 

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

1523 filename: str, 

1524 title: str, 

1525 **kwargs, 

1526): 

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

1528 

1529 Parameters 

1530 ---------- 

1531 cubes: Cube or CubeList 

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

1533 filename: str 

1534 Filename of the plot to write. 

1535 title: str 

1536 Plot title. 

1537 """ 

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

1539 ax = plt.gca() 

1540 

1541 model_colors_map = get_model_colors_map(cubes) 

1542 

1543 for cube in iter_maybe(cubes): 

1544 # Calculate power spectrum 

1545 

1546 # Extract time coordinate and convert to datetime 

1547 time_coord = cube.coord("time") 

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

1549 

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

1551 target_time = time_points[0] 

1552 

1553 # Bind target_time inside the lambda using a default argument 

1554 time_constraint = iris.Constraint( 

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

1556 ) 

1557 

1558 cube = cube.extract(time_constraint) 

1559 

1560 if cube.ndim == 2: 

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

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

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

1564 cube_3d = cube.data 

1565 else: 

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

1567 raise ValueError( 

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

1569 ) 

1570 

1571 # Calculate spectra 

1572 ps_array = DCT_ps(cube_3d) 

1573 

1574 ps_cube = iris.cube.Cube( 

1575 ps_array, 

1576 long_name="power_spectra", 

1577 ) 

1578 

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

1580 

1581 # Create a frequency/wavelength array for coordinate 

1582 ps_len = ps_cube.data.shape[1] 

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

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

1585 

1586 # Convert datetime to numeric time using original units 

1587 numeric_time = time_coord.units.date2num(time_points) 

1588 # Create a new DimCoord with numeric time 

1589 new_time_coord = iris.coords.DimCoord( 

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

1591 ) 

1592 

1593 # Add time and frequency coordinate to spectra cube. 

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

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

1596 

1597 # Extract data from the cube 

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

1599 power_spectrum = ps_cube.data 

1600 

1601 label = None 

1602 color = "black" 

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

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

1605 color = model_colors_map[label] 

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

1607 

1608 # Add some labels and tweak the style. 

1609 ax.set_title(title, fontsize=16) 

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

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

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

1613 

1614 # Set log-log scale 

1615 ax.set_xscale("log") 

1616 ax.set_yscale("log") 

1617 

1618 # Overlay grid-lines onto power spectrum plot. 

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

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

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

1622 

1623 # Save plot. 

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

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

1626 plt.close(fig) 

1627 

1628 

1629def _plot_and_save_postage_stamp_power_spectrum_series( 

1630 cube: iris.cube.Cube, 

1631 filename: str, 

1632 title: str, 

1633 stamp_coordinate: str, 

1634 **kwargs, 

1635): 

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

1637 

1638 Parameters 

1639 ---------- 

1640 cube: Cube 

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

1642 filename: str 

1643 Filename of the plot to write. 

1644 title: str 

1645 Plot title. 

1646 stamp_coordinate: str 

1647 Coordinate that becomes different plots. 

1648 """ 

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

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

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

1652 grid_size = math.ceil(nmember / grid_rows) 

1653 

1654 fig = plt.figure( 

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

1656 ) 

1657 

1658 # Make a subplot for each member. 

1659 for member, subplot in zip( 

1660 cube.slices_over(stamp_coordinate), 

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

1662 strict=False, 

1663 ): 

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

1665 # cartopy GeoAxes generated. 

1666 plt.subplot(grid_rows, grid_size, subplot) 

1667 

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

1669 

1670 axes = plt.gca() 

1671 axes.plot(frequency, member.data) 

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

1673 

1674 # Overall figure title. 

1675 fig.suptitle(title, fontsize=16) 

1676 

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

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

1679 plt.close(fig) 

1680 

1681 

1682def _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series( 

1683 cube: iris.cube.Cube, 

1684 filename: str, 

1685 title: str, 

1686 stamp_coordinate: str, 

1687 **kwargs, 

1688): 

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

1690 ax.set_title(title, fontsize=16) 

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

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

1693 # Loop over all slices along the stamp_coordinate 

1694 for member in cube.slices_over(stamp_coordinate): 

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

1696 ax.plot( 

1697 frequency, 

1698 member.data, 

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

1700 ) 

1701 

1702 # Add a legend 

1703 ax.legend(fontsize=16) 

1704 

1705 # Save the figure to a file 

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

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

1708 

1709 # Close the figure 

1710 plt.close(fig) 

1711 

1712 

1713def _spatial_plot( 

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

1715 cube: iris.cube.Cube, 

1716 filename: str | None, 

1717 sequence_coordinate: str, 

1718 stamp_coordinate: str, 

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

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

1721 **kwargs, 

1722): 

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

1724 

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

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

1727 is present then postage stamp plots will be produced. 

1728 

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

1730 be overplotted on the same figure. 

1731 

1732 Parameters 

1733 ---------- 

1734 method: "contourf" | "pcolormesh" 

1735 The plotting method to use. 

1736 cube: Cube 

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

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

1739 plotted sequentially and/or as postage stamp plots. 

1740 filename: str | None 

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

1742 uses the recipe name. 

1743 sequence_coordinate: str 

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

1745 This coordinate must exist in the cube. 

1746 stamp_coordinate: str 

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

1748 ``"realization"``. 

1749 overlay_cube: Cube | None, optional 

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

1751 contour_cube: Cube | None, optional 

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

1753 

1754 Raises 

1755 ------ 

1756 ValueError 

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

1758 TypeError 

1759 If the cube isn't a single cube. 

1760 """ 

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

1762 

1763 # Ensure we've got a single cube. 

1764 cube = check_single_cube(cube) 

1765 

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

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

1768 stamp_coordinate = check_stamp_coordinate(cube) 

1769 

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

1771 # single point. 

1772 plotting_func = _plot_and_save_spatial_plot 

1773 try: 

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

1775 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1776 except iris.exceptions.CoordinateNotFoundError: 

1777 pass 

1778 

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

1780 # dimension called observation or model_obs_error 

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

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

1783 for crd in cube.coords() 

1784 ): 

1785 plotting_func = _plot_and_save_scattermap_plot 

1786 

1787 # Must have a sequence coordinate. 

1788 try: 

1789 cube.coord(sequence_coordinate) 

1790 except iris.exceptions.CoordinateNotFoundError as err: 

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

1792 

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

1794 plot_index = [] 

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

1796 

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

1798 # Set plot titles and filename 

1799 seq_coord = cube_slice.coord(sequence_coordinate) 

1800 plot_title, plot_filename = _set_title_and_filename( 

1801 seq_coord, nplot, recipe_title, filename 

1802 ) 

1803 

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

1805 overlay_slice = slice_over_maybe(overlay_cube, sequence_coordinate, iseq) 

1806 contour_slice = slice_over_maybe(contour_cube, sequence_coordinate, iseq) 

1807 

1808 # Do the actual plotting. 

1809 plotting_func( 

1810 cube_slice, 

1811 filename=plot_filename, 

1812 stamp_coordinate=stamp_coordinate, 

1813 title=plot_title, 

1814 method=method, 

1815 overlay_cube=overlay_slice, 

1816 contour_cube=contour_slice, 

1817 **kwargs, 

1818 ) 

1819 plot_index.append(plot_filename) 

1820 

1821 # Add list of plots to plot metadata. 

1822 complete_plot_index = _append_to_plot_index(plot_index) 

1823 

1824 # Make a page to display the plots. 

1825 _make_plot_html_page(complete_plot_index) 

1826 

1827 

1828#################### 

1829# Public functions # 

1830#################### 

1831 

1832 

1833def spatial_contour_plot( 

1834 cube: iris.cube.Cube, 

1835 filename: str = None, 

1836 sequence_coordinate: str = "time", 

1837 stamp_coordinate: str = "realization", 

1838 **kwargs, 

1839) -> iris.cube.Cube: 

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

1841 

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

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

1844 is present then postage stamp plots will be produced. 

1845 

1846 Parameters 

1847 ---------- 

1848 cube: Cube 

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

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

1851 plotted sequentially and/or as postage stamp plots. 

1852 filename: str, optional 

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

1854 to the recipe name. 

1855 sequence_coordinate: str, optional 

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

1857 This coordinate must exist in the cube. 

1858 stamp_coordinate: str, optional 

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

1860 ``"realization"``. 

1861 

1862 Returns 

1863 ------- 

1864 Cube 

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

1866 

1867 Raises 

1868 ------ 

1869 ValueError 

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

1871 TypeError 

1872 If the cube isn't a single cube. 

1873 """ 

1874 _spatial_plot( 

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

1876 ) 

1877 return cube 

1878 

1879 

1880def spatial_pcolormesh_plot( 

1881 cube: iris.cube.Cube, 

1882 filename: str = None, 

1883 sequence_coordinate: str = "time", 

1884 stamp_coordinate: str = "realization", 

1885 **kwargs, 

1886) -> iris.cube.Cube: 

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

1888 

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

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

1891 is present then postage stamp plots will be produced. 

1892 

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

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

1895 contour areas are important. 

1896 

1897 Parameters 

1898 ---------- 

1899 cube: Cube 

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

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

1902 plotted sequentially and/or as postage stamp plots. 

1903 filename: str, optional 

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

1905 to the recipe name. 

1906 sequence_coordinate: str, optional 

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

1908 This coordinate must exist in the cube. 

1909 stamp_coordinate: str, optional 

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

1911 ``"realization"``. 

1912 

1913 Returns 

1914 ------- 

1915 Cube 

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

1917 

1918 Raises 

1919 ------ 

1920 ValueError 

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

1922 TypeError 

1923 If the cube isn't a single cube. 

1924 """ 

1925 _spatial_plot( 

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

1927 ) 

1928 return cube 

1929 

1930 

1931def spatial_multi_pcolormesh_plot( 

1932 cube: iris.cube.Cube, 

1933 overlay_cube: iris.cube.Cube, 

1934 contour_cube: iris.cube.Cube, 

1935 filename: str = None, 

1936 sequence_coordinate: str = "time", 

1937 stamp_coordinate: str = "realization", 

1938 **kwargs, 

1939) -> iris.cube.Cube: 

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

1941 

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

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

1944 is present then postage stamp plots will be produced. 

1945 

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

1947 

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

1949 

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

1951 

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

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

1954 contour areas are important. 

1955 

1956 Parameters 

1957 ---------- 

1958 cube: Cube 

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

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

1961 plotted sequentially and/or as postage stamp plots. 

1962 overlay_cube: Cube 

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

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

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

1966 contour_cube: Cube 

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

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

1969 plotted sequentially and/or as postage stamp plots. 

1970 filename: str, optional 

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

1972 to the recipe name. 

1973 sequence_coordinate: str, optional 

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

1975 This coordinate must exist in the cube. 

1976 stamp_coordinate: str, optional 

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

1978 ``"realization"``. 

1979 

1980 Returns 

1981 ------- 

1982 Cube 

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

1984 

1985 Raises 

1986 ------ 

1987 ValueError 

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

1989 TypeError 

1990 If the cube isn't a single cube. 

1991 """ 

1992 _spatial_plot( 

1993 "pcolormesh", 

1994 cube, 

1995 filename, 

1996 sequence_coordinate, 

1997 stamp_coordinate, 

1998 overlay_cube=overlay_cube, 

1999 contour_cube=contour_cube, 

2000 ) 

2001 return cube, overlay_cube, contour_cube 

2002 

2003 

2004# TODO: Expand function to handle ensemble data. 

2005# line_coordinate: str, optional 

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

2007# ``"realization"``. 

2008def plot_line_series( 

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

2010 filename: str = None, 

2011 series_coordinate: str = "time", 

2012 # line_coordinate: str = "realization", 

2013 **kwargs, 

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

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

2016 

2017 The Cube or CubeList must be 1D. 

2018 

2019 Parameters 

2020 ---------- 

2021 iris.cube | iris.cube.CubeList 

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

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

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

2025 filename: str, optional 

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

2027 to the recipe name. 

2028 series_coordinate: str, optional 

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

2030 coordinate must exist in the cube. 

2031 

2032 Returns 

2033 ------- 

2034 iris.cube.Cube | iris.cube.CubeList 

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

2036 plotted data. 

2037 

2038 Raises 

2039 ------ 

2040 ValueError 

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

2042 TypeError 

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

2044 """ 

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

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

2047 

2048 num_models = get_num_models(cube) 

2049 

2050 validate_cube_shape(cube, num_models) 

2051 

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

2053 cubes = iter_maybe(cube) 

2054 coords = [] 

2055 for cube in cubes: 

2056 try: 

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

2058 except iris.exceptions.CoordinateNotFoundError as err: 

2059 raise ValueError( 

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

2061 ) from err 

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

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

2064 

2065 # Format the title and filename using plotted series coordinate 

2066 nplot = 1 

2067 seq_coord = coords[0] 

2068 plot_title, plot_filename = _set_title_and_filename( 

2069 seq_coord, nplot, recipe_title, filename 

2070 ) 

2071 

2072 # Do the actual plotting. 

2073 _plot_and_save_line_series(cubes, coords, "realization", plot_filename, plot_title) 

2074 

2075 # Add list of plots to plot metadata. 

2076 plot_index = _append_to_plot_index([plot_filename]) 

2077 

2078 # Make a page to display the plots. 

2079 _make_plot_html_page(plot_index) 

2080 

2081 return cube 

2082 

2083 

2084def plot_vertical_line_series( 

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

2086 filename: str = None, 

2087 series_coordinate: str = "model_level_number", 

2088 sequence_coordinate: str = "time", 

2089 # line_coordinate: str = "realization", 

2090 **kwargs, 

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

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

2093 

2094 The Cube or CubeList must be 1D. 

2095 

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

2097 then a sequence of plots will be produced. 

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 to plot on the y-axis. Can be ``pressure`` or 

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

2111 for LFRic. Defaults to ``model_level_number``. 

2112 This coordinate must exist in the cube. 

2113 sequence_coordinate: str, optional 

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

2115 This coordinate must exist in the cube. 

2116 

2117 Returns 

2118 ------- 

2119 iris.cube.Cube | iris.cube.CubeList 

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

2121 Plotted data. 

2122 

2123 Raises 

2124 ------ 

2125 ValueError 

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

2127 TypeError 

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

2129 """ 

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

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

2132 

2133 cubes = iter_maybe(cubes) 

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

2135 all_data = [] 

2136 

2137 # Store min/max ranges for x range. 

2138 x_levels = [] 

2139 

2140 num_models = get_num_models(cubes) 

2141 

2142 validate_cube_shape(cubes, num_models) 

2143 

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

2145 coords = [] 

2146 for cube in cubes: 

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

2148 try: 

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

2150 except iris.exceptions.CoordinateNotFoundError as err: 

2151 raise ValueError( 

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

2153 ) from err 

2154 

2155 try: 

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

2157 cube.coord(sequence_coordinate) 

2158 except iris.exceptions.CoordinateNotFoundError as err: 

2159 raise ValueError( 

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

2161 ) from err 

2162 

2163 # Get minimum and maximum from levels information. 

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

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

2166 x_levels.append(min(levels)) 

2167 x_levels.append(max(levels)) 

2168 else: 

2169 all_data.append(cube.data) 

2170 

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

2172 # Combine all data into a single NumPy array 

2173 combined_data = np.concatenate(all_data) 

2174 

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

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

2177 # sequence and if applicable postage stamp coordinate. 

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

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

2180 else: 

2181 vmin = min(x_levels) 

2182 vmax = max(x_levels) 

2183 

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

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

2186 # necessary) 

2187 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

2188 

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

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

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

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

2193 # or an iris.cube.CubeList. 

2194 plot_index = [] 

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

2196 for cubes_slice in cube_iterables: 

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

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

2199 plot_title, plot_filename = _set_title_and_filename( 

2200 seq_coord, nplot, recipe_title, filename 

2201 ) 

2202 

2203 # Do the actual plotting. 

2204 _plot_and_save_vertical_line_series( 

2205 cubes_slice, 

2206 coords, 

2207 "realization", 

2208 plot_filename, 

2209 series_coordinate, 

2210 title=plot_title, 

2211 vmin=vmin, 

2212 vmax=vmax, 

2213 ) 

2214 plot_index.append(plot_filename) 

2215 

2216 # Add list of plots to plot metadata. 

2217 complete_plot_index = _append_to_plot_index(plot_index) 

2218 

2219 # Make a page to display the plots. 

2220 _make_plot_html_page(complete_plot_index) 

2221 

2222 return cubes 

2223 

2224 

2225def qq_plot( 

2226 cubes: iris.cube.CubeList, 

2227 coordinates: list[str], 

2228 percentiles: list[float], 

2229 model_names: list[str], 

2230 filename: str = None, 

2231 one_to_one: bool = True, 

2232 **kwargs, 

2233) -> iris.cube.CubeList: 

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

2235 

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

2237 collapsed within the operator over all specified coordinates such as 

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

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

2240 

2241 Parameters 

2242 ---------- 

2243 cubes: iris.cube.CubeList 

2244 Two cubes of the same variable with different models. 

2245 coordinate: list[str] 

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

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

2248 the percentile coordinate. 

2249 percent: list[float] 

2250 A list of percentiles to appear in the plot. 

2251 model_names: list[str] 

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

2253 filename: str, optional 

2254 Filename of the plot to write. 

2255 one_to_one: bool, optional 

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

2257 

2258 Raises 

2259 ------ 

2260 ValueError 

2261 When the cubes are not compatible. 

2262 

2263 Notes 

2264 ----- 

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

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

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

2268 compares percentiles of two datasets. This plot does 

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

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

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

2272 

2273 Quantile-quantile plots are valuable for comparing against 

2274 observations and other models. Identical percentiles between the variables 

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

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

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

2278 Wilks 2011 [Wilks2011]_). 

2279 

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

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

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

2283 the extremes. 

2284 

2285 References 

2286 ---------- 

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

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

2289 """ 

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

2291 if len(cubes) != 2: 

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

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

2294 other: Cube = cubes.extract_cube( 

2295 iris.Constraint( 

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

2297 ) 

2298 ) 

2299 

2300 # Get spatial coord names. 

2301 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

2302 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

2303 

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

2305 # This is triggered if either 

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

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

2308 # errors. 

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

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

2311 # for UM and LFRic comparisons. 

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

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

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

2315 # given this dependency on regridding. 

2316 if ( 

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

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

2319 ) or ( 

2320 base.long_name 

2321 in [ 

2322 "eastward_wind_at_10m", 

2323 "northward_wind_at_10m", 

2324 "northward_wind_at_cell_centres", 

2325 "eastward_wind_at_cell_centres", 

2326 "zonal_wind_at_pressure_levels", 

2327 "meridional_wind_at_pressure_levels", 

2328 "potential_vorticity_at_pressure_levels", 

2329 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

2330 ] 

2331 ): 

2332 logging.debug( 

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

2334 ) 

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

2336 

2337 # Extract just common time points. 

2338 base, other = _extract_common_time_points(base, other) 

2339 

2340 # Equalise attributes so we can merge. 

2341 fully_equalise_attributes([base, other]) 

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

2343 

2344 # Collapse cubes. 

2345 base = collapse( 

2346 base, 

2347 coordinate=coordinates, 

2348 method="PERCENTILE", 

2349 additional_percent=percentiles, 

2350 ) 

2351 other = collapse( 

2352 other, 

2353 coordinate=coordinates, 

2354 method="PERCENTILE", 

2355 additional_percent=percentiles, 

2356 ) 

2357 

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

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

2360 title = f"{recipe_title}" 

2361 

2362 if filename is None: 

2363 filename = slugify(recipe_title) 

2364 

2365 # Add file extension. 

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

2367 

2368 # Do the actual plotting on a scatter plot 

2369 _plot_and_save_scatter_plot( 

2370 base, other, plot_filename, title, one_to_one, model_names 

2371 ) 

2372 

2373 # Add list of plots to plot metadata. 

2374 plot_index = _append_to_plot_index([plot_filename]) 

2375 

2376 # Make a page to display the plots. 

2377 _make_plot_html_page(plot_index) 

2378 

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

2380 

2381 

2382def scatter_plot( 

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

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

2385 filename: str = None, 

2386 one_to_one: bool = True, 

2387 **kwargs, 

2388) -> iris.cube.CubeList: 

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

2390 

2391 Both cubes must be 1D. 

2392 

2393 Parameters 

2394 ---------- 

2395 cube_x: Cube | CubeList 

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

2397 cube_y: Cube | CubeList 

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

2399 filename: str, optional 

2400 Filename of the plot to write. 

2401 one_to_one: bool, optional 

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

2403 

2404 Returns 

2405 ------- 

2406 cubes: CubeList 

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

2408 

2409 Raises 

2410 ------ 

2411 ValueError 

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

2413 size. 

2414 TypeError 

2415 If the cube isn't a single cube. 

2416 

2417 Notes 

2418 ----- 

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

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

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

2422 """ 

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

2424 for cube_iter in iter_maybe(cube_x): 

2425 # Check cubes are correct shape. 

2426 cube_iter = check_single_cube(cube_iter) 

2427 if cube_iter.ndim > 1: 

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

2429 

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

2431 for cube_iter in iter_maybe(cube_y): 

2432 # Check cubes are correct shape. 

2433 cube_iter = check_single_cube(cube_iter) 

2434 if cube_iter.ndim > 1: 

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

2436 

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

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

2439 title = f"{recipe_title}" 

2440 

2441 if filename is None: 

2442 filename = slugify(recipe_title) 

2443 

2444 # Add file extension. 

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

2446 

2447 # Do the actual plotting. 

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

2449 

2450 # Add list of plots to plot metadata. 

2451 plot_index = _append_to_plot_index([plot_filename]) 

2452 

2453 # Make a page to display the plots. 

2454 _make_plot_html_page(plot_index) 

2455 

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

2457 

2458 

2459def vector_plot( 

2460 cube_u: iris.cube.Cube, 

2461 cube_v: iris.cube.Cube, 

2462 filename: str = None, 

2463 sequence_coordinate: str = "time", 

2464 **kwargs, 

2465) -> iris.cube.CubeList: 

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

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

2468 

2469 # Cubes must have a matching sequence coordinate. 

2470 try: 

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

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

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

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

2475 raise ValueError( 

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

2477 ) from err 

2478 

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

2480 plot_index = [] 

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

2482 for cube_u_slice, cube_v_slice in zip( 

2483 cube_u.slices_over(sequence_coordinate), 

2484 cube_v.slices_over(sequence_coordinate), 

2485 strict=True, 

2486 ): 

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

2488 seq_coord = cube_u_slice.coord(sequence_coordinate) 

2489 plot_title, plot_filename = _set_title_and_filename( 

2490 seq_coord, nplot, recipe_title, filename 

2491 ) 

2492 

2493 # Do the actual plotting. 

2494 _plot_and_save_vector_plot( 

2495 cube_u_slice, 

2496 cube_v_slice, 

2497 filename=plot_filename, 

2498 title=plot_title, 

2499 method="contourf", 

2500 ) 

2501 plot_index.append(plot_filename) 

2502 

2503 # Add list of plots to plot metadata. 

2504 complete_plot_index = _append_to_plot_index(plot_index) 

2505 

2506 # Make a page to display the plots. 

2507 _make_plot_html_page(complete_plot_index) 

2508 

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

2510 

2511 

2512def plot_histogram_series( 

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

2514 filename: str = None, 

2515 sequence_coordinate: str = "time", 

2516 stamp_coordinate: str = "realization", 

2517 single_plot: bool = False, 

2518 **kwargs, 

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

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

2521 

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

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

2524 functionality to scroll through histograms against time. If a 

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

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

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

2528 

2529 Parameters 

2530 ---------- 

2531 cubes: Cube | iris.cube.CubeList 

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

2533 than the stamp coordinate. 

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

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

2536 filename: str, optional 

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

2538 to the recipe name. 

2539 sequence_coordinate: str, optional 

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

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

2542 slider. 

2543 stamp_coordinate: str, optional 

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

2545 ``"realization"``. 

2546 single_plot: bool, optional 

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

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

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

2550 

2551 Returns 

2552 ------- 

2553 iris.cube.Cube | iris.cube.CubeList 

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

2555 Plotted data. 

2556 

2557 Raises 

2558 ------ 

2559 ValueError 

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

2561 TypeError 

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

2563 """ 

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

2565 

2566 cubes = iter_maybe(cubes) 

2567 

2568 # Internal plotting function. 

2569 plotting_func = _plot_and_save_histogram_series 

2570 

2571 num_models = get_num_models(cubes) 

2572 

2573 validate_cube_shape(cubes, num_models) 

2574 

2575 # If several histograms are plotted, check sequence_coordinate 

2576 check_sequence_coordinate(cubes, sequence_coordinate) 

2577 

2578 # Get axis minimum and maximum from levels information. 

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

2580 vmin, vmax = _set_axis_range(cubes) 

2581 

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

2583 # single point. If single_plot is True: 

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

2585 # separate postage stamp plots. 

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

2587 # produced per single model only 

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

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

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

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

2592 ): 

2593 if single_plot: 

2594 plotting_func = ( 

2595 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

2596 ) 

2597 else: 

2598 plotting_func = _plot_and_save_postage_stamp_histogram_series 

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

2600 else: 

2601 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

2602 

2603 plot_index = [] 

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

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

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

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

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

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

2610 for cube_slice in cube_iterables: 

2611 single_cube = cube_slice 

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

2613 single_cube = cube_slice[0] 

2614 

2615 # Ensure valid stamp coordinate in cube dimensions 

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

2617 stamp_coordinate = check_stamp_coordinate(single_cube) 

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

2619 seq_coord = single_cube.coord(sequence_coordinate) 

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

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

2622 seq_coord = single_cube.coord("time") 

2623 plot_title, plot_filename = _set_title_and_filename( 

2624 seq_coord, nplot, recipe_title, filename 

2625 ) 

2626 

2627 # Do the actual plotting. 

2628 plotting_func( 

2629 cube_slice, 

2630 filename=plot_filename, 

2631 stamp_coordinate=stamp_coordinate, 

2632 title=plot_title, 

2633 vmin=vmin, 

2634 vmax=vmax, 

2635 ) 

2636 plot_index.append(plot_filename) 

2637 

2638 # Add list of plots to plot metadata. 

2639 complete_plot_index = _append_to_plot_index(plot_index) 

2640 

2641 # Make a page to display the plots. 

2642 _make_plot_html_page(complete_plot_index) 

2643 

2644 return cubes 

2645 

2646 

2647def plot_power_spectrum_series( 

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

2649 filename: str = None, 

2650 sequence_coordinate: str = "time", 

2651 stamp_coordinate: str = "realization", 

2652 single_plot: bool = False, 

2653 **kwargs, 

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

2655 """Plot a power spectrum plot for each vertical level provided. 

2656 

2657 A power spectrum plot can be plotted, but if the sequence_coordinate (i.e. time) 

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

2659 functionality to scroll through power spectrum against time. If a 

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

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

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

2663 

2664 Parameters 

2665 ---------- 

2666 cubes: Cube | iris.cube.CubeList 

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

2668 than the stamp coordinate. 

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

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

2671 filename: str, optional 

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

2673 to the recipe name. 

2674 sequence_coordinate: str, optional 

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

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

2677 slider. 

2678 stamp_coordinate: str, optional 

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

2680 ``"realization"``. 

2681 single_plot: bool, optional 

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

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

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

2685 

2686 Returns 

2687 ------- 

2688 iris.cube.Cube | iris.cube.CubeList 

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

2690 Plotted data. 

2691 

2692 Raises 

2693 ------ 

2694 ValueError 

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

2696 TypeError 

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

2698 """ 

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

2700 

2701 cubes = iter_maybe(cubes) 

2702 

2703 # Internal plotting function. 

2704 plotting_func = _plot_and_save_power_spectrum_series 

2705 

2706 num_models = get_num_models(cubes) 

2707 

2708 validate_cube_shape(cubes, num_models) 

2709 

2710 # If several power spectra are plotted, check sequence_coordinate 

2711 check_sequence_coordinate(cubes, sequence_coordinate) 

2712 

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

2714 # single point. If single_plot is True: 

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

2716 # separate postage stamp plots. 

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

2718 # produced per single model only 

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

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

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

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

2723 ): 

2724 if single_plot: 

2725 plotting_func = ( 

2726 _plot_and_save_postage_stamps_in_single_plot_power_spectrum_series 

2727 ) 

2728 else: 

2729 plotting_func = _plot_and_save_postage_stamp_power_spectrum_series 

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

2731 else: 

2732 cube_iterables = _find_matched_slices(cubes, sequence_coordinate) 

2733 

2734 plot_index = [] 

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

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

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

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

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

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

2741 for cube_slice in cube_iterables: 

2742 single_cube = cube_slice 

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

2744 single_cube = cube_slice[0] 

2745 

2746 # Set stamp coordinate 

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

2748 stamp_coordinate = check_stamp_coordinate(single_cube) 

2749 # Set plot title and filenames based on sequence values 

2750 seq_coord = single_cube.coord(sequence_coordinate) 

2751 plot_title, plot_filename = _set_title_and_filename( 

2752 seq_coord, nplot, recipe_title, filename 

2753 ) 

2754 

2755 # Do the actual plotting. 

2756 plotting_func( 

2757 cube_slice, 

2758 filename=plot_filename, 

2759 stamp_coordinate=stamp_coordinate, 

2760 title=plot_title, 

2761 ) 

2762 plot_index.append(plot_filename) 

2763 

2764 # Add list of plots to plot metadata. 

2765 complete_plot_index = _append_to_plot_index(plot_index) 

2766 

2767 # Make a page to display the plots. 

2768 _make_plot_html_page(complete_plot_index) 

2769 

2770 return cubes