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

231 statements  

« prev     ^ index     » next       coverage.py v7.5.4, created at 2024-07-01 09:02 +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 title: str, 

339 vmin: float, 

340 vmax: float, 

341 **kwargs, 

342): 

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

344 

345 Parameters 

346 ---------- 

347 cube: Cube 

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

349 coord: Coord 

350 Coordinate to plot on y-axis. 

351 filename: str 

352 Filename of the plot to write. 

353 title: str 

354 Plot title. 

355 vmin: float 

356 Minimum value for the x-axis. 

357 vmax: float 

358 Maximum value for the x-axis. 

359 """ 

360 # plot the vertical pressure axis using log scale 

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

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

363 ax = plt.gca() 

364 ax.invert_yaxis() 

365 ax.set_yscale("log") 

366 

367 # Define y-ticks and labels for pressure log axis 

368 y_tick_labels = [ 

369 "1000", 

370 "850", 

371 "700", 

372 "500", 

373 "300", 

374 "200", 

375 "100", 

376 "50", 

377 "30", 

378 "20", 

379 "10", 

380 ] 

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

382 

383 # Set y-axis limits and ticks 

384 ax.set_ylim(1100, 100) 

385 ax.set_yticks(y_ticks) 

386 ax.set_yticklabels(y_tick_labels) 

387 

388 # set x-axis limits 

389 ax.set_xlim(vmin, vmax) 

390 

391 # Add some labels and tweak the style. 

392 ax.set( 

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

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

395 title=title, 

396 ) 

397 

398 # Save plot. 

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

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

401 plt.close(fig) 

402 

403 

404#################### 

405# Public functions # 

406#################### 

407 

408 

409def spatial_contour_plot( 

410 cube: iris.cube.Cube, 

411 filename: str = None, 

412 sequence_coordinate: str = "time", 

413 stamp_coordinate: str = "realization", 

414 **kwargs, 

415) -> iris.cube.Cube: 

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

417 

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

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

420 is present then postage stamp plots will be produced. 

421 

422 Parameters 

423 ---------- 

424 cube: Cube 

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

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

427 plotted sequentially and/or as postage stamp plots. 

428 filename: str, optional 

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

430 to the recipe name. 

431 sequence_coordinate: str, optional 

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

433 This coordinate must exist in the cube. 

434 stamp_coordinate: str, optional 

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

436 ``"realization"``. 

437 

438 Returns 

439 ------- 

440 Cube 

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

442 

443 Raises 

444 ------ 

445 ValueError 

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

447 TypeError 

448 If the cube isn't a single cube. 

449 """ 

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

451 

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

453 if filename is None: 

454 filename = slugify(recipe_title) 

455 

456 # Ensure we've got a single cube. 

457 cube = _check_single_cube(cube) 

458 

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

460 # single point. 

461 plotting_func = _plot_and_save_contour_plot 

462 try: 

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

464 plotting_func = _plot_and_save_postage_stamp_contour_plot 

465 except iris.exceptions.CoordinateNotFoundError: 

466 pass 

467 

468 try: 

469 cube.coord(sequence_coordinate) 

470 except iris.exceptions.CoordinateNotFoundError as err: 

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

472 

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

474 plot_index = [] 

475 for cube_slice in cube.slices_over(sequence_coordinate): 

476 # Use sequence value so multiple sequences can merge. 

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

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

479 coord = cube_slice.coord(sequence_coordinate) 

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

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

482 # Do the actual plotting. 

483 plotting_func( 

484 cube_slice, 

485 plot_filename, 

486 stamp_coordinate=stamp_coordinate, 

487 title=title, 

488 ) 

489 plot_index.append(plot_filename) 

490 

491 # Add list of plots to plot metadata. 

492 complete_plot_index = _append_to_plot_index(plot_index) 

493 

494 # Make a page to display the plots. 

495 _make_plot_html_page(complete_plot_index) 

496 

497 return cube 

498 

499 

500# Deprecated 

501def postage_stamp_contour_plot( 

502 cube: iris.cube.Cube, 

503 filename: str = None, 

504 coordinate: str = "realization", 

505 **kwargs, 

506) -> iris.cube.Cube: 

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

508 

509 Depreciated. Use spatial_contour_plot with a stamp_coordinate argument 

510 instead. 

511 

512 Parameters 

513 ---------- 

514 cube: Cube 

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

516 filename: pathlike, optional 

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

518 coordinate: str 

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

520 

521 Returns 

522 ------- 

523 Cube 

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

525 

526 Raises 

527 ------ 

528 ValueError 

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

530 TypeError 

531 If cube isn't a Cube. 

532 """ 

533 warnings.warn( 

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

535 DeprecationWarning, 

536 stacklevel=2, 

537 ) 

538 # Get suitable filename. 

539 if filename is None: 

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

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

542 filename = filename + ".png" 

543 

544 # Check cube is suitable. 

545 cube = _check_single_cube(cube) 

546 try: 

547 cube.coord(coordinate) 

548 except iris.exceptions.CoordinateNotFoundError as err: 

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

550 

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

552 _make_plot_html_page([filename]) 

553 return cube 

554 

555 

556# TODO: Expand function to handle ensemble data. 

557# line_coordinate: str, optional 

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

559# ``"realization"``. 

560def plot_line_series( 

561 cube: iris.cube.Cube, 

562 filename: str = None, 

563 series_coordinate: str = "time", 

564 # line_coordinate: str = "realization", 

565 **kwargs, 

566) -> iris.cube.Cube: 

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

568 

569 The cube must be 1D. 

570 

571 Parameters 

572 ---------- 

573 cube: Cube 

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

575 filename: str, optional 

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

577 to the recipe name. 

578 series_coordinate: str, optional 

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

580 coordinate must exist in the cube. 

581 

582 Returns 

583 ------- 

584 Cube 

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

586 

587 Raises 

588 ------ 

589 ValueError 

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

591 TypeError 

592 If the cube isn't a single cube. 

593 """ 

594 # Check cube is right shape. 

595 cube = _check_single_cube(cube) 

596 try: 

597 coord = cube.coord(series_coordinate) 

598 except iris.exceptions.CoordinateNotFoundError as err: 

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

600 if cube.ndim > 1: 

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

602 

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

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

605 if filename is None: 

606 filename = slugify(title) 

607 

608 # Add file extension. 

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

610 

611 # Do the actual plotting. 

612 _plot_and_save_line_series(cube, coord, plot_filename, title) 

613 

614 # Add list of plots to plot metadata. 

615 plot_index = _append_to_plot_index([plot_filename]) 

616 

617 # Make a page to display the plots. 

618 _make_plot_html_page(plot_index) 

619 

620 return cube 

621 

622 

623def plot_vertical_line_series( 

624 cube: iris.cube.Cube, 

625 filename: str = None, 

626 series_coordinate: str = "pressure", 

627 sequence_coordinate: str = "time", 

628 # line_coordinate: str = "realization", 

629 **kwargs, 

630) -> iris.cube.Cube: 

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

632 

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

634 then a sequence of plots will be produced. 

635 

636 The cube must be 1D. 

637 

638 Parameters 

639 ---------- 

640 cube: Cube 

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

642 filename: str, optional 

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

644 to the recipe name. 

645 series_coordinate: str, optional 

646 Coordinate to plot on the y-axis. Defaults to ``pressure``. 

647 This coordinate must exist in the cube. 

648 sequence_coordinate: str, optional 

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

650 This coordinate must exist in the cube. 

651 

652 Returns 

653 ------- 

654 Cube 

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

656 

657 Raises 

658 ------ 

659 ValueError 

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

661 TypeError 

662 If the cube isn't a single cube. 

663 """ 

664 # Ensure we've got a single cube. 

665 cube = _check_single_cube(cube) 

666 

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

668 try: 

669 coord = cube.coord(series_coordinate) 

670 except iris.exceptions.CoordinateNotFoundError as err: 

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

672 

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

674 # for the time slider option. 

675 try: 

676 cube.coord(sequence_coordinate) 

677 except iris.exceptions.CoordinateNotFoundError as err: 

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

679 

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

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

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

683 filename = slugify(recipe_title) 

684 

685 # Make vertical line plot 

686 plotting_func = _plot_and_save_vertical_line_series 

687 

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

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

690 # the sequence and if applicable postage stamp coordinate. 

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

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

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

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

695 

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

697 plot_index = [] 

698 for cube_slice in cube.slices_over(sequence_coordinate): 

699 # Use sequence value so multiple sequences can merge. 

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

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

702 coord = cube_slice.coord(series_coordinate) 

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

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

705 # Do the actual plotting. 

706 plotting_func( 

707 cube_slice, 

708 coord, 

709 plot_filename, 

710 title=title, 

711 vmin=vmin, 

712 vmax=vmax, 

713 ) 

714 plot_index.append(plot_filename) 

715 

716 # Add list of plots to plot metadata. 

717 complete_plot_index = _append_to_plot_index(plot_index) 

718 

719 # Make a page to display the plots. 

720 _make_plot_html_page(complete_plot_index) 

721 

722 return cube