ncagg 0.8.19__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ncagg/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ from ncagg.aggregator import aggregate as aggregate
2
+ from ncagg.config import Config as Config
ncagg/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import cli
2
+
3
+ cli()
ncagg/aggregator.py ADDED
@@ -0,0 +1,416 @@
1
+ import logging
2
+ import traceback
3
+ import warnings
4
+ from datetime import datetime
5
+
6
+ import netCDF4 as nc
7
+ import numpy as np
8
+
9
+ from .aggrelist import FillNode, InputFileNode, VariableNotFoundException
10
+ from .attributes import AttributeHandler
11
+ from .config import Config
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ warnings.filterwarnings("ignore", category=RuntimeWarning)
16
+
17
+ # WELCOME:
18
+ # A convenience wrapper is provided as `aggregate(files, output, config=None)`
19
+ #
20
+ # Under the hood, this is a two step process.
21
+ # # STEP 1. Create the aggregation list from a list of files.
22
+ # aggregation_list = generate_aggregation_list(config, files)
23
+ # # STEP 2. Evaluate the aggregation list to an output file.
24
+ # evaluate_aggregation_list(aggregation_list, filename)
25
+ #
26
+ # -----------------------
27
+ #
28
+ # How certain is the cadence/arrival of unlim dim indicies? Most likely, real instruments
29
+ # don't produce measurements at an EXACT step... generally there will be some wiggle room.
30
+ # Should take a value between 0.0 < timing_certainty <= 1.0, where 1.0 is most certain and
31
+ # allows no deviation. # TODO: configurable per unlim dim?
32
+ timing_certainty = 0.9
33
+
34
+
35
+ def aggregate(files_to_aggregate, output_filename, config=None):
36
+ # type: (list[str], str, None | Config) -> None
37
+ """
38
+ Aggregate files_to_aggregate into output_filename, with optional conifg.
39
+ Convenience function intended to be primary external interface to aggregation.
40
+
41
+ :param files_to_aggregate: List of NetCDF filenames to aggregate.
42
+ :param output_filename: Filename to create and write output to.
43
+ :param config: Optional configuration, default generated from first file if not given.
44
+ :return: None
45
+ """
46
+ if config is None:
47
+ config = Config.from_nc(files_to_aggregate[0])
48
+
49
+ agg_list = generate_aggregation_list(config, files_to_aggregate)
50
+ evaluate_aggregation_list(config, agg_list, output_filename)
51
+
52
+
53
+ def generate_aggregation_list(config, files_to_aggregate):
54
+ # type: (Config, list[str]) -> list[AbstractNode]
55
+ """
56
+ Generate an aggregation list from a list of input files.
57
+
58
+ :param config: Aggregation configuration
59
+ :param files_to_aggregate: a list of filenames to aggregate.
60
+ :return: a list containing objects inheriting from AbstractNode describing aggregation
61
+ """
62
+ preliminary = []
63
+
64
+ for f in sorted(files_to_aggregate):
65
+ try:
66
+ preliminary.append(InputFileNode(config, f))
67
+ except Exception as e:
68
+ logger.warning(
69
+ "Error initializing InputFileNode for %s, skipping: %s" % (f, repr(e))
70
+ )
71
+ logger.debug(traceback.format_exc())
72
+
73
+ if len(preliminary) == 0:
74
+ # no files in aggregation list... abort
75
+ return preliminary
76
+
77
+ index_by_dims = [
78
+ d
79
+ for d in config.dims.values()
80
+ if d["index_by"] is not None and not d["flatten"]
81
+ ]
82
+ if len(index_by_dims) == 0:
83
+ # no indexing dimensions found... nothing further to do here...
84
+ return preliminary
85
+
86
+ # Find the primary index_by dim. First is_primary found, otherwise first index_by dim.
87
+ primary_index_by = next(
88
+ (d for d in config.dims.values() if d.get("is_primary", False)),
89
+ index_by_dims[0],
90
+ )
91
+
92
+ # Transfer items from perlimiary to final. According to primary index_by dim,
93
+ # adding fill nodes and correcting overlap.
94
+ preliminary = sorted(
95
+ preliminary, key=lambda p: p.get_first_of_index_by(primary_index_by)
96
+ )
97
+
98
+ def cast_bound(bound):
99
+ # type: (float | datetime) -> float
100
+ """Cast a bound to a numerical type for use. Will not be working directly with datetime objects."""
101
+ if isinstance(bound, datetime):
102
+ units = config.vars[primary_index_by["index_by"]]["attributes"]["units"]
103
+ return nc.date2num(bound, units)
104
+ return bound
105
+
106
+ first_along_primary = cast_bound(primary_index_by["min"])
107
+ last_along_primary = cast_bound(primary_index_by["max"])
108
+ cadence_hz = primary_index_by["expected_cadence"].get(
109
+ primary_index_by["name"], None
110
+ )
111
+
112
+ # Can continue into the correction loop as long as we have at least cadence_hz, or min and max.
113
+ if cadence_hz is None or (
114
+ first_along_primary is None and last_along_primary is None
115
+ ):
116
+ return preliminary
117
+
118
+ # dt_min is minimum time between files given timing_certainty. Inflate cadence (higher hz) -> smaller time step
119
+ # similarly, lower cadence (lower hz) -> larger time step
120
+ dt_min = 1.0 / (
121
+ (2.0 - timing_certainty) * cadence_hz
122
+ ) # smallest expected time step
123
+ dt_nom = 1.0 / cadence_hz # nominal expected time step
124
+ dt_max = 1.0 / (timing_certainty * cadence_hz) # largest expected time step
125
+
126
+ # final list of components to aggregate, containing InputFileNodes and possibly
127
+ # FillValueNodes. Built below going through priliminary list, adding one by one
128
+ # while examining spacing between files and the bounds at the beginning and end.
129
+ final = []
130
+ while len(preliminary) > 0:
131
+ next_f = preliminary.pop(0) # type: InputFileNode
132
+ next_start = next_f.get_first_of_index_by(primary_index_by)
133
+ next_end = next_f.get_last_of_index_by(primary_index_by)
134
+
135
+ # check if this potential next file is completely outside of the time bounds...
136
+ if (first_along_primary is not None and first_along_primary > next_end) or (
137
+ last_along_primary is not None and last_along_primary < next_start
138
+ ):
139
+ logger.info("File not in bounds: %s" % next_f)
140
+ # out of bounds, doesn't get included. Continue without adding this next_f to final.
141
+ continue
142
+
143
+ # if we get here, but have no cadence_hz, nothing more to do, just add file to final and continue.
144
+ if cadence_hz is None:
145
+ final.append(next_f)
146
+ continue
147
+
148
+ # subtract dt_min since first_along_primary is the bound, not a valid time point, so decrease to ensure
149
+ # that CASE: gap-too-small isn't triggered for first point, causing first point to get chopped off.
150
+ if len(final) > 0:
151
+ prev_end = final[-1].get_last_of_index_by(primary_index_by)
152
+ # the size of the gap between the previous file and the next, nominally time gap
153
+ gap_between = next_start - prev_end
154
+ elif first_along_primary is None:
155
+ # don't have a bound to compare against, so just have to start by adding first file we get
156
+ final.append(next_f)
157
+ continue
158
+ else:
159
+ # CASE: first file, take the bound and subtract a min time step so that
160
+ # the first time step greater than or equal to the bound will be included.
161
+ gap_between = next_start - first_along_primary + dt_min
162
+
163
+ # gap too big if skips 1.62 of the largest possible expected dt...
164
+ # The value 1.62 is picked somewhat artfully... just make sure all the tests pass.
165
+ if (
166
+ gap_between > 1.6 * dt_max and next_start - dt_nom > first_along_primary
167
+ ): # <----------- CASE: gap-too-big
168
+ # if the gap is too big, insert an appropriate fill value.
169
+ if len(final) > 0: # <-------------- CASE: exists-previous-file
170
+ size = int(
171
+ max(1, np.round((gap_between - dt_nom) * cadence_hz))
172
+ ) # probably correct
173
+ # when there is a previous file, make timestamps even from end of that one
174
+ start_from = prev_end
175
+ else: # <------------- CASE: no-previous-file
176
+ size = int(np.round(gap_between * cadence_hz)) - 1
177
+ # otherwise look at the next timestamp, and go backward _size_ from there to get the start_from.
178
+ start_from = next_start - ((size + 1) * dt_nom)
179
+ # note: start_from must be greater than first_along_primary
180
+ if start_from + dt_nom < first_along_primary:
181
+ # ... the calculated start_from is just a bit too short, so bump it up.
182
+ #
183
+ # Have not been able to find an expression yeilding correct results everywhere. A conditional
184
+ # in this situation appears to be necessary.
185
+ start_from += dt_nom
186
+ size -= 1
187
+
188
+ assert start_from + dt_nom >= first_along_primary, (
189
+ "{} + {} not gt {}".format(start_from, dt_nom, first_along_primary)
190
+ )
191
+
192
+ fill_node = FillNode(config)
193
+ fill_node.set_udim(primary_index_by, size, start_from)
194
+ final.append(fill_node)
195
+
196
+ # if the gap is too small, chop some off this next file to make it fit...
197
+ if gap_between < dt_min: # <----------- CASE: gap-too-small
198
+ # tbh, getting num_overlap correct has been mostly trial and error...
199
+ num_overlap = np.ceil(np.abs((gap_between - dt_min) * cadence_hz))
200
+ next_f.set_dim_slice_start(primary_index_by, num_overlap)
201
+ # note: setting dim_slice_start effectively invalidates the previously set next_start variable
202
+
203
+ # make sure the end of the next_f isn't sticking out after the max boundary
204
+ if (
205
+ last_along_primary is not None and last_along_primary < next_end
206
+ ): # <---------- CASE: hanging-over-edge
207
+ gap_between_end = next_end - last_along_primary
208
+ num_overlap = np.abs(np.ceil(gap_between_end * cadence_hz))
209
+ # note: set stop to negative of overlap, ie. backwards from end since
210
+ # there will be no following file.
211
+ next_f.set_dim_slice_stop(primary_index_by, -num_overlap)
212
+
213
+ # And finally, if there's anything left of this next_f, add it to the final.
214
+ # Note: strict=False allows get_size_along to return a negative number.
215
+ # This negative number may arise if both CASE: gap-too-small and CASE: hanging-over-edge are triggered above.
216
+ # Example: a file with 10 records: gap-to-small sets start to 8 and hanging-over-edge sets # stop to -5,
217
+ # then get_size_along with strict=False will return -3. Up to this point, that's fine, that means don't
218
+ # include the file since none of it belongs!
219
+ if next_f.get_size_along(primary_index_by, strict=False) > 0:
220
+ final.append(next_f)
221
+
222
+ # after looping over the input files given, check if we haven't quite reached the end point and need
223
+ # to add a FillNode to get the final way there.
224
+ if len(final) > 0 and not isinstance(final[-1], FillNode):
225
+ prev_end = final[-1].get_last_of_index_by(primary_index_by)
226
+ # add dt_min to last_along_primary again since last_along_primary isn't a real data point
227
+ # again, gap_to_end must consider numerical issues. last and prev large magnitude, dt_min is small
228
+ gap_to_end = (last_along_primary - prev_end) + dt_min
229
+ if gap_to_end > dt_max:
230
+ fill_node = FillNode(config)
231
+ size = np.floor((gap_to_end - dt_min) * cadence_hz)
232
+ fill_node.set_udim(primary_index_by, size, prev_end)
233
+ final.append(fill_node)
234
+
235
+ return final
236
+
237
+
238
+ def evaluate_aggregation_list(config, aggregation_list, to_fullpath, callback=None):
239
+ # type: (Config, list[AbstractNode], str, None | Callable) -> None
240
+ """
241
+ Evaluate an aggregation list to a file.... ie. actually do the aggregation.
242
+
243
+ :param config: Aggregation configuration
244
+ :param aggregation_list: AggList specifying how to create aggregation.
245
+ :param to_fullpath: Filename for output.
246
+ :param callback: called every time an aggregation_list element is processed.
247
+ :return: None
248
+ """
249
+ if aggregation_list is None or len(aggregation_list) == 0:
250
+ logger.warn("No files in aggregation list, nothing to do.")
251
+ return # bail early
252
+
253
+ initialize_aggregation_file(config, to_fullpath)
254
+
255
+ attribute_handler = AttributeHandler(config, filename=to_fullpath)
256
+
257
+ vars_once = []
258
+ vars_unlim = []
259
+
260
+ # Each of lists above is treated differently, figure out treatment for each variable ahead of time, once.
261
+ for v in config.vars.values():
262
+ var_dims = [config.dims[d] for d in v["dimensions"]]
263
+
264
+ depends_on_unlimited = any((d["size"] is None for d in var_dims))
265
+ if not depends_on_unlimited:
266
+ # variables that don't depend on an unlimited dimension, we'll only need to copy once.
267
+ vars_once.append(v)
268
+ else:
269
+ vars_unlim.append(v)
270
+
271
+ with nc.Dataset(to_fullpath, "r+") as nc_out: # type: nc.Dataset
272
+ # the vars once don't depend on an unlimited dim so only need to be copied once. Find the first
273
+ # InputFileNode to copy from so we don't get fill values. Otherwise, if none exists, which shouldn't
274
+ # happen, but oh well, use a fill node.
275
+ vars_once_src = next(
276
+ (i for i in aggregation_list if isinstance(i, InputFileNode)),
277
+ aggregation_list[0],
278
+ )
279
+ with vars_once_src.get_evaluation_functions() as (data_for, _):
280
+ for var in vars_once: # case: do once, only for first input file node
281
+ try:
282
+ nc_out.variables[var["name"]][:] = data_for(var)
283
+ except Exception:
284
+ logger.error(
285
+ "Error copying component: %s, one time variable: %s"
286
+ % (vars_once_src, var)
287
+ )
288
+ logger.error(traceback.format_exc())
289
+
290
+ for component in aggregation_list: # type: AbstractNode
291
+ with component.get_evaluation_functions() as (data_for, callback_with_file):
292
+ unlim_starts = {
293
+ k: nc_out.dimensions[k].size
294
+ for k, v in config.dims.items()
295
+ if v["size"] is None
296
+ }
297
+ for var in vars_unlim:
298
+ write_slices = []
299
+ for dim in [config.dims[d] for d in var["dimensions"]]:
300
+ if dim["size"] is None and not dim["flatten"]:
301
+ # case: regular concat var along unlim dim
302
+ d_start = unlim_starts[dim["name"]]
303
+ write_slices.append(
304
+ slice(d_start, d_start + component.get_size_along(dim))
305
+ )
306
+ elif (
307
+ dim["size"] is None
308
+ and dim["flatten"]
309
+ and dim["index_by"] is None
310
+ ):
311
+ # case: simple flatten unlim
312
+ write_slices.append(slice(0, component.get_size_along(dim)))
313
+ elif (
314
+ dim["size"] is None
315
+ and dim["flatten"]
316
+ and dim["index_by"] is not None
317
+ ):
318
+ # case: flattening according to an index
319
+ # TODO: finish this...
320
+ # implementation will probably include ensuring that
321
+ # the InputFileNode and generate_aggregation_list
322
+ # work on multidims. Should just be copying the data here.
323
+ write_slices.append(slice(0, component.get_size_along(dim)))
324
+ else:
325
+ write_slices.append(slice(None))
326
+ try:
327
+ output_data = data_for(var) # type: np.array
328
+ if np.issubdtype(output_data.dtype, np.floating):
329
+ # numpy ufunc isnan only defined for floating types.
330
+ nc_out.variables[var["name"]][write_slices] = (
331
+ np.ma.masked_where(np.isnan(output_data), output_data)
332
+ )
333
+ else:
334
+ nc_out.variables[var["name"]][write_slices] = output_data
335
+
336
+ except VariableNotFoundException:
337
+ # this error is fine and expected. The template may contain variables that don't
338
+ # exist in the inputs, just pass over them and they will come out as fill values.
339
+ pass
340
+ except Exception:
341
+ # something else... unexpected
342
+ logger.error(
343
+ "Error copying component: %s, unlim variable: %s"
344
+ % (component, var)
345
+ )
346
+ logger.error(traceback.format_exc())
347
+
348
+ # do once per component
349
+ callback_with_file(attribute_handler.process_file)
350
+
351
+ if callback is not None:
352
+ callback()
353
+
354
+ nc_out.sync() # write buffered data to disk
355
+
356
+ attribute_handler.finalize_file(
357
+ nc_out
358
+ ) # after aggregation finished, finalize the global attributes
359
+
360
+
361
+ def initialize_aggregation_file(config, fullpath):
362
+ # type: (Config, str) -> None
363
+ """
364
+ Based on the configuration, initialize a file in which to write the aggregated output.
365
+
366
+ In other words, resurrect a netcdf file that would result in config.
367
+
368
+ :param config: Aggregation configuration
369
+ :param fullpath: filename of output to initialize.
370
+ :return: None
371
+ """
372
+ with nc.Dataset(fullpath, "w") as nc_out:
373
+ for dim in config.dims.values():
374
+ # note: dim["size"] will be None for unlimited dimensions.
375
+ nc_out.createDimension(dim["name"], dim["size"])
376
+ for var in config.vars.values():
377
+ var_name = var.get("map_to", var["name"])
378
+ var_type = np.dtype(var["datatype"])
379
+ fill_value = var["attributes"].get("_FillValue", None)
380
+ if fill_value is not None:
381
+ # fill_value is None by default, but if there is a value specified,
382
+ # explicitly cast it to the same type as the data.
383
+ fill_value = var_type.type(fill_value)
384
+ zlib = True
385
+ if np.issubdtype(var_type, str):
386
+ # NetCDF started raising RuntimeError when passed compression args on
387
+ # vlen datatypes. Detect vlens (str for now) and avoid compression.
388
+ # Ref: https://github.com/Unidata/netcdf4-python/issues/1205
389
+ zlib = False
390
+ var_out = nc_out.createVariable(
391
+ var_name,
392
+ var_type,
393
+ var["dimensions"],
394
+ chunksizes=var["chunksizes"],
395
+ zlib=zlib,
396
+ complevel=7 if zlib else None,
397
+ fill_value=fill_value,
398
+ )
399
+ for k, v in var["attributes"].items():
400
+ if k in ["valid_min", "valid_max"]:
401
+ # cast scalar attributes to datatype of variable
402
+ # smc@20181206: moved hanldling of _FillValue to createVariable, seems
403
+ # to matter for netcdf vlen datatypes (eg. string types)
404
+ var["attributes"][k] = var_type.type(v)
405
+ if k in ["valid_range", "flag_masks", "flag_values"]:
406
+ # cast array attributes to datatype of variable
407
+ # Note: two ways to specify an array attribute in Config, either CSV or as actual array.
408
+ if isinstance(v, str):
409
+ var["attributes"][k] = np.array(
410
+ map(var_type.type, v.split(", ")), dtype=var_type
411
+ )
412
+ else:
413
+ var["attributes"][k] = np.array(v, dtype=var_type)
414
+
415
+ ncatts = {k: v for k, v in var["attributes"].items() if k != "_FillValue"}
416
+ var_out.setncatts(ncatts)