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

903 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-02 17:30 +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 functools 

19import importlib.resources 

20import itertools 

21import json 

22import logging 

23import math 

24import os 

25from typing import Literal 

26 

27import cartopy.crs as ccrs 

28import iris 

29import iris.coords 

30import iris.cube 

31import iris.exceptions 

32import iris.plot as iplt 

33import matplotlib as mpl 

34import matplotlib.colors as mcolors 

35import matplotlib.pyplot as plt 

36import numpy as np 

37from iris.cube import Cube 

38from markdown_it import MarkdownIt 

39 

40from CSET._common import ( 

41 combine_dicts, 

42 get_recipe_metadata, 

43 iter_maybe, 

44 render_file, 

45 slugify, 

46) 

47from CSET.operators._utils import ( 

48 fully_equalise_attributes, 

49 get_cube_yxcoordname, 

50 is_transect, 

51) 

52from CSET.operators.collapse import collapse 

53from CSET.operators.misc import _extract_common_time_points 

54from CSET.operators.regrid import regrid_onto_cube 

55 

56# Use a non-interactive plotting backend. 

57mpl.use("agg") 

58 

59DEFAULT_DISCRETE_COLORS = mpl.colormaps["tab10"].colors + mpl.colormaps["Accent"].colors 

60 

61############################ 

62# Private helper functions # 

63############################ 

64 

65 

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

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

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

69 fcntl.flock(fp, fcntl.LOCK_EX) 

70 fp.seek(0) 

71 meta = json.load(fp) 

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

73 complete_plot_index = complete_plot_index + plot_index 

74 meta["plots"] = complete_plot_index 

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

76 os.getenv("DO_CASE_AGGREGATION") 

77 ): 

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

79 fp.seek(0) 

80 fp.truncate() 

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

82 return complete_plot_index 

83 

84 

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

86 """Ensure a single cube is given. 

87 

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

89 otherwise an error is raised. 

90 

91 Parameters 

92 ---------- 

93 cube: Cube | CubeList 

94 The cube to check. 

95 

96 Returns 

97 ------- 

98 cube: Cube 

99 The checked cube. 

100 

101 Raises 

102 ------ 

103 TypeError 

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

105 """ 

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

107 return cube 

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

109 if len(cube) == 1: 

110 return cube[0] 

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

112 

113 

114def _make_plot_html_page(plots: list): 

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

116 # Debug check that plots actually contains some strings. 

117 assert isinstance(plots[0], str) 

118 

119 # Load HTML template file. 

120 operator_files = importlib.resources.files() 

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

122 

123 # Get some metadata. 

124 meta = get_recipe_metadata() 

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

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

127 

128 # Prepare template variables. 

129 variables = { 

130 "title": title, 

131 "description": description, 

132 "initial_plot": plots[0], 

133 "plots": plots, 

134 "title_slug": slugify(title), 

135 } 

136 

137 # Render template. 

138 html = render_file(template_file, **variables) 

139 

140 # Save completed HTML. 

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

142 fp.write(html) 

143 

144 

145@functools.cache 

146def _load_colorbar_map(user_colorbar_file: str = None) -> dict: 

147 """Load the colorbar definitions from a file. 

148 

149 This is a separate function to make it cacheable. 

150 """ 

151 colorbar_file = importlib.resources.files().joinpath("_colorbar_definition.json") 

152 with open(colorbar_file, "rt", encoding="UTF-8") as fp: 

153 colorbar = json.load(fp) 

154 

155 logging.debug("User colour bar file: %s", user_colorbar_file) 

156 override_colorbar = {} 

157 if user_colorbar_file: 

158 try: 

159 with open(user_colorbar_file, "rt", encoding="UTF-8") as fp: 

160 override_colorbar = json.load(fp) 

161 except FileNotFoundError: 

162 logging.warning("Colorbar file does not exist. Using default values.") 

163 

164 # Overwrite values with the user supplied colorbar definition. 

165 colorbar = combine_dicts(colorbar, override_colorbar) 

166 return colorbar 

167 

168 

169def _get_model_colors_map(cubes: iris.cube.CubeList | iris.cube.Cube) -> dict: 

170 """Get an appropriate colors for model lines in line plots. 

171 

172 For each model in the list of cubes colors either from user provided 

173 color definition file (so-called style file) or from default colors are mapped 

174 to model_name attribute. 

175 

176 Parameters 

177 ---------- 

178 cubes: CubeList or Cube 

179 Cubes with model_name attribute 

180 

181 Returns 

182 ------- 

183 model_colors_map: 

184 Dictionary mapping model_name attribute to colors 

185 """ 

186 user_colorbar_file = get_recipe_metadata().get("style_file_path", None) 

187 colorbar = _load_colorbar_map(user_colorbar_file) 

188 model_names = sorted( 

189 filter( 

190 lambda x: x is not None, 

191 (cube.attributes.get("model_name", None) for cube in iter_maybe(cubes)), 

192 ) 

193 ) 

194 if not model_names: 

195 return {} 

196 use_user_colors = all(mname in colorbar.keys() for mname in model_names) 

197 if use_user_colors: 197 ↛ 198line 197 didn't jump to line 198 because the condition on line 197 was never true

198 return {mname: colorbar[mname] for mname in model_names} 

199 

200 color_list = itertools.cycle(DEFAULT_DISCRETE_COLORS) 

201 return {mname: color for mname, color in zip(model_names, color_list, strict=False)} 

202 

203 

204def _colorbar_map_levels(cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None): 

205 """Get an appropriate colorbar for the given cube. 

206 

207 For the given variable the appropriate colorbar is looked up from a 

208 combination of the built-in CSET colorbar definitions, and any user supplied 

209 definitions. As well as varying on variables, these definitions may also 

210 exist for specific pressure levels to account for variables with 

211 significantly different ranges at different heights. The colorbars also exist 

212 for masks and mask differences for considering variable presence diagnostics. 

213 Specific variable ranges can be separately set in user-supplied definition 

214 for x- or y-axis limits, or indicate where automated range preferred. 

215 

216 Parameters 

217 ---------- 

218 cube: Cube 

219 Cube of variable for which the colorbar information is desired. 

220 axis: "x", "y", optional 

221 Select the levels for just this axis of a line plot. The min and max 

222 can be set by xmin/xmax or ymin/ymax respectively. For variables where 

223 setting a universal range is not desirable (e.g. temperature), users 

224 can set ymin/ymax values to "auto" in the colorbar definitions file. 

225 Where no additional xmin/xmax or ymin/ymax values are provided, the 

226 axis bounds default to use the vmin/vmax values provided. 

227 

228 Returns 

229 ------- 

230 cmap: 

231 Matplotlib colormap. 

232 levels: 

233 List of levels to use for plotting. For continuous plots the min and max 

234 should be taken as the range. 

235 norm: 

236 BoundaryNorm information. 

237 """ 

238 # Grab the colorbar file from the recipe global metadata. 

239 user_colorbar_file = get_recipe_metadata().get("style_file_path", None) 

240 colorbar = _load_colorbar_map(user_colorbar_file) 

241 cmap = None 

242 

243 try: 

244 # We assume that pressure is a scalar coordinate here. 

245 pressure_level_raw = cube.coord("pressure").points[0] 

246 # Ensure pressure_level is a string, as it is used as a JSON key. 

247 pressure_level = str(int(pressure_level_raw)) 

248 except iris.exceptions.CoordinateNotFoundError: 

249 pressure_level = None 

250 

251 # First try long name, then standard name, then var name. This order is used 

252 # as long name is the one we correct between models, so it most likely to be 

253 # consistent. 

254 varnames = list(filter(None, [cube.long_name, cube.standard_name, cube.var_name])) 

255 for varname in varnames: 

256 # Get the colormap for this variable. 

257 try: 

258 var_colorbar = colorbar[varname] 

259 cmap = plt.get_cmap(colorbar[varname]["cmap"], 51) 

260 varname_key = varname 

261 break 

262 except KeyError: 

263 logging.debug("Cube name %s has no colorbar definition.", varname) 

264 

265 # Get colormap if it is a mask. 

266 if any("mask_for_" in name for name in varnames): 

267 cmap, levels, norm = _custom_colormap_mask(cube, axis=axis) 

268 return cmap, levels, norm 

269 # If winds on Beaufort Scale use custom colorbar and levels 

270 if any("Beaufort_Scale" in name for name in varnames): 

271 cmap, levels, norm = _custom_beaufort_scale(cube, axis=axis) 

272 return cmap, levels, norm 

273 # If probability is plotted use custom colorbar and levels 

274 if any("probability_of_" in name for name in varnames): 

275 cmap, levels, norm = _custom_colormap_probability(cube, axis=axis) 

276 return cmap, levels, norm 

277 # If aviation colour state use custom colorbar and levels 

278 if any("aviation_colour_state" in name for name in varnames): 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true

279 cmap, levels, norm = _custom_colormap_aviation_colour_state(cube) 

280 return cmap, levels, norm 

281 

282 # If no valid colormap has been defined, use defaults and return. 

283 if not cmap: 

284 logging.warning("No colorbar definition exists for %s.", cube.name()) 

285 cmap, levels, norm = mpl.colormaps["viridis"], None, None 

286 return cmap, levels, norm 

287 

288 # Test if pressure-level specific settings are provided for cube. 

289 if pressure_level: 

290 try: 

291 var_colorbar = colorbar[varname_key]["pressure_levels"][pressure_level] 

292 except KeyError: 

293 logging.debug( 

294 "%s has no colorbar definition for pressure level %s.", 

295 varname, 

296 pressure_level, 

297 ) 

298 

299 # Check for availability of x-axis or y-axis user-specific overrides 

300 # for setting level bounds for line plot types and return just levels. 

301 # Line plots do not need a colormap, and just use the data range. 

302 if axis: 

303 if axis == "x": 

304 try: 

305 vmin, vmax = var_colorbar["xmin"], var_colorbar["xmax"] 

306 except KeyError: 

307 vmin, vmax = var_colorbar["min"], var_colorbar["max"] 

308 if axis == "y": 

309 try: 

310 vmin, vmax = var_colorbar["ymin"], var_colorbar["ymax"] 

311 except KeyError: 

312 vmin, vmax = var_colorbar["min"], var_colorbar["max"] 

313 # Check if user-specified auto-scaling for this variable 

314 if vmin == "auto" or vmax == "auto": 

315 levels = None 

316 else: 

317 levels = [vmin, vmax] 

318 return None, levels, None 

319 # Get and use the colorbar levels for this variable if spatial or histogram. 

320 else: 

321 try: 

322 levels = var_colorbar["levels"] 

323 # Use discrete bins when levels are specified, rather 

324 # than a smooth range. 

325 norm = mpl.colors.BoundaryNorm(levels, ncolors=cmap.N) 

326 logging.debug("Using levels for %s colorbar.", varname) 

327 logging.info("Using levels: %s", levels) 

328 except KeyError: 

329 # Get the range for this variable. 

330 vmin, vmax = var_colorbar["min"], var_colorbar["max"] 

331 logging.debug("Using min and max for %s colorbar.", varname) 

332 # Calculate levels from range. 

333 levels = np.linspace(vmin, vmax, 101) 

334 norm = None 

335 

336 # Overwrite cmap, levels and norm for specific variables that 

337 # require custom colorbar_map as these can not be defined in the 

338 # JSON file. 

339 cmap, levels, norm = _custom_colourmap_precipitation(cube, cmap, levels, norm) 

340 cmap, levels, norm = _custom_colourmap_visibility_in_air( 

341 cube, cmap, levels, norm 

342 ) 

343 cmap, levels, norm = _custom_colormap_celsius(cube, cmap, levels, norm) 

344 return cmap, levels, norm 

345 

346 

347def _setup_spatial_map( 

348 cube: iris.cube.Cube, 

349 figure, 

350 cmap, 

351 grid_size: int | None = None, 

352 subplot: int | None = None, 

353): 

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

355 

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

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

358 

359 Parameters 

360 ---------- 

361 cube: Cube 

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

363 figure: 

364 Matplotlib Figure object holding all plot elements. 

365 cmap: 

366 Matplotlib colormap. 

367 grid_size: int, optional 

368 Size of grid for subplots if multiple spatial subplots in figure. 

369 subplot: int, optional 

370 Subplot index if multiple spatial subplots in figure. 

371 

372 Returns 

373 ------- 

374 axes: 

375 Matplotlib GeoAxes definition. 

376 """ 

377 # Identify min/max plot bounds. 

378 try: 

379 lat_axis, lon_axis = get_cube_yxcoordname(cube) 

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

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

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

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

384 

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

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

387 x1 = x1 - 180.0 

388 x2 = x2 - 180.0 

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

390 

391 # Consider map projection orientation. 

392 # Adapting orientation enables plotting across international dateline. 

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

394 if x2 > 180.0 or x1 < -180.0: 

395 central_longitude = 180.0 

396 else: 

397 central_longitude = 0.0 

398 

399 # Define spatial map projection. 

400 coord_system = cube.coord(lat_axis).coord_system 

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

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

403 projection = ccrs.RotatedPole( 

404 pole_longitude=coord_system.grid_north_pole_longitude, 

405 pole_latitude=coord_system.grid_north_pole_latitude, 

406 central_rotated_longitude=central_longitude, 

407 ) 

408 crs = projection 

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

410 # Define Transverse Mercator projection for TM inputs. 

411 projection = ccrs.TransverseMercator( 

412 central_longitude=coord_system.longitude_of_central_meridian, 

413 central_latitude=coord_system.latitude_of_projection_origin, 

414 false_easting=coord_system.false_easting, 

415 false_northing=coord_system.false_northing, 

416 scale_factor=coord_system.scale_factor_at_central_meridian, 

417 ) 

418 crs = projection 

419 else: 

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

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

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

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

424 projection = ccrs.PlateCarree(central_longitude=central_longitude) 

425 crs = ccrs.PlateCarree() 

426 

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

428 if subplot is not None: 

429 axes = figure.add_subplot( 

430 grid_size, grid_size, subplot, projection=projection 

431 ) 

432 else: 

433 axes = figure.add_subplot(projection=projection) 

434 

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

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

437 coastcol = "magenta" 

438 else: 

439 coastcol = "black" 

440 logging.debug("Plotting coastlines in colour %s.", coastcol) 

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

442 

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

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

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

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

447 

448 except ValueError: 

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

450 axes = figure.gca() 

451 pass 

452 

453 return axes 

454 

455 

456def _get_plot_resolution() -> int: 

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

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

459 

460 

461def _plot_and_save_spatial_plot( 

462 cube: iris.cube.Cube, 

463 filename: str, 

464 title: str, 

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

466 **kwargs, 

467): 

468 """Plot and save a spatial plot. 

469 

470 Parameters 

471 ---------- 

472 cube: Cube 

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

474 filename: str 

475 Filename of the plot to write. 

476 title: str 

477 Plot title. 

478 method: "contourf" | "pcolormesh" 

479 The plotting method to use. 

480 """ 

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

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

483 

484 # Specify the color bar 

485 cmap, levels, norm = _colorbar_map_levels(cube) 

486 

487 # Setup plot map projection, extent and coastlines. 

488 axes = _setup_spatial_map(cube, fig, cmap) 

489 

490 # Plot the field. 

491 if method == "contourf": 

492 # Filled contour plot of the field. 

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

494 elif method == "pcolormesh": 

495 try: 

496 vmin = min(levels) 

497 vmax = max(levels) 

498 except TypeError: 

499 vmin, vmax = None, None 

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

501 # if levels are defined. 

502 if norm is not None: 

503 vmin = None 

504 vmax = None 

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

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

507 else: 

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

509 

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

511 if is_transect(cube): 

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

513 axes.invert_yaxis() 

514 axes.set_yscale("log") 

515 axes.set_ylim(1100, 100) 

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

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

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

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

520 ): 

521 axes.set_yscale("log") 

522 

523 axes.set_title( 

524 f"{title}\n" 

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

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

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

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

529 fontsize=16, 

530 ) 

531 

532 else: 

533 # Add title. 

534 axes.set_title(title, fontsize=16) 

535 

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

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

538 axes.annotate( 

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

540 xy=(1, -0.05), 

541 xycoords="axes fraction", 

542 xytext=(-5, 5), 

543 textcoords="offset points", 

544 ha="right", 

545 va="bottom", 

546 size=11, 

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

548 ) 

549 

550 # Add colour bar. 

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

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

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

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

555 cbar.set_ticks(levels) 

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

557 if "visibility" in cube.name(): 557 ↛ 558line 557 didn't jump to line 558 because the condition on line 557 was never true

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

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

560 

561 # Save plot. 

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

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

564 plt.close(fig) 

565 

566 

567def _plot_and_save_postage_stamp_spatial_plot( 

568 cube: iris.cube.Cube, 

569 filename: str, 

570 stamp_coordinate: str, 

571 title: str, 

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

573 **kwargs, 

574): 

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

576 

577 Parameters 

578 ---------- 

579 cube: Cube 

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

581 filename: str 

582 Filename of the plot to write. 

583 stamp_coordinate: str 

584 Coordinate that becomes different plots. 

585 method: "contourf" | "pcolormesh" 

586 The plotting method to use. 

587 

588 Raises 

589 ------ 

590 ValueError 

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

592 """ 

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

594 grid_size = int(math.ceil(math.sqrt(len(cube.coord(stamp_coordinate).points)))) 

595 

596 fig = plt.figure(figsize=(10, 10)) 

597 

598 # Specify the color bar 

599 cmap, levels, norm = _colorbar_map_levels(cube) 

600 

601 # Make a subplot for each member. 

602 for member, subplot in zip( 

603 cube.slices_over(stamp_coordinate), range(1, grid_size**2 + 1), strict=False 

604 ): 

605 # Setup subplot map projection, extent and coastlines. 

606 axes = _setup_spatial_map( 

607 member, fig, cmap, grid_size=grid_size, subplot=subplot 

608 ) 

609 if method == "contourf": 

610 # Filled contour plot of the field. 

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

612 elif method == "pcolormesh": 

613 if levels is not None: 

614 vmin = min(levels) 

615 vmax = max(levels) 

616 else: 

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

618 vmin, vmax = None, None 

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

620 # if levels are defined. 

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

622 vmin = None 

623 vmax = None 

624 # pcolormesh plot of the field. 

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

626 else: 

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

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

629 axes.set_axis_off() 

630 

631 # Put the shared colorbar in its own axes. 

632 colorbar_axes = fig.add_axes([0.15, 0.07, 0.7, 0.03]) 

633 colorbar = fig.colorbar( 

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

635 ) 

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

637 

638 # Overall figure title. 

639 fig.suptitle(title, fontsize=16) 

640 

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

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

643 plt.close(fig) 

644 

645 

646def _plot_and_save_line_series( 

647 cubes: iris.cube.CubeList, 

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

649 ensemble_coord: str, 

650 filename: str, 

651 title: str, 

652 **kwargs, 

653): 

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

655 

656 Parameters 

657 ---------- 

658 cubes: Cube or CubeList 

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

660 coords: list[Coord] 

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

662 ensemble_coord: str 

663 Ensemble coordinate in the cube. 

664 filename: str 

665 Filename of the plot to write. 

666 title: str 

667 Plot title. 

668 """ 

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

670 

671 model_colors_map = _get_model_colors_map(cubes) 

672 

673 # Store min/max ranges. 

674 y_levels = [] 

675 

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

677 _validate_cubes_coords(cubes, coords) 

678 

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

680 label = None 

681 color = "black" 

682 if model_colors_map: 

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

684 color = model_colors_map.get(label) 

685 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

688 iplt.plot( 

689 coord, 

690 cube_slice, 

691 color=color, 

692 marker="o", 

693 ls="-", 

694 lw=3, 

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

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

697 else label, 

698 ) 

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

700 else: 

701 iplt.plot( 

702 coord, 

703 cube_slice, 

704 color=color, 

705 ls="-", 

706 lw=1.5, 

707 alpha=0.75, 

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

709 ) 

710 

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

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

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

714 y_levels.append(min(levels)) 

715 y_levels.append(max(levels)) 

716 

717 # Get the current axes. 

718 ax = plt.gca() 

719 

720 # Add some labels and tweak the style. 

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

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

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

724 ax.set_title(title, fontsize=16) 

725 

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

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

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

729 

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

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

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

733 # Add zero line. 

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

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

736 logging.debug( 

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

738 ) 

739 else: 

740 ax.autoscale() 

741 

742 # Add gridlines 

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

744 # Ientify unique labels for legend 

745 handles = list( 

746 { 

747 label: handle 

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

749 }.values() 

750 ) 

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

752 

753 # Save plot. 

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

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

756 plt.close(fig) 

757 

758 

759def _plot_and_save_line_1D( 

760 cubes: iris.cube.CubeList, 

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

762 ensemble_coord: str, 

763 filename: str, 

764 title: str, 

765 **kwargs, 

766): 

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

768 

769 Parameters 

770 ---------- 

771 cubes: Cube or CubeList 

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

773 coords: list[Coord] 

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

775 ensemble_coord: str 

776 Ensemble coordinate in the cube. 

777 filename: str 

778 Filename of the plot to write. 

779 title: str 

780 Plot title. 

781 """ 

782 xn = coords[0].name() # x-axis (e.g. frequency) 

783 

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

785 model_colors_map = _get_model_colors_map(cubes) 

786 ax = plt.gca() 

787 

788 # Store min/max ranges. 

789 y_levels = [] 

790 

791 if "Spectrum" in title: 791 ↛ 792line 791 didn't jump to line 792 because the condition on line 791 was never true

792 line_marker = None 

793 line_width = 1 

794 else: 

795 line_marker = "o" 

796 line_width = 3 

797 

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

799 for cube in iter_maybe(cubes): 

800 xname = cube.coord(xn).points # frequency 

801 yfield = cube.data # power spectrum 

802 label = None 

803 color = "black" 

804 if model_colors_map: 

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

806 color = model_colors_map.get(label) 

807 for cube_slice in cube.slices_over(ensemble_coord): 

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

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

810 ax.plot( 

811 xname, 

812 yfield, 

813 color=color, 

814 marker=line_marker, 

815 ls="-", 

816 lw=line_width, 

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

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

819 else label, 

820 ) 

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

822 else: 

823 ax.plot( 

824 xname, 

825 yfield, 

826 color=color, 

827 ls="-", 

828 lw=1.5, 

829 alpha=0.75, 

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

831 ) 

832 

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

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

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

836 y_levels.append(min(levels)) 

837 y_levels.append(max(levels)) 

838 

839 # Add some labels and tweak the style. 

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

841 if "Spectrum" in title: 841 ↛ 843line 841 didn't jump to line 843 because the condition on line 841 was never true

842 # title = f"{title}\n [{coords.units.title(coords.points[0])}]" 

843 title = f"{title}" 

844 ax.set_title(title, fontsize=16) 

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

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

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

848 else: 

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

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

851 ax.set_title(title, fontsize=16) 

852 

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

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

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

856 

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

858 if "Spectrum" in title: 858 ↛ 860line 858 didn't jump to line 860 because the condition on line 858 was never true

859 # Set log-log scale 

860 ax.set_xscale("log") 

861 ax.set_yscale("log") 

862 

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

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

865 # Add zero line. 

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

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

868 logging.debug( 

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

870 ) 

871 else: 

872 ax.autoscale() 

873 

874 # Add gridlines 

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

876 # Ientify unique labels for legend 

877 handles = list( 

878 { 

879 label: handle 

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

881 }.values() 

882 ) 

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

884 

885 # Save plot. 

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

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

888 plt.close(fig) 

889 

890 

891def _plot_and_save_vertical_line_series( 

892 cubes: iris.cube.CubeList, 

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

894 ensemble_coord: str, 

895 filename: str, 

896 series_coordinate: str, 

897 title: str, 

898 vmin: float, 

899 vmax: float, 

900 **kwargs, 

901): 

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

903 

904 Parameters 

905 ---------- 

906 cubes: CubeList 

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

908 coord: list[Coord] 

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

910 ensemble_coord: str 

911 Ensemble coordinate in the cube. 

912 filename: str 

913 Filename of the plot to write. 

914 series_coordinate: str 

915 Coordinate to use as vertical axis. 

916 title: str 

917 Plot title. 

918 vmin: float 

919 Minimum value for the x-axis. 

920 vmax: float 

921 Maximum value for the x-axis. 

922 """ 

923 # plot the vertical pressure axis using log scale 

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

925 

926 model_colors_map = _get_model_colors_map(cubes) 

927 

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

929 _validate_cubes_coords(cubes, coords) 

930 

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

932 label = None 

933 color = "black" 

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

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

936 color = model_colors_map.get(label) 

937 

938 for cube_slice in cube.slices_over(ensemble_coord): 

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

940 # unless single forecast. 

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

942 iplt.plot( 

943 cube_slice, 

944 coord, 

945 color=color, 

946 marker="o", 

947 ls="-", 

948 lw=3, 

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

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

951 else label, 

952 ) 

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

954 else: 

955 iplt.plot( 

956 cube_slice, 

957 coord, 

958 color=color, 

959 ls="-", 

960 lw=1.5, 

961 alpha=0.75, 

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

963 ) 

964 

965 # Get the current axis 

966 ax = plt.gca() 

967 

968 # Special handling for pressure level data. 

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

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

971 ax.invert_yaxis() 

972 ax.set_yscale("log") 

973 

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

975 y_tick_labels = [ 

976 "1000", 

977 "850", 

978 "700", 

979 "500", 

980 "300", 

981 "200", 

982 "100", 

983 ] 

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

985 

986 # Set y-axis limits and ticks. 

987 ax.set_ylim(1100, 100) 

988 

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

990 # model_level_number and lfric uses full_levels as coordinate. 

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

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

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

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

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

996 

997 ax.set_yticks(y_ticks) 

998 ax.set_yticklabels(y_tick_labels) 

999 

1000 # Set x-axis limits. 

1001 ax.set_xlim(vmin, vmax) 

1002 # Mark y=0 if present in plot. 

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

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

1005 

1006 # Add some labels and tweak the style. 

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

1008 ax.set_xlabel( 

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

1010 ) 

1011 ax.set_title(title, fontsize=16) 

1012 ax.ticklabel_format(axis="x") 

1013 ax.tick_params(axis="y") 

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

1015 

1016 # Add gridlines 

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

1018 # Ientify unique labels for legend 

1019 handles = list( 

1020 { 

1021 label: handle 

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

1023 }.values() 

1024 ) 

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

1026 

1027 # Save plot. 

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

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

1030 plt.close(fig) 

1031 

1032 

1033def _plot_and_save_scatter_plot( 

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

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

1036 filename: str, 

1037 title: str, 

1038 one_to_one: bool, 

1039 model_names: list[str] = None, 

1040 **kwargs, 

1041): 

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

1043 

1044 Parameters 

1045 ---------- 

1046 cube_x: Cube | CubeList 

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

1048 cube_y: Cube | CubeList 

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

1050 filename: str 

1051 Filename of the plot to write. 

1052 title: str 

1053 Plot title. 

1054 one_to_one: bool 

1055 Whether a 1:1 line is plotted. 

1056 """ 

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

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

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

1060 # over the pairs simultaneously. 

1061 

1062 # Ensure cube_x and cube_y are iterable 

1063 cube_x_iterable = iter_maybe(cube_x) 

1064 cube_y_iterable = iter_maybe(cube_y) 

1065 

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

1067 iplt.scatter(cube_x_iter, cube_y_iter) 

1068 if one_to_one is True: 

1069 plt.plot( 

1070 [ 

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

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

1073 ], 

1074 [ 

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

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

1077 ], 

1078 "k", 

1079 linestyle="--", 

1080 ) 

1081 ax = plt.gca() 

1082 

1083 # Add some labels and tweak the style. 

1084 if model_names is None: 

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

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

1087 else: 

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

1089 ax.set_xlabel( 

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

1091 ) 

1092 ax.set_ylabel( 

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

1094 ) 

1095 ax.set_title(title, fontsize=16) 

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

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

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

1099 ax.autoscale() 

1100 

1101 # Save plot. 

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

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

1104 plt.close(fig) 

1105 

1106 

1107def _plot_and_save_vector_plot( 

1108 cube_u: iris.cube.Cube, 

1109 cube_v: iris.cube.Cube, 

1110 filename: str, 

1111 title: str, 

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

1113 **kwargs, 

1114): 

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

1116 

1117 Parameters 

1118 ---------- 

1119 cube_u: Cube 

1120 2 dimensional Cube of u component of the data. 

1121 cube_v: Cube 

1122 2 dimensional Cube of v component of the data. 

1123 filename: str 

1124 Filename of the plot to write. 

1125 title: str 

1126 Plot title. 

1127 """ 

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

1129 

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

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

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

1133 

1134 # Specify the color bar 

1135 cmap, levels, norm = _colorbar_map_levels(cube_vec_mag) 

1136 

1137 # Setup plot map projection, extent and coastlines. 

1138 axes = _setup_spatial_map(cube_vec_mag, fig, cmap) 

1139 

1140 if method == "contourf": 1140 ↛ 1143line 1140 didn't jump to line 1143 because the condition on line 1140 was always true

1141 # Filled contour plot of the field. 

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

1143 elif method == "pcolormesh": 

1144 try: 

1145 vmin = min(levels) 

1146 vmax = max(levels) 

1147 except TypeError: 

1148 vmin, vmax = None, None 

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

1150 # if levels are defined. 

1151 if norm is not None: 

1152 vmin = None 

1153 vmax = None 

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

1155 else: 

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

1157 

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

1159 if is_transect(cube_vec_mag): 1159 ↛ 1160line 1159 didn't jump to line 1160 because the condition on line 1159 was never true

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

1161 axes.invert_yaxis() 

1162 axes.set_yscale("log") 

1163 axes.set_ylim(1100, 100) 

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

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

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

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

1168 ): 

1169 axes.set_yscale("log") 

1170 

1171 axes.set_title( 

1172 f"{title}\n" 

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

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

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

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

1177 fontsize=16, 

1178 ) 

1179 

1180 else: 

1181 # Add title. 

1182 axes.set_title(title, fontsize=16) 

1183 

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

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

1186 axes.annotate( 

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

1188 xy=(1, -0.05), 

1189 xycoords="axes fraction", 

1190 xytext=(-5, 5), 

1191 textcoords="offset points", 

1192 ha="right", 

1193 va="bottom", 

1194 size=11, 

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

1196 ) 

1197 

1198 # Add colour bar. 

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

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

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

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

1203 cbar.set_ticks(levels) 

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

1205 

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

1207 # with less than 30 points. 

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

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

1210 

1211 # Save plot. 

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

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

1214 plt.close(fig) 

1215 

1216 

1217def _plot_and_save_histogram_series( 

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

1219 filename: str, 

1220 title: str, 

1221 vmin: float, 

1222 vmax: float, 

1223 **kwargs, 

1224): 

1225 """Plot and save a histogram series. 

1226 

1227 Parameters 

1228 ---------- 

1229 cubes: Cube or CubeList 

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

1231 filename: str 

1232 Filename of the plot to write. 

1233 title: str 

1234 Plot title. 

1235 vmin: float 

1236 minimum for colorbar 

1237 vmax: float 

1238 maximum for colorbar 

1239 """ 

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

1241 ax = plt.gca() 

1242 

1243 model_colors_map = _get_model_colors_map(cubes) 

1244 

1245 # Set default that histograms will produce probability density function 

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

1247 density = True 

1248 

1249 for cube in iter_maybe(cubes): 

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

1251 # than seeing if long names exist etc. 

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

1253 if "surface_microphysical" in title: 

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

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

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

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

1258 density = False 

1259 else: 

1260 bins = 10.0 ** ( 

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

1262 ) # Suggestion from RMED toolbox. 

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

1264 ax.set_yscale("log") 

1265 vmin = bins[1] 

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

1267 ax.set_xscale("log") 

1268 elif "lightning" in title: 

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

1270 else: 

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

1272 logging.debug( 

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

1274 np.size(bins), 

1275 np.min(bins), 

1276 np.max(bins), 

1277 ) 

1278 

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

1280 # Otherwise we plot xdim histograms stacked. 

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

1282 

1283 label = None 

1284 color = "black" 

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

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

1287 color = model_colors_map[label] 

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

1289 

1290 # Compute area under curve. 

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

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

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

1294 x = x[1:] 

1295 y = y[1:] 

1296 

1297 ax.plot( 

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

1299 ) 

1300 

1301 # Add some labels and tweak the style. 

1302 ax.set_title(title, fontsize=16) 

1303 ax.set_xlabel( 

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

1305 ) 

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

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

1308 ax.set_ylabel( 

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

1310 ) 

1311 ax.set_xlim(vmin, vmax) 

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

1313 

1314 # Overlay grid-lines onto histogram plot. 

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

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

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

1318 

1319 # Save plot. 

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

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

1322 plt.close(fig) 

1323 

1324 

1325def _plot_and_save_postage_stamp_histogram_series( 

1326 cube: iris.cube.Cube, 

1327 filename: str, 

1328 title: str, 

1329 stamp_coordinate: str, 

1330 vmin: float, 

1331 vmax: float, 

1332 **kwargs, 

1333): 

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

1335 

1336 Parameters 

1337 ---------- 

1338 cube: Cube 

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

1340 filename: str 

1341 Filename of the plot to write. 

1342 title: str 

1343 Plot title. 

1344 stamp_coordinate: str 

1345 Coordinate that becomes different plots. 

1346 vmin: float 

1347 minimum for pdf x-axis 

1348 vmax: float 

1349 maximum for pdf x-axis 

1350 """ 

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

1352 grid_size = int(math.ceil(math.sqrt(len(cube.coord(stamp_coordinate).points)))) 

1353 

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

1355 # Make a subplot for each member. 

1356 for member, subplot in zip( 

1357 cube.slices_over(stamp_coordinate), range(1, grid_size**2 + 1), strict=False 

1358 ): 

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

1360 # cartopy GeoAxes generated. 

1361 plt.subplot(grid_size, grid_size, subplot) 

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

1363 # Otherwise we plot xdim histograms stacked. 

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

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

1366 ax = plt.gca() 

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

1368 ax.set_xlim(vmin, vmax) 

1369 

1370 # Overall figure title. 

1371 fig.suptitle(title, fontsize=16) 

1372 

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

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

1375 plt.close(fig) 

1376 

1377 

1378def _plot_and_save_postage_stamps_in_single_plot_histogram_series( 

1379 cube: iris.cube.Cube, 

1380 filename: str, 

1381 title: str, 

1382 stamp_coordinate: str, 

1383 vmin: float, 

1384 vmax: float, 

1385 **kwargs, 

1386): 

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

1388 ax.set_title(title, fontsize=16) 

1389 ax.set_xlim(vmin, vmax) 

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

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

1392 # Loop over all slices along the stamp_coordinate 

1393 for member in cube.slices_over(stamp_coordinate): 

1394 # Flatten the member data to 1D 

1395 member_data_1d = member.data.flatten() 

1396 # Plot the histogram using plt.hist 

1397 plt.hist( 

1398 member_data_1d, 

1399 density=True, 

1400 stacked=True, 

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

1402 ) 

1403 

1404 # Add a legend 

1405 ax.legend(fontsize=16) 

1406 

1407 # Save the figure to a file 

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

1409 

1410 # Close the figure 

1411 plt.close(fig) 

1412 

1413 

1414def _plot_and_save_scattermap_plot( 

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

1416): 

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

1418 

1419 Parameters 

1420 ---------- 

1421 cube: Cube 

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

1423 longitude coordinates, 

1424 filename: str 

1425 Filename of the plot to write. 

1426 title: str 

1427 Plot title. 

1428 projection: str 

1429 Mapping projection to be used by cartopy. 

1430 """ 

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

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

1433 if projection is not None: 

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

1435 # a stereographic projection over the North Pole. 

1436 if projection == "NP_Stereo": 

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

1438 else: 

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

1440 else: 

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

1442 

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

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

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

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

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

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

1449 # proportion to the area of the figure. 

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

1451 klon = None 

1452 klat = None 

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

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

1455 klat = kc 

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

1457 klon = kc 

1458 scatter_map = iplt.scatter( 

1459 cube.aux_coords[klon], 

1460 cube.aux_coords[klat], 

1461 c=cube.data[:], 

1462 s=mrk_size, 

1463 cmap="jet", 

1464 edgecolors="k", 

1465 ) 

1466 

1467 # Add coastlines. 

1468 try: 

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

1470 except AttributeError: 

1471 pass 

1472 

1473 # Add title. 

1474 axes.set_title(title, fontsize=16) 

1475 

1476 # Add colour bar. 

1477 cbar = fig.colorbar(scatter_map) 

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

1479 

1480 # Save plot. 

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

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

1483 plt.close(fig) 

1484 

1485 

1486def _spatial_plot( 

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

1488 cube: iris.cube.Cube, 

1489 filename: str | None, 

1490 sequence_coordinate: str, 

1491 stamp_coordinate: str, 

1492 **kwargs, 

1493): 

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

1495 

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

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

1498 is present then postage stamp plots will be produced. 

1499 

1500 Parameters 

1501 ---------- 

1502 method: "contourf" | "pcolormesh" 

1503 The plotting method to use. 

1504 cube: Cube 

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

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

1507 plotted sequentially and/or as postage stamp plots. 

1508 filename: str | None 

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

1510 uses the recipe name. 

1511 sequence_coordinate: str 

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

1513 This coordinate must exist in the cube. 

1514 stamp_coordinate: str 

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

1516 ``"realization"``. 

1517 

1518 Raises 

1519 ------ 

1520 ValueError 

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

1522 TypeError 

1523 If the cube isn't a single cube. 

1524 """ 

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

1526 

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

1528 if filename is None: 

1529 filename = slugify(recipe_title) 

1530 

1531 # Ensure we've got a single cube. 

1532 cube = _check_single_cube(cube) 

1533 

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

1535 # single point. 

1536 plotting_func = _plot_and_save_spatial_plot 

1537 try: 

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

1539 plotting_func = _plot_and_save_postage_stamp_spatial_plot 

1540 except iris.exceptions.CoordinateNotFoundError: 

1541 pass 

1542 

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

1544 # dimension called observation or model_obs_error 

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

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

1547 for crd in cube.coords() 

1548 ): 

1549 plotting_func = _plot_and_save_scattermap_plot 

1550 

1551 # Must have a sequence coordinate. 

1552 try: 

1553 cube.coord(sequence_coordinate) 

1554 except iris.exceptions.CoordinateNotFoundError as err: 

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

1556 

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

1558 plot_index = [] 

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

1560 for cube_slice in cube.slices_over(sequence_coordinate): 

1561 # Use sequence value so multiple sequences can merge. 

1562 sequence_value = cube_slice.coord(sequence_coordinate).points[0] 

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

1564 coord = cube_slice.coord(sequence_coordinate) 

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

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

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

1568 if nplot == 1 and coord.has_bounds: 

1569 if np.size(coord.bounds) > 1: 

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

1571 # Do the actual plotting. 

1572 plotting_func( 

1573 cube_slice, 

1574 filename=plot_filename, 

1575 stamp_coordinate=stamp_coordinate, 

1576 title=title, 

1577 method=method, 

1578 **kwargs, 

1579 ) 

1580 plot_index.append(plot_filename) 

1581 

1582 # Add list of plots to plot metadata. 

1583 complete_plot_index = _append_to_plot_index(plot_index) 

1584 

1585 # Make a page to display the plots. 

1586 _make_plot_html_page(complete_plot_index) 

1587 

1588 

1589def _custom_colormap_mask(cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None): 

1590 """Get colourmap for mask. 

1591 

1592 If "mask_for_" appears anywhere in the name of a cube this function will be called 

1593 regardless of the name of the variable to ensure a consistent plot. 

1594 

1595 Parameters 

1596 ---------- 

1597 cube: Cube 

1598 Cube of variable for which the colorbar information is desired. 

1599 axis: "x", "y", optional 

1600 Select the levels for just this axis of a line plot. The min and max 

1601 can be set by xmin/xmax or ymin/ymax respectively. For variables where 

1602 setting a universal range is not desirable (e.g. temperature), users 

1603 can set ymin/ymax values to "auto" in the colorbar definitions file. 

1604 Where no additional xmin/xmax or ymin/ymax values are provided, the 

1605 axis bounds default to use the vmin/vmax values provided. 

1606 

1607 Returns 

1608 ------- 

1609 cmap: 

1610 Matplotlib colormap. 

1611 levels: 

1612 List of levels to use for plotting. For continuous plots the min and max 

1613 should be taken as the range. 

1614 norm: 

1615 BoundaryNorm information. 

1616 """ 

1617 if "difference" not in cube.long_name: 

1618 if axis: 

1619 levels = [0, 1] 

1620 # Complete settings based on levels. 

1621 return None, levels, None 

1622 else: 

1623 # Define the levels and colors. 

1624 levels = [0, 1, 2] 

1625 colors = ["white", "dodgerblue"] 

1626 # Create a custom color map. 

1627 cmap = mcolors.ListedColormap(colors) 

1628 # Normalize the levels. 

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

1630 logging.debug("Colourmap for %s.", cube.long_name) 

1631 return cmap, levels, norm 

1632 else: 

1633 if axis: 

1634 levels = [-1, 1] 

1635 return None, levels, None 

1636 else: 

1637 # Search for if mask difference, set to +/- 0.5 as values plotted < 

1638 # not <=. 

1639 levels = [-2, -0.5, 0.5, 2] 

1640 colors = ["goldenrod", "white", "teal"] 

1641 cmap = mcolors.ListedColormap(colors) 

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

1643 logging.debug("Colourmap for %s.", cube.long_name) 

1644 return cmap, levels, norm 

1645 

1646 

1647def _custom_beaufort_scale(cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None): 

1648 """Get a custom colorbar for a cube in the Beaufort Scale. 

1649 

1650 Specific variable ranges can be separately set in user-supplied definition 

1651 for x- or y-axis limits, or indicate where automated range preferred. 

1652 

1653 Parameters 

1654 ---------- 

1655 cube: Cube 

1656 Cube of variable with Beaufort Scale in name. 

1657 axis: "x", "y", optional 

1658 Select the levels for just this axis of a line plot. The min and max 

1659 can be set by xmin/xmax or ymin/ymax respectively. For variables where 

1660 setting a universal range is not desirable (e.g. temperature), users 

1661 can set ymin/ymax values to "auto" in the colorbar definitions file. 

1662 Where no additional xmin/xmax or ymin/ymax values are provided, the 

1663 axis bounds default to use the vmin/vmax values provided. 

1664 

1665 Returns 

1666 ------- 

1667 cmap: 

1668 Matplotlib colormap. 

1669 levels: 

1670 List of levels to use for plotting. For continuous plots the min and max 

1671 should be taken as the range. 

1672 norm: 

1673 BoundaryNorm information. 

1674 """ 

1675 if "difference" not in cube.long_name: 

1676 if axis: 

1677 levels = [0, 12] 

1678 return None, levels, None 

1679 else: 

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

1681 colors = [ 

1682 "black", 

1683 (0, 0, 0.6), 

1684 "blue", 

1685 "cyan", 

1686 "green", 

1687 "yellow", 

1688 (1, 0.5, 0), 

1689 "red", 

1690 "pink", 

1691 "magenta", 

1692 "purple", 

1693 "maroon", 

1694 "white", 

1695 ] 

1696 cmap = mcolors.ListedColormap(colors) 

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

1698 logging.info("change colormap for Beaufort Scale colorbar.") 

1699 return cmap, levels, norm 

1700 else: 

1701 if axis: 

1702 levels = [-4, 4] 

1703 return None, levels, None 

1704 else: 

1705 levels = [ 

1706 -3.5, 

1707 -2.5, 

1708 -1.5, 

1709 -0.5, 

1710 0.5, 

1711 1.5, 

1712 2.5, 

1713 3.5, 

1714 ] 

1715 cmap = plt.get_cmap("bwr", 8) 

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

1717 return cmap, levels, norm 

1718 

1719 

1720def _custom_colormap_celsius(cube: iris.cube.Cube, cmap, levels, norm): 

1721 """Return altered colourmap for temperature with change in units to Celsius. 

1722 

1723 If "Celsius" appears anywhere in the name of a cube this function will be called. 

1724 

1725 Parameters 

1726 ---------- 

1727 cube: Cube 

1728 Cube of variable for which the colorbar information is desired. 

1729 cmap: Matplotlib colormap. 

1730 levels: List 

1731 List of levels to use for plotting. For continuous plots the min and max 

1732 should be taken as the range. 

1733 norm: BoundaryNorm. 

1734 

1735 Returns 

1736 ------- 

1737 cmap: Matplotlib colormap. 

1738 levels: List 

1739 List of levels to use for plotting. For continuous plots the min and max 

1740 should be taken as the range. 

1741 norm: BoundaryNorm. 

1742 """ 

1743 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name]) 

1744 if any("temperature" in name for name in varnames) and "Celsius" == cube.units: 

1745 levels = np.array(levels) 

1746 levels -= 273 

1747 levels = levels.tolist() 

1748 else: 

1749 # Do nothing keep the existing colourbar attributes 

1750 levels = levels 

1751 cmap = cmap 

1752 norm = norm 

1753 return cmap, levels, norm 

1754 

1755 

1756def _custom_colormap_probability( 

1757 cube: iris.cube.Cube, axis: Literal["x", "y"] | None = None 

1758): 

1759 """Get a custom colorbar for a probability cube. 

1760 

1761 Specific variable ranges can be separately set in user-supplied definition 

1762 for x- or y-axis limits, or indicate where automated range preferred. 

1763 

1764 Parameters 

1765 ---------- 

1766 cube: Cube 

1767 Cube of variable with probability in name. 

1768 axis: "x", "y", optional 

1769 Select the levels for just this axis of a line plot. The min and max 

1770 can be set by xmin/xmax or ymin/ymax respectively. For variables where 

1771 setting a universal range is not desirable (e.g. temperature), users 

1772 can set ymin/ymax values to "auto" in the colorbar definitions file. 

1773 Where no additional xmin/xmax or ymin/ymax values are provided, the 

1774 axis bounds default to use the vmin/vmax values provided. 

1775 

1776 Returns 

1777 ------- 

1778 cmap: 

1779 Matplotlib colormap. 

1780 levels: 

1781 List of levels to use for plotting. For continuous plots the min and max 

1782 should be taken as the range. 

1783 norm: 

1784 BoundaryNorm information. 

1785 """ 

1786 if axis: 

1787 levels = [0, 1] 

1788 return None, levels, None 

1789 else: 

1790 cmap = mcolors.ListedColormap( 

1791 [ 

1792 "#FFFFFF", 

1793 "#636363", 

1794 "#e1dada", 

1795 "#B5CAFF", 

1796 "#8FB3FF", 

1797 "#7F97FF", 

1798 "#ABCF63", 

1799 "#E8F59E", 

1800 "#FFFA14", 

1801 "#FFD121", 

1802 "#FFA30A", 

1803 ] 

1804 ) 

1805 levels = [0.0, 0.01, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] 

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

1807 return cmap, levels, norm 

1808 

1809 

1810def _custom_colourmap_precipitation(cube: iris.cube.Cube, cmap, levels, norm): 

1811 """Return a custom colourmap for the current recipe.""" 

1812 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name]) 

1813 if ( 

1814 any("surface_microphysical" in name for name in varnames) 

1815 and "difference" not in cube.long_name 

1816 and "mask" not in cube.long_name 

1817 ): 

1818 # Define the levels and colors 

1819 levels = [0, 0.125, 0.25, 0.5, 1, 2, 4, 8, 16, 32, 64, 128, 256] 

1820 colors = [ 

1821 "w", 

1822 (0, 0, 0.6), 

1823 "b", 

1824 "c", 

1825 "g", 

1826 "y", 

1827 (1, 0.5, 0), 

1828 "r", 

1829 "pink", 

1830 "m", 

1831 "purple", 

1832 "maroon", 

1833 "gray", 

1834 ] 

1835 # Create a custom colormap 

1836 cmap = mcolors.ListedColormap(colors) 

1837 # Normalize the levels 

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

1839 logging.info("change colormap for surface_microphysical variable colorbar.") 

1840 else: 

1841 # do nothing and keep existing colorbar attributes 

1842 cmap = cmap 

1843 levels = levels 

1844 norm = norm 

1845 return cmap, levels, norm 

1846 

1847 

1848def _custom_colormap_aviation_colour_state(cube: iris.cube.Cube): 

1849 """Return custom colourmap for aviation colour state. 

1850 

1851 If "aviation_colour_state" appears anywhere in the name of a cube 

1852 this function will be called. 

1853 

1854 Parameters 

1855 ---------- 

1856 cube: Cube 

1857 Cube of variable for which the colorbar information is desired. 

1858 

1859 Returns 

1860 ------- 

1861 cmap: Matplotlib colormap. 

1862 levels: List 

1863 List of levels to use for plotting. For continuous plots the min and max 

1864 should be taken as the range. 

1865 norm: BoundaryNorm. 

1866 """ 

1867 levels = [-0.5, 0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5] 

1868 colors = [ 

1869 "#87ceeb", 

1870 "#ffffff", 

1871 "#8ced69", 

1872 "#ffff00", 

1873 "#ffd700", 

1874 "#ffa500", 

1875 "#fe3620", 

1876 ] 

1877 # Create a custom colormap 

1878 cmap = mcolors.ListedColormap(colors) 

1879 # Normalise the levels 

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

1881 return cmap, levels, norm 

1882 

1883 

1884def _custom_colourmap_visibility_in_air(cube: iris.cube.Cube, cmap, levels, norm): 

1885 """Return a custom colourmap for the current recipe.""" 

1886 varnames = filter(None, [cube.long_name, cube.standard_name, cube.var_name]) 

1887 if ( 

1888 any("visibility_in_air" in name for name in varnames) 

1889 and "difference" not in cube.long_name 

1890 and "mask" not in cube.long_name 

1891 ): 

1892 # Define the levels and colors (in km) 

1893 levels = [0, 0.05, 0.1, 0.2, 1.0, 2.0, 5.0, 10.0, 20.0, 30.0, 50.0, 70.0, 100.0] 

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

1895 colours = [ 

1896 "#8f00d6", 

1897 "#d10000", 

1898 "#ff9700", 

1899 "#ffff00", 

1900 "#00007f", 

1901 "#6c9ccd", 

1902 "#aae8ff", 

1903 "#37a648", 

1904 "#8edc64", 

1905 "#c5ffc5", 

1906 "#dcdcdc", 

1907 "#ffffff", 

1908 ] 

1909 # Create a custom colormap 

1910 cmap = mcolors.ListedColormap(colours) 

1911 # Normalize the levels 

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

1913 logging.info("change colormap for visibility_in_air variable colorbar.") 

1914 else: 

1915 # do nothing and keep existing colorbar attributes 

1916 cmap = cmap 

1917 levels = levels 

1918 norm = norm 

1919 return cmap, levels, norm 

1920 

1921 

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

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

1924 model_names = list( 

1925 filter( 

1926 lambda x: x is not None, 

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

1928 ) 

1929 ) 

1930 if not model_names: 

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

1932 return 1 

1933 else: 

1934 return len(model_names) 

1935 

1936 

1937def _validate_cube_shape( 

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

1939) -> None: 

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

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

1942 raise ValueError( 

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

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

1945 ) 

1946 

1947 

1948def _validate_cubes_coords( 

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

1950) -> None: 

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

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

1953 raise ValueError( 

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

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

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

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

1958 ) 

1959 

1960 

1961#################### 

1962# Public functions # 

1963#################### 

1964 

1965 

1966def spatial_contour_plot( 

1967 cube: iris.cube.Cube, 

1968 filename: str = None, 

1969 sequence_coordinate: str = "time", 

1970 stamp_coordinate: str = "realization", 

1971 **kwargs, 

1972) -> iris.cube.Cube: 

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

1974 

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

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

1977 is present then postage stamp plots will be produced. 

1978 

1979 Parameters 

1980 ---------- 

1981 cube: Cube 

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

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

1984 plotted sequentially and/or as postage stamp plots. 

1985 filename: str, optional 

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

1987 to the recipe name. 

1988 sequence_coordinate: str, optional 

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

1990 This coordinate must exist in the cube. 

1991 stamp_coordinate: str, optional 

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

1993 ``"realization"``. 

1994 

1995 Returns 

1996 ------- 

1997 Cube 

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

1999 

2000 Raises 

2001 ------ 

2002 ValueError 

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

2004 TypeError 

2005 If the cube isn't a single cube. 

2006 """ 

2007 _spatial_plot( 

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

2009 ) 

2010 return cube 

2011 

2012 

2013def spatial_pcolormesh_plot( 

2014 cube: iris.cube.Cube, 

2015 filename: str = None, 

2016 sequence_coordinate: str = "time", 

2017 stamp_coordinate: str = "realization", 

2018 **kwargs, 

2019) -> iris.cube.Cube: 

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

2021 

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

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

2024 is present then postage stamp plots will be produced. 

2025 

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

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

2028 contour areas are important. 

2029 

2030 Parameters 

2031 ---------- 

2032 cube: Cube 

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

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

2035 plotted sequentially and/or as postage stamp plots. 

2036 filename: str, optional 

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

2038 to the recipe name. 

2039 sequence_coordinate: str, optional 

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

2041 This coordinate must exist in the cube. 

2042 stamp_coordinate: str, optional 

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

2044 ``"realization"``. 

2045 

2046 Returns 

2047 ------- 

2048 Cube 

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

2050 

2051 Raises 

2052 ------ 

2053 ValueError 

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

2055 TypeError 

2056 If the cube isn't a single cube. 

2057 """ 

2058 _spatial_plot( 

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

2060 ) 

2061 return cube 

2062 

2063 

2064# TODO: Expand function to handle ensemble data. 

2065# line_coordinate: str, optional 

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

2067# ``"realization"``. 

2068def plot_line_series( 

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

2070 filename: str = None, 

2071 series_coordinate: str = "time", 

2072 sequence_coordinate: str = "time", 

2073 # line_coordinate: str = "realization", 

2074 **kwargs, 

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

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

2077 

2078 The Cube or CubeList must be 1D. 

2079 

2080 Parameters 

2081 ---------- 

2082 iris.cube | iris.cube.CubeList 

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

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

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

2086 filename: str, optional 

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

2088 to the recipe name. 

2089 series_coordinate: str, optional 

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

2091 coordinate must exist in the cube. 

2092 

2093 Returns 

2094 ------- 

2095 iris.cube.Cube | iris.cube.CubeList 

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

2097 plotted data. 

2098 

2099 Raises 

2100 ------ 

2101 ValueError 

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

2103 TypeError 

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

2105 """ 

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

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

2108 

2109 if filename is None: 

2110 filename = slugify(recipe_title) 

2111 

2112 # Add file extension. This may be overwritten later on. 

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

2114 

2115 num_models = _get_num_models(cube) 

2116 

2117 _validate_cube_shape(cube, num_models) 

2118 

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

2120 cubes = iter_maybe(cube) 

2121 

2122 coords = [] 

2123 for cube in cubes: 

2124 try: 

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

2126 except iris.exceptions.CoordinateNotFoundError as err: 

2127 raise ValueError( 

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

2129 ) from err 

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

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

2132 

2133 plot_index = [] 

2134 if series_coordinate == "time": 

2135 # Do the actual plotting for timeseries. 

2136 _plot_and_save_line_series( 

2137 cubes, coords, "realization", plot_filename, recipe_title 

2138 ) 

2139 

2140 plot_index.append(plot_filename) 

2141 else: 

2142 # If series coordinate is not time, for example power spectra with series 

2143 # coordinate frequency/wavelength. 

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

2145 # time slider option. 

2146 for cube in cubes: 

2147 try: 

2148 cube.coord(sequence_coordinate) 

2149 except iris.exceptions.CoordinateNotFoundError as err: 

2150 raise ValueError( 

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

2152 ) from err 

2153 

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

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

2156 else: 

2157 all_points = sorted( 

2158 set( 

2159 itertools.chain.from_iterable( 

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

2161 ) 

2162 ) 

2163 ) 

2164 all_slices = list( 

2165 itertools.chain.from_iterable( 

2166 cb.slices_over(sequence_coordinate) for cb in cubes 

2167 ) 

2168 ) 

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

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

2171 # necessary) 

2172 cube_iterables = [ 

2173 iris.cube.CubeList( 

2174 s 

2175 for s in all_slices 

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

2177 ) 

2178 for point in all_points 

2179 ] 

2180 

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

2182 

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

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

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

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

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

2188 

2189 for cube_slice in cube_iterables: 

2190 # Normalize cube_slice to a list of cubes 

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

2192 cubes = list(cube_slice) 

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

2194 cubes = [cube_slice] 

2195 else: 

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

2197 

2198 single_cube = cubes[0] 

2199 

2200 # Use sequence value so multiple sequences can merge. 

2201 sequence_value = single_cube.coord(sequence_coordinate).points[0] 

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

2203 coord = single_cube.coord(sequence_coordinate) 

2204 

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

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

2207 

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

2209 if nplot == 1 and coord.has_bounds: 2209 ↛ 2215line 2209 didn't jump to line 2215 because the condition on line 2209 was always true

2210 if np.size(coord.bounds) > 1: 

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

2212 

2213 # Do the actual plotting. 

2214 

2215 _plot_and_save_line_1D( 

2216 cube_slice, coords, "realization", plot_filename, title 

2217 ) 

2218 

2219 plot_index.append(plot_filename) 

2220 

2221 # append plot to list of plots 

2222 complete_plot_index = _append_to_plot_index(plot_index) 

2223 

2224 # Make a page to display the plots. 

2225 _make_plot_html_page(complete_plot_index) 

2226 

2227 return cube 

2228 

2229 

2230def plot_vertical_line_series( 

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

2232 filename: str = None, 

2233 series_coordinate: str = "model_level_number", 

2234 sequence_coordinate: str = "time", 

2235 # line_coordinate: str = "realization", 

2236 **kwargs, 

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

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

2239 

2240 The Cube or CubeList must be 1D. 

2241 

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

2243 then a sequence of plots will be produced. 

2244 

2245 Parameters 

2246 ---------- 

2247 iris.cube | iris.cube.CubeList 

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

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

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

2251 filename: str, optional 

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

2253 to the recipe name. 

2254 series_coordinate: str, optional 

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

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

2257 for LFRic. Defaults to ``model_level_number``. 

2258 This coordinate must exist in the cube. 

2259 sequence_coordinate: str, optional 

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

2261 This coordinate must exist in the cube. 

2262 

2263 Returns 

2264 ------- 

2265 iris.cube.Cube | iris.cube.CubeList 

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

2267 Plotted data. 

2268 

2269 Raises 

2270 ------ 

2271 ValueError 

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

2273 TypeError 

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

2275 """ 

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

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

2278 

2279 if filename is None: 

2280 filename = slugify(recipe_title) 

2281 

2282 cubes = iter_maybe(cubes) 

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

2284 all_data = [] 

2285 

2286 # Store min/max ranges for x range. 

2287 x_levels = [] 

2288 

2289 num_models = _get_num_models(cubes) 

2290 

2291 _validate_cube_shape(cubes, num_models) 

2292 

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

2294 coords = [] 

2295 for cube in cubes: 

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

2297 try: 

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

2299 except iris.exceptions.CoordinateNotFoundError as err: 

2300 raise ValueError( 

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

2302 ) from err 

2303 

2304 try: 

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

2306 cube.coord(sequence_coordinate) 

2307 except iris.exceptions.CoordinateNotFoundError as err: 

2308 raise ValueError( 

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

2310 ) from err 

2311 

2312 # Get minimum and maximum from levels information. 

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

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

2315 x_levels.append(min(levels)) 

2316 x_levels.append(max(levels)) 

2317 else: 

2318 all_data.append(cube.data) 

2319 

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

2321 # Combine all data into a single NumPy array 

2322 combined_data = np.concatenate(all_data) 

2323 

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

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

2326 # sequence and if applicable postage stamp coordinate. 

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

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

2329 else: 

2330 vmin = min(x_levels) 

2331 vmax = max(x_levels) 

2332 

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

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

2335 # necessary) 

2336 def filter_cube_iterables(cube_iterables) -> bool: 

2337 return len(cube_iterables) == len(coords) 

2338 

2339 cube_iterables = filter( 

2340 filter_cube_iterables, 

2341 ( 

2342 iris.cube.CubeList( 

2343 s 

2344 for s in itertools.chain.from_iterable( 

2345 cb.slices_over(sequence_coordinate) for cb in cubes 

2346 ) 

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

2348 ) 

2349 for point in sorted( 

2350 set( 

2351 itertools.chain.from_iterable( 

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

2353 ) 

2354 ) 

2355 ) 

2356 ), 

2357 ) 

2358 

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

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

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

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

2363 # or an iris.cube.CubeList. 

2364 plot_index = [] 

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

2366 for cubes_slice in cube_iterables: 

2367 # Use sequence value so multiple sequences can merge. 

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

2369 sequence_value = seq_coord.points[0] 

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

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

2372 title = f"{recipe_title}\n [{seq_coord.units.title(sequence_value)}]" 

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

2374 if nplot == 1 and seq_coord.has_bounds: 2374 ↛ 2375line 2374 didn't jump to line 2375 because the condition on line 2374 was never true

2375 if np.size(seq_coord.bounds) > 1: 

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

2377 # Do the actual plotting. 

2378 _plot_and_save_vertical_line_series( 

2379 cubes_slice, 

2380 coords, 

2381 "realization", 

2382 plot_filename, 

2383 series_coordinate, 

2384 title=title, 

2385 vmin=vmin, 

2386 vmax=vmax, 

2387 ) 

2388 plot_index.append(plot_filename) 

2389 

2390 # Add list of plots to plot metadata. 

2391 complete_plot_index = _append_to_plot_index(plot_index) 

2392 

2393 # Make a page to display the plots. 

2394 _make_plot_html_page(complete_plot_index) 

2395 

2396 return cubes 

2397 

2398 

2399def qq_plot( 

2400 cubes: iris.cube.CubeList, 

2401 coordinates: list[str], 

2402 percentiles: list[float], 

2403 model_names: list[str], 

2404 filename: str = None, 

2405 one_to_one: bool = True, 

2406 **kwargs, 

2407) -> iris.cube.CubeList: 

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

2409 

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

2411 collapsed within the operator over all specified coordinates such as 

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

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

2414 

2415 Parameters 

2416 ---------- 

2417 cubes: iris.cube.CubeList 

2418 Two cubes of the same variable with different models. 

2419 coordinate: list[str] 

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

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

2422 the percentile coordinate. 

2423 percent: list[float] 

2424 A list of percentiles to appear in the plot. 

2425 model_names: list[str] 

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

2427 filename: str, optional 

2428 Filename of the plot to write. 

2429 one_to_one: bool, optional 

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

2431 

2432 Raises 

2433 ------ 

2434 ValueError 

2435 When the cubes are not compatible. 

2436 

2437 Notes 

2438 ----- 

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

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

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

2442 compares percentiles of two datasets. This plot does 

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

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

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

2446 

2447 Quantile-quantile plots are valuable for comparing against 

2448 observations and other models. Identical percentiles between the variables 

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

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

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

2452 Wilks 2011 [Wilks2011]_). 

2453 

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

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

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

2457 the extremes. 

2458 

2459 References 

2460 ---------- 

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

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

2463 """ 

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

2465 if len(cubes) != 2: 

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

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

2468 other: Cube = cubes.extract_cube( 

2469 iris.Constraint( 

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

2471 ) 

2472 ) 

2473 

2474 # Get spatial coord names. 

2475 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

2476 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

2477 

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

2479 # This is triggered if either 

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

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

2482 # errors. 

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

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

2485 # for UM and LFRic comparisons. 

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

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

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

2489 # given this dependency on regridding. 

2490 if ( 

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

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

2493 ) or ( 

2494 base.long_name 

2495 in [ 

2496 "eastward_wind_at_10m", 

2497 "northward_wind_at_10m", 

2498 "northward_wind_at_cell_centres", 

2499 "eastward_wind_at_cell_centres", 

2500 "zonal_wind_at_pressure_levels", 

2501 "meridional_wind_at_pressure_levels", 

2502 "potential_vorticity_at_pressure_levels", 

2503 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

2504 ] 

2505 ): 

2506 logging.debug( 

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

2508 ) 

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

2510 

2511 # Extract just common time points. 

2512 base, other = _extract_common_time_points(base, other) 

2513 

2514 # Equalise attributes so we can merge. 

2515 fully_equalise_attributes([base, other]) 

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

2517 

2518 # Collapse cubes. 

2519 base = collapse( 

2520 base, 

2521 coordinate=coordinates, 

2522 method="PERCENTILE", 

2523 additional_percent=percentiles, 

2524 ) 

2525 other = collapse( 

2526 other, 

2527 coordinate=coordinates, 

2528 method="PERCENTILE", 

2529 additional_percent=percentiles, 

2530 ) 

2531 

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

2533 title = get_recipe_metadata().get("title", "Untitled") 

2534 

2535 if filename is None: 

2536 filename = slugify(title) 

2537 

2538 # Add file extension. 

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

2540 

2541 # Do the actual plotting on a scatter plot 

2542 _plot_and_save_scatter_plot( 

2543 base, other, plot_filename, title, one_to_one, model_names 

2544 ) 

2545 

2546 # Add list of plots to plot metadata. 

2547 plot_index = _append_to_plot_index([plot_filename]) 

2548 

2549 # Make a page to display the plots. 

2550 _make_plot_html_page(plot_index) 

2551 

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

2553 

2554 

2555def scatter_plot( 

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

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

2558 filename: str = None, 

2559 one_to_one: bool = True, 

2560 **kwargs, 

2561) -> iris.cube.CubeList: 

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

2563 

2564 Both cubes must be 1D. 

2565 

2566 Parameters 

2567 ---------- 

2568 cube_x: Cube | CubeList 

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

2570 cube_y: Cube | CubeList 

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

2572 filename: str, optional 

2573 Filename of the plot to write. 

2574 one_to_one: bool, optional 

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

2576 

2577 Returns 

2578 ------- 

2579 cubes: CubeList 

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

2581 

2582 Raises 

2583 ------ 

2584 ValueError 

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

2586 size. 

2587 TypeError 

2588 If the cube isn't a single cube. 

2589 

2590 Notes 

2591 ----- 

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

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

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

2595 """ 

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

2597 for cube_iter in iter_maybe(cube_x): 

2598 # Check cubes are correct shape. 

2599 cube_iter = _check_single_cube(cube_iter) 

2600 if cube_iter.ndim > 1: 

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

2602 

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

2604 for cube_iter in iter_maybe(cube_y): 

2605 # Check cubes are correct shape. 

2606 cube_iter = _check_single_cube(cube_iter) 

2607 if cube_iter.ndim > 1: 

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

2609 

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

2611 title = get_recipe_metadata().get("title", "Untitled") 

2612 

2613 if filename is None: 

2614 filename = slugify(title) 

2615 

2616 # Add file extension. 

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

2618 

2619 # Do the actual plotting. 

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

2621 

2622 # Add list of plots to plot metadata. 

2623 plot_index = _append_to_plot_index([plot_filename]) 

2624 

2625 # Make a page to display the plots. 

2626 _make_plot_html_page(plot_index) 

2627 

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

2629 

2630 

2631def vector_plot( 

2632 cube_u: iris.cube.Cube, 

2633 cube_v: iris.cube.Cube, 

2634 filename: str = None, 

2635 sequence_coordinate: str = "time", 

2636 **kwargs, 

2637) -> iris.cube.CubeList: 

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

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

2640 

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

2642 if filename is None: 2642 ↛ 2643line 2642 didn't jump to line 2643 because the condition on line 2642 was never true

2643 filename = slugify(recipe_title) 

2644 

2645 # Cubes must have a matching sequence coordinate. 

2646 try: 

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

2648 if cube_u.coord(sequence_coordinate) != cube_v.coord(sequence_coordinate): 2648 ↛ 2649line 2648 didn't jump to line 2649 because the condition on line 2648 was never true

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

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

2651 raise ValueError( 

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

2653 ) from err 

2654 

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

2656 plot_index = [] 

2657 for cube_u_slice, cube_v_slice in zip( 

2658 cube_u.slices_over(sequence_coordinate), 

2659 cube_v.slices_over(sequence_coordinate), 

2660 strict=True, 

2661 ): 

2662 # Use sequence value so multiple sequences can merge. 

2663 sequence_value = cube_u_slice.coord(sequence_coordinate).points[0] 

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

2665 coord = cube_u_slice.coord(sequence_coordinate) 

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

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

2668 # Do the actual plotting. 

2669 _plot_and_save_vector_plot( 

2670 cube_u_slice, 

2671 cube_v_slice, 

2672 filename=plot_filename, 

2673 title=title, 

2674 method="contourf", 

2675 ) 

2676 plot_index.append(plot_filename) 

2677 

2678 # Add list of plots to plot metadata. 

2679 complete_plot_index = _append_to_plot_index(plot_index) 

2680 

2681 # Make a page to display the plots. 

2682 _make_plot_html_page(complete_plot_index) 

2683 

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

2685 

2686 

2687def plot_histogram_series( 

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

2689 filename: str = None, 

2690 sequence_coordinate: str = "time", 

2691 stamp_coordinate: str = "realization", 

2692 single_plot: bool = False, 

2693 **kwargs, 

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

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

2696 

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

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

2699 functionality to scroll through histograms against time. If a 

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

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

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

2703 

2704 Parameters 

2705 ---------- 

2706 cubes: Cube | iris.cube.CubeList 

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

2708 than the stamp coordinate. 

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

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

2711 filename: str, optional 

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

2713 to the recipe name. 

2714 sequence_coordinate: str, optional 

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

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

2717 slider. 

2718 stamp_coordinate: str, optional 

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

2720 ``"realization"``. 

2721 single_plot: bool, optional 

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

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

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

2725 

2726 Returns 

2727 ------- 

2728 iris.cube.Cube | iris.cube.CubeList 

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

2730 Plotted data. 

2731 

2732 Raises 

2733 ------ 

2734 ValueError 

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

2736 TypeError 

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

2738 """ 

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

2740 

2741 cubes = iter_maybe(cubes) 

2742 

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

2744 if filename is None: 

2745 filename = slugify(recipe_title) 

2746 

2747 # Internal plotting function. 

2748 plotting_func = _plot_and_save_histogram_series 

2749 

2750 num_models = _get_num_models(cubes) 

2751 

2752 _validate_cube_shape(cubes, num_models) 

2753 

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

2755 # time slider option. 

2756 for cube in cubes: 

2757 try: 

2758 cube.coord(sequence_coordinate) 

2759 except iris.exceptions.CoordinateNotFoundError as err: 

2760 raise ValueError( 

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

2762 ) from err 

2763 

2764 # Get minimum and maximum from levels information. 

2765 levels = None 

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

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

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

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

2770 if levels is None: 

2771 break 

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

2773 # levels-based ranges for histogram plots. 

2774 _, levels, _ = _colorbar_map_levels(cube) 

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

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

2777 vmin = min(levels) 

2778 vmax = max(levels) 

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

2780 break 

2781 

2782 if levels is None: 

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

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

2785 

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

2787 # single point. If single_plot is True: 

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

2789 # separate postage stamp plots. 

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

2791 # produced per single model only 

2792 

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

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

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

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

2797 ): 

2798 if single_plot: 

2799 plotting_func = ( 

2800 _plot_and_save_postage_stamps_in_single_plot_histogram_series 

2801 ) 

2802 else: 

2803 plotting_func = _plot_and_save_postage_stamp_histogram_series 

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

2805 else: 

2806 all_points = sorted( 

2807 set( 

2808 itertools.chain.from_iterable( 

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

2810 ) 

2811 ) 

2812 ) 

2813 all_slices = list( 

2814 itertools.chain.from_iterable( 

2815 cb.slices_over(sequence_coordinate) for cb in cubes 

2816 ) 

2817 ) 

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

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

2820 # necessary) 

2821 cube_iterables = [ 

2822 iris.cube.CubeList( 

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

2824 ) 

2825 for point in all_points 

2826 ] 

2827 

2828 plot_index = [] 

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

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

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

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

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

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

2835 for cube_slice in cube_iterables: 

2836 single_cube = cube_slice 

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

2838 single_cube = cube_slice[0] 

2839 

2840 # Use sequence value so multiple sequences can merge. 

2841 sequence_value = single_cube.coord(sequence_coordinate).points[0] 

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

2843 coord = single_cube.coord(sequence_coordinate) 

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

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

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

2847 if nplot == 1 and coord.has_bounds: 2847 ↛ 2848line 2847 didn't jump to line 2848 because the condition on line 2847 was never true

2848 if np.size(coord.bounds) > 1: 

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

2850 # Do the actual plotting. 

2851 

2852 plotting_func( 

2853 cube_slice, 

2854 filename=plot_filename, 

2855 stamp_coordinate=stamp_coordinate, 

2856 title=title, 

2857 vmin=vmin, 

2858 vmax=vmax, 

2859 ) 

2860 plot_index.append(plot_filename) 

2861 

2862 # Add list of plots to plot metadata. 

2863 complete_plot_index = _append_to_plot_index(plot_index) 

2864 

2865 # Make a page to display the plots. 

2866 _make_plot_html_page(complete_plot_index) 

2867 

2868 return cubes