carray-netcdf 1.0.13 → 2.0.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 +79 -0
- data/CONTRIBUTING.md +75 -0
- data/LICENSE +21 -0
- data/README.md +108 -2
- data/bin/ncheader +223 -0
- data/bin/ncvar +160 -0
- data/carray-netcdf.gemspec +37 -19
- data/docs/00_introduction.md +72 -0
- data/docs/01_getting_started.md +114 -0
- data/docs/02_reading.md +216 -0
- data/docs/03_writing.md +418 -0
- data/docs/04_codec.md +373 -0
- data/docs/05_groups.md +200 -0
- data/docs/06_layer1.md +160 -0
- data/docs/07_cookbook.md +345 -0
- data/docs/08_cheatsheet.md +326 -0
- data/docs/09_cli.md +79 -0
- data/lib/carray/netcdf/codec.rb +536 -0
- data/lib/carray/netcdf/dim.rb +67 -0
- data/lib/carray/netcdf/file.rb +354 -0
- data/lib/carray/netcdf/group.rb +517 -0
- data/lib/carray/netcdf/var.rb +539 -0
- data/lib/carray/netcdf/version.rb +5 -0
- data/lib/carray/netcdf.rb +287 -0
- data/lib/carray-netcdf.rb +3 -1
- metadata +80 -25
- data/API.md +0 -270
- data/Rakefile +0 -12
- data/bin/ncdef2yaml +0 -60
- data/examples/test.nc +0 -0
- data/examples/test.rb +0 -41
- data/examples/time.rb +0 -11
- data/ext/extconf.rb +0 -11
- data/ext/rb_netcdflib.c +0 -2674
- data/lib/io/netcdf.rb +0 -848
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
module CA
|
|
2
|
+
module NetCDF
|
|
3
|
+
# A NetCDF file — unified read / write / create.
|
|
4
|
+
#
|
|
5
|
+
# @example
|
|
6
|
+
# File.open("data.nc") # read
|
|
7
|
+
# File.open("data.nc", :write) # modify existing
|
|
8
|
+
# File.create("out.nc") # new file
|
|
9
|
+
# File.create("out.nc", atomic: true) # tmp + rename on success
|
|
10
|
+
#
|
|
11
|
+
# The `File` IS the root {Group}; all Group methods (`dims`, `vars`,
|
|
12
|
+
# `def_dim`, `def_var`, `def_group`, `nc[name]`, `nc[name]=`, …) work
|
|
13
|
+
# directly on it and operate on the root group. Named subgroups are
|
|
14
|
+
# {Group} instances reachable via {#groups} / {Group#group} /
|
|
15
|
+
# {Group#def_group}.
|
|
16
|
+
#
|
|
17
|
+
# Block forms auto-close (and atomic writes commit at that point).
|
|
18
|
+
# Names are Strings throughout.
|
|
19
|
+
class File < Group
|
|
20
|
+
|
|
21
|
+
# Kwargs on {.create} that become file-wide defaults for every
|
|
22
|
+
# subsequent {Group#def_var} call. See docs/writing.md for details.
|
|
23
|
+
# @return [Array<Symbol>]
|
|
24
|
+
FILE_VAR_DEFAULTS = %i[chunks deflate shuffle fletcher32 endian no_fill].freeze
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Open an existing NetCDF file.
|
|
28
|
+
#
|
|
29
|
+
# @param path [String]
|
|
30
|
+
# @param mode [:read, :write, :append]
|
|
31
|
+
# @param codec [Symbol, Array, Object] see docs/codec.md
|
|
32
|
+
# @yieldparam file [File]
|
|
33
|
+
# @return [File] the file (or block's return value if a block is
|
|
34
|
+
# given; the file is closed after the block)
|
|
35
|
+
def open(path, mode = :read, codec: :auto, &block)
|
|
36
|
+
flag = case mode
|
|
37
|
+
when :read then ::NetCDF::NC_NOWRITE
|
|
38
|
+
when :write, :append then ::NetCDF::NC_WRITE | ::NetCDF::NC_SHARE
|
|
39
|
+
else raise ArgumentError, "unknown mode: #{mode.inspect}"
|
|
40
|
+
end
|
|
41
|
+
build(::NetCDF.nc_open(path, flag), path,
|
|
42
|
+
mode: mode, codec: codec, defining: false, &block)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Create a new NetCDF file.
|
|
46
|
+
#
|
|
47
|
+
# @param path [String] final file path
|
|
48
|
+
# @param codec [Symbol, Array, Object] see docs/codec.md
|
|
49
|
+
# @param clobber [true, false, :skip] overwrite policy when `path`
|
|
50
|
+
# already exists. `true` (default) overwrites; `false` raises
|
|
51
|
+
# `Errno::EEXIST`; `:skip` returns `nil` without calling the
|
|
52
|
+
# block — handy for resumable batches (`Dir[...]` picks up
|
|
53
|
+
# completed outputs unchanged).
|
|
54
|
+
# @param netcdf4 [Boolean] use the NetCDF-4 (HDF5) format. When a
|
|
55
|
+
# `template:` File / Group is provided, this defaults to
|
|
56
|
+
# whatever the template's format was, so a NetCDF-4 template
|
|
57
|
+
# produces a NetCDF-4 output unless explicitly overridden.
|
|
58
|
+
# @param atomic [Boolean] write to a same-dir tmp file, rename to
|
|
59
|
+
# `path` only on successful close; unlink the tmp on any
|
|
60
|
+
# exception from the block form. Rename is atomic on POSIX.
|
|
61
|
+
# Consumers polling the target path won't see anything until
|
|
62
|
+
# close — for a long-running unlimited-dim streaming write,
|
|
63
|
+
# prefer `atomic: false`.
|
|
64
|
+
# @param template [File, Group, nil] a reference file / group
|
|
65
|
+
# whose skeleton (dimensions and global attributes) is copied
|
|
66
|
+
# into the new file before the block runs. See `dims:`, `atts:`,
|
|
67
|
+
# `vars:`.
|
|
68
|
+
# @param dims [true, false, Array<String>] which template dims to
|
|
69
|
+
# copy. `true` (default) = all dims; `false` = none; Array =
|
|
70
|
+
# named subset. Ignored when `template:` is nil.
|
|
71
|
+
# @param atts [true, false, Hash] which template global attributes
|
|
72
|
+
# to copy. `true` (default) = all; `false` = none; Hash =
|
|
73
|
+
# template's atts merged with these overrides (last-writer
|
|
74
|
+
# wins).
|
|
75
|
+
# @param vars [Array<String>] template variable names to copy in
|
|
76
|
+
# full (definition AND data, bit-identical via raw + layer 1).
|
|
77
|
+
# Typical use: copy coordinate vars like `%w[time lat lon]` once
|
|
78
|
+
# per batch iteration.
|
|
79
|
+
# @param var_defaults [Hash] file-wide defaults picked up by every
|
|
80
|
+
# subsequent `def_var` (a per-var kwarg on `def_var` overrides
|
|
81
|
+
# the file default). Valid keys: {FILE_VAR_DEFAULTS}.
|
|
82
|
+
# @yieldparam file [File]
|
|
83
|
+
# @return [File, nil] the file (or block's return value); `nil`
|
|
84
|
+
# when `clobber: :skip` and `path` already exists.
|
|
85
|
+
def create(path, codec: :auto, clobber: true, netcdf4: false,
|
|
86
|
+
atomic: false, template: nil,
|
|
87
|
+
dims: true, atts: true, vars: [],
|
|
88
|
+
**var_defaults, &block)
|
|
89
|
+
# Extract file-wide var defaults; anything else is an error.
|
|
90
|
+
unknown = var_defaults.keys - FILE_VAR_DEFAULTS
|
|
91
|
+
unless unknown.empty?
|
|
92
|
+
raise ArgumentError, "unknown File.create kwargs: #{unknown.inspect}"
|
|
93
|
+
end
|
|
94
|
+
# A URL -- a Zarr store, "file:///data/t.zarr#mode=zarr,file" -- is
|
|
95
|
+
# not a path. File.exist? cannot see it, NCZarr does not honour
|
|
96
|
+
# NC_NOCLOBBER, and a tmp name appended to it lands in the "#mode="
|
|
97
|
+
# part, so libnetcdf creates over the target itself.
|
|
98
|
+
if url?(path) && (atomic || clobber != true)
|
|
99
|
+
raise ArgumentError, "atomic: and clobber: take a file path, not a URL"
|
|
100
|
+
end
|
|
101
|
+
# clobber: :skip — path already exists, quietly do nothing.
|
|
102
|
+
if clobber == :skip && ::File.exist?(path)
|
|
103
|
+
return nil
|
|
104
|
+
end
|
|
105
|
+
# Format inheritance from the template unless netcdf4: is set
|
|
106
|
+
# explicitly. A NetCDF-4 template silently upgrades the output
|
|
107
|
+
# so vars using groups / chunking / deflate stay defineable.
|
|
108
|
+
if template && !netcdf4
|
|
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
|
|
114
|
+
end
|
|
115
|
+
# Pre-flight clobber check for atomic: refuse before creating a tmp.
|
|
116
|
+
if atomic && clobber == false && ::File.exist?(path)
|
|
117
|
+
raise Errno::EEXIST, path
|
|
118
|
+
end
|
|
119
|
+
flag = clobber ? ::NetCDF::NC_CLOBBER : ::NetCDF::NC_NOCLOBBER
|
|
120
|
+
flag |= ::NetCDF::NC_NETCDF4 if netcdf4
|
|
121
|
+
write_path = atomic ? atomic_tmp_path_for(path) : path
|
|
122
|
+
after_new = template ?
|
|
123
|
+
->(f) { apply_template(f, template, dims: dims, atts: atts, vars: vars) } :
|
|
124
|
+
nil
|
|
125
|
+
build(::NetCDF.nc_create(write_path, flag), path,
|
|
126
|
+
mode: :create, codec: codec, defining: true,
|
|
127
|
+
tmp_path: (write_path if atomic),
|
|
128
|
+
var_defaults: var_defaults,
|
|
129
|
+
after_new: after_new, &block)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
# libnetcdf takes a "scheme://..." string as a URL, not a path.
|
|
135
|
+
def url?(path)
|
|
136
|
+
path.to_s.match?(%r{\A[A-Za-z][A-Za-z0-9+.-]*://})
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Same directory as the target so `File.rename` is same-device
|
|
140
|
+
# (POSIX cross-device rename fails with EXDEV). Visible (no
|
|
141
|
+
# dot-prefix) with `.tmp` as the trailing extension so
|
|
142
|
+
# `find … -name '*.tmp'` picks up orphans. PID + random so
|
|
143
|
+
# parallel writers don't collide.
|
|
144
|
+
def atomic_tmp_path_for(path)
|
|
145
|
+
dir = ::File.dirname(path)
|
|
146
|
+
base = ::File.basename(path)
|
|
147
|
+
::File.join(dir, "#{base}.#{Process.pid}.#{rand(2**32).to_s(16)}.tmp")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def build(ncid, filename, mode:, codec:, defining:,
|
|
151
|
+
tmp_path: nil, var_defaults: {}, after_new: nil, &block)
|
|
152
|
+
f = new(ncid, filename, mode, codec, defining, tmp_path, var_defaults)
|
|
153
|
+
# after_new runs BEFORE the user's block, but still inside the same
|
|
154
|
+
# try/close so a failure there also aborts an atomic write.
|
|
155
|
+
begin
|
|
156
|
+
after_new&.call(f)
|
|
157
|
+
rescue Exception
|
|
158
|
+
f.abort rescue nil
|
|
159
|
+
raise
|
|
160
|
+
end
|
|
161
|
+
return f unless block
|
|
162
|
+
begin
|
|
163
|
+
result = block.call(f)
|
|
164
|
+
f.close # normal close: atomic rename happens here
|
|
165
|
+
result
|
|
166
|
+
rescue Exception
|
|
167
|
+
f.abort rescue nil # abort: unlink tmp, no rename
|
|
168
|
+
raise
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Copy dims / atts / whole vars from a template File or Group into
|
|
173
|
+
# a freshly-created File. Called from create() via after_new so
|
|
174
|
+
# the copy happens BEFORE the user's block runs — the block sees
|
|
175
|
+
# a File already populated with the template's skeleton and can
|
|
176
|
+
# start adding new definitions immediately.
|
|
177
|
+
def apply_template(f, template, dims:, atts:, vars:)
|
|
178
|
+
case dims
|
|
179
|
+
when true
|
|
180
|
+
template.dims.each_value { |d| f.copy_dim(d) }
|
|
181
|
+
when false, nil
|
|
182
|
+
# skip
|
|
183
|
+
when Array
|
|
184
|
+
dims.each { |n| f.copy_dim(n.to_s, from: template) }
|
|
185
|
+
else
|
|
186
|
+
raise ArgumentError,
|
|
187
|
+
"File.create dims: expected true / false / Array, got #{dims.inspect}"
|
|
188
|
+
end
|
|
189
|
+
case atts
|
|
190
|
+
when true
|
|
191
|
+
template.atts.each { |k, v| f.def_att(k, v) }
|
|
192
|
+
when false, nil
|
|
193
|
+
# skip
|
|
194
|
+
when Hash
|
|
195
|
+
template.atts
|
|
196
|
+
.merge(atts.transform_keys(&:to_s))
|
|
197
|
+
.each { |k, v| f.def_att(k, v) }
|
|
198
|
+
else
|
|
199
|
+
raise ArgumentError,
|
|
200
|
+
"File.create atts: expected true / false / Hash, got #{atts.inspect}"
|
|
201
|
+
end
|
|
202
|
+
Array(vars).each { |n| f.copy_var(n.to_s, from: template) }
|
|
203
|
+
end
|
|
204
|
+
private :apply_template
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# @api private
|
|
208
|
+
# Use {.open} / {.create} instead.
|
|
209
|
+
def initialize(ncid, filename, mode, codec, defining,
|
|
210
|
+
tmp_path = nil, var_defaults = {})
|
|
211
|
+
@filename = filename
|
|
212
|
+
@mode = mode
|
|
213
|
+
@defining = defining
|
|
214
|
+
@codec_request = codec
|
|
215
|
+
@tmp_path = tmp_path
|
|
216
|
+
@var_defaults = var_defaults.freeze
|
|
217
|
+
# We are the root: pass self as `file`, nil as `parent`.
|
|
218
|
+
super(ncid, self, nil)
|
|
219
|
+
# In create mode the Conventions attribute is not defined yet, so
|
|
220
|
+
# resolve the codec lazily (on first read/write, once definitions are
|
|
221
|
+
# in place). In read mode it can be resolved eagerly.
|
|
222
|
+
@codec = defining ? nil : Codec.resolve(self, codec)
|
|
223
|
+
# Safety net: if the user forgets close on an atomic-write File,
|
|
224
|
+
# at least unlink the tmp (never publish half-written data via a
|
|
225
|
+
# finalizer — rename is close's job, not GC's).
|
|
226
|
+
if @tmp_path
|
|
227
|
+
ObjectSpace.define_finalizer(self,
|
|
228
|
+
self.class.send(:atomic_tmp_cleaner, @tmp_path))
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Registers the finalizer as a separate class method so the proc
|
|
233
|
+
# does NOT capture `self` (that would pin the File and defeat GC).
|
|
234
|
+
# @api private
|
|
235
|
+
def self.atomic_tmp_cleaner(tmp)
|
|
236
|
+
proc { ::File.unlink(tmp) if ::File.exist?(tmp) rescue nil }
|
|
237
|
+
end
|
|
238
|
+
private_class_method :atomic_tmp_cleaner
|
|
239
|
+
|
|
240
|
+
# The final (logical) file path. For an atomic-write File before
|
|
241
|
+
# close, the bytes are actually at a same-dir tmp path (see
|
|
242
|
+
# {#tmp_path}); once close succeeds, they are at {#filename}.
|
|
243
|
+
# (Not to be confused with {Group#path}, which is the NetCDF-4
|
|
244
|
+
# group hierarchy path — always `"/"` for a root group == a File.)
|
|
245
|
+
# @return [String]
|
|
246
|
+
attr_reader :filename
|
|
247
|
+
|
|
248
|
+
# The tmp path an atomic-write File is currently writing to; `nil`
|
|
249
|
+
# otherwise or after a successful close.
|
|
250
|
+
# @return [String, nil]
|
|
251
|
+
attr_reader :tmp_path
|
|
252
|
+
|
|
253
|
+
# File-wide NetCDF-4 var defaults applied to every `def_var` unless
|
|
254
|
+
# overridden per-var.
|
|
255
|
+
# @return [Hash]
|
|
256
|
+
attr_reader :var_defaults
|
|
257
|
+
|
|
258
|
+
# @return [Boolean]
|
|
259
|
+
def atomic? = !@tmp_path.nil?
|
|
260
|
+
|
|
261
|
+
# @return [Object] the codec (resolved lazily on first access)
|
|
262
|
+
def codec = (@codec ||= Codec.resolve(self, @codec_request))
|
|
263
|
+
|
|
264
|
+
# Define a global attribute. Under `codec: :auto`, (re)defining
|
|
265
|
+
# `Conventions` drops the codec resolved so far, so the next read or
|
|
266
|
+
# write picks it by the new value — even if something was already
|
|
267
|
+
# read or written before the attribute was set.
|
|
268
|
+
# @param (see Group#def_att)
|
|
269
|
+
# @return [Object] the value written
|
|
270
|
+
def def_att(name, value)
|
|
271
|
+
super.tap { @codec = nil if @codec_request == :auto && name.to_s == "Conventions" }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# @return [Integer] `NC_FORMAT_*` constant
|
|
275
|
+
def format = nc_inq_format(@ncid)
|
|
276
|
+
|
|
277
|
+
# Close and, for an atomic-write File, atomically rename the tmp to
|
|
278
|
+
# the final path. Idempotent: a second call is a no-op.
|
|
279
|
+
# @return [void]
|
|
280
|
+
def close
|
|
281
|
+
return if @closed
|
|
282
|
+
nc_close(@ncid)
|
|
283
|
+
@closed = true
|
|
284
|
+
if @tmp_path
|
|
285
|
+
::File.rename(@tmp_path, @filename)
|
|
286
|
+
@tmp_path = nil
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Discard a partially-written atomic file. Closes without renaming
|
|
291
|
+
# and unlinks the tmp. Used internally by the block-form `ensure`
|
|
292
|
+
# clause when the block raises; can be called explicitly to abandon
|
|
293
|
+
# a write in progress. Idempotent.
|
|
294
|
+
# @return [void]
|
|
295
|
+
def abort
|
|
296
|
+
return if @closed
|
|
297
|
+
nc_close(@ncid) rescue nil
|
|
298
|
+
@closed = true
|
|
299
|
+
if @tmp_path
|
|
300
|
+
::File.unlink(@tmp_path) if ::File.exist?(@tmp_path)
|
|
301
|
+
@tmp_path = nil
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# @return [Boolean]
|
|
306
|
+
def closed? = @closed == true
|
|
307
|
+
|
|
308
|
+
# The `Conventions` global attribute split into tokens (empty if
|
|
309
|
+
# the attribute is missing). Provided so a higher-level convenience
|
|
310
|
+
# layer can dispatch on the same string this gem uses to pick a
|
|
311
|
+
# codec, without re-parsing it. Values like `"CF-1.7 ACDD-1.3"` or
|
|
312
|
+
# `"COARDS/CF-1.0"` decompose the way you'd expect.
|
|
313
|
+
# @return [Array<String>]
|
|
314
|
+
def conventions
|
|
315
|
+
s = att("Conventions") or return []
|
|
316
|
+
s.split(/[\s,\/]+/).reject(&:empty?)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# @!group Define / data mode management (also used by every Group / Var)
|
|
320
|
+
|
|
321
|
+
# Switch to define mode if not already in it.
|
|
322
|
+
# @return [void]
|
|
323
|
+
def ensure_define_mode
|
|
324
|
+
return if @defining
|
|
325
|
+
nc_redef(@ncid)
|
|
326
|
+
@defining = true
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Switch to data mode if not already in it.
|
|
330
|
+
# @return [void]
|
|
331
|
+
def ensure_data_mode
|
|
332
|
+
return unless @defining
|
|
333
|
+
nc_enddef(@ncid)
|
|
334
|
+
@defining = false
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# @api private
|
|
338
|
+
# Run a block within a define-mode (or data-mode) transaction.
|
|
339
|
+
# @param define [Boolean] `true` to enter define mode first,
|
|
340
|
+
# `false` for data mode
|
|
341
|
+
# @yield the transactional body
|
|
342
|
+
# @return [Object] the block's return value
|
|
343
|
+
def __transaction__(define: true)
|
|
344
|
+
define ? ensure_define_mode : ensure_data_mode
|
|
345
|
+
yield
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# @!endgroup
|
|
349
|
+
|
|
350
|
+
# @return [String]
|
|
351
|
+
def inspect = "#<#{self.class} #{@filename.inspect} vars=#{vars.keys} groups=#{groups.keys}>"
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|