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

225 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-20 14:01 +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.coords 

22import iris.exceptions 

23import numpy as np 

24import scores 

25import scores.categorical 

26import scores.continuous 

27import scores.probability 

28import xarray as xr 

29from iris.cube import Cube, CubeList 

30from iris.util import reverse 

31 

32from CSET._common import is_increasing 

33from CSET.operators._utils import ( 

34 ensure_common_time_points, 

35 fully_equalise_attributes, 

36 get_cube_yxcoordname, 

37) 

38from CSET.operators.constraints import ( 

39 generate_realization_constraint, 

40 generate_remove_single_ensemble_member_constraint, 

41) 

42from CSET.operators.read import _realization_callback 

43from CSET.operators.regrid import regrid_onto_cube 

44 

45logger = logging.getLogger(__name__) 

46 

47 

48def scores_rmse( 

49 cubes: CubeList, 

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

51) -> CubeList: 

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

53 

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

55 It is calculated as 

56 

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

58 

59 Parameters 

60 ---------- 

61 cubes: iris.cube.CubeList 

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

63 this can be an analysis and the model. 

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

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

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

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

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

69 use `None`. The default is `None`. 

70 

71 Returns 

72 ------- 

73 scores_cubelist: iris.cube.CubeList 

74 A cubelist containing the RMSE between the base and other cube. 

75 """ 

76 scores_cubelist = CubeList() 

77 

78 base, others = _split_base_and_other(cubes) 

79 

80 for other in others: 

81 base, other = _process_cubes_for_verification(base, other) 

82 

83 scores_cube = _make_scores_cube(base, other, "rmse", preserved_coordinates) 

84 

85 scores_cubelist.append(scores_cube) 

86 

87 return scores_cubelist[0] if len(scores_cubelist) == 1 else scores_cubelist 

88 

89 

90def scores_mae( 

91 cubes: CubeList, 

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

93) -> CubeList: 

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

95 

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

97 

98 Parameters 

99 ---------- 

100 cubes: iris.cube.CubeList 

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

102 this can be an analysis and the model. 

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

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

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

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

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

108 use `None`. The default is `None`. 

109 

110 Returns 

111 ------- 

112 scores_cubelist: iris.cube.CubeList 

113 A cubelist containing the MAE between the base and other cube(s). 

114 """ 

115 scores_cubelist = CubeList() 

116 base, others = _split_base_and_other(cubes) 

117 

118 for other in others: 

119 base, other = _process_cubes_for_verification(base, other) 

120 scores_cube = _make_scores_cube(base, other, "mae", preserved_coordinates) 

121 scores_cubelist.append(scores_cube) 

122 

123 return scores_cubelist[0] if len(scores_cubelist) == 1 else scores_cubelist 

124 

125 

126def scores_additive_bias( 

127 cubes: CubeList, 

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

129) -> CubeList: 

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

131 

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

133 

134 Parameters 

135 ---------- 

136 cubes: iris.cube.CubeList 

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

138 this can be an analysis and the model. 

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

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

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

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

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

144 use `None`. The default is `None`. 

145 

146 Returns 

147 ------- 

148 scores_cubelist: iris.cube.CubeList 

149 A cubelist containing the ME between the base and other cube(s). 

150 """ 

151 scores_cubelist = CubeList() 

152 base, others = _split_base_and_other(cubes) 

153 

154 for other in others: 

155 base, other = _process_cubes_for_verification(base, other) 

156 

157 scores_cube = _make_scores_cube( 

158 base, other, "additive_bias", preserved_coordinates 

159 ) 

160 

161 scores_cubelist.append(scores_cube) 

162 

163 return scores_cubelist[0] if len(scores_cubelist) == 1 else scores_cubelist 

164 

165 

166def scores_correlation_pearsonr( 

167 cubes: CubeList, 

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

169) -> CubeList: 

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

171 

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

173 

174 Parameters 

175 ---------- 

176 cubes: iris.cube.CubeList 

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

178 this can be an analysis and the model. 

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

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

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

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

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

184 use `None`. The default is `None`. 

185 

186 Returns 

187 ------- 

188 scores_cubelist: iris.cube.CubeList 

189 A cubelist containing the PC between the base and other cube(s). 

190 """ 

191 scores_cubelist = CubeList() 

192 base, others = _split_base_and_other(cubes) 

193 

194 for other in others: 

195 base, other = _process_cubes_for_verification(base, other) 

196 

197 scores_cube = _make_scores_cube( 

198 base, other, "pearson_correlation", preserved_coordinates 

199 ) 

200 

201 scores_cubelist.append(scores_cube) 

202 

203 return scores_cubelist[0] if len(scores_cubelist) == 1 else scores_cubelist 

204 

205 

206def scores_crps_for_ensemble( 

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

208) -> Cube: 

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

210 

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

212 

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

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

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

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

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

218 

219 See [CRPS]_ for further information. 

220 

221 Parameters 

222 ---------- 

223 cubes: iris.cube.Cube 

224 A Cube containing ensembles data 

225 

226 method: str ["ecfd" or "fair"] 

227 Determines the method to use for calculating the CRPS. Defaults to "ecdf". 

228 

229 control_member: int 

230 What the realisation the control member of the ensemble is. Defaults to 0. Sometimes this is 1. 

231 

232 Returns 

233 ------- 

234 crps: iris.cube.Cube 

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

236 """ 

237 if control_member != 0: 

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

239 

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

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

242 logger.warning( 

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

244 ) 

245 control_member = new_control_member 

246 

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

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

249 

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

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

252 

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

254 ens_mem = cubes.extract( 

255 generate_remove_single_ensemble_member_constraint(control_member) 

256 ) 

257 

258 # Realising the data in advance provides a large speedup 

259 _ = ctrl.data 

260 _ = ens_mem.data 

261 del _ 

262 

263 ctrl = xr.DataArray.from_iris(ctrl) 

264 ens_mem = xr.DataArray.from_iris(ens_mem) 

265 

266 crps = xr.DataArray.to_iris( 

267 scores.probability.crps_for_ensemble( 

268 ens_mem, 

269 ctrl, 

270 ensemble_member_dim="realization", 

271 method=method, 

272 preserve_dims="time", 

273 ) 

274 ) 

275 

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

277 _realization_callback(crps) 

278 return crps 

279 

280 

281def _scores_categorical_metric( 

282 cubes: CubeList, 

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

284 threshold: str, 

285 op_func: str, 

286 metric: str, 

287) -> CubeList: 

288 """ 

289 Prepare cubes for computing categorical metrics using Scores. 

290 

291 Parameters 

292 ---------- 

293 cubes: iris.cube.CubeList 

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

295 preserved_coordinates: list | str | None 

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

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

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

299 threshold: str 

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

301 op_func: str 

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

303 to generate the mask. 

304 metric: str 

305 The scores metric to compute. 

306 

307 Returns 

308 ------- 

309 scores_results: iris.cube.CubeList 

310 An iris cubelist, containing the scores metric for each model for further plotting. 

311 

312 """ 

313 # Split obs/models 

314 models = CubeList() 

315 for c in cubes: 

316 if "observed" in c.long_name: 

317 observed = c 

318 else: 

319 models.append(c) 

320 

321 ops = { 

322 "gt": operator.gt, 

323 "lt": operator.lt, 

324 } 

325 

326 # Check if this exists. 

327 try: 

328 op = ops[op_func] 

329 except KeyError as err: 

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

331 

332 scores_results = CubeList() 

333 

334 for model in models: 

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

336 other_xr = xr.DataArray.from_iris(model) 

337 base_xr = xr.DataArray.from_iris(observed) 

338 preserve_dims = _resolve_preserve_dims( 

339 observed, other_xr, preserved_coordinates 

340 ) 

341 

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

343 event_operator = scores.categorical.ThresholdEventOperator( 

344 default_event_threshold=float(threshold), default_op_fn=op 

345 ) 

346 

347 # Generate binary fields using the event operator. 

348 forecast_binary, observed_binary = event_operator.make_event_tables( 

349 other_xr, base_xr 

350 ) 

351 

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

353 contingency_manager = scores.categorical.BinaryContingencyManager( 

354 forecast_binary, observed_binary 

355 ).transform(preserve_dims=preserve_dims) 

356 

357 # Compute required categorical score. 

358 if metric == "pod": 

359 result = contingency_manager.probability_of_detection() 

360 name = "Probability_Of_Detection" 

361 

362 elif metric == "ets": 

363 result = contingency_manager.equitable_threat_score() 

364 name = "Equitable_Threat_Score" 

365 

366 elif metric == "fb": 

367 result = contingency_manager.frequency_bias() 

368 name = "Frequency_Bias" 

369 

370 elif metric == "pfd": 370 ↛ 375line 370 didn't jump to line 375 because the condition on line 370 was always true

371 result = contingency_manager.probability_of_false_detection() 

372 name = "Probability_Of_False_Detection" 

373 

374 else: 

375 raise ValueError(f"Unknown metric {metric}") 

376 

377 scores_cube = xr.DataArray.to_iris(result) 

378 _fix_spatial_coord(cubes[0], scores_cube) 

379 scores_cube.rename(f"{name}_{op_func}_{threshold}_{observed.name()}") 

380 scores_cube.units = "1" 

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

382 

383 scores_results.append(scores_cube) 

384 

385 return scores_results 

386 

387 

388def scores_pod( 

389 cubes, 

390 preserved_coordinates, 

391 threshold, 

392 op_func, 

393) -> CubeList: 

394 r""" 

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

396 

397 Parameters 

398 ---------- 

399 cubes: iris.cube.CubeList 

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

401 preserved_coordinates: list | str | None 

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

403 then preserving coordinate 'time' will produce the equitable threat score for each timeslice (shape time). If None, 

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

405 threshold: str 

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

407 op_func: str 

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

409 to generate the mask. 

410 

411 Returns 

412 ------- 

413 iris.cube.CubeList 

414 An iris cubelist, containing the probability of detection score for each model for further plotting. 

415 

416 

417 Notes 

418 ----- 

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

420 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 

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

422 

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

424 

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

426 

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

428 """ 

429 return _scores_categorical_metric( 

430 cubes, 

431 preserved_coordinates, 

432 threshold, 

433 op_func, 

434 "pod", 

435 ) 

436 

437 

438def scores_ets( 

439 cubes: CubeList, 

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

441 threshold: str, 

442 op_func: str, 

443) -> CubeList: 

444 r""" 

445 Compute the Equitable Threat Score (ETS) score using Scores ([scoresa]_ [scoresb]_). 

446 

447 Parameters 

448 ---------- 

449 cubes: iris.cube.CubeList 

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

451 preserved_coordinates: list | str | None 

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

453 then preserving coordinate 'time' will produce the equitable threat score for each timeslice (shape time). If None, 

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

455 threshold: str 

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

457 op_func: str 

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

459 to generate the mask. 

460 

461 Returns 

462 ------- 

463 iris.cube.CubeList 

464 An iris cubelist, containing the probability of detection score for each model for further plotting. 

465 

466 Notes 

467 ----- 

468 The Equitable Threat Score (ETS) evaluates the accuracy of forecasts for events that meet a specified threshold, 

469 hile accounting for correct forecasts that could occur purely by chance. Unlike the Probability of Detection (POD), 

470 ETS considers hits, misses, and false alarms, providing a more balanced assessment of forecast skill. 

471 

472 For example, if the threshold is 290 K and op_func is gt (greater than), an observation of 292 K and a forecast of 295 K 

473 would be counted as a hit. ETS adjusts the total number of hits by removing the number of hits expected due to random chance. 

474 

475 It is calculated as: 

476 

477 .. math:: 

478 

479 ETS = \frac{hits - hits_{random}} 

480 {hits + misses + false\ alarms - hits_{random}} 

481 

482 where 

483 

484 hits_{random} = \frac{(hits + misses)(hits + false\ alarms)}{total count} 

485 

486 ETS ranges from -1/3 to 1, where 1 indicates a perfect forecast, 0 indicates no skill beyond random chance, and negative values indicate worse than 

487 random chance. 

488 """ 

489 return _scores_categorical_metric( 

490 cubes, 

491 preserved_coordinates, 

492 threshold, 

493 op_func, 

494 "ets", 

495 ) 

496 

497 

498def scores_pfd( 

499 cubes: CubeList, 

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

501 threshold: str, 

502 op_func: str, 

503) -> CubeList: 

504 r""" 

505 Compute the Probability of False Detection (PFD) score using Scores ([scoresa]_ [scoresb]_). 

506 

507 Parameters 

508 ---------- 

509 cubes: iris.cube.CubeList 

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

511 preserved_coordinates: list | str | None 

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

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

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

515 threshold: str 

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

517 op_func: str 

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

519 to generate the mask. 

520 

521 Returns 

522 ------- 

523 iris.cube.CubeList 

524 An iris cubelist, containing the probability of false detection score for each model for further plotting. 

525 

526 Notes 

527 ----- 

528 The Probability of False Detection (PFD) measures the proportion of observed non-events 

529 that were incorrectly forecast as events. It is a measure of the false alarm rate and 

530 provides information on how often a forecast system predicts threshold exceedances when 

531 none actually occurred. 

532 

533 For example, if the threshold is 290 K and op_func is gt (greater than), an observation 

534 of 288 K and a forecast of 295 K would be considered a false alarm, since the model 

535 predicted an event but the observed value did not exceed the threshold. 

536 

537 It is calculated as: 

538 

539 .. math:: 

540 

541 POFD = \frac{false\ alarms} 

542 {false\ alarms + true\ negatives} 

543 

544 where 

545 

546 false alarms 

547 Number of occasions where an event was forecast but did not occur. 

548 

549 true negatives 

550 Number of occasions where neither the forecast nor the observations 

551 indicated an event. 

552 

553 PFD ranges from 0 to 1, where 0 indicates a perfect score with no false alarms, 

554 and 1 indicates that every observed non-event was incorrectly forecast as an event. 

555 

556 Lower values are therefore better. 

557 """ 

558 return _scores_categorical_metric( 

559 cubes, 

560 preserved_coordinates, 

561 threshold, 

562 op_func, 

563 "pfd", 

564 ) 

565 

566 

567def scores_frequency_bias( 

568 cubes: CubeList, 

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

570 threshold: str, 

571 op_func: str, 

572) -> CubeList: 

573 r""" 

574 Compute the Frequency Bias (FB) score using Scores ([scoresa]_ [scoresb]_). 

575 

576 Parameters 

577 ---------- 

578 cubes: iris.cube.CubeList 

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

580 preserved_coordinates: list | str | None 

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

582 then preserving coordinate 'time' will produce the frequency bias score for each timeslice (shape time). If None, 

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

584 threshold: str 

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

586 op_func: str 

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

588 to generate the mask. 

589 

590 Returns 

591 ------- 

592 iris.cube.CubeList 

593 An iris cube, containing the frequency bias score for each model for further plotting. 

594 

595 Notes 

596 ----- 

597 The Frequency Bias (FB) measures whether a forecasting system predicts an 

598 event too frequently or too infrequently compared to observations. Unlike 

599 metrics such as the Probability of Detection (POD) or Equitable Threat Score (ETS), 

600 Frequency Bias does not assess the accuracy of forecast locations or timings, 

601 only the overall frequency with which events are forecast. 

602 

603 For example, if the threshold is 290 K and op_func is gt (greater than), and 

604 events exceeding this threshold are forecast twice as often as they are observed, 

605 the frequency bias would be approximately 2. Conversely, if events are forecast 

606 only half as often as they occur, the frequency bias would be approximately 0.5. 

607 

608 It is calculated as: 

609 

610 .. math:: 

611 

612 Frequency\ Bias = \frac{hits + false\ alarms} 

613 {hits + misses} 

614 

615 where 

616 

617 hits 

618 Number of occasions where an event was forecast and observed. 

619 

620 false alarms 

621 Number of occasions where an event was forecast but not observed. 

622 

623 misses 

624 Number of occasions where an event was observed but not forecast. 

625 

626 Frequency Bias ranges from 0 to infinity, where: 

627 

628 * 1 indicates the forecast predicts events at the correct frequency. 

629 * Greater than 1 indicates overforecasting of events. 

630 * Less than 1 indicates underforecasting of events. 

631 

632 A perfect frequency bias score is therefore 1, although a value of 1 does not 

633 necessarily imply a skillful forecast, as hits and false alarms may compensate 

634 for one another. 

635 """ 

636 return _scores_categorical_metric( 

637 cubes, 

638 preserved_coordinates, 

639 threshold, 

640 op_func, 

641 "fb", 

642 ) 

643 

644 

645def _make_scores_cube( 

646 base: Cube, other: Cube, metric: str, preserved_coordinates: list[str] 

647) -> Cube: 

648 r"""Make the scores cube using the given scores metric. 

649 

650 Parameters 

651 ---------- 

652 base: iris.cube.Cube 

653 The cube from the "analysis" or observed cube. 

654 other: iris.cube.Cube 

655 The cube from the model. 

656 

657 metric: str 

658 The scores metric to compute. 

659 

660 preserved_coordinates: list[str] 

661 The list of preserved coordinates given by a user. 

662 

663 Returns 

664 ------- 

665 scores_cube: iris.cube.Cube 

666 The cube containing the calculated scores metric. 

667 

668 

669 """ 

670 other_xr = xr.DataArray.from_iris(other) 

671 base_xr = xr.DataArray.from_iris(base) 

672 preserve_dims = _resolve_preserve_dims(other, other_xr, preserved_coordinates) 

673 

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

675 # apply scores, and then transform it back. 

676 if metric == "rmse": 

677 scores_cube = xr.DataArray.to_iris( 

678 scores.continuous.rmse(other_xr, base_xr, preserve_dims=preserve_dims) 

679 ) 

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

681 elif metric == "mae": 

682 scores_cube = xr.DataArray.to_iris( 

683 scores.continuous.mae(other_xr, base_xr, preserve_dims=preserve_dims) 

684 ) 

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

686 elif metric == "additive_bias": 

687 scores_cube = xr.DataArray.to_iris( 

688 scores.continuous.additive_bias( 

689 other_xr, base_xr, preserve_dims=preserve_dims 

690 ) 

691 ) 

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

693 elif metric == "pearson_correlation": 693 ↛ 701line 693 didn't jump to line 701 because the condition on line 693 was always true

694 scores_cube = xr.DataArray.to_iris( 

695 scores.continuous.correlation.pearsonr( 

696 other_xr, base_xr, preserve_dims=preserve_dims 

697 ) 

698 ) 

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

700 else: 

701 raise ValueError(f"Scores Unknown metric: {metric}") 

702 

703 _fix_spatial_coord(base, scores_cube) 

704 _attach_scaler_time_coord_maybe(scores_cube, base) 

705 model_name = other.attributes["model_name"] 

706 scores_cube.attributes["model_name"] = model_name 

707 return scores_cube 

708 

709 

710def _fix_spatial_coord(base_cube: Cube, scores_cube: Cube): 

711 if not scores_cube.coords("grid_latitude") or not base_cube.coords("grid_latitude"): 

712 return 

713 if not scores_cube.coord("grid_latitude").coord_system: 713 ↛ exitline 713 didn't return from function '_fix_spatial_coord' because the condition on line 713 was always true

714 for xy_name in ("grid_latitude", "grid_longitude"): 

715 scores_cube.coord(xy_name).coord_system = base_cube.coord( 

716 xy_name 

717 ).coord_system 

718 

719 

720def _sort_cube_into_base_and_other(cubes: CubeList) -> tuple[Cube, CubeList]: 

721 r"""Sorts cube into base and other models. 

722 

723 Parameters 

724 ---------- 

725 cubes: iris.cube.CubeList 

726 A CubeList of multiple cubes. One base cube and other model cubes. 

727 

728 Returns 

729 ------- 

730 base: iris.cube.Cube 

731 The cube from the "analysis" or observed cube in the same format as the other model. 

732 others: iris.cube.CubeList 

733 The cube list of containing the cube(s) from the model in the same format as the base model. 

734 

735 """ 

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

737 others: CubeList = cubes.extract( 

738 iris.Constraint( 

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

740 ) 

741 ) 

742 

743 return base, others 

744 

745 

746def _ensure_increasing_pressure_coordinates(cubes: CubeList) -> CubeList: 

747 r"""Ensure the pressure coordinate is increasing. 

748 

749 Parameters 

750 ---------- 

751 cubes: iris.cube.CubeList 

752 A CubeList of n cubes 

753 

754 Returns 

755 ------- 

756 Cubes: iris.cube.CubeList 

757 The original cube list but where each cube is ensured to have an increasing pressure coordinate. 

758 """ 

759 for cube in cubes: 

760 try: 

761 if len(cube.coord("pressure").points) > 2 and not is_increasing( 

762 cube.coord("pressure").points 

763 ): 

764 reverse(cube, "pressure") 

765 

766 except iris.exceptions.CoordinateNotFoundError: 

767 pass 

768 

769 

770def _process_cubes_for_verification(base: Cube, other: Cube) -> tuple[Cube, Cube]: 

771 r"""Prepare cubes ready for verification in scores. 

772 

773 Parameters 

774 ---------- 

775 base: iris.cube.Cube 

776 The cube from the "analysis" or observed cube. 

777 other: iris.cube.Cube 

778 The cube from the model. 

779 

780 Returns 

781 ------- 

782 base: iris.cube.Cube 

783 The cube from the "analysis" or observed cube in the same format as the other model. 

784 other: iris.cube.Cube 

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

786 

787 Notes 

788 ----- 

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

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

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

792 """ 

793 # raises value error if cube time points don't match 

794 ensure_common_time_points(base, other) 

795 

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

797 

798 other_model_name = other.attributes["model_name"] 

799 

800 if base.coords("pressure") and other.coords("pressure"): 

801 common_pressure = np.intersect1d( 

802 base.coord("pressure").points, 

803 other.coord("pressure").points, 

804 ) 

805 

806 pressure_constraint = iris.Constraint(pressure=common_pressure.tolist()) 

807 

808 base = base.extract(pressure_constraint) 

809 other = other.extract(pressure_constraint) 

810 

811 # Get spatial coord names. 

812 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

813 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

814 

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

816 # This is triggered if either 

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

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

819 # errors. 

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

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

822 # for UM and LFRic comparisons. 

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

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

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

826 # given this dependency on regridding. 

827 if ( 

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

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

830 ) or ( 

831 base.long_name 

832 in [ 

833 "eastward_wind_at_10m", 

834 "northward_wind_at_10m", 

835 "northward_wind_at_cell_centres", 

836 "eastward_wind_at_cell_centres", 

837 "zonal_wind_at_pressure_levels", 

838 "meridional_wind_at_pressure_levels", 

839 "potential_vorticity_at_pressure_levels", 

840 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

841 ] 

842 ): 

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

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

845 

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

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

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

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

850 # Copy base cube for correct coordinate information. 

851 other_tmp = base.copy() 

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

853 other_tmp.data = np.flip( 

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

855 ) 

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

857 other_tmp.rename(other.name()) 

858 other_tmp.units = other.units 

859 # Replace the cube. 

860 other = other_tmp 

861 

862 # Equalise attributes so we can merge. 

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

864 

865 other.attributes["model_name"] = other_model_name 

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

867 

868 return base, other 

869 

870 

871def _resolve_preserve_dims( 

872 cube: Cube, 

873 data_array: xr.DataArray, 

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

875) -> list[str] | None: 

876 r"""Resolve preserve coordinates to xarray dimension names. 

877 

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

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

880 may be auxiliary coordinates attached to a differently named dimension 

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

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

883 

884 Parameters 

885 ---------- 

886 cube: iris.cube.Cube 

887 The ccomparison model cube. 

888 data_array: xr.DataArray 

889 The comparison model cube, but in xarray format. 

890 preserved_coordinates: list[str] 

891 The list of preserved coordinates given by a user. 

892 

893 

894 Returns 

895 ------- 

896 preserved_dims : list[str] 

897 List of preserved dimension names in xarray convention. 

898 

899 """ 

900 if preserved_coordinates is None: 

901 return None 

902 

903 coord_names = ( 

904 [preserved_coordinates] 

905 if isinstance(preserved_coordinates, str) 

906 else preserved_coordinates 

907 ) 

908 preserve_dims: list[str] = [] 

909 

910 for coord_name in coord_names: 

911 # Already an xarray dimension name. 

912 if coord_name in data_array.dims: 

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

914 preserve_dims.append(coord_name) 

915 continue 

916 

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

918 try: 

919 dim_indices = cube.coord_dims(coord_name) 

920 except iris.exceptions.CoordinateNotFoundError: 

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

922 if coord_name not in preserve_dims: 

923 preserve_dims.append(coord_name) 

924 continue 

925 

926 for dim_index in dim_indices: 

927 dim_name = data_array.dims[dim_index] 

928 if dim_name not in preserve_dims: 

929 preserve_dims.append(dim_name) 

930 

931 return preserve_dims 

932 

933 

934def _attach_scaler_time_coord_maybe(scores_cube: Cube, base: Cube) -> None: 

935 r"""Attaches scaler time coordinate if time is aggregated out. 

936 

937 In place function that attaches a scaler time coordinate to scores_cube 

938 if time is aggregated out so plotting can display the aggregated period in the title. 

939 

940 Parameters 

941 ---------- 

942 scores_cube: iris.cube.Cube 

943 The calculated scores cube. 

944 base: iris.cube.Cube 

945 The cube from the "analysis" or observed cube. 

946 

947 Returns 

948 ------- 

949 None 

950 

951 """ 

952 try: 

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

954 base_time = base.coord("time") 

955 time_vals = ( 

956 base_time.bounds.flatten() 

957 if base_time.has_bounds() 

958 else base_time.points 

959 ) 

960 t_start = float(time_vals[0]) 

961 t_end = float(time_vals[-1]) 

962 t_mid = 0.5 * (t_start + t_end) 

963 

964 scores_cube.add_aux_coord( 

965 iris.coords.AuxCoord( 

966 t_mid, 

967 standard_name=base_time.standard_name, 

968 long_name=base_time.long_name, 

969 var_name=base_time.var_name, 

970 units=base_time.units, 

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

972 attributes=base_time.attributes.copy(), 

973 ) 

974 ) 

975 except iris.exceptions.CoordinateNotFoundError: 

976 pass 

977 

978 

979def _split_base_and_other(cubes: CubeList): 

980 r"""Split the cube into base and other cubes. 

981 

982 Split depends on whether there 

983 is an observed cube in the cubes. If there is an observed cube, 

984 then 'base' is the observed cube, if not then 'base' is the comparison 

985 model cube. 

986 

987 Parameters 

988 ---------- 

989 cubes: iris.cube.CubeList 

990 Cubes to split into base and other cubes. 

991 

992 Returns 

993 ------- 

994 tuple 

995 A tuple containing a base cube, and other cube/cubelist. 

996 

997 """ 

998 obs_cube = [cb for cb in cubes if "observed" in (cb.long_name or "")] 

999 if obs_cube: 

1000 if len(obs_cube) > 1: 1000 ↛ 1001line 1000 didn't jump to line 1001 because the condition on line 1000 was never true

1001 raise ValueError( 

1002 f"Expected exactly one 'observed' cube, found {len(obs_cube)}" 

1003 ) 

1004 base = obs_cube[0] 

1005 others = CubeList(cb for cb in cubes if cb is not base) 

1006 return base, others 

1007 

1008 return _sort_cube_into_base_and_other(cubes)