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.
@@ -14,6 +14,9 @@ module CA
14
14
  class Group
15
15
 
16
16
  include CA::NetCDF
17
+ # The low-level calls are this class's helpers, not its interface.
18
+ private :nc_get_var, :nc_get_vara, :nc_get_vars, :nc_get_varm,
19
+ :nc_put_var, :nc_put_vara, :nc_put_vars, :nc_put_varm
17
20
 
18
21
  # @param ncid [Integer] group id
19
22
  # @param file [File] root file (for codec + mode + close)
@@ -28,6 +31,22 @@ module CA
28
31
  load_metadata
29
32
  end
30
33
 
34
+ # Read this group's dimensions, variables, subgroups and attributes
35
+ # again from the file. They are read once, when the file is opened,
36
+ # and kept up to date by this API's own `def_*` calls; a change made
37
+ # through the low-level API (`nc_put_att`, `nc_rename_var`,
38
+ # `nc_def_var` on `ncid`) is seen only after a reload. Var, Dim and
39
+ # Group objects taken before it describe the old state; take new
40
+ # ones after it.
41
+ # @return [self]
42
+ def reload
43
+ @file.ensure_open
44
+ @dims, @vars, @groups = {}, {}, {}
45
+ @atts_cache = nil
46
+ load_metadata
47
+ self
48
+ end
49
+
31
50
  # @!attribute [r] ncid
32
51
  # @return [Integer]
33
52
  # @!attribute [r] file
@@ -36,13 +55,13 @@ module CA
36
55
  # @return [Group, nil]
37
56
  attr_reader :ncid, :file, :parent
38
57
 
39
- # @return [String] `""` for the root group, `"myGroup"` for a named
58
+ # @return [String] `"/"` for the root group, `"myGroup"` for a named
40
59
  # group
41
- def name = nc_inq_grpname(@ncid)
60
+ def name = (@file.ensure_open; nc_inq_grpname(@ncid))
42
61
 
43
62
  # @return [String] `"/"` for root, `"/model/ocean"` for a nested
44
63
  # group
45
- def path = nc_inq_grpname_full(@ncid)
64
+ def path = (@file.ensure_open; nc_inq_grpname_full(@ncid))
46
65
 
47
66
  # @return [Boolean]
48
67
  def root? = @parent.nil?
@@ -50,11 +69,12 @@ module CA
50
69
  # @!group Introspection (this group only)
51
70
 
52
71
  # @return [Hash{String => Dim}]
53
- def dims = @dims
72
+ # (a frozen copy: the group's own table is changed only by def_*)
73
+ def dims = @dims.dup.freeze
54
74
  # @return [Hash{String => Var}]
55
- def vars = @vars
75
+ def vars = @vars.dup.freeze
56
76
  # @return [Hash{String => Group}]
57
- def groups = @groups
77
+ def groups = @groups.dup.freeze
58
78
  # @param name [#to_s]
59
79
  # @return [Dim, nil]
60
80
  def dim(name) = @dims[name.to_s]
@@ -74,10 +94,11 @@ module CA
74
94
  # Group-scoped global attributes (each group has its own set in
75
95
  # netcdf-4). Cached; invalidated by our own {#def_att}. Other paths
76
96
  # 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
97
+ # `nc_put_att`) aren't exposed by the high-level API, so we don't have to watch
78
98
  # for them.
79
99
  # @return [Hash{String => Object}] frozen
80
100
  def atts
101
+ @file.ensure_open
81
102
  @atts_cache ||= read_group_attributes
82
103
  end
83
104
 
@@ -129,8 +150,8 @@ module CA
129
150
  n = name.to_s
130
151
  len = length == :unlimited ? NC_UNLIMITED : Integer(length)
131
152
  @file.ensure_define_mode
132
- @dims[n] = Dim.new(self, nc_def_dim(@ncid, n, len),
133
- unlimited: length == :unlimited)
153
+ register_dim(Dim.new(self, nc_def_dim(@ncid, n, len),
154
+ unlimited: length == :unlimited))
134
155
  end
135
156
 
136
157
  # NetCDF-4 per-variable storage / filter options that {#def_var}
@@ -138,9 +159,16 @@ module CA
138
159
  # variable attribute). See {#apply_var_options} for accepted values.
139
160
  # @return [Array<Symbol>]
140
161
  VAR_OPTION_KEYS = %i[
141
- chunks deflate shuffle fletcher32 endian no_fill
162
+ chunks deflate shuffle zstandard bzip2 szip blosc fletcher32 endian no_fill
142
163
  ].freeze
143
164
 
165
+ # The compressors among {VAR_OPTION_KEYS}, and with shuffle, the
166
+ # options {#def_var} takes from one source as a whole.
167
+ # @api private
168
+ COMPRESSION_KEYS = %i[deflate zstandard bzip2 szip blosc].freeze
169
+ # @api private
170
+ COMPRESSION_GROUP = (COMPRESSION_KEYS + %i[shuffle]).freeze
171
+
144
172
  # Define a variable in this group.
145
173
  #
146
174
  # @example Positional
@@ -165,7 +193,10 @@ module CA
165
193
  # Kwargs are variable attributes, plus the reserved storage / filter
166
194
  # options (see {VAR_OPTION_KEYS}). Overriding attributes merge into
167
195
  # the template's; a kwarg keyed the same as a template attribute
168
- # wins.
196
+ # wins. Between two NetCDF-4 files the template's storage settings
197
+ # ({Var#storage_options}) are copied too, under any given here and
198
+ # over the file-wide defaults; its chunk sizes are copied only when
199
+ # the new variable's dimensions have the template's lengths.
169
200
  #
170
201
  # @param name [#to_s] variable name
171
202
  # @param type [Symbol, String, Integer, nil] on-disk type; required
@@ -179,6 +210,13 @@ module CA
179
210
  def def_var(name, type = nil, dims = nil, template: nil, **atts)
180
211
  n = name.to_s
181
212
 
213
+ # Split the reserved storage / filter kwargs off first, before a
214
+ # template's attributes are merged in under String keys.
215
+ given_opts = {}
216
+ VAR_OPTION_KEYS.each do |k|
217
+ given_opts[k] = atts.delete(k) if atts.key?(k)
218
+ end
219
+
182
220
  # Accept `dims:` as a keyword too — a natural spelling when template:
183
221
  # is in use and the positional slot would otherwise force `nil` fillers.
184
222
  if atts.key?(:dims)
@@ -204,11 +242,14 @@ module CA
204
242
  raise ArgumentError,
205
243
  "def_var template: expected Var / Group / File, got #{template.class}"
206
244
  end
245
+ template_storage = (src.file.netcdf4? && @file.netcdf4?) ? src.storage_options : {}
207
246
  type ||= src.nc_type
208
247
  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))
248
+ # The template's attributes are copied as stored, keeping their
249
+ # types (below); the explicit ones (Symbol / String keys) replace
250
+ # any of the same name and are written by value.
251
+ atts = atts.transform_keys(&:to_s)
252
+ copied_atts = src.atts.keys - atts.keys
212
253
  end
213
254
 
214
255
  raise ArgumentError,
@@ -217,28 +258,119 @@ module CA
217
258
 
218
259
  # Accept a name (String / Symbol) or a Dim object. Names are resolved
219
260
  # 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).
261
+ # A Dim is taken as it is only when it belongs to this file and is in
262
+ # scope here; one from another file or a sibling group is looked up
263
+ # by its name, since its id means something else here.
264
+ dim_objects = Array(dims).map do |d|
265
+ if d.is_a?(Dim) && d.group.file.equal?(@file) && dim_by_id(d.to_i)
266
+ d
267
+ else
268
+ resolve_dim(d.is_a?(Dim) ? d.name : d.to_s)
269
+ end
270
+ end
271
+ dimids = dim_objects.map(&:to_i)
272
+ # The template's chunk sizes fit only its own shape: keep them when
273
+ # every dimension has the template's length (or is unlimited in
274
+ # both), and leave chunking to libnetcdf otherwise.
275
+ if template_storage&.key?(:chunks) && ! same_extent?(src.dims, dim_objects)
276
+ template_storage.delete(:chunks)
277
+ end
278
+ # Per-var kwargs win over the template's storage, which wins over
279
+ # the file-wide defaults (nil / false / 0 are all "explicit"; they
280
+ # override). Compression is taken whole from the first of these
281
+ # that names any compressor, so `zstandard: 3` over a deflated
282
+ # template replaces deflate rather than stacking on it.
283
+ template_storage ||= {}
224
284
  file_defaults = @file.var_defaults
285
+ chosen = [given_opts, template_storage, file_defaults].find do |o|
286
+ COMPRESSION_KEYS.any? { |k| o.key?(k) }
287
+ end
288
+ unless chosen.nil? || chosen.equal?(template_storage)
289
+ template_storage = template_storage.reject { |k, _| COMPRESSION_GROUP.include?(k) }
290
+ end
291
+ unless chosen.nil? || chosen.equal?(file_defaults)
292
+ file_defaults = file_defaults.reject { |k, _| COMPRESSION_GROUP.include?(k) }
293
+ end
225
294
  opts = {}
226
295
  VAR_OPTION_KEYS.each do |k|
227
- if atts.key?(k)
228
- opts[k] = atts.delete(k)
296
+ if given_opts.key?(k)
297
+ opts[k] = given_opts[k]
298
+ elsif template_storage.key?(k)
299
+ opts[k] = template_storage[k]
229
300
  elsif file_defaults.key?(k)
230
301
  opts[k] = file_defaults[k]
231
302
  end
232
303
  end
233
304
  @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)
305
+ nct = nc_type_for(type)
306
+ # NetCDF has no way to remove a variable, so what can be checked
307
+ # here is checked before nc_def_var defines it.
308
+ check_var_options(opts)
309
+ atts.each { |k, v| attribute_values(k.to_s, v) if v.is_a?(CArray) }
310
+ # A NaN fill (xarray writes one on every float variable) has no
311
+ # value in an integer type the template's type is overridden with.
312
+ if copied_atts && nct != src.nc_type && Var::INTEGER_TYPES.include?(NC_TO_CA[nct])
313
+ (copied_atts & PACKED_SPACE_ATTS).each do |k|
314
+ next unless Array(src.atts[k]).any? { |x| x.is_a?(Float) && !x.finite? }
315
+ raise ArgumentError,
316
+ "the template's #{k} is #{src.atts[k].inspect}, which an integer type " \
317
+ "cannot hold; give #{k}: for the new type"
318
+ end
319
+ end
320
+ varid = nc_def_var(@ncid, n, nct, dimids)
321
+ begin
322
+ apply_var_options(varid, opts) unless opts.empty?
323
+ # A packed-space attribute takes the variable's type (CF §2.5.1),
324
+ # a CArray value included; another CArray keeps its own type.
325
+ unsigned = unsigned_flag?(atts["_Unsigned"] || atts[:_Unsigned] || (copied_atts && src.atts["_Unsigned"]))
326
+ atts.each { |k, v| put_var_att(@ncid, varid, k.to_s, v, nct, unsigned: unsigned) }
327
+ copied_atts&.each do |k|
328
+ # A packed-space attribute follows the variable when its type
329
+ # was overridden; anything else keeps the template's type.
330
+ if PACKED_SPACE_ATTS.include?(k) && nct != src.nc_type
331
+ put_var_att(@ncid, varid, k, src.atts[k], nct, unsigned: unsigned)
332
+ else
333
+ copy_att_from(src.group, src.varid, k, varid)
334
+ end
335
+ end
336
+ ensure
337
+ # Registered even when a later step failed, so that the object
338
+ # model matches the file (the block form's abort rolls it back).
339
+ @vars[n] = Var.new(self, varid)
340
+ end
341
+ @vars[n]
342
+ end
343
+
344
+ # Raise on storage options that would fail only after the variable
345
+ # is defined: a value of the wrong form, or a NetCDF-4 option on a
346
+ # classic file.
347
+ # @api private
348
+ private def check_var_options(opts)
349
+ c = opts[:chunks]
350
+ unless c.nil? || c.is_a?(Array) || %i[contiguous compact].include?(c)
351
+ raise ArgumentError, "chunks: expected Array / :contiguous / :compact, got #{c.inspect}"
352
+ end
353
+ e = opts[:endian]
354
+ unless e.nil? || %i[native little big].include?(e)
355
+ raise ArgumentError, "endian: expected :native / :little / :big, got #{e.inspect}"
356
+ end
357
+ szip_arguments(opts[:szip]) if opts[:szip]
358
+ blosc_arguments(opts[:blosc]) if opts[:blosc]
359
+ return if @file.netcdf4?
360
+ netcdf4_only = []
361
+ netcdf4_only << :chunks if c
362
+ netcdf4_only << :deflate if opts[:deflate].to_i.positive?
363
+ netcdf4_only << :shuffle if opts[:shuffle]
364
+ netcdf4_only << :zstandard if opts[:zstandard] && opts[:zstandard].to_i != 0
365
+ netcdf4_only << :bzip2 if opts[:bzip2].to_i.positive?
366
+ netcdf4_only << :szip if opts[:szip]
367
+ netcdf4_only << :blosc if opts[:blosc]
368
+ netcdf4_only << :fletcher32 if opts[:fletcher32]
369
+ netcdf4_only << :endian if e
370
+ return if netcdf4_only.empty?
371
+ raise ArgumentError,
372
+ "#{netcdf4_only.map { |k| "#{k}:" }.join(', ')} need a NetCDF-4 file " \
373
+ "(create it with netcdf4: true)"
242
374
  end
243
375
 
244
376
  # Apply NetCDF-4 storage / filter options on a freshly defined
@@ -248,12 +380,21 @@ module CA
248
380
  # chunks: :contiguous # NC_CONTIGUOUS
249
381
  # chunks: :compact # NC_COMPACT (small vars only)
250
382
  # deflate: 4 # compression level 1..9 (0 / nil = off)
251
- # shuffle: true # shuffle filter, usually paired with deflate
383
+ # shuffle: true # shuffle filter, usually paired with a compressor
384
+ # zstandard: 3 # zstandard level (0 / nil = off; libnetcdf >= 4.9)
385
+ # bzip2: 9 # bzip2 level 1..9 (0 / nil = off; libnetcdf >= 4.9)
386
+ # szip: { coding: :nn, pixels_per_block: 32 } # coding :nn / :ec
387
+ # blosc: { compressor: :lz4, level: 5, blocksize: 0, shuffle: :byte }
388
+ # # libnetcdf >= 4.9; compressor :lz / :lz4 /
389
+ # # :lz4hc / :snappy / :zlib / :zstd,
390
+ # # shuffle :none / :byte / :bit
252
391
  # fletcher32: true # per-chunk fletcher32 checksum
253
392
  # endian: :little / :big / :native
254
393
  # no_fill: true # disable pre-fill (perf)
255
394
  #
256
- # These all require NetCDF-4; libnetcdf raises on a NetCDF-3 file
395
+ # These all require NetCDF-4, except `no_fill:` and an off
396
+ # `deflate:` (0 / nil, without `shuffle:`), which is a new variable's
397
+ # default and so is not sent; libnetcdf raises on a NetCDF-3 file
257
398
  # and the raise propagates.
258
399
  # @api private
259
400
  private def apply_var_options(varid, opts)
@@ -266,13 +407,33 @@ module CA
266
407
  end
267
408
  end
268
409
  if opts.key?(:deflate) || opts.key?(:shuffle)
410
+ # The netcdf gem reads these flags as Ruby truth values, so they
411
+ # go as true / false (a 0 would read as true).
269
412
  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)
413
+ on = level.positive?
414
+ shuffle = opts[:shuffle] ? true : false
415
+ if on || shuffle
416
+ nc_def_var_deflate(@ncid, varid, shuffle, on, level)
417
+ end
418
+ end
419
+ # The other compressors, after shuffle so that it runs first. A
420
+ # filter libnetcdf lacks raises NC_ENOFILTER from the netcdf gem.
421
+ # zstandard also takes negative ("fast") levels; only 0 / nil / false
422
+ # is off, and libnetcdf checks the range.
423
+ if (level = opts[:zstandard]) && level.to_i != 0
424
+ nc_def_var_zstandard(@ncid, varid, level.to_i)
425
+ end
426
+ if opts[:bzip2].to_i.positive?
427
+ nc_def_var_bzip2(@ncid, varid, opts[:bzip2].to_i)
428
+ end
429
+ if (s = opts[:szip])
430
+ nc_def_var_szip(@ncid, varid, *szip_arguments(s))
431
+ end
432
+ if (b = opts[:blosc])
433
+ nc_def_var_blosc(@ncid, varid, *blosc_arguments(b))
273
434
  end
274
435
  if opts[:fletcher32]
275
- nc_def_var_fletcher32(@ncid, varid, 1)
436
+ nc_def_var_fletcher32(@ncid, varid, true)
276
437
  end
277
438
  if (e = opts[:endian])
278
439
  endian = case e
@@ -284,8 +445,81 @@ module CA
284
445
  nc_def_var_endian(@ncid, varid, endian)
285
446
  end
286
447
  if opts.key?(:no_fill)
287
- nc_def_var_fill(@ncid, varid, opts[:no_fill] ? 1 : 0, nil)
448
+ nc_def_var_fill(@ncid, varid, opts[:no_fill] ? true : false, nil)
449
+ end
450
+ end
451
+
452
+ SZIP_CODINGS = { nn: :NC_SZIP_NN, ec: :NC_SZIP_EC }.freeze
453
+ BLOSC_COMPRESSORS = {
454
+ lz: :BLOSC_LZ, lz4: :BLOSC_LZ4, lz4hc: :BLOSC_LZ4HC,
455
+ snappy: :BLOSC_SNAPPY, zlib: :BLOSC_ZLIB, zstd: :BLOSC_ZSTD,
456
+ }.freeze
457
+ BLOSC_SHUFFLES = { none: :BLOSC_NOSHUFFLE, byte: :BLOSC_SHUFFLE, bit: :BLOSC_BITSHUFFLE }.freeze
458
+ private_constant :SZIP_CODINGS, :BLOSC_COMPRESSORS, :BLOSC_SHUFFLES
459
+
460
+ # Whether two lists of Dims have the same extent: the same number,
461
+ # and each pair unlimited in both or of the same length.
462
+ # @api private
463
+ private def same_extent?(dims, others)
464
+ return false unless dims.size == others.size
465
+ dims.zip(others).all? do |a, b|
466
+ if a.unlimited? || b.unlimited?
467
+ a.unlimited? == b.unlimited?
468
+ else
469
+ a.length == b.length
470
+ end
471
+ end
472
+ end
473
+
474
+ # `szip: { coding:, pixels_per_block: }` as the arguments of
475
+ # `nc_def_var_szip`.
476
+ # @api private
477
+ private def szip_arguments(opt)
478
+ unless opt.is_a?(Hash)
479
+ raise ArgumentError, "szip: expected a Hash, got #{opt.inspect}"
480
+ end
481
+ coding = SZIP_CODINGS[opt.fetch(:coding, :nn)] or
482
+ raise ArgumentError, "szip: coding: expected :nn / :ec, got #{opt[:coding].inspect}"
483
+ [::NetCDF.const_get(coding), Integer(opt.fetch(:pixels_per_block, 32))]
484
+ end
485
+
486
+ # `blosc: { compressor:, level:, blocksize:, shuffle: }` as the
487
+ # arguments of `nc_def_var_blosc`.
488
+ # @api private
489
+ private def blosc_arguments(opt)
490
+ unless opt.is_a?(Hash)
491
+ raise ArgumentError, "blosc: expected a Hash, got #{opt.inspect}"
492
+ end
493
+ compressor = BLOSC_COMPRESSORS[opt.fetch(:compressor, :lz4)] or
494
+ raise ArgumentError, "blosc: compressor: expected one of #{BLOSC_COMPRESSORS.keys.inspect}, got #{opt[:compressor].inspect}"
495
+ shuffle = BLOSC_SHUFFLES[opt.fetch(:shuffle, :byte)] or
496
+ raise ArgumentError, "blosc: shuffle: expected :none / :byte / :bit, got #{opt[:shuffle].inspect}"
497
+ [::NetCDF.const_get(compressor), Integer(opt.fetch(:level, 5)),
498
+ Integer(opt.fetch(:blocksize, 0)), ::NetCDF.const_get(shuffle)]
499
+ end
500
+
501
+ # Copy attribute `name` of `src_varid` (or `NC_GLOBAL`) in `src_group`
502
+ # onto `varid` here, keeping its type. When libnetcdf cannot (an
503
+ # `NC_STRING` attribute into a classic file), it is written by value.
504
+ # @api private
505
+ private def copy_att_from(src_group, src_varid, name, varid = NC_GLOBAL)
506
+ @file.ensure_define_mode
507
+ begin
508
+ nc_copy_att(src_group.ncid, src_varid, name, @ncid, varid)
509
+ rescue ::NetCDF::Error
510
+ value = nc_get_att(src_group.ncid, src_varid, name)
511
+ if value.is_a?(Array) && value.all?(String)
512
+ raise ArgumentError,
513
+ "attribute #{name.inspect} holds several strings (NC_STRING), " \
514
+ "which a classic-format file cannot store; give it a value of your own"
515
+ end
516
+ if varid == NC_GLOBAL
517
+ nc_put_att(@ncid, NC_GLOBAL, name, value)
518
+ else
519
+ put_var_att(@ncid, varid, name, value, nc_inq_vartype(@ncid, varid))
520
+ end
288
521
  end
522
+ @atts_cache = nil if varid == NC_GLOBAL
289
523
  end
290
524
 
291
525
  # Define a group-level (global) attribute. A CArray value keeps its
@@ -296,7 +530,7 @@ module CA
296
530
  def def_att(name, value)
297
531
  @file.ensure_define_mode
298
532
  if value.is_a?(CArray)
299
- put_att_typed(@ncid, NC_GLOBAL, name.to_s, value.to_a, TYPE_TO_NC[value.data_type])
533
+ put_att_typed(@ncid, NC_GLOBAL, name.to_s, attribute_values(name.to_s, value), TYPE_TO_NC[value.data_type])
300
534
  else
301
535
  nc_put_att(@ncid, NC_GLOBAL, name.to_s, value)
302
536
  end
@@ -338,7 +572,7 @@ module CA
338
572
  # copy_var("lat", from: src, units: "deg") # override / add attributes
339
573
  #
340
574
  # 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
575
+ # {Var#raw} (no codec) and written via the low-level API (no codec). Users who
342
576
  # need a codec-mediated copy should call `def_var(template:)` and
343
577
  # write `dst[name] = src[name].to_ca` themselves.
344
578
  #
@@ -358,7 +592,17 @@ module CA
358
592
  dst = @vars[dst_name]
359
593
  ensure_data_mode
360
594
  # Bit-identical raw copy (skip both source's decode and dst's encode).
361
- if src.scalar?
595
+ if src.nc_type == NC_STRING
596
+ # Strings have no CArray type; the netcdf gem moves them as Ruby
597
+ # Strings, nested by shape.
598
+ strings = ::NetCDF.nc_get_var(src.group.ncid, src.varid)
599
+ shape = src.shape
600
+ if shape.empty?
601
+ ::NetCDF.nc_put_var(@ncid, dst.varid, strings)
602
+ elsif shape.inject(1, :*) > 0
603
+ ::NetCDF.nc_put_vara(@ncid, dst.varid, shape.map { 0 }, shape, Array(strings).flatten)
604
+ end
605
+ elsif src.scalar?
362
606
  buf = CArray.new(src.data_type, [1])
363
607
  buf[0] = src.raw
364
608
  nc_put_var(@ncid, dst.varid, buf)
@@ -393,13 +637,17 @@ module CA
393
637
  def ensure_define_mode = @file.ensure_define_mode
394
638
  # @return [void]
395
639
  def ensure_data_mode = @file.ensure_data_mode
640
+ # Run a block in define or data mode ({File#__transaction__}).
396
641
  # @api private
397
642
  def __transaction__(**opts, &block) = @file.__transaction__(**opts, &block)
398
643
 
399
644
  # @!endgroup
400
645
 
401
646
  # @return [String]
402
- def inspect = "#<#{self.class} #{path.inspect} vars=#{@vars.keys} groups=#{@groups.keys}>"
647
+ def inspect
648
+ return "#<#{self.class} (closed)>" if @file.closed?
649
+ "#<#{self.class} #{path.inspect} vars=#{@vars.keys} groups=#{@groups.keys}>"
650
+ end
403
651
 
404
652
  # Resolve a dimension name by walking up the group tree — netcdf-4
405
653
  # dimension inheritance.
@@ -423,15 +671,23 @@ module CA
423
671
  # @return [Dim, nil]
424
672
  # @api private
425
673
  def dim_by_id(dimid)
674
+ # Dim ids are unique in a file; the file keeps them in one table,
675
+ # and the dim is found here when it is in this group's scope.
676
+ d = @file.dim_index[dimid] or return nil
426
677
  g = self
427
678
  while g
428
- d = g.dims.each_value.find { |x| x.to_i == dimid }
429
- return d if d
679
+ return d if g.equal?(d.group)
430
680
  g = g.parent
431
681
  end
432
682
  nil
433
683
  end
434
684
 
685
+ # @api private
686
+ private def register_dim(dim)
687
+ @file.dim_index[dim.to_i] = dim
688
+ @dims[dim.name] = dim
689
+ end
690
+
435
691
  # ---- template / copy source resolution -----------------------------
436
692
  # Common: accept the object directly (Dim / Var) or a name + `from:`
437
693
  # group; raise cleanly on the invalid combinations.
@@ -469,6 +725,7 @@ module CA
469
725
  # Resolve a "/absolute" or "relative/nested" path down to a Var.
470
726
  # @api private
471
727
  private def resolve_path(str)
728
+ raise KeyError, "path #{str.inspect} names no variable (it ends in /)" if str.end_with?("/")
472
729
  parts = str.split("/").reject(&:empty?)
473
730
  raise KeyError, "empty path" if parts.empty?
474
731
  leaf = parts.pop
@@ -495,7 +752,7 @@ module CA
495
752
  # fallback path is needed; a real error should propagate rather than
496
753
  # be silently swallowed (any rescue here used to also catch
497
754
  # 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 }
755
+ nc_inq_dimids(@ncid, false).each { |d| register_dim(Dim.new(self, d)) }
499
756
  nc_inq_varids(@ncid).each { |v| vr = Var.new(self, v); @vars[vr.name] = vr }
500
757
  nc_inq_grps(@ncid).each do |gid|
501
758
  g = Group.new(gid, @file, self)
@@ -508,7 +765,8 @@ module CA
508
765
  atts = {}
509
766
  nc_inq_natts(@ncid).times do |i|
510
767
  name = nc_inq_attname(@ncid, NC_GLOBAL, i)
511
- atts[name] = nc_get_att(@ncid, NC_GLOBAL, name)
768
+ value = readable_att(@ncid, NC_GLOBAL, name, path)
769
+ atts[name] = deep_freeze(value) unless value.equal?(UNREADABLE_ATT)
512
770
  end
513
771
  atts.freeze
514
772
  end