Coverage for src/CSET/operators/precipitation.py: 99%

204 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-16 13:38 +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"""Operators to perform various kinds of image processing.""" 

16 

17from typing import Literal 

18 

19import iris 

20import iris.cube 

21import numpy as np 

22from skimage.measure import label 

23 

24from CSET._common import iter_maybe 

25from CSET.operators.wind import calculate_vector_wind 

26 

27 

28def MAUL_properties( 

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

30 u_cubes: iris.cube.Cube | iris.cube.CubeList, 

31 v_cubes: iris.cube.Cube | iris.cube.CubeList, 

32 output: Literal["number", "base", "depth", "wind_below", "directional_shear"], 

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

34 """Identify properties of Moist Absolutely Unstable Layers. 

35 

36 Parameters 

37 ---------- 

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

39 A cube or cubelist of a mask(s) as to whether a MAUL exists. 

40 This input must be a binary field. 

41 u_cubes: iris.cube.Cube | iris.cube.CubeList 

42 A cube or cubelist of the wind in the u direction. 

43 v_cubes: iris.cube.Cube | iris.cube.CubeList 

44 A cube or cubelist of the wind in the v direction. 

45 output: Literal["number", "base", "depth", "wind_below", "directional_shear"] 

46 The output is the desired property required. It can be 

47 number, base, depth for the number of MAULs, base height 

48 of the deepest MAUL, the depth of the deepest MAUL, the 

49 average windspeed below the MAUL, or the difference in 

50 wind direction across the MAUL, respectively. 

51 

52 

53 Returns 

54 ------- 

55 cube: iris.cube.Cube | iris.cube.CubeList 

56 A Cube or CubeList depending upon the output specified. 

57 

58 Raises 

59 ------ 

60 ValueError: Data contains values that are not 0 or 1, only masked data should be used. 

61 This error is raised when a mask field is not provided to the operator. 

62 ValueError: Unexpected value for output. Expected number, base, depth, wind_below or directional_shear. Got {output}. 

63 This error is raised when the wrong output string is specified. 

64 

65 Notes 

66 ----- 

67 Having been provided with a mask field for identifying whether Moist 

68 Absolutely Unstable Layers (MAULs) are present, based on criteria 

69 set out in a recipe. The operator applies image processing to the mask 

70 to each point in the latitude/longitude coordinates. It uses the image 

71 processing to identify continuous layers (1s), and labels them. 

72 It identifies the number of layers by identifying the maximum label number, 

73 and then finds the top and base of each layer. It will also find the average 

74 windspeed below the MAUL for indications of presence of low-level jets. The 

75 change in wind direction across the MAUL (top - base) is also calculated. 

76 Depending on the output desired it will output information for the deepest MAUL. 

77 

78 When a MAUL is not present the output will be set to NaN for depth, base, wind below 

79 and directional shear. Should the MAUL start at the surface the wind below will also 

80 be set to NaN. If number of MAULs is the desired output it will be set to zero. 

81 

82 The MAUL diagnostic is applicable anywhere in the globe and across all scales. 

83 The properties used here are based upon [Daviesetal24]_ and [Daviesetal26]_. 

84 

85 References 

86 ---------- 

87 .. [Daviesetal24] Davies, P.A., Fowler, H.J, Villalobos-Herrera, R., 

88 Slingo, J., Flack, D.L.A., and Taszarek, M (2024) 

89 "A New Conceptual Model for Understanding and Predicting Life-Threatening 

90 Rainfall Extremes." Weather and Climate Extremes, vol. 45, 100696, 

91 doi: 10.1016/j.wace.2024.100696 

92 .. [Daviesetal26] Davies, P. A., Flack, D. L. A., Pirret, J., Fowler, H. J. 

93 (2026) "Application of the Davies Four-Stage Conceptual Model for 

94 Life-Threatening Rainfall Extremes on the April 2024 United Arab Emirates 

95 and Oman Floods." Weather and Climate Extremes, vol. 51, 100846. 

96 doi:10.1016/j.wace.2025.100846 

97 

98 Examples 

99 -------- 

100 >>> No_MAULs = precipitation.MAUL_properties(maul_mask, u, v, output="number") 

101 >>> MAUL_base = precipitation.MAUL_properties(maul_mask, u, v, output="base") 

102 >>> MAUL_depth = precipitation.MAUL_properties(maul_mask, u, v, output="depth") 

103 >>> Ave_windspeed_below_MAUL = precipitation.MAUL_properties(maul_mask, u, v, output="wind_below") 

104 >>> Direction_shear_across_MAUL = precipitation.MAUL_properties(maul_mask, u, v, output="directional_shear") 

105 """ 

106 num_MAULs = iris.cube.CubeList([]) 

107 maul_d = iris.cube.CubeList([]) 

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

109 windspeed_below_MAUL = iris.cube.CubeList([]) 

110 directional_shear_across_MAUL = iris.cube.CubeList([]) 

111 if output not in ("number", "base", "depth", "wind_below", "directional_shear"): 

112 raise ValueError( 

113 f"""Unexpected value for output. Expected number, base, depth, wind_below or directional_shear. Got {output}.""" 

114 ) 

115 

116 for cube, u, v in zip( 

117 iter_maybe(cubes), iter_maybe(u_cubes), iter_maybe(v_cubes), strict=True 

118 ): 

119 # Check for binary fields. 

120 if not np.array_equal(cube.data, cube.data.astype(bool)): 

121 raise ValueError( 

122 "Data contains values that are not 0 or 1, only masked data should be used." 

123 ) 

124 # Create dummy cubes to store the output. The shape of the dummy cube 

125 # depends upon which dimensions are present in the mask cube. 

126 number_of_MAULs = next(cube.slices_over("model_level_number")).copy() 

127 number_of_MAULs.data[:] = 0.0 

128 maul_depth = number_of_MAULs.copy() 

129 maul_base = number_of_MAULs.copy() 

130 wind_below_maul = number_of_MAULs.copy() 

131 directional_shear = number_of_MAULs.copy() 

132 # Calculate windspeed and direction. 

133 windspeed_and_direction = calculate_vector_wind(u, v) 

134 # Select windspeed, hard coded as always in same position from output 

135 # of calculate_vector_wind. 

136 windspeed = windspeed_and_direction[0] 

137 # As above but for wind direction. 

138 direction = windspeed_and_direction[1] 

139 # Ensure direction in range +/- 180 for difference calculations. 

140 direction.data[direction.data > 180.0] -= 360.0 

141 # Loop over realization. 

142 for mem_number, member in enumerate(cube.slices_over("realization")): 

143 # Loop over time. 

144 for time_point, time in enumerate(member.slices_over("time")): 

145 # Loop over latitude. 

146 for lat_point, lat in enumerate(time.slices_over("latitude")): 

147 # Loop over longitude. 

148 for lon_point, lon in enumerate(lat.slices_over("longitude")): 

149 # Label each object in the vertical. 

150 labels = label(lon.core_data()) 

151 # Finds the number of MAULs present based upon the 

152 # number of objects identified, if no MAUL is present 

153 # the value is set to zero. 

154 # The code checks for whether there are multiple 

155 # realization and/or time points for correct 

156 # indexing of the output data and applies accordingly. 

157 if ( 

158 len(number_of_MAULs.coord("realization").points) != 1 

159 and len(number_of_MAULs.coord("time").points) != 1 

160 ): 

161 number_of_MAULs.data[ 

162 mem_number, time_point, lat_point, lon_point 

163 ] = np.max(labels) 

164 elif ( 

165 len(number_of_MAULs.coord("realization").points) != 1 

166 and len(number_of_MAULs.coord("time").points) == 1 

167 ): 

168 number_of_MAULs.data[mem_number, lat_point, lon_point] = ( 

169 np.max(labels) 

170 ) 

171 elif ( 

172 len(number_of_MAULs.coord("time").points) != 1 

173 and len(number_of_MAULs.coord("realization").points) == 1 

174 ): 

175 number_of_MAULs.data[time_point, lat_point, lon_point] = ( 

176 np.max(labels) 

177 ) 

178 else: 

179 number_of_MAULs.data[lat_point, lon_point] = np.max(labels) 

180 if output not in ("number", "wind_below", "directional_shear"): 

181 # Find the base, top, and depth for each object 

182 # using cube metadata. 

183 maul_start = [] 

184 maul_end = [] 

185 maul_dep = [] 

186 # Loop over the number of MAULs (plus one to ensure 

187 # the case for only one MAUL being present). 

188 for maul in range(1, np.max(labels) + 1): 

189 # Find all vertical indices belonging to a MAUL. 

190 maul_range = np.where(labels == maul) 

191 # Find the height at the base of the MAUL 

192 # (lowest level). 

193 maul_start_point = lon.coord("level_height").points[ 

194 maul_range[0][0] 

195 ] 

196 # Find the height at the top of the MAUL 

197 # (highest level). 

198 maul_end_point = lon.coord("level_height").points[ 

199 maul_range[0][-1] 

200 ] 

201 # Calculate the MAUL depth, and store 

202 # base and top heights. 

203 maul_dep.append(maul_end_point - maul_start_point) 

204 maul_start.append(maul_start_point) 

205 maul_end.append(maul_end_point) 

206 try: 

207 # Idendtify where the deepest MAUL is. 

208 index = int( 

209 np.where(maul_dep == np.max(maul_dep))[0][0] 

210 ) 

211 # As with number the code checks for whether 

212 # there are multiple realization and/or time 

213 # points for correct indexing of the output data 

214 # and applies accordingly. 

215 if ( 

216 len(number_of_MAULs.coord("realization").points) 

217 != 1 

218 and len(number_of_MAULs.coord("time").points) != 1 

219 ): 

220 # Store the deepest MAUL. 

221 maul_depth.data[ 

222 mem_number, time_point, lat_point, lon_point 

223 ] = np.max(maul_dep) 

224 # Store the base height of the deepest MAUL. 

225 maul_base.data[ 

226 mem_number, time_point, lat_point, lon_point 

227 ] = maul_start[index] 

228 elif ( 

229 len(number_of_MAULs.coord("realization").points) 

230 != 1 

231 and len(number_of_MAULs.coord("time").points) == 1 

232 ): 

233 maul_depth.data[ 

234 mem_number, lat_point, lon_point 

235 ] = np.max(maul_dep) 

236 maul_base.data[mem_number, lat_point, lon_point] = ( 

237 maul_start[index] 

238 ) 

239 elif ( 

240 len(number_of_MAULs.coord("time").points) != 1 

241 and len(number_of_MAULs.coord("realization").points) 

242 == 1 

243 ): 

244 maul_depth.data[ 

245 time_point, lat_point, lon_point 

246 ] = np.max(maul_dep) 

247 maul_base.data[time_point, lat_point, lon_point] = ( 

248 maul_start[index] 

249 ) 

250 else: 

251 maul_depth.data[lat_point, lon_point] = np.max( 

252 maul_dep 

253 ) 

254 maul_base.data[lat_point, lon_point] = maul_start[ 

255 index 

256 ] 

257 # Here a ValueError is raised if a MAUL is not found, however 

258 # this is a valid answer, and so output data is set to NaN. 

259 # The dimensionality logic for output data is identical 

260 # to that used previously. 

261 except ValueError: 

262 if ( 

263 len(number_of_MAULs.coord("realization").points) 

264 != 1 

265 and len(number_of_MAULs.coord("time").points) != 1 

266 ): 

267 maul_depth.data[ 

268 mem_number, time_point, lat_point, lon_point 

269 ] = np.nan 

270 maul_base.data[ 

271 mem_number, time_point, lat_point, lon_point 

272 ] = np.nan 

273 elif ( 

274 len(number_of_MAULs.coord("realization").points) 

275 != 1 

276 and len(number_of_MAULs.coord("time").points) == 1 

277 ): 

278 maul_depth.data[ 

279 mem_number, lat_point, lon_point 

280 ] = np.nan 

281 maul_base.data[mem_number, lat_point, lon_point] = ( 

282 np.nan 

283 ) 

284 elif ( 

285 len(number_of_MAULs.coord("time").points) != 1 

286 and len(number_of_MAULs.coord("realization").points) 

287 == 1 

288 ): 

289 maul_depth.data[ 

290 time_point, lat_point, lon_point 

291 ] = np.nan 

292 maul_base.data[time_point, lat_point, lon_point] = ( 

293 np.nan 

294 ) 

295 else: 

296 maul_depth.data[lat_point, lon_point] = np.nan 

297 maul_base.data[lat_point, lon_point] = np.nan 

298 # Separate loop for calculating wind properties. 

299 elif output not in ("number"): 

300 # Find the base, top, and depth for each object 

301 # using cube metadata. 

302 maul_start = [] 

303 maul_end = [] 

304 maul_dep = [] 

305 # Loop over the number of MAULs. The loop starts 

306 # at one as a value of zero implies there is not 

307 # a MAUL present, so the first MAUL is one. 

308 # Given this labelling convention plus one is required 

309 # to ensure that the correct number of MAULs are 

310 # looped over. 

311 for maul in range(1, np.max(labels) + 1): 

312 # Find all vertical indices belonging to a MAUL. 

313 maul_range = np.where(labels == maul) 

314 # Find the height at the base of the MAUL 

315 # (lowest level). 

316 maul_start_point = lon.coord("level_height").points[ 

317 maul_range[0][0] 

318 ] 

319 # Find the height at the top of the MAUL 

320 # (highest level). 

321 maul_end_point = lon.coord("level_height").points[ 

322 maul_range[0][-1] 

323 ] 

324 # Calculate the MAUL depth, and store 

325 # base and top heights. 

326 maul_dep.append(maul_end_point - maul_start_point) 

327 maul_start.append(maul_start_point) 

328 maul_end.append(maul_end_point) 

329 try: 

330 # Identify where the deepest MAUL is. 

331 index = np.argmax(maul_dep) 

332 maul_base_value = maul_start[index] 

333 maul_top_value = maul_end[index] 

334 height_index = np.abs( 

335 lon.coord("level_height").points - maul_base_value 

336 ).argmin() 

337 top_index = np.abs( 

338 lon.coord("level_height").points - maul_top_value 

339 ).argmin() 

340 # As with number the code checks for whether 

341 # there are multiple realization and/or time 

342 # points for correct indexing of the output data 

343 # and applies accordingly. 

344 if ( 

345 len(number_of_MAULs.coord("realization").points) 

346 != 1 

347 and len(number_of_MAULs.coord("time").points) != 1 

348 ): 

349 # Store and calculate the windspeed below the 

350 # deepest MAUL. 

351 wind_below_maul.data[ 

352 mem_number, time_point, lat_point, lon_point 

353 ] = np.mean( 

354 windspeed[ 

355 mem_number, 

356 time_point, 

357 0:height_index, 

358 lat_point, 

359 lon_point, 

360 ].data 

361 ) 

362 # Store and calculate the directional wind shear (difference) 

363 # across the deepest MAUL from the top to the bottom. 

364 directional_shear.data[ 

365 mem_number, time_point, lat_point, lon_point 

366 ] = ( 

367 direction[ 

368 mem_number, 

369 time_point, 

370 top_index, 

371 lat_point, 

372 lon_point, 

373 ].data 

374 - direction[ 

375 mem_number, 

376 time_point, 

377 height_index, 

378 lat_point, 

379 lon_point, 

380 ].data 

381 ) 

382 elif ( 

383 len(number_of_MAULs.coord("realization").points) 

384 != 1 

385 and len(number_of_MAULs.coord("time").points) == 1 

386 ): 

387 wind_below_maul.data[ 

388 mem_number, lat_point, lon_point 

389 ] = np.mean( 

390 windspeed[ 

391 mem_number, 

392 0:height_index, 

393 lat_point, 

394 lon_point, 

395 ].data 

396 ) 

397 directional_shear.data[ 

398 mem_number, lat_point, lon_point 

399 ] = ( 

400 direction[ 

401 mem_number, top_index, lat_point, lon_point 

402 ].data 

403 - direction[ 

404 mem_number, 

405 height_index, 

406 lat_point, 

407 lon_point, 

408 ].data 

409 ) 

410 elif ( 

411 len(number_of_MAULs.coord("time").points) != 1 

412 and len(number_of_MAULs.coord("realization").points) 

413 == 1 

414 ): 

415 wind_below_maul.data[ 

416 time_point, lat_point, lon_point 

417 ] = np.mean( 

418 windspeed[ 

419 time_point, 

420 0:height_index, 

421 lat_point, 

422 lon_point, 

423 ].data 

424 ) 

425 directional_shear.data[ 

426 time_point, lat_point, lon_point 

427 ] = ( 

428 direction[ 

429 time_point, top_index, lat_point, lon_point 

430 ].data 

431 - direction[ 

432 time_point, 

433 height_index, 

434 lat_point, 

435 lon_point, 

436 ].data 

437 ) 

438 else: 

439 wind_below_maul.data[lat_point, lon_point] = ( 

440 np.mean( 

441 windspeed[ 

442 0:height_index, lat_point, lon_point 

443 ].data 

444 ) 

445 ) 

446 directional_shear.data[lat_point, lon_point] = ( 

447 direction[top_index, lat_point, lon_point].data 

448 - direction[ 

449 height_index, lat_point, lon_point 

450 ].data 

451 ) 

452 

453 # Here a ValueError is raised if a MAUL is not found, or an 

454 # IndexError if the MAUL starts at the surface and so there 

455 # is no wind below the MAUL however these are a valid answers, 

456 # and so output data is set to NaN. 

457 # The dimensionality logic for output data is identical 

458 # to that used previously. 

459 except (ValueError, IndexError): 

460 if ( 

461 len(number_of_MAULs.coord("realization").points) 

462 != 1 

463 and len(number_of_MAULs.coord("time").points) != 1 

464 ): 

465 wind_below_maul.data[ 

466 mem_number, time_point, lat_point, lon_point 

467 ] = np.nan 

468 directional_shear.data[ 

469 mem_number, time_point, lat_point, lon_point 

470 ] = np.nan 

471 elif ( 

472 len(number_of_MAULs.coord("realization").points) 

473 != 1 

474 and len(number_of_MAULs.coord("time").points) == 1 

475 ): 

476 wind_below_maul.data[ 

477 mem_number, lat_point, lon_point 

478 ] = np.nan 

479 directional_shear.data[ 

480 mem_number, lat_point, lon_point 

481 ] = np.nan 

482 elif ( 

483 len(number_of_MAULs.coord("time").points) != 1 

484 and len(number_of_MAULs.coord("realization").points) 

485 == 1 

486 ): 

487 wind_below_maul.data[ 

488 time_point, lat_point, lon_point 

489 ] = np.nan 

490 directional_shear.data[ 

491 time_point, lat_point, lon_point 

492 ] = np.nan 

493 else: 

494 wind_below_maul.data[lat_point, lon_point] = np.nan 

495 directional_shear.data[lat_point, lon_point] = ( 

496 np.nan 

497 ) 

498 

499 # Ensure directional shear differences are in range +/- 180 degrees. 

500 directional_shear.data[directional_shear.data > 180.0] -= 360.0 

501 directional_shear.data[directional_shear.data < -180.0] += 360.0 

502 

503 # Units and renaming for number, depth and base (the other case). 

504 match output: 

505 case "number": 

506 number_of_MAULs.units = "1" 

507 number_of_MAULs.rename("Number_of_MAULs") 

508 num_MAULs.append(number_of_MAULs) 

509 case "depth": 

510 maul_depth.units = "m" 

511 maul_depth.rename("MAUL_depth") 

512 maul_d.append(maul_depth) 

513 case "base": 

514 maul_base.units = "m" 

515 maul_base.rename("MAUL_base_height") 

516 maul_b.append(maul_base) 

517 case "wind_below": 

518 wind_below_maul.units = "m s^-1" 

519 wind_below_maul.rename("windspeed_below_MAUL") 

520 windspeed_below_MAUL.append(wind_below_maul) 

521 case _: 

522 directional_shear.units = "degrees" 

523 directional_shear.rename("directional_shear_across_MAUL") 

524 directional_shear_across_MAUL.append(directional_shear) 

525 

526 # Output data. 

527 match output: 

528 case "number" if len(num_MAULs) == 1: 

529 return num_MAULs[0] 

530 case "number": 

531 return num_MAULs 

532 case "depth" if len(maul_d) == 1: 

533 return maul_d[0] 

534 case "depth": 

535 return maul_d 

536 case "base" if len(maul_b) == 1: 

537 return maul_b[0] 

538 case "base": 

539 return maul_b 

540 case "wind_below" if len(windspeed_below_MAUL) == 1: 

541 return windspeed_below_MAUL[0] 

542 case "wind_below": 542 ↛ 543line 542 didn't jump to line 543 because the pattern on line 542 never matched

543 return windspeed_below_MAUL 

544 case "directional_shear" if len(directional_shear_across_MAUL) == 1: 

545 return directional_shear_across_MAUL[0] 

546 case _: 

547 return directional_shear_across_MAUL 

548 

549 

550def convert_rainfall_depth_to_rate(cubes, **kwargs): 

551 """Convert rainfall depth to rate. 

552 

553 Convert rainfall depth (e.g. mm or kg m-2) 

554 over a time interval into a rainfall rate (kg m-2 s-1). 

555 

556 The conversion uses the duration associated with the time coordinate: 

557 - If time bounds are present, the bounds define the accumulation interval 

558 - Otherwise, the interval is inferred from differences between time points 

559 

560 Arguments 

561 --------- 

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

563 Cube(s) containing rainfall accumulation (depth), with units convertible 

564 to kg m-2 (equivalent to mm of water). 

565 

566 Each cube must include a time coordinate, optionally with bounds. 

567 

568 kwargs: 

569 Additional keyword arguments (currently unused, present for API compatibility). 

570 

571 Returns 

572 ------- 

573 iris.cube.Cube | iris.cube.CubeList 

574 Cube(s) with rainfall expressed as a rate in kg m-2 s-1. 

575 

576 The returned object matches the type of the input: 

577 - single Cube → single Cube 

578 - CubeList → CubeList 

579 

580 Raises 

581 ------ 

582 ValueError 

583 - If no time coordinate is present 

584 - If only a single time point is available without bounds 

585 - If any inferred duration is non-positive 

586 

587 Notes 

588 ----- 

589 - Conversion relies on the equivalence: 

590 1 mm of rainfall ≡ 1 kg m-2 

591 - iris does not know that mm = kg m-2 

592 

593 - Unit handling: 

594 * Cubes already in rate units (convertible to kg m-2 s-1) are left unchanged 

595 * Cubes not representing accumulation (not convertible to kg m-2) are skipped 

596 

597 - Time handling: 

598 * If units are time-reference units (e.g. "hours since ..."), 

599 only the base unit (e.g. hours) is used for duration conversion 

600 

601 - Broadcasting: 

602 The duration array is reshaped to match the time dimension of the cube 

603 before division. 

604 

605 Examples 

606 -------- 

607 >>> rate = precipitation.convert_rainfall_depth_to_rate(cube) 

608 >>> rate_list = precipitation.convert_rainfall_depth_to_rate(cube_list) 

609 """ 

610 from cf_units import Unit 

611 

612 cubes_list = iris.cube.CubeList(iter_maybe(cubes)) 

613 output = iris.cube.CubeList() 

614 for cube in cubes_list: 

615 # Identify input type 

616 is_rate = cube.units.is_convertible("kg m-2 s-1") or cube.units.is_convertible( 

617 "mm s-1" 

618 ) 

619 is_mass_accum = cube.units.is_convertible("kg m-2") 

620 is_depth_accum = cube.units.is_convertible("mm") 

621 

622 # Skip rates and unrelated variables 

623 if is_rate or not (is_mass_accum or is_depth_accum): 

624 output.append(cube) 

625 continue 

626 

627 # Time coordinate is required for rainfall accumulations 

628 try: 

629 time_coord = cube.coord("time") 

630 except iris.exceptions.CoordinateNotFoundError as exc: 

631 raise ValueError("No time coordinate; cannot convert rainfall.") from exc 

632 

633 # Get accumulation duration 

634 if time_coord.bounds is not None: 

635 duration = time_coord.bounds[:, 1] - time_coord.bounds[:, 0] 

636 else: 

637 t = time_coord.points 

638 

639 if t.size < 2: 

640 raise ValueError("Cannot infer duration from a single time point") 

641 

642 dt = np.diff(t) 

643 duration = np.concatenate([dt, dt[-1:]]) # assume last interval repeats 

644 

645 # Convert duration to seconds 

646 units = time_coord.units 

647 if units.is_time_reference(): 647 ↛ 651line 647 didn't jump to line 651 because the condition on line 647 was always true

648 base_unit = str(units).split(" since ")[0].strip() 

649 duration = Unit(base_unit).convert(duration, "seconds") 

650 else: 

651 duration = units.convert(duration, "seconds") 

652 

653 if np.any(duration <= 0): 

654 raise ValueError("Non-positive rainfall accumulation interval detected.") 

655 

656 # Normalise rainfall accumulation units before dividing 

657 # e.g. if rainfall amount is in cm, then the conversion will still work 

658 

659 data = cube.lazy_data() 

660 

661 if is_depth_accum: 

662 factor = cube.units.convert(1.0, "mm") 

663 data = data * factor 

664 else: 

665 factor = cube.units.convert(1.0, "kg m-2") 

666 data = data * factor 

667 

668 # Reshape duration for broadcasting along time dimension 

669 reshape = [1] * cube.ndim 

670 time_dim = cube.coord_dims("time")[0] 

671 reshape[time_dim] = -1 

672 duration = duration.reshape(reshape) 

673 

674 # Convert depth(amount) to rate 

675 # Numerically: mm s-1 == kg m-2 s-1 

676 data = data / duration 

677 new_cube = cube.copy(data=data) 

678 new_cube.units = "kg m-2 s-1" 

679 output.append(new_cube) 

680 

681 return output[0] if isinstance(cubes, iris.cube.Cube) else output