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

229 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-17 13:24 +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_additive_bias_model_obs( 

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

250): 

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

252 

253 Acts as a wrapper around the ME 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 ME. 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.CubeList 

269 A cube list containing the ME between the models and observation cube. 

270 """ 

271 additive_bias_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 additive_bias = scores_additive_bias( 

285 input_cubelist, preserved_coordinates, obs_model_comparison=True 

286 ) 

287 model_name = model.attributes["model_name"] 

288 additive_bias.attributes["model_name"] = model_name 

289 additive_bias_cubes.append(additive_bias) 

290 

291 return additive_bias_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(cubes: CubeList, preserved_coordinates: list[str] | str | None = None): 

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

383 

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

385 

386 Parameters 

387 ---------- 

388 cubes: iris.cube.CubeList 

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

390 this can be an analysis and the model. 

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

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

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

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

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

396 use `None`. The default is `None`. 

397 

398 Returns 

399 ------- 

400 scores_cube: iris.cube.Cube 

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

402 """ 

403 base, other = _sort_cubes_for_verification(cubes) 

404 

405 # Copy the coordinates of the input cubes. 

406 other_xr = xr.DataArray.from_iris(other) 

407 base_xr = xr.DataArray.from_iris(base) 

408 preserve_dims = _resolve_preserve_dims(other, other_xr, preserved_coordinates) 

409 

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

411 # apply scores, and then transform it back. 

412 scores_cube = xr.DataArray.to_iris( 

413 scores.continuous.mae( 

414 other_xr, 

415 base_xr, 

416 preserve_dims=preserve_dims, 

417 ) 

418 ) 

419 

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

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

422 try: 

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

424 base_time = base.coord("time") 

425 time_vals = ( 

426 base_time.bounds.flatten() 

427 if base_time.has_bounds() 

428 else base_time.points 

429 ) 

430 t_start = float(time_vals[0]) 

431 t_end = float(time_vals[-1]) 

432 t_mid = 0.5 * (t_start + t_end) 

433 

434 scores_cube.add_aux_coord( 

435 iris.coords.AuxCoord( 

436 t_mid, 

437 standard_name=base_time.standard_name, 

438 long_name=base_time.long_name, 

439 var_name=base_time.var_name, 

440 units=base_time.units, 

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

442 attributes=base_time.attributes.copy(), 

443 ) 

444 ) 

445 except iris.exceptions.CoordinateNotFoundError: 

446 pass 

447 

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

449 return scores_cube 

450 

451 

452def scores_additive_bias( 

453 cubes: CubeList, 

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

455 obs_model_comparison: bool = False, 

456): 

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

458 

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

460 

461 Parameters 

462 ---------- 

463 cubes: iris.cube.CubeList 

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

465 this can be an analysis and the model. 

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

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

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

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

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

471 use `None`. The default is `None`. 

472 

473 Returns 

474 ------- 

475 scores_cube: iris.cube.Cube 

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

477 """ 

478 if obs_model_comparison: 

479 for cb in cubes: 

480 if "observed" in cb.long_name: 

481 base = cb 

482 else: 

483 other = cb 

484 else: 

485 base, other = _sort_cubes_for_verification(cubes) 

486 

487 # Copy the coordinates of the input cubes. 

488 other_xr = xr.DataArray.from_iris(other) 

489 base_xr = xr.DataArray.from_iris(base) 

490 preserve_dims = _resolve_preserve_dims(other, other_xr, preserved_coordinates) 

491 

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

493 # apply scores, and then transform it back. 

494 scores_cube = xr.DataArray.to_iris( 

495 scores.continuous.additive_bias( 

496 other_xr, 

497 base_xr, 

498 preserve_dims=preserve_dims, 

499 ) 

500 ) 

501 

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

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

504 try: 

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

506 base_time = base.coord("time") 

507 time_vals = ( 

508 base_time.bounds.flatten() 

509 if base_time.has_bounds() 

510 else base_time.points 

511 ) 

512 t_start = float(time_vals[0]) 

513 t_end = float(time_vals[-1]) 

514 t_mid = 0.5 * (t_start + t_end) 

515 

516 scores_cube.add_aux_coord( 

517 iris.coords.AuxCoord( 

518 t_mid, 

519 standard_name=base_time.standard_name, 

520 long_name=base_time.long_name, 

521 var_name=base_time.var_name, 

522 units=base_time.units, 

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

524 attributes=base_time.attributes.copy(), 

525 ) 

526 ) 

527 except iris.exceptions.CoordinateNotFoundError: 

528 pass 

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

530 return scores_cube 

531 

532 

533def scores_correlation_pearsonr( 

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

535): 

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

537 

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

539 

540 Parameters 

541 ---------- 

542 cubes: iris.cube.CubeList 

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

544 this can be an analysis and the model. 

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

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

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

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

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

550 use `None`. The default is `None`. 

551 

552 Returns 

553 ------- 

554 scores_cube: iris.cube.Cube 

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

556 """ 

557 base, other = _sort_cubes_for_verification(cubes) 

558 

559 # Copy the coordinates of the input cubes. 

560 other_xr = xr.DataArray.from_iris(other) 

561 base_xr = xr.DataArray.from_iris(base) 

562 preserve_dims = _resolve_preserve_dims(other, other_xr, preserved_coordinates) 

563 

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

565 # apply scores, and then transform it back. 

566 scores_cube = xr.DataArray.to_iris( 

567 scores.continuous.correlation.pearsonr( 

568 other_xr, 

569 base_xr, 

570 preserve_dims=preserve_dims, 

571 ) 

572 ) 

573 

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

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

576 try: 

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

578 base_time = base.coord("time") 

579 time_vals = ( 

580 base_time.bounds.flatten() 

581 if base_time.has_bounds() 

582 else base_time.points 

583 ) 

584 t_start = float(time_vals[0]) 

585 t_end = float(time_vals[-1]) 

586 t_mid = 0.5 * (t_start + t_end) 

587 

588 scores_cube.add_aux_coord( 

589 iris.coords.AuxCoord( 

590 t_mid, 

591 standard_name=base_time.standard_name, 

592 long_name=base_time.long_name, 

593 var_name=base_time.var_name, 

594 units=base_time.units, 

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

596 attributes=base_time.attributes.copy(), 

597 ) 

598 ) 

599 except iris.exceptions.CoordinateNotFoundError: 

600 pass 

601 

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

603 return scores_cube 

604 

605 

606def scores_crps_for_ensemble( 

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

608) -> iris.Constraint: 

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

610 

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

612 

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

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

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

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

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

618 

619 See [CRPS]_ for further information. 

620 

621 Parameters 

622 ---------- 

623 cubes: iris.cube.Cube 

624 A Cube containing ensembles data 

625 

626 Returns 

627 ------- 

628 crps: iris.cube.Cube 

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

630 """ 

631 if control_member != 0: 

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

633 

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

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

636 logger.warning( 

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

638 ) 

639 control_member = new_control_member 

640 

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

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

643 

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

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

646 

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

648 ens_mem = cubes.extract( 

649 generate_remove_single_ensemble_member_constraint(control_member) 

650 ) 

651 

652 # Realising the data in advance provides a large speedup 

653 _ = ctrl.data 

654 _ = ens_mem.data 

655 del _ 

656 

657 ctrl = xr.DataArray.from_iris(ctrl) 

658 ens_mem = xr.DataArray.from_iris(ens_mem) 

659 

660 crps = xr.DataArray.to_iris( 

661 scores.probability.crps_for_ensemble( 

662 ens_mem, 

663 ctrl, 

664 ensemble_member_dim="realization", 

665 method=method, 

666 preserve_dims="time", 

667 ) 

668 ) 

669 

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

671 _realization_callback(crps) 

672 return crps 

673 

674 

675def scores_pod_model_obs( 

676 cubes: CubeList, 

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

678 threshold: str, 

679 op_func: str, 

680): 

681 r""" 

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

683 

684 Parameters 

685 ---------- 

686 cubes: iris.cube.CubeList 

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

688 preserved_coordinates: list | str | None 

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

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

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

692 threshold: str 

693 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). 

694 op_func: str 

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

696 to generate the mask. 

697 

698 Returns 

699 ------- 

700 cube: iris.cube 

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

702 

703 Notes 

704 ----- 

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

706 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 

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

708 

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

710 

711 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. 

712 

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

714 """ 

715 # Split out model(s) and obs 

716 models = CubeList() 

717 for c in cubes: 

718 if "observed" in c.long_name: 

719 observed = c 

720 else: 

721 models.append(c) 

722 

723 # Setup cubelist to store results 

724 scores_results = iris.cube.CubeList() 

725 

726 # Setup operators greater than, less than. 

727 ops = { 

728 "gt": operator.gt, 

729 "lt": operator.lt, 

730 } 

731 

732 try: 

733 op = ops[op_func] 

734 except KeyError as err: 

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

736 

737 for model in models: 

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

739 other_xr = xr.DataArray.from_iris(model) 

740 base_xr = xr.DataArray.from_iris(observed) 

741 preserve_dims = _resolve_preserve_dims( 

742 observed, other_xr, preserved_coordinates 

743 ) 

744 

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

746 event_operator = scores.categorical.ThresholdEventOperator( 

747 default_event_threshold=float(threshold), default_op_fn=op 

748 ) 

749 

750 # Generate binary fields using the event operator. 

751 forecast_binary, observed_binary = event_operator.make_event_tables( 

752 other_xr, base_xr 

753 ) 

754 

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

756 contingency_manager = scores.categorical.BinaryContingencyManager( 

757 forecast_binary, observed_binary 

758 ).transform(preserve_dims=preserve_dims) 

759 

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

761 scores_cube = xr.DataArray.to_iris( 

762 contingency_manager.probability_of_detection() 

763 ) 

764 

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

766 scores_cube.rename( 

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

768 ) 

769 scores_cube.units = "1" 

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

771 

772 scores_results.append(scores_cube) 

773 

774 return scores_results