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,536 @@
1
+ module CA
2
+ module NetCDF
3
+ # Value codec — the encode/decode half of a NetCDF "convention".
4
+ #
5
+ # ## Contract (var-local, value-only)
6
+ #
7
+ # A codec only transforms one variable's VALUES using that variable's
8
+ # own attributes. It does not model CF's coordinate systems, grid
9
+ # mappings, cell methods, `standard_name`, `coordinates`, or
10
+ # `ancillary_variables` — anything that names OTHER variables or lives
11
+ # outside the variable itself belongs in a higher-level convenience
12
+ # layer. A codec method takes `(var, ca)`; walking to `var.file` to
13
+ # reach other variables is a contract violation, not a feature.
14
+ #
15
+ # The transforms this codec performs (in CF §2.5 / §8.1 order):
16
+ #
17
+ # 1. `_Unsigned` bit-reinterpret (signed on-disk → unsigned user)
18
+ # 2. `_FillValue` / `missing_value` → mask
19
+ # 3. `valid_range` / `valid_min` / `valid_max` → mask (opt-in)
20
+ # 4. `scale_factor` / `add_offset` → unpack
21
+ # 5. `units: "<u> since <ref>"` (+ `calendar`) → CATime
22
+ #
23
+ # `Conventions` is used at {File.open} time to CHOOSE a codec
24
+ # (dispatch), not by a codec method to interpret values. Once
25
+ # selected, a codec instance stays var-local for the file's lifetime.
26
+ module Codec
27
+
28
+ @registry = []
29
+
30
+ class << self
31
+ # Register a codec (a class or a stateless module). Later
32
+ # registrations take precedence.
33
+ # @param codec [Class, Module]
34
+ # @return [Class, Module] `codec`
35
+ def register(codec)
36
+ @registry.unshift(codec)
37
+ codec
38
+ end
39
+
40
+ # Resolve `requested` into an object that answers to
41
+ # `#decode(var, ca)` / `#encode(var, ca)`. Accepts:
42
+ #
43
+ # :auto # pick by Conventions attr (or a token of it), else Identity
44
+ # :identity | false | nil # Identity
45
+ # :cf | :coards # the named codec with defaults
46
+ # [:cf, mask_valid_range: ...] # the named codec with options
47
+ # Class # instantiated with defaults (Codec::CF)
48
+ # Module # a stateless codec module (Identity)
49
+ # codec instance # e.g. Codec::CF.new(...)
50
+ #
51
+ # @param file [File]
52
+ # @param requested [Symbol, Array, Module, Object]
53
+ # @return [Object] the resolved codec
54
+ # @raise [ArgumentError] on an unrecognized specifier
55
+ def resolve(file, requested = :auto)
56
+ case requested
57
+ when :auto
58
+ # The whole string first, then each of its tokens, so
59
+ # "ACDD-1.3, CF-1.8" still finds CF.
60
+ conv = file.att("Conventions")
61
+ hit = conv && (@registry.find { |c| c.claims?(conv) } ||
62
+ @registry.find { |c| file.conventions.any? { |t| c.claims?(t) } })
63
+ hit ? build(hit) : Identity
64
+ when :identity, false, nil
65
+ Identity
66
+ when Symbol
67
+ symbol_class(requested).new
68
+ when Array
69
+ name, opts = requested[0], (requested[1] || {})
70
+ symbol_class(name).new(**opts)
71
+ when Class
72
+ # A class is instantiated (Class < Module, so this comes
73
+ # first), unless it is itself the codec.
74
+ codec?(requested) ? requested : requested.new
75
+ when Module
76
+ requested # a stateless codec module used verbatim
77
+ else
78
+ return requested if codec?(requested)
79
+ raise ArgumentError, "unknown codec: #{requested.inspect}"
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def codec?(obj) = obj.respond_to?(:decode) && obj.respond_to?(:encode)
86
+
87
+ def symbol_class(sym)
88
+ case sym
89
+ when :cf then CF
90
+ when :coards then COARDS
91
+ else raise ArgumentError, "unknown codec: #{sym.inspect}"
92
+ end
93
+ end
94
+
95
+ def build(codec) = codec.is_a?(Class) ? codec.new : codec
96
+ end
97
+
98
+ # No decoding — raw values pass through unchanged. The safe default
99
+ # so a file written by an unknown tool round-trips untouched.
100
+ module Identity
101
+ module_function
102
+ # @return [false]
103
+ def claims?(_conventions) = false
104
+ # @param _var [Var]
105
+ # @param ca [CArray]
106
+ # @return [CArray] `ca`
107
+ def decode(_var, ca) = ca
108
+ # @param _var [Var]
109
+ # @param ca [CArray]
110
+ # @return [CArray] `ca`
111
+ def encode(_var, ca) = ca
112
+ end
113
+
114
+ # CF / COARDS value transforms. See the module-level docstring for
115
+ # the pipeline order; the codec chapter of the user guide has the
116
+ # CF attribute matrix.
117
+ class CF
118
+ # @param conventions [String]
119
+ # @return [Boolean]
120
+ def self.claims?(conventions)
121
+ s = conventions.to_s
122
+ s.start_with?("CF-", "CF ") || s == "CF"
123
+ end
124
+
125
+ # @param mask_valid_range [Boolean] when true, mask cells that
126
+ # fall outside `valid_range` / `valid_min` / `valid_max`
127
+ # (packed-space judgement, before scale/offset).
128
+ def initialize(mask_valid_range: false)
129
+ @mask_valid_range = mask_valid_range
130
+ @warned = {} # per-instance = per-File-open; not process-global
131
+ end
132
+
133
+ # @return [Boolean]
134
+ attr_reader :mask_valid_range
135
+
136
+ # Decode on-disk raw values into Ruby-side values.
137
+ # @param var [Var]
138
+ # @param ca [CArray] raw values
139
+ # @return [CArray, CATime] decoded values
140
+ def decode(var, ca)
141
+ a = var.atts
142
+ ca = decode_unsigned(var, ca) # signed disk -> unsigned user
143
+ ca = decode_fill(var, a, ca) # (in unsigned/packed space)
144
+ ca = decode_valid(var, a, ca) if @mask_valid_range # (same)
145
+ ca = decode_scale(a, ca) # packed -> physical
146
+ ca = decode_time(var, a, ca) if time_units?(a["units"])
147
+ ca
148
+ end
149
+
150
+ # Encode Ruby-side values into on-disk raw values.
151
+ # @param var [Var]
152
+ # @param ca [CArray, CATime]
153
+ # @return [CArray] raw values ready for `nc_put_var*`
154
+ def encode(var, ca)
155
+ a = var.atts
156
+ ca = encode_time(var, a, ca) if time_units?(a["units"])
157
+ ca = encode_scale(var, a, ca)
158
+ ca = encode_fill(var, a, ca)
159
+ ca = encode_unsigned(var, ca) # unsigned user -> signed disk
160
+ ca
161
+ end
162
+
163
+ # ---- _Unsigned: bit-reinterpret signed <-> unsigned ---------------
164
+ #
165
+ # netCDF-3 has no unsigned integer types. Producers set
166
+ # `_Unsigned = "true"` on a byte/short/int/int64 variable to say
167
+ # "these bits are semantically unsigned; reinterpret." Same
168
+ # convention shows up on NetCDF-4 files from tools with an
169
+ # HDF-EOS heritage (satellite data, etc.). This runs FIRST
170
+ # because every subsequent transform (fill/valid comparison,
171
+ # scale/offset) needs the numerically-correct interpretation.
172
+
173
+ SIGNED_TO_UNSIGNED = {
174
+ int8: :uint8, int16: :uint16, int32: :uint32, int64: :uint64,
175
+ }.freeze
176
+ UNSIGNED_TO_SIGNED = SIGNED_TO_UNSIGNED.invert.freeze
177
+ UNSIGNED_MASK = {
178
+ int8: 0xFF, int16: 0xFFFF, int32: 0xFFFFFFFF,
179
+ int64: 0xFFFFFFFFFFFFFFFF,
180
+ }.freeze
181
+
182
+ # @api private
183
+ private def unsigned?(var)
184
+ v = var.att("_Unsigned")
185
+ case v
186
+ when String then v.downcase == "true"
187
+ when Integer then v != 0
188
+ when true then true
189
+ else false
190
+ end
191
+ end
192
+
193
+ # @api private
194
+ private def decode_unsigned(var, ca)
195
+ return ca unless unsigned?(var)
196
+ t = SIGNED_TO_UNSIGNED[var.data_type] or return ca
197
+ ca.refer(t) # zero-copy view
198
+ end
199
+
200
+ # @api private
201
+ private def encode_unsigned(var, ca)
202
+ return ca unless unsigned?(var)
203
+ t = var.data_type # signed on-disk type
204
+ return ca unless SIGNED_TO_UNSIGNED.key?(t)
205
+ return ca unless ca.is_a?(CArray)
206
+ # An already-signed value is written as is. Anything else is
207
+ # brought to the unsigned type of the same width (rounding a
208
+ # float, as a packed value is rounded), and `refer`
209
+ # bit-reinterprets that — `refer` only works between types of
210
+ # one byte size.
211
+ return ca if ca.data_type == t
212
+ u = SIGNED_TO_UNSIGNED[t]
213
+ ca = ca.round if ca.float?
214
+ ca = ca.to_type(u) unless ca.data_type == u
215
+ ca.refer(t)
216
+ end
217
+
218
+ # If `_Unsigned` is set, the attribute is stored on disk in the
219
+ # underlying signed type — reinterpret to the semantic unsigned
220
+ # value so comparisons in unsigned space work directly.
221
+ # @api private
222
+ private def unsigned_reinterpret(var, val)
223
+ return val unless unsigned?(var)
224
+ mask = UNSIGNED_MASK[var.data_type] or return val
225
+ case val
226
+ when Integer then val & mask
227
+ when Array then val.map { |x| x.is_a?(Integer) ? (x & mask) : x }
228
+ when CArray then val & mask
229
+ else val
230
+ end
231
+ end
232
+
233
+ # ---- fill / missing -> mask -----------------------------------------
234
+ # Only copy when at least one cell actually matches a fill sentinel;
235
+ # a large (100s of MB) variable with fill defined but no fill cells
236
+ # would otherwise double its resident size for no reason.
237
+ # @api private
238
+ private def decode_fill(var, atts, ca)
239
+ fills = fill_values(var, atts).select { |fv| fill_cells(ca, fv).any }
240
+ return ca if fills.empty?
241
+ ca = ca.copy
242
+ fills.each { |fv| ca[fill_cells(ca, fv)] = UNDEF }
243
+ ca
244
+ end
245
+
246
+ # Cells equal to the fill value. A NaN fill (common in float data)
247
+ # equals nothing, not even NaN, so it matches the NaN cells.
248
+ # @api private
249
+ private def fill_cells(ca, fv)
250
+ fv.is_a?(Float) && fv.nan? && ca.float? ? ca.is_nan : ca.eq(fv)
251
+ end
252
+
253
+ # ---- valid_range -> mask (opt-in) -----------------------------------
254
+ # Same copy-avoidance as decode_fill.
255
+ # @api private
256
+ private def decode_valid(var, atts, ca)
257
+ vmin, vmax = valid_bounds(var, atts)
258
+ return ca unless vmin || vmax
259
+ below = vmin && ca.lt(vmin).any
260
+ above = vmax && ca.gt(vmax).any
261
+ return ca unless below || above
262
+ ca = ca.copy
263
+ ca[:lt, vmin] = UNDEF if below
264
+ ca[:gt, vmax] = UNDEF if above
265
+ ca
266
+ end
267
+
268
+ # -> [min, max] with either bound possibly nil. `valid_range`
269
+ # wins over `valid_min` / `valid_max` per CF; `valid_range` must
270
+ # be length 2.
271
+ # @api private
272
+ private def valid_bounds(var, atts)
273
+ if (vr = atts["valid_range"])
274
+ pair = vr.is_a?(CArray) ? vr.to_a : Array(vr)
275
+ if pair.length == 2
276
+ return [unsigned_reinterpret(var, pair[0]),
277
+ unsigned_reinterpret(var, pair[1])]
278
+ end
279
+ end
280
+ [unsigned_reinterpret(var, atts["valid_min"]),
281
+ unsigned_reinterpret(var, atts["valid_max"])]
282
+ end
283
+
284
+ # ---- scale_factor / add_offset --------------------------------------
285
+
286
+ # `unpacked = packed * scale_factor + add_offset` (CF §8.1)
287
+ # @api private
288
+ private def decode_scale(atts, ca)
289
+ sf = atts["scale_factor"]
290
+ ao = atts["add_offset"]
291
+ ca = ca * sf if sf
292
+ ca = ca + ao if ao
293
+ ca
294
+ end
295
+
296
+ INTEGER_TYPES = %i[int8 uint8 int16 uint16 int32 uint32 int64 uint64].freeze
297
+
298
+ # `packed = round((unpacked - add_offset) / scale_factor)`
299
+ # (A bare cast to an integer variable would truncate:
300
+ # `1.0/0.1 == 9.999… -> 9`.)
301
+ # @api private
302
+ private def encode_scale(var, atts, ca)
303
+ sf = atts["scale_factor"]
304
+ ao = atts["add_offset"]
305
+ return ca unless sf || ao
306
+ ca = ca - ao if ao
307
+ ca = ca / sf if sf
308
+ ca = ca.round if INTEGER_TYPES.include?(var.data_type)
309
+ ca
310
+ end
311
+
312
+ # ---- mask <-> fill --------------------------------------------------
313
+
314
+ # @api private
315
+ private def encode_fill(var, atts, ca)
316
+ return ca unless ca.is_a?(CArray) && ca.has_mask?
317
+ fv = fill_values(var, atts).first or
318
+ raise ArgumentError,
319
+ "variable '#{var.name}' has masked values but no _FillValue " \
320
+ "to encode them"
321
+ ca.strip_mask(fv)
322
+ end
323
+
324
+ # @api private
325
+ private def fill_values(var, atts)
326
+ fills = []
327
+ if atts.key?("_FillValue")
328
+ fills << unsigned_reinterpret(var, atts["_FillValue"])
329
+ end
330
+ if atts.key?("missing_value")
331
+ Array(atts["missing_value"]).each do |mv|
332
+ fills << unsigned_reinterpret(var, mv)
333
+ end
334
+ end
335
+ fills
336
+ end
337
+
338
+ # ---- CF time --------------------------------------------------------
339
+ #
340
+ # `units: "<interval> since <reference>"` maps directly to
341
+ # `CATime`'s `.time(unit: <resolution>, origin: <reference>)`
342
+ # (designed for exactly this "index relative to a base instant"
343
+ # pattern). `dt.timesteps(unit:, origin:)` is the inverse, used
344
+ # on encode.
345
+ #
346
+ # This gem does NOT do UDUNITS conversions of other units strings
347
+ # (`"m s-1"`, `"K"`, `"kg m-3"`, …). Those pass through as raw
348
+ # attribute values. Only the `"<u> since <ref>"` form is
349
+ # recognized.
350
+ #
351
+ # CATime is fixed to the proleptic Gregorian calendar in UTC, so:
352
+ # - `standard` / `gregorian` / `proleptic_gregorian` /
353
+ # (missing) -> CATime
354
+ # - `noleap` / `365_day` / `all_leap` / `366_day` / `360_day` /
355
+ # `julian` / `none` -> leave the raw values alone (and warn
356
+ # once per calendar per File).
357
+
358
+ # @api private
359
+ private def time_units?(units)
360
+ units.is_a?(String) && units.include?(" since ")
361
+ end
362
+
363
+ UNIT_WORDS = {
364
+ "year" => :Y, "years" => :Y, "yr" => :Y, "yrs" => :Y, "y" => :Y,
365
+ "month" => :M, "months" => :M, "mon" => :M, "mons" => :M,
366
+ "day" => :D, "days" => :D, "d" => :D,
367
+ "hour" => :h, "hours" => :h, "hr" => :h, "hrs" => :h, "h" => :h,
368
+ "minute" => :m, "minutes" => :m, "min" => :m, "mins" => :m,
369
+ "second" => :s, "seconds" => :s, "sec" => :s, "secs" => :s, "s" => :s,
370
+ "millisecond" => :ms, "milliseconds" => :ms, "msec" => :ms, "msecs" => :ms, "ms" => :ms,
371
+ "microsecond" => :us, "microseconds" => :us, "usec" => :us, "usecs" => :us, "us" => :us,
372
+ "nanosecond" => :ns, "nanoseconds" => :ns, "nsec" => :ns, "nsecs" => :ns, "ns" => :ns,
373
+ }.freeze
374
+
375
+ SUPPORTED_CALENDARS = %w[standard gregorian proleptic_gregorian].freeze
376
+
377
+ # -> [count, base_symbol, reference_string] or nil if unparseable.
378
+ # @api private
379
+ private def parse_time_units(units)
380
+ interval, ref = units.split(/\s+since\s+/, 2)
381
+ return nil unless ref
382
+ m = interval.strip.match(/\A(\d+)?\s*([A-Za-z]+)\z/)
383
+ return nil unless m
384
+ count = (m[1] || "1").to_i
385
+ base = UNIT_WORDS[m[2].downcase] or return nil
386
+ [count, base, ref.strip]
387
+ end
388
+
389
+ # See docs/codec.md §"N-of-a-unit intervals" for why we fold
390
+ # count into the raw values instead of storing at `count × base`
391
+ # resolution.
392
+ # @api private
393
+ private def decode_time(var, atts, ca)
394
+ units = atts["units"]
395
+ calendar = atts["calendar"]
396
+ unless supported_calendar?(calendar)
397
+ warn_once(:calendar, calendar,
398
+ "variable #{var.name.inspect} uses calendar " \
399
+ "#{calendar.inspect} which is not supported; " \
400
+ "raw values returned")
401
+ return ca
402
+ end
403
+ parsed = parse_time_units(units)
404
+ unless parsed
405
+ warn_once(:units, units,
406
+ "variable #{var.name.inspect} has units " \
407
+ "#{units.inspect} which the CF time parser does not " \
408
+ "understand; raw values returned")
409
+ return ca
410
+ end
411
+ count, base, ref = parsed
412
+ vals = count == 1 ? ca : ca * count
413
+ if vals.float?
414
+ unit, ticks = time_ticks(vals, base)
415
+ return ticks.time(unit: unit, origin: ref)
416
+ end
417
+ ints = vals.data_type == :int64 ? vals : vals.int64
418
+ ints.time(unit: base, origin: ref)
419
+ end
420
+
421
+ # Length of each fixed-length unit, in ns, finest last. `:Y` / `:M`
422
+ # have no fixed length, so a fraction of one can't be carried to a
423
+ # finer unit.
424
+ NS_PER_UNIT = {
425
+ D: 86_400_000_000_000, h: 3_600_000_000_000, m: 60_000_000_000,
426
+ s: 1_000_000_000, ms: 1_000_000, us: 1_000, ns: 1,
427
+ }.freeze
428
+
429
+ # Float time values in `base` units -> [unit, int64 ticks]. Values
430
+ # with a fraction (`0.5 days since …`) are carried to the first
431
+ # finer unit that holds them whole, so the fraction is not rounded
432
+ # away; the finest unit that still fits int64 is rounded to when
433
+ # none does.
434
+ # @api private
435
+ private def time_ticks(vals, base)
436
+ eps = vals.data_type == :float32 ? 1.2e-7 : 2.3e-16
437
+ limit = vals.abs.max
438
+ limit = 0.0 unless limit.is_a?(Numeric) # all masked
439
+ whole = ->(x, f) { d = (x - x.round).abs.max
440
+ !d.is_a?(Numeric) || d <= [1e-6, 4 * limit * f * eps].max }
441
+ return [base, vals.round.int64] if whole.(vals, 1) || !NS_PER_UNIT.key?(base)
442
+ best = [base, vals]
443
+ NS_PER_UNIT.each_key.drop_while { |u| u != base }.drop(1).each do |u|
444
+ f = NS_PER_UNIT[base] / NS_PER_UNIT[u]
445
+ break if limit * f >= 2**62
446
+ x = vals * f
447
+ return [u, x.round.int64] if whole.(x, f)
448
+ best = [u, x]
449
+ end
450
+ [best[0], best[1].round.int64]
451
+ end
452
+
453
+ # @api private
454
+ private def encode_time(var, atts, ca)
455
+ return ca unless ca.is_a?(CATime)
456
+ units = atts["units"]
457
+ calendar = atts["calendar"]
458
+ unless supported_calendar?(calendar)
459
+ raise ArgumentError,
460
+ "cannot encode CATime into variable #{var.name.inspect}: " \
461
+ "calendar #{calendar.inspect} is not supported"
462
+ end
463
+ parsed = parse_time_units(units) or
464
+ raise ArgumentError,
465
+ "cannot encode CATime into variable #{var.name.inspect}: " \
466
+ "units #{units.inspect} is not a CF time-since spec"
467
+ count, base, ref = parsed
468
+ fine = ca.unit.base
469
+ if finer_unit?(fine, base)
470
+ # The instants are finer than the units' interval: work in the
471
+ # finer unit so a fraction of an interval isn't truncated.
472
+ f = NS_PER_UNIT[base] / NS_PER_UNIT[fine] * count
473
+ ticks = ca.timesteps(unit: fine, origin: ref)
474
+ return ticks.float64 / f unless INTEGER_TYPES.include?(var.data_type)
475
+ unless (ticks % f).eq(0).all
476
+ raise ArgumentError,
477
+ "cannot encode CATime into integer variable #{var.name.inspect}: " \
478
+ "some instants fall between the steps of units #{units.inspect}"
479
+ end
480
+ return ticks / f
481
+ end
482
+ ticks = ca.timesteps(unit: base, origin: ref)
483
+ return ticks if count == 1
484
+ unless (ticks % count).eq(0).all
485
+ raise ArgumentError,
486
+ "cannot encode CATime into variable #{var.name.inspect}: " \
487
+ "some instants do not land on the #{count}-#{base} grid " \
488
+ "implied by units #{units.inspect}"
489
+ end
490
+ ticks / count
491
+ end
492
+
493
+ # @api private
494
+ private def finer_unit?(unit, base)
495
+ NS_PER_UNIT.key?(unit) && NS_PER_UNIT.key?(base) &&
496
+ NS_PER_UNIT[unit] < NS_PER_UNIT[base]
497
+ end
498
+
499
+ # @api private
500
+ private def supported_calendar?(cal)
501
+ cal.nil? || SUPPORTED_CALENDARS.include?(cal.to_s)
502
+ end
503
+
504
+ # Per-instance cache (a new CF instance is built per
505
+ # {File.open}, so this is effectively per-File — not
506
+ # process-global).
507
+ # @api private
508
+ private def warn_once(kind, key, message)
509
+ k = [kind, key]
510
+ return if @warned[k]
511
+ @warned[k] = true
512
+ warn "carray-netcdf CF codec: #{message}"
513
+ end
514
+ end
515
+
516
+ # COARDS (1995) is CF's predecessor. Every adherent COARDS file is
517
+ # also an adherent CF file — set inclusion, not just similarity —
518
+ # so we inherit every transform and only override the class-level
519
+ # dispatch name. Callers that need to distinguish (COARDS pins the
520
+ # axis order T,Z,Y,X; CF does not) should check `is_a?(COARDS)`
521
+ # BEFORE `is_a?(CF)`.
522
+ class COARDS < CF
523
+ # @param conventions [String]
524
+ # @return [Boolean]
525
+ def self.claims?(conventions)
526
+ conventions.to_s.start_with?("COARDS")
527
+ end
528
+ end
529
+
530
+ register(Identity)
531
+ register(CF)
532
+ register(COARDS) # registered last -> checked first, so "COARDS/CF-*"
533
+ # goes to COARDS rather than CF.
534
+ end
535
+ end
536
+ end
@@ -0,0 +1,67 @@
1
+ module CA
2
+ module NetCDF
3
+ # A NetCDF dimension. Bridges to its coordinate variable (a variable of
4
+ # the same name in the same group) when one exists.
5
+ class Dim
6
+
7
+ include CA::NetCDF
8
+
9
+ # @param group [Group] containing group
10
+ # @param dimid [Integer] NetCDF dimension id within `group`
11
+ # @param unlimited [Boolean, nil] known unlimited-ness (from
12
+ # {Group#def_dim}); nil to inquire the file
13
+ def initialize(group, dimid, unlimited: nil)
14
+ @group = group
15
+ @ncid = group.ncid
16
+ @dimid = dimid
17
+ @name = nc_inq_dimname(@ncid, @dimid)
18
+ # Whether a dim is unlimited is a define-time property and doesn't
19
+ # change over the file's lifetime — cache it once. Asked of the
20
+ # dim's own group, which is where netcdf-4 keeps it.
21
+ # nc_inq_unlimdim names only the group's first unlimited dim; a
22
+ # further one is known here only when this gem defined it.
23
+ @unlimited = if unlimited.nil?
24
+ @dimid == (nc_inq_unlimdim(@ncid) rescue -1)
25
+ else
26
+ unlimited
27
+ end
28
+ end
29
+
30
+ # @!attribute [r] name
31
+ # @return [String] dimension name
32
+ # @!attribute [r] group
33
+ # @return [Group] containing group
34
+ attr_reader :name, :group
35
+
36
+ # Current length. Always freshly inquired from the file: an unlimited
37
+ # dim grows as writes come in, so a value cached at open time goes
38
+ # stale.
39
+ # @return [Integer]
40
+ def length = nc_inq_dimlen(@ncid, @dimid)
41
+
42
+ # @return [Boolean] true if this is the unlimited dimension
43
+ def unlimited? = @unlimited
44
+
45
+ # @return [Integer] raw NetCDF dimension id
46
+ def to_i = @dimid
47
+
48
+ # The coordinate variable of the same name in the same group.
49
+ # @return [Var, nil]
50
+ def var = @group.var(@name)
51
+
52
+ # Coordinate values (via the coordinate variable).
53
+ #
54
+ # `writable:` is passed through to {Var#to_ca}, which refuses it — the
55
+ # values are read into a fresh array either way.
56
+ #
57
+ # @param writable [Boolean] forwarded to the coordinate variable.
58
+ # @return [CArray, nil]
59
+ def to_ca(writable: false) = var&.to_ca(writable: writable)
60
+
61
+ # @return [String]
62
+ def inspect
63
+ "#<#{self.class} #{@name}=#{length}#{@unlimited ? '(unlimited)' : ''}>"
64
+ end
65
+ end
66
+ end
67
+ end