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

221 statements  

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

23from typing import Union 

24 

25import iris 

26import iris.coords 

27import iris.cube 

28import iris.exceptions 

29import iris.plot as iplt 

30import matplotlib as mpl 

31import matplotlib.pyplot as plt 

32import numpy as np 

33from markdown_it import MarkdownIt 

34 

35from CSET._common import get_recipe_metadata, render_file, slugify 

36from CSET.operators._utils import get_cube_yxcoordname 

37 

38############################ 

39# Private helper functions # 

40############################ 

41 

42 

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

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

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

46 fcntl.flock(fp, fcntl.LOCK_EX) 

47 fp.seek(0) 

48 meta = json.load(fp) 

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

50 complete_plot_index = complete_plot_index + plot_index 

51 meta["plots"] = complete_plot_index 

52 fp.seek(0) 

53 fp.truncate() 

54 json.dump(meta, fp) 

55 return complete_plot_index 

56 

57 

58def _check_single_cube( 

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

60) -> iris.cube.Cube: 

61 """Ensure a single cube is given. 

62 

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

64 otherwise an error is raised. 

65 

66 Parameters 

67 ---------- 

68 cube: Cube | CubeList 

69 The cube to check. 

70 

71 Returns 

72 ------- 

73 cube: Cube 

74 The checked cube. 

75 

76 Raises 

77 ------ 

78 TypeError 

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

80 """ 

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

82 return cube 

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

84 if len(cube) == 1: 

85 return cube[0] 

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

87 

88 

89def _make_plot_html_page(plots: list): 

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

91 # Debug check that plots actually contains some strings. 

92 assert isinstance(plots[0], str) 

93 

94 # Load HTML template file. 

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

96 if sys.version_info.minor >= 12: 

97 operator_files = importlib.resources.files() 

98 else: 

99 import CSET.operators 

100 

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

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

103 

104 # Get some metadata. 

105 meta = get_recipe_metadata() 

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

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

108 

109 # Prepare template variables. 

110 variables = { 

111 "title": title, 

112 "description": description, 

113 "initial_plot": plots[0], 

114 "plots": plots, 

115 "title_slug": slugify(title), 

116 } 

117 

118 # Render template. 

119 html = render_file(template_file, **variables) 

120 

121 # Save completed HTML. 

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

123 fp.write(html) 

124 

125 

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

127 """ 

128 Specify the color map and levels. 

129 

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

131 

132 Parameters 

133 ---------- 

134 colorbar_file: str 

135 Filename of the colorbar dictionary to read. 

136 varname: str 

137 Variable name to extract from the dictionary 

138 

139 """ 

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

141 # placeholder path is used if not found. 

142 colorbar_file = get_recipe_metadata().get( 

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

144 ) 

145 try: 

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

147 colorbar = json.load(fp) 

148 

149 # Specify the colormap for this variable 

150 try: 

151 cmap = colorbar[varname]["cmap"] 

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

153 except KeyError: 

154 cmap = mpl.colormaps["viridis"] 

155 

156 # Specify the colorbar levels for this variable 

157 try: 

158 levels = colorbar[varname]["levels"] 

159 

160 actual_cmap = mpl.cm.get_cmap(cmap) 

161 

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

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

164 except KeyError: 

165 try: 

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

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

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

169 norm = None 

170 except KeyError: 

171 levels = None 

172 norm = None 

173 

174 except FileNotFoundError: 

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

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

177 levels = None 

178 norm = None 

179 cmap = mpl.colormaps["viridis"] 

180 

181 return cmap, levels, norm 

182 

183 

184def _plot_and_save_contour_plot( 

185 cube: iris.cube.Cube, 

186 filename: str, 

187 title: str, 

188 **kwargs, 

189): 

190 """Plot and save a contour plot. 

191 

192 Parameters 

193 ---------- 

194 cube: Cube 

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

196 filename: str 

197 Filename of the plot to write. 

198 title: str 

199 Plot title. 

200 

201 """ 

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

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

204 

205 # Specify the color bar 

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

207 

208 # Filled contour plot of the field. 

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

210 

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

212 axes = plt.gca() 

213 

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

215 try: 

216 get_cube_yxcoordname(cube) 

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

218 except ValueError: 

219 pass 

220 

221 # Add title. 

222 axes.set_title(title, fontsize=16) 

223 

224 # Add colour bar. 

225 cbar = fig.colorbar(contours) 

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

227 

228 # Save plot. 

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

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

231 plt.close(fig) 

232 

233 

234def _plot_and_save_postage_stamp_contour_plot( 

235 cube: iris.cube.Cube, 

236 filename: str, 

237 stamp_coordinate: str, 

238 title: str, 

239 **kwargs, 

240): 

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

242 

243 Parameters 

244 ---------- 

245 cube: Cube 

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

247 filename: str 

248 Filename of the plot to write. 

249 stamp_coordinate: str 

250 Coordinate that becomes different plots. 

251 

252 Raises 

253 ------ 

254 ValueError 

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

256 """ 

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

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

259 

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

261 

262 # Specify the color bar 

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

264 

265 # Make a subplot for each member. 

266 for member, subplot in zip( 

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

268 ): 

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

270 # cartopy GeoAxes generated. 

271 plt.subplot(grid_size, grid_size, subplot) 

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

273 ax = plt.gca() 

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

275 ax.set_axis_off() 

276 

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

278 try: 

279 get_cube_yxcoordname(cube) 

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

281 except ValueError: 

282 pass 

283 

284 # Put the shared colorbar in its own axes. 

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

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

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

288 

289 # Overall figure title. 

290 fig.suptitle(title) 

291 

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

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

294 plt.close(fig) 

295 

296 

297def _plot_and_save_line_series( 

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

299): 

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

301 

302 Parameters 

303 ---------- 

304 cube: Cube 

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

306 coord: Coord 

307 Coordinate to plot on x-axis. 

308 filename: str 

309 Filename of the plot to write. 

310 title: str 

311 Plot title. 

312 """ 

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

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

315 ax = plt.gca() 

316 

317 # Add some labels and tweak the style. 

318 ax.set( 

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

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

321 title=title, 

322 ) 

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

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

325 ax.autoscale() 

326 

327 # Save plot. 

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

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

330 plt.close(fig) 

331 

332 

333def _plot_and_save_vertical_line_series( 

334 cube: iris.cube.Cube, 

335 coord: iris.coords.Coord, 

336 filename: str, 

337 series_coordinate: 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 series_coordinate: str 

354 Coordinate to use as vertical axis. 

355 title: str 

356 Plot title. 

357 vmin: float 

358 Minimum value for the x-axis. 

359 vmax: float 

360 Maximum value for the x-axis. 

361 """ 

362 # plot the vertical pressure axis using log scale 

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

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

365 ax = plt.gca() 

366 

367 # Special handling for pressure level data. 

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

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

370 ax.invert_yaxis() 

371 ax.set_yscale("log") 

372 

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

374 y_tick_labels = [ 

375 "1000", 

376 "850", 

377 "700", 

378 "500", 

379 "300", 

380 "200", 

381 "100", 

382 "50", 

383 "30", 

384 "20", 

385 "10", 

386 ] 

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

388 

389 # Set y-axis limits and ticks. 

390 ax.set_ylim(1100, 100) 

391 

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

393 # and lfric uses full_levels as coordinate. 

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

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

396 y_ticks = cube.coord(series_coordinate).points 

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

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

399 

400 ax.set_yticks(y_ticks) 

401 ax.set_yticklabels(y_tick_labels) 

402 

403 # set x-axis limits 

404 ax.set_xlim(vmin, vmax) 

405 

406 # Add some labels and tweak the style. 

407 ax.set( 

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

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

410 title=title, 

411 ) 

412 

413 # Save plot. 

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

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

416 plt.close(fig) 

417 

418 

419#################### 

420# Public functions # 

421#################### 

422 

423 

424def spatial_contour_plot( 

425 cube: iris.cube.Cube, 

426 filename: str = None, 

427 sequence_coordinate: str = "time", 

428 stamp_coordinate: str = "realization", 

429 **kwargs, 

430) -> iris.cube.Cube: 

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

432 

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

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

435 is present then postage stamp plots will be produced. 

436 

437 Parameters 

438 ---------- 

439 cube: Cube 

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

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

442 plotted sequentially and/or as postage stamp plots. 

443 filename: str, optional 

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

445 to the recipe name. 

446 sequence_coordinate: str, optional 

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

448 This coordinate must exist in the cube. 

449 stamp_coordinate: str, optional 

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

451 ``"realization"``. 

452 

453 Returns 

454 ------- 

455 Cube 

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

457 

458 Raises 

459 ------ 

460 ValueError 

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

462 TypeError 

463 If the cube isn't a single cube. 

464 """ 

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

466 

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

468 if filename is None: 

469 filename = slugify(recipe_title) 

470 

471 # Ensure we've got a single cube. 

472 cube = _check_single_cube(cube) 

473 

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

475 # single point. 

476 plotting_func = _plot_and_save_contour_plot 

477 try: 

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

479 plotting_func = _plot_and_save_postage_stamp_contour_plot 

480 except iris.exceptions.CoordinateNotFoundError: 

481 pass 

482 

483 try: 

484 cube.coord(sequence_coordinate) 

485 except iris.exceptions.CoordinateNotFoundError as err: 

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

487 

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

489 plot_index = [] 

490 for cube_slice in cube.slices_over(sequence_coordinate): 

491 # Use sequence value so multiple sequences can merge. 

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

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

494 coord = cube_slice.coord(sequence_coordinate) 

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

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

497 # Do the actual plotting. 

498 plotting_func( 

499 cube_slice, 

500 plot_filename, 

501 stamp_coordinate=stamp_coordinate, 

502 title=title, 

503 ) 

504 plot_index.append(plot_filename) 

505 

506 # Add list of plots to plot metadata. 

507 complete_plot_index = _append_to_plot_index(plot_index) 

508 

509 # Make a page to display the plots. 

510 _make_plot_html_page(complete_plot_index) 

511 

512 return cube 

513 

514 

515# TODO: Expand function to handle ensemble data. 

516# line_coordinate: str, optional 

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

518# ``"realization"``. 

519def plot_line_series( 

520 cube: iris.cube.Cube, 

521 filename: str = None, 

522 series_coordinate: str = "time", 

523 # line_coordinate: str = "realization", 

524 **kwargs, 

525) -> iris.cube.Cube: 

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

527 

528 The cube must be 1D. 

529 

530 Parameters 

531 ---------- 

532 cube: Cube 

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

534 filename: str, optional 

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

536 to the recipe name. 

537 series_coordinate: str, optional 

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

539 coordinate must exist in the cube. 

540 

541 Returns 

542 ------- 

543 Cube 

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

545 

546 Raises 

547 ------ 

548 ValueError 

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

550 TypeError 

551 If the cube isn't a single cube. 

552 """ 

553 # Check cube is right shape. 

554 cube = _check_single_cube(cube) 

555 try: 

556 coord = cube.coord(series_coordinate) 

557 except iris.exceptions.CoordinateNotFoundError as err: 

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

559 if cube.ndim > 1: 

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

561 

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

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

564 if filename is None: 

565 filename = slugify(title) 

566 

567 # Add file extension. 

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

569 

570 # Do the actual plotting. 

571 _plot_and_save_line_series(cube, coord, plot_filename, title) 

572 

573 # Add list of plots to plot metadata. 

574 plot_index = _append_to_plot_index([plot_filename]) 

575 

576 # Make a page to display the plots. 

577 _make_plot_html_page(plot_index) 

578 

579 return cube 

580 

581 

582def plot_vertical_line_series( 

583 cube: iris.cube.Cube, 

584 filename: str = None, 

585 series_coordinate: str = "model_level_number", 

586 sequence_coordinate: str = "time", 

587 # line_coordinate: str = "realization", 

588 **kwargs, 

589) -> iris.cube.Cube: 

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

591 

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

593 then a sequence of plots will be produced. 

594 

595 The cube must be 1D. 

596 

597 Parameters 

598 ---------- 

599 cube: Cube 

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

601 filename: str, optional 

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

603 to the recipe name. 

604 series_coordinate: str, optional 

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

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

607 for LFRic. Defaults to ``model_level_number``. 

608 This coordinate must exist in the cube. 

609 sequence_coordinate: str, optional 

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

611 This coordinate must exist in the cube. 

612 

613 Returns 

614 ------- 

615 Cube 

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

617 

618 Raises 

619 ------ 

620 ValueError 

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

622 TypeError 

623 If the cube isn't a single cube. 

624 """ 

625 # Ensure we've got a single cube. 

626 cube = _check_single_cube(cube) 

627 

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

629 try: 

630 coord = cube.coord(series_coordinate) 

631 except iris.exceptions.CoordinateNotFoundError as err: 

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

633 

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

635 # for the time slider option. 

636 try: 

637 cube.coord(sequence_coordinate) 

638 except iris.exceptions.CoordinateNotFoundError as err: 

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

640 

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

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

643 if filename is None: 

644 filename = slugify(recipe_title) 

645 

646 # Make vertical line plot 

647 plotting_func = _plot_and_save_vertical_line_series 

648 

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

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

651 # the sequence and if applicable postage stamp coordinate. 

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

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

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

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

656 

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

658 plot_index = [] 

659 for cube_slice in cube.slices_over(sequence_coordinate): 

660 # Use sequence value so multiple sequences can merge. 

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

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

663 coord = cube_slice.coord(series_coordinate) 

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

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

666 # Do the actual plotting. 

667 plotting_func( 

668 cube_slice, 

669 coord, 

670 plot_filename, 

671 series_coordinate, 

672 title=title, 

673 vmin=vmin, 

674 vmax=vmax, 

675 ) 

676 plot_index.append(plot_filename) 

677 

678 # Add list of plots to plot metadata. 

679 complete_plot_index = _append_to_plot_index(plot_index) 

680 

681 # Make a page to display the plots. 

682 _make_plot_html_page(complete_plot_index) 

683 

684 return cube