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

164 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-08 11:47 +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 is_power_spectrum = any( 

82 cubes[0].coords(coord) 

83 for coord in ["frequency", "physical_wavenumber", "wavelength"] 

84 ) 

85 if is_power_spectrum: 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true

86 for cube in cubes: 

87 cube.coord("time").bounds = None 

88 cubes = cubes.extract_overlapping(["time"]) 

89 

90 for cube in cubes: 

91 t = cube.coord("time") 

92 t.points = t.points.astype(np.float64) 

93 

94 if t.bounds is not None: 

95 t.bounds = t.bounds.astype(np.float64) 

96 

97 else: 

98 for cube in cubes: 

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

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

101 cubes = cubes.extract_overlapping( 

102 ["forecast_reference_time", "forecast_period"] 

103 ) 

104 

105 if len(cubes) == 0: 

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

107 

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

109 with warnings.catch_warnings(): 

110 warnings.filterwarnings( 

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

112 ) 

113 warnings.filterwarnings( 

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

115 ) 

116 for cube in iter_maybe(cubes): 

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

118 # be ignored. 

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

120 if method == "PERCENTILE": 

121 collapsed_cubes.append( 

122 cube.collapsed( 

123 coordinate, 

124 getattr(iris.analysis, method), 

125 percent=additional_percent, 

126 ) 

127 ) 

128 elif method == "RANGE": 

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

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

131 collapsed_cubes.append(cube_max - cube_min) 

132 else: 

133 collapsed_cubes.append( 

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

135 ) 

136 if len(collapsed_cubes) == 1: 

137 return collapsed_cubes[0] 

138 else: 

139 return collapsed_cubes 

140 

141 

142def collapse_by_hour_of_day( 

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

144 method: str, 

145 additional_percent: float | None = None, 

146 **kwargs, 

147) -> iris.cube.Cube: 

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

149 

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

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

152 UTC together regardless of lead time. 

153 

154 Arguments 

155 --------- 

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

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

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

159 is provided each cube is handled separately. 

160 method: str 

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

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

163 

164 Returns 

165 ------- 

166 cube: iris.cube.Cube 

167 Single variable but several methods of aggregation. 

168 

169 Raises 

170 ------ 

171 ValueError 

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

173 

174 Notes 

175 ----- 

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

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

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

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

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

181 

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

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

184 collapse operator to reduce the time dimensions before applying this 

185 operator. A cube containing the two time dimensions 

186 'forecast_reference_time' and 'forecast_period' will be automatically 

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

188 """ 

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

190 raise ValueError("Must specify additional_percent") 

191 

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

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

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

195 for cube in cubes: 

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

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

198 cubes = cubes.extract_overlapping( 

199 ["forecast_reference_time", "forecast_period"] 

200 ) 

201 if len(cubes) == 0: 

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

203 

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

205 for cube in iter_maybe(cubes): 

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

207 sorted_cube = iris.cube.CubeList() 

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

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

210 fcst_slice = add_hour_coordinate(fcst_slice) 

211 if method == "PERCENTILE": 

212 by_hour = fcst_slice.aggregated_by( 

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

214 ) 

215 else: 

216 by_hour = fcst_slice.aggregated_by( 

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

218 ) 

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

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

221 # initialisation times and data ranges. 

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

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

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

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

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

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

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

229 

230 # Remove unnecessary time coordinate. 

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

232 by_hour.remove_coord("time") 

233 

234 sorted_cube.append(by_hour) 

235 

236 # Recombine cube slices. 

237 cube = sorted_cube.merge_cube() 

238 

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

240 # be ignored. 

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

242 

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

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

245 cube = collapse( 

246 cube, 

247 "forecast_reference_time", 

248 method, 

249 additional_percent=additional_percent, 

250 ) 

251 else: 

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

253 # will have effectively done this. 

254 cube.remove_coord("forecast_reference_time") 

255 

256 # Promote "hour" to dim_coord. 

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

258 collapsed_cubes.append(cube) 

259 

260 if len(collapsed_cubes) == 1: 

261 return collapsed_cubes[0] 

262 else: 

263 return collapsed_cubes 

264 

265 

266def collapse_by_validity_time( 

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

268 method: str, 

269 additional_percent: float | None = None, 

270 **kwargs, 

271) -> iris.cube.Cube: 

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

273 

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

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

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

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

278 function. 

279 

280 Arguments 

281 --------- 

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

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

284 to a cube before collapsing by validity time. 

285 method: str 

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

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

288 

289 Returns 

290 ------- 

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

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

293 

294 Raises 

295 ------ 

296 ValueError 

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

298 """ 

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

300 raise ValueError("Must specify additional_percent") 

301 

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

303 for cube in iter_maybe(cubes): 

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

305 new_cubelist = iris.cube.CubeList( 

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

307 ) 

308 for sub_cube in new_cubelist: 

309 # Reconstruct the time coordinate if it is missing. 

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

311 ref_time_coord = sub_cube.coord("forecast_reference_time") 

312 ref_units = ref_time_coord.units 

313 ref_time = ref_units.num2date(ref_time_coord.points) 

314 period_coord = sub_cube.coord("forecast_period") 

315 period_units = period_coord.units 

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

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

318 period_duration = datetime.timedelta(seconds=period_seconds) 

319 time = ref_time + period_duration 

320 time_points = ref_units.date2num(time) 

321 time_coord = iris.coords.AuxCoord( 

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

323 ) 

324 sub_cube.add_aux_coord(time_coord) 

325 # Remove forecast_period and forecast_reference_time coordinates. 

326 sub_cube.remove_coord("forecast_period") 

327 sub_cube.remove_coord("forecast_reference_time") 

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

329 # time cube. 

330 merged_list_1 = new_cubelist.merge(unique=False) 

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

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

333 equalised_validity_time = iris.coords.AuxCoord( 

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

335 ) 

336 for sub_cube, eq_valid_time in zip( 

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

338 ): 

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

340 

341 # Merge CubeList to create final cube. 

342 final_cube = merged_list_1.merge_cube() 

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

344 

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

346 # be ignored. 

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

348 

349 # Collapse over equalised_validity_time as a proxy for equal validity 

350 # time. 

351 try: 

352 collapsed_cube = collapse( 

353 final_cube, 

354 "equalised_validity_time", 

355 method, 

356 additional_percent=additional_percent, 

357 ) 

358 except iris.exceptions.CoordinateCollapseError as err: 

359 raise ValueError( 

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

361 ) from err 

362 collapsed_cube.remove_coord("equalised_validity_time") 

363 collapsed_cubes.append(collapsed_cube) 

364 

365 if len(collapsed_cubes) == 1: 

366 return collapsed_cubes[0] 

367 else: 

368 return collapsed_cubes 

369 

370 

371def proportion( 

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

373 coordinate: str | list[str], 

374 condition: str, 

375 threshold: float, 

376 **kwargs, 

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

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

379 

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

381 cube collapsing around the specified coordinate(s). 

382 

383 Arguments 

384 --------- 

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

386 Cube or CubeList to collapse and iterate over one dimension 

387 coordinate: str | list[str] 

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

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

390 be given. 

391 condition: str 

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

393 The letters correspond to the following conditions 

394 eq: equal to; 

395 ne: not equal to; 

396 lt: less than; 

397 gt: greater than; 

398 le: less than or equal to; 

399 ge: greater than or equal to. 

400 threshold: float 

401 The value for the event. 

402 

403 Returns 

404 ------- 

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

406 The proportion of the event. 

407 """ 

408 # Set method 

409 method = "PROPORTION" 

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

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

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

413 for cube in cubes: 

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

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

416 cubes = cubes.extract_overlapping( 

417 ["forecast_reference_time", "forecast_period"] 

418 ) 

419 if len(cubes) == 0: 

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

421 

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

423 with warnings.catch_warnings(): 

424 warnings.filterwarnings( 

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

426 ) 

427 warnings.filterwarnings( 

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

429 ) 

430 for cube in iter_maybe(cubes): 

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

432 # be ignored. 

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

434 match condition: 

435 case "eq": 

436 new_cube = cube.collapsed( 

437 coordinate, 

438 getattr(iris.analysis, method), 

439 function=lambda values: values == threshold, 

440 ) 

441 case "ne": 

442 new_cube = cube.collapsed( 

443 coordinate, 

444 getattr(iris.analysis, method), 

445 function=lambda values: values != threshold, 

446 ) 

447 case "gt": 

448 new_cube = cube.collapsed( 

449 coordinate, 

450 getattr(iris.analysis, method), 

451 function=lambda values: values > threshold, 

452 ) 

453 case "ge": 

454 new_cube = cube.collapsed( 

455 coordinate, 

456 getattr(iris.analysis, method), 

457 function=lambda values: values >= threshold, 

458 ) 

459 case "lt": 

460 new_cube = cube.collapsed( 

461 coordinate, 

462 getattr(iris.analysis, method), 

463 function=lambda values: values < threshold, 

464 ) 

465 case "le": 

466 new_cube = cube.collapsed( 

467 coordinate, 

468 getattr(iris.analysis, method), 

469 function=lambda values: values <= threshold, 

470 ) 

471 case _: 

472 raise ValueError( 

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

474 ) 

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

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

477 new_cube.units = "1" 

478 collapsed_cubes.append(new_cube) 

479 

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

481 return collapsed_cubes[0] 

482 else: 

483 return collapsed_cubes 

484 

485 

486# TODO 

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

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

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