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

236 statements  

« prev     ^ index     » next       coverage.py v7.5.4, created at 2024-07-01 10:31 +0000

1# Copyright 2022 Met Office and contributors. 

2# 

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

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

5# You may obtain a copy of the License at 

6# 

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

8# 

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

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

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

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

13# limitations under the License. 

14 

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

16 

17import fcntl 

18import importlib.resources 

19import json 

20import logging 

21import math 

22import sys 

23import warnings 

24from typing import Union 

25 

26import iris 

27import iris.coords 

28import iris.cube 

29import iris.exceptions 

30import iris.plot as iplt 

31import matplotlib as mpl 

32import matplotlib.pyplot as plt 

33import numpy as np 

34from markdown_it import MarkdownIt 

35 

36from CSET._common import get_recipe_metadata, render_file, slugify 

37from CSET.operators._utils import get_cube_yxcoordname 

38 

39############################ 

40# Private helper functions # 

41############################ 

42 

43 

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

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

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

47 fcntl.flock(fp, fcntl.LOCK_EX) 

48 fp.seek(0) 

49 meta = json.load(fp) 

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

51 complete_plot_index = complete_plot_index + plot_index 

52 meta["plots"] = complete_plot_index 

53 fp.seek(0) 

54 fp.truncate() 

55 json.dump(meta, fp) 

56 return complete_plot_index 

57 

58 

59def _check_single_cube( 

60 cube: Union[iris.cube.Cube, iris.cube.CubeList], 

61) -> iris.cube.Cube: 

62 """Ensure a single cube is given. 

63 

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

65 otherwise an error is raised. 

66 

67 Parameters 

68 ---------- 

69 cube: Cube | CubeList 

70 The cube to check. 

71 

72 Returns 

73 ------- 

74 cube: Cube 

75 The checked cube. 

76 

77 Raises 

78 ------ 

79 TypeError 

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

81 """ 

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

83 return cube 

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

85 if len(cube) == 1: 

86 return cube[0] 

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

88 

89 

90def _make_plot_html_page(plots: list): 

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

92 # Debug check that plots actually contains some strings. 

93 assert isinstance(plots[0], str) 

94 

95 # Load HTML template file. 

96 # Importlib behaviour changed in 3.12 to avoid circular dependencies. 

97 if sys.version_info.minor >= 12: 

98 operator_files = importlib.resources.files() 

99 else: 

100 import CSET.operators 

101 

102 operator_files = importlib.resources.files(CSET.operators) 

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

104 

105 # Get some metadata. 

106 meta = get_recipe_metadata() 

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

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

109 

110 # Prepare template variables. 

111 variables = { 

112 "title": title, 

113 "description": description, 

114 "initial_plot": plots[0], 

115 "plots": plots, 

116 "title_slug": slugify(title), 

117 } 

118 

119 # Render template. 

120 html = render_file(template_file, **variables) 

121 

122 # Save completed HTML. 

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

124 fp.write(html) 

125 

126 

127def _colorbar_map_levels(varname: str, **kwargs): 

128 """ 

129 Specify the color map and levels. 

130 

131 For the given variable name, from a colorbar dictionary file. 

132 

133 Parameters 

134 ---------- 

135 colorbar_file: str 

136 Filename of the colorbar dictionary to read. 

137 varname: str 

138 Variable name to extract from the dictionary 

139 

140 """ 

141 # Grab the colour bar file from the recipe global metadata. A non-existent 

142 # placeholder path is used if not found. 

143 colorbar_file = get_recipe_metadata().get( 

144 "style_file_path", "/non-existent/NO_FILE_SPECIFIED" 

145 ) 

146 try: 

147 with open(colorbar_file, "rt", encoding="UTF-8") as fp: 147 ↛ 148,   147 ↛ 1512 missed branches: 1) line 147 didn't jump to line 148 because , 2) line 147 didn't jump to line 151 because

148 colorbar = json.load(fp) 

149 

150 # Specify the colormap for this variable 

151 try: 

152 cmap = colorbar[varname]["cmap"] 

153 logging.debug("From color_bar dictionary: Using cmap") 

154 except KeyError: 

155 cmap = mpl.colormaps["viridis"] 

156 

157 # Specify the colorbar levels for this variable 

158 try: 

159 levels = colorbar[varname]["levels"] 

160 

161 actual_cmap = mpl.cm.get_cmap(cmap) 

162 

163 norm = mpl.colors.BoundaryNorm(levels, ncolors=actual_cmap.N) 

164 logging.debug("From color_bar dictionary: Using levels") 

165 except KeyError: 

166 try: 

167 vmin, vmax = colorbar[varname]["min"], colorbar[varname]["max"] 

168 logging.debug("From color_bar dictionary: Using min and max") 

169 levels = np.linspace(vmin, vmax, 10) 

170 norm = None 

171 except KeyError: 

172 levels = None 

173 norm = None 

174 

175 except FileNotFoundError: 

176 logging.debug("Colour bar file: %s", colorbar_file) 

177 logging.info("Colour bar file does not exist. Using default values.") 

178 levels = None 

179 norm = None 

180 cmap = mpl.colormaps["viridis"] 

181 

182 return cmap, levels, norm 

183 

184 

185def _plot_and_save_contour_plot( 

186 cube: iris.cube.Cube, 

187 filename: str, 

188 title: str, 

189 **kwargs, 

190): 

191 """Plot and save a contour plot. 

192 

193 Parameters 

194 ---------- 

195 cube: Cube 

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

197 filename: str 

198 Filename of the plot to write. 

199 title: str 

200 Plot title. 

201 

202 """ 

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

204 fig = plt.figure(figsize=(15, 15), facecolor="w", edgecolor="k") 

205 

206 # Specify the color bar 

207 cmap, levels, norm = _colorbar_map_levels(cube.name()) 

208 

209 # Filled contour plot of the field. 

210 contours = iplt.contourf(cube, cmap=cmap, levels=levels, norm=norm) 

211 

212 # Using pyplot interface here as we need iris to generate a cartopy GeoAxes. 

213 axes = plt.gca() 

214 

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

216 try: 

217 get_cube_yxcoordname(cube) 

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

219 except ValueError: 

220 pass 

221 

222 # Add title. 

223 axes.set_title(title, fontsize=16) 

224 

225 # Add colour bar. 

226 cbar = fig.colorbar(contours) 

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

228 

229 # Save plot. 

230 fig.savefig(filename, bbox_inches="tight", dpi=150) 

231 logging.info("Saved contour plot to %s", filename) 

232 plt.close(fig) 

233 

234 

235def _plot_and_save_postage_stamp_contour_plot( 

236 cube: iris.cube.Cube, 

237 filename: str, 

238 stamp_coordinate: str, 

239 title: str, 

240 **kwargs, 

241): 

242 """Plot postage stamp contour plots from an ensemble. 

243 

244 Parameters 

245 ---------- 

246 cube: Cube 

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

248 filename: str 

249 Filename of the plot to write. 

250 stamp_coordinate: str 

251 Coordinate that becomes different plots. 

252 

253 Raises 

254 ------ 

255 ValueError 

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

257 """ 

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

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

260 

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

262 

263 # Specify the color bar 

264 cmap, levels, norm = _colorbar_map_levels(cube.name()) 

265 

266 # Make a subplot for each member. 

267 for member, subplot in zip( 

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

269 ): 

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

271 # cartopy GeoAxes generated. 

272 plt.subplot(grid_size, grid_size, subplot) 

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

274 ax = plt.gca() 

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

276 ax.set_axis_off() 

277 

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

279 try: 

280 get_cube_yxcoordname(cube) 

281 ax.coastlines(resolution="10m") 

282 except ValueError: 

283 pass 

284 

285 # Put the shared colorbar in its own axes. 

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

287 colorbar = fig.colorbar(plot, colorbar_axes, orientation="horizontal") 

288 colorbar.set_label(f"{cube.name()} / {cube.units}") 

289 

290 # Overall figure title. 

291 fig.suptitle(title) 

292 

293 fig.savefig(filename, bbox_inches="tight", dpi=150) 

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

295 plt.close(fig) 

296 

297 

298def _plot_and_save_line_series( 

299 cube: iris.cube.Cube, coord: iris.coords.Coord, filename: str, title: str, **kwargs 

300): 

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

302 

303 Parameters 

304 ---------- 

305 cube: Cube 

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

307 coord: Coord 

308 Coordinate to plot on x-axis. 

309 filename: str 

310 Filename of the plot to write. 

311 title: str 

312 Plot title. 

313 """ 

314 fig = plt.figure(figsize=(8, 8), facecolor="w", edgecolor="k") 

315 iplt.plot(coord, cube, "o-") 

316 ax = plt.gca() 

317 

318 # Add some labels and tweak the style. 

319 ax.set( 

320 xlabel=f"{coord.name()} / {coord.units}", 

321 ylabel=f"{cube.name()} / {cube.units}", 

322 title=title, 

323 ) 

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

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

326 ax.autoscale() 

327 

328 # Save plot. 

329 fig.savefig(filename, bbox_inches="tight", dpi=150) 

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

331 plt.close(fig) 

332 

333 

334def _plot_and_save_vertical_line_series( 

335 cube: iris.cube.Cube, 

336 coord: iris.coords.Coord, 

337 filename: str, 

338 series_coordinate: str, 

339 title: str, 

340 vmin: float, 

341 vmax: float, 

342 **kwargs, 

343): 

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

345 

346 Parameters 

347 ---------- 

348 cube: Cube 

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

350 coord: Coord 

351 Coordinate to plot on y-axis. 

352 filename: str 

353 Filename of the plot to write. 

354 series_coordinate: str 

355 Coordinate to use as vertical axis. 

356 title: str 

357 Plot title. 

358 vmin: float 

359 Minimum value for the x-axis. 

360 vmax: float 

361 Maximum value for the x-axis. 

362 """ 

363 # plot the vertical pressure axis using log scale 

364 fig = plt.figure(figsize=(8, 8), facecolor="w", edgecolor="k") 

365 iplt.plot(cube, coord, "o-") 

366 ax = plt.gca() 

367 

368 # Special handling for pressure level data. 

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

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

371 ax.invert_yaxis() 

372 ax.set_yscale("log") 

373 

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

375 y_tick_labels = [ 

376 "1000", 

377 "850", 

378 "700", 

379 "500", 

380 "300", 

381 "200", 

382 "100", 

383 "50", 

384 "30", 

385 "20", 

386 "10", 

387 ] 

388 y_ticks = [1000, 850, 700, 500, 300, 200, 100, 50, 30, 20, 10] 

389 

390 # Set y-axis limits and ticks. 

391 ax.set_ylim(1100, 100) 

392 

393 # test if series_coordinate is model level data. The um data uses model_level_number 

394 # and lfric uses full_levels as coordinate. 

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

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

397 y_ticks = cube.coord(series_coordinate).points 

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

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

400 

401 ax.set_yticks(y_ticks) 

402 ax.set_yticklabels(y_tick_labels) 

403 

404 # set x-axis limits 

405 ax.set_xlim(vmin, vmax) 

406 

407 # Add some labels and tweak the style. 

408 ax.set( 

409 ylabel=f"{coord.name()} / {coord.units}", 

410 xlabel=f"{cube.name()} / {cube.units}", 

411 title=title, 

412 ) 

413 

414 # Save plot. 

415 fig.savefig(filename, bbox_inches="tight", dpi=150) 

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

417 plt.close(fig) 

418 

419 

420#################### 

421# Public functions # 

422#################### 

423 

424 

425def spatial_contour_plot( 

426 cube: iris.cube.Cube, 

427 filename: str = None, 

428 sequence_coordinate: str = "time", 

429 stamp_coordinate: str = "realization", 

430 **kwargs, 

431) -> iris.cube.Cube: 

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

433 

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

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

436 is present then postage stamp plots will be produced. 

437 

438 Parameters 

439 ---------- 

440 cube: Cube 

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

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

443 plotted sequentially and/or as postage stamp plots. 

444 filename: str, optional 

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

446 to the recipe name. 

447 sequence_coordinate: str, optional 

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

449 This coordinate must exist in the cube. 

450 stamp_coordinate: str, optional 

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

452 ``"realization"``. 

453 

454 Returns 

455 ------- 

456 Cube 

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

458 

459 Raises 

460 ------ 

461 ValueError 

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

463 TypeError 

464 If the cube isn't a single cube. 

465 """ 

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

467 

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

469 if filename is None: 

470 filename = slugify(recipe_title) 

471 

472 # Ensure we've got a single cube. 

473 cube = _check_single_cube(cube) 

474 

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

476 # single point. 

477 plotting_func = _plot_and_save_contour_plot 

478 try: 

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

480 plotting_func = _plot_and_save_postage_stamp_contour_plot 

481 except iris.exceptions.CoordinateNotFoundError: 

482 pass 

483 

484 try: 

485 cube.coord(sequence_coordinate) 

486 except iris.exceptions.CoordinateNotFoundError as err: 

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

488 

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

490 plot_index = [] 

491 for cube_slice in cube.slices_over(sequence_coordinate): 

492 # Use sequence value so multiple sequences can merge. 

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

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

495 coord = cube_slice.coord(sequence_coordinate) 

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

497 title = f"{recipe_title} | {coord.units.title(coord.points[0])}" 

498 # Do the actual plotting. 

499 plotting_func( 

500 cube_slice, 

501 plot_filename, 

502 stamp_coordinate=stamp_coordinate, 

503 title=title, 

504 ) 

505 plot_index.append(plot_filename) 

506 

507 # Add list of plots to plot metadata. 

508 complete_plot_index = _append_to_plot_index(plot_index) 

509 

510 # Make a page to display the plots. 

511 _make_plot_html_page(complete_plot_index) 

512 

513 return cube 

514 

515 

516# Deprecated 

517def postage_stamp_contour_plot( 

518 cube: iris.cube.Cube, 

519 filename: str = None, 

520 coordinate: str = "realization", 

521 **kwargs, 

522) -> iris.cube.Cube: 

523 """Plot postage stamp contour plots from an ensemble. 

524 

525 Depreciated. Use spatial_contour_plot with a stamp_coordinate argument 

526 instead. 

527 

528 Parameters 

529 ---------- 

530 cube: Cube 

531 Iris cube of data to be plotted. It must have a realization coordinate. 

532 filename: pathlike, optional 

533 The path of the plot to write. Defaults to the recipe name. 

534 coordinate: str 

535 The coordinate that becomes different plots. Defaults to "realization". 

536 

537 Returns 

538 ------- 

539 Cube 

540 The original cube (so further operations can be applied) 

541 

542 Raises 

543 ------ 

544 ValueError 

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

546 TypeError 

547 If cube isn't a Cube. 

548 """ 

549 warnings.warn( 

550 "postage_stamp_contour_plot is depreciated. Use spatial_contour_plot with a stamp_coordinate argument instead.", 

551 DeprecationWarning, 

552 stacklevel=2, 

553 ) 

554 # Get suitable filename. 

555 if filename is None: 

556 filename = slugify(get_recipe_metadata().get("title", "Untitled")) 

557 if not filename.endswith(".png"): 

558 filename = filename + ".png" 

559 

560 # Check cube is suitable. 

561 cube = _check_single_cube(cube) 

562 try: 

563 cube.coord(coordinate) 

564 except iris.exceptions.CoordinateNotFoundError as err: 

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

566 

567 _plot_and_save_postage_stamp_contour_plot(cube, filename, coordinate, title="") 

568 _make_plot_html_page([filename]) 

569 return cube 

570 

571 

572# TODO: Expand function to handle ensemble data. 

573# line_coordinate: str, optional 

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

575# ``"realization"``. 

576def plot_line_series( 

577 cube: iris.cube.Cube, 

578 filename: str = None, 

579 series_coordinate: str = "time", 

580 # line_coordinate: str = "realization", 

581 **kwargs, 

582) -> iris.cube.Cube: 

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

584 

585 The cube must be 1D. 

586 

587 Parameters 

588 ---------- 

589 cube: Cube 

590 Iris cube of the data to plot. It should have a single dimension. 

591 filename: str, optional 

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

593 to the recipe name. 

594 series_coordinate: str, optional 

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

596 coordinate must exist in the cube. 

597 

598 Returns 

599 ------- 

600 Cube 

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

602 

603 Raises 

604 ------ 

605 ValueError 

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

607 TypeError 

608 If the cube isn't a single cube. 

609 """ 

610 # Check cube is right shape. 

611 cube = _check_single_cube(cube) 

612 try: 

613 coord = cube.coord(series_coordinate) 

614 except iris.exceptions.CoordinateNotFoundError as err: 

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

616 if cube.ndim > 1: 

617 raise ValueError("Cube must be 1D.") 

618 

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

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

621 if filename is None: 

622 filename = slugify(title) 

623 

624 # Add file extension. 

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

626 

627 # Do the actual plotting. 

628 _plot_and_save_line_series(cube, coord, plot_filename, title) 

629 

630 # Add list of plots to plot metadata. 

631 plot_index = _append_to_plot_index([plot_filename]) 

632 

633 # Make a page to display the plots. 

634 _make_plot_html_page(plot_index) 

635 

636 return cube 

637 

638 

639def plot_vertical_line_series( 

640 cube: iris.cube.Cube, 

641 filename: str = None, 

642 series_coordinate: str = "model_level_number", 

643 sequence_coordinate: str = "time", 

644 # line_coordinate: str = "realization", 

645 **kwargs, 

646) -> iris.cube.Cube: 

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

648 

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

650 then a sequence of plots will be produced. 

651 

652 The cube must be 1D. 

653 

654 Parameters 

655 ---------- 

656 cube: Cube 

657 Iris cube of the data to plot. It should have a single dimension. 

658 filename: str, optional 

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

660 to the recipe name. 

661 series_coordinate: str, optional 

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

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

664 for LFRic. Defaults to ``model_level_number``. 

665 This coordinate must exist in the cube. 

666 sequence_coordinate: str, optional 

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

668 This coordinate must exist in the cube. 

669 

670 Returns 

671 ------- 

672 Cube 

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

674 

675 Raises 

676 ------ 

677 ValueError 

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

679 TypeError 

680 If the cube isn't a single cube. 

681 """ 

682 # Ensure we've got a single cube. 

683 cube = _check_single_cube(cube) 

684 

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

686 try: 

687 coord = cube.coord(series_coordinate) 

688 except iris.exceptions.CoordinateNotFoundError as err: 

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

690 

691 # If several individual vertical lines are plotted with time as sequence_coordinate 

692 # for the time slider option. 

693 try: 

694 cube.coord(sequence_coordinate) 

695 except iris.exceptions.CoordinateNotFoundError as err: 

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

697 

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

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

700 if filename is None: 700 ↛ 704line 700 didn't jump to line 704 because the condition on line 700 was always true

701 filename = slugify(recipe_title) 

702 

703 # Make vertical line plot 

704 plotting_func = _plot_and_save_vertical_line_series 

705 

706 # set the lower and upper limit for the x-axis to ensure all plots 

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

708 # the sequence and if applicable postage stamp coordinate. 

709 # This only works if the plotting is done in the collate section of a 

710 # recipe and not in the parallel section of a recipe. 

711 vmin = np.floor((cube.data.min())) 

712 vmax = np.ceil((cube.data.max())) 

713 

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

715 plot_index = [] 

716 for cube_slice in cube.slices_over(sequence_coordinate): 

717 # Use sequence value so multiple sequences can merge. 

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

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

720 coord = cube_slice.coord(series_coordinate) 

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

722 title = f"{recipe_title} | {coord.units.title(coord.points[0])}" 

723 # Do the actual plotting. 

724 plotting_func( 

725 cube_slice, 

726 coord, 

727 plot_filename, 

728 series_coordinate, 

729 title=title, 

730 vmin=vmin, 

731 vmax=vmax, 

732 ) 

733 plot_index.append(plot_filename) 

734 

735 # Add list of plots to plot metadata. 

736 complete_plot_index = _append_to_plot_index(plot_index) 

737 

738 # Make a page to display the plots. 

739 _make_plot_html_page(complete_plot_index) 

740 

741 return cube