Coverage for src/CSET/operators/collapse.py: 93%

155 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 08:32 +0000

1# © Crown copyright, Met Office (2022-2025) 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 perform various kind of collapse on either 1 or 2 dimensions.""" 

16 

17import datetime 

18import logging 

19import warnings 

20 

21import iris 

22import iris.analysis 

23import iris.coord_categorisation 

24import iris.coords 

25import iris.cube 

26import iris.exceptions 

27import iris.util 

28import numpy as np 

29 

30from CSET._common import iter_maybe 

31from CSET.operators.aggregate import add_hour_coordinate 

32 

33logger = logging.getLogger(__name__) 

34 

35 

36def collapse( 

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

38 coordinate: str | list[str], 

39 method: str, 

40 additional_percent: float | None = None, 

41 **kwargs, 

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

43 """Collapse coordinate(s) of a single cube or of every cube in a cube list. 

44 

45 Collapses similar fields in each cube into a cube collapsing around the 

46 specified coordinate(s) and method. This could be a (weighted) mean or 

47 percentile. 

48 

49 Arguments 

50 --------- 

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

52 Cube or CubeList to collapse and iterate over one dimension 

53 coordinate: str | list[str] 

54 Coordinate(s) to collapse over e.g. 'time', 'longitude', 'latitude', 

55 'model_level_number', 'realization'. A list of multiple coordinates can 

56 be given. 

57 method: str 

58 Type of collapse i.e. method: 'MEAN', 'MAX', 'MIN', 'MEDIAN', 

59 'PERCENTILE' getattr creates iris.analysis.MEAN, etc. For PERCENTILE YAML 

60 file requires i.e. method: 'PERCENTILE' additional_percent: 90. 

61 additional_percent: float, optional 

62 Required for the PERCENTILE method. This is a number between 0 and 100. 

63 

64 Returns 

65 ------- 

66 collapsed_cubes: iris.cube.Cube | iris.cube.CubeList 

67 Single variable but several methods of aggregation 

68 

69 Raises 

70 ------ 

71 ValueError 

72 If additional_percent wasn't supplied while using PERCENTILE method. 

73 """ 

74 if method == "SEQ" or method == "" or method is None: 

75 return cubes 

76 if method == "PERCENTILE" and additional_percent is None: 

77 raise ValueError("Must specify additional_percent") 

78 

79 # Retain only common time points between different models if multiple model inputs. 

80 if isinstance(cubes, iris.cube.CubeList) and len(cubes) > 1: 

81 logger.debug("Extracting common time points as multiple model inputs detected.") 

82 for cube in cubes: 

83 cube.coord("forecast_reference_time").bounds = None 

84 cube.coord("forecast_period").bounds = None 

85 cubes = cubes.extract_overlapping( 

86 ["forecast_reference_time", "forecast_period"] 

87 ) 

88 if len(cubes) == 0: 

89 raise ValueError("No overlapping times detected in input cubes.") 

90 

91 collapsed_cubes = iris.cube.CubeList([]) 

92 with warnings.catch_warnings(): 

93 warnings.filterwarnings( 

94 "ignore", "Cannot check if coordinate is contiguous", UserWarning 

95 ) 

96 warnings.filterwarnings( 

97 "ignore", "Collapsing spatial coordinate.+without weighting", UserWarning 

98 ) 

99 for cube in iter_maybe(cubes): 

100 # Apply a mask to check for invalid data, this will allow NaNs to 

101 # be ignored. 

102 cube.data = np.ma.masked_invalid(cube.data) 

103 if method == "PERCENTILE": 

104 collapsed_cubes.append( 

105 cube.collapsed( 

106 coordinate, 

107 getattr(iris.analysis, method), 

108 percent=additional_percent, 

109 ) 

110 ) 

111 elif method == "RANGE": 

112 cube_max = cube.collapsed(coordinate, iris.analysis.MAX) 

113 cube_min = cube.collapsed(coordinate, iris.analysis.MIN) 

114 collapsed_cubes.append(cube_max - cube_min) 

115 else: 

116 collapsed_cubes.append( 

117 cube.collapsed(coordinate, getattr(iris.analysis, method)) 

118 ) 

119 if len(collapsed_cubes) == 1: 

120 return collapsed_cubes[0] 

121 else: 

122 return collapsed_cubes 

123 

124 

125def collapse_by_hour_of_day( 

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

127 method: str, 

128 additional_percent: float | None = None, 

129 **kwargs, 

130) -> iris.cube.Cube: 

131 """Collapse a cube by hour of the day. 

132 

133 Collapses a cube by hour of the day in the time coordinates provided by the 

134 model. It is useful for creating diurnal cycle plots. It aggregates all 00 

135 UTC together regardless of lead time. 

136 

137 Arguments 

138 --------- 

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

140 Cube to collapse and iterate over one dimension or CubeList to convert 

141 to a cube and then collapse prior to aggregating by hour. If a CubeList 

142 is provided each cube is handled separately. 

143 method: str 

144 Type of collapse i.e. method: 'MEAN', 'MAX', 'MIN', 'MEDIAN', 

145 'PERCENTILE'. For 'PERCENTILE' the additional_percent must be specified. 

146 

147 Returns 

148 ------- 

149 cube: iris.cube.Cube 

150 Single variable but several methods of aggregation. 

151 

152 Raises 

153 ------ 

154 ValueError 

155 If additional_percent wasn't supplied while using PERCENTILE method. 

156 

157 Notes 

158 ----- 

159 Collapsing of the cube is around the 'time' coordinate. The coordinates are 

160 first grouped by the hour of day, and then aggregated by the hour of day to 

161 create a diurnal cycle. This operator is applicable for both single 

162 forecasts and for multiple forecasts. The hour used is based on the units of 

163 the time coordinate. If the time coordinate is in UTC, hour will be in UTC. 

164 

165 To apply this operator successfully there must only be one time dimension. 

166 Should a MultiDim exception be raised the user first needs to apply the 

167 collapse operator to reduce the time dimensions before applying this 

168 operator. A cube containing the two time dimensions 

169 'forecast_reference_time' and 'forecast_period' will be automatically 

170 collapsed by lead time before being being collapsed by hour of day. 

171 """ 

172 if method == "PERCENTILE" and additional_percent is None: 

173 raise ValueError("Must specify additional_percent") 

174 

175 # Retain only common time points between different models if multiple model inputs. 

176 if isinstance(cubes, iris.cube.CubeList) and len(cubes) > 1: 

177 logger.debug("Extracting common time points as multiple model inputs detected.") 

178 for cube in cubes: 

179 cube.coord("forecast_reference_time").bounds = None 

180 cube.coord("forecast_period").bounds = None 

181 cubes = cubes.extract_overlapping( 

182 ["forecast_reference_time", "forecast_period"] 

183 ) 

184 if len(cubes) == 0: 

185 raise ValueError("No overlapping times detected in input cubes.") 

186 

187 collapsed_cubes = iris.cube.CubeList([]) 

188 for cube in iter_maybe(cubes): 

189 # Ensure hour coordinate in each input is sorted, and data adjusted if needed. 

190 sorted_cube = iris.cube.CubeList() 

191 for fcst_slice in cube.slices_over(["forecast_reference_time"]): 

192 # Categorise the time coordinate by hour of the day. 

193 fcst_slice = add_hour_coordinate(fcst_slice) 

194 if method == "PERCENTILE": 

195 by_hour = fcst_slice.aggregated_by( 

196 "hour", getattr(iris.analysis, method), percent=additional_percent 

197 ) 

198 else: 

199 by_hour = fcst_slice.aggregated_by( 

200 "hour", getattr(iris.analysis, method) 

201 ) 

202 # Compute if data needs sorting to lie in increasing order [0..23]. 

203 # Note multiple forecasts can sit in same cube spanning different 

204 # initialisation times and data ranges. 

205 time_points = by_hour.coord("hour").points 

206 time_points_sorted = np.sort(by_hour.coord("hour").points) 

207 if time_points[0] != time_points_sorted[0]: 207 ↛ 215line 207 didn't jump to line 215 because the condition on line 207 was always true

208 nroll = time_points[0] / (time_points[1] - time_points[0]) 

209 # Shift hour coordinate and data cube to be in time of day order. 

210 by_hour.coord("hour").points = np.roll(time_points, nroll, 0) 

211 by_hour.data = np.roll(by_hour.data, nroll, axis=0) 

212 

213 # Remove unnecessary time coordinate. 

214 # "hour" and "forecast_period" remain as AuxCoord. 

215 by_hour.remove_coord("time") 

216 

217 sorted_cube.append(by_hour) 

218 

219 # Recombine cube slices. 

220 cube = sorted_cube.merge_cube() 

221 

222 # Apply a mask to check for invalid data, this will allow NaNs to 

223 # be ignored. 

224 cube.data = np.ma.masked_invalid(cube.data) 

225 

226 if cube.coords("forecast_reference_time", dim_coords=True): 

227 # Collapse by forecast reference time to get a single cube. 

228 cube = collapse( 

229 cube, 

230 "forecast_reference_time", 

231 method, 

232 additional_percent=additional_percent, 

233 ) 

234 else: 

235 # Or remove forecast reference time if a single case, as collapse 

236 # will have effectively done this. 

237 cube.remove_coord("forecast_reference_time") 

238 

239 # Promote "hour" to dim_coord. 

240 iris.util.promote_aux_coord_to_dim_coord(cube, "hour") 

241 collapsed_cubes.append(cube) 

242 

243 if len(collapsed_cubes) == 1: 

244 return collapsed_cubes[0] 

245 else: 

246 return collapsed_cubes 

247 

248 

249def collapse_by_validity_time( 

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

251 method: str, 

252 additional_percent: float | None = None, 

253 **kwargs, 

254) -> iris.cube.Cube: 

255 """Collapse a cube around validity time for multiple cases. 

256 

257 First checks if the data can be aggregated easily. Then creates a new cube 

258 by slicing over the time dimensions, removing the time dimensions, 

259 re-merging the data, and creating a new time coordinate. It then collapses 

260 by the new time coordinate for a specified method using the collapse 

261 function. 

262 

263 Arguments 

264 --------- 

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

266 Cube to collapse by validity time or CubeList that will be converted 

267 to a cube before collapsing by validity time. 

268 method: str 

269 Type of collapse i.e. method: 'MEAN', 'MAX', 'MIN', 'MEDIAN', 

270 'PERCENTILE'. For 'PERCENTILE' the additional_percent must be specified. 

271 

272 Returns 

273 ------- 

274 cube: iris.cube.Cube | iris.cube.CubeList 

275 Single variable collapsed by lead time based on chosen method. 

276 

277 Raises 

278 ------ 

279 ValueError 

280 If additional_percent wasn't supplied while using PERCENTILE method. 

281 """ 

282 if method == "PERCENTILE" and additional_percent is None: 

283 raise ValueError("Must specify additional_percent") 

284 

285 collapsed_cubes = iris.cube.CubeList([]) 

286 for cube in iter_maybe(cubes): 

287 # Slice over cube by both time dimensions to create a CubeList. 

288 new_cubelist = iris.cube.CubeList( 

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

290 ) 

291 for sub_cube in new_cubelist: 

292 # Reconstruct the time coordinate if it is missing. 

293 if "time" not in [coord.name() for coord in sub_cube.coords()]: 

294 ref_time_coord = sub_cube.coord("forecast_reference_time") 

295 ref_units = ref_time_coord.units 

296 ref_time = ref_units.num2date(ref_time_coord.points) 

297 period_coord = sub_cube.coord("forecast_period") 

298 period_units = period_coord.units 

299 # Given how we are slicing there will only be one point. 

300 period_seconds = period_units.convert(period_coord.points[0], "seconds") 

301 period_duration = datetime.timedelta(seconds=period_seconds) 

302 time = ref_time + period_duration 

303 time_points = ref_units.date2num(time) 

304 time_coord = iris.coords.AuxCoord( 

305 points=time_points, standard_name="time", units=ref_units 

306 ) 

307 sub_cube.add_aux_coord(time_coord) 

308 # Remove forecast_period and forecast_reference_time coordinates. 

309 sub_cube.remove_coord("forecast_period") 

310 sub_cube.remove_coord("forecast_reference_time") 

311 # Create new CubeList by merging with unique = False to produce a validity 

312 # time cube. 

313 merged_list_1 = new_cubelist.merge(unique=False) 

314 # Create a new "fake" coordinate and apply to each remaining cube to allow 

315 # final merging to take place into a single cube. 

316 equalised_validity_time = iris.coords.AuxCoord( 

317 points=0, long_name="equalised_validity_time", units="1" 

318 ) 

319 for sub_cube, eq_valid_time in zip( 

320 merged_list_1, range(len(merged_list_1)), strict=True 

321 ): 

322 sub_cube.add_aux_coord(equalised_validity_time.copy(points=eq_valid_time)) 

323 

324 # Merge CubeList to create final cube. 

325 final_cube = merged_list_1.merge_cube() 

326 logger.debug("Pre-collapse validity time cube:\n%s", final_cube) 

327 

328 # Apply a mask to check for invalid data, this will allow NaNs to 

329 # be ignored. 

330 final_cube.data = np.ma.masked_invalid(final_cube.data) 

331 

332 # Collapse over equalised_validity_time as a proxy for equal validity 

333 # time. 

334 try: 

335 collapsed_cube = collapse( 

336 final_cube, 

337 "equalised_validity_time", 

338 method, 

339 additional_percent=additional_percent, 

340 ) 

341 except iris.exceptions.CoordinateCollapseError as err: 

342 raise ValueError( 

343 "Cubes do not overlap therefore cannot collapse across validity time." 

344 ) from err 

345 collapsed_cube.remove_coord("equalised_validity_time") 

346 collapsed_cubes.append(collapsed_cube) 

347 

348 if len(collapsed_cubes) == 1: 

349 return collapsed_cubes[0] 

350 else: 

351 return collapsed_cubes 

352 

353 

354def proportion( 

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

356 coordinate: str | list[str], 

357 condition: str, 

358 threshold: float, 

359 **kwargs, 

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

361 """Find the proportion of an event for all cubes. 

362 

363 Find the proportion of points at a specified threhsold in each cube into a 

364 cube collapsing around the specified coordinate(s). 

365 

366 Arguments 

367 --------- 

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

369 Cube or CubeList to collapse and iterate over one dimension 

370 coordinate: str | list[str] 

371 Coordinate(s) to collapse over e.g. 'time', 'longitude', 'latitude', 

372 'model_level_number', 'realization'. A list of multiple coordinates can 

373 be given. 

374 condition: str 

375 The condition for the event. Expected arguments are eq, ne, lt, gt, le, ge. 

376 The letters correspond to the following conditions 

377 eq: equal to; 

378 ne: not equal to; 

379 lt: less than; 

380 gt: greater than; 

381 le: less than or equal to; 

382 ge: greater than or equal to. 

383 threshold: float 

384 The value for the event. 

385 

386 Returns 

387 ------- 

388 collapsed_cubes: iris.cube.Cube | iris.cube.CubeList 

389 The proportion of the event. 

390 """ 

391 # Set method 

392 method = "PROPORTION" 

393 # Retain only common time points between different models if multiple model inputs. 

394 if isinstance(cubes, iris.cube.CubeList) and len(cubes) > 1: 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true

395 logger.debug("Extracting common time points as multiple model inputs detected.") 

396 for cube in cubes: 

397 cube.coord("forecast_reference_time").bounds = None 

398 cube.coord("forecast_period").bounds = None 

399 cubes = cubes.extract_overlapping( 

400 ["forecast_reference_time", "forecast_period"] 

401 ) 

402 if len(cubes) == 0: 

403 raise ValueError("No overlapping times detected in input cubes.") 

404 

405 collapsed_cubes = iris.cube.CubeList([]) 

406 with warnings.catch_warnings(): 

407 warnings.filterwarnings( 

408 "ignore", "Cannot check if coordinate is contiguous", UserWarning 

409 ) 

410 warnings.filterwarnings( 

411 "ignore", "Collapsing spatial coordinate.+without weighting", UserWarning 

412 ) 

413 for cube in iter_maybe(cubes): 

414 # Apply a mask to check for invalid data, this will allow NaNs to 

415 # be ignored. 

416 cube.data = np.ma.masked_invalid(cube.data) 

417 match condition: 

418 case "eq": 

419 new_cube = cube.collapsed( 

420 coordinate, 

421 getattr(iris.analysis, method), 

422 function=lambda values: values == threshold, 

423 ) 

424 case "ne": 

425 new_cube = cube.collapsed( 

426 coordinate, 

427 getattr(iris.analysis, method), 

428 function=lambda values: values != threshold, 

429 ) 

430 case "gt": 

431 new_cube = cube.collapsed( 

432 coordinate, 

433 getattr(iris.analysis, method), 

434 function=lambda values: values > threshold, 

435 ) 

436 case "ge": 

437 new_cube = cube.collapsed( 

438 coordinate, 

439 getattr(iris.analysis, method), 

440 function=lambda values: values >= threshold, 

441 ) 

442 case "lt": 

443 new_cube = cube.collapsed( 

444 coordinate, 

445 getattr(iris.analysis, method), 

446 function=lambda values: values < threshold, 

447 ) 

448 case "le": 

449 new_cube = cube.collapsed( 

450 coordinate, 

451 getattr(iris.analysis, method), 

452 function=lambda values: values <= threshold, 

453 ) 

454 case _: 

455 raise ValueError( 

456 """Unexpected value for condition. Expected eq, ne, gt, ge, lt, le. Got {condition}.""" 

457 ) 

458 name = cube.long_name if cube.long_name else cube.name() 

459 new_cube.rename(f"probability_of_{name}_{condition}_{threshold}") 

460 new_cube.units = "1" 

461 collapsed_cubes.append(new_cube) 

462 

463 if len(collapsed_cubes) == 1: 463 ↛ 466line 463 didn't jump to line 466 because the condition on line 463 was always true

464 return collapsed_cubes[0] 

465 else: 

466 return collapsed_cubes 

467 

468 

469# TODO 

470# Collapse function that calculates means, medians etc across members of an 

471# ensemble or stratified groups. Need to allow collapse over realisation 

472# dimension for fixed time. Hence will require reading in of CubeList