Coverage for src/CSET/operators/aggregate.py: 91%

184 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-11 15:11 +0000

1# © Crown copyright, Met Office (2022-2024) 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"""Operators to aggregate across either 1 or 2 dimensions.""" 

16 

17import logging 

18from typing import NamedTuple 

19 

20import iris 

21import iris.analysis 

22import iris.coord_categorisation 

23import iris.cube 

24import iris.exceptions 

25import iris.util 

26import isodate 

27import numpy as np 

28from iris.coords import AuxCoord, DimCoord 

29from iris.cube import Cube, CubeList 

30 

31from CSET._common import iter_maybe 

32from CSET.operators._utils import is_time_aggregatable 

33 

34logger = logging.getLogger(__name__) 

35 

36 

37def time_aggregate( 

38 cubes: iris.cube.Cube | iris.cube.CubeList, 

39 method: str, 

40 interval_iso: str, 

41 **kwargs, 

42) -> iris.cube.Cube | iris.cube.CubeList: 

43 """Aggregate cube/cubes by its time coordinate. 

44 

45 Aggregates fields in a cube or cube list for the specified coordinate and 

46 using the method supplied. The aggregated cube/cubelist will keep the coordinate and 

47 add a coordinate with the aggregated end time points. 

48 

49 Can also handle multiple forecast reference times. 

50 

51 Examples include generating hourly or 6-hourly precipitation accumulations for precipitation, or maximum screen level temperature every 3 hours. 

52 

53 resampled_cubes = iris.cube.CubeList() 

54 

55 timedelta = isodate.parse_duration(interval_iso) 

56 interval = int(timedelta.total_seconds() / 3600) 

57 

58 Arguments 

59 --------- 

60 cubes: iris.cube.Cube | iris.cube.CubeList 

61 Cube or CubeList to aggregate and iterate over one dimension 

62 method: str 

63 Type of aggregate i.e. method: 'SUM', getattr creates 

64 iris.analysis.SUM, etc. 

65 interval_iso: isodate timedelta ISO 8601 object i.e PT6H (6 hours), PT30M (30 mins) 

66 Interval to aggregate over. 

67 interval_iso: str 

68 A string containing a datetime timedelta for resampling over in hours, i.e. PT3H, PT24H. 

69 

70 Returns 

71 ------- 

72 resampled_cubes: iris.cube.Cube | iris.cube.CubeList 

73 Cube or CubeList containing aggregated cubes. 

74 

75 Raises 

76 ------ 

77 ValueError 

78 If the constraint doesn't produce a single cube containing a field. 

79 """ 

80 # Return unchanged cubes if interval_iso is 0, to allow this operator to be used across multiple fields where only some will need resampling. 

81 if interval_iso == "0": 81 ↛ 82line 81 didn't jump to line 82 because the condition on line 81 was never true

82 return cubes 

83 

84 cubes = iter_maybe(cubes) 

85 

86 resampled_cubes = iris.cube.CubeList() 

87 

88 timedelta = isodate.parse_duration(interval_iso) 

89 interval = int(timedelta.total_seconds() / 3600) 

90 

91 for cube in cubes: 

92 if cube.coord("forecast_reference_time").shape[0] > 1: 

93 # Handle cubes with multiple forecast cycles. 

94 aggregated_cube = _aggregate_in_time_multiple_frt(cube, method, interval) 

95 else: 

96 aggregated_cube = _aggregate_in_time_single_frt(cube, method, interval) 

97 

98 resampled_cubes.append(aggregated_cube) 

99 if len(resampled_cubes) == 1: 99 ↛ 101line 99 didn't jump to line 101 because the condition on line 99 was always true

100 return resampled_cubes[0] 

101 return resampled_cubes 

102 

103 

104def ensure_aggregatable_across_cases( 

105 cubes: iris.cube.Cube | iris.cube.CubeList, 

106) -> iris.cube.CubeList: 

107 """Ensure a Cube or CubeList can be aggregated across multiple cases. 

108 

109 The cubes are grouped into buckets of compatible cubes, then each bucket is 

110 converted into a single aggregatable cube with ``forecast_period`` and 

111 ``forecast_reference_time`` dimension coordinates. 

112 

113 Arguments 

114 --------- 

115 cubes: iris.cube.Cube | iris.cube.CubeList 

116 Each cube is checked to determine if it has the necessary 

117 dimensional coordinates to be aggregatable, being processed if needed. 

118 

119 Returns 

120 ------- 

121 cubes: iris.cube.CubeList 

122 A CubeList of time aggregatable cubes. 

123 

124 Raises 

125 ------ 

126 ValueError 

127 If any of the provided cubes cannot be made aggregatable. 

128 

129 Notes 

130 ----- 

131 This is a simple operator designed to ensure that a Cube is aggregatable 

132 across cases. If a CubeList is presented it will create an aggregatable Cube 

133 from that list. Its functionality is for case study (or trial) aggregation 

134 to ensure that the full dataset can be loaded as a single cube. This 

135 functionality is particularly useful for percentiles, Q-Q plots, and 

136 histograms. 

137 

138 The necessary dimension coordinates for a cube to be aggregatable are 

139 ``forecast_period`` and ``forecast_reference_time``. 

140 """ 

141 

142 # Group compatible cubes. 

143 class Buckets: 

144 def __init__(self): 

145 self.buckets = [] 

146 

147 def add(self, cube: iris.cube.Cube): 

148 """Add a cube into a bucket. 

149 

150 If the cube is compatible with an existing bucket it is added there. 

151 Otherwise it gets its own bucket. 

152 """ 

153 for bucket in self.buckets: 

154 if bucket[0].is_compatible(cube): 

155 bucket.append(cube) 

156 return 

157 self.buckets.append(iris.cube.CubeList([cube])) 

158 

159 def get_buckets(self) -> list[iris.cube.CubeList]: 

160 return self.buckets 

161 

162 b = Buckets() 

163 for cube in iter_maybe(cubes): 

164 b.add(cube) 

165 buckets = b.get_buckets() 

166 

167 logger.debug("Buckets:\n%s", "\n---\n".join(str(b) for b in buckets)) 

168 

169 # Ensure each bucket is a single aggregatable cube. 

170 aggregatable_cubes = iris.cube.CubeList() 

171 for bucket in buckets: 

172 # Single cubes that are already aggregatable won't need processing. 

173 if len(bucket) == 1 and is_time_aggregatable(bucket[0]): 

174 aggregatable_cube = bucket[0] 

175 aggregatable_cube = _add_nref(aggregatable_cube) 

176 aggregatable_cubes.append(aggregatable_cube) 

177 continue 

178 

179 # Create an aggregatable cube from the provided CubeList. 

180 to_merge = iris.cube.CubeList() 

181 for cube in bucket: 

182 try: 

183 to_merge.extend( 

184 cube.slices_over(["forecast_period", "forecast_reference_time"]) 

185 ) 

186 except iris.exceptions.CoordinateNotFoundError as err: 

187 raise ValueError( 

188 "Cube should have 'forecast_period' and 'forecast_reference_time' dimension coordinates.", 

189 cube, 

190 ) from err 

191 aggregatable_cube = to_merge.merge_cube() 

192 

193 # Add attribute on number of forecast_reference_times 

194 aggregatable_cube = _add_nref(aggregatable_cube) 

195 

196 # Verify cube is now aggregatable. 

197 if not is_time_aggregatable(aggregatable_cube): 197 ↛ 198line 197 didn't jump to line 198 because the condition on line 197 was never true

198 raise ValueError( 

199 "Cube should have 'forecast_period' and 'forecast_reference_time' dimension coordinates.", 

200 aggregatable_cube, 

201 ) 

202 aggregatable_cubes.append(aggregatable_cube) 

203 return aggregatable_cubes 

204 

205 

206def combine_obs_across_forecasts(cubes: CubeList) -> Cube: 

207 """ 

208 Combine observation cubes from multiple forecast_reference_times. 

209 

210 Input: 

211 CubeList of cubes with dimensions 

212 

213 (time, station) 

214 

215 Output: 

216 Cube with dimensions 

217 

218 (forecast_reference_time, 

219 forecast_period, 

220 station) 

221 

222 where 

223 

224 time 

225 

226 becomes a 2D auxiliary coordinate attached to 

227 

228 (forecast_reference_time, forecast_period) 

229 

230 Only stations present in every forecast are retained. 

231 All station metadata coordinates are preserved. 

232 """ 

233 if len(cubes) < 2: 

234 raise ValueError("Need at least two cubes") 

235 

236 common_stations = _get_common_stations(cubes) 

237 station_lookup = _build_station_lookup(cubes, common_stations) 

238 fp_hours = _generate_forecast_period(cubes) 

239 return _make_aggregated_obs_cube(cubes, station_lookup, common_stations, fp_hours) 

240 

241 

242def add_hour_coordinate( 

243 cubes: iris.cube.Cube | iris.cube.CubeList, 

244) -> iris.cube.Cube | iris.cube.CubeList: 

245 """Add a category coordinate of hour of day to a Cube or CubeList. 

246 

247 Arguments 

248 --------- 

249 cubes: iris.cube.Cube | iris.cube.CubeList 

250 Cube of any variable that has a time coordinate. 

251 Note input Cube or CubeList items should only have 1 time dimension. 

252 

253 Returns 

254 ------- 

255 cube: iris.cube.Cube 

256 A Cube with an additional auxiliary coordinate of hour. 

257 

258 Notes 

259 ----- 

260 This is a simple operator designed to be used prior to case aggregation for 

261 histograms, Q-Q plots, and percentiles when aggregated by hour of day. 

262 """ 

263 new_cubelist = iris.cube.CubeList() 

264 for cube in iter_maybe(cubes): 

265 # Add a category coordinate of hour into each cube. 

266 iris.util.promote_aux_coord_to_dim_coord(cube, "time") 

267 iris.coord_categorisation.add_hour(cube, "time", name="hour") 

268 cube.coord("hour").units = "hours" 

269 new_cubelist.append(cube) 

270 

271 if len(new_cubelist) == 1: 

272 return new_cubelist[0] 

273 else: 

274 return new_cubelist 

275 

276 

277def rolling_window_time_aggregation( 

278 cubes: iris.cube.Cube | iris.cube.CubeList, method: str, window: int 

279) -> iris.cube.Cube | iris.cube.CubeList: 

280 """Aggregate a cube along the time dimension using a rolling window. 

281 

282 Arguments 

283 --------- 

284 cubes: iris.cube.Cube | iris.cube.CubeList 

285 Cube or Cubelist of any variable to be aggregated over a rolling window 

286 in time. 

287 method: str 

288 Type of aggregate i.e. method: 'MAX', getattr creates 

289 iris.analysis.MAX, etc. 

290 window: int 

291 The rolling window size. 

292 

293 Returns 

294 ------- 

295 cube: iris.cube.Cube | iris.cube.CubeList 

296 A Cube or Cubelist of the rolling window aggregate. The Cubes will have 

297 a time dimension that is reduced in size to the original cube by the 

298 window size. 

299 

300 Notes 

301 ----- 

302 This operator is designed to be used to help create daily maxima and minima 

303 for any variable. 

304 """ 

305 new_cubelist = iris.cube.CubeList() 

306 for cube in iter_maybe(cubes): 

307 # Use a rolling window in time to applied specified aggregation method 

308 # over a specified window length. 

309 window_cube = cube.rolling_window( 

310 "time", getattr(iris.analysis, method), window 

311 ) 

312 new_cubelist.append(window_cube) 

313 

314 if len(new_cubelist) == 1: 

315 return new_cubelist[0] 

316 else: 

317 return new_cubelist 

318 

319 

320def _get_common_stations(cubes: CubeList) -> list[str]: 

321 """Find stations common to all cubes with complete data.""" 

322 if len(cubes) < 2: 

323 raise ValueError( 

324 f"Need at least two cubes to find common stations, but got {len(cubes)}" 

325 ) 

326 

327 valid_station_sets = [] 

328 

329 for cb in cubes: 

330 names = cb.coord("Station_Name").points 

331 data = cb.data 

332 

333 # Handle masked and unmasked arrays 

334 if np.ma.isMaskedArray(data): 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true

335 mask = np.ma.getmaskarray(data) 

336 station_valid = ~np.any(mask, axis=0) & np.all( 

337 np.isfinite(data.filled(np.nan)), axis=0 

338 ) 

339 else: 

340 station_valid = np.all(np.isfinite(data), axis=0) 

341 valid_station_sets.append(set(names[station_valid])) 

342 

343 common_stations = sorted(set.intersection(*valid_station_sets)) 

344 

345 logger.info( 

346 "Retaining %s stations with complete observations", len(common_stations) 

347 ) 

348 

349 if not common_stations: 349 ↛ 350line 349 didn't jump to line 350 because the condition on line 349 was never true

350 raise ValueError( 

351 "No stations with complete data in all forecast_reference_times" 

352 ) 

353 return common_stations 

354 

355 

356class StationLookup(NamedTuple): 

357 """Container for station data, forecast reference times and validity times.""" 

358 

359 data: list[np.ma.MaskedArray | np.ndarray] 

360 frt_points: list[float] 

361 time_points: list[np.ndarray] 

362 

363 

364def _build_station_lookup(cubes: CubeList, common_stations: list[str]) -> StationLookup: 

365 """Build station lookup table. 

366 

367 This makes a data structure which contains 

368 

369 data_common_stations: 

370 the data, for example observed_temperature_at_screen_level, but only from the common stations. 

371 

372 frt_points: 

373 the forecast reference times. 

374 

375 time_points: 

376 the time points. 

377 

378 """ 

379 data_common_stations = [] 

380 frt_points = [] 

381 time_points = [] 

382 

383 for cube in cubes: 

384 # get the station names 

385 station_names = cube.coord("Station_Name").points 

386 

387 # dict where {station_name0: idx0, station_name1: idx1, ... } 

388 lookup = {station_name: idx for idx, station_name in enumerate(station_names)} 

389 

390 # the station indices of the common stations 

391 station_indices = [lookup[name] for name in common_stations] 

392 

393 # get cube with only the common stations 

394 cube_common_stations = cube[:, station_indices] 

395 

396 # get only the data from common stations cube 

397 data_common_stations.append(cube_common_stations.data) 

398 

399 # get the forecast reference times 

400 frt_points.append(cube.coord("forecast_reference_time").points[0]) 

401 

402 # get the time points 

403 time_points.append(cube.coord("time").points) 

404 

405 # Get the time axis length 

406 ntime = len(time_points[0]) 

407 

408 for t in time_points[1:]: 

409 if len(t) != ntime: 409 ↛ 410line 409 didn't jump to line 410 because the condition on line 409 was never true

410 raise ValueError("Forecasts have different numbers of lead times") 

411 return StationLookup(data_common_stations, frt_points, time_points) 

412 

413 

414def _generate_forecast_period(cubes) -> np.ndarray: 

415 """Generate forecast period values from time and forecast reference time. 

416 

417 Calculates the forecast period in hours for each time point, relative to 

418 the first forecast reference time of the first cube. 

419 """ 

420 time_coord = cubes[0].coord("time") 

421 frt_coord = cubes[0].coord("forecast_reference_time") 

422 

423 frt_date = frt_coord.units.num2date(frt_coord.points[0]) 

424 

425 fp_hours = [] 

426 for dt in time_coord.units.num2date(time_coord.points): 

427 fp_hours.append((dt - frt_date).total_seconds() / 3600) 

428 

429 fp_hours = np.asarray(fp_hours) 

430 

431 return fp_hours 

432 

433 

434def _make_aggregated_obs_cube( 

435 cubes: CubeList, 

436 station_lookup: StationLookup, 

437 common_stations: list[str], 

438 fp_hours: np.ndarray, 

439) -> Cube: 

440 time_coord = cubes[0].coord("time") 

441 """Create new aggregated obs cube by rebuilding.""" 

442 # Stack data 

443 data = np.stack(station_lookup.data, axis=0) 

444 

445 # shape (forecast_reference_time, forecast_period, station) 

446 # Output coordinates 

447 frt_out = DimCoord( 

448 station_lookup.frt_points, 

449 standard_name="forecast_reference_time", 

450 units=cubes[0].coord("forecast_reference_time").units, 

451 ) 

452 

453 fp_out = DimCoord( 

454 fp_hours, 

455 standard_name="forecast_period", 

456 units="hours", 

457 ) 

458 

459 station_out = DimCoord( 

460 np.arange(len(common_stations)), 

461 long_name="station", 

462 ) 

463 

464 cube_out = Cube( 

465 data, 

466 standard_name=cubes[0].standard_name, 

467 long_name=cubes[0].long_name, 

468 var_name=cubes[0].var_name, 

469 units=cubes[0].units, 

470 attributes=cubes[0].attributes.copy(), 

471 dim_coords_and_dims=[ 

472 (frt_out, 0), 

473 (fp_out, 1), 

474 (station_out, 2), 

475 ], 

476 ) 

477 

478 # Preserve station metadata coordinates 

479 ref_cube = cubes[0] 

480 

481 ref_names = ref_cube.coord("Station_Name").points 

482 

483 ref_lookup = {name: idx for idx, name in enumerate(ref_names)} 

484 

485 common_idx = [ref_lookup[name] for name in common_stations] 

486 

487 # skip coords as we have awkward station and station_0 arbitrary monotonic arrays. 

488 for coord in ref_cube.aux_coords: 

489 dims = ref_cube.coord_dims(coord) 

490 

491 # only coords attached solely to station axis 

492 if dims != (1,): 

493 continue 

494 

495 values = coord.points[common_idx] 

496 

497 # verify same in every cube 

498 for cube in cubes[1:]: 

499 cube_names = cube.coord("Station_Name").points 

500 

501 cube_lookup = {name: idx for idx, name in enumerate(cube_names)} 

502 

503 idx = [cube_lookup[name] for name in common_stations] 

504 

505 other_values = cube.coord(coord.name()).points[idx] 

506 

507 if not np.array_equal( 507 ↛ 511line 507 didn't jump to line 511 because the condition on line 507 was never true

508 values, 

509 other_values, 

510 ): 

511 raise ValueError(f"Station metadata differs for coord '{coord.name()}'") 

512 

513 aux = AuxCoord( 

514 values, 

515 standard_name=coord.standard_name, 

516 long_name=coord.long_name, 

517 var_name=coord.var_name, 

518 units=coord.units, 

519 attributes=coord.attributes.copy(), 

520 ) 

521 

522 cube_out.add_aux_coord(aux, (2,)) 

523 

524 # Add valid-time auxiliary coord 

525 time_2d = np.vstack(station_lookup.time_points) 

526 

527 cube_out.add_aux_coord( 

528 AuxCoord( 

529 time_2d, 

530 standard_name="time", 

531 units=time_coord.units, 

532 ), 

533 (0, 1), 

534 ) 

535 

536 return cube_out 

537 

538 

539def _add_nref(cube: iris.cube.Cube): 

540 """Retain information on number of forecast_reference_time inputs. 

541 

542 This preserves information on number of aggregated cases that can 

543 otherwise be lost on subsequent calls to collapse functions. 

544 """ 

545 nref = np.size(cube.coord("forecast_reference_time").points) 

546 cube.coord("time").attributes["number_reference_times"] = nref 

547 return cube 

548 

549 

550def _aggregate_in_time_multiple_frt( 

551 cube: iris.cube.Cube, method: str, interval: int 

552) -> iris.cube.Cube: 

553 """Aggregate a cube with multiple forecast reference times. 

554 

555 Aggregates each forecast cycle separately, then concatenates the results 

556 back into a single cube along forecast_reference_time. 

557 """ 

558 aggregated_cycles = iris.cube.CubeList() 

559 for frt_cube in cube.slices_over("forecast_reference_time"): 

560 iris.coord_categorisation.add_categorised_coord( 

561 frt_cube, 

562 "interval", 

563 "time", 

564 lambda coord, cell: cell // interval * interval, 

565 ) 

566 agg = frt_cube.aggregated_by( 

567 "interval", 

568 getattr(iris.analysis, method), 

569 ) 

570 agg.remove_coord("interval") 

571 agg = iris.util.new_axis( 

572 agg, 

573 agg.coord("forecast_reference_time"), 

574 ) 

575 aggregated_cycles.append(agg) 

576 # Current approach is to remove time 2d auxcoord as it causes issues concatenating into a single cube. 

577 # Time can be reconstructed from forecast_reference_time and forecast_period later on. 

578 # as we can construct it if needed. 

579 for cb in aggregated_cycles: 

580 cb.remove_coord("time") 

581 return aggregated_cycles.concatenate_cube() 

582 

583 

584def _aggregate_in_time_single_frt(cube: iris.cube.Cube, method: str, interval: int): 

585 """Aggregate a cube with one forecast reference time.""" 

586 iris.coord_categorisation.add_categorised_coord( 

587 cube, 

588 "interval", 

589 "time", 

590 lambda coord, cell: cell // interval * interval, 

591 ) 

592 aggregated_cube = cube.aggregated_by( 

593 "interval", 

594 getattr(iris.analysis, method), 

595 ) 

596 aggregated_cube.remove_coord("interval") 

597 return aggregated_cube