Coverage for src/CSET/operators/scoreswrappers.py: 92%

229 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-17 11:00 +0000

1# © Crown copyright, Met Office (2022-2026) 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"""A module containing wrappers for the scores module.""" 

16 

17import logging 

18import operator 

19 

20import iris 

21import iris.exceptions 

22import numpy as np 

23import scores 

24import scores.categorical 

25import scores.continuous 

26import scores.probability 

27import xarray as xr 

28from iris.cube import Cube, CubeList 

29from iris.util import reverse 

30 

31from CSET._common import is_increasing 

32from CSET.operators._utils import fully_equalise_attributes, get_cube_yxcoordname 

33from CSET.operators.constraints import ( 

34 generate_realization_constraint, 

35 generate_remove_single_ensemble_member_constraint, 

36) 

37from CSET.operators.misc import _extract_common_time_points 

38from CSET.operators.read import _realization_callback 

39from CSET.operators.regrid import regrid_onto_cube 

40 

41logger = logging.getLogger(__name__) 

42 

43 

44def _sort_cubes_for_verification(cubes: CubeList): 

45 """Prepare cubes ready for verification in scores. 

46 

47 Parameters 

48 ---------- 

49 cubes: iris.cube.CubeList 

50 A CubeList of exact 2 cubes, one from each model. 

51 

52 Returns 

53 ------- 

54 base: iris.cube.Cube 

55 The cube from the "analysis" in the same format as the other model. 

56 other: iris.cube.Cube 

57 The cube from the model in the same format as the base model. 

58 

59 Raises 

60 ------ 

61 ValueError: "cubes should contain exactly 2 cubes." 

62 If any other number of cubes are present. 

63 

64 Notes 

65 ----- 

66 This operator is used for sorting the data into the correct format. It 

67 is likely going to need to be refactored out of CSET and perhaps moved into 

68 `CSET._utils` given common code between here and `misc.difference`. 

69 """ 

70 # Set cubes into correct format using code from difference operator 

71 if len(cubes) != 2: 

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

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

74 other: Cube = cubes.extract_cube( 

75 iris.Constraint( 

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

77 ) 

78 ) 

79 

80 # If cubes contain a pressure coordinate, ensure it is increasing. 

81 for cube in cubes: 

82 try: 

83 if len(cube.coord("pressure").points) > 2 and not is_increasing( 83 ↛ 86line 83 didn't jump to line 86 because the condition on line 83 was never true

84 cube.coord("pressure").points 

85 ): 

86 reverse(cube, "pressure") 

87 

88 except iris.exceptions.CoordinateNotFoundError: 

89 pass 

90 

91 # Extract just common time points. 

92 base, other = _extract_common_time_points(base, other) 

93 

94 # Get spatial coord names. 

95 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

96 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

97 

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

99 # This is triggered if either 

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

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

102 # errors. 

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

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

105 # for UM and LFRic comparisons. 

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

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

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

109 # given this dependency on regridding. 

110 if ( 

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

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

113 ) or ( 

114 base.long_name 

115 in [ 

116 "eastward_wind_at_10m", 

117 "northward_wind_at_10m", 

118 "northward_wind_at_cell_centres", 

119 "eastward_wind_at_cell_centres", 

120 "zonal_wind_at_pressure_levels", 

121 "meridional_wind_at_pressure_levels", 

122 "potential_vorticity_at_pressure_levels", 

123 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

124 ] 

125 ): 

126 logger.debug("Linear regridding base cube to other grid to compute differences") 

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

128 

129 # Figure out if we are comparing between UM and LFRic; flip array if so. 

130 base_lat_direction = is_increasing(base.coord(base_lat_name).points) 

131 other_lat_direction = is_increasing(other.coord(other_lat_name).points) 

132 if base_lat_direction != other_lat_direction: 132 ↛ 134line 132 didn't jump to line 134 because the condition on line 132 was never true

133 # Copy base cube for correct coordinate information. 

134 other_tmp = base.copy() 

135 # Flip the data and place in the copied cube. 

136 other_tmp.data = np.flip( 

137 other.data, other.coord(other_lat_name).cube_dims(other) 

138 ) 

139 # Use original name and units from the other cube. 

140 other_tmp.rename(other.name()) 

141 other_tmp.units = other.units 

142 # Replace the cube. 

143 other = other_tmp 

144 

145 # Equalise attributes so we can merge. 

146 fully_equalise_attributes(CubeList([base, other])) 

147 logger.debug("Base: %s\nOther: %s", base, other) 

148 

149 return base, other 

150 

151 

152def _resolve_preserve_dims( 

153 cube: Cube, 

154 data_array: xr.DataArray, 

155 preserved_coordinates: list[str] | str | None, 

156) -> list[str] | None: 

157 """Resolve preserve coordinates to xarray dimension names. 

158 

159 The ``scores`` package expects preserve dimensions to match xarray 

160 dimension names. In Iris data, commonly used coordinates such as ``time`` 

161 may be auxiliary coordinates attached to a differently named dimension 

162 (e.g. ``dim0``). This helper maps coordinate names to their underlying 

163 dimension names and helps to convert from iris to xarray coordinate dimension names. 

164 """ 

165 if preserved_coordinates is None: 

166 return None 

167 

168 coord_names = ( 

169 [preserved_coordinates] 

170 if isinstance(preserved_coordinates, str) 

171 else preserved_coordinates 

172 ) 

173 preserve_dims: list[str] = [] 

174 

175 for coord_name in coord_names: 

176 # Already an xarray dimension name. 

177 if coord_name in data_array.dims: 

178 if coord_name not in preserve_dims: 178 ↛ 180line 178 didn't jump to line 180 because the condition on line 178 was always true

179 preserve_dims.append(coord_name) 

180 continue 

181 

182 # Otherwise, map coordinate name to dimension index/indices. 

183 try: 

184 dim_indices = cube.coord_dims(coord_name) 

185 except iris.exceptions.CoordinateNotFoundError: 

186 # Keep original name so scores raises a clear error for unknown keys. 

187 if coord_name not in preserve_dims: 

188 preserve_dims.append(coord_name) 

189 continue 

190 

191 for dim_index in dim_indices: 

192 dim_name = data_array.dims[dim_index] 

193 if dim_name not in preserve_dims: 

194 preserve_dims.append(dim_name) 

195 

196 return preserve_dims 

197 

198 

199def scores_rmse_model_obs( 

200 cubes: CubeList, preserved_coordinates: list[str] | str | None = None 

201): 

202 r"""Calculate the Root Mean Square Error (RMSE) using scores. 

203 

204 Acts as a wrapper around the RMSE calculation from ``scores`` ([scoresa]_, [scoresb]_). 

205 It is calculated as 

206 

207 .. math:: RMSE = \sqrt{\frac{1}{N} \Sigma(forecast - observations)^2} 

208 

209 Parameters 

210 ---------- 

211 cubes: iris.cube.CubeList 

212 A CubeList containing an observation cube and at least one model cube. 

213 preserved_coordinates: list[str] | str | None, default is None. 

214 The coordinates that you wish to preserve in the calculaiton of the 

215 RMSE. For example if you want a map of each time you can preserve 

216 ["time","grid_latitude", "grid_longitude"] or if you want a time series 

217 you can preserve ["time"], if you want to collapse to a single value 

218 use `None`. The default is `None`. 

219 

220 Returns 

221 ------- 

222 scores_cube: iris.cube.Cube 

223 A cube containing the RMSE between the models and observation cube. 

224 """ 

225 rmse_cubes = CubeList() 

226 model_list = CubeList() 

227 

228 for cb in cubes: 

229 if "observed" in cb.long_name: 

230 observed = cb 

231 else: 

232 model_list.append(cb) 

233 

234 for model in model_list: 

235 input_cubelist = CubeList() 

236 input_cubelist.append(observed) 

237 input_cubelist.append(model) 

238 rmse = scores_rmse( 

239 input_cubelist, preserved_coordinates, obs_model_comparison=True 

240 ) 

241 model_name = model.attributes["model_name"] 

242 rmse.attributes["model_name"] = model_name 

243 rmse_cubes.append(rmse) 

244 

245 return rmse_cubes 

246 

247 

248def scores_mae_model_obs( 

249 cubes: CubeList, preserved_coordinates: list[str] | str | None = None 

250): 

251 r"""Calculate the Mean Absolute Error (MAE) using scores. 

252 

253 Acts as a wrapper around the MAE calculation from ``scores`` ([scoresa]_, [scoresb]_). 

254 

255 Parameters 

256 ---------- 

257 cubes: iris.cube.CubeList 

258 A CubeList containing an observation cube and at least one model cube. 

259 preserved_coordinates: list[str] | str | None, default is None. 

260 The coordinates that you wish to preserve in the calculaiton of the 

261 MAE. For example if you want a map of each time you can preserve 

262 ["time","latitude", "longitude"] or if you want a time series 

263 you can preserve ["time"], if you want to collapse to a single value 

264 use `None`. The default is `None`. 

265 

266 Returns 

267 ------- 

268 scores_cube: iris.cube.Cube 

269 A cube containing the MAE between the models and observation cube. 

270 """ 

271 mae_cubes = CubeList() 

272 model_list = CubeList() 

273 

274 for cb in cubes: 

275 if "observed" in cb.long_name: 

276 observed = cb 

277 else: 

278 model_list.append(cb) 

279 

280 for model in model_list: 

281 input_cubelist = CubeList() 

282 input_cubelist.append(observed) 

283 input_cubelist.append(model) 

284 mae = scores_mae( 

285 input_cubelist, preserved_coordinates, obs_model_comparison=True 

286 ) 

287 model_name = model.attributes["model_name"] 

288 mae.attributes["model_name"] = model_name 

289 mae_cubes.append(mae) 

290 

291 return mae_cubes 

292 

293 

294def scores_rmse( 

295 cubes: CubeList, 

296 preserved_coordinates: list[str] | str | None = None, 

297 obs_model_comparison: bool = False, 

298): 

299 r"""Calculate the Root Mean Square Error (RMSE) using scores. 

300 

301 Acts as a wrapper around the RMSE calculation from ``scores`` ([scoresa]_, [scoresb]_). 

302 It is calculated as 

303 

304 .. math:: RMSE = \sqrt{\frac{1}{N} \Sigma(forecast - observations)^2} 

305 

306 Parameters 

307 ---------- 

308 cubes: iris.cube.CubeList 

309 A CubeList containing exactly two cubes: a base and an "other" model, 

310 this can be an analysis and the model. 

311 preserved_coordinates: list[str] | str | None, default is None. 

312 The coordinates (or xarray dimension names) that you wish to preserve in the calculaiton of the 

313 RMSE. For example if you want a map of each time you can preserve 

314 ["time","grid_latitude", "grid_longitude"] or if you want a time series 

315 you can preserve ["time"], if you want to collapse to a single value 

316 use `None`. The default is `None`. 

317 

318 Returns 

319 ------- 

320 scores_cube: iris.cube.Cube 

321 A cube containing the RMSE between the base and other cube. 

322 """ 

323 if obs_model_comparison: 

324 for cb in cubes: 

325 if "observed" in cb.long_name: 

326 base = cb 

327 else: 

328 other = cb 

329 else: 

330 base, other = _sort_cubes_for_verification(cubes) 

331 

332 # Copy the coordinates of the input cubes. 

333 other_xr = xr.DataArray.from_iris(other) 

334 base_xr = xr.DataArray.from_iris(base) 

335 preserve_dims = _resolve_preserve_dims(other, other_xr, preserved_coordinates) 

336 

337 # Scores operates on xarray data arrays, so we transform the iris cube into an array, 

338 # apply scores, and then transform it back. 

339 scores_cube = xr.DataArray.to_iris( 

340 scores.continuous.rmse( 

341 other_xr, 

342 base_xr, 

343 preserve_dims=preserve_dims, 

344 ) 

345 ) 

346 

347 # If time is aggregated out, attach a scalar time coordinate with bounds 

348 # so plotting can display the aggregated period in the title. 

349 try: 

350 if not scores_cube.coords("time"): 

351 base_time = base.coord("time") 

352 time_vals = ( 

353 base_time.bounds.flatten() 

354 if base_time.has_bounds() 

355 else base_time.points 

356 ) 

357 t_start = float(time_vals[0]) 

358 t_end = float(time_vals[-1]) 

359 t_mid = 0.5 * (t_start + t_end) 

360 

361 scores_cube.add_aux_coord( 

362 iris.coords.AuxCoord( 

363 t_mid, 

364 standard_name=base_time.standard_name, 

365 long_name=base_time.long_name, 

366 var_name=base_time.var_name, 

367 units=base_time.units, 

368 bounds=np.array([t_start, t_end]), 

369 attributes=base_time.attributes.copy(), 

370 ) 

371 ) 

372 except iris.exceptions.CoordinateNotFoundError: 

373 pass 

374 

375 scores_cube.rename(f"RMSE_of_{base.name()}") 

376 # if preserved_coordinates == ["grid_latitude", "grid_longitude"]: 

377 # scores_cube.add_aux_coord(time_coord) 

378 return scores_cube 

379 

380 

381def scores_mae( 

382 cubes: CubeList, 

383 preserved_coordinates: list[str] | str | None = None, 

384 obs_model_comparison: bool = False, 

385): 

386 r"""Calculate the Mean Absolute Error (MAE) using scores. 

387 

388 Acts as a wrapper around the MAE calculation from ``scores`` ([scoresa]_, [scoresb]_). 

389 

390 Parameters 

391 ---------- 

392 cubes: iris.cube.CubeList 

393 A CubeList containing exactly two cubes: a base and an "other" model, 

394 this can be an analysis and the model. 

395 preserved_coordinates: list[str] | str | None, default is None. 

396 The coordinates that you wish to preserve in the calculaiton of the 

397 MAE. For example if you want a map of each time you can preserve 

398 ["time","grid_latitude", "grid_longitude"] or if you want a time series 

399 you can preserve ["time"], if you want to collapse to a single value 

400 use `None`. The default is `None`. 

401 

402 Returns 

403 ------- 

404 scores_cube: iris.cube.Cube 

405 A cube containing the MAE between the base and other cube. 

406 """ 

407 if obs_model_comparison: 

408 for cb in cubes: 

409 if "observed" in cb.long_name: 

410 base = cb 

411 else: 

412 other = cb 

413 else: 

414 base, other = _sort_cubes_for_verification(cubes) 

415 

416 # Copy the coordinates of the input cubes. 

417 other_xr = xr.DataArray.from_iris(other) 

418 base_xr = xr.DataArray.from_iris(base) 

419 preserve_dims = _resolve_preserve_dims(other, other_xr, preserved_coordinates) 

420 

421 # Scores operates on xarray data arrays, so we transform the iris cube into an array, 

422 # apply scores, and then transform it back. 

423 scores_cube = xr.DataArray.to_iris( 

424 scores.continuous.mae( 

425 other_xr, 

426 base_xr, 

427 preserve_dims=preserve_dims, 

428 ) 

429 ) 

430 

431 # If time is aggregated out, attach a scalar time coordinate with bounds 

432 # so plotting can display the aggregated period in the title. 

433 try: 

434 if not scores_cube.coords("time"): 

435 base_time = base.coord("time") 

436 time_vals = ( 

437 base_time.bounds.flatten() 

438 if base_time.has_bounds() 

439 else base_time.points 

440 ) 

441 t_start = float(time_vals[0]) 

442 t_end = float(time_vals[-1]) 

443 t_mid = 0.5 * (t_start + t_end) 

444 

445 scores_cube.add_aux_coord( 

446 iris.coords.AuxCoord( 

447 t_mid, 

448 standard_name=base_time.standard_name, 

449 long_name=base_time.long_name, 

450 var_name=base_time.var_name, 

451 units=base_time.units, 

452 bounds=np.array([t_start, t_end]), 

453 attributes=base_time.attributes.copy(), 

454 ) 

455 ) 

456 except iris.exceptions.CoordinateNotFoundError: 

457 pass 

458 

459 scores_cube.rename(f"MAE_of_{base.name()}") 

460 return scores_cube 

461 

462 

463def scores_additive_bias( 

464 cubes: CubeList, preserved_coordinates: list[str] | str | None = None 

465): 

466 r"""Calculate the Additive Bias (Mean Error) using scores. 

467 

468 Acts as a wrapper around the ME calculation from ``scores`` ([scoresa]_, [scoresb]_). 

469 

470 Parameters 

471 ---------- 

472 cubes: iris.cube.CubeList 

473 A CubeList containing exactly two cubes: a base and an "other" model, 

474 this can be an analysis and the model. 

475 preserved_coordinates: list[str] | str | None, default is None. 

476 The coordinates that you wish to preserve in the calculaiton of the 

477 ME. For example if you want a map of each time you can preserve 

478 ["time","grid_latitude", "grid_longitude"] or if you want a time series 

479 you can preserve ["time"], if you want to collapse to a single value 

480 use `None`. The default is `None`. 

481 

482 Returns 

483 ------- 

484 scores_cube: iris.cube.Cube 

485 A cube containing the ME between the base and other cube. 

486 """ 

487 base, other = _sort_cubes_for_verification(cubes) 

488 

489 # Copy the coordinates of the input cubes. 

490 other_xr = xr.DataArray.from_iris(other) 

491 base_xr = xr.DataArray.from_iris(base) 

492 preserve_dims = _resolve_preserve_dims(other, other_xr, preserved_coordinates) 

493 

494 # Scores operates on xarray data arrays, so we transform the iris cube into an array, 

495 # apply scores, and then transform it back. 

496 scores_cube = xr.DataArray.to_iris( 

497 scores.continuous.additive_bias( 

498 other_xr, 

499 base_xr, 

500 preserve_dims=preserve_dims, 

501 ) 

502 ) 

503 

504 # If time is aggregated out, attach a scalar time coordinate with bounds 

505 # so plotting can display the aggregated period in the title. 

506 try: 

507 if not scores_cube.coords("time"): 507 ↛ 531line 507 didn't jump to line 531 because the condition on line 507 was always true

508 base_time = base.coord("time") 

509 time_vals = ( 

510 base_time.bounds.flatten() 

511 if base_time.has_bounds() 

512 else base_time.points 

513 ) 

514 t_start = float(time_vals[0]) 

515 t_end = float(time_vals[-1]) 

516 t_mid = 0.5 * (t_start + t_end) 

517 

518 scores_cube.add_aux_coord( 

519 iris.coords.AuxCoord( 

520 t_mid, 

521 standard_name=base_time.standard_name, 

522 long_name=base_time.long_name, 

523 var_name=base_time.var_name, 

524 units=base_time.units, 

525 bounds=np.array([t_start, t_end]), 

526 attributes=base_time.attributes.copy(), 

527 ) 

528 ) 

529 except iris.exceptions.CoordinateNotFoundError: 

530 pass 

531 scores_cube.rename(f"Additive_Bias_of_{base.name()}") 

532 return scores_cube 

533 

534 

535def scores_correlation_pearsonr( 

536 cubes: CubeList, preserved_coordinates: list[str] | str | None = None 

537): 

538 r"""Calculate the Pearson's Correlation (PC) coefficient using scores. 

539 

540 Acts as a wrapper around the PC calculation from ``scores`` ([scoresa]_, [scoresb]_). 

541 

542 Parameters 

543 ---------- 

544 cubes: iris.cube.CubeList 

545 A CubeList containing exactly two cubes: a base and an "other" model, 

546 this can be an analysis and the model. 

547 preserved_coordinates: list[str] | str | None, default is None. 

548 The coordinates that you wish to preserve in the calculation of the 

549 PC. For example if you want a map of each time you can preserve 

550 ["time","grid_latitude", "grid_longitude"] or if you want a time series 

551 you can preserve ["time"], if you want to collapse to a single value 

552 use `None`. The default is `None`. 

553 

554 Returns 

555 ------- 

556 scores_cube: iris.cube.Cube 

557 A cube containing the PC between the base and other cube. 

558 """ 

559 base, other = _sort_cubes_for_verification(cubes) 

560 

561 # Copy the coordinates of the input cubes. 

562 other_xr = xr.DataArray.from_iris(other) 

563 base_xr = xr.DataArray.from_iris(base) 

564 preserve_dims = _resolve_preserve_dims(other, other_xr, preserved_coordinates) 

565 

566 # Scores operates on xarray data arrays, so we transform the iris cube into an array, 

567 # apply scores, and then transform it back. 

568 scores_cube = xr.DataArray.to_iris( 

569 scores.continuous.correlation.pearsonr( 

570 other_xr, 

571 base_xr, 

572 preserve_dims=preserve_dims, 

573 ) 

574 ) 

575 

576 # If time is aggregated out, attach a scalar time coordinate with bounds 

577 # so plotting can display the aggregated period in the title. 

578 try: 

579 if not scores_cube.coords("time"): 579 ↛ 604line 579 didn't jump to line 604 because the condition on line 579 was always true

580 base_time = base.coord("time") 

581 time_vals = ( 

582 base_time.bounds.flatten() 

583 if base_time.has_bounds() 

584 else base_time.points 

585 ) 

586 t_start = float(time_vals[0]) 

587 t_end = float(time_vals[-1]) 

588 t_mid = 0.5 * (t_start + t_end) 

589 

590 scores_cube.add_aux_coord( 

591 iris.coords.AuxCoord( 

592 t_mid, 

593 standard_name=base_time.standard_name, 

594 long_name=base_time.long_name, 

595 var_name=base_time.var_name, 

596 units=base_time.units, 

597 bounds=np.array([t_start, t_end]), 

598 attributes=base_time.attributes.copy(), 

599 ) 

600 ) 

601 except iris.exceptions.CoordinateNotFoundError: 

602 pass 

603 

604 scores_cube.rename(f"Pearson_Correlation_of_{base.name()}") 

605 return scores_cube 

606 

607 

608def scores_crps_for_ensemble( 

609 cubes: Cube | CubeList, method: str = "ecdf", control_member: int = 0 

610) -> iris.Constraint: 

611 r"""Calculate the CRPS for an ensemble. 

612 

613 Acts as a wrapper around the crps_for_ensemble from ``scores`` ([scoresa]_, [scoresb]_). 

614 

615 Lower CRPS values are better (implies experiment distribution is closer to control distribution/observations), 

616 larger values are worse (implies distributions are dissimilar). 

617 It is applicable across time and spatial scales as the focus is on the distribution of the values. 

618 Default method is ecdf. ecdf is exact value from the empirical distributions, 

619 whereas fair produces an approximated value based on a random sample of the underlying distribution. 

620 

621 See [CRPS]_ for further information. 

622 

623 Parameters 

624 ---------- 

625 cubes: iris.cube.Cube 

626 A Cube containing ensembles data 

627 

628 Returns 

629 ------- 

630 crps: iris.cube.Cube 

631 A cube containing the crps between the ensemble members and the control 

632 """ 

633 if control_member != 0: 

634 logger.warning("control member is usual 0") 

635 

636 if control_member not in cubes.coords("realization")[0].points: 

637 new_control_member = cubes.coords("realization")[0].points[0] 

638 logger.warning( 

639 f"control member value {control_member} out of bounds, defaulting to control member={new_control_member}" 

640 ) 

641 control_member = new_control_member 

642 

643 if cubes.coord("time").shape[0] == 1: 

644 raise ValueError("Cube has only one time point.") 

645 

646 if cubes.coord("realization").shape[0] < 3: 

647 raise ValueError("Cube should have one control member and at least two members") 

648 

649 ctrl = cubes.extract(generate_realization_constraint([control_member])) 

650 ens_mem = cubes.extract( 

651 generate_remove_single_ensemble_member_constraint(control_member) 

652 ) 

653 

654 # Realising the data in advance provides a large speedup 

655 _ = ctrl.data 

656 _ = ens_mem.data 

657 del _ 

658 

659 ctrl = xr.DataArray.from_iris(ctrl) 

660 ens_mem = xr.DataArray.from_iris(ens_mem) 

661 

662 crps = xr.DataArray.to_iris( 

663 scores.probability.crps_for_ensemble( 

664 ens_mem, 

665 ctrl, 

666 ensemble_member_dim="realization", 

667 method=method, 

668 preserve_dims="time", 

669 ) 

670 ) 

671 

672 crps.rename(f"CRPS_of_{cubes[0].name()}") 

673 _realization_callback(crps) 

674 return crps 

675 

676 

677def scores_pod_model_obs( 

678 cubes: CubeList, 

679 preserved_coordinates: list[str] | str | None, 

680 threshold: str, 

681 op_func: str, 

682): 

683 r""" 

684 Compute the Probability of Detection (POD) score using Scores ([scoresa]_ [scoresb]_). 

685 

686 Parameters 

687 ---------- 

688 cubes: iris.cube.CubeList 

689 An iris cubelist containing model(s) and an observation cube. 

690 preserved_coordinates: list | str | None 

691 An object containing which coordinates to preserve in the computation. For example, if cubes contain shape time, point location, 

692 then preserving coordinate 'time' will produce a probability of detection score for each timeslice (shape time). If None, 

693 then it will return a single value score for all times/point locations. 

694 threshold: str 

695 A str containing the threshold to use to generate the binary masks, which subsequently gets turned to a float (but passed as str around the recipe templating). 

696 op_func: str 

697 A string either containing 'lt' for less than or 'gt for greater than, to determine how the threshold is applied to the data 

698 to generate the mask. 

699 

700 Returns 

701 ------- 

702 cube: iris.cube 

703 An iris cube, containing the probability of detection score for further plotting. 

704 

705 Notes 

706 ----- 

707 The probability of detection calculates the proportion of observed events that meet a threshold that were correctly forecast by the model. 

708 For example, if threshold is 290K and op_func is gt (greater than), and at some station a temperature was recorded as 292K and the model produced 

709 295k, that would be a positive hit. It does not take into account how far above/below a threshold a model forecasts. 

710 

711 It is calculated as .. math:: POD = \frac{true positives}{true positives + false negatives} 

712 

713 It is equivalent to the hit rate. Note if there are no events that meet the threshold in model and observations, a POD of zero is returned. 

714 

715 POD produces a range of 0 to 1, where 1 is a perfect score. 

716 """ 

717 # Split out model(s) and obs 

718 models = CubeList() 

719 for c in cubes: 

720 if "observed" in c.long_name: 

721 observed = c 

722 else: 

723 models.append(c) 

724 

725 # Setup cubelist to store results 

726 scores_results = iris.cube.CubeList() 

727 

728 # Setup operators greater than, less than. 

729 ops = { 

730 "gt": operator.gt, 

731 "lt": operator.lt, 

732 } 

733 

734 try: 

735 op = ops[op_func] 

736 except KeyError as err: 

737 raise ValueError(f"Operator {op_func} not supported.") from err 

738 

739 for model in models: 

740 # Convert obs cubes to xarray and resolve preserved dimensions. 

741 other_xr = xr.DataArray.from_iris(model) 

742 base_xr = xr.DataArray.from_iris(observed) 

743 preserve_dims = _resolve_preserve_dims( 

744 observed, other_xr, preserved_coordinates 

745 ) 

746 

747 # Create event operator object using threshold and operator direction. 

748 event_operator = scores.categorical.ThresholdEventOperator( 

749 default_event_threshold=float(threshold), default_op_fn=op 

750 ) 

751 

752 # Generate binary fields using the event operator. 

753 forecast_binary, observed_binary = event_operator.make_event_tables( 

754 other_xr, base_xr 

755 ) 

756 

757 # Create binary contigency manager, as per Scores API, using transform to preserve preserve_dims 

758 contingency_manager = scores.categorical.BinaryContingencyManager( 

759 forecast_binary, observed_binary 

760 ).transform(preserve_dims=preserve_dims) 

761 

762 # Get POD from the contigency manager, and convert back to an iris cube. 

763 scores_cube = xr.DataArray.to_iris( 

764 contingency_manager.probability_of_detection() 

765 ) 

766 

767 # Rename cube so it plots correctly alongside correcting cube units. 

768 scores_cube.rename( 

769 f"Probability_Of_Detection_{op_func}_{threshold}_{observed.name()}" 

770 ) 

771 scores_cube.units = "1" 

772 scores_cube.attributes["model_name"] = model.attributes["model_name"] 

773 

774 scores_results.append(scores_cube) 

775 

776 return scores_results