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 +2 -0
- ncagg/__main__.py +3 -0
- ncagg/aggregator.py +416 -0
- ncagg/aggrelist.py +645 -0
- ncagg/attributes.py +385 -0
- ncagg/cli.py +255 -0
- ncagg/config.py +382 -0
- ncagg-0.8.19.dist-info/METADATA +485 -0
- ncagg-0.8.19.dist-info/RECORD +13 -0
- ncagg-0.8.19.dist-info/WHEEL +5 -0
- ncagg-0.8.19.dist-info/entry_points.txt +2 -0
- ncagg-0.8.19.dist-info/licenses/LICENSE +21 -0
- ncagg-0.8.19.dist-info/top_level.txt +1 -0
ncagg/__init__.py
ADDED
ncagg/__main__.py
ADDED
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)
|