Coverage for src/CSET/operators/aggregate.py: 96%
101 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-24 15:03 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-24 15:03 +0000
1# © Crown copyright, Met Office (2022-2024) 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 aggregate across either 1 or 2 dimensions."""
17import logging
19import iris
20import iris.analysis
21import iris.coord_categorisation
22import iris.cube
23import iris.exceptions
24import iris.util
25import isodate
26import numpy as np
28from CSET._common import iter_maybe
29from CSET.operators._utils import is_time_aggregatable
31logger = logging.getLogger(__name__)
34def time_aggregate(
35 cubes: iris.cube.Cube | iris.cube.CubeList,
36 method: str,
37 interval_iso: str,
38 **kwargs,
39) -> iris.cube.Cube | iris.cube.CubeList:
40 """Aggregate cube/cubes by its time coordinate.
42 Aggregates fields in a cube or cube list for the specified coordinate and
43 using the method supplied. The aggregated cube/cubelist will keep the coordinate and
44 add a coordinate with the aggregated end time points.
46 Can also handle multiple forecast reference times.
48 Examples include generating hourly or 6-hourly precipitation accumulations for precipitation, or maximum screen level temperature every 3 hours.
50 We use the isodate class to convert ISO 8601 durations into time intervals
51 for creating a new time coordinate for aggregation.
53 We use the lambda function to pass coord and interval into the callable
54 category function in add_categorised to allow users to define their own
55 sub-daily intervals for the new time coordinate.
57 Arguments
58 ---------
59 cubes: iris.cube.Cube | iris.cube.CubeList
60 Cube or CubeList to aggregate and iterate over one dimension
61 method: str
62 Type of aggregate i.e. method: 'SUM', getattr creates
63 iris.analysis.SUM, etc.
64 interval_iso: isodate timedelta ISO 8601 object i.e PT6H (6 hours), PT30M (30 mins)
65 Interval to aggregate over.
66 interval_iso: str
67 A string containing a datetime timedelta for resampling over in hours, i.e. PT3H, PT24H.
69 Returns
70 -------
71 resampled_cubes: iris.cube.Cube | iris.cube.CubeList
72 Cube or CubeList containing aggregated cubes.
74 Raises
75 ------
76 ValueError
77 If the constraint doesn't produce a single cube containing a field.
78 """
79 # Return unchanged cubes if interval_iso is 0, to allow this operator to be used across multiple fields where only some will need resampling.
80 if interval_iso == "0": 80 ↛ 81line 80 didn't jump to line 81 because the condition on line 80 was never true
81 return cubes
83 cubes = iter_maybe(cubes)
85 resampled_cubes = iris.cube.CubeList()
87 timedelta = isodate.parse_duration(interval_iso)
88 interval = int(timedelta.total_seconds() / 3600)
90 for cube in cubes:
91 if cube.coord("forecast_reference_time").shape[0] > 1:
92 # Handle cubes with multiple forecast cycles.
93 aggregated_cube = _aggregate_in_time_multiple_frt(cube, method, interval)
94 else:
95 aggregated_cube = _aggregate_in_time_single_frt(cube, method, interval)
97 resampled_cubes.append(aggregated_cube)
98 if len(resampled_cubes) == 1: 98 ↛ 100line 98 didn't jump to line 100 because the condition on line 98 was always true
99 return resampled_cubes[0]
100 return resampled_cubes
103def ensure_aggregatable_across_cases(
104 cubes: iris.cube.Cube | iris.cube.CubeList,
105) -> iris.cube.CubeList:
106 """Ensure a Cube or CubeList can be aggregated across multiple cases.
108 The cubes are grouped into buckets of compatible cubes, then each bucket is
109 converted into a single aggregatable cube with ``forecast_period`` and
110 ``forecast_reference_time`` dimension coordinates.
112 Arguments
113 ---------
114 cubes: iris.cube.Cube | iris.cube.CubeList
115 Each cube is checked to determine if it has the necessary
116 dimensional coordinates to be aggregatable, being processed if needed.
118 Returns
119 -------
120 cubes: iris.cube.CubeList
121 A CubeList of time aggregatable cubes.
123 Raises
124 ------
125 ValueError
126 If any of the provided cubes cannot be made aggregatable.
128 Notes
129 -----
130 This is a simple operator designed to ensure that a Cube is aggregatable
131 across cases. If a CubeList is presented it will create an aggregatable Cube
132 from that list. Its functionality is for case study (or trial) aggregation
133 to ensure that the full dataset can be loaded as a single cube. This
134 functionality is particularly useful for percentiles, Q-Q plots, and
135 histograms.
137 The necessary dimension coordinates for a cube to be aggregatable are
138 ``forecast_period`` and ``forecast_reference_time``.
139 """
141 # Group compatible cubes.
142 class Buckets:
143 def __init__(self):
144 self.buckets = []
146 def add(self, cube: iris.cube.Cube):
147 """Add a cube into a bucket.
149 If the cube is compatible with an existing bucket it is added there.
150 Otherwise it gets its own bucket.
151 """
152 for bucket in self.buckets:
153 if bucket[0].is_compatible(cube):
154 bucket.append(cube)
155 return
156 self.buckets.append(iris.cube.CubeList([cube]))
158 def get_buckets(self) -> list[iris.cube.CubeList]:
159 return self.buckets
161 b = Buckets()
162 for cube in iter_maybe(cubes):
163 b.add(cube)
164 buckets = b.get_buckets()
166 logger.debug("Buckets:\n%s", "\n---\n".join(str(b) for b in buckets))
168 # Ensure each bucket is a single aggregatable cube.
169 aggregatable_cubes = iris.cube.CubeList()
170 for bucket in buckets:
171 # Single cubes that are already aggregatable won't need processing.
172 if len(bucket) == 1 and is_time_aggregatable(bucket[0]):
173 aggregatable_cube = bucket[0]
174 aggregatable_cube = _add_nref(aggregatable_cube)
175 aggregatable_cubes.append(aggregatable_cube)
176 continue
178 # Create an aggregatable cube from the provided CubeList.
179 to_merge = iris.cube.CubeList()
180 for cube in bucket:
181 try:
182 to_merge.extend(
183 cube.slices_over(["forecast_period", "forecast_reference_time"])
184 )
185 except iris.exceptions.CoordinateNotFoundError as err:
186 raise ValueError(
187 "Cube should have 'forecast_period' and 'forecast_reference_time' dimension coordinates.",
188 cube,
189 ) from err
190 aggregatable_cube = to_merge.merge_cube()
192 # Add attribute on number of forecast_reference_times
193 aggregatable_cube = _add_nref(aggregatable_cube)
195 # Verify cube is now aggregatable.
196 if not is_time_aggregatable(aggregatable_cube): 196 ↛ 197line 196 didn't jump to line 197 because the condition on line 196 was never true
197 raise ValueError(
198 "Cube should have 'forecast_period' and 'forecast_reference_time' dimension coordinates.",
199 aggregatable_cube,
200 )
201 aggregatable_cubes.append(aggregatable_cube)
203 return aggregatable_cubes
206def add_hour_coordinate(
207 cubes: iris.cube.Cube | iris.cube.CubeList,
208) -> iris.cube.Cube | iris.cube.CubeList:
209 """Add a category coordinate of hour of day to a Cube or CubeList.
211 Arguments
212 ---------
213 cubes: iris.cube.Cube | iris.cube.CubeList
214 Cube of any variable that has a time coordinate.
215 Note input Cube or CubeList items should only have 1 time dimension.
217 Returns
218 -------
219 cube: iris.cube.Cube
220 A Cube with an additional auxiliary coordinate of hour.
222 Notes
223 -----
224 This is a simple operator designed to be used prior to case aggregation for
225 histograms, Q-Q plots, and percentiles when aggregated by hour of day.
226 """
227 new_cubelist = iris.cube.CubeList()
228 for cube in iter_maybe(cubes):
229 # Add a category coordinate of hour into each cube.
230 iris.util.promote_aux_coord_to_dim_coord(cube, "time")
231 iris.coord_categorisation.add_hour(cube, "time", name="hour")
232 cube.coord("hour").units = "hours"
233 new_cubelist.append(cube)
235 if len(new_cubelist) == 1:
236 return new_cubelist[0]
237 else:
238 return new_cubelist
241def rolling_window_time_aggregation(
242 cubes: iris.cube.Cube | iris.cube.CubeList, method: str, window: int
243) -> iris.cube.Cube | iris.cube.CubeList:
244 """Aggregate a cube along the time dimension using a rolling window.
246 Arguments
247 ---------
248 cubes: iris.cube.Cube | iris.cube.CubeList
249 Cube or Cubelist of any variable to be aggregated over a rolling window
250 in time.
251 method: str
252 Type of aggregate i.e. method: 'MAX', getattr creates
253 iris.analysis.MAX, etc.
254 window: int
255 The rolling window size.
257 Returns
258 -------
259 cube: iris.cube.Cube | iris.cube.CubeList
260 A Cube or Cubelist of the rolling window aggregate. The Cubes will have
261 a time dimension that is reduced in size to the original cube by the
262 window size.
264 Notes
265 -----
266 This operator is designed to be used to help create daily maxima and minima
267 for any variable.
268 """
269 new_cubelist = iris.cube.CubeList()
270 for cube in iter_maybe(cubes):
271 # Use a rolling window in time to applied specified aggregation method
272 # over a specified window length.
273 window_cube = cube.rolling_window(
274 "time", getattr(iris.analysis, method), window
275 )
276 new_cubelist.append(window_cube)
278 if len(new_cubelist) == 1:
279 return new_cubelist[0]
280 else:
281 return new_cubelist
284def _add_nref(cube: iris.cube.Cube):
285 """Retain information on number of forecast_reference_time inputs.
287 This preserves information on number of aggregated cases that can
288 otherwise be lost on subsequent calls to collapse functions.
289 """
290 nref = np.size(cube.coord("forecast_reference_time").points)
291 cube.coord("time").attributes["number_reference_times"] = nref
292 return cube
295def _aggregate_in_time_multiple_frt(
296 cube: iris.cube.Cube, method: str, interval: int
297) -> iris.cube.Cube:
298 """Aggregate a cube with multiple forecast reference times.
300 Aggregates each forecast cycle separately, then concatenates the results
301 back into a single cube along forecast_reference_time.
302 """
303 aggregated_cycles = iris.cube.CubeList()
304 for frt_cube in cube.slices_over("forecast_reference_time"):
305 iris.coord_categorisation.add_categorised_coord(
306 frt_cube,
307 "interval",
308 "time",
309 lambda coord, cell: cell // interval * interval,
310 )
311 agg = frt_cube.aggregated_by(
312 "interval",
313 getattr(iris.analysis, method),
314 )
315 agg.remove_coord("interval")
316 agg = iris.util.new_axis(
317 agg,
318 agg.coord("forecast_reference_time"),
319 )
320 aggregated_cycles.append(agg)
321 # Current approach is to remove time 2d auxcoord as it causes issues concatenating into a single cube.
322 # Time can be reconstructed from forecast_reference_time and forecast_period later on.
323 # as we can construct it if needed.
324 for cb in aggregated_cycles:
325 cb.remove_coord("time")
326 return aggregated_cycles.concatenate_cube()
329def _aggregate_in_time_single_frt(cube: iris.cube.Cube, method: str, interval: int):
330 """Aggregate a cube with one forecast reference time."""
331 iris.coord_categorisation.add_categorised_coord(
332 cube,
333 "interval",
334 "time",
335 lambda coord, cell: cell // interval * interval,
336 )
337 aggregated_cube = cube.aggregated_by(
338 "interval",
339 getattr(iris.analysis, method),
340 )
341 aggregated_cube.remove_coord("interval")
342 return aggregated_cube