Coverage for src/CSET/operators/radar_filter.py: 48%

110 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-24 15:03 +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 kind of filtering.""" 

16 

17import iris 

18import iris.cube 

19import iris.exceptions 

20import numpy as np 

21 

22from CSET._common import iter_maybe 

23from CSET.operators.filters import apply_mask, generate_mask 

24 

25 

26def mask_list(model_names: list[str]) -> list[str]: 

27 """Determine the Nimrod weights files to use. 

28 

29 Parameters 

30 ---------- 

31 model_names: list[str] 

32 A list containing model names and at least one Nimrod hourly 

33 rainfall accumulation source. 

34 Possible radar sources are: 

35 "Nimrod2km", "Nimrod_2km". 

36 "Nimrodxkm", "Nimrod_xkm". 

37 "Nimrod1km", "Nimrod_1km". 

38 

39 Returns 

40 ------- 

41 list[str] 

42 A list of the Nimrod weights files to use with each of the input 

43 models and radar sources. 

44 

45 Notes 

46 ----- 

47 At least one of the entries in the input list must be a Nimrod hourly 

48 rainfall accumulation source. 

49 

50 If just one Nimrod source is specified, then the weights file associated 

51 with this source is used. 

52 

53 If more than one Nimrod source is in the input list, then each of the these 

54 Nimrod sources is associated with its own weights file e.g. if the input list 

55 contains ["Nimrod1km", "Nimrod2km"] then the weights files for these will 

56 be ["Nimrod1km_weights", "Nimrod2km_weights"]. Any model fields in the input 

57 list will be allocated a weights file according to the order of preference 

58 specified in the list nimrod_preference e.g. if the input list is 

59 ["UM_model", "Nimrod1km", "Nimrod2km"] then the output weights files list will 

60 be ["Nimrod2km_weights", "Nimrod1km_weights", "Nimrod2km_weights"] as the Nimrod 

61 weights for 2km data are preferred over those for 1km. 

62 

63 Examples 

64 -------- 

65 >>> list_weights = mask_list( ["UM_model", "Nimrod1km", "Nimrod2km"] ) 

66 >>> print(list_weights) 

67 ["Nimrod2km_weights", "Nimrod1km_weights", "Nimrod2km_weights"] 

68 

69 """ 

70 # Set the preference order for choosing a Nimrod radar weights source 

71 # in order of most to least preferred. 

72 nimrod_preference = [ 

73 "Nimrod2km", 

74 "Nimrod_2km", 

75 "Nimrodxkm", 

76 "Nimrod_xkm", 

77 "Nimrod1km", 

78 "Nimrod_1km", 

79 ] 

80 

81 # Define the string that helps form a Nimrod weights file. 

82 wei = "_weights" 

83 

84 # Determine the preferred Nimrod mask to use. 

85 empty_string = "" 

86 preferred_nimrod = empty_string 

87 for prefer in reversed(nimrod_preference): 

88 if any(prefer in model for model in model_names): 

89 preferred_nimrod = prefer 

90 

91 # Create the list of the required Nimrod masks. 

92 mask_names_list = [] 

93 if preferred_nimrod != empty_string: 93 ↛ 102line 93 didn't jump to line 102 because the condition on line 93 was always true

94 # Loop over the input model_names. 

95 for model in model_names: 

96 if any(model in nimrod for nimrod in nimrod_preference): 

97 nimrod_mask = model + wei 

98 else: 

99 nimrod_mask = preferred_nimrod + wei 

100 mask_names_list.append(nimrod_mask) 

101 

102 return mask_names_list 

103 

104 

105def mask_by_weights( 

106 cubes: iris.cube.CubeList, 

107 model_names: list[str], 

108 weights_names: list[str], 

109 **kwargs, 

110) -> iris.cube.CubeList: 

111 """Filter a field using a radar weights field as a mask. 

112 

113 Parameters 

114 ---------- 

115 cubes: iris.cube.CubeList 

116 CubeList containing fields to mask and radar weights fields to use as the masks. 

117 model_names: list[str] 

118 A list of the model_names or radar sources to mask. 

119 weights_names: list[str] 

120 A list of radar weights sources to use as masks. There should be an entry 

121 in weights_names to correspond with every entry in model_names. 

122 

123 Returns 

124 ------- 

125 CubeList: 

126 A CubeList of masked fields. 

127 

128 Examples 

129 -------- 

130 >>> field_filtered = mask_by_weights(cubelist, model_names) 

131 

132 """ 

133 # Check the input unfiltered cubes and the mask cubes are both cubelists 

134 # with the same number of cubes. If not, then add extra mask cubes. 

135 if len(model_names) != len(weights_names): 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true

136 weights_names = mask_list(model_names) 

137 

138 # Create an empty cubelist to hold the filtered fields. 

139 filtered_list = iris.cube.CubeList([]) 

140 

141 # Loop over the fields to filter. 

142 for model, mask in zip( 

143 iter_maybe(model_names), 

144 iter_maybe(weights_names), 

145 strict=True, 

146 ): 

147 # Grab the field to filter. 

148 model_constraint = iris.AttributeConstraint(model_name=model) 

149 unfiltered_field = cubes.extract_cube(model_constraint) 

150 

151 # Select the field to use as the mask. 

152 # Nice to do - put in support for a static mask. 

153 mask_constraint = iris.AttributeConstraint(model_name=mask) 

154 mask_field = cubes.extract_cube(mask_constraint) 

155 

156 # Create the mask - note that the condition e.g. "ge" can be set by a loader 

157 # as can the threshold value. 

158 mask_radar_wts = generate_mask(mask_field, "ge", 11) 

159 

160 # Apply the mask. 

161 masked_radar_obs = apply_mask(unfiltered_field, mask_radar_wts) 

162 

163 # Put the filtered cube into the list of filtered cubes. 

164 filtered_list.append(masked_radar_obs) 

165 

166 # Preserve returning a cube if only a cube has been supplied to filter. 

167 if len(filtered_list) == 1: 167 ↛ 170line 167 didn't jump to line 170 because the condition on line 167 was always true

168 return filtered_list[0] 

169 else: 

170 return filtered_list 

171 

172 

173def radar_apply_mask( 

174 original_field: iris.cube.Cube | iris.cube.CubeList, 

175 mask: iris.cube.Cube | iris.cube.CubeList, 

176 boundary_margin: int = 8, 

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

178 """Apply a mask to given field as a masked array. 

179 

180 Parameters 

181 ---------- 

182 original_field: iris.cube.Cube | iris.cube.CubeList 

183 The field(s) to be masked. 

184 mask: iris.cube.Cube | iris.cube.CubeList 

185 The mask(s) being applied to the original field(s). 

186 boundary_margin: int, optional 

187 Number of grid points from the domain boundary considered "unreliable". 

188 Defaults to 8. 

189 

190 Returns 

191 ------- 

192 masked_field: iris.cube.Cube | iris.cube.CubeList 

193 A cube or CubeList of the masked field(s). 

194 

195 Notes 

196 ----- 

197 The mask is first converted to 1s and NaNs before multiplication with 

198 the original data. 

199 

200 As discussed in filters.generate_mask, you can combine multiple masks in a 

201 recipe using other functions before applying the mask to the data. 

202 

203 Examples 

204 -------- 

205 >>> radar_domain_only = radar_apply_mask( surface_microphysical_rainfall_rate, Nimrod2km_wts) 

206 """ 

207 # Create an empty cubelist to hold the filtered fields. 

208 masked_fields = iris.cube.CubeList([]) 

209 

210 # Loop over the input mask and field cubes. 

211 for M, F in zip(iter_maybe(mask), iter_maybe(original_field), strict=True): 

212 masked_field = F.copy() 

213 

214 # Set the model perimeter to NaN as these gridpoints contain no useful data. 

215 # c.f. boundary_margin in regrid.py 

216 margin_width = boundary_margin 

217 if margin_width > 0: 

218 masked_field.data[:, -margin_width - 1 :, :] = np.nan 

219 masked_field.data[:, :, -margin_width - 1 :] = np.nan 

220 masked_field.data[:, :, 0:margin_width] = np.nan 

221 masked_field.data[:, 0:margin_width, :] = np.nan 

222 

223 # If the field and mask are on different grids, then regrid the field. 

224 if M[0].shape != masked_field[0].shape: 

225 scheme = iris.analysis.Linear(extrapolation_mode="nan") 

226 masked_field = masked_field.regrid(M, scheme) 

227 

228 # Apply the mask. 

229 min_timesteps = min(M.shape[0], masked_field.shape[0]) 

230 masked_field = apply_mask(masked_field[0:min_timesteps], M[0:min_timesteps]) 

231 

232 # Attach an attribute to the masked field detailing the mask used. 

233 masked_field.attributes["mask"] = f"mask_of_{F.name()}" 

234 

235 # Append the masked field to the output list of masked fields. 

236 masked_fields.append(masked_field) 

237 

238 # Return either a single cube or a cubelist. 

239 if len(masked_fields) == 1: 

240 return masked_fields[0] 

241 else: 

242 # return masked_fields 

243 return masked_fields.merge() 

244 

245 

246def radar_mask( 

247 model_field: iris.cube.Cube | iris.cube.CubeList, 

248 nimrod_field: iris.cube.Cube | iris.cube.CubeList, 

249 nimrod_mask: iris.cube.Cube | iris.cube.CubeList, 

250 boundary_margin: int = 8, 

251 outputs: str = "radar", 

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

253 """Apply a mask to given fields using a masked array. 

254 

255 Parameters 

256 ---------- 

257 model_field: iris.cube.Cube | iris.cube.CubeList 

258 The model field(s) to be masked. 

259 nimrod_field: iris.cube.Cube | iris.cube.CubeList 

260 The Nimrod field(s) to be masked. 

261 nimrod_mask: iris.cube.Cube | iris.cube.CubeList 

262 The Nimrod mask(s) to use. These are normally Nimrod wts fields. 

263 boundary_margin: int, optional 

264 Number of grid points from the domain boundary considered "unreliable". 

265 Defaults to 8. 

266 outputs: str, optional 

267 Specifies which outputs are required: 

268 "radar" outputs masked Nimrod rainfall field(s) 

269 "model" outputs masked model field(s) 

270 "all" outputs both masked model and masked Nimrod field(s). 

271 

272 Returns 

273 ------- 

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

275 A cube or CubeList of the masked field(s). 

276 

277 Examples 

278 -------- 

279 To mask both model and Nimrod fields using the Nimrod weights in nimrod_wts: 

280 >>> masked_fields = radar_mask( model_fields, nimrod_fields, nimrod_wts, outputs="all") 

281 

282 """ 

283 # Create an empty cubelist to hold the filtered fields. 

284 filtered_fields = iris.cube.CubeList([]) 

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

286 filtered_model = iris.cube.CubeList([]) 

287 

288 # Loop over nimrod_field, model_field and nimrod_mask. 

289 for M, F, N in zip( 

290 iter_maybe(nimrod_mask), 

291 iter_maybe(model_field), 

292 iter_maybe(nimrod_field), 

293 strict=True, 

294 ): 

295 # Apply the function radar_apply_mask to generate the re-gridded 

296 # and masked model field. 

297 masked_model_field = radar_apply_mask(F, M, boundary_margin=boundary_margin) 

298 

299 # Use the masked model field as the mask for the Nimrod field. 

300 # Note: no re-gridding required. 

301 min_timesteps = min(N.shape[0], masked_model_field.shape[0]) 

302 

303 temp_mask = masked_model_field[0:min_timesteps].copy() 

304 temp_mask.data[~np.isnan(temp_mask.data)] = 1.0 

305 

306 masked_nimrod_field = N[0:min_timesteps].copy() 

307 masked_nimrod_field.data *= temp_mask.data 

308 

309 # Append the masked field to the output list of masked fields. 

310 filtered_model.append(masked_model_field) 

311 filtered_radar.append(masked_nimrod_field) 

312 

313 # Return either the masked model or Nimrod fields, or both. 

314 if outputs == "radar": 

315 filtered_fields.append(filtered_radar.merge_cube()) 

316 if outputs == "model": 

317 filtered_fields.append(filtered_model.merge_cube()) 

318 if outputs == "all": 

319 filtered_fields.append(filtered_model.merge_cube()) 

320 filtered_fields.append(filtered_radar.merge_cube()) 

321 

322 # Return either a single cube or a cubelist. 

323 if len(filtered_fields) == 1: 

324 return filtered_fields[0] 

325 else: 

326 return filtered_fields 

327 

328 

329def radar_mask_loop( 

330 model_field: iris.cube.Cube | iris.cube.CubeList, 

331 nimrod_field: iris.cube.Cube | iris.cube.CubeList, 

332 nimrod_mask: iris.cube.Cube | iris.cube.CubeList, 

333 boundary_margin: int = 8, 

334 outputs: str = "radar", 

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

336 """Find common domains between a list of models and radar sources. 

337 

338 Parameters 

339 ---------- 

340 model_field: iris.cube.Cube | iris.cube.CubeList 

341 The model field(s) to be masked. 

342 nimrod_field: iris.cube.Cube | iris.cube.CubeList 

343 The Nimrod field(s) to be masked. 

344 nimrod_mask: iris.cube.Cube | iris.cube.CubeList 

345 The Nimrod mask(s) to use. These are normally Nimrod wts fields. 

346 boundary_margin: int, optional 

347 Number of grid points from the domain boundary considered "unreliable". 

348 Defaults to 8. 

349 outputs: str, optional 

350 Specifies which outputs are required: 

351 "radar" outputs masked Nimrod rainfall field(s) 

352 "model" outputs masked model field(s) 

353 "all" outputs both masked model and masked Nimrod field(s). 

354 

355 Returns 

356 ------- 

357 masked_field: iris.cube.Cube | iris.cube.CubeList 

358 A cube or CubeList of the masked field(s). 

359 

360 Examples 

361 -------- 

362 To mask both model and Nimrod fields using the Nimrod weights in nimrod_wts: 

363 >>> masked_fields = radar_mask_loop( model_fields, nimrod_fields, nimrod_wts, outputs="all") 

364 

365 """ 

366 # Create an empty cubelist to hold the filtered fields. 

367 filtered_cubes = iris.cube.CubeList([]) 

368 

369 use_nimrod_field = nimrod_field 

370 use_nimrod_mask = nimrod_mask 

371 

372 # Loop over the models. 

373 for model in model_field: 

374 print("-------> using model ", model) 

375 filtered_model = radar_mask( 

376 model, 

377 use_nimrod_field, 

378 use_nimrod_mask, 

379 boundary_margin=boundary_margin, 

380 outputs="model", 

381 ) 

382 filtered_cubes.append(filtered_model) 

383 

384 # Filter the radar observations. 

385 if len(model_field) == 1: 

386 filtered_radar = radar_mask( 

387 model_field[0], 

388 use_nimrod_field, 

389 use_nimrod_mask, 

390 boundary_margin=boundary_margin, 

391 outputs="radar", 

392 ) 

393 else: 

394 filtered_radar = radar_mask( 

395 model_field[0], 

396 use_nimrod_field, 

397 use_nimrod_mask, 

398 boundary_margin=boundary_margin, 

399 outputs="radar", 

400 ) 

401 filtered_cubes.append(filtered_radar) 

402 

403 return filtered_cubes 

404 

405 

406def match_varname_and_units(cubes: iris.cube.Cube | iris.cube.CubeList): 

407 """Match the varname and units of a cube list. 

408 

409 Arguments 

410 --------- 

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

412 A Cube or CubeList of a field to be matched. 

413 

414 Returns 

415 ------- 

416 iris.cube.Cube | iris.cube.CubeList 

417 The matched cubes. 

418 

419 

420 Notes 

421 ----- 

422 This function converts the names and units of a cube list to match 

423 the first cube in the list. If just one cube is input, then this is 

424 returned. 

425 """ 

426 # If just one cube, then no need to match so return. 

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

428 cubes_in = iris.cube.CubeList([cubes]) 

429 else: 

430 cubes_in = cubes 

431 if len(cubes_in) == 1: 

432 return cubes 

433 

434 # Initialise the list of matched cubes. 

435 new_cubelist = iris.cube.CubeList([]) 

436 

437 # Use the first cube in the CubeList as the base cube. 

438 base_cube = cubes_in[0] 

439 new_cubelist.append(base_cube) 

440 

441 # Loop over the cubes matching each to the base cube. 

442 for cube in cubes_in[1:]: 

443 new_cube = cube.copy() 

444 

445 # Match the cube varname. 

446 new_cube.rename(base_cube.long_name) 

447 new_cube.long_name = base_cube.long_name 

448 new_cube.var_name = base_cube.var_name 

449 

450 # Match the cube units. 

451 new_cube.units = base_cube.units 

452 

453 # Append the matched cube to the output cube list. 

454 new_cubelist.append(new_cube) 

455 

456 return new_cubelist