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

121 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-22 13:57 +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 

18 

19import iris 

20import iris.analysis 

21import iris.coord_categorisation 

22import iris.cube 

23import iris.exceptions 

24import iris.util 

25import isodate 

26import numpy as np 

27 

28from CSET._common import iter_maybe 

29from CSET.operators._utils import ( 

30 identify_unique_times, 

31 is_time_aggregatable, 

32 is_time_aux_coord, 

33 remove_cell_method, 

34 remove_duplicates, 

35) 

36 

37logger = logging.getLogger(__name__) 

38 

39 

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

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

42 

43 This preserves information on number of aggregated cases that can 

44 otherwise be lost on subsequent calls to collapse functions. 

45 """ 

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

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

48 return cube 

49 

50 

51def time_aggregate( 

52 cube: iris.cube.Cube, 

53 method: str, 

54 interval_iso: str, 

55 **kwargs, 

56) -> iris.cube.Cube: 

57 """Aggregate cube by its time coordinate. 

58 

59 Aggregates similar (stash) fields in a cube for the specified coordinate and 

60 using the method supplied. The aggregated cube will keep the coordinate and 

61 add a further coordinate with the aggregated end time points. 

62 

63 Examples are: 1. Generating hourly or 6-hourly precipitation accumulations 

64 given an interval for the new time coordinate. 

65 

66 We use the isodate class to convert ISO 8601 durations into time intervals 

67 for creating a new time coordinate for aggregation. 

68 

69 We use the lambda function to pass coord and interval into the callable 

70 category function in add_categorised to allow users to define their own 

71 sub-daily intervals for the new time coordinate. 

72 

73 Arguments 

74 --------- 

75 cube: iris.cube.Cube 

76 Cube to aggregate and iterate over one dimension 

77 coordinate: str 

78 Coordinate to aggregate over i.e. 'time', 'longitude', 

79 'latitude','model_level_number'. 

80 method: str 

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

82 iris.analysis.SUM, etc. 

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

84 Interval to aggregate over. 

85 

86 Returns 

87 ------- 

88 cube: iris.cube.Cube 

89 Single variable but several methods of aggregation 

90 

91 Raises 

92 ------ 

93 ValueError 

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

95 """ 

96 # Duration of ISO timedelta. 

97 timedelta = isodate.parse_duration(interval_iso) 

98 

99 # Convert interval format to whole hours. 

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

101 

102 # Add time categorisation overwriting hourly increment via lambda coord. 

103 # https://scitools-iris.readthedocs.io/en/latest/_modules/iris/coord_categorisation.html 

104 iris.coord_categorisation.add_categorised_coord( 

105 cube, "interval", "time", lambda coord, cell: cell // interval * interval 

106 ) 

107 

108 # Aggregate cube using supplied method. 

109 aggregated_cube = cube.aggregated_by("interval", getattr(iris.analysis, method)) 

110 aggregated_cube.remove_coord("interval") 

111 return aggregated_cube 

112 

113 

114def ensure_aggregatable_across_cases( 

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

116 time_coord_name: str, 

117) -> iris.cube.CubeList: 

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

119 

120 The cubes are grouped into buckets of compatible cubes. Each bucket is then 

121 processed to create aggregatable cubes. The function handles two types of 

122 time coordinates: 

123 

124 - Dimension coordinates: Cubes with ``forecast_period`` and 

125 ``forecast_reference_time`` as dimension coordinates are sliced and merged. 

126 - Auxiliary coordinates: Cubes with time as an auxiliary coordinate are 

127 aggregated at each unique time point and then merged. 

128 

129 Arguments 

130 --------- 

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

132 Each cube is checked to determine if it has the necessary time 

133 coordinates (either as dimension or auxiliary coordinates) to be 

134 aggregatable, being processed if needed. 

135 time_coord_name: str 

136 Name of the time coordinate to aggregate over, typically "time" or 

137 "forecast_period". 

138 

139 Returns 

140 ------- 

141 cubes: iris.cube.CubeList 

142 A CubeList of time aggregatable cubes. Each cube will have a 

143 ``number_reference_times`` attribute on its time coordinate indicating 

144 the number of forecast reference times aggregated. 

145 

146 Raises 

147 ------ 

148 ValueError 

149 If any of the provided cubes cannot be made aggregatable (i.e., missing 

150 required time coordinates). 

151 

152 Notes 

153 ----- 

154 This operator is designed to ensure that cubes can be aggregated across 

155 multiple cases (e.g., different model runs or forecast times). Its 

156 functionality is particularly useful for case study or trial aggregation 

157 when computing statistics such as percentiles, Q-Q plots, and histograms. 

158 

159 For cubes to be aggregatable, they must have either: 

160 

161 - ``forecast_period`` and ``forecast_reference_time`` as dimension 

162 or auxiliary coordinates 

163 """ 

164 

165 # Group compatible cubes. 

166 class Buckets: 

167 def __init__(self): 

168 self.buckets = [] 

169 

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

171 """Add a cube into a bucket. 

172 

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

174 Otherwise it gets its own bucket. 

175 """ 

176 for bucket in self.buckets: 

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

178 bucket.append(cube) 

179 return 

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

181 

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

183 return self.buckets 

184 

185 b = Buckets() 

186 for cube in iter_maybe(cubes): 

187 b.add(cube) 

188 buckets = b.get_buckets() 

189 

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

191 

192 # Ensure each bucket is a single aggregatable cube. 

193 aggregatable_cubes = iris.cube.CubeList() 

194 for bucket in buckets: 

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

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

197 aggregatable_cube = bucket[0] 

198 aggregatable_cube = _add_nref(aggregatable_cube) 

199 aggregatable_cubes.append(aggregatable_cube) 

200 continue 

201 

202 # Check if all cubes in bucket are time_aggregatable 

203 if all(is_time_aggregatable(cube) for cube in bucket): 

204 to_merge = iris.cube.CubeList() 

205 for cube in bucket: 

206 try: 

207 to_merge.extend( 

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

209 ) 

210 except iris.exceptions.CoordinateNotFoundError as err: 

211 raise ValueError( 

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

213 cube, 

214 ) from err 

215 aggregatable_cube = to_merge.merge_cube() 

216 

217 # Add attribute on number of forecast_reference_times 

218 aggregatable_cube = _add_nref(aggregatable_cube) 

219 

220 aggregatable_cubes.append(aggregatable_cube) 

221 

222 elif all(is_time_aux_coord(cube) for cube in bucket): 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was never true

223 times = identify_unique_times(bucket, time_coord_name) 

224 

225 aggregated_list = [] 

226 for time in times: 

227 for cube in bucket: 

228 aggregated_list.extend( 

229 _aggregate_without_time_dimcoords( 

230 cube, time, iris.analysis.MEAN, None 

231 ) 

232 ) 

233 

234 cubes_to_merge = iris.cube.CubeList(aggregated_list) 

235 

236 aggregatable_cube = cubes_to_merge.merge_cube() 

237 aggregatable_cube = _add_nref(aggregatable_cube) 

238 aggregatable_cubes.append(aggregatable_cube) 

239 

240 else: 

241 raise ValueError( 

242 "Cube is missing required dimension or auxiliary time coordinates", 

243 bucket, 

244 ) 

245 

246 return aggregatable_cubes 

247 

248 

249def add_hour_coordinate( 

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

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

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

253 

254 Arguments 

255 --------- 

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

257 Cube of any variable that has a time coordinate. 

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

259 

260 Returns 

261 ------- 

262 cube: iris.cube.Cube 

263 A Cube with an additional auxiliary coordinate of hour. 

264 

265 Notes 

266 ----- 

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

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

269 """ 

270 new_cubelist = iris.cube.CubeList() 

271 for cube in iter_maybe(cubes): 

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

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

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

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

276 new_cubelist.append(cube) 

277 

278 if len(new_cubelist) == 1: 

279 return new_cubelist[0] 

280 else: 

281 return new_cubelist 

282 

283 

284def rolling_window_time_aggregation( 

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

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

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

288 

289 Arguments 

290 --------- 

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

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

293 in time. 

294 method: str 

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

296 iris.analysis.MAX, etc. 

297 window: int 

298 The rolling window size. 

299 

300 Returns 

301 ------- 

302 cube: iris.cube.Cube | iris.cube.CubeList 

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

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

305 window size. 

306 

307 Notes 

308 ----- 

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

310 for any variable. 

311 """ 

312 new_cubelist = iris.cube.CubeList() 

313 for cube in iter_maybe(cubes): 

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

315 # over a specified window length. 

316 window_cube = cube.rolling_window( 

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

318 ) 

319 new_cubelist.append(window_cube) 

320 

321 if len(new_cubelist) == 1: 

322 return new_cubelist[0] 

323 else: 

324 return new_cubelist 

325 

326 

327def _aggregate_without_time_dimcoords(cubes, time_coord, aggregator, percentile): 

328 """Aggregate cubes at a specific time without time dimension coordinates. 

329 

330 Arguments 

331 --------- 

332 cubes: iris.cube.CubeList 

333 Cubes to aggregate. 

334 time_coord: iris.coords.Coord 

335 Single time coordinate point to aggregate at. 

336 aggregator: iris.analysis.Aggregator 

337 Aggregation method (e.g., iris.analysis.MEAN). 

338 percentile: float, optional 

339 Percentile value if using PERCENTILE aggregator. 

340 """ 

341 # Handle single cube input 

342 if isinstance(cubes, iris.cube.Cube): 

343 cubes = iris.cube.CubeList([cubes]) 

344 

345 # Check the supplied time coordinate to make sure it corresponds to a 

346 # single time only 

347 if len(time_coord.points) != 1: 

348 raise ValueError("Time coordinate should specify a single time only") 

349 

350 # Remove any duplicate cubes in the input 

351 cubes = remove_duplicates(cubes) 

352 

353 time_coord_name = time_coord.name() 

354 

355 time_constraint = iris.Constraint( 

356 coord_values={time_coord_name: lambda cell: cell.point in time_coord.cells()} 

357 ) 

358 cubes_at_time = cubes.extract(time_constraint) 

359 

360 # Add a temporary "number" coordinate to uniquely label the different 

361 # data points at this time 

362 number = 0 

363 numbered_cubes = iris.cube.CubeList() 

364 for cube in cubes_at_time: 

365 for slc in cube.slices_over(time_coord_name): 

366 number_coord = iris.coords.AuxCoord(number, long_name="number") 

367 slc.add_aux_coord(number_coord) 

368 numbered_cubes.append(slc) 

369 number += 1 

370 cubes_at_time = numbered_cubes 

371 

372 cubes_at_time = cubes_at_time.merge() 

373 

374 aggregated_cubes = iris.cube.CubeList() 

375 for cube in cubes_at_time: 

376 # If there was only a single data point at this time, then "number" 

377 # will be a scalar coordinate. If so, make it a dimension coordinate 

378 # to allow collapsing 

379 if not cube.coord_dims("number"): 

380 cube = iris.util.new_axis(cube, scalar_coord="number") 

381 

382 num_cases = cube.coord("number").points.size 

383 num_cases_coord = iris.coords.AuxCoord(num_cases, long_name="num_cases") 

384 cube.add_aux_coord(num_cases_coord) 

385 

386 # Do aggregation across the temporary "number" coordinate 

387 if isinstance(aggregator, type(iris.analysis.PERCENTILE)): 

388 cube = cube.collapsed("number", aggregator, percent=percentile) 

389 else: 

390 cube = cube.collapsed("number", aggregator) 

391 

392 # Now remove the "number" coordinate and its cell method 

393 cube.remove_coord("number") 

394 cell_method = iris.coords.CellMethod(aggregator.name(), coords="number") 

395 cube = remove_cell_method(cube, cell_method) 

396 

397 aggregated_cubes.append(cube) 

398 

399 return aggregated_cubes