Coverage for src/CSET/operators/filters.py: 98%
67 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 08:32 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 08:32 +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.
15"""Operators to perform various kind of filtering."""
17import logging
19import iris
20import iris.cube
21import iris.exceptions
22import numpy as np
24from CSET._common import iter_maybe
26logger = logging.getLogger(__name__)
29def apply_mask(
30 original_field: iris.cube.Cube | iris.cube.CubeList,
31 mask: iris.cube.Cube | iris.cube.CubeList,
32) -> iris.cube.Cube | iris.cube.CubeList:
33 """Apply a mask to given data as a masked array.
35 Parameters
36 ----------
37 original_field: iris.cube.Cube | iris.cube.CubeList
38 The field(s) to be masked.
39 mask: iris.cube.Cube | iris.cube.CubeList
40 The mask(s) being applied to the original field(s).
42 Returns
43 -------
44 masked_field: iris.cube.Cube | iris.cube.CubeList
45 A cube or cubelist of the masked field(s).
47 Notes
48 -----
49 The mask is first converted to 1s and NaNs before multiplication with
50 the original data.
52 As discussed in generate_mask, you can combine multiple masks in a
53 recipe using other functions before applying the mask to the data.
55 Examples
56 --------
57 >>> land_points_only = apply_mask(temperature, land_mask)
58 """
59 masked_fields = iris.cube.CubeList([])
60 for M, F in zip(iter_maybe(mask), iter_maybe(original_field), strict=True):
61 # Ensure mask data are floats and only 1s or NaNs.
62 M.data = np.float64(M.data)
63 M.data[M.data == 0.0] = np.nan
64 M.data[~np.isnan(M.data)] = 1.0
65 logger.info(
66 "Mask set to 1 or 0s, if addition of multiple masks results"
67 "in values > 1 these are set to 1."
68 )
69 masked_field = F.copy()
70 masked_field.data *= M.data
71 masked_field.attributes["mask"] = f"mask_of_{F.name()}"
72 masked_fields.append(masked_field)
73 if len(masked_fields) == 1:
74 return masked_fields[0]
75 else:
76 return masked_fields
79def filter_cubes(
80 cube: iris.cube.Cube | iris.cube.CubeList,
81 constraint: iris.Constraint,
82 **kwargs,
83) -> iris.cube.Cube:
84 """Filter a CubeList down to a single Cube based on a constraint.
86 Arguments
87 ---------
88 cube: iris.cube.Cube | iris.cube.CubeList
89 Cube(s) to filter
90 constraint: iris.Constraint
91 Constraint to extract
93 Returns
94 -------
95 iris.cube.Cube
97 Raises
98 ------
99 ValueError
100 If the constraint doesn't produce a single cube.
101 """
102 filtered_cubes = cube.extract(constraint)
103 # Return directly if already a cube.
104 if isinstance(filtered_cubes, iris.cube.Cube):
105 return filtered_cubes
106 # Check filtered cubes is a CubeList containing one cube.
107 if isinstance(filtered_cubes, iris.cube.CubeList) and len(filtered_cubes) == 1:
108 return filtered_cubes[0]
109 else:
110 raise ValueError(
111 f"Constraint doesn't produce single cube. Constraint: {constraint}"
112 f"\nSource: {cube}\nResult: {filtered_cubes}"
113 )
116def filter_multiple_cubes(
117 cubes: iris.cube.Cube | iris.cube.CubeList,
118 **kwargs,
119) -> iris.cube.CubeList:
120 """Filter a CubeList on multiple constraints, returning another CubeList.
122 Arguments
123 ---------
124 cube: iris.cube.Cube | iris.cube.CubeList
125 Cube(s) to filter
126 constraint: iris.Constraint
127 Constraint to extract. This must be a named argument. There can be any
128 number of additional constraints, they just need unique names.
130 Returns
131 -------
132 iris.cube.CubeList
134 Raises
135 ------
136 ValueError
137 Some constraints don't produce any cubes.
138 """
139 # Ensure input is a CubeList.
140 if isinstance(cubes, iris.cube.Cube):
141 cubes = iris.cube.CubeList((cubes,))
142 if len(kwargs) < 1:
143 raise ValueError("Must have at least one constraint.")
144 # Switch to extract due to lack of instance requiring one cube per
145 # constraint.
146 try:
147 filtered_cubes = cubes.extract(kwargs.values())
148 except iris.exceptions.ConstraintMismatchError as err:
149 raise ValueError("The constraints don't produce a cube or cubelist.") from err
150 if len(filtered_cubes) == 0:
151 raise ValueError("No cubes loaded. Please check your constraints.")
152 return filtered_cubes
155def generate_mask(
156 mask_field: iris.cube.Cube | iris.cube.CubeList,
157 condition: str,
158 value: float,
159) -> iris.cube.Cube | iris.cube.CubeList:
160 """Generate a mask to remove data not meeting conditions.
162 Parameters
163 ----------
164 mask_field: iris.cube.Cube | iris.cube.CubeList
165 The field(s) to be used for creating the mask.
166 condition: str
167 The type of condition applied, six available options:
168 'eq','ne','lt','le','gt', and 'ge'. The condition is consistent
169 regardless of whether mask_field is a cube or CubeList.
170 The conditions are as follows
171 eq: equal to,
172 ne: not equal to,
173 lt: less than,
174 le: less than or equal to,
175 gt: greater than,
176 ge: greater than or equal to.
177 value: float
178 The value on the right hand side of the condition. The value is
179 consistent regardless of whether mask_field is a cube or CubeList.
181 Returns
182 -------
183 mask: iris.cube.Cube | iris.cube.CubeList
184 Mask(s) meeting the condition applied.
186 Raises
187 ------
188 ValueError: Unexpected value for condition. Expected eq, ne, gt, ge, lt, le.
189 Got {condition}.
190 Raised when condition is not supported.
192 Notes
193 -----
194 The mask is created in the opposite sense to numpy.ma.masked_arrays. This
195 method was chosen to allow easy combination of masks together outside of
196 this function using misc.addition or misc.multiplication depending on
197 applicability. The combinations can be of any fields such as orography >
198 500 m, and humidity == 100 %.
200 The conversion to a masked array occurs in the apply_mask routine, which
201 should happen after all relevant masks have been combined.
203 Examples
204 --------
205 >>> land_mask = generate_mask(land_sea_mask,'gt',1)
206 """
207 mask_list = iris.cube.CubeList([])
208 for cube in iter_maybe(mask_field):
209 mask = cube.copy()
210 mask.data[:] = 0.0
211 match condition:
212 case "eq":
213 mask.data[cube.data == value] = 1.0
214 case "ne":
215 mask.data[cube.data != value] = 1.0
216 case "gt":
217 mask.data[cube.data > value] = 1.0
218 case "ge":
219 mask.data[cube.data >= value] = 1.0
220 case "lt":
221 mask.data[cube.data < value] = 1.0
222 case "le":
223 mask.data[cube.data <= value] = 1.0
224 case _:
225 raise ValueError("""Unexpected value for condition. Expected eq, ne,
226 gt, ge, lt, le. Got {condition}.""")
227 mask.attributes["mask"] = f"mask_for_{cube.name()}_{condition}_{value}"
228 mask.rename(f"mask_for_{cube.name()}_{condition}_{value}")
229 mask.units = "1"
230 mask_list.append(mask)
232 if len(mask_list) == 1:
233 return mask_list[0]
234 else:
235 return mask_list