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,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
|