carray 3.0.4 → 3.0.6

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.
Files changed (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +438 -0
  3. data/README.md +1 -1
  4. data/carray.gemspec +3 -2
  5. data/ext/ca_array_pool.c +25 -1
  6. data/ext/ca_axis_dispatch.c +19 -20
  7. data/ext/ca_axis_group.c +1 -1
  8. data/ext/ca_categorical_iterator.c +34 -27
  9. data/ext/ca_compare.c +4 -4
  10. data/ext/ca_composite_dispatch.c +9 -9
  11. data/ext/ca_kernel_iterator.c +122 -237
  12. data/ext/ca_kernel_iterator.h +522 -337
  13. data/ext/ca_obj_array.c +69 -19
  14. data/ext/ca_obj_bincmp.c +10 -1
  15. data/ext/ca_obj_binop.c +11 -1
  16. data/ext/ca_obj_bitarray.c +5 -1
  17. data/ext/ca_obj_bitfield.c +60 -43
  18. data/ext/ca_obj_byte_swap.c +4 -1
  19. data/ext/ca_obj_const_string.c +15 -3
  20. data/ext/ca_obj_face.c +44 -1
  21. data/ext/ca_obj_fake.c +3 -1
  22. data/ext/ca_obj_field.c +13 -3
  23. data/ext/ca_obj_grid.c +219 -17
  24. data/ext/ca_obj_meld.c +69 -113
  25. data/ext/ca_obj_moncmp.c +9 -1
  26. data/ext/ca_obj_monop.c +86 -12
  27. data/ext/ca_obj_object.c +118 -32
  28. data/ext/ca_obj_record.c +3 -0
  29. data/ext/ca_obj_reduce.c +19 -1
  30. data/ext/ca_obj_refer.c +80 -26
  31. data/ext/ca_obj_remap.c +69 -10
  32. data/ext/ca_obj_roll.c +27 -6
  33. data/ext/ca_obj_select.c +38 -6
  34. data/ext/ca_obj_select_axis.c +1 -0
  35. data/ext/ca_obj_shift.c +32 -7
  36. data/ext/ca_obj_stack.c +311 -111
  37. data/ext/ca_obj_stride.c +159 -64
  38. data/ext/ca_obj_string.c +27 -2
  39. data/ext/ca_obj_tile.c +28 -7
  40. data/ext/ca_obj_transpose.c +8 -4
  41. data/ext/ca_obj_triop.c +12 -1
  42. data/ext/ca_obj_window.c +80 -50
  43. data/ext/ca_op_ipower.c +12 -12
  44. data/ext/ca_sort_kernels.h +6 -3
  45. data/ext/caframe_csv_reader.c +1 -1
  46. data/ext/carray.h +35 -1
  47. data/ext/carray_access.c +43 -67
  48. data/ext/carray_address_basis.c +134 -0
  49. data/ext/carray_attribute.c +32 -0
  50. data/ext/carray_broadcast.c +3 -4
  51. data/ext/carray_build_flags.h +1 -1
  52. data/ext/carray_cast.c +251 -93
  53. data/ext/carray_cast_func.rb +56 -26
  54. data/ext/carray_conversion.c +11 -7
  55. data/ext/carray_core.c +814 -72
  56. data/ext/carray_count.c +6 -0
  57. data/ext/carray_element.c +22 -13
  58. data/ext/carray_factorize.c +20 -20
  59. data/ext/carray_generate.c +8 -2
  60. data/ext/carray_histogram.c +26 -4
  61. data/ext/carray_index_classifier.c +4 -4
  62. data/ext/carray_internal.h +159 -12
  63. data/ext/carray_kernels_bincmp.c +2174 -170
  64. data/ext/carray_kernels_binop.c +25653 -5487
  65. data/ext/carray_kernels_init.c +8 -8
  66. data/ext/carray_kernels_moncmp.c +96 -96
  67. data/ext/carray_kernels_monop.c +12253 -5235
  68. data/ext/carray_kernels_reduce_aggregate.c +6150 -2423
  69. data/ext/carray_kernels_reduce_boolean.c +1533 -15
  70. data/ext/carray_kernels_reduce_cumulative.c +2442 -882
  71. data/ext/carray_kernels_reduce_extreme.c +5658 -1661
  72. data/ext/carray_kernels_reduce_variance.c +2534 -402
  73. data/ext/carray_kernels_scan.c +368 -368
  74. data/ext/carray_kernels_search.c +4551 -604
  75. data/ext/carray_kernels_sort.c +659 -751
  76. data/ext/carray_kernels_triop.c +114 -114
  77. data/ext/carray_key_windows.c +4 -0
  78. data/ext/carray_lazy.c +47 -0
  79. data/ext/carray_loop.c +1 -1
  80. data/ext/carray_mask.c +175 -161
  81. data/ext/carray_median_percentile.c +4 -4
  82. data/ext/carray_memory_view.c +15 -9
  83. data/ext/carray_operator.c +152 -49
  84. data/ext/carray_order.c +230 -10
  85. data/ext/carray_random.c +22 -19
  86. data/ext/carray_scatter.c +24 -7
  87. data/ext/carray_slab.c +128 -29
  88. data/ext/carray_sort.c +224 -16
  89. data/ext/carray_sort_kernel.c +5 -7
  90. data/ext/carray_struct.c +35 -43
  91. data/ext/carray_test.c +74 -19
  92. data/ext/carray_time_text.c +6 -4
  93. data/ext/carray_utils.c +63 -21
  94. data/ext/extconf.rb +11 -0
  95. data/ext/mkkernel.rb +546 -199
  96. data/ext/ruby_carray.c +4 -1
  97. data/ext/version.h +4 -4
  98. data/lib/carray/attribute.rb +42 -5
  99. data/lib/carray/autoload_carray.rb +3 -3
  100. data/lib/carray/autoload_method_extension.rb +5 -3
  101. data/lib/carray/axis_group.rb +13 -7
  102. data/lib/carray/basics.rb +28 -8
  103. data/lib/carray/block_iterator.rb +17 -3
  104. data/lib/carray/categorical.rb +35 -23
  105. data/lib/carray/categorical_iterator.rb +8 -8
  106. data/lib/carray/complex.rb +2 -2
  107. data/lib/carray/conditional.rb +3 -2
  108. data/lib/carray/const_string.rb +10 -0
  109. data/lib/carray/data_type_extension.rb +8 -1
  110. data/lib/carray/fixlen_string.rb +41 -2
  111. data/lib/carray/frame/column_verbs.rb +48 -16
  112. data/lib/carray/frame/concat.rb +17 -3
  113. data/lib/carray/frame/convert.rb +19 -0
  114. data/lib/carray/frame/csv_parser.rb +32 -32
  115. data/lib/carray/frame/describe.rb +4 -2
  116. data/lib/carray/frame/frame.rb +405 -85
  117. data/lib/carray/frame/group.rb +777 -64
  118. data/lib/carray/frame/io.rb +212 -72
  119. data/lib/carray/frame/join.rb +146 -37
  120. data/lib/carray/frame/records.rb +3 -3
  121. data/lib/carray/frame/reshape.rb +89 -11
  122. data/lib/carray/frame/sort.rb +57 -9
  123. data/lib/carray/frame/verbs.rb +384 -49
  124. data/lib/carray/frame/windows.rb +44 -6
  125. data/lib/carray/fuse_source.rb +31 -2
  126. data/lib/carray/inspect.rb +7 -2
  127. data/lib/carray/iterator.rb +32 -0
  128. data/lib/carray/lazy.rb +52 -8
  129. data/lib/carray/mask_gap_fill.rb +37 -17
  130. data/lib/carray/meld_reduce.rb +1 -1
  131. data/lib/carray/methods/choose.rb +3 -2
  132. data/lib/carray/methods/is_in.rb +8 -1
  133. data/lib/carray/methods/meshgrid.rb +3 -1
  134. data/lib/carray/runtime.rb +47 -8
  135. data/lib/carray/segment_iterator.rb +35 -16
  136. data/lib/carray/serialize.rb +53 -19
  137. data/lib/carray/stack.rb +33 -18
  138. data/lib/carray/string.rb +8 -0
  139. data/lib/carray/string_operation_extension.rb +13 -5
  140. data/lib/carray/struct_builder.rb +5 -3
  141. data/lib/carray/time.rb +112 -68
  142. data/lib/carray/window_iterator.rb +16 -4
  143. data/yard-stubs/ca_obj_array.rb +26 -1
  144. data/yard-stubs/ca_obj_transpose.rb +5 -4
  145. data/yard-stubs/carray_attribute.rb +3 -1
  146. data/yard-stubs/carray_cast.rb +10 -5
  147. data/yard-stubs/carray_conversion.rb +13 -3
  148. data/yard-stubs/carray_element.rb +7 -6
  149. data/yard-stubs/carray_mask.rb +28 -4
  150. data/yard-stubs/carray_math.rb +45 -20
  151. data/yard-stubs/carray_memory_view.rb +7 -0
  152. data/yard-stubs/carray_order.rb +100 -4
  153. data/yard-stubs/carray_scatter.rb +7 -5
  154. data/yard-stubs/carray_test.rb +7 -4
  155. metadata +18 -4
@@ -1,9 +1,11 @@
1
1
  # CAFrame column verbs (memo §11.4, §12-A, §13.2).
2
2
  #
3
- # Each verb touches this frame's own columns Hash (per-frame, §12-B) and
4
- # returns self so calls chain. Column data is still shared with any parent
5
- # (view-frame), but membership — which columns this frame names — is local:
6
- # append/drop/rename here never change a parent's column set.
3
+ # A verb that changes which columns the frame has, their names or their
4
+ # types returns a new frame and leaves this one as it is; a verb that edits
5
+ # cells (fill, mask_eq) edits them in place and returns self. The new frame
6
+ # shares the columns it did not replace, so a write to a cell through it
7
+ # reaches this frame too: only the table (names, index, column types) is its
8
+ # own.
7
9
 
8
10
  class CAFrame
9
11
  # Add (or replace) a column, returning a new frame — the column set changes,
@@ -49,11 +51,12 @@ class CAFrame
49
51
  end
50
52
 
51
53
  # Remove one or more columns, returning a new frame (column set changes,
52
- # memo §3.8). Column data is untouched and shared with the original.
54
+ # memo §3.8). Column data is untouched and shared with the original. Each
55
+ # argument is a name, a Range of names or a Regexp, as +select+ takes them.
53
56
  def drop(*names)
54
57
  cols = @columns.dup
55
- column_keys(names, "drop").each do |key|
56
- raise KeyError, "no column #{key.inspect}" unless cols.key?(key)
58
+ names_selected(names, "drop").each do |key|
59
+ refuse_missing_column(key) unless cols.key?(key)
57
60
  cols.delete(key)
58
61
  end
59
62
  rebuild(cols)
@@ -61,21 +64,87 @@ class CAFrame
61
64
 
62
65
  # Rename columns, returning a new frame (column names change, memo §3.8).
63
66
  # Column order is preserved (memo §13.2 "column order = insertion order");
64
- # columns are shared by reference.
65
- def rename(mapping)
67
+ # columns are shared by reference. The new names are given as a Hash of
68
+ # old name => new name, or by a block that receives each column's name and
69
+ # returns its new one. The index has a name too, and is renamed the same
70
+ # way. Names may be exchanged; two columns, or a column and the index, may
71
+ # not end up with one name.
72
+ #
73
+ # df.rename("temp" => "T")
74
+ # df.rename { |name| name.upcase }
75
+ # df.group_by("shop").sum.rename("shop" => "store") # the index
76
+ def rename(mapping = nil)
77
+ index_name = @index ? @axis_name : nil
78
+ if block_given?
79
+ raise ArgumentError, "rename: give a Hash or a block, not both" unless mapping.nil?
80
+ mapping = (@columns.keys + [index_name].compact).to_h { |name| [name, yield(name)] }
81
+ end
82
+ raise ArgumentError, "rename: give a Hash of old name => new name, or a block" if mapping.nil?
66
83
  norm = {}
67
84
  mapping.each do |old, new|
68
85
  o = column_key(old, "rename")
69
86
  n = column_name_arg(new, "rename")
70
- raise KeyError, "no column #{o.inspect}" unless @columns.key?(o)
71
- if n != o && @columns.key?(n)
72
- raise ArgumentError, "rename target #{n.inspect} already exists"
73
- end
87
+ refuse_missing_column(o, "rename") unless @columns.key?(o) || o == index_name
74
88
  norm[o] = n
75
89
  end
90
+ names = @columns.keys.map { |k| norm[k] || k }
91
+ names << (norm[index_name] || index_name) if index_name
92
+ CAFrame.__send__(:refuse_repeated_names, names, "rename", "the columns and index would be named")
76
93
  rebuilt = {}
77
94
  @columns.each { |k, v| rebuilt[norm[k] || k] = v }
78
- rebuild(rebuilt)
95
+ return rebuild(rebuilt) unless index_name && norm.key?(index_name)
96
+ CAFrame.new(rebuilt, axis_name: norm[index_name], index: @index, attrs: @attrs)
97
+ end
98
+
99
+ # A new frame with each column replaced by what the block computes from it,
100
+ # the names, the index and the attributes kept; this frame is left as it
101
+ # is. Without names every column is given to the block; names pick the
102
+ # columns as +select+ does (a name, a Range of names, a Regexp), and the
103
+ # others are kept as they are. The block receives the column and its name
104
+ # and returns a CArray (or an Array) of one value per row. A single value,
105
+ # such as a reduction's, is refused: to give every row its column's mean,
106
+ # use +mean(name, keep_rows: true)+.
107
+ #
108
+ # df.transform { |col| col.round(1) }
109
+ # df.transform("tmax".."tmin") { |col| (col - col.mean) / col.stddev }
110
+ # df.transform(/^t/) { |col, name| ... }
111
+ #
112
+ # What the block returns becomes the column as it is: a view stays a view
113
+ # of what it was made from, so a write to a cell reaches it (+copy+ cuts
114
+ # that).
115
+ #
116
+ # @param names [Array<String, Range, Regexp>] the columns; default all.
117
+ # @yieldparam col [CArray] a column.
118
+ # @yieldparam name [String] its name.
119
+ # @yieldreturn [CArray, Array] one value per row.
120
+ # @return [CAFrame]
121
+ # @raise [ArgumentError] without a block, or when the block gives a single
122
+ # value or a column of another length.
123
+ def transform (*names)
124
+ raise ArgumentError, "transform needs a block that computes each column" unless block_given?
125
+ keys = names.empty? ? @columns.keys : names_selected(names, "transform")
126
+ keys.each { |key| refuse_missing_column(key) unless @columns.key?(key) }
127
+ cols = @columns.dup
128
+ computed = keys.map do |key|
129
+ out = yield(@columns[key], key)
130
+ unless out.is_a?(CArray) || out.is_a?(Array)
131
+ raise ArgumentError,
132
+ "transform: the block gave #{out.class} for column #{key.inspect}, not a column; " \
133
+ "return one value per row (to give every row a summary, use " \
134
+ "a reduction with keep_rows: true)"
135
+ end
136
+ # An empty Array on a frame with no rows has no value to type it by,
137
+ # so the column keeps the type it was given as.
138
+ out = out.is_a?(Array) && out.empty? && @nrow == 0 ? @columns[key].copy : coerce_column(out)
139
+ unless out.ndim >= 1 && out.shape[0] == @nrow
140
+ raise ArgumentError,
141
+ "transform: the block gave #{out.ndim >= 1 ? out.shape[0] : "no"} rows for " \
142
+ "column #{key.inspect}; the frame has #{@nrow}"
143
+ end
144
+ [key, out]
145
+ end
146
+ computed.each { |key, out| cols[key] = out }
147
+ return rebuild(cols)
79
148
  end
80
149
 
81
150
  # Split a text column at +sep+ into the columns named by +into:+,
@@ -98,7 +167,7 @@ class CAFrame
98
167
  # fewer than two columns or one twice, or names a column already there.
99
168
  def split_column(name, sep, into:)
100
169
  key = column_key(name, "split_column")
101
- col = @columns.fetch(key) { raise KeyError, "no column #{key.inspect}" }
170
+ col = @columns.fetch(key) { refuse_missing_column(key) }
102
171
  unless string_column?(col) && col.ndim == 1
103
172
  raise ArgumentError, "split_column: #{key.inspect} is not a one-dimensional text column"
104
173
  end
@@ -159,7 +228,7 @@ class CAFrame
159
228
  # already a column; and as CACategorical#contrast raises.
160
229
  def contrast_columns(name, scheme = :dummy, base: nil, full: false, into: nil)
161
230
  key = column_key(name, "contrast_columns")
162
- col = @columns.fetch(key) { raise KeyError, "no column #{key.inspect}" }
231
+ col = @columns.fetch(key) { refuse_missing_column(key) }
163
232
  unless col.ndim == 1
164
233
  raise ArgumentError, "contrast_columns: #{key.inspect} is not a one-dimensional column"
165
234
  end
@@ -290,7 +359,7 @@ class CAFrame
290
359
  # @return [CAFrame] a new frame; the other columns are shared.
291
360
  def unstack_column(name, into: nil)
292
361
  key = column_key(name, "unstack_column")
293
- col = @columns.fetch(key) { raise KeyError, "no column #{key.inspect}" }
362
+ col = @columns.fetch(key) { refuse_missing_column(key) }
294
363
  if col.ndim < 2
295
364
  raise ArgumentError, "unstack_column: #{key.inspect} is one-dimensional; there is nothing to unstack"
296
365
  end
@@ -350,15 +419,15 @@ class CAFrame
350
419
  unless from.is_a?(String) && to.is_a?(String)
351
420
  raise ArgumentError, "#{verb}: a Range of column names takes two names (got #{selection.inspect})"
352
421
  end
353
- i = keys.index(from) or raise KeyError, "#{verb}: no column #{from.inspect}"
354
- j = keys.index(to) or raise KeyError, "#{verb}: no column #{to.inspect}"
422
+ i = keys.index(from) or refuse_missing_column(from, verb)
423
+ j = keys.index(to) or refuse_missing_column(to, verb)
355
424
  if j < i
356
425
  raise ArgumentError, "#{verb}: #{to.inspect} comes before #{from.inspect} in the frame"
357
426
  end
358
427
  keys[i..(selection.exclude_end? ? j - 1 : j)]
359
428
  when Array
360
429
  selection.map { |n| column_key(n, verb) }.each do |n|
361
- raise KeyError, "#{verb}: no column #{n.inspect}" unless @columns.key?(n)
430
+ refuse_missing_column(n, verb) unless @columns.key?(n)
362
431
  end
363
432
  when Regexp
364
433
  keys.grep(selection)
@@ -389,9 +458,11 @@ class CAFrame
389
458
  # column cast to :time is parsed into a CATime column, in the finest unit
390
459
  # its text shows (:D for dates alone, :s with a time of day, :ms / :us /
391
460
  # :ns for fractions of a second); parse_to_time takes a format and a
392
- # unit. A column cast to :categorical becomes a CACategorical of its
461
+ # unit. A text column cast to :timedelta reads the text to_csv writes for
462
+ # a duration ("194400s", "3M", "3 @ 10 m") into a CATimedelta column, in
463
+ # the finest unit its cells show. A column cast to :categorical becomes a CACategorical of its
393
464
  # values, the labels in the order they first appear (categorize); a text
394
- # column read by from_csv with types: gets there without a String made per
465
+ # column read by read_csv with types: gets there without a String made per
395
466
  # cell. Other targets use to_type.
396
467
  # The call shapes are told apart by the fact that column names are always
397
468
  # Strings and types always Symbols (§3.7):
@@ -416,8 +487,9 @@ class CAFrame
416
487
  #
417
488
  # A name that is not a column raises KeyError, and a Symbol key other than
418
489
  # +:default+ raises TypeError; both before any column is read. Every
419
- # column is read before any is rebound, so a raise leaves the frame as it
420
- # was. Returns self so calls chain.
490
+ # column is read before any is converted. Returns a new frame holding the
491
+ # converted columns; this frame keeps its own, and the columns not named are
492
+ # shared by the two.
421
493
  #
422
494
  # +on_error:+ says what to do with a text cell that holds something but
423
495
  # does not read as the number type ("x", "1.5" for an integer, "300" for
@@ -431,12 +503,16 @@ class CAFrame
431
503
  # cell; no column is rebound
432
504
  #
433
505
  # +option_name:+ is for a method that takes the map as one of its own
434
- # options and hands it to cast, as from_csv does with +types:+. An error
506
+ # options and hands it to cast, as read_csv does with +types:+. An error
435
507
  # about the map then names that option instead of cast, and the map must
436
508
  # be a Hash or :infer:
437
509
  #
438
510
  # frame.cast(types, on_error: on_error, option_name: :types)
439
- def cast(name_or_map = nil, type = nil, on_error: :mask, option_name: nil, **map)
511
+ def cast(...)
512
+ return dup.__send__(:cast_in_place, ...)
513
+ end
514
+
515
+ private def cast_in_place(name_or_map = nil, type = nil, on_error: :mask, option_name: nil, **map)
440
516
  refuse_if_frozen
441
517
  label = option_name ? "#{option_name}:" : "cast"
442
518
  # A brace-less map (cast("temp" => :float64, on_error: :warn)) arrives as
@@ -460,8 +536,9 @@ class CAFrame
460
536
  "on_error: must be :mask, :warn or :raise (got #{on_error.inspect})"
461
537
  end
462
538
  name_or_map = { default: :infer } if name_or_map == :infer && type.nil?
539
+ name_or_map = { default: :string } if name_or_map == :string && type.nil?
463
540
  if option_name && !name_or_map.is_a?(Hash)
464
- raise ArgumentError, "#{label} takes a map of column types or :infer " \
541
+ raise ArgumentError, "#{label} takes a map of column types, :infer or :string " \
465
542
  "(got #{name_or_map.inspect})"
466
543
  end
467
544
  types =
@@ -500,7 +577,7 @@ class CAFrame
500
577
  end
501
578
  end
502
579
  named.each_key do |name|
503
- raise KeyError, "#{label} names no column #{name.inspect}" unless @columns.key?(name)
580
+ refuse_missing_column(name, label.chomp(":")) unless @columns.key?(name)
504
581
  end
505
582
  rest = @columns.keys - named.keys
506
583
  types =
@@ -554,9 +631,8 @@ class CAFrame
554
631
  # Bring every column to one common data type and rebind them (memo §11.4).
555
632
  # The frame-level counterpart of +CArray.promote_list+: where +cast+ forces
556
633
  # named columns to a type, +promote+ widens the whole table until it has a
557
- # single type. Returns self so calls chain (§3.8 -- a type-interpretation
558
- # change), and like +cast+ it allocates fresh columns, so a parent frame
559
- # sharing the old ones is untouched. The index is not a column and is left
634
+ # single type. Returns a new frame; like +cast+ it allocates fresh columns,
635
+ # so this frame keeps its own. The index is not a column and is left
560
636
  # alone.
561
637
  #
562
638
  # df.promote # the common type CArray.result_type would pick
@@ -574,14 +650,19 @@ class CAFrame
574
650
  # and therefore always accepted -- it is the way a frame mixing text,
575
651
  # Face-typed and numeric columns becomes a single-type table (and so the
576
652
  # way +to_ca+ can hand back a matrix for it).
577
- def promote(type = nil)
653
+ def promote(...)
654
+ return dup.__send__(:promote_in_place, ...)
655
+ end
656
+
657
+ private def promote_in_place(type = nil)
578
658
  refuse_if_frozen
579
659
  return self if @columns.empty?
580
660
  type.nil? ? promote_to_common : promote_to_type(type)
581
661
  self
582
662
  end
583
663
 
584
- # Parse a string column into a time column and rebind it (memo §11.2).
664
+ # A new frame with a string column parsed into a time column (memo §11.2);
665
+ # this frame keeps the text column.
585
666
  # The column must be string-bearing (an object CArray of Strings, or a
586
667
  # CAString / CAConstString / CAFixlenString) — this is the "text -> time"
587
668
  # mode, distinct from the integer-serial mode of +to_time+.
@@ -603,14 +684,18 @@ class CAFrame
603
684
  # df.parse_to_time("time").set_index("time")
604
685
  # df.parse_to_time("date", "%d/%m/%Y")
605
686
  # df.parse_to_time("date", :infer)
606
- def parse_to_time(name, format = nil, unit: nil, on_error: :mask)
687
+ def parse_to_time(...)
688
+ return dup.__send__(:parse_to_time_in_place, ...)
689
+ end
690
+
691
+ private def parse_to_time_in_place(name, format = nil, unit: nil, on_error: :mask)
607
692
  refuse_if_frozen
608
693
  unless CAST_ON_ERROR.include?(on_error)
609
694
  raise ArgumentError,
610
695
  "on_error: must be :mask, :warn or :raise (got #{on_error.inspect})"
611
696
  end
612
697
  key = column_key(name, "parse_to_time")
613
- col = @columns.fetch(key) { raise KeyError, "no column #{key.inspect}" }
698
+ col = @columns.fetch(key) { refuse_missing_column(key) }
614
699
  unless string_column?(col)
615
700
  raise ArgumentError,
616
701
  "parse_to_time needs a string column (object / CAString / " \
@@ -632,15 +717,15 @@ class CAFrame
632
717
  # df.infer_time_format("date") # => "%d/%m/%Y"
633
718
  def infer_time_format(name)
634
719
  key = column_key(name, "infer_time_format")
635
- col = @columns.fetch(key) { raise KeyError, "no column #{key.inspect}" }
720
+ col = @columns.fetch(key) { refuse_missing_column(key) }
636
721
  unless string_column?(col)
637
722
  raise ArgumentError, "column #{key.inspect} is #{col.data_type}, not text"
638
723
  end
639
724
  inferred_time_format(key, col)&.first&.format
640
725
  end
641
726
 
642
- # Reinterpret an integer column as time serial counts and rebind it (memo
643
- # §11.2). This is the "serial -> time" mode: each value is a count of
727
+ # A new frame with an integer column read as time serial counts (memo
728
+ # §11.2); this frame keeps the integer column. This is the "serial -> time" mode: each value is a count of
644
729
  # +unit+ resolution since +epoch+ (default the Unix epoch, 1970-01-01 UTC).
645
730
  # +epoch+ takes any time literal (String / Time / Integer), so a column
646
731
  # measured from another origin — a netCDF "hours since 1990-01-01" axis, an
@@ -660,10 +745,14 @@ class CAFrame
660
745
  # df.to_time("time", unit: :h, epoch: "1990-01-01").set_index("time")
661
746
  # df.to_time("time", CATime::Grid.parse("hours since 1990-01-01"))
662
747
  # df.to_time("time", CATime::Grid.parse("days since 1980-01-01 12:00"))
663
- def to_time(name, grid = nil, unit: :s, epoch: nil)
748
+ def to_time(...)
749
+ return dup.__send__(:to_time_in_place, ...)
750
+ end
751
+
752
+ private def to_time_in_place(name, grid = nil, unit: :s, epoch: nil)
664
753
  refuse_if_frozen
665
754
  key = column_key(name, "to_time")
666
- col = @columns.fetch(key) { raise KeyError, "no column #{key.inspect}" }
755
+ col = @columns.fetch(key) { refuse_missing_column(key) }
667
756
  raw = integer_serial_column(col, key)
668
757
  grid = unit if unit.is_a?(CATime::Grid)
669
758
  if grid.is_a?(CATime::Grid)
@@ -698,10 +787,27 @@ class CAFrame
698
787
  # CATimedelta) column. A categorical column's codes are read-only
699
788
  # (memo §13.4), so any fill on it raises — rebind a filled copy instead:
700
789
  # `df.append(name, df[name].strip_mask(method: :forward))`.
701
- def fill(name, method_or_value)
790
+ #
791
+ # Several columns, or every one, are filled the same way: the method or
792
+ # value comes last, after the columns as +select+ takes them; alone, it
793
+ # fills every column that has a masked cell, as +strip_mask+ does. A Hash
794
+ # fills several columns, each its own way. Every column is checked before
795
+ # any is filled:
796
+ #
797
+ # fill("temp", "wind", :ffill)
798
+ # fill(:numeric, 0)
799
+ # fill(:ffill) # every column with a masked cell
800
+ # fill("temp" => :linear, "wind" => 0.0)
801
+ def fill(*args)
802
+ plan = fill_arguments(args)
803
+ return self if plan.nil?
804
+ if plan.is_a?(Hash)
805
+ return fill_by_map(plan, "fill") { |frame, key, how| frame.fill(key, how) }
806
+ end
807
+ name, method_or_value = plan
702
808
  refuse_if_frozen
703
809
  key = column_key(name, "fill")
704
- raise KeyError, "no column #{key.inspect}" unless @columns.key?(key)
810
+ refuse_missing_column(key) unless @columns.key?(key)
705
811
  col = @columns[key]
706
812
  # An N-D column is filled down the rows: each position on its trailing
707
813
  # axes is a series of its own.
@@ -715,17 +821,183 @@ class CAFrame
715
821
  "fill: unknown method #{method_or_value.inspect} " \
716
822
  "(:ffill | :bfill | :linear, or a constant fill value)"
717
823
  else
718
- col.unmask(method_or_value) # constant fill, in place
824
+ fill_constant(key, col, method_or_value)
719
825
  end
720
826
  self
721
827
  end
722
828
 
723
- private def fill_linear_column(key, col)
829
+ # A constant fill, in place, naming the column when the value does not go
830
+ # into it, as strip_mask names it.
831
+ private def fill_constant (key, col, value)
832
+ col.unmask(value)
833
+ rescue ArgumentError, TypeError, RangeError, FloatDomainError => e
834
+ raise e.exception("fill: column #{key.inspect} (#{CArray::Inspector.type_label(col)}) " \
835
+ "cannot take #{value.inspect}: #{e.message}")
836
+ end
837
+
838
+ # A frame with the masked cells filled, this frame left as it is: the
839
+ # sibling of +fill+ that returns a new frame, as CArray#strip_mask is of
840
+ # CArray#unmask. The value or the method is given as CArray#strip_mask
841
+ # takes it, for every column or, with a Hash, column by column:
842
+ #
843
+ # df.strip_mask(0.0) # every column, one value
844
+ # df.strip_mask("temp" => Float::NAN, "n" => -1)
845
+ # df.strip_mask(method: :forward) # every column, one method
846
+ # df.strip_mask(method: { "temp" => :linear, "station" => :forward })
847
+ #
848
+ # A positional Symbol is a value, as in CArray#strip_mask. The methods are
849
+ # :forward and :backward (carry the last or the next present value down the
850
+ # rows; :ffill and :bfill are the same) and :linear, which interpolates
851
+ # against this frame's index when it has one, as +fill+ does, and against
852
+ # the row position when it has none. An N-D column is filled down the rows.
853
+ # A cell a method cannot reach (before the first present value, say) stays
854
+ # masked.
855
+ #
856
+ # The forms for every column fill the columns that have a masked cell and
857
+ # leave the others alone, so one value serves a frame whose complete
858
+ # columns could not hold it (NaN beside a complete integer column). A
859
+ # column named in a Hash is filled whatever it holds. A column that cannot
860
+ # take its value is refused, by name, and no frame is returned.
861
+ #
862
+ # The filled columns are new arrays; the others are this frame's own, shared
863
+ # as +select+ shares them. The index is not filled.
864
+ #
865
+ # @return [CAFrame]
866
+ # @raise [KeyError] for a name that is no column.
867
+ # @raise [ArgumentError] for neither or both of a value and method:, or an
868
+ # unknown method.
869
+ def strip_mask (fill = STRIP_MASK_UNSET, method: nil, **named)
870
+ # df.strip_mask("x" => 0.0) arrives as keywords: the String keys are
871
+ # column names, never an option.
872
+ unless named.empty?
873
+ unless fill.equal?(STRIP_MASK_UNSET)
874
+ raise ArgumentError, "strip_mask: give a fill value or a Hash of them, not both"
875
+ end
876
+ fill = named
877
+ end
878
+ given = !fill.equal?(STRIP_MASK_UNSET)
879
+ if given && method
880
+ raise ArgumentError, "strip_mask: pass either a fill value or method:, not both"
881
+ end
882
+ unless given || method
883
+ raise ArgumentError, "strip_mask: a fill value is required (or method:)"
884
+ end
885
+ how = given ? fill : method
886
+ plan = if how.is_a?(Hash)
887
+ raise ArgumentError, "strip_mask: the Hash names no column" if how.empty?
888
+ keys = column_keys(how.keys, "strip_mask")
889
+ keys.each { |key| refuse_missing_column(key) unless @columns.key?(key) }
890
+ keys.zip(how.values)
891
+ else
892
+ @columns.select { |_, col| col.any_masked? }.keys.map { |key| [key, how] }
893
+ end
894
+ unless given
895
+ plan.each do |_, m|
896
+ next if STRIP_MASK_METHODS.include?(m)
897
+ raise ArgumentError,
898
+ "strip_mask: unknown method #{m.inspect} (:forward | :backward | :linear)"
899
+ end
900
+ end
901
+ cols = @columns.dup
902
+ plan.each do |key, h|
903
+ cols[key] = given ? strip_mask_by_value(key, @columns[key], h)
904
+ : strip_mask_by_method(key, @columns[key], h)
905
+ end
906
+ return rebuild(cols)
907
+ end
908
+
909
+ STRIP_MASK_UNSET = ::Object.new
910
+ Ractor.make_shareable(STRIP_MASK_UNSET)
911
+ private_constant :STRIP_MASK_UNSET
912
+
913
+ STRIP_MASK_METHODS = %i[forward backward ffill bfill linear].freeze
914
+ private_constant :STRIP_MASK_METHODS
915
+
916
+ private def strip_mask_by_value (key, col, value)
917
+ return col.strip_mask(value)
918
+ rescue ArgumentError, TypeError, RangeError, FloatDomainError => e
919
+ raise e.exception("strip_mask: column #{key.inspect} (#{CArray::Inspector.type_label(col)}) " \
920
+ "cannot take #{value.inspect}: #{e.message}")
921
+ end
922
+
923
+ private def strip_mask_by_method (key, col, method)
924
+ if method == :linear && @index
925
+ out = col.copy
926
+ fill_linear_column(key, out, "strip_mask")
927
+ return out
928
+ end
929
+ begin
930
+ return col.strip_mask(method: method, **(col.ndim > 1 ? { axis: 0 } : {}))
931
+ rescue ArgumentError, TypeError => e
932
+ raise e.exception("strip_mask: column #{key.inspect}: #{e.message}")
933
+ end
934
+ end
935
+
936
+ private def linear_call (verb, key)
937
+ return "fill(#{key.inspect}, :linear)" if verb == "fill"
938
+ return "strip_mask(method: :linear) of column #{key.inspect}"
939
+ end
940
+
941
+ # A fill Hash, name => method or value. Each column is filled first on a
942
+ # copy, in a frame holding the copy, by the block (frame, name, how) -- so
943
+ # every check a single fill makes applies -- and the stored columns are
944
+ # written only when every one has passed: a refusal leaves them all as
945
+ # they were. A read-only column is given to the block as it is, to refuse
946
+ # as a single fill does before anything is written.
947
+ # What fill's arguments ask for: [name, how] for one column, or a Hash of
948
+ # name => how for several (the columns named before the method or value,
949
+ # or, with none named, every column with a masked cell, +key_names+ left
950
+ # out; nil when there is none).
951
+ private def fill_arguments (args, key_names = [])
952
+ return args.first if args.size == 1 && args.first.is_a?(Hash)
953
+ if args.empty? || args.first.is_a?(Hash)
954
+ raise ArgumentError,
955
+ "fill: give the columns and a method or value, a method or value alone, " \
956
+ "or one Hash of them"
957
+ end
958
+ *names, how = args
959
+ if names.empty?
960
+ if how.is_a?(String)
961
+ raise ArgumentError,
962
+ "fill: give a method or value after the column #{how.inspect}; " \
963
+ "a String alone is a column name"
964
+ end
965
+ map = @columns.select { |key, col| !key_names.include?(key) && col.any_masked? }
966
+ .keys.to_h { |key| [key, how] }
967
+ return map.empty? ? nil : map
968
+ end
969
+ return [names.first, how] if names.size == 1 && names.first.is_a?(String)
970
+ return names_selected(names, "fill").to_h { |key| [key, how] }
971
+ end
972
+
973
+ private def fill_by_map (map, verb)
974
+ raise ArgumentError, "#{verb}: the Hash names no column" if map.empty?
975
+ refuse_if_frozen
976
+ keys = column_keys(map.keys, verb)
977
+ keys.each { |key| refuse_missing_column(key) unless @columns.key?(key) }
978
+ map.each_value do |how|
979
+ next unless how.is_a?(Symbol) && !%i[ffill bfill linear].include?(how)
980
+ raise ArgumentError,
981
+ "#{verb}: unknown method #{how.inspect} (:ffill | :bfill | :linear, or a constant fill value)"
982
+ end
983
+ pairs = keys.zip(map.values)
984
+ pairs.each { |key, how| yield self, key, how if @columns[key].read_only? }
985
+ filled = pairs.map do |key, how|
986
+ next if @columns[key].read_only?
987
+ probe = rebuild(@columns.merge(key => @columns[key].copy))
988
+ yield probe, key, how
989
+ [key, probe[key]]
990
+ end
991
+ filled.compact.each { |key, col| @columns[key][] = col }
992
+ return self
993
+ end
994
+
995
+ private def fill_linear_column(key, col, verb = "fill")
724
996
  # A Face column interpolates through its own linear_fetch (a time
725
997
  # column's keeps the Face and its unit), as the core gap fill does.
726
998
  unless col.__send__(:interpolable?)
727
999
  raise ArgumentError,
728
- "fill(#{key.inspect}, :linear): numeric or time column required " \
1000
+ "#{linear_call(verb, key)}: numeric or time column required " \
729
1001
  "(got #{col.data_type_name})"
730
1002
  end
731
1003
  # Without an index coordinate x is the cell position, which is exactly what
@@ -736,16 +1008,16 @@ class CAFrame
736
1008
  unless @index.numeric? || @index.is_a?(CATime) || @index.is_a?(CATimedelta)
737
1009
  along = col.ndim > 1 ? ", axis: 0" : ""
738
1010
  raise ArgumentError,
739
- "fill(#{key.inspect}, :linear): the index #{@axis_name.inspect} is " \
1011
+ "#{linear_call(verb, key)}: the index #{@axis_name.inspect} is " \
740
1012
  "#{@index.data_type_name}, not numeric or time, so it cannot be the x of " \
741
1013
  "the interpolation; to interpolate by row position, use " \
742
- "df[#{key.inspect}].unmask(method: :linear#{along})"
1014
+ "df[#{key.inspect}].#{verb == "fill" ? "unmask" : "strip_mask"}(method: :linear#{along})"
743
1015
  end
744
1016
  # With the index as x, an N-D column is filled one trailing position at a
745
1017
  # time; each position is a 1-D view, so the fill writes through to it.
746
1018
  if col.ndim > 1
747
1019
  col.shape[1..].map { |d| (0...d).to_a }.inject { |a, b| a.product(b).map { |e| Array(e).flatten } }
748
- .each { |pos| fill_linear_column(key, col[nil, *Array(pos)]) }
1020
+ .each { |pos| fill_linear_column(key, col[nil, *Array(pos)], verb) }
749
1021
  return
750
1022
  end
751
1023
  # The same fill as the core, with the index as x.
@@ -757,8 +1029,20 @@ class CAFrame
757
1029
  # are not numbers). Other targets go through to_type.
758
1030
  private def cast_column(name, type, on_error)
759
1031
  key = name.to_s
760
- col = @columns.fetch(key) { raise KeyError, "no column #{key.inspect}" }
1032
+ col = @columns.fetch(key) { refuse_missing_column(key) }
761
1033
  return cast_time_column(key, col, on_error) if type == :time
1034
+ return cast_timedelta_column(key, col, on_error) if type == :timedelta
1035
+ # Text stays text, an object column of Strings becomes a CAString, and
1036
+ # anything else is refused as CArray.string refuses it (a number is
1037
+ # written as text with #format).
1038
+ if type == :string
1039
+ return col if col.is_a?(CArray::StringOperationMixin)
1040
+ begin
1041
+ return CArray.string(col)
1042
+ rescue CArray::DataTypeError, TypeError => e
1043
+ raise e.class, "column #{key.inspect} cannot be cast to :string: #{e.message}"
1044
+ end
1045
+ end
762
1046
  if type == :categorical
763
1047
  return col.is_a?(CACategorical) ? col : col.categorize
764
1048
  end
@@ -849,6 +1133,57 @@ class CAFrame
849
1133
  parse_time_column(key, col, nil, nil, on_error)
850
1134
  end
851
1135
 
1136
+ # cast(name => :timedelta): the text #to_csv writes for a duration -- a
1137
+ # signed integer and its unit ("194400s", "3M"), or "<n> @ <count> <unit>"
1138
+ # for a unit of several base ticks ("3 @ 10 m") -- to CATimedelta. Cells in
1139
+ # different units are brought to the finest of them; a column that mixes
1140
+ # calendar units (Y, M) with fixed-length ones has no common unit.
1141
+ private def cast_timedelta_column(key, col, on_error)
1142
+ return col if col.is_a?(CATimedelta)
1143
+ unless string_column?(col)
1144
+ raise ArgumentError,
1145
+ "cast: column #{key.inspect} is #{col.data_type}, not text; " \
1146
+ "#timedelta(unit:) reads a column of counts as durations"
1147
+ end
1148
+ cells = time_text_of(col).flatten
1149
+ present = cells.is_not_masked
1150
+ values = Array.new(cells.elements)
1151
+ units = Array.new(cells.elements)
1152
+ bad = []
1153
+ cells.elements.times do |addr|
1154
+ next unless present[addr]
1155
+ text = cells[addr]
1156
+ m = text.is_a?(String) && TIMEDELTA_TEXT.match(text)
1157
+ if m
1158
+ values[addr] = Integer(m[1], 10)
1159
+ units[addr] = CATime::Resolution.new(m[2] ? Integer(m[2], 10) : 1, m[3].to_sym)
1160
+ elsif !text.nil? && !(text.is_a?(String) && text.strip.empty?)
1161
+ bad << addr
1162
+ end
1163
+ end
1164
+ report_unreadable(key, col, :timedelta, bad, on_error) unless bad.empty? || on_error == :mask
1165
+ distinct = units.compact.uniq
1166
+ calendar = distinct.partition { |u| %i[Y M].include?(u.base) }
1167
+ unless calendar.all?(&:empty?) || calendar.any?(&:empty?)
1168
+ raise ArgumentError,
1169
+ "cast: column #{key.inspect} mixes calendar units (Y, M) with " \
1170
+ "fixed-length ones (#{distinct.map(&:to_s).join(', ')}); " \
1171
+ "they have no common unit"
1172
+ end
1173
+ unit = distinct.reduce { |a, b| CATimeUnitAlgebra.finer(a, b) } || CATime::Resolution.new(1, :s)
1174
+ ticks = CArray.int64(cells.elements)
1175
+ ticks[nil] = UNDEF
1176
+ distinct.each do |u|
1177
+ addrs = units.each_index.select { |i| units[i] == u }
1178
+ scaled = CATimeUnitAlgebra.convert_scale!(CA_INT64(addrs.map { |i| values[i] }), u, unit)
1179
+ ticks[CA_INT64(addrs)] = scaled
1180
+ end
1181
+ ticks.reshape(*col.shape).timedelta(unit: unit)
1182
+ end
1183
+
1184
+ TIMEDELTA_TEXT = /\A\s*([-+]?\d+)\s*(?:@\s*(\d+)\s+)?(Y|M|W|D|h|ms|us|ns|ps|fs|as|m|s)\s*\z/
1185
+ private_constant :TIMEDELTA_TEXT
1186
+
852
1187
  # Text to CATime. Without a format only year-first text is read; a
853
1188
  # strptime format, or :mixed for a guess at each cell, goes through
854
1189
  # CArray.time. Cells that hold something but do not parse are reported as