carray-netcdf 2.0.0 → 2.1.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -2
- data/CONTRIBUTING.md +3 -3
- data/README.md +53 -21
- data/bin/ncheader +43 -8
- data/bin/ncvar +48 -12
- data/carray-netcdf.gemspec +4 -4
- data/docs/00_introduction.md +19 -30
- data/docs/01_getting_started.md +16 -25
- data/docs/02_reading.md +26 -26
- data/docs/03_writing.md +53 -44
- data/docs/04_codec.md +74 -82
- data/docs/05_groups.md +5 -5
- data/docs/06_low_level_api.md +161 -0
- data/docs/07_cookbook.md +167 -126
- data/docs/08_cheatsheet.md +77 -52
- data/docs/09_cli.md +4 -4
- data/lib/carray/netcdf/codec.rb +223 -29
- data/lib/carray/netcdf/dim.rb +8 -5
- data/lib/carray/netcdf/file.rb +184 -57
- data/lib/carray/netcdf/group.rb +301 -43
- data/lib/carray/netcdf/var.rb +311 -31
- data/lib/carray/netcdf/version.rb +1 -1
- data/lib/carray/netcdf.rb +99 -17
- metadata +7 -7
- data/docs/06_layer1.md +0 -160
data/lib/carray/netcdf/file.rb
CHANGED
|
@@ -19,16 +19,16 @@ module CA
|
|
|
19
19
|
class File < Group
|
|
20
20
|
|
|
21
21
|
# Kwargs on {.create} that become file-wide defaults for every
|
|
22
|
-
# subsequent {Group#def_var} call. See docs/
|
|
22
|
+
# subsequent {Group#def_var} call. See docs/03_writing.md for details.
|
|
23
23
|
# @return [Array<Symbol>]
|
|
24
|
-
FILE_VAR_DEFAULTS =
|
|
24
|
+
FILE_VAR_DEFAULTS = VAR_OPTION_KEYS
|
|
25
25
|
|
|
26
26
|
class << self
|
|
27
27
|
# Open an existing NetCDF file.
|
|
28
28
|
#
|
|
29
29
|
# @param path [String]
|
|
30
30
|
# @param mode [:read, :write, :append]
|
|
31
|
-
# @param codec [Symbol, Array, Object] see docs/
|
|
31
|
+
# @param codec [Symbol, Array, Object] see docs/04_codec.md
|
|
32
32
|
# @yieldparam file [File]
|
|
33
33
|
# @return [File] the file (or block's return value if a block is
|
|
34
34
|
# given; the file is closed after the block)
|
|
@@ -45,16 +45,19 @@ module CA
|
|
|
45
45
|
# Create a new NetCDF file.
|
|
46
46
|
#
|
|
47
47
|
# @param path [String] final file path
|
|
48
|
-
# @param codec [Symbol, Array, Object] see docs/
|
|
48
|
+
# @param codec [Symbol, Array, Object] see docs/04_codec.md
|
|
49
49
|
# @param clobber [true, false, :skip] overwrite policy when `path`
|
|
50
50
|
# already exists. `true` (default) overwrites; `false` raises
|
|
51
51
|
# `Errno::EEXIST`; `:skip` returns `nil` without calling the
|
|
52
52
|
# block — handy for resumable batches (`Dir[...]` picks up
|
|
53
53
|
# completed outputs unchanged).
|
|
54
|
-
# @param netcdf4 [Boolean] use the NetCDF-4 (HDF5) format.
|
|
55
|
-
# `template:` File / Group
|
|
56
|
-
#
|
|
57
|
-
#
|
|
54
|
+
# @param netcdf4 [Boolean, nil] use the NetCDF-4 (HDF5) format.
|
|
55
|
+
# Left nil, it follows a `template:` File / Group's format, so a
|
|
56
|
+
# NetCDF-4 template produces a NetCDF-4 output; `false` asks for
|
|
57
|
+
# the classic format whatever the template.
|
|
58
|
+
# @param format [Symbol, nil] `:classic`, `:offset64` (CDF-2),
|
|
59
|
+
# `:cdf5`, `:netcdf4` or `:netcdf4_classic`; instead of
|
|
60
|
+
# `netcdf4:`. Left nil, as for `netcdf4:`.
|
|
58
61
|
# @param atomic [Boolean] write to a same-dir tmp file, rename to
|
|
59
62
|
# `path` only on successful close; unlink the tmp on any
|
|
60
63
|
# exception from the block form. Rename is atomic on POSIX.
|
|
@@ -73,7 +76,7 @@ module CA
|
|
|
73
76
|
# template's atts merged with these overrides (last-writer
|
|
74
77
|
# wins).
|
|
75
78
|
# @param vars [Array<String>] template variable names to copy in
|
|
76
|
-
# full (definition AND data, bit-identical via raw +
|
|
79
|
+
# full (definition AND data, bit-identical via raw + the low-level API).
|
|
77
80
|
# Typical use: copy coordinate vars like `%w[time lat lon]` once
|
|
78
81
|
# per batch iteration.
|
|
79
82
|
# @param var_defaults [Hash] file-wide defaults picked up by every
|
|
@@ -82,7 +85,7 @@ module CA
|
|
|
82
85
|
# @yieldparam file [File]
|
|
83
86
|
# @return [File, nil] the file (or block's return value); `nil`
|
|
84
87
|
# when `clobber: :skip` and `path` already exists.
|
|
85
|
-
def create(path, codec: :auto, clobber: true, netcdf4:
|
|
88
|
+
def create(path, codec: :auto, clobber: true, netcdf4: nil, format: nil,
|
|
86
89
|
atomic: false, template: nil,
|
|
87
90
|
dims: true, atts: true, vars: [],
|
|
88
91
|
**var_defaults, &block)
|
|
@@ -102,22 +105,33 @@ module CA
|
|
|
102
105
|
if clobber == :skip && ::File.exist?(path)
|
|
103
106
|
return nil
|
|
104
107
|
end
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
src_file = template.is_a?(File) ? template : template.file
|
|
110
|
-
# nc_inq_format returns the NC_FORMAT_* Integer constant.
|
|
111
|
-
fmt = src_file.format
|
|
112
|
-
netcdf4 = true if fmt == ::NetCDF::NC_FORMAT_NETCDF4 ||
|
|
113
|
-
fmt == ::NetCDF::NC_FORMAT_NETCDF4_CLASSIC
|
|
108
|
+
# The format: `format:`, else `netcdf4:` (true / false), else the
|
|
109
|
+
# template's, else classic.
|
|
110
|
+
unless format.nil? || netcdf4.nil?
|
|
111
|
+
raise ArgumentError, "give format: or netcdf4:, not both"
|
|
114
112
|
end
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
format ||= case netcdf4
|
|
114
|
+
when true then :netcdf4
|
|
115
|
+
when false then :classic
|
|
116
|
+
else
|
|
117
|
+
if template
|
|
118
|
+
src_file = template.is_a?(File) ? template : template.file
|
|
119
|
+
FORMAT_NAMES.fetch(src_file.format, :classic)
|
|
120
|
+
else
|
|
121
|
+
:classic
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
format_flag = FORMAT_FLAGS.fetch(format) do
|
|
125
|
+
raise ArgumentError,
|
|
126
|
+
"format: expected one of #{FORMAT_FLAGS.keys.inspect}, got #{format.inspect}"
|
|
127
|
+
end
|
|
128
|
+
# Pre-flight clobber check: refuse before creating anything, so an
|
|
129
|
+
# atomic and a direct write raise the same error.
|
|
130
|
+
if clobber == false && ::File.exist?(path)
|
|
117
131
|
raise Errno::EEXIST, path
|
|
118
132
|
end
|
|
119
133
|
flag = clobber ? ::NetCDF::NC_CLOBBER : ::NetCDF::NC_NOCLOBBER
|
|
120
|
-
flag |=
|
|
134
|
+
flag |= format_flag
|
|
121
135
|
write_path = atomic ? atomic_tmp_path_for(path) : path
|
|
122
136
|
after_new = template ?
|
|
123
137
|
->(f) { apply_template(f, template, dims: dims, atts: atts, vars: vars) } :
|
|
@@ -131,6 +145,39 @@ module CA
|
|
|
131
145
|
|
|
132
146
|
private
|
|
133
147
|
|
|
148
|
+
# @api private
|
|
149
|
+
NC_64BIT_DATA = ::NetCDF.const_defined?(:NC_64BIT_DATA) ? ::NetCDF::NC_64BIT_DATA : 0x0020
|
|
150
|
+
|
|
151
|
+
# `format:` names -> the `nc_create` flags that make them.
|
|
152
|
+
# @api private
|
|
153
|
+
FORMAT_FLAGS = {
|
|
154
|
+
classic: 0,
|
|
155
|
+
offset64: ::NetCDF::NC_64BIT_OFFSET,
|
|
156
|
+
cdf5: NC_64BIT_DATA,
|
|
157
|
+
netcdf4: ::NetCDF::NC_NETCDF4,
|
|
158
|
+
netcdf4_classic: ::NetCDF::NC_NETCDF4 | ::NetCDF::NC_CLASSIC_MODEL,
|
|
159
|
+
}.freeze
|
|
160
|
+
|
|
161
|
+
# `NC_FORMAT_*` (what {#format} returns) -> the `format:` name.
|
|
162
|
+
# @api private
|
|
163
|
+
FORMAT_NAMES = {
|
|
164
|
+
::NetCDF::NC_FORMAT_CLASSIC => :classic,
|
|
165
|
+
::NetCDF::NC_FORMAT_64BIT_OFFSET => :offset64,
|
|
166
|
+
::NetCDF::NC_FORMAT_64BIT_DATA => :cdf5,
|
|
167
|
+
::NetCDF::NC_FORMAT_NETCDF4 => :netcdf4,
|
|
168
|
+
::NetCDF::NC_FORMAT_NETCDF4_CLASSIC => :netcdf4_classic,
|
|
169
|
+
}.freeze
|
|
170
|
+
|
|
171
|
+
# The file an atomic write replaces: the target of a symlink, so
|
|
172
|
+
# the link is kept and the tmp is made beside the real file.
|
|
173
|
+
# @api private
|
|
174
|
+
def atomic_target(path)
|
|
175
|
+
return path unless ::File.symlink?(path)
|
|
176
|
+
::File.realpath(path)
|
|
177
|
+
rescue SystemCallError
|
|
178
|
+
path # a dangling link is replaced as it is
|
|
179
|
+
end
|
|
180
|
+
|
|
134
181
|
# libnetcdf takes a "scheme://..." string as a URL, not a path.
|
|
135
182
|
def url?(path)
|
|
136
183
|
path.to_s.match?(%r{\A[A-Za-z][A-Za-z0-9+.-]*://})
|
|
@@ -142,6 +189,7 @@ module CA
|
|
|
142
189
|
# `find … -name '*.tmp'` picks up orphans. PID + random so
|
|
143
190
|
# parallel writers don't collide.
|
|
144
191
|
def atomic_tmp_path_for(path)
|
|
192
|
+
path = atomic_target(path)
|
|
145
193
|
dir = ::File.dirname(path)
|
|
146
194
|
base = ::File.basename(path)
|
|
147
195
|
::File.join(dir, "#{base}.#{Process.pid}.#{rand(2**32).to_s(16)}.tmp")
|
|
@@ -149,7 +197,14 @@ module CA
|
|
|
149
197
|
|
|
150
198
|
def build(ncid, filename, mode:, codec:, defining:,
|
|
151
199
|
tmp_path: nil, var_defaults: {}, after_new: nil, &block)
|
|
152
|
-
|
|
200
|
+
begin
|
|
201
|
+
f = new(ncid, filename, mode, codec, defining, tmp_path, var_defaults)
|
|
202
|
+
rescue Exception
|
|
203
|
+
# Without a File there is nothing to close it later.
|
|
204
|
+
::NetCDF.nc_close(ncid) rescue nil
|
|
205
|
+
::File.unlink(tmp_path) if tmp_path && ::File.exist?(tmp_path)
|
|
206
|
+
raise
|
|
207
|
+
end
|
|
153
208
|
# after_new runs BEFORE the user's block, but still inside the same
|
|
154
209
|
# try/close so a failure there also aborts an atomic write.
|
|
155
210
|
begin
|
|
@@ -186,15 +241,17 @@ module CA
|
|
|
186
241
|
raise ArgumentError,
|
|
187
242
|
"File.create dims: expected true / false / Array, got #{dims.inspect}"
|
|
188
243
|
end
|
|
244
|
+
# The template's attributes are copied as stored, keeping their
|
|
245
|
+
# types; Hash overrides are written by value.
|
|
189
246
|
case atts
|
|
190
247
|
when true
|
|
191
|
-
template.atts.
|
|
248
|
+
template.atts.each_key { |k| f.send(:copy_att_from, template, ::NetCDF::NC_GLOBAL, k) }
|
|
192
249
|
when false, nil
|
|
193
250
|
# skip
|
|
194
251
|
when Hash
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
252
|
+
overrides = atts.transform_keys(&:to_s)
|
|
253
|
+
(template.atts.keys - overrides.keys).each { |k| f.send(:copy_att_from, template, ::NetCDF::NC_GLOBAL, k) }
|
|
254
|
+
overrides.each { |k, v| f.def_att(k, v) }
|
|
198
255
|
else
|
|
199
256
|
raise ArgumentError,
|
|
200
257
|
"File.create atts: expected true / false / Hash, got #{atts.inspect}"
|
|
@@ -214,28 +271,37 @@ module CA
|
|
|
214
271
|
@codec_request = codec
|
|
215
272
|
@tmp_path = tmp_path
|
|
216
273
|
@var_defaults = var_defaults.freeze
|
|
274
|
+
@dim_index = {} # dimid -> Dim, file-wide (see Group#dim_by_id)
|
|
217
275
|
# We are the root: pass self as `file`, nil as `parent`.
|
|
218
276
|
super(ncid, self, nil)
|
|
219
277
|
# In create mode the Conventions attribute is not defined yet, so
|
|
220
278
|
# resolve the codec lazily (on first read/write, once definitions are
|
|
221
279
|
# in place). In read mode it can be resolved eagerly.
|
|
222
280
|
@codec = defining ? nil : Codec.resolve(self, codec)
|
|
223
|
-
# Safety net
|
|
224
|
-
#
|
|
225
|
-
#
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
281
|
+
# Safety net for a File never closed, at GC or process exit: close
|
|
282
|
+
# the ncid, or a NetCDF-3 file loses what was written and the ids
|
|
283
|
+
# run out. An atomic write's tmp is unlinked rather than published
|
|
284
|
+
# (rename is close's job, not GC's). `@state` is shared with the
|
|
285
|
+
# finalizer, so a File closed by hand is not closed again — its id
|
|
286
|
+
# may by then name another file.
|
|
287
|
+
@state = [false]
|
|
288
|
+
ObjectSpace.define_finalizer(self,
|
|
289
|
+
self.class.send(:unclosed_file_closer, ncid, @state, @tmp_path))
|
|
230
290
|
end
|
|
231
291
|
|
|
232
|
-
#
|
|
233
|
-
#
|
|
292
|
+
# Builds the finalizer in a class method so the proc does NOT capture
|
|
293
|
+
# `self` (that would pin the File and defeat GC).
|
|
234
294
|
# @api private
|
|
235
|
-
def self.
|
|
236
|
-
proc
|
|
295
|
+
def self.unclosed_file_closer(ncid, state, tmp)
|
|
296
|
+
proc do
|
|
297
|
+
unless state[0]
|
|
298
|
+
state[0] = true
|
|
299
|
+
::NetCDF.nc_close(ncid) rescue nil
|
|
300
|
+
::File.unlink(tmp) if tmp && ::File.exist?(tmp) rescue nil
|
|
301
|
+
end
|
|
302
|
+
end
|
|
237
303
|
end
|
|
238
|
-
private_class_method :
|
|
304
|
+
private_class_method :unclosed_file_closer
|
|
239
305
|
|
|
240
306
|
# The final (logical) file path. For an atomic-write File before
|
|
241
307
|
# close, the bytes are actually at a same-dir tmp path (see
|
|
@@ -271,40 +337,94 @@ module CA
|
|
|
271
337
|
super.tap { @codec = nil if @codec_request == :auto && name.to_s == "Conventions" }
|
|
272
338
|
end
|
|
273
339
|
|
|
340
|
+
# {Group#reload}, and the codec chosen again from `Conventions`
|
|
341
|
+
# (unless one was given with `codec:`).
|
|
342
|
+
# @return [self]
|
|
343
|
+
def reload
|
|
344
|
+
super
|
|
345
|
+
@codec = nil if @codec_request == :auto
|
|
346
|
+
self
|
|
347
|
+
end
|
|
348
|
+
|
|
274
349
|
# @return [Integer] `NC_FORMAT_*` constant
|
|
275
|
-
def format = nc_inq_format(@ncid)
|
|
350
|
+
def format = (ensure_open; nc_inq_format(@ncid))
|
|
351
|
+
|
|
352
|
+
# Whether the file is in a NetCDF-4 format (NetCDF-4, or NetCDF-4
|
|
353
|
+
# classic model), the formats with chunking and filters.
|
|
354
|
+
# @return [Boolean]
|
|
355
|
+
def netcdf4? = [NC_FORMAT_NETCDF4, NC_FORMAT_NETCDF4_CLASSIC].include?(format)
|
|
276
356
|
|
|
277
357
|
# Close and, for an atomic-write File, atomically rename the tmp to
|
|
278
358
|
# the final path. Idempotent: a second call is a no-op.
|
|
359
|
+
#
|
|
360
|
+
# The File counts as closed even when `nc_close` raises: libnetcdf
|
|
361
|
+
# has let go of the id by then, and closing it again crashes the
|
|
362
|
+
# process. An atomic write whose close or rename fails discards
|
|
363
|
+
# its tmp.
|
|
279
364
|
# @return [void]
|
|
280
365
|
def close
|
|
281
366
|
return if @closed
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
367
|
+
@closed = @state[0] = true
|
|
368
|
+
begin
|
|
369
|
+
nc_close(@ncid)
|
|
370
|
+
publish_tmp if @tmp_path
|
|
371
|
+
rescue Exception
|
|
372
|
+
discard_tmp
|
|
373
|
+
raise
|
|
287
374
|
end
|
|
375
|
+
@tmp_path = nil
|
|
288
376
|
end
|
|
289
377
|
|
|
290
|
-
# Discard
|
|
291
|
-
#
|
|
292
|
-
#
|
|
293
|
-
#
|
|
378
|
+
# Discard what has not been committed, and close. Used by the block
|
|
379
|
+
# form when the block raises; can be called explicitly to abandon a
|
|
380
|
+
# write in progress. Idempotent.
|
|
381
|
+
#
|
|
382
|
+
# It calls `nc_abort`: definitions made since the file last left
|
|
383
|
+
# define mode are rolled back, and a file created here that is still
|
|
384
|
+
# in define mode is removed. Data already written stays. An atomic
|
|
385
|
+
# write's tmp is unlinked.
|
|
294
386
|
# @return [void]
|
|
295
387
|
def abort
|
|
296
388
|
return if @closed
|
|
297
|
-
|
|
298
|
-
@
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
389
|
+
@closed = @state[0] = true
|
|
390
|
+
nc_abort(@ncid) rescue nil
|
|
391
|
+
discard_tmp
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Move an atomic write's tmp into place: over the target of a
|
|
395
|
+
# symlink rather than the link, with the replaced file's permissions.
|
|
396
|
+
# @api private
|
|
397
|
+
private def publish_tmp
|
|
398
|
+
target = self.class.send(:atomic_target, @filename)
|
|
399
|
+
if ::File.exist?(target)
|
|
400
|
+
::File.chmod(::File.stat(target).mode & 0o7777, @tmp_path)
|
|
302
401
|
end
|
|
402
|
+
::File.rename(@tmp_path, target)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Unlink an atomic write's tmp, if there is one.
|
|
406
|
+
# @api private
|
|
407
|
+
private def discard_tmp
|
|
408
|
+
::File.unlink(@tmp_path) if @tmp_path && ::File.exist?(@tmp_path)
|
|
409
|
+
@tmp_path = nil
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Raise if the file has been closed: libnetcdf reuses ids, so a
|
|
413
|
+
# closed File's ncid may already name another open file.
|
|
414
|
+
# @raise [IOError]
|
|
415
|
+
# @api private
|
|
416
|
+
def ensure_open
|
|
417
|
+
raise IOError, "closed NetCDF file #{@filename}" if @closed
|
|
303
418
|
end
|
|
304
419
|
|
|
305
420
|
# @return [Boolean]
|
|
306
421
|
def closed? = @closed == true
|
|
307
422
|
|
|
423
|
+
# The file's dimensions by id, for {Group#dim_by_id}.
|
|
424
|
+
# @return [Hash{Integer => Dim}]
|
|
425
|
+
# @api private
|
|
426
|
+
attr_reader :dim_index
|
|
427
|
+
|
|
308
428
|
# The `Conventions` global attribute split into tokens (empty if
|
|
309
429
|
# the attribute is missing). Provided so a higher-level convenience
|
|
310
430
|
# layer can dispatch on the same string this gem uses to pick a
|
|
@@ -312,8 +432,10 @@ module CA
|
|
|
312
432
|
# `"COARDS/CF-1.0"` decompose the way you'd expect.
|
|
313
433
|
# @return [Array<String>]
|
|
314
434
|
def conventions
|
|
315
|
-
|
|
316
|
-
|
|
435
|
+
# An NC_STRING array gives an Array; a numeric attribute is ignored.
|
|
436
|
+
Array(att("Conventions")).grep(String)
|
|
437
|
+
.flat_map { |s| s.split(/[\s,\/]+/) }
|
|
438
|
+
.reject(&:empty?)
|
|
317
439
|
end
|
|
318
440
|
|
|
319
441
|
# @!group Define / data mode management (also used by every Group / Var)
|
|
@@ -321,6 +443,7 @@ module CA
|
|
|
321
443
|
# Switch to define mode if not already in it.
|
|
322
444
|
# @return [void]
|
|
323
445
|
def ensure_define_mode
|
|
446
|
+
ensure_open
|
|
324
447
|
return if @defining
|
|
325
448
|
nc_redef(@ncid)
|
|
326
449
|
@defining = true
|
|
@@ -329,6 +452,7 @@ module CA
|
|
|
329
452
|
# Switch to data mode if not already in it.
|
|
330
453
|
# @return [void]
|
|
331
454
|
def ensure_data_mode
|
|
455
|
+
ensure_open
|
|
332
456
|
return unless @defining
|
|
333
457
|
nc_enddef(@ncid)
|
|
334
458
|
@defining = false
|
|
@@ -348,7 +472,10 @@ module CA
|
|
|
348
472
|
# @!endgroup
|
|
349
473
|
|
|
350
474
|
# @return [String]
|
|
351
|
-
def inspect
|
|
475
|
+
def inspect
|
|
476
|
+
return "#<#{self.class} #{@filename.inspect} (closed)>" if closed?
|
|
477
|
+
"#<#{self.class} #{@filename.inspect} vars=#{vars.keys} groups=#{groups.keys}>"
|
|
478
|
+
end
|
|
352
479
|
end
|
|
353
480
|
end
|
|
354
481
|
end
|