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

113 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-13 14:57 +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 generate constraints to filter with.""" 

16 

17import numbers 

18import re 

19from collections.abc import Iterable 

20from datetime import timedelta 

21 

22import iris 

23import iris.coords 

24import iris.cube 

25 

26import CSET.operators._utils as operator_utils 

27from CSET._common import iter_maybe 

28 

29# STASH code pattern: mXXsXXiXXX where X is a digit 

30_STASH_RE = re.compile(r"^m\d{2}s\d{2}i\d{3}$") 

31 

32 

33def generate_stash_constraint(stash: str, **kwargs) -> iris.AttributeConstraint: 

34 """Generate constraint from STASH code. 

35 

36 Operator that takes a stash string, and uses iris to generate a constraint 

37 to be passed into the read operator to minimize the CubeList the read 

38 operator loads and speed up loading. 

39 

40 Arguments 

41 --------- 

42 stash: str 

43 stash code to build iris constraint, such as "m01s03i236" 

44 

45 Returns 

46 ------- 

47 stash_constraint: iris.AttributeConstraint 

48 """ 

49 # At a later stage str list an option to combine constraints. Arguments 

50 # could be a list of stash codes that combined build the constraint. 

51 stash_constraint = iris.AttributeConstraint(STASH=stash) 

52 return stash_constraint 

53 

54 

55def generate_var_constraint(varname: str, **kwargs) -> iris.Constraint: 

56 """Generate constraint from variable name or STASH code. 

57 

58 Operator that takes a CF compliant variable name string or list of names, and generates an 

59 iris constraint to be passed into the read or filter operator. Can also be 

60 passed a STASH code to generate a STASH constraint. 

61 

62 Arguments 

63 --------- 

64 varname: str | list[str] 

65 CF compliant name(s) of variable, or a UM STASH code such as "m01s03i236". 

66 

67 Returns 

68 ------- 

69 varname_constraint: iris.Constraint 

70 If a single UM STASHcode is requested, varname constraint is by STASHcode 

71 If a single variable name is requested, constraint by varname 

72 If multiple variable names are requested, constrain by list of variables. 

73 """ 

74 # Case 1: UM STASHcode input 

75 

76 if isinstance(varname, str) and _STASH_RE.match(varname): 

77 return iris.AttributeConstraint(STASH=varname) 

78 

79 # Ensure access to variable vector components for computed fields 

80 if "wind_speed_at_10m" in iter_maybe(varname): 

81 if isinstance(varname, str): 81 ↛ 83line 81 didn't jump to line 83 because the condition on line 81 was always true

82 varname = [varname] 

83 varname.extend(["eastward_wind_at_10m", "northward_wind_at_10m"]) 

84 varname.extend(["u_wind_at_10m", "v_wind_at_10m"]) 

85 

86 # Case 2: Multiple varnames 

87 if isinstance(varname, (list, tuple)): 

88 varname_constraint = iris.Constraint( 

89 cube_func=lambda cube: ( 

90 cube.long_name in varname 

91 or cube.standard_name in varname 

92 or cube.var_name in varname 

93 ) 

94 ) 

95 

96 else: 

97 varname_constraint = iris.Constraint(name=varname) 

98 

99 return varname_constraint 

100 

101 

102def generate_level_constraint( 

103 coordinate: str, levels: int | list[int] | str, **kwargs 

104) -> iris.Constraint: 

105 """Generate constraint for particular levels on the specified coordinate. 

106 

107 Operator that generates a constraint to constrain to specific model or 

108 pressure levels. If no levels are specified then any cube with the specified 

109 coordinate is rejected. 

110 

111 Typically ``coordinate`` will be ``"pressure"`` or ``"model_level_number"`` 

112 for UM, or ``"full_levels"`` or ``"half_levels"`` for LFRic. 

113 

114 Arguments 

115 --------- 

116 coordinate: str 

117 Level coordinate name about which to constraint. 

118 levels: int | list[int] | str 

119 CF compliant level points, ``"*"`` for retrieving all levels, or 

120 ``[]`` for no levels. 

121 

122 Returns 

123 ------- 

124 constraint: iris.Constraint 

125 

126 Notes 

127 ----- 

128 Due to the specification of ``coordinate`` as an argument any iterable 

129 coordinate can be stratified with this function. Therefore, 

130 ``"realization"`` is a valid option. Subsequently, ``levels`` specifies the 

131 ensemble members, or group of ensemble members you wish to constrain your 

132 results over. 

133 """ 

134 # If asterisks, then return all levels for given coordinate. 

135 if levels == "*": 

136 return iris.Constraint(**{coordinate: lambda cell: True}) 

137 else: 

138 # Ensure is iterable. 

139 if not isinstance(levels, Iterable): 

140 levels = [levels] 

141 

142 # When no levels specified reject cube with level coordinate. 

143 if len(levels) == 0: 

144 

145 def no_levels(cube): 

146 # Reject cubes for which coordinate exists. 

147 return not cube.coords(coordinate) 

148 

149 return iris.Constraint(cube_func=no_levels) 

150 

151 # Filter the coordinate to the desired levels. 

152 # Dictionary unpacking is used to provide programmatic keyword arguments. 

153 return iris.Constraint(**{coordinate: levels}) 

154 

155 

156def generate_remove_single_level_constraint( 

157 coord: str, level: int = 0, **kwargs 

158) -> iris.Constraint: 

159 """ 

160 Generate a constraint to remove a single model level number. 

161 

162 Operator that returns a constraint to remove the given level. By 

163 default the first level is removed (assumed to be 

164 level zero). However, any level can be removed. 

165 

166 Arguments 

167 --------- 

168 coord: str 

169 The coordinate for which the level is to be removed. 

170 level: int 

171 Default is 0. The model level number to remove. 

172 

173 Returns 

174 ------- 

175 iris.Constraint 

176 

177 Notes 

178 ----- 

179 This operator is primarily used to ensure the levels are consistent 

180 as some level sets (e.g. specific humidity) will be on the same level set 

181 but have a different number of levels (e.g 71 instead of expected 70). 

182 """ 

183 return iris.Constraint(**{coord: lambda m: m.point != level}) 

184 

185 

186def generate_cell_methods_constraint( 

187 cell_methods: list, 

188 varname: str | None = None, 

189 coord: iris.coords.Coord | None = None, 

190 interval: str | None = None, 

191 comment: str | None = None, 

192 **kwargs, 

193) -> iris.Constraint: 

194 """Generate constraint from cell methods. 

195 

196 Operator that takes a list of cell methods and generates a constraint from 

197 that. Use [] to specify non-aggregated data. 

198 

199 Arguments 

200 --------- 

201 cell_methods: list 

202 cube.cell_methods for filtering. 

203 varname: str, optional 

204 CF compliant name of variable. 

205 coord: iris.coords.Coord, optional 

206 iris.coords.Coord to which the cell method is applied to. 

207 interval: str, optional 

208 interval over which the cell method is applied to (e.g. 1 hour). 

209 comment: str, optional 

210 any comments in Cube meta data associated with the cell method. 

211 

212 Returns 

213 ------- 

214 cell_method_constraint: iris.Constraint 

215 """ 

216 if len(cell_methods) == 0: 

217 

218 def check_no_aggregation(cube: iris.cube.Cube) -> bool: 

219 """Check that any cell methods are "point", meaning no aggregation.""" 

220 return {cm.method for cm in cube.cell_methods} <= {"point"} 

221 

222 def check_cell_sum(cube: iris.cube.Cube) -> bool: 

223 """Check that any cell methods are "sum".""" 

224 return {cm.method for cm in cube.cell_methods} == {"sum"} 

225 

226 def check_cell_mean(cube: iris.cube.Cube) -> bool: 

227 """Check that any cell methods are "mean".""" 

228 return {cm.method for cm in cube.cell_methods} == {"mean"} 

229 

230 if varname: 

231 # Require number_of_lightning_flashes to be "sum" cell_method input. 

232 # Require surface_microphyisical_rainfall_amount and surface_microphysical_snowfall_amount to be "sum" cell_method inputs. 

233 if ("lightning" in varname) or ( 

234 "surface_microphysical" in varname and "amount" in varname 

235 ): 

236 cell_methods_constraint = iris.Constraint(cube_func=check_cell_sum) 

237 return cell_methods_constraint 

238 # Require climatological ancillary as time-average mean. 

239 if ("albedo" in varname) or ( 239 ↛ 246line 239 didn't jump to line 246 because the condition on line 239 was always true

240 "ocean" in varname and "chlorophyll" in varname 

241 ): 

242 cell_methods_constraint = iris.Constraint(cube_func=check_cell_mean) 

243 return cell_methods_constraint 

244 

245 # If no variable name set, assume require instantaneous cube. 

246 cell_methods_constraint = iris.Constraint(cube_func=check_no_aggregation) 

247 

248 else: 

249 # If cell_method constraint set in recipe, check for required input. 

250 def check_cell_methods(cube: iris.cube.Cube) -> bool: 

251 return all( 

252 iris.coords.CellMethod( 

253 method=cm, coords=coord, intervals=interval, comments=comment 

254 ) 

255 in cube.cell_methods 

256 for cm in cell_methods 

257 ) 

258 

259 cell_methods_constraint = iris.Constraint(cube_func=check_cell_methods) 

260 

261 return cell_methods_constraint 

262 

263 

264def generate_time_constraint( 

265 time_start: str, time_end: str | None = None, **kwargs 

266) -> iris.Constraint: 

267 """Generate constraint between times. 

268 

269 Operator that takes one or two ISO 8601 date strings, and returns a 

270 constraint that selects values between those dates (inclusive). 

271 

272 Arguments 

273 --------- 

274 time_start: str | datetime.datetime | cftime.datetime 

275 ISO date for lower bound 

276 

277 time_end: str | datetime.datetime | cftime.datetime 

278 ISO date for upper bound. If omitted it defaults to the same as 

279 time_start 

280 

281 Returns 

282 ------- 

283 time_constraint: iris.Constraint 

284 """ 

285 if isinstance(time_start, str): 

286 pdt_start, offset_start = operator_utils.pdt_fromisoformat(time_start) 

287 else: 

288 pdt_start, offset_start = time_start, timedelta(0) 

289 

290 if time_end is None: 

291 pdt_end, offset_end = time_start, offset_start 

292 elif isinstance(time_end, str): 

293 pdt_end, offset_end = operator_utils.pdt_fromisoformat(time_end) 

294 print(pdt_end) 

295 print(offset_end) 

296 else: 

297 pdt_end, offset_end = time_end, timedelta(0) 

298 

299 if offset_start is None: 

300 offset_start = timedelta(0) 

301 if offset_end is None: 

302 offset_end = timedelta(0) 

303 

304 time_constraint = iris.Constraint( 

305 time=lambda t: ( 

306 (pdt_start <= (t.point - offset_start)) 

307 and ((t.point - offset_end) <= pdt_end) 

308 ) 

309 ) 

310 

311 return time_constraint 

312 

313 

314def generate_area_constraint( 

315 lat_start: float | None, 

316 lat_end: float | None, 

317 lon_start: float | None, 

318 lon_end: float | None, 

319 **kwargs, 

320) -> iris.Constraint: 

321 """Generate an area constraint between latitude/longitude limits. 

322 

323 Operator that takes a set of latitude and longitude limits and returns a 

324 constraint that selects grid values only inside that area. Works with the 

325 data's native grid so is defined within the rotated pole CRS. 

326 

327 Alternatively, all arguments may be None to indicate the area should not be 

328 constrained. This is useful to allow making subsetting an optional step in a 

329 processing pipeline. 

330 

331 Arguments 

332 --------- 

333 lat_start: float | None 

334 Latitude value for lower bound 

335 lat_end: float | None 

336 Latitude value for top bound 

337 lon_start: float | None 

338 Longitude value for left bound 

339 lon_end: float | None 

340 Longitude value for right bound 

341 

342 Returns 

343 ------- 

344 area_constraint: iris.Constraint 

345 """ 

346 # Check all arguments are defined, or all are None. 

347 if not ( 

348 all( 

349 ( 

350 isinstance(lat_start, numbers.Real), 

351 isinstance(lat_end, numbers.Real), 

352 isinstance(lon_start, numbers.Real), 

353 isinstance(lon_end, numbers.Real), 

354 ) 

355 ) 

356 or all((lat_start is None, lat_end is None, lon_start is None, lon_end is None)) 

357 ): 

358 raise TypeError("Bounds must real numbers, or all None.") 

359 

360 # Don't constrain area if all arguments are None. 

361 if lat_start is None: # Only need to check once, as they will be the same. 

362 # An empty constraint allows everything. 

363 return iris.Constraint() 

364 

365 # Handle bounds crossing the date line. 

366 if lon_end < lon_start: 366 ↛ 367line 366 didn't jump to line 367 because the condition on line 366 was never true

367 lon_end = lon_end + 360 

368 

369 def bound_lat(cell: iris.coords.Cell) -> bool: 

370 return lat_start < cell < lat_end 

371 

372 def bound_lon(cell: iris.coords.Cell) -> bool: 

373 # Adjust cell values to handle crossing the date line. 

374 if cell < lon_start: 

375 cell = cell + 360 

376 return lon_start < cell < lon_end 

377 

378 area_constraint = iris.Constraint( 

379 coord_values={"grid_latitude": bound_lat, "grid_longitude": bound_lon} 

380 ) 

381 return area_constraint 

382 

383 

384def generate_remove_single_ensemble_member_constraint( 

385 ensemble_member: int = 0, **kwargs 

386) -> iris.Constraint: 

387 """ 

388 Generate a constraint to remove a single ensemble member. 

389 

390 Operator that returns a constraint to remove the given ensemble member. By 

391 default the ensemble member removed is the control member (assumed to have 

392 a realization of zero). However, any ensemble member can be removed, thus 

393 allowing a non-zero control member to be removed if the control is a 

394 different member. 

395 

396 Arguments 

397 --------- 

398 ensemble_member: int 

399 Default is 0. The ensemble member realization to remove. 

400 

401 Returns 

402 ------- 

403 iris.Constraint 

404 

405 Notes 

406 ----- 

407 This operator is primarily used to remove the control member to allow 

408 ensemble metrics to be calculated without the control member. For 

409 example, the ensemble mean is not normally calculated including the 

410 control member. It is particularly useful to remove the control member 

411 when it is not an equally-likely member of the ensemble. 

412 """ 

413 return iris.Constraint(realization=lambda m: m.point != ensemble_member) 

414 

415 

416def generate_realization_constraint( 

417 ensemble_members: int | list[int], **kwargs 

418) -> iris.Constraint: 

419 """ 

420 Generate a constraint to subset ensemble members. 

421 

422 Operator that is given a list of ensemble members and returns a constraint 

423 to select those ensemble members. This operator is particularly useful for 

424 subsetting ensembles. 

425 

426 Arguments 

427 --------- 

428 ensemble_members: int | list[int] 

429 The ensemble members to be subsetted over. 

430 

431 Returns 

432 ------- 

433 iris.Constraint 

434 """ 

435 # Ensure ensemble_members is iterable. 

436 ensemble_members = iter_maybe(ensemble_members) 

437 return iris.Constraint(realization=ensemble_members) 

438 

439 

440def generate_hour_constraint( 

441 hour_start: int, 

442 hour_end: int | None = None, 

443 **kwargs, 

444) -> iris.Constraint: 

445 """Generate an hour constraint between hour of day limits. 

446 

447 Operator that takes a set of hour of day limits and returns a constraint that 

448 selects only hours within that time frame regardless of day. 

449 

450 Alternatively, the result can be constrained to a single hour by just entering 

451 a starting hour. 

452 

453 Should any sub-hourly data be given these will have the same hour coordinate 

454 (e.g., 12:00 and 12:05 both have an hour coordinate of 12) all 

455 times will be selected with this constraint. 

456 

457 Arguments 

458 --------- 

459 hour_start: int 

460 The hour of day for the lower bound, within 0 to 23. 

461 hour_end: int | None 

462 The hour of day for the upper bound, within 0 to 23. Alternatively, 

463 set to None if only one hour required. 

464 

465 Returns 

466 ------- 

467 hour_constraint: iris.Constraint 

468 

469 Raises 

470 ------ 

471 ValueError 

472 If the provided arguments are outside of the range 0 to 23. 

473 """ 

474 if hour_end is None: 

475 hour_end = hour_start 

476 

477 if (hour_start < 0) or (hour_start > 23) or (hour_end < 0) or (hour_end > 23): 

478 raise ValueError("Hours must be between 0 and 23 inclusive.") 

479 

480 hour_constraint = iris.Constraint(hour=lambda h: hour_start <= h.point <= hour_end) 

481 return hour_constraint 

482 

483 

484def combine_constraints( 

485 constraint: iris.Constraint = None, **kwargs 

486) -> iris.Constraint: 

487 """ 

488 Operator that combines multiple constraints into one. 

489 

490 Arguments 

491 --------- 

492 constraint: iris.Constraint 

493 First constraint to combine. 

494 additional_constraint_1: iris.Constraint 

495 Second constraint to combine. This must be a named argument. 

496 additional_constraint_2: iris.Constraint 

497 There can be any number of additional constraint, they just need unique 

498 names. 

499 ... 

500 

501 Returns 

502 ------- 

503 combined_constraint: iris.Constraint 

504 

505 Raises 

506 ------ 

507 TypeError 

508 If the provided arguments are not constraints. 

509 """ 

510 # If the first argument is not a constraint, it is ignored. This handles the 

511 # automatic passing of the previous step's output. 

512 if isinstance(constraint, iris.Constraint): 

513 combined_constraint = constraint 

514 else: 

515 combined_constraint = iris.Constraint() 

516 

517 for constr in kwargs.values(): 

518 combined_constraint = combined_constraint & constr 

519 return combined_constraint 

520 

521 

522def generate_attribute_constraint( 

523 attribute: str, value: str | None = None, **kwargs 

524) -> iris.AttributeConstraint: 

525 """Generate constraint on cube attributes. 

526 

527 Constrains based on the presence of an attribute, and that attribute having 

528 a particular value. 

529 

530 Arguments 

531 --------- 

532 attribute: str 

533 Attribute to constraint on. 

534 

535 value: str 

536 Attribute value to constrain on. If omitted the constraint merely checks 

537 for the presence of an attribute. 

538 

539 Returns 

540 ------- 

541 attribute_constraint: iris.Constraint 

542 """ 

543 if value is None: 

544 attribute_constraint = iris.Constraint( 

545 cube_func=lambda cube: attribute in cube.attributes 

546 ) 

547 else: 

548 attribute_constraint = iris.AttributeConstraint(**{attribute: value}) 

549 return attribute_constraint