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

204 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-07 15:12 +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 Examples 

86 -------- 

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

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

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

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

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

92 """ 

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

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

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

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

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

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

99 raise ValueError( 

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

101 ) 

102 

103 for cube, u, v in zip( 

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

105 ): 

106 # Check for binary fields. 

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

108 raise ValueError( 

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

110 ) 

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

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

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

114 number_of_MAULs.data[:] = 0.0 

115 maul_depth = number_of_MAULs.copy() 

116 maul_base = number_of_MAULs.copy() 

117 wind_below_maul = number_of_MAULs.copy() 

118 directional_shear = number_of_MAULs.copy() 

119 # Calculate windspeed and direction. 

120 windspeed_and_direction = calculate_vector_wind(u, v) 

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

122 # of calculate_vector_wind. 

123 windspeed = windspeed_and_direction[0] 

124 # As above but for wind direction. 

125 direction = windspeed_and_direction[1] 

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

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

128 # Loop over realization. 

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

130 # Loop over time. 

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

132 # Loop over latitude. 

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

134 # Loop over longitude. 

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

136 # Label each object in the vertical. 

137 labels = label(lon.core_data()) 

138 # Finds the number of MAULs present based upon the 

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

140 # the value is set to zero. 

141 # The code checks for whether there are multiple 

142 # realization and/or time points for correct 

143 # indexing of the output data and applies accordingly. 

144 if ( 

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

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

147 ): 

148 number_of_MAULs.data[ 

149 mem_number, time_point, lat_point, lon_point 

150 ] = np.max(labels) 

151 elif ( 

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

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

154 ): 

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

156 np.max(labels) 

157 ) 

158 elif ( 

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

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

161 ): 

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

163 np.max(labels) 

164 ) 

165 else: 

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

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

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

169 # using cube metadata. 

170 maul_start = [] 

171 maul_end = [] 

172 maul_dep = [] 

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

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

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

176 # Find all vertical indices belonging to a MAUL. 

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

178 # Find the height at the base of the MAUL 

179 # (lowest level). 

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

181 maul_range[0][0] 

182 ] 

183 # Find the height at the top of the MAUL 

184 # (highest level). 

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

186 maul_range[0][-1] 

187 ] 

188 # Calculate the MAUL depth, and store 

189 # base and top heights. 

190 maul_dep.append(maul_end_point - maul_start_point) 

191 maul_start.append(maul_start_point) 

192 maul_end.append(maul_end_point) 

193 try: 

194 # Idendtify where the deepest MAUL is. 

195 index = int( 

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

197 ) 

198 # As with number the code checks for whether 

199 # there are multiple realization and/or time 

200 # points for correct indexing of the output data 

201 # and applies accordingly. 

202 if ( 

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

204 != 1 

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

206 ): 

207 # Store the deepest MAUL. 

208 maul_depth.data[ 

209 mem_number, time_point, lat_point, lon_point 

210 ] = np.max(maul_dep) 

211 # Store the base height of the deepest MAUL. 

212 maul_base.data[ 

213 mem_number, time_point, lat_point, lon_point 

214 ] = maul_start[index] 

215 elif ( 

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

217 != 1 

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

219 ): 

220 maul_depth.data[ 

221 mem_number, lat_point, lon_point 

222 ] = np.max(maul_dep) 

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

224 maul_start[index] 

225 ) 

226 elif ( 

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

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

229 == 1 

230 ): 

231 maul_depth.data[ 

232 time_point, lat_point, lon_point 

233 ] = np.max(maul_dep) 

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

235 maul_start[index] 

236 ) 

237 else: 

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

239 maul_dep 

240 ) 

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

242 index 

243 ] 

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

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

246 # The dimensionality logic for output data is identical 

247 # to that used previously. 

248 except ValueError: 

249 if ( 

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

251 != 1 

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

253 ): 

254 maul_depth.data[ 

255 mem_number, time_point, lat_point, lon_point 

256 ] = np.nan 

257 maul_base.data[ 

258 mem_number, time_point, lat_point, lon_point 

259 ] = np.nan 

260 elif ( 

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

262 != 1 

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

264 ): 

265 maul_depth.data[ 

266 mem_number, lat_point, lon_point 

267 ] = np.nan 

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

269 np.nan 

270 ) 

271 elif ( 

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

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

274 == 1 

275 ): 

276 maul_depth.data[ 

277 time_point, lat_point, lon_point 

278 ] = np.nan 

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

280 np.nan 

281 ) 

282 else: 

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

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

285 # Separate loop for calculating wind properties. 

286 elif output not in ("number"): 

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

288 # using cube metadata. 

289 maul_start = [] 

290 maul_end = [] 

291 maul_dep = [] 

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

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

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

295 # Given this labelling convention plus one is required 

296 # to ensure that the correct number of MAULs are 

297 # looped over. 

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

299 # Find all vertical indices belonging to a MAUL. 

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

301 # Find the height at the base of the MAUL 

302 # (lowest level). 

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

304 maul_range[0][0] 

305 ] 

306 # Find the height at the top of the MAUL 

307 # (highest level). 

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

309 maul_range[0][-1] 

310 ] 

311 # Calculate the MAUL depth, and store 

312 # base and top heights. 

313 maul_dep.append(maul_end_point - maul_start_point) 

314 maul_start.append(maul_start_point) 

315 maul_end.append(maul_end_point) 

316 try: 

317 # Identify where the deepest MAUL is. 

318 index = np.argmax(maul_dep) 

319 maul_base_value = maul_start[index] 

320 maul_top_value = maul_end[index] 

321 height_index = np.abs( 

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

323 ).argmin() 

324 top_index = np.abs( 

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

326 ).argmin() 

327 # As with number the code checks for whether 

328 # there are multiple realization and/or time 

329 # points for correct indexing of the output data 

330 # and applies accordingly. 

331 if ( 

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

333 != 1 

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

335 ): 

336 # Store and calculate the windspeed below the 

337 # deepest MAUL. 

338 wind_below_maul.data[ 

339 mem_number, time_point, lat_point, lon_point 

340 ] = np.mean( 

341 windspeed[ 

342 mem_number, 

343 time_point, 

344 0:height_index, 

345 lat_point, 

346 lon_point, 

347 ].data 

348 ) 

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

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

351 directional_shear.data[ 

352 mem_number, time_point, lat_point, lon_point 

353 ] = ( 

354 direction[ 

355 mem_number, 

356 time_point, 

357 top_index, 

358 lat_point, 

359 lon_point, 

360 ].data 

361 - direction[ 

362 mem_number, 

363 time_point, 

364 height_index, 

365 lat_point, 

366 lon_point, 

367 ].data 

368 ) 

369 elif ( 

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

371 != 1 

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

373 ): 

374 wind_below_maul.data[ 

375 mem_number, lat_point, lon_point 

376 ] = np.mean( 

377 windspeed[ 

378 mem_number, 

379 0:height_index, 

380 lat_point, 

381 lon_point, 

382 ].data 

383 ) 

384 directional_shear.data[ 

385 mem_number, lat_point, lon_point 

386 ] = ( 

387 direction[ 

388 mem_number, top_index, lat_point, lon_point 

389 ].data 

390 - direction[ 

391 mem_number, 

392 height_index, 

393 lat_point, 

394 lon_point, 

395 ].data 

396 ) 

397 elif ( 

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

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

400 == 1 

401 ): 

402 wind_below_maul.data[ 

403 time_point, lat_point, lon_point 

404 ] = np.mean( 

405 windspeed[ 

406 time_point, 

407 0:height_index, 

408 lat_point, 

409 lon_point, 

410 ].data 

411 ) 

412 directional_shear.data[ 

413 time_point, lat_point, lon_point 

414 ] = ( 

415 direction[ 

416 time_point, top_index, lat_point, lon_point 

417 ].data 

418 - direction[ 

419 time_point, 

420 height_index, 

421 lat_point, 

422 lon_point, 

423 ].data 

424 ) 

425 else: 

426 wind_below_maul.data[lat_point, lon_point] = ( 

427 np.mean( 

428 windspeed[ 

429 0:height_index, lat_point, lon_point 

430 ].data 

431 ) 

432 ) 

433 directional_shear.data[lat_point, lon_point] = ( 

434 direction[top_index, lat_point, lon_point].data 

435 - direction[ 

436 height_index, lat_point, lon_point 

437 ].data 

438 ) 

439 

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

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

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

443 # and so output data is set to NaN. 

444 # The dimensionality logic for output data is identical 

445 # to that used previously. 

446 except (ValueError, IndexError): 

447 if ( 

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

449 != 1 

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

451 ): 

452 wind_below_maul.data[ 

453 mem_number, time_point, lat_point, lon_point 

454 ] = np.nan 

455 directional_shear.data[ 

456 mem_number, time_point, lat_point, lon_point 

457 ] = np.nan 

458 elif ( 

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

460 != 1 

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

462 ): 

463 wind_below_maul.data[ 

464 mem_number, lat_point, lon_point 

465 ] = np.nan 

466 directional_shear.data[ 

467 mem_number, lat_point, lon_point 

468 ] = np.nan 

469 elif ( 

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

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

472 == 1 

473 ): 

474 wind_below_maul.data[ 

475 time_point, lat_point, lon_point 

476 ] = np.nan 

477 directional_shear.data[ 

478 time_point, lat_point, lon_point 

479 ] = np.nan 

480 else: 

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

482 directional_shear.data[lat_point, lon_point] = ( 

483 np.nan 

484 ) 

485 

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

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

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

489 

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

491 match output: 

492 case "number": 

493 number_of_MAULs.units = "1" 

494 number_of_MAULs.rename("Number_of_MAULs") 

495 num_MAULs.append(number_of_MAULs) 

496 case "depth": 

497 maul_depth.units = "m" 

498 maul_depth.rename("MAUL_depth") 

499 maul_d.append(maul_depth) 

500 case "base": 

501 maul_base.units = "m" 

502 maul_base.rename("MAUL_base_height") 

503 maul_b.append(maul_base) 

504 case "wind_below": 

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

506 wind_below_maul.rename("windspeed_below_MAUL") 

507 windspeed_below_MAUL.append(wind_below_maul) 

508 case _: 

509 directional_shear.units = "degrees" 

510 directional_shear.rename("directional_shear_across_MAUL") 

511 directional_shear_across_MAUL.append(directional_shear) 

512 

513 # Output data. 

514 match output: 

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

516 return num_MAULs[0] 

517 case "number": 

518 return num_MAULs 

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

520 return maul_d[0] 

521 case "depth": 

522 return maul_d 

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

524 return maul_b[0] 

525 case "base": 

526 return maul_b 

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

528 return windspeed_below_MAUL[0] 

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

530 return windspeed_below_MAUL 

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

532 return directional_shear_across_MAUL[0] 

533 case _: 

534 return directional_shear_across_MAUL 

535 

536 

537def convert_rainfall_depth_to_rate(cubes, **kwargs): 

538 """Convert rainfall depth to rate. 

539 

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

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

542 

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

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

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

546 

547 Arguments 

548 --------- 

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

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

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

552 

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

554 

555 kwargs: 

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

557 

558 Returns 

559 ------- 

560 iris.cube.Cube | iris.cube.CubeList 

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

562 

563 The returned object matches the type of the input: 

564 - single Cube → single Cube 

565 - CubeList → CubeList 

566 

567 Raises 

568 ------ 

569 ValueError 

570 - If no time coordinate is present 

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

572 - If any inferred duration is non-positive 

573 

574 Notes 

575 ----- 

576 - Conversion relies on the equivalence: 

577 1 mm of rainfall ≡ 1 kg m-2 

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

579 

580 - Unit handling: 

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

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

583 

584 - Time handling: 

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

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

587 

588 - Broadcasting: 

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

590 before division. 

591 

592 Examples 

593 -------- 

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

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

596 """ 

597 from cf_units import Unit 

598 

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

600 output = iris.cube.CubeList() 

601 for cube in cubes_list: 

602 # Identify input type 

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

604 "mm s-1" 

605 ) 

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

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

608 

609 # Skip rates and unrelated variables 

610 if is_rate or not (is_mass_accum or is_depth_accum): 

611 output.append(cube) 

612 continue 

613 

614 # Time coordinate is required for rainfall accumulations 

615 try: 

616 time_coord = cube.coord("time") 

617 except iris.exceptions.CoordinateNotFoundError as exc: 

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

619 

620 # Get accumulation duration 

621 if time_coord.bounds is not None: 

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

623 else: 

624 t = time_coord.points 

625 

626 if t.size < 2: 

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

628 

629 dt = np.diff(t) 

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

631 

632 # Convert duration to seconds 

633 units = time_coord.units 

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

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

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

637 else: 

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

639 

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

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

642 

643 # Normalise rainfall accumulation units before dividing 

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

645 

646 data = cube.lazy_data() 

647 

648 if is_depth_accum: 

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

650 data = data * factor 

651 else: 

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

653 data = data * factor 

654 

655 # Reshape duration for broadcasting along time dimension 

656 reshape = [1] * cube.ndim 

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

658 reshape[time_dim] = -1 

659 duration = duration.reshape(reshape) 

660 

661 # Convert depth(amount) to rate 

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

663 data = data / duration 

664 new_cube = cube.copy(data=data) 

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

666 output.append(new_cube) 

667 

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