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

115 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-20 09:36 +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 | list[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 varname_copy = iter_maybe(varname)[:] 

81 

82 if "wind_speed_at_10m" in varname_copy: 

83 if isinstance(varname, str): 

84 varname = [varname] 

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

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

87 # Case 2: Multiple varnames 

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

89 varname_constraint = iris.Constraint( 

90 cube_func=lambda cube: ( 

91 cube.long_name in varname 

92 or cube.standard_name in varname 

93 or cube.var_name in varname 

94 ) 

95 ) 

96 

97 else: 

98 varname_constraint = iris.Constraint(name=varname) 

99 

100 varname_constraint.varname = varname_copy 

101 

102 return varname_constraint 

103 

104 

105def generate_level_constraint( 

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

107) -> iris.Constraint: 

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

109 

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

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

112 coordinate is rejected. 

113 

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

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

116 

117 Arguments 

118 --------- 

119 coordinate: str 

120 Level coordinate name about which to constraint. 

121 levels: int | list[int] | str 

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

123 ``[]`` for no levels. 

124 

125 Returns 

126 ------- 

127 constraint: iris.Constraint 

128 

129 Notes 

130 ----- 

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

132 coordinate can be stratified with this function. Therefore, 

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

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

135 results over. 

136 """ 

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

138 if levels == "*": 

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

140 else: 

141 # Ensure is iterable. 

142 if not isinstance(levels, Iterable): 

143 levels = [levels] 

144 

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

146 if len(levels) == 0: 

147 

148 def no_levels(cube): 

149 # Reject cubes for which coordinate exists. 

150 return not cube.coords(coordinate) 

151 

152 return iris.Constraint(cube_func=no_levels) 

153 

154 # Filter the coordinate to the desired levels. 

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

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

157 

158 

159def generate_remove_single_level_constraint( 

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

161) -> iris.Constraint: 

162 """ 

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

164 

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

166 default the first level is removed (assumed to be 

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

168 

169 Arguments 

170 --------- 

171 coord: str 

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

173 level: int 

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

175 

176 Returns 

177 ------- 

178 iris.Constraint 

179 

180 Notes 

181 ----- 

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

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

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

185 """ 

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

187 

188 

189def generate_cell_methods_constraint( 

190 cell_methods: list, 

191 varname: str | None = None, 

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

193 interval: str | None = None, 

194 comment: str | None = None, 

195 **kwargs, 

196) -> iris.Constraint: 

197 """Generate constraint from cell methods. 

198 

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

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

201 

202 Arguments 

203 --------- 

204 cell_methods: list 

205 cube.cell_methods for filtering. 

206 varname: str, optional 

207 CF compliant name of variable. 

208 coord: iris.coords.Coord, optional 

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

210 interval: str, optional 

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

212 comment: str, optional 

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

214 

215 Returns 

216 ------- 

217 cell_method_constraint: iris.Constraint 

218 """ 

219 if len(cell_methods) == 0: 

220 

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

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

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

224 

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

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

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

228 

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

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

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

232 

233 if varname: 

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

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

236 if ("lightning" in varname) or ( 

237 "surface_microphysical" in varname and "amount" in varname 

238 ): 

239 cell_methods_constraint = iris.Constraint(cube_func=check_cell_sum) 

240 return cell_methods_constraint 

241 # Require climatological ancillary as time-average mean. 

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

243 "ocean" in varname and "chlorophyll" in varname 

244 ): 

245 cell_methods_constraint = iris.Constraint(cube_func=check_cell_mean) 

246 return cell_methods_constraint 

247 

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

249 cell_methods_constraint = iris.Constraint(cube_func=check_no_aggregation) 

250 

251 else: 

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

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

254 return all( 

255 iris.coords.CellMethod( 

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

257 ) 

258 in cube.cell_methods 

259 for cm in cell_methods 

260 ) 

261 

262 cell_methods_constraint = iris.Constraint(cube_func=check_cell_methods) 

263 

264 return cell_methods_constraint 

265 

266 

267def generate_time_constraint( 

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

269) -> iris.Constraint: 

270 """Generate constraint between times. 

271 

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

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

274 

275 Arguments 

276 --------- 

277 time_start: str | datetime.datetime | cftime.datetime 

278 ISO date for lower bound 

279 

280 time_end: str | datetime.datetime | cftime.datetime 

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

282 time_start 

283 

284 Returns 

285 ------- 

286 time_constraint: iris.Constraint 

287 """ 

288 if isinstance(time_start, str): 

289 pdt_start, offset_start = operator_utils.pdt_fromisoformat(time_start) 

290 else: 

291 pdt_start, offset_start = time_start, timedelta(0) 

292 

293 if time_end is None: 

294 pdt_end, offset_end = time_start, offset_start 

295 elif isinstance(time_end, str): 

296 pdt_end, offset_end = operator_utils.pdt_fromisoformat(time_end) 

297 print(pdt_end) 

298 print(offset_end) 

299 else: 

300 pdt_end, offset_end = time_end, timedelta(0) 

301 

302 if offset_start is None: 

303 offset_start = timedelta(0) 

304 if offset_end is None: 

305 offset_end = timedelta(0) 

306 

307 time_constraint = iris.Constraint( 

308 time=lambda t: ( 

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

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

311 ) 

312 ) 

313 

314 return time_constraint 

315 

316 

317def generate_area_constraint( 

318 lat_start: float | None, 

319 lat_end: float | None, 

320 lon_start: float | None, 

321 lon_end: float | None, 

322 **kwargs, 

323) -> iris.Constraint: 

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

325 

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

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

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

329 

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

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

332 processing pipeline. 

333 

334 Arguments 

335 --------- 

336 lat_start: float | None 

337 Latitude value for lower bound 

338 lat_end: float | None 

339 Latitude value for top bound 

340 lon_start: float | None 

341 Longitude value for left bound 

342 lon_end: float | None 

343 Longitude value for right bound 

344 

345 Returns 

346 ------- 

347 area_constraint: iris.Constraint 

348 """ 

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

350 if not ( 

351 all( 

352 ( 

353 isinstance(lat_start, numbers.Real), 

354 isinstance(lat_end, numbers.Real), 

355 isinstance(lon_start, numbers.Real), 

356 isinstance(lon_end, numbers.Real), 

357 ) 

358 ) 

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

360 ): 

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

362 

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

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

365 # An empty constraint allows everything. 

366 return iris.Constraint() 

367 

368 # Handle bounds crossing the date line. 

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

370 lon_end = lon_end + 360 

371 

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

373 return lat_start < cell < lat_end 

374 

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

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

377 if cell < lon_start: 

378 cell = cell + 360 

379 return lon_start < cell < lon_end 

380 

381 area_constraint = iris.Constraint( 

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

383 ) 

384 return area_constraint 

385 

386 

387def generate_remove_single_ensemble_member_constraint( 

388 ensemble_member: int = 0, **kwargs 

389) -> iris.Constraint: 

390 """ 

391 Generate a constraint to remove a single ensemble member. 

392 

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

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

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

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

397 different member. 

398 

399 Arguments 

400 --------- 

401 ensemble_member: int 

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

403 

404 Returns 

405 ------- 

406 iris.Constraint 

407 

408 Notes 

409 ----- 

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

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

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

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

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

415 """ 

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

417 

418 

419def generate_realization_constraint( 

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

421) -> iris.Constraint: 

422 """ 

423 Generate a constraint to subset ensemble members. 

424 

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

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

427 subsetting ensembles. 

428 

429 Arguments 

430 --------- 

431 ensemble_members: int | list[int] 

432 The ensemble members to be subsetted over. 

433 

434 Returns 

435 ------- 

436 iris.Constraint 

437 """ 

438 # Ensure ensemble_members is iterable. 

439 ensemble_members = iter_maybe(ensemble_members) 

440 return iris.Constraint(realization=ensemble_members) 

441 

442 

443def generate_hour_constraint( 

444 hour_start: int, 

445 hour_end: int | None = None, 

446 **kwargs, 

447) -> iris.Constraint: 

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

449 

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

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

452 

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

454 a starting hour. 

455 

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

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

458 times will be selected with this constraint. 

459 

460 Arguments 

461 --------- 

462 hour_start: int 

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

464 hour_end: int | None 

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

466 set to None if only one hour required. 

467 

468 Returns 

469 ------- 

470 hour_constraint: iris.Constraint 

471 

472 Raises 

473 ------ 

474 ValueError 

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

476 """ 

477 if hour_end is None: 

478 hour_end = hour_start 

479 

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

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

482 

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

484 return hour_constraint 

485 

486 

487def combine_constraints( 

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

489) -> iris.Constraint: 

490 """ 

491 Operator that combines multiple constraints into one. 

492 

493 Arguments 

494 --------- 

495 constraint: iris.Constraint 

496 First constraint to combine. 

497 additional_constraint_1: iris.Constraint 

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

499 additional_constraint_2: iris.Constraint 

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

501 names. 

502 ... 

503 

504 Returns 

505 ------- 

506 combined_constraint: iris.Constraint 

507 

508 Raises 

509 ------ 

510 TypeError 

511 If the provided arguments are not constraints. 

512 """ 

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

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

515 if isinstance(constraint, iris.Constraint): 

516 combined_constraint = constraint 

517 else: 

518 combined_constraint = iris.Constraint() 

519 

520 for constr in kwargs.values(): 

521 combined_constraint = combined_constraint & constr 

522 return combined_constraint 

523 

524 

525def generate_attribute_constraint( 

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

527) -> iris.AttributeConstraint: 

528 """Generate constraint on cube attributes. 

529 

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

531 a particular value. 

532 

533 Arguments 

534 --------- 

535 attribute: str 

536 Attribute to constraint on. 

537 

538 value: str 

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

540 for the presence of an attribute. 

541 

542 Returns 

543 ------- 

544 attribute_constraint: iris.Constraint 

545 """ 

546 if value is None: 

547 attribute_constraint = iris.Constraint( 

548 cube_func=lambda cube: attribute in cube.attributes 

549 ) 

550 else: 

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

552 return attribute_constraint