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

223 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-11 15:11 +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 base, others = _split_base_and_other(cubes) 

78 

79 for other in others: 

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

81 

82 scores_cubelist.append(scores_cube) 

83 

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

85 

86 

87def scores_mae( 

88 cubes: CubeList, 

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

90) -> CubeList: 

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

92 

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

94 

95 Parameters 

96 ---------- 

97 cubes: iris.cube.CubeList 

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

99 this can be an analysis and the model. 

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

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

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

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

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

105 use `None`. The default is `None`. 

106 

107 Returns 

108 ------- 

109 scores_cubelist: iris.cube.CubeList 

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

111 """ 

112 scores_cubelist = CubeList() 

113 base, others = _split_base_and_other(cubes) 

114 

115 for other in others: 

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

117 scores_cubelist.append(scores_cube) 

118 

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

120 

121 

122def scores_additive_bias( 

123 cubes: CubeList, 

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

125) -> CubeList: 

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

127 

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

129 

130 Parameters 

131 ---------- 

132 cubes: iris.cube.CubeList 

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

134 this can be an analysis and the model. 

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

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

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

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

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

140 use `None`. The default is `None`. 

141 

142 Returns 

143 ------- 

144 scores_cubelist: iris.cube.CubeList 

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

146 """ 

147 scores_cubelist = CubeList() 

148 base, others = _split_base_and_other(cubes) 

149 

150 for other in others: 

151 scores_cube = _make_scores_cube( 

152 base, other, "additive_bias", preserved_coordinates 

153 ) 

154 

155 scores_cubelist.append(scores_cube) 

156 

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

158 

159 

160def scores_correlation_pearsonr( 

161 cubes: CubeList, 

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

163) -> CubeList: 

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

165 

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

167 

168 Parameters 

169 ---------- 

170 cubes: iris.cube.CubeList 

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

172 this can be an analysis and the model. 

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

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

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

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

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

178 use `None`. The default is `None`. 

179 

180 Returns 

181 ------- 

182 scores_cubelist: iris.cube.CubeList 

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

184 """ 

185 scores_cubelist = CubeList() 

186 base, others = _split_base_and_other(cubes) 

187 

188 for other in others: 

189 scores_cube = _make_scores_cube( 

190 base, other, "pearson_correlation", preserved_coordinates 

191 ) 

192 

193 scores_cubelist.append(scores_cube) 

194 

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

196 

197 

198def scores_crps_for_ensemble( 

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

200) -> Cube: 

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

202 

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

204 

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

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

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

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

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

210 

211 See [CRPS]_ for further information. 

212 

213 Parameters 

214 ---------- 

215 cubes: iris.cube.Cube 

216 A Cube containing ensembles data 

217 

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

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

220 

221 control_member: int 

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

223 

224 Returns 

225 ------- 

226 crps: iris.cube.Cube 

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

228 """ 

229 if control_member != 0: 

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

231 

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

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

234 logger.warning( 

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

236 ) 

237 control_member = new_control_member 

238 

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

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

241 

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

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

244 

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

246 ens_mem = cubes.extract( 

247 generate_remove_single_ensemble_member_constraint(control_member) 

248 ) 

249 

250 # Realising the data in advance provides a large speedup 

251 _ = ctrl.data 

252 _ = ens_mem.data 

253 del _ 

254 

255 ctrl = xr.DataArray.from_iris(ctrl) 

256 ens_mem = xr.DataArray.from_iris(ens_mem) 

257 

258 crps = xr.DataArray.to_iris( 

259 scores.probability.crps_for_ensemble( 

260 ens_mem, 

261 ctrl, 

262 ensemble_member_dim="realization", 

263 method=method, 

264 preserve_dims="time", 

265 ) 

266 ) 

267 

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

269 _realization_callback(crps) 

270 return crps 

271 

272 

273def _scores_categorical_metric( 

274 cubes: CubeList, 

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

276 threshold: str, 

277 op_func: str, 

278 metric: str, 

279) -> CubeList: 

280 """ 

281 Prepare cubes for computing categorical metrics using Scores. 

282 

283 Parameters 

284 ---------- 

285 cubes: iris.cube.CubeList 

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

287 preserved_coordinates: list | str | None 

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

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

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

291 threshold: str 

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

293 op_func: str 

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

295 to generate the mask. 

296 metric: str 

297 The scores metric to compute. 

298 

299 Returns 

300 ------- 

301 scores_results: iris.cube.CubeList 

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

303 

304 """ 

305 # Split obs/models 

306 models = CubeList() 

307 for c in cubes: 

308 if "observed" in c.long_name: 

309 observed = c 

310 else: 

311 models.append(c) 

312 

313 ops = { 

314 "gt": operator.gt, 

315 "lt": operator.lt, 

316 } 

317 

318 # Check if this exists. 

319 try: 

320 op = ops[op_func] 

321 except KeyError as err: 

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

323 

324 scores_results = CubeList() 

325 

326 for model in models: 

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

328 other_xr = xr.DataArray.from_iris(model) 

329 base_xr = xr.DataArray.from_iris(observed) 

330 preserve_dims = _resolve_preserve_dims( 

331 observed, other_xr, preserved_coordinates 

332 ) 

333 

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

335 event_operator = scores.categorical.ThresholdEventOperator( 

336 default_event_threshold=float(threshold), default_op_fn=op 

337 ) 

338 

339 # Generate binary fields using the event operator. 

340 forecast_binary, observed_binary = event_operator.make_event_tables( 

341 other_xr, base_xr 

342 ) 

343 

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

345 contingency_manager = scores.categorical.BinaryContingencyManager( 

346 forecast_binary, observed_binary 

347 ).transform(preserve_dims=preserve_dims) 

348 

349 # Compute required categorical score. 

350 if metric == "pod": 

351 result = contingency_manager.probability_of_detection() 

352 name = "Probability_Of_Detection" 

353 

354 elif metric == "ets": 

355 result = contingency_manager.equitable_threat_score() 

356 name = "Equitable_Threat_Score" 

357 

358 elif metric == "fb": 

359 result = contingency_manager.frequency_bias() 

360 name = "Frequency_Bias" 

361 

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

363 result = contingency_manager.probability_of_false_detection() 

364 name = "Probability_Of_False_Detection" 

365 

366 else: 

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

368 

369 scores_cube = xr.DataArray.to_iris(result) 

370 _fix_spatial_coord(cubes[0], scores_cube) 

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

372 scores_cube.units = "1" 

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

374 

375 scores_results.append(scores_cube) 

376 

377 return scores_results 

378 

379 

380def scores_pod( 

381 cubes, 

382 preserved_coordinates, 

383 threshold, 

384 op_func, 

385) -> CubeList: 

386 r""" 

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

388 

389 Parameters 

390 ---------- 

391 cubes: iris.cube.CubeList 

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

393 preserved_coordinates: list | str | None 

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

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

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

397 threshold: str 

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

399 op_func: str 

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

401 to generate the mask. 

402 

403 Returns 

404 ------- 

405 iris.cube.CubeList 

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

407 

408 

409 Notes 

410 ----- 

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

412 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 

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

414 

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

416 

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

418 

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

420 """ 

421 return _scores_categorical_metric( 

422 cubes, 

423 preserved_coordinates, 

424 threshold, 

425 op_func, 

426 "pod", 

427 ) 

428 

429 

430def scores_ets( 

431 cubes: CubeList, 

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

433 threshold: str, 

434 op_func: str, 

435) -> CubeList: 

436 r""" 

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

438 

439 Parameters 

440 ---------- 

441 cubes: iris.cube.CubeList 

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

443 preserved_coordinates: list | str | None 

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

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

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

447 threshold: str 

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

449 op_func: str 

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

451 to generate the mask. 

452 

453 Returns 

454 ------- 

455 iris.cube.CubeList 

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

457 

458 Notes 

459 ----- 

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

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

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

463 

464 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 

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

466 

467 It is calculated as: 

468 

469 .. math:: 

470 

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

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

473 

474 where 

475 

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

477 

478 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 

479 random chance. 

480 """ 

481 return _scores_categorical_metric( 

482 cubes, 

483 preserved_coordinates, 

484 threshold, 

485 op_func, 

486 "ets", 

487 ) 

488 

489 

490def scores_pfd( 

491 cubes: CubeList, 

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

493 threshold: str, 

494 op_func: str, 

495) -> CubeList: 

496 r""" 

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

498 

499 Parameters 

500 ---------- 

501 cubes: iris.cube.CubeList 

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

503 preserved_coordinates: list | str | None 

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

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

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

507 threshold: str 

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

509 op_func: str 

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

511 to generate the mask. 

512 

513 Returns 

514 ------- 

515 iris.cube.CubeList 

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

517 

518 Notes 

519 ----- 

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

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

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

523 none actually occurred. 

524 

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

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

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

528 

529 It is calculated as: 

530 

531 .. math:: 

532 

533 POFD = \frac{false\ alarms} 

534 {false\ alarms + true\ negatives} 

535 

536 where 

537 

538 false alarms 

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

540 

541 true negatives 

542 Number of occasions where neither the forecast nor the observations 

543 indicated an event. 

544 

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

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

547 

548 Lower values are therefore better. 

549 """ 

550 return _scores_categorical_metric( 

551 cubes, 

552 preserved_coordinates, 

553 threshold, 

554 op_func, 

555 "pfd", 

556 ) 

557 

558 

559def scores_frequency_bias( 

560 cubes: CubeList, 

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

562 threshold: str, 

563 op_func: str, 

564) -> CubeList: 

565 r""" 

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

567 

568 Parameters 

569 ---------- 

570 cubes: iris.cube.CubeList 

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

572 preserved_coordinates: list | str | None 

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

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

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

576 threshold: str 

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

578 op_func: str 

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

580 to generate the mask. 

581 

582 Returns 

583 ------- 

584 iris.cube.CubeList 

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

586 

587 Notes 

588 ----- 

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

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

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

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

593 only the overall frequency with which events are forecast. 

594 

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

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

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

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

599 

600 It is calculated as: 

601 

602 .. math:: 

603 

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

605 {hits + misses} 

606 

607 where 

608 

609 hits 

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

611 

612 false alarms 

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

614 

615 misses 

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

617 

618 Frequency Bias ranges from 0 to infinity, where: 

619 

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

621 * Greater than 1 indicates overforecasting of events. 

622 * Less than 1 indicates underforecasting of events. 

623 

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

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

626 for one another. 

627 """ 

628 return _scores_categorical_metric( 

629 cubes, 

630 preserved_coordinates, 

631 threshold, 

632 op_func, 

633 "fb", 

634 ) 

635 

636 

637def _make_scores_cube( 

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

639) -> Cube: 

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

641 

642 Parameters 

643 ---------- 

644 base: iris.cube.Cube 

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

646 other: iris.cube.Cube 

647 The cube from the model. 

648 

649 metric: str 

650 The scores metric to compute. 

651 

652 preserved_coordinates: list[str] 

653 The list of preserved coordinates given by a user. 

654 

655 Returns 

656 ------- 

657 scores_cube: iris.cube.Cube 

658 The cube containing the calculated scores metric. 

659 

660 

661 """ 

662 if not base.long_name or not "observed" in base.long_name: 

663 base, other = _process_cubes_for_verification(base, other) 

664 

665 other_xr = xr.DataArray.from_iris(other) 

666 base_xr = xr.DataArray.from_iris(base) 

667 preserve_dims = _resolve_preserve_dims(other, other_xr, preserved_coordinates) 

668 

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

670 # apply scores, and then transform it back. 

671 

672 if metric == "rmse": 

673 scores_cube = xr.DataArray.to_iris( 

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

675 ) 

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

677 elif metric == "mae": 

678 scores_cube = xr.DataArray.to_iris( 

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

680 ) 

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

682 elif metric == "additive_bias": 

683 scores_cube = xr.DataArray.to_iris( 

684 scores.continuous.additive_bias( 

685 other_xr, base_xr, preserve_dims=preserve_dims 

686 ) 

687 ) 

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

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

690 scores_cube = xr.DataArray.to_iris( 

691 scores.continuous.correlation.pearsonr( 

692 other_xr, base_xr, preserve_dims=preserve_dims 

693 ) 

694 ) 

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

696 else: 

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

698 

699 _fix_spatial_coord(base, scores_cube) 

700 

701 _attach_scaler_time_coord_maybe(scores_cube, base) 

702 model_name = other.attributes["model_name"] 

703 scores_cube.attributes["model_name"] = model_name 

704 return scores_cube 

705 

706 

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

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

709 return 

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

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

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

713 xy_name 

714 ).coord_system 

715 

716 

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

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

719 

720 Parameters 

721 ---------- 

722 cubes: iris.cube.CubeList 

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

724 

725 Returns 

726 ------- 

727 base: iris.cube.Cube 

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

729 others: iris.cube.CubeList 

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

731 

732 """ 

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

734 others: CubeList = cubes.extract( 

735 iris.Constraint( 

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

737 ) 

738 ) 

739 

740 return base, others 

741 

742 

743def _split_base_and_other(cubes: CubeList): 

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

745 

746 Split depends on whether there 

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

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

749 model cube. 

750 

751 Parameters 

752 ---------- 

753 cubes: iris.cube.CubeList 

754 Cubes to split into base and other cubes. 

755 

756 Returns 

757 ------- 

758 tuple 

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

760 

761 """ 

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

763 if obs_cube: 

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

765 raise ValueError( 

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

767 ) 

768 base = obs_cube[0] 

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

770 return base, others 

771 

772 return _sort_cube_into_base_and_other(cubes) 

773 

774 

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

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

777 

778 Parameters 

779 ---------- 

780 cubes: iris.cube.CubeList 

781 A CubeList of n cubes 

782 

783 Returns 

784 ------- 

785 Cubes: iris.cube.CubeList 

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

787 """ 

788 for cube in cubes: 

789 try: 

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

791 cube.coord("pressure").points 

792 ): 

793 reverse(cube, "pressure") 

794 

795 except iris.exceptions.CoordinateNotFoundError: 

796 pass 

797 

798 

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

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

801 

802 Parameters 

803 ---------- 

804 base: iris.cube.Cube 

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

806 other: iris.cube.Cube 

807 The cube from the model. 

808 

809 Returns 

810 ------- 

811 base: iris.cube.Cube 

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

813 other: iris.cube.Cube 

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

815 

816 Notes 

817 ----- 

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

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

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

821 """ 

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

823 ensure_common_time_points(base, other) 

824 

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

826 

827 other_model_name = other.attributes["model_name"] 

828 

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

830 common_pressure = np.intersect1d( 

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

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

833 ) 

834 

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

836 

837 base = base.extract(pressure_constraint) 

838 other = other.extract(pressure_constraint) 

839 

840 # Get spatial coord names. 

841 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

842 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

843 

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

845 # This is triggered if either 

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

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

848 # errors. 

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

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

851 # for UM and LFRic comparisons. 

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

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

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

855 # given this dependency on regridding. 

856 

857 if ( 

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

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

860 ) or ( 

861 base.long_name 

862 in [ 

863 "eastward_wind_at_10m", 

864 "northward_wind_at_10m", 

865 "northward_wind_at_cell_centres", 

866 "eastward_wind_at_cell_centres", 

867 "zonal_wind_at_pressure_levels", 

868 "meridional_wind_at_pressure_levels", 

869 "potential_vorticity_at_pressure_levels", 

870 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

871 ] 

872 ): 

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

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

875 

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

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

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

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

880 # Copy base cube for correct coordinate information. 

881 other_tmp = base.copy() 

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

883 other_tmp.data = np.flip( 

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

885 ) 

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

887 other_tmp.rename(other.name()) 

888 other_tmp.units = other.units 

889 # Replace the cube. 

890 other = other_tmp 

891 

892 # Equalise attributes so we can merge. 

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

894 

895 other.attributes["model_name"] = other_model_name 

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

897 

898 return base, other 

899 

900 

901def _resolve_preserve_dims( 

902 cube: Cube, 

903 data_array: xr.DataArray, 

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

905) -> list[str] | None: 

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

907 

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

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

910 may be auxiliary coordinates attached to a differently named dimension 

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

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

913 

914 Parameters 

915 ---------- 

916 cube: iris.cube.Cube 

917 The ccomparison model cube. 

918 data_array: xr.DataArray 

919 The comparison model cube, but in xarray format. 

920 preserved_coordinates: list[str] 

921 The list of preserved coordinates given by a user. 

922 

923 

924 Returns 

925 ------- 

926 preserved_dims : list[str] 

927 List of preserved dimension names in xarray convention. 

928 

929 """ 

930 if preserved_coordinates is None: 

931 return None 

932 

933 coord_names = ( 

934 [preserved_coordinates] 

935 if isinstance(preserved_coordinates, str) 

936 else preserved_coordinates 

937 ) 

938 preserve_dims: list[str] = [] 

939 

940 for coord_name in coord_names: 

941 # Already an xarray dimension name. 

942 if coord_name in data_array.dims: 

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

944 preserve_dims.append(coord_name) 

945 continue 

946 

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

948 try: 

949 dim_indices = cube.coord_dims(coord_name) 

950 except iris.exceptions.CoordinateNotFoundError: 

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

952 if coord_name not in preserve_dims: 

953 preserve_dims.append(coord_name) 

954 continue 

955 

956 for dim_index in dim_indices: 

957 dim_name = data_array.dims[dim_index] 

958 if dim_name not in preserve_dims: 

959 preserve_dims.append(dim_name) 

960 

961 return preserve_dims 

962 

963 

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

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

966 

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

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

969 

970 Parameters 

971 ---------- 

972 scores_cube: iris.cube.Cube 

973 The calculated scores cube. 

974 base: iris.cube.Cube 

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

976 

977 Returns 

978 ------- 

979 None 

980 

981 """ 

982 try: 

983 if not scores_cube.coords("time") and not scores_cube.coords("forecast_period"): 

984 base_time = base.coord("time") 

985 time_vals = ( 

986 base_time.bounds.flatten() 

987 if base_time.has_bounds() 

988 else base_time.points 

989 ) 

990 t_start = float(time_vals[0]) 

991 t_end = float(time_vals[-1]) 

992 t_mid = 0.5 * (t_start + t_end) 

993 

994 scores_cube.add_aux_coord( 

995 iris.coords.AuxCoord( 

996 t_mid, 

997 standard_name=base_time.standard_name, 

998 long_name=base_time.long_name, 

999 var_name=base_time.var_name, 

1000 units=base_time.units, 

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

1002 attributes=base_time.attributes.copy(), 

1003 ) 

1004 ) 

1005 

1006 except iris.exceptions.CoordinateNotFoundError: 

1007 pass