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.
@@ -1,3 +1,5 @@
1
+ require "date"
2
+
1
3
  module CA
2
4
  module NetCDF
3
5
  # Value codec — the encode/decode half of a NetCDF "convention".
@@ -57,10 +59,15 @@ module CA
57
59
  when :auto
58
60
  # The whole string first, then each of its tokens, so
59
61
  # "ACDD-1.3, CF-1.8" still finds CF.
62
+ # A non-String attribute (an NC_STRING array, a number) is
63
+ # tried only through its String tokens.
60
64
  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
65
+ conv = nil unless conv.is_a?(String)
66
+ hit = (conv && @registry.find { |c| c.claims?(conv) }) ||
67
+ @registry.find { |c| file.conventions.any? { |t| c.claims?(t) } }
68
+ return build(hit) if hit
69
+ warn_cf_without_conventions(file) if conv.nil? && file.conventions.empty?
70
+ Identity
64
71
  when :identity, false, nil
65
72
  Identity
66
73
  when Symbol
@@ -82,6 +89,23 @@ module CA
82
89
 
83
90
  private
84
91
 
92
+ # Attributes that make a variable look CF-encoded.
93
+ CF_LOOKING_ATTS = %w[_FillValue missing_value scale_factor add_offset].freeze
94
+
95
+ # xarray and netCDF4-python write CF attributes but no Conventions,
96
+ # so such a file reads as stored. Say so once, and how to decode it.
97
+ def warn_cf_without_conventions(file)
98
+ v = file.vars.each_value.find do |var|
99
+ atts = var.atts
100
+ CF_LOOKING_ATTS.any? { |k| atts.key?(k) } ||
101
+ (atts["units"].is_a?(String) && atts["units"].include?(" since "))
102
+ end
103
+ return unless v
104
+ warn "carray-netcdf: #{file.filename} has CF attributes (on #{v.name.inspect}) " \
105
+ "but no Conventions attribute naming CF or COARDS, so values are read " \
106
+ "as stored; open it with codec: :cf to decode them"
107
+ end
108
+
85
109
  def codec?(obj) = obj.respond_to?(:decode) && obj.respond_to?(:encode)
86
110
 
87
111
  def symbol_class(sym)
@@ -142,7 +166,7 @@ module CA
142
166
  ca = decode_unsigned(var, ca) # signed disk -> unsigned user
143
167
  ca = decode_fill(var, a, ca) # (in unsigned/packed space)
144
168
  ca = decode_valid(var, a, ca) if @mask_valid_range # (same)
145
- ca = decode_scale(a, ca) # packed -> physical
169
+ ca = decode_scale(var, a, ca) # packed -> physical
146
170
  ca = decode_time(var, a, ca) if time_units?(a["units"])
147
171
  ca
148
172
  end
@@ -154,6 +178,7 @@ module CA
154
178
  def encode(var, ca)
155
179
  a = var.atts
156
180
  ca = encode_time(var, a, ca) if time_units?(a["units"])
181
+ ca = mask_nan(var, ca)
157
182
  ca = encode_scale(var, a, ca)
158
183
  ca = encode_fill(var, a, ca)
159
184
  ca = encode_unsigned(var, ca) # unsigned user -> signed disk
@@ -285,15 +310,36 @@ module CA
285
310
 
286
311
  # `unpacked = packed * scale_factor + add_offset` (CF §8.1)
287
312
  # @api private
288
- private def decode_scale(atts, ca)
313
+ private def decode_scale(var, atts, ca)
289
314
  sf = atts["scale_factor"]
290
315
  ao = atts["add_offset"]
316
+ return ca unless sf || ao
317
+ # CF: the unpacked type is the attributes'. float32 attributes
318
+ # unpack in float32, as xarray and netCDF4-python do.
319
+ if !ca.float? || ca.data_type == :float32
320
+ names = %w[scale_factor add_offset].select { |k| atts.key?(k) }
321
+ if names.all? { |k| var.respond_to?(:att_type) && var.att_type(k) == ::NetCDF::NC_FLOAT }
322
+ return unpack(ca.to_type(:float32), sf, ao)
323
+ end
324
+ end
325
+ # Integer attributes keep integer arithmetic in the packed type,
326
+ # which overflows (a short times 200); widen to int64 first.
327
+ if INTEGER_TYPES.include?(ca.data_type) && (sf.is_a?(Integer) || ao.is_a?(Integer))
328
+ ca = ca.to_type(:int64)
329
+ end
330
+ unpack(ca, sf, ao)
331
+ end
332
+
333
+ # @api private
334
+ private def unpack(ca, sf, ao)
291
335
  ca = ca * sf if sf
292
336
  ca = ca + ao if ao
293
337
  ca
294
338
  end
295
339
 
296
340
  INTEGER_TYPES = %i[int8 uint8 int16 uint16 int32 uint32 int64 uint64].freeze
341
+ INT64_MIN = -2**63
342
+ INT64_MAX = 2**63 - 1
297
343
 
298
344
  # `packed = round((unpacked - add_offset) / scale_factor)`
299
345
  # (A bare cast to an integer variable would truncate:
@@ -303,18 +349,41 @@ module CA
303
349
  sf = atts["scale_factor"]
304
350
  ao = atts["add_offset"]
305
351
  return ca unless sf || ao
352
+ # Integer values would be divided as integers (truncating) and
353
+ # could overflow their type; work in float64 and round below.
354
+ ca = ca.to_type(:float64) if INTEGER_TYPES.include?(ca.data_type)
306
355
  ca = ca - ao if ao
307
356
  ca = ca / sf if sf
308
357
  ca = ca.round if INTEGER_TYPES.include?(var.data_type)
309
358
  ca
310
359
  end
311
360
 
361
+ # NaN is how float data marks a missing value (xarray writes it so).
362
+ # An integer variable cannot hold it — cast, it would become a
363
+ # valid-looking 0 — so it is masked here and written as the fill.
364
+ # @api private
365
+ private def mask_nan(var, ca)
366
+ return ca unless ca.is_a?(CArray) && ca.float? && INTEGER_TYPES.include?(var.data_type)
367
+ nan = ca.is_nan
368
+ return ca unless nan.any
369
+ ca = ca.copy
370
+ ca[nan] = UNDEF
371
+ ca
372
+ end
373
+
312
374
  # ---- mask <-> fill --------------------------------------------------
313
375
 
314
376
  # @api private
315
377
  private def encode_fill(var, atts, ca)
316
378
  return ca unless ca.is_a?(CArray) && ca.has_mask?
317
- fv = fill_values(var, atts).first or
379
+ # Values still in the signed on-disk type take the stored fill;
380
+ # unsigned ones take it reinterpreted, as fill_values gives it.
381
+ fv = if unsigned?(var) && ca.data_type == var.data_type
382
+ atts["_FillValue"] || Array(atts["missing_value"]).first
383
+ else
384
+ fill_values(var, atts).first
385
+ end
386
+ fv or
318
387
  raise ArgumentError,
319
388
  "variable '#{var.name}' has masked values but no _FillValue " \
320
389
  "to encode them"
@@ -383,10 +452,55 @@ module CA
383
452
  return nil unless m
384
453
  count = (m[1] || "1").to_i
385
454
  base = UNIT_WORDS[m[2].downcase] or return nil
386
- [count, base, ref.strip]
455
+ # UDUNITS writes short years ("days since 1-1-1"); CATime would
456
+ # read a one- or two-digit year as 20xx / 19xx, so pad it.
457
+ ref = ref.strip.sub(/\A(-?)(\d{1,3})(?=-)/) { "#{$1}#{$2.rjust(4, '0')}" }
458
+ [count, base, ref]
459
+ end
460
+
461
+ # The unit to work in for a reference time: `base` when the
462
+ # reference falls on its grid, else the first finer fixed-length
463
+ # unit it falls on (`"days since 2000-01-01 12:00"` works in hours).
464
+ # nil when there is none — a `:Y` / `:M` base whose reference is not
465
+ # the head of a year / month. Cached per instance.
466
+ # @api private
467
+ private def origin_unit(base, ref)
468
+ @origin_units ||= {}
469
+ key = [base, ref]
470
+ return @origin_units[key] if @origin_units.key?(key)
471
+ units = NS_PER_UNIT.key?(base) ? NS_PER_UNIT.keys.drop_while { |u| u != base } : [base]
472
+ @origin_units[key] = units.find { |u| origin_on_grid?(u, ref) }
387
473
  end
388
474
 
389
- # See docs/codec.md §"N-of-a-unit intervals" for why we fold
475
+ # The first day of the Gregorian calendar; under `standard` /
476
+ # `gregorian` (CF's mixed calendar) a date before it is Julian.
477
+ GREGORIAN_REFORM = Date.new(1582, 10, 15, Date::GREGORIAN)
478
+
479
+ # A reference time before the reform, in the mixed calendar, is a
480
+ # Julian date. CATime is proleptic Gregorian, so the same day is
481
+ # relabelled (`1-1-1` is `0000-12-30`); every instant from the reform
482
+ # on then comes out as cftime / UDUNITS give it. -> [ref, shifted?]
483
+ # @api private
484
+ private def mixed_calendar_reference(ref, calendar)
485
+ return [ref, false] unless calendar.nil? || %w[standard gregorian].include?(calendar.to_s.strip.downcase)
486
+ m = ref.match(/\A(-?\d+)-(\d{1,2})-(\d{1,2})(.*)\z/m) or return [ref, false]
487
+ y, mo, d = m[1].to_i, m[2].to_i, m[3].to_i
488
+ return [ref, false] unless Date.valid_date?(y, mo, d, Date::JULIAN)
489
+ julian = Date.new(y, mo, d, Date::JULIAN)
490
+ return [ref, false] unless julian < GREGORIAN_REFORM
491
+ g = julian.gregorian
492
+ return [format("%04d-%02d-%02d%s", g.year, g.month, g.day, m[4]), true]
493
+ end
494
+
495
+ # @api private
496
+ private def origin_on_grid?(unit, ref)
497
+ CA_INT64([0]).time(unit: unit, origin: ref)
498
+ return true
499
+ rescue ArgumentError
500
+ return false
501
+ end
502
+
503
+ # See docs/04_codec.md §"N-of-a-unit intervals" for why we fold
390
504
  # count into the raw values instead of storing at `count × base`
391
505
  # resolution.
392
506
  # @api private
@@ -409,15 +523,61 @@ module CA
409
523
  return ca
410
524
  end
411
525
  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)
526
+ ref, shifted = mixed_calendar_reference(ref, calendar)
527
+ unit = origin_unit(base, ref)
528
+ unless unit
529
+ warn_once(:origin, units,
530
+ "variable #{var.name.inspect} has units #{units.inspect} " \
531
+ "whose reference time is not on the #{base} grid; " \
532
+ "raw values returned")
533
+ return ca
534
+ end
535
+ # Values in `count × base` steps, counted in `unit`.
536
+ f = count * (NS_PER_UNIT.key?(base) ? NS_PER_UNIT[base] / NS_PER_UNIT[unit] : 1)
537
+ if ca.float?
538
+ vals = f == 1 ? ca : ca * f
539
+ tick_unit, ticks = time_ticks(vals, unit)
540
+ return check_reform(var, ticks.time(unit: tick_unit, origin: ref), shifted)
416
541
  end
417
- ints = vals.data_type == :int64 ? vals : vals.int64
418
- ints.time(unit: base, origin: ref)
542
+ ints = ca.data_type == :int64 ? ca : ca.int64
543
+ # xarray writes NaT as the smallest int64, with no _FillValue.
544
+ if ca.data_type == :int64 && ! atts.key?("_FillValue")
545
+ nat = ints.eq(INT64_MIN)
546
+ if nat.any
547
+ ints = ints.copy
548
+ ints[nat] = UNDEF
549
+ end
550
+ end
551
+ unless f == 1
552
+ # Counted in a finer unit, a value may no longer fit int64.
553
+ limit = ints.abs.max
554
+ if limit.is_a?(Integer) && limit > INT64_MAX / f
555
+ warn_once(:overflow, units,
556
+ "variable #{var.name.inspect} has values too large to count " \
557
+ "in #{unit} for units #{units.inspect}; raw values returned")
558
+ return ca
559
+ end
560
+ ints = ints * f
561
+ end
562
+ check_reform(var, ints.time(unit: unit, origin: ref), shifted)
563
+ end
564
+
565
+ # With a pre-reform reference, an instant before the reform is a
566
+ # Julian date that CATime shows as proleptic Gregorian. Say so.
567
+ # @api private
568
+ private def check_reform(var, t, shifted)
569
+ if shifted && (t < reform_instant).any
570
+ warn_once(:reform, var.name,
571
+ "variable #{var.name.inspect} has times before 1582-10-15 " \
572
+ "in the mixed Julian/Gregorian calendar; they are shown as " \
573
+ "proleptic Gregorian, not as the Julian dates the file means")
574
+ end
575
+ t
419
576
  end
420
577
 
578
+ # @api private
579
+ private def reform_instant = (@reform_instant ||= CArray.time(["1582-10-15"], unit: :D)[0])
580
+
421
581
  # Length of each fixed-length unit, in ns, finest last. `:Y` / `:M`
422
582
  # have no fixed length, so a fraction of one can't be carried to a
423
583
  # finer unit.
@@ -436,8 +596,11 @@ module CA
436
596
  eps = vals.data_type == :float32 ? 1.2e-7 : 2.3e-16
437
597
  limit = vals.abs.max
438
598
  limit = 0.0 unless limit.is_a?(Numeric) # all masked
599
+ # Whole within the rounding error of the values themselves; an
600
+ # absolute floor would round away real fractions (50 ms of a day).
601
+ scale = [limit, 1.0].max
439
602
  whole = ->(x, f) { d = (x - x.round).abs.max
440
- !d.is_a?(Numeric) || d <= [1e-6, 4 * limit * f * eps].max }
603
+ !d.is_a?(Numeric) || d <= 4 * scale * f * eps }
441
604
  return [base, vals.round.int64] if whole.(vals, 1) || !NS_PER_UNIT.key?(base)
442
605
  best = [base, vals]
443
606
  NS_PER_UNIT.each_key.drop_while { |u| u != base }.drop(1).each do |u|
@@ -465,21 +628,52 @@ module CA
465
628
  "cannot encode CATime into variable #{var.name.inspect}: " \
466
629
  "units #{units.inspect} is not a CF time-since spec"
467
630
  count, base, ref = parsed
631
+ ref, shifted = mixed_calendar_reference(ref, calendar)
632
+ if shifted && (ca < reform_instant).any
633
+ raise ArgumentError,
634
+ "cannot encode CATime into variable #{var.name.inspect}: instants " \
635
+ "before 1582-10-15 would be Julian dates in its mixed calendar; " \
636
+ "use calendar: \"proleptic_gregorian\""
637
+ end
638
+ unit = origin_unit(base, ref) or
639
+ raise ArgumentError,
640
+ "cannot encode CATime into variable #{var.name.inspect}: " \
641
+ "the reference time of units #{units.inspect} is not on the #{base} grid"
642
+ return encode_calendar_time(var, ca, count, base, ref, units) unless NS_PER_UNIT.key?(base)
643
+ # A month or year instant is a real instant on a finer grid.
644
+ ca = ca.to_unit(unit) unless NS_PER_UNIT.key?(ca.unit.base)
468
645
  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
646
+ work = finer_unit?(fine, unit) ? fine : unit
647
+ # One `count × base` step, in `work` ticks.
648
+ f = NS_PER_UNIT[base] / NS_PER_UNIT[work] * count
649
+ ticks = ca.timesteps(unit: work, origin: ref)
650
+ return (f == 1 ? ticks : ticks.float64 / f) unless INTEGER_TYPES.include?(var.data_type)
651
+ return ticks if f == 1
652
+ unless (ticks % f).eq(0).all
653
+ raise ArgumentError,
654
+ "cannot encode CATime into integer variable #{var.name.inspect}: " \
655
+ "some instants fall between the steps of units #{units.inspect}"
656
+ end
657
+ ticks / f
658
+ end
659
+
660
+ # Encode into `months since` / `years since` units. A month or year
661
+ # has no fixed length, so an instant between two steps can't be
662
+ # written, even into a float variable, and raises.
663
+ # @api private
664
+ private def encode_calendar_time(var, ca, count, base, ref, units)
665
+ warn_once(:calendar_units, units,
666
+ "variable #{var.name.inspect} is written in #{units.inspect} as " \
667
+ "calendar months / years; xarray and cftime refuse such units under " \
668
+ "this calendar (UDUNITS takes a month as 30.436875 days), so other " \
669
+ "tools may not read it")
670
+ on_grid = ca.to_unit(base).to_unit(ca.unit.base)
671
+ unless on_grid.ticks.eq(ca.ticks).all
672
+ raise ArgumentError,
673
+ "cannot encode CATime into variable #{var.name.inspect}: " \
674
+ "some instants fall between the steps of units #{units.inspect}"
481
675
  end
482
- ticks = ca.timesteps(unit: base, origin: ref)
676
+ ticks = ca.to_unit(base).timesteps(unit: base, origin: ref)
483
677
  return ticks if count == 1
484
678
  unless (ticks % count).eq(0).all
485
679
  raise ArgumentError,
@@ -498,7 +692,7 @@ module CA
498
692
 
499
693
  # @api private
500
694
  private def supported_calendar?(cal)
501
- cal.nil? || SUPPORTED_CALENDARS.include?(cal.to_s)
695
+ cal.nil? || SUPPORTED_CALENDARS.include?(cal.to_s.strip.downcase)
502
696
  end
503
697
 
504
698
  # Per-instance cache (a new CF instance is built per
@@ -5,6 +5,9 @@ module CA
5
5
  class Dim
6
6
 
7
7
  include CA::NetCDF
8
+ # The low-level calls are this class's helpers, not its interface.
9
+ private :nc_get_var, :nc_get_vara, :nc_get_vars, :nc_get_varm,
10
+ :nc_put_var, :nc_put_vara, :nc_put_vars, :nc_put_varm
8
11
 
9
12
  # @param group [Group] containing group
10
13
  # @param dimid [Integer] NetCDF dimension id within `group`
@@ -17,11 +20,10 @@ module CA
17
20
  @name = nc_inq_dimname(@ncid, @dimid)
18
21
  # Whether a dim is unlimited is a define-time property and doesn't
19
22
  # 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
+ # dim's own group, which is where netcdf-4 keeps it; a NetCDF-4
24
+ # group can have several.
23
25
  @unlimited = if unlimited.nil?
24
- @dimid == (nc_inq_unlimdim(@ncid) rescue -1)
26
+ (nc_inq_unlimdims(@ncid) rescue []).include?(@dimid)
25
27
  else
26
28
  unlimited
27
29
  end
@@ -37,7 +39,7 @@ module CA
37
39
  # dim grows as writes come in, so a value cached at open time goes
38
40
  # stale.
39
41
  # @return [Integer]
40
- def length = nc_inq_dimlen(@ncid, @dimid)
42
+ def length = (@group.file.ensure_open; nc_inq_dimlen(@ncid, @dimid))
41
43
 
42
44
  # @return [Boolean] true if this is the unlimited dimension
43
45
  def unlimited? = @unlimited
@@ -60,6 +62,7 @@ module CA
60
62
 
61
63
  # @return [String]
62
64
  def inspect
65
+ return "#<#{self.class} #{@name} (closed)>" if @group.file.closed?
63
66
  "#<#{self.class} #{@name}=#{length}#{@unlimited ? '(unlimited)' : ''}>"
64
67
  end
65
68
  end