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.
@@ -0,0 +1,517 @@
1
+ module CA
2
+ module NetCDF
3
+ # A NetCDF-4 group. The root group is a {File} (a subclass of this).
4
+ #
5
+ # nc.groups # => {String => Group}
6
+ # nc.group("model") # => Group (subgroup by name in this group)
7
+ # nc.def_group("model") # => Group (create + return)
8
+ # nc["temp"] # => Var (in this group)
9
+ # nc["/model/temp"] # => Var (full path from root)
10
+ #
11
+ # Dims defined in an ancestor group are visible to a `def_var` / {Var}
12
+ # in a descendant (netcdf-4 dimension inheritance), so `def_var`
13
+ # resolves a dim name by walking up the group tree.
14
+ class Group
15
+
16
+ include CA::NetCDF
17
+
18
+ # @param ncid [Integer] group id
19
+ # @param file [File] root file (for codec + mode + close)
20
+ # @param parent [Group, nil] parent group (nil for root)
21
+ def initialize(ncid, file, parent = nil)
22
+ @ncid = ncid
23
+ @file = file
24
+ @parent = parent
25
+ @dims = {}
26
+ @vars = {}
27
+ @groups = {}
28
+ load_metadata
29
+ end
30
+
31
+ # @!attribute [r] ncid
32
+ # @return [Integer]
33
+ # @!attribute [r] file
34
+ # @return [File] root file
35
+ # @!attribute [r] parent
36
+ # @return [Group, nil]
37
+ attr_reader :ncid, :file, :parent
38
+
39
+ # @return [String] `""` for the root group, `"myGroup"` for a named
40
+ # group
41
+ def name = nc_inq_grpname(@ncid)
42
+
43
+ # @return [String] `"/"` for root, `"/model/ocean"` for a nested
44
+ # group
45
+ def path = nc_inq_grpname_full(@ncid)
46
+
47
+ # @return [Boolean]
48
+ def root? = @parent.nil?
49
+
50
+ # @!group Introspection (this group only)
51
+
52
+ # @return [Hash{String => Dim}]
53
+ def dims = @dims
54
+ # @return [Hash{String => Var}]
55
+ def vars = @vars
56
+ # @return [Hash{String => Group}]
57
+ def groups = @groups
58
+ # @param name [#to_s]
59
+ # @return [Dim, nil]
60
+ def dim(name) = @dims[name.to_s]
61
+ # @param name [#to_s]
62
+ # @return [Var, nil]
63
+ def var(name) = @vars[name.to_s]
64
+ # @param name [#to_s]
65
+ # @return [Group, nil]
66
+ def group(name) = @groups[name.to_s]
67
+ # @return [Boolean]
68
+ def dim?(name) = @dims.key?(name.to_s)
69
+ # @return [Boolean]
70
+ def var?(name) = @vars.key?(name.to_s)
71
+ # @return [Boolean]
72
+ def group?(name) = @groups.key?(name.to_s)
73
+
74
+ # Group-scoped global attributes (each group has its own set in
75
+ # netcdf-4). Cached; invalidated by our own {#def_att}. Other paths
76
+ # that mutate attributes (`rename_att` / `del_att` / a direct
77
+ # `nc_put_att`) aren't exposed by layer 2, so we don't have to watch
78
+ # for them.
79
+ # @return [Hash{String => Object}] frozen
80
+ def atts
81
+ @atts_cache ||= read_group_attributes
82
+ end
83
+
84
+ # @param name [#to_s]
85
+ # @return [Object, nil]
86
+ def att(name) = atts[name.to_s]
87
+
88
+ # @!endgroup
89
+
90
+ # @!group Access
91
+
92
+ # Look up a variable by name or path.
93
+ #
94
+ # nc["temp"] # Var in this group
95
+ # nc["/foo/bar/x"] # Var at that path from the root
96
+ # nc["foo/x"] # Var at that path relative to this group
97
+ #
98
+ # @param name_or_path [String, Symbol]
99
+ # @return [Var]
100
+ # @raise [KeyError] if the variable / any path segment doesn't exist
101
+ def [](name_or_path)
102
+ s = name_or_path.to_s
103
+ return resolve_path(s) if s.include?("/")
104
+ @vars[s] or raise KeyError, "no variable #{s.inspect} in group #{path}"
105
+ end
106
+
107
+ # Convenience combined variable-lookup and write.
108
+ #
109
+ # nc[name] = ca # whole-variable write
110
+ # nc[name, *index] = ca # hyperslab write
111
+ # nc["/path/name", ...] = ca # full-path write
112
+ #
113
+ # @return [Object] the value written
114
+ def []=(*argv)
115
+ value = argv.pop
116
+ name = argv.shift
117
+ self[name][*argv] = value
118
+ end
119
+
120
+ # @!endgroup
121
+
122
+ # @!group Definition (scoped to this group)
123
+
124
+ # Define a dimension.
125
+ # @param name [#to_s]
126
+ # @param length [Integer, :unlimited]
127
+ # @return [Dim]
128
+ def def_dim(name, length)
129
+ n = name.to_s
130
+ len = length == :unlimited ? NC_UNLIMITED : Integer(length)
131
+ @file.ensure_define_mode
132
+ @dims[n] = Dim.new(self, nc_def_dim(@ncid, n, len),
133
+ unlimited: length == :unlimited)
134
+ end
135
+
136
+ # NetCDF-4 per-variable storage / filter options that {#def_var}
137
+ # recognizes in its keyword arguments (any other kwarg becomes a
138
+ # variable attribute). See {#apply_var_options} for accepted values.
139
+ # @return [Array<Symbol>]
140
+ VAR_OPTION_KEYS = %i[
141
+ chunks deflate shuffle fletcher32 endian no_fill
142
+ ].freeze
143
+
144
+ # Define a variable in this group.
145
+ #
146
+ # @example Positional
147
+ # def_var("temp", :float, ["time","lat","lon"], units: "K")
148
+ # @example Copy definition
149
+ # def_var("temp", template: src["temp"])
150
+ # @example Look up name in a template group
151
+ # def_var("temp", template: src)
152
+ # @example Rename on copy
153
+ # def_var("temp2", template: src["temp"])
154
+ # @example Override type
155
+ # def_var("temp", :double, template: src["temp"])
156
+ # @example Override / add attributes
157
+ # def_var("temp", template: src, units: "F")
158
+ #
159
+ # When `template:` is a {Var}, its definition (type, dims, atts) is
160
+ # used; positional `type` / `dims` and kwargs are treated as
161
+ # overrides. When `template:` is a {File} / {Group}, its variable of
162
+ # the same name (first positional arg) is looked up and used as the
163
+ # template — the rename form must use the {Var} version.
164
+ #
165
+ # Kwargs are variable attributes, plus the reserved storage / filter
166
+ # options (see {VAR_OPTION_KEYS}). Overriding attributes merge into
167
+ # the template's; a kwarg keyed the same as a template attribute
168
+ # wins.
169
+ #
170
+ # @param name [#to_s] variable name
171
+ # @param type [Symbol, String, Integer, nil] on-disk type; required
172
+ # unless a `template:` is given
173
+ # @param dims [Array<String, Symbol, Dim>, nil] dimensions
174
+ # @param template [Var, Group, File, nil] source of a copied
175
+ # definition
176
+ # @param atts [Hash] variable attributes and/or storage options
177
+ # (see {VAR_OPTION_KEYS})
178
+ # @return [Var]
179
+ def def_var(name, type = nil, dims = nil, template: nil, **atts)
180
+ n = name.to_s
181
+
182
+ # Accept `dims:` as a keyword too — a natural spelling when template:
183
+ # is in use and the positional slot would otherwise force `nil` fillers.
184
+ if atts.key?(:dims)
185
+ raise ArgumentError,
186
+ "def_var: `dims` given both positionally and as a keyword" if dims
187
+ dims = atts.delete(:dims)
188
+ end
189
+ # Symmetric for type: `type:` as keyword. Same collision rule.
190
+ if atts.key?(:type)
191
+ raise ArgumentError,
192
+ "def_var: `type` given both positionally and as a keyword" if type
193
+ type = atts.delete(:type)
194
+ end
195
+
196
+ if template
197
+ src = case template
198
+ when Var then template
199
+ when Group then template.var(n) or
200
+ raise KeyError,
201
+ "def_var(#{n.inspect}, template: ...): no variable " \
202
+ "#{n.inspect} in template group #{template.path}"
203
+ else
204
+ raise ArgumentError,
205
+ "def_var template: expected Var / Group / File, got #{template.class}"
206
+ end
207
+ type ||= src.nc_type
208
+ dims ||= src.dims.map(&:name)
209
+ # template.atts (String keys, frozen) merged under any explicit
210
+ # atts (Symbol / String keys). Explicit wins on collision.
211
+ atts = src.atts.merge(atts.transform_keys(&:to_s))
212
+ end
213
+
214
+ raise ArgumentError,
215
+ "def_var: `type` is required (or pass a template:)" if type.nil?
216
+ dims ||= []
217
+
218
+ # Accept a name (String / Symbol) or a Dim object. Names are resolved
219
+ # by walking up the group tree for netcdf-4 dim inheritance.
220
+ dimids = Array(dims).map { |d| d.is_a?(Dim) ? d.to_i : resolve_dim(d.to_s).to_i }
221
+ # Split reserved storage/filter kwargs off before treating the rest
222
+ # as variable attributes. Per-var kwargs win over the file-wide
223
+ # defaults (nil / false / 0 are all "explicit"; they override).
224
+ file_defaults = @file.var_defaults
225
+ opts = {}
226
+ VAR_OPTION_KEYS.each do |k|
227
+ if atts.key?(k)
228
+ opts[k] = atts.delete(k)
229
+ elsif file_defaults.key?(k)
230
+ opts[k] = file_defaults[k]
231
+ end
232
+ end
233
+ @file.ensure_define_mode
234
+ nct = nc_type_for(type)
235
+ varid = nc_def_var(@ncid, n, nct, dimids)
236
+ apply_var_options(varid, opts) unless opts.empty?
237
+ # A CArray value keeps its own on-disk type (so a schema loader's
238
+ # `_FillValue: {short: -32768}` on a float variable stays short);
239
+ # a packed-space attribute takes the variable's type (CF §2.5.1).
240
+ atts.each { |k, v| put_var_att(@ncid, varid, k.to_s, v, nct) }
241
+ @vars[n] = Var.new(self, varid)
242
+ end
243
+
244
+ # Apply NetCDF-4 storage / filter options on a freshly defined
245
+ # variable. Kwargs on {#def_var}:
246
+ #
247
+ # chunks: [1, 73, 144] # NC_CHUNKED with explicit sizes
248
+ # chunks: :contiguous # NC_CONTIGUOUS
249
+ # chunks: :compact # NC_COMPACT (small vars only)
250
+ # deflate: 4 # compression level 1..9 (0 / nil = off)
251
+ # shuffle: true # shuffle filter, usually paired with deflate
252
+ # fletcher32: true # per-chunk fletcher32 checksum
253
+ # endian: :little / :big / :native
254
+ # no_fill: true # disable pre-fill (perf)
255
+ #
256
+ # These all require NetCDF-4; libnetcdf raises on a NetCDF-3 file
257
+ # and the raise propagates.
258
+ # @api private
259
+ private def apply_var_options(varid, opts)
260
+ if (c = opts[:chunks])
261
+ case c
262
+ when :contiguous then nc_def_var_chunking(@ncid, varid, NC_CONTIGUOUS, [])
263
+ when :compact then nc_def_var_chunking(@ncid, varid, NC_COMPACT, [])
264
+ when Array then nc_def_var_chunking(@ncid, varid, NC_CHUNKED, c)
265
+ else raise ArgumentError, "chunks: expected Array / :contiguous / :compact, got #{c.inspect}"
266
+ end
267
+ end
268
+ if opts.key?(:deflate) || opts.key?(:shuffle)
269
+ level = (opts[:deflate] || 0).to_i
270
+ on = level.positive? ? 1 : 0
271
+ shuffle = opts[:shuffle] ? 1 : 0
272
+ nc_def_var_deflate(@ncid, varid, shuffle, on, level)
273
+ end
274
+ if opts[:fletcher32]
275
+ nc_def_var_fletcher32(@ncid, varid, 1)
276
+ end
277
+ if (e = opts[:endian])
278
+ endian = case e
279
+ when :native then NC_ENDIAN_NATIVE
280
+ when :little then NC_ENDIAN_LITTLE
281
+ when :big then NC_ENDIAN_BIG
282
+ else raise ArgumentError, "endian: expected :native / :little / :big, got #{e.inspect}"
283
+ end
284
+ nc_def_var_endian(@ncid, varid, endian)
285
+ end
286
+ if opts.key?(:no_fill)
287
+ nc_def_var_fill(@ncid, varid, opts[:no_fill] ? 1 : 0, nil)
288
+ end
289
+ end
290
+
291
+ # Define a group-level (global) attribute. A CArray value keeps its
292
+ # own type, as it does on a variable.
293
+ # @param name [#to_s]
294
+ # @param value [Object] Ruby value or CArray
295
+ # @return [Object] the value written
296
+ def def_att(name, value)
297
+ @file.ensure_define_mode
298
+ if value.is_a?(CArray)
299
+ put_att_typed(@ncid, NC_GLOBAL, name.to_s, value.to_a, TYPE_TO_NC[value.data_type])
300
+ else
301
+ nc_put_att(@ncid, NC_GLOBAL, name.to_s, value)
302
+ end
303
+ @atts_cache = nil # invalidate — new attribute is now visible
304
+ value
305
+ end
306
+
307
+ # Copy a dimension into this group.
308
+ #
309
+ # @example
310
+ # copy_dim(src.dim("time")) # same name
311
+ # copy_dim(src.dim("time"), name: "t") # rename
312
+ # copy_dim("time", from: src) # look up by name in src
313
+ #
314
+ # An unlimited source becomes an unlimited destination dim.
315
+ # Coordinate values (the same-named variable, if any) are NOT
316
+ # copied — use {#copy_var} separately, or `File.create(template:,
317
+ # vars: [...])`.
318
+ #
319
+ # @param dim_or_name [Dim, String, Symbol]
320
+ # @param from [Group, File, nil] required when `dim_or_name` is a
321
+ # name
322
+ # @param name [String, Symbol, nil] destination name (defaults to
323
+ # the source's)
324
+ # @return [Dim]
325
+ def copy_dim(dim_or_name, from: nil, name: nil)
326
+ src = resolve_dim_source(dim_or_name, from)
327
+ dst_name = (name || src.name).to_s
328
+ len = src.unlimited? ? :unlimited : src.length
329
+ def_dim(dst_name, len)
330
+ end
331
+
332
+ # Copy a variable's definition AND its data into this group.
333
+ #
334
+ # @example
335
+ # copy_var(src["lat"]) # same name, values included
336
+ # copy_var(src["lat"], name: "latitude") # rename
337
+ # copy_var("lat", from: src) # look up by name in src
338
+ # copy_var("lat", from: src, units: "deg") # override / add attributes
339
+ #
340
+ # The data copy is bit-identical: raw on-disk values are read via
341
+ # {Var#raw} (no codec) and written via layer 1 (no codec). Users who
342
+ # need a codec-mediated copy should call `def_var(template:)` and
343
+ # write `dst[name] = src[name].to_ca` themselves.
344
+ #
345
+ # Extra kwargs are forwarded to {#def_var}(template:), so per-var
346
+ # storage options (`deflate:`, `chunks:`, …) and attribute
347
+ # overrides both work.
348
+ #
349
+ # @param var_or_name [Var, String, Symbol]
350
+ # @param from [Group, File, nil]
351
+ # @param name [String, Symbol, nil]
352
+ # @param overrides [Hash] attribute / storage overrides
353
+ # @return [Var]
354
+ def copy_var(var_or_name, from: nil, name: nil, **overrides)
355
+ src = resolve_var_source(var_or_name, from)
356
+ dst_name = (name || src.name).to_s
357
+ def_var(dst_name, template: src, **overrides)
358
+ dst = @vars[dst_name]
359
+ ensure_data_mode
360
+ # Bit-identical raw copy (skip both source's decode and dst's encode).
361
+ if src.scalar?
362
+ buf = CArray.new(src.data_type, [1])
363
+ buf[0] = src.raw
364
+ nc_put_var(@ncid, dst.varid, buf)
365
+ else
366
+ # nc_put_var writes exactly the variable's current shape. For an
367
+ # unlimited-dim var, the destination shape starts at length 0 in the
368
+ # newly-defined var, so nc_put_var would silently write nothing;
369
+ # nc_put_vara with explicit count grows the unlimited dim to fit.
370
+ ca = src.raw
371
+ nc_put_vara(@ncid, dst.varid, ca.shape.map { 0 }, ca.shape, ca)
372
+ end
373
+ dst
374
+ end
375
+
376
+ # Define a subgroup.
377
+ # @param name [#to_s]
378
+ # @return [Group]
379
+ def def_group(name)
380
+ n = name.to_s
381
+ @file.ensure_define_mode
382
+ child = nc_def_grp(@ncid, n)
383
+ @groups[n] = Group.new(child, @file, self)
384
+ end
385
+
386
+ # @!endgroup
387
+
388
+ # @!group Forwarding to the root {File} (codec + mode management)
389
+
390
+ # @return [Object] the codec used for this file
391
+ def codec = @file.codec
392
+ # @return [void]
393
+ def ensure_define_mode = @file.ensure_define_mode
394
+ # @return [void]
395
+ def ensure_data_mode = @file.ensure_data_mode
396
+ # @api private
397
+ def __transaction__(**opts, &block) = @file.__transaction__(**opts, &block)
398
+
399
+ # @!endgroup
400
+
401
+ # @return [String]
402
+ def inspect = "#<#{self.class} #{path.inspect} vars=#{@vars.keys} groups=#{@groups.keys}>"
403
+
404
+ # Resolve a dimension name by walking up the group tree — netcdf-4
405
+ # dimension inheritance.
406
+ # @param name [#to_s]
407
+ # @return [Dim]
408
+ # @raise [KeyError] if not found in the ancestor chain
409
+ def resolve_dim(name)
410
+ g = self
411
+ while g
412
+ d = g.dim(name)
413
+ return d if d
414
+ g = g.parent
415
+ end
416
+ raise KeyError, "no dimension #{name.inspect} in scope of #{path}"
417
+ end
418
+
419
+ # The {Dim} with this id, in this group or an ancestor (netcdf-4
420
+ # dimids are unique across a file, so the id is unambiguous where a
421
+ # name may be shadowed).
422
+ # @param dimid [Integer]
423
+ # @return [Dim, nil]
424
+ # @api private
425
+ def dim_by_id(dimid)
426
+ g = self
427
+ while g
428
+ d = g.dims.each_value.find { |x| x.to_i == dimid }
429
+ return d if d
430
+ g = g.parent
431
+ end
432
+ nil
433
+ end
434
+
435
+ # ---- template / copy source resolution -----------------------------
436
+ # Common: accept the object directly (Dim / Var) or a name + `from:`
437
+ # group; raise cleanly on the invalid combinations.
438
+
439
+ # @api private
440
+ private def resolve_dim_source(dim_or_name, from)
441
+ if dim_or_name.is_a?(Dim)
442
+ raise ArgumentError,
443
+ "copy_dim: pass either a Dim or (name, from:), not both" if from
444
+ dim_or_name
445
+ else
446
+ raise ArgumentError,
447
+ "copy_dim(name, from:): `from:` is required" unless from
448
+ from.dim(dim_or_name.to_s) or
449
+ raise KeyError,
450
+ "copy_dim: no dimension #{dim_or_name.inspect} in #{from.path}"
451
+ end
452
+ end
453
+
454
+ # @api private
455
+ private def resolve_var_source(var_or_name, from)
456
+ if var_or_name.is_a?(Var)
457
+ raise ArgumentError,
458
+ "copy_var: pass either a Var or (name, from:), not both" if from
459
+ var_or_name
460
+ else
461
+ raise ArgumentError,
462
+ "copy_var(name, from:): `from:` is required" unless from
463
+ from.var(var_or_name.to_s) or
464
+ raise KeyError,
465
+ "copy_var: no variable #{var_or_name.inspect} in #{from.path}"
466
+ end
467
+ end
468
+
469
+ # Resolve a "/absolute" or "relative/nested" path down to a Var.
470
+ # @api private
471
+ private def resolve_path(str)
472
+ parts = str.split("/").reject(&:empty?)
473
+ raise KeyError, "empty path" if parts.empty?
474
+ leaf = parts.pop
475
+ start = str.start_with?("/") ? @file : self
476
+ g = start
477
+ parts.each do |part|
478
+ nxt = g.group(part) or
479
+ raise KeyError, "no group #{part.inspect} in #{g.path}"
480
+ g = nxt
481
+ end
482
+ g[leaf]
483
+ end
484
+
485
+ # @api private
486
+ private def load_metadata
487
+ # Dimensions defined IN THIS GROUP (not inherited). nc_inq_dimids
488
+ # with include_parents=0 gives exactly this group's dims; falling
489
+ # back to nc_inq_ndims/nc_inq_dimname for the root of a netcdf-3
490
+ # file (no groups, no dimids call needed but works anyway).
491
+ # nc_inq_dimids's include_parents arg is Ruby-truthy in the netcdf gem
492
+ # (RTEST), so pass `false` — plain `0` is truthy and would include
493
+ # parent-group dims. We deliberately want THIS group's dims only.
494
+ # libnetcdf's nc_inq_dimids/varids/grps work on netcdf-3 too, so no
495
+ # fallback path is needed; a real error should propagate rather than
496
+ # be silently swallowed (any rescue here used to also catch
497
+ # permission errors and pretend "netcdf-3 layout" — misleading).
498
+ nc_inq_dimids(@ncid, false).each { |d| dm = Dim.new(self, d); @dims[dm.name] = dm }
499
+ nc_inq_varids(@ncid).each { |v| vr = Var.new(self, v); @vars[vr.name] = vr }
500
+ nc_inq_grps(@ncid).each do |gid|
501
+ g = Group.new(gid, @file, self)
502
+ @groups[g.name] = g
503
+ end
504
+ end
505
+
506
+ # @api private
507
+ private def read_group_attributes
508
+ atts = {}
509
+ nc_inq_natts(@ncid).times do |i|
510
+ name = nc_inq_attname(@ncid, NC_GLOBAL, i)
511
+ atts[name] = nc_get_att(@ncid, NC_GLOBAL, name)
512
+ end
513
+ atts.freeze
514
+ end
515
+ end
516
+ end
517
+ end