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,539 @@
1
+ module CA
2
+ module NetCDF
3
+ # A NetCDF variable: a lazy handle. Indexing issues a hyperslab read
4
+ # and runs the file's {Codec} (decode on read, encode on write).
5
+ # Delegates unknown methods to the decoded CArray (`var.mean`,
6
+ # `var.sum`, …).
7
+ #
8
+ # @example Reading
9
+ # var = nc["temp"]
10
+ # var[] # whole variable, decoded
11
+ # var[0, 0..9, ..] # hyperslab
12
+ # var.to_ca # whole variable
13
+ # var.raw # bypass codec
14
+ #
15
+ # @example Writing
16
+ # nc["temp"][] = ca # whole variable
17
+ # nc["temp"][0, ..] = row # hyperslab
18
+ class Var
19
+
20
+ include CA::NetCDF
21
+
22
+ # @param group [Group] containing group (root or subgroup)
23
+ # @param varid [Integer] NetCDF variable id within `group`
24
+ def initialize(group, varid)
25
+ @group = group
26
+ @ncid = group.ncid
27
+ @varid = varid
28
+ @name = nc_inq_varname(@ncid, @varid)
29
+ @nc_type = nc_inq_vartype(@ncid, @varid)
30
+ @dimids = nc_inq_vardimid(@ncid, @varid)
31
+ # Per axis: may a write extend it? The dim can live in an ancestor
32
+ # group, and a group can hold several unlimited dims (netcdf-4).
33
+ @unlimited = @dimids.map { |d| group.dim_by_id(d)&.unlimited? || false }
34
+ refresh_shape
35
+ @atts = read_attributes
36
+ end
37
+
38
+ # @!attribute [r] name
39
+ # @return [String]
40
+ # @!attribute [r] atts
41
+ # @return [Hash{String => Object}] frozen attribute map
42
+ # @!attribute [r] group
43
+ # @return [Group]
44
+ # @!attribute [r] varid
45
+ # @return [Integer]
46
+ attr_reader :name, :atts, :group, :varid
47
+
48
+ # Current shape. Always freshly inquired — the unlimited dim may have
49
+ # grown since this Var was created.
50
+ # @return [Array<Integer>]
51
+ def shape = refresh_shape
52
+
53
+ # @return [Integer] number of dimensions
54
+ def ndim = shape.length
55
+
56
+ # @return [Boolean] true if this is a 0-D scalar variable
57
+ def scalar? = @dimids.empty?
58
+
59
+ # @return [Integer] on-disk `NC_*` type constant
60
+ def nc_type = @nc_type
61
+
62
+ # @return [Symbol] CArray `data_type` symbol
63
+ def data_type = NC_TO_CA[@nc_type]
64
+
65
+ # @return [File] root file
66
+ def file = @group.file
67
+
68
+ # Look up a variable attribute.
69
+ # @param name [#to_s]
70
+ # @return [Object, nil]
71
+ def att(name) = @atts[name.to_s]
72
+
73
+ # The variable's dimensions, in order. A dim may live in an ancestor
74
+ # group (netcdf-4 dim inheritance), so resolve by name up the tree.
75
+ # @return [Array<Dim>]
76
+ def dims = @dimids.map { |d| @group.resolve_dim(nc_inq_dimname(@ncid, d)) }
77
+
78
+ # @return [Object] the codec object (Identity / CF / COARDS / custom)
79
+ def codec = @group.codec
80
+
81
+ # @!group Read
82
+
83
+ # Read (a hyperslab of) this variable with codec decoding applied.
84
+ # Accepts any CArray indexing form (range, integer, `..`, `nil`).
85
+ # Like a write, a read leaves define mode first, so a file being
86
+ # created can be read back between definitions.
87
+ #
88
+ # @param argv [Array] CArray-style index arguments
89
+ # @return [CArray, Numeric, CATime::Element] decoded values
90
+ def [](*argv)
91
+ @group.ensure_data_mode
92
+ refresh_shape
93
+ return read_scalar_var(argv) if scalar?
94
+ info = CArray.scan_index(@shape, argv)
95
+ if info.type == CA_REG_METHOD_CALL
96
+ return decode(nc_get_var(@ncid, @varid))[*argv]
97
+ end
98
+ decode(read_raw(info, argv))
99
+ end
100
+
101
+ # Read the whole variable with codec decoding.
102
+ #
103
+ # The values are read out of the file into a fresh array, which shares
104
+ # nothing with the variable. `writable: true` — a caller asking for an
105
+ # array whose writes reach the source — is therefore refused rather
106
+ # than answered with one that would swallow them; write with
107
+ # `v[…] = …` instead.
108
+ #
109
+ # @param writable [Boolean] refused when true (see above).
110
+ # @return [CArray, Numeric] decoded values (scalar for 0-D vars)
111
+ # @raise [RuntimeError] when `writable: true` is given.
112
+ def to_ca(writable: false)
113
+ if writable
114
+ raise "#{self.class}#to_ca reads into a new array; it can't " \
115
+ "satisfy `writable: true' (write with `v[...] = ...')"
116
+ end
117
+ @group.ensure_data_mode
118
+ refresh_shape
119
+ return read_scalar_var([]) if scalar?
120
+ decode(nc_get_var(@ncid, @varid))
121
+ end
122
+
123
+ # Raw values — the public descent point that bypasses the codec.
124
+ #
125
+ # `v[…]` and `v.to_ca` compose as
126
+ #
127
+ # v[…] == v.codec.decode(v, v.raw(…))
128
+ # v.to_ca == v.codec.decode(v, v.raw)
129
+ #
130
+ # so a caller (or an upper convenience layer) can reach the on-disk
131
+ # bytes without going through `instance_variable_get(:@varid)`. No
132
+ # `_FillValue → UNDEF`, no scale/offset, no `_Unsigned` reinterpret,
133
+ # no CATime. Accepts the same indexing as {#[]}.
134
+ #
135
+ # @param argv [Array] CArray-style index arguments
136
+ # @return [CArray, Numeric] raw on-disk values
137
+ def raw(*argv)
138
+ @group.ensure_data_mode
139
+ refresh_shape
140
+ if scalar?
141
+ raise ArgumentError, "scalar variable #{@name.inspect} takes no index" unless argv.empty?
142
+ return nc_get_var(@ncid, @varid)[0]
143
+ end
144
+ return nc_get_var(@ncid, @varid) if argv.empty?
145
+ info = CArray.scan_index(@shape, argv)
146
+ case info.type
147
+ when CA_REG_METHOD_CALL
148
+ nc_get_var(@ncid, @varid)[*argv]
149
+ else
150
+ read_raw(info, argv)
151
+ end
152
+ end
153
+
154
+ # @!endgroup
155
+
156
+ # @!group Write
157
+
158
+ # Write (to a hyperslab of) this variable with codec encoding
159
+ # applied.
160
+ #
161
+ # @param argv [Array] index arguments followed by the value; the
162
+ # final positional argument is the value to assign
163
+ # @return [Object] the assigned value
164
+ def []=(*argv)
165
+ value = argv.pop
166
+ @group.ensure_data_mode
167
+ refresh_shape
168
+ return write_scalar_var(argv, value) if scalar?
169
+
170
+ info, scan_shape = scan_index_for_write(argv)
171
+ case info.type
172
+ when CA_REG_ADDRESS
173
+ put_var1(addr_to_index(info.index[0], scan_shape), encoded_scalar(value))
174
+ when CA_REG_POINT
175
+ put_var1(info.index, encoded_scalar(value))
176
+ when CA_REG_FLATTEN
177
+ nc_put_var(@ncid, @varid, encoded_array(value))
178
+ when CA_REG_ALL
179
+ ca = encoded_array(value)
180
+ # Use ca.shape (not @shape) so a whole-variable write into an
181
+ # unlimited-dim variable can extend the unlimited axis; every
182
+ # fixed axis must match, or part of the variable would be left
183
+ # as it was.
184
+ check_whole_write_shape(ca.shape)
185
+ start = ca.shape.map { 0 }
186
+ nc_put_vara(@ncid, @varid, start, ca.shape, ca)
187
+ when CA_REG_BLOCK
188
+ start, count, stride, _, reversed = block_spec(info)
189
+ ca = reverse_axes(encoded_array(value, count), count, reversed)
190
+ if stride.all? { |s| s == 1 }
191
+ nc_put_vara(@ncid, @varid, start, count, ca)
192
+ else
193
+ nc_put_vars(@ncid, @varid, start, count, stride, ca)
194
+ end
195
+ else
196
+ raise ArgumentError, "unsupported index for writing #{@name}"
197
+ end
198
+ end
199
+
200
+ # @!endgroup
201
+
202
+ # Define a variable attribute, typed as {Group#def_var} types its
203
+ # attributes: a CArray keeps its own type, and a packed-space
204
+ # attribute (`_FillValue`, `valid_range`, …) given as a Ruby value
205
+ # takes the variable's on-disk type.
206
+ # @param name [#to_s]
207
+ # @param value [Object] Ruby value or CArray
208
+ # @return [Object] the value written
209
+ def def_att(name, value)
210
+ @group.__transaction__ do
211
+ put_var_att(@ncid, @varid, name.to_s, value, @nc_type)
212
+ end
213
+ @atts = read_attributes
214
+ value
215
+ end
216
+
217
+ # @return [String]
218
+ def inspect = "#<#{self.class} #{@name}#{shape.inspect}>"
219
+
220
+ # CArray methods that change their receiver without a `!` / `=` in
221
+ # the name. Delegated, they would change a throwaway copy.
222
+ # @api private
223
+ IN_PLACE_METHODS = %i[
224
+ fill unmask paste inherit_mask inherit_mask_replace attach
225
+ load_binary set_attr
226
+ ].freeze
227
+
228
+ # @api private
229
+ def respond_to_missing?(id, include_private = false)
230
+ (CArray.method_defined?(id) && !in_place?(id)) || super
231
+ end
232
+
233
+ # Delegate unknown methods to {#to_ca} — enables `var.mean`, `var.sum`, …
234
+ #
235
+ # A method that changes its receiver (`fill`, `seq!`, `mask=`, …) is
236
+ # refused: it would change the fresh array {#to_ca} returns, not the
237
+ # file. Write with `v[…] = …`, or call it on `v.to_ca` explicitly.
238
+ # @raise [NoMethodError] for an in-place CArray method
239
+ def method_missing(id, *argv, &block)
240
+ return super unless CArray.method_defined?(id)
241
+ if in_place?(id)
242
+ raise NoMethodError.new(
243
+ "#{self.class}##{id} would change a copy read from the file, not " \
244
+ "the variable; write with `v[...] = ...' (or call it on v.to_ca)",
245
+ id)
246
+ end
247
+ to_ca.__send__(id, *argv, &block)
248
+ end
249
+
250
+ # @api private
251
+ private def in_place?(id)
252
+ id.end_with?("!", "=") || IN_PLACE_METHODS.include?(id)
253
+ end
254
+
255
+ # ---- helpers -------------------------------------------------------
256
+
257
+ # The array read path goes straight through the codec; the scalar
258
+ # (var1) read path wraps the value in a 1-elem carrier, decodes it
259
+ # with the same codec pipeline, and unwraps. That way
260
+ # `_FillValue → UNDEF`, scale/offset, and CF time → `CATime::Element`
261
+ # all work identically for `v[i, j]` and for `v[:, :]`. The 1-cell
262
+ # allocation is cheap enough for interactive use and keeps the
263
+ # semantics uniform.
264
+ # @api private
265
+ private def decode(raw)
266
+ return codec.decode(self, raw) if raw.is_a?(CArray)
267
+ wrapped = CArray.new(data_type, [1]) { raw }
268
+ codec.decode(self, wrapped)[0] # CArray[0] / CATime[0]
269
+ end
270
+
271
+ # Encode a value to a CArray usable by `nc_put_var{,a,s,m}`.
272
+ # - CArray input: pass through the codec.
273
+ # - Numeric / UNDEF / Element: broadcast-fill this variable's shape,
274
+ # run through the codec. This is how `nc["v"] = 42.0` fills the
275
+ # whole variable with a scalar (matches CArray's `ca[] = 42.0`
276
+ # idiom); a block write passes the block's `shape`, which may reach
277
+ # past the end of an unlimited dim. Only used by array-shaped
278
+ # writes (`CA_REG_ALL` / `_BLOCK` / `_FLATTEN`); scalar writes go
279
+ # through {#encoded_scalar}.
280
+ # @api private
281
+ private def encoded_array(value, shape = @shape)
282
+ return codec.encode(self, value) if value.is_a?(CArray)
283
+ ca = broadcast_scalar_to_shape(value, shape)
284
+ codec.encode(self, ca)
285
+ end
286
+
287
+ # Encode a value to a plain Numeric usable by `nc_put_var1`. Accepts
288
+ # a Numeric, UNDEF, a `CATime[delta]::Element`, OR a 1-element /
289
+ # 0-D CArray (the point-write case where the user handed us an
290
+ # array).
291
+ #
292
+ # Numerics are wrapped in a type that preserves precision (int64 for
293
+ # Integer, float64 for Float / UNDEF) so scale/offset can operate on
294
+ # the true value; the array-write path never truncates for the same
295
+ # reason. A float value going to an integer variable is rounded at
296
+ # the end to match `encode_scale`'s CF §8.1 round rule.
297
+ # @api private
298
+ private def encoded_scalar(value)
299
+ wrapped = case value
300
+ when CATime::Element
301
+ CA_INT64([value.value]).time(unit: value.unit)
302
+ when CATimedelta::Element
303
+ CA_INT64([value.value]).timedelta(unit: value.unit)
304
+ when CArray
305
+ unless value.elements == 1
306
+ raise ArgumentError,
307
+ "point write to #{@name} needs a single value; " \
308
+ "got a #{value.shape.inspect} array"
309
+ end
310
+ value # trust caller's dtype
311
+ else
312
+ w = CArray.new(preserve_scalar_type(value), [1])
313
+ w[0] = value # handles UNDEF, coerces numerics
314
+ w
315
+ end
316
+ encoded = codec.encode(self, wrapped)
317
+ # Consistency with the array-write path: for an integer target, a
318
+ # float-typed CArray gets rounded (encode_scale does this when
319
+ # scale/offset is present; do it unconditionally here so the point
320
+ # path also rounds when the user writes 3.7 to an :int var).
321
+ if encoded.is_a?(CArray) &&
322
+ INTEGER_TYPES.include?(data_type) &&
323
+ !INTEGER_TYPES.include?(encoded.data_type)
324
+ encoded = encoded.round
325
+ end
326
+ # If the codec did NOT encode the mask into a sentinel (Identity,
327
+ # or CF for a var without _FillValue), guard against silently
328
+ # writing CArray's internal UNDEF representation to disk.
329
+ if encoded.is_a?(CArray) && encoded.has_mask? && encoded.is_masked[0]
330
+ raise ArgumentError,
331
+ "cannot point-write UNDEF to #{@name}: the codec did not " \
332
+ "encode the mask. Add a _FillValue attribute and open with " \
333
+ "the CF codec, or write a real value."
334
+ end
335
+ encoded[0]
336
+ end
337
+
338
+ # @api private
339
+ INTEGER_TYPES = %i[int8 uint8 int16 uint16 int32 uint32 int64 uint64].freeze
340
+
341
+ # Widest natural type for a Ruby scalar, so wrapping a Float in a
342
+ # 1-elem CArray doesn't truncate against an int variable's data_type.
343
+ # @api private
344
+ private def preserve_scalar_type(value)
345
+ case value
346
+ when Float then :float64
347
+ when Integer then :int64
348
+ else data_type # UNDEF, nil, ... (mask-capable of any type)
349
+ end
350
+ end
351
+
352
+ # Build a shape-matching CArray filled with `value`. CArray's
353
+ # arity-0 block form is a documented single-evaluation fill idiom.
354
+ # @api private
355
+ private def broadcast_scalar_to_shape(value, shape)
356
+ if shape.empty? # 0-D scalar variable
357
+ w = CArray.new(data_type, [1])
358
+ w[0] = value
359
+ w
360
+ else
361
+ CArray.new(data_type, shape) { value }
362
+ end
363
+ end
364
+
365
+ # 0-D scalar variables have shape [] — `CArray.scan_index` doesn't
366
+ # accept that, and `nc_get_var` / `nc_put_var` deal in a 1-element
367
+ # buffer. Reads / writes route through `nc_get_var` / `nc_put_var`
368
+ # and drop the extra dimension for the caller.
369
+ # @api private
370
+ private def read_scalar_var(argv)
371
+ unless argv.empty?
372
+ raise ArgumentError, "scalar variable #{@name.inspect} takes no index"
373
+ end
374
+ raw = nc_get_var(@ncid, @varid) # 1-element CArray
375
+ decoded = codec.decode(self, raw)
376
+ decoded[0] # scalar Numeric / UNDEF / Element
377
+ end
378
+
379
+ # @api private
380
+ private def write_scalar_var(argv, value)
381
+ unless argv.empty?
382
+ raise ArgumentError, "scalar variable #{@name.inspect} takes no index"
383
+ end
384
+ ca = encoded_array(value) # 1-element CArray of var type
385
+ nc_put_var(@ncid, @varid, ca)
386
+ end
387
+
388
+ # @api private
389
+ private def read_raw(info, argv)
390
+ case info.type
391
+ when CA_REG_ALL
392
+ nc_get_var(@ncid, @varid)
393
+ when CA_REG_FLATTEN
394
+ nc_get_var(@ncid, @varid)[nil]
395
+ when CA_REG_ADDRESS
396
+ get_var1(addr_to_index(info.index[0]))
397
+ when CA_REG_POINT
398
+ get_var1(info.index)
399
+ when CA_REG_BLOCK
400
+ start, count, stride, kept, reversed = block_spec(info)
401
+ out = if stride.all? { |s| s == 1 }
402
+ nc_get_vara(@ncid, @varid, start, count)
403
+ else
404
+ nc_get_vars(@ncid, @varid, start, count, stride)
405
+ end
406
+ out = reverse_axes(out, count, reversed)
407
+ # drop point-indexed axes (size 1) by reshaping to the kept shape
408
+ (kept.empty? || kept.length == count.length) ? out : out.reshape(*kept)
409
+ when CA_REG_SELECT, CA_REG_GRID
410
+ nc_get_var(@ncid, @varid)[*argv]
411
+ else
412
+ raise ArgumentError, "unsupported index for reading #{@name}"
413
+ end
414
+ end
415
+
416
+ # One element. An NC_CHAR element travels as a 1-char String in the
417
+ # netcdf gem's var1 calls; here it is the byte, as it is in an array
418
+ # read (NC_CHAR maps to :uint8).
419
+ # @api private
420
+ private def get_var1(index)
421
+ v = nc_get_var1(@ncid, @varid, index)
422
+ @nc_type == NC_CHAR && v.is_a?(String) ? (v.getbyte(0) || 0) : v
423
+ end
424
+
425
+ # @api private
426
+ private def put_var1(index, value)
427
+ value = Integer(value).chr if @nc_type == NC_CHAR && value.is_a?(Numeric)
428
+ nc_put_var1(@ncid, @varid, index, value)
429
+ end
430
+
431
+ # @api private
432
+ private def check_whole_write_shape(shape)
433
+ fits = shape.length == @shape.length &&
434
+ shape.each_index.all? { |i| @unlimited[i] || shape[i] == @shape[i] }
435
+ return if fits
436
+ raise ArgumentError,
437
+ "whole-variable write to #{@name} needs shape #{@shape.inspect}" \
438
+ "#{' (any length on an unlimited axis)' if @unlimited.any?}; " \
439
+ "got #{shape.inspect}"
440
+ end
441
+
442
+ # -> [start, count, stride, kept_shape, reversed]; kept_shape drops
443
+ # point axes. libnetcdf takes only a positive stride, so a reversed
444
+ # range (`(9..0).step(-1)`) becomes the same cells walked forward,
445
+ # with its axis flagged in `reversed` for {#reverse_axes}.
446
+ # @api private
447
+ private def block_spec(info)
448
+ start = []; count = []; stride = []; kept = []; reversed = []
449
+ info.index.each do |idx|
450
+ if idx.is_a?(Array)
451
+ first, n, step = idx
452
+ if step.negative?
453
+ first += (n - 1) * step
454
+ step = -step
455
+ end
456
+ start << first; count << n; stride << step; kept << n
457
+ reversed << idx[2].negative?
458
+ else
459
+ start << idx; count << 1; stride << 1; reversed << false
460
+ end
461
+ end
462
+ [start, count, stride, kept, reversed]
463
+ end
464
+
465
+ # `ca` (of `count` elements) laid out as `count`, with the flagged
466
+ # axes reversed — the same both ways, so it serves read and write.
467
+ # @api private
468
+ private def reverse_axes(ca, count, reversed)
469
+ return ca unless reversed.any?
470
+ ca = ca.to_ca.reshape(*count) unless ca.shape == count
471
+ ca[*reversed.each_with_index.map { |r, i| r ? [count[i] - 1, count[i], -1] : nil }].to_ca
472
+ end
473
+
474
+ # @api private
475
+ private def addr_to_index(addr, shape = @shape)
476
+ index = Array.new(shape.length)
477
+ (shape.length - 1).downto(0) do |i|
478
+ index[i] = addr % shape[i]
479
+ addr /= shape[i]
480
+ end
481
+ index
482
+ end
483
+
484
+ # Refresh `@shape` from the file and return it — the unlimited dim
485
+ # can grow between reads/writes, so the length cached at open time
486
+ # goes stale. Public callers (like {#shape}) receive the returned
487
+ # array; internal callers rely on `@shape` being updated as a side
488
+ # effect.
489
+ # @api private
490
+ private def refresh_shape
491
+ @shape = @dimids.map { |d| nc_inq_dimlen(@ncid, d) }
492
+ end
493
+
494
+ # Resolve a write index -> [info, shape it was resolved against].
495
+ #
496
+ # Indices are resolved against the true `@shape` whenever they fit,
497
+ # so a negative index or an open range means what it means on read.
498
+ # Only an index past the end of an unlimited dimension (libnetcdf
499
+ # grows it on the actual `put_var*` call) falls back to a shape with
500
+ # that axis capped at a huge length. An index that resolves
501
+ # differently under two such caps depends on the axis length — a
502
+ # negative index or an open range reaching past the end — and has no
503
+ # meaning there, so it is refused instead of landing near 2**31.
504
+ # @api private
505
+ private def scan_index_for_write(argv)
506
+ [CArray.scan_index(@shape, argv), @shape]
507
+ rescue IndexError
508
+ raise unless @unlimited.any?
509
+ shape = shape_for_write(INT_MAX_SCAN)
510
+ info = CArray.scan_index(shape, argv)
511
+ unless CArray.scan_index(shape_for_write(INT_MAX_SCAN - 1), argv) == info
512
+ raise IndexError,
513
+ "index #{argv.inspect} for writing #{@name} is out of range " \
514
+ "for shape #{@shape.inspect}; only a non-negative index can " \
515
+ "extend the unlimited dimension"
516
+ end
517
+ [info, shape]
518
+ end
519
+
520
+ # `@shape` with every unlimited axis replaced by `cap`.
521
+ # @api private
522
+ INT_MAX_SCAN = 2**31 - 1
523
+ private def shape_for_write(cap)
524
+ @unlimited.each_with_index.map { |u, i| u ? cap : @shape[i] }
525
+ end
526
+
527
+ # @api private
528
+ private def read_attributes
529
+ natts = nc_inq_varnatts(@ncid, @varid)
530
+ atts = {}
531
+ natts.times do |i|
532
+ name = nc_inq_attname(@ncid, @varid, i)
533
+ atts[name] = nc_get_att(@ncid, @varid, name)
534
+ end
535
+ atts.freeze
536
+ end
537
+ end
538
+ end
539
+ end
@@ -0,0 +1,5 @@
1
+ module CA
2
+ module NetCDF
3
+ VERSION = "2.0.0"
4
+ end
5
+ end