carray 3.0.2 → 3.0.4

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 (247) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +568 -0
  3. data/README.md +6 -6
  4. data/carray.gemspec +1 -1
  5. data/ext/ca_axis_descriptor.h +27 -24
  6. data/ext/ca_axis_dispatch.c +220 -0
  7. data/ext/ca_axis_group.c +201 -123
  8. data/ext/ca_binop_dispatch.c +46 -52
  9. data/ext/ca_binop_dispatch.h +1 -1
  10. data/ext/ca_categorical_iterator.c +249 -183
  11. data/ext/ca_compare.c +7 -12
  12. data/ext/ca_for_buffer.h +21 -68
  13. data/ext/ca_group_iter.c +38 -23
  14. data/ext/ca_kernel_iterator.c +1765 -2076
  15. data/ext/ca_kernel_iterator.h +584 -566
  16. data/ext/ca_monop_dispatch.c +86 -63
  17. data/ext/ca_monop_dispatch.h +5 -0
  18. data/ext/ca_obj_array.c +223 -56
  19. data/ext/ca_obj_bincmp.c +172 -138
  20. data/ext/ca_obj_binop.c +116 -230
  21. data/ext/ca_obj_bitarray.c +40 -26
  22. data/ext/ca_obj_bitfield.c +100 -68
  23. data/ext/ca_obj_block.c +19 -9
  24. data/ext/ca_obj_byte_swap.c +45 -46
  25. data/ext/ca_obj_const_string.c +269 -44
  26. data/ext/ca_obj_face.c +612 -89
  27. data/ext/ca_obj_face.h +111 -47
  28. data/ext/ca_obj_fake.c +36 -79
  29. data/ext/ca_obj_farray.c +7 -3
  30. data/ext/ca_obj_field.c +33 -8
  31. data/ext/ca_obj_fixlen_string.c +23 -24
  32. data/ext/ca_obj_grid.c +541 -210
  33. data/ext/ca_obj_meld.c +331 -111
  34. data/ext/ca_obj_moncmp.c +80 -65
  35. data/ext/ca_obj_monop.c +177 -149
  36. data/ext/ca_obj_object.c +664 -248
  37. data/ext/ca_obj_record.c +63 -23
  38. data/ext/ca_obj_reduce.c +18 -16
  39. data/ext/ca_obj_refer.c +140 -58
  40. data/ext/ca_obj_remap.c +85 -24
  41. data/ext/ca_obj_repeat.c +24 -9
  42. data/ext/ca_obj_roll.c +121 -16
  43. data/ext/ca_obj_select.c +61 -58
  44. data/ext/ca_obj_select_axis.c +73 -107
  45. data/ext/ca_obj_shift.c +32 -102
  46. data/ext/ca_obj_stack.c +372 -122
  47. data/ext/ca_obj_stride.c +348 -140
  48. data/ext/ca_obj_string.c +88 -22
  49. data/ext/ca_obj_tile.c +38 -21
  50. data/ext/ca_obj_time.c +45 -31
  51. data/ext/ca_obj_timedelta.c +43 -29
  52. data/ext/ca_obj_transpose.c +17 -6
  53. data/ext/ca_obj_triop.c +125 -141
  54. data/ext/ca_obj_window.c +330 -44
  55. data/ext/ca_op_ipower.c +24 -11
  56. data/ext/ca_op_powi.h +57 -30
  57. data/ext/ca_pow5_table.h +656 -0
  58. data/ext/ca_sort_kernels.h +70 -0
  59. data/ext/ca_sweep_engine.c +104 -81
  60. data/ext/ca_sweep_engine.h +7 -20
  61. data/ext/ca_triop_dispatch.c +4 -1
  62. data/ext/ca_triop_dispatch.h +5 -2
  63. data/ext/caframe_csv_reader.c +542 -0
  64. data/ext/carray.h +241 -92
  65. data/ext/carray_access.c +608 -276
  66. data/ext/carray_address_basis.c +494 -97
  67. data/ext/carray_attribute.c +129 -4
  68. data/ext/carray_bincount.c +39 -7
  69. data/ext/carray_broadcast.c +56 -19
  70. data/ext/carray_build_flags.h +1 -1
  71. data/ext/carray_call_cfunc.c +108 -28
  72. data/ext/carray_cast.c +1576 -223
  73. data/ext/carray_cast_func.rb +77 -24
  74. data/ext/carray_class.c +4 -3
  75. data/ext/carray_conversion.c +208 -75
  76. data/ext/carray_copy.c +82 -24
  77. data/ext/carray_core.c +646 -162
  78. data/ext/carray_count.c +171 -88
  79. data/ext/carray_element.c +94 -60
  80. data/ext/carray_factorize.c +670 -324
  81. data/ext/carray_generate.c +260 -83
  82. data/ext/carray_histogram.c +209 -106
  83. data/ext/carray_hold.c +14 -16
  84. data/ext/carray_index_classifier.c +422 -243
  85. data/ext/carray_internal.h +692 -3
  86. data/ext/carray_kernels_bincmp.c +7 -0
  87. data/ext/carray_kernels_binop.c +487 -120
  88. data/ext/carray_kernels_init.c +653 -50
  89. data/ext/carray_kernels_moncmp.c +15 -8
  90. data/ext/carray_kernels_monop.c +99 -43
  91. data/ext/carray_kernels_reduce_aggregate.c +3925 -6112
  92. data/ext/carray_kernels_reduce_boolean.c +112 -36
  93. data/ext/carray_kernels_reduce_cumulative.c +2127 -6903
  94. data/ext/carray_kernels_reduce_extreme.c +3032 -2892
  95. data/ext/carray_kernels_reduce_variance.c +439 -500
  96. data/ext/carray_kernels_scan.c +1097 -488
  97. data/ext/carray_kernels_search.c +3787 -2176
  98. data/ext/carray_kernels_sort.c +1200 -920
  99. data/ext/carray_kernels_triop.c +479 -7
  100. data/ext/carray_key_windows.c +216 -0
  101. data/ext/carray_lazy.c +967 -153
  102. data/ext/carray_loop.c +81 -65
  103. data/ext/carray_mask.c +910 -324
  104. data/ext/carray_math_kernel.h +1 -0
  105. data/ext/carray_median_percentile.c +299 -100
  106. data/ext/carray_memory_view.c +872 -758
  107. data/ext/carray_operator.c +1171 -364
  108. data/ext/carray_order.c +505 -133
  109. data/ext/carray_partition.c +112 -65
  110. data/ext/carray_random.c +327 -239
  111. data/ext/carray_round.c +273 -0
  112. data/ext/carray_scatter.c +346 -78
  113. data/ext/carray_segment.c +257 -0
  114. data/ext/carray_slab.c +249 -86
  115. data/ext/carray_sort.c +224 -227
  116. data/ext/carray_sort_kernel.c +8 -1
  117. data/ext/carray_struct.c +118 -39
  118. data/ext/carray_test.c +193 -69
  119. data/ext/carray_time_text.c +1182 -0
  120. data/ext/carray_undef.c +4 -0
  121. data/ext/carray_utils.c +579 -113
  122. data/ext/extconf.rb +27 -2
  123. data/ext/mk_call_cfunc.rb +19 -4
  124. data/ext/mk_pow5_table.rb +35 -0
  125. data/ext/mkkernel.rb +1202 -1014
  126. data/ext/ruby_carray.c +17 -5
  127. data/ext/version.h +4 -4
  128. data/ext/version.rb +4 -2
  129. data/lib/carray/attribute.rb +160 -17
  130. data/lib/carray/autoload_carray.rb +23 -27
  131. data/lib/carray/autoload_method_extension.rb +20 -5
  132. data/lib/carray/axis_group.rb +360 -63
  133. data/lib/carray/basics.rb +162 -28
  134. data/lib/carray/bincount_nd.rb +18 -2
  135. data/lib/carray/block_iterator.rb +231 -58
  136. data/lib/carray/boolean_reduce.rb +34 -42
  137. data/lib/carray/categorical.rb +242 -52
  138. data/lib/carray/categorical_iterator.rb +437 -827
  139. data/lib/carray/complex.rb +41 -34
  140. data/lib/carray/conditional.rb +150 -14
  141. data/lib/carray/const_string.rb +59 -4
  142. data/lib/carray/construct.rb +15 -11
  143. data/lib/carray/data_type_extension.rb +1 -0
  144. data/lib/carray/distance_limit.rb +77 -0
  145. data/lib/carray/fixlen_string.rb +7 -1
  146. data/lib/carray/frame/column_verbs.rb +70 -0
  147. data/lib/carray/frame/concat.rb +157 -6
  148. data/lib/carray/frame/convert.rb +82 -7
  149. data/lib/carray/frame/csv_parser.rb +688 -87
  150. data/lib/carray/frame/describe.rb +80 -0
  151. data/lib/carray/frame/frame.rb +369 -45
  152. data/lib/carray/frame/group.rb +631 -90
  153. data/lib/carray/frame/io.rb +506 -79
  154. data/lib/carray/frame/join.rb +411 -62
  155. data/lib/carray/frame/records.rb +116 -19
  156. data/lib/carray/frame/reshape.rb +455 -0
  157. data/lib/carray/frame/sort.rb +30 -18
  158. data/lib/carray/frame/verbs.rb +868 -61
  159. data/lib/carray/frame/windows.rb +117 -0
  160. data/lib/carray/frame.rb +4 -0
  161. data/lib/carray/fuse_source.rb +118 -2
  162. data/lib/carray/fusion.rb +257 -42
  163. data/lib/carray/histogram.rb +6 -1
  164. data/lib/carray/inspect.rb +54 -13
  165. data/lib/carray/iterator.rb +99 -9
  166. data/lib/carray/key_windows.rb +620 -0
  167. data/lib/carray/lazy.rb +216 -193
  168. data/lib/carray/mask_gap_fill.rb +112 -92
  169. data/lib/carray/math.rb +32 -19
  170. data/lib/carray/meld_reduce.rb +52 -21
  171. data/lib/carray/methods/align_addr.rb +8 -4
  172. data/lib/carray/methods/bin.rb +26 -18
  173. data/lib/carray/methods/bincount.rb +18 -2
  174. data/lib/carray/methods/bit_string.rb +15 -4
  175. data/lib/carray/methods/choose.rb +16 -3
  176. data/lib/carray/methods/composition.rb +18 -10
  177. data/lib/carray/methods/factorize.rb +2 -2
  178. data/lib/carray/methods/gather_nd.rb +25 -5
  179. data/lib/carray/methods/insert_block.rb +9 -14
  180. data/lib/carray/methods/is_between.rb +59 -0
  181. data/lib/carray/methods/is_in.rb +140 -10
  182. data/lib/carray/methods/join.rb +2 -7
  183. data/lib/carray/methods/lag.rb +49 -0
  184. data/lib/carray/methods/locate_addr.rb +182 -45
  185. data/lib/carray/methods/mode.rb +18 -1
  186. data/lib/carray/methods/nunique.rb +11 -6
  187. data/lib/carray/methods/repeat.rb +1 -20
  188. data/lib/carray/methods/resize.rb +21 -23
  189. data/lib/carray/methods/snap.rb +34 -12
  190. data/lib/carray/methods/unique.rb +11 -2
  191. data/lib/carray/methods/value_counts.rb +4 -11
  192. data/lib/carray/record_order.rb +208 -0
  193. data/lib/carray/rng.rb +3 -0
  194. data/lib/carray/runtime.rb +65 -17
  195. data/lib/carray/segment_iterator.rb +1221 -0
  196. data/lib/carray/serialize.rb +214 -30
  197. data/lib/carray/slab_iterator.rb +101 -27
  198. data/lib/carray/stack.rb +10 -4
  199. data/lib/carray/string.rb +105 -5
  200. data/lib/carray/string_operation_extension.rb +12 -4
  201. data/lib/carray/struct.rb +137 -58
  202. data/lib/carray/struct_builder.rb +124 -17
  203. data/lib/carray/time.rb +1222 -186
  204. data/lib/carray/window_iterator.rb +242 -81
  205. data/lib/carray.rb +9 -2
  206. data/yard-stubs/ca_obj_array.rb +4 -4
  207. data/yard-stubs/ca_obj_bitarray.rb +7 -0
  208. data/yard-stubs/ca_obj_bitfield.rb +23 -12
  209. data/yard-stubs/ca_obj_fake.rb +1 -1
  210. data/yard-stubs/ca_obj_field.rb +5 -1
  211. data/yard-stubs/ca_obj_grid.rb +21 -10
  212. data/yard-stubs/ca_obj_refer.rb +5 -4
  213. data/yard-stubs/ca_obj_roll.rb +2 -3
  214. data/yard-stubs/ca_obj_shift.rb +5 -1
  215. data/yard-stubs/ca_obj_stride.rb +10 -7
  216. data/yard-stubs/ca_obj_tile.rb +1 -1
  217. data/yard-stubs/ca_obj_transpose.rb +1 -1
  218. data/yard-stubs/ca_obj_window.rb +9 -5
  219. data/yard-stubs/carray_access.rb +11 -5
  220. data/yard-stubs/carray_attribute.rb +0 -5
  221. data/yard-stubs/carray_broadcast.rb +1 -1
  222. data/yard-stubs/carray_cast.rb +88 -43
  223. data/yard-stubs/carray_conversion.rb +15 -2
  224. data/yard-stubs/carray_copy.rb +12 -5
  225. data/yard-stubs/carray_core.rb +3 -3
  226. data/yard-stubs/carray_count.rb +9 -0
  227. data/yard-stubs/carray_element.rb +10 -4
  228. data/yard-stubs/carray_generate.rb +21 -1
  229. data/yard-stubs/carray_lazy.rb +5 -1
  230. data/yard-stubs/carray_loop.rb +4 -1
  231. data/yard-stubs/carray_mask.rb +60 -12
  232. data/yard-stubs/carray_math.rb +52 -14
  233. data/yard-stubs/carray_median_percentile.rb +15 -8
  234. data/yard-stubs/carray_memory_view.rb +40 -23
  235. data/yard-stubs/carray_order.rb +61 -29
  236. data/yard-stubs/carray_random.rb +13 -4
  237. data/yard-stubs/carray_scatter.rb +21 -9
  238. data/yard-stubs/carray_segment.rb +72 -0
  239. data/yard-stubs/carray_slab.rb +10 -2
  240. data/yard-stubs/carray_sort.rb +49 -8
  241. data/yard-stubs/carray_test.rb +15 -3
  242. data/yard-stubs/carray_utils.rb +0 -26
  243. metadata +19 -5
  244. data/ext/ca_for_each_element.h +0 -239
  245. data/ext/carray_kernels_map.c +0 -3467
  246. data/lib/carray/arrow_tensor.rb +0 -401
  247. data/lib/carray/table.rb +0 -108
@@ -9,18 +9,50 @@ class CAFrame
9
9
  # Add (or replace) a column, returning a new frame — the column set changes,
10
10
  # so the result is a different table (memo §3.8). The column is shared by
11
11
  # reference; the envelope is cheap. Chain or reassign: +df = df.append(...)+.
12
- def append(name, col)
13
- key = name.to_s
12
+ #
13
+ # The column goes at the end, or where a column of that name already is.
14
+ # +before:+ / +after:+ name a column to put it next to instead; a column
15
+ # of that name already in the frame is moved there.
16
+ #
17
+ # df.append("id", ids, before: df.column_names.first) # leftmost
18
+ def append(name, col, before: nil, after: nil)
19
+ key = column_name_arg(name, "append")
14
20
  ca = coerce_column(col)
15
- rebuild(@columns.merge(key => ca))
21
+ unless before.nil? || after.nil?
22
+ raise ArgumentError, "append: give before: or after:, not both"
23
+ end
24
+ # Checked here: the new frame would take N from whichever witness comes
25
+ # first, and blame the frame's own column for the new one's length. A
26
+ # column that replaces the only one is held to N too, as df[name] = is;
27
+ # only a frame with no column and no index takes N from the new column.
28
+ if (@columns.any? || @index) && ca.shape[0] != @nrow
29
+ raise ArgumentError,
30
+ "append: column #{key.inspect} has axis-0 length #{ca.shape[0]}, expected #{@nrow}"
31
+ end
32
+ anchor = before.nil? ? after : before
33
+ return rebuild(@columns.merge(key => ca)) if anchor.nil?
34
+
35
+ anchor = column_key(anchor, "append")
36
+ unless @columns.key?(anchor)
37
+ raise KeyError, "append: no column #{anchor.inspect} to put #{key.inspect} #{before.nil? ? "after" : "before"}"
38
+ end
39
+ return rebuild(@columns.merge(key => ca)) if anchor == key
40
+
41
+ cols = {}
42
+ @columns.each do |k, v|
43
+ next if k == key
44
+ cols[key] = ca if !before.nil? && k == anchor
45
+ cols[k] = v
46
+ cols[key] = ca if !after.nil? && k == anchor
47
+ end
48
+ rebuild(cols)
16
49
  end
17
50
 
18
51
  # Remove one or more columns, returning a new frame (column set changes,
19
52
  # memo §3.8). Column data is untouched and shared with the original.
20
53
  def drop(*names)
21
54
  cols = @columns.dup
22
- names.each do |name|
23
- key = name.to_s
55
+ column_keys(names, "drop").each do |key|
24
56
  raise KeyError, "no column #{key.inspect}" unless cols.key?(key)
25
57
  cols.delete(key)
26
58
  end
@@ -33,8 +65,8 @@ class CAFrame
33
65
  def rename(mapping)
34
66
  norm = {}
35
67
  mapping.each do |old, new|
36
- o = old.to_s
37
- n = new.to_s
68
+ o = column_key(old, "rename")
69
+ n = column_name_arg(new, "rename")
38
70
  raise KeyError, "no column #{o.inspect}" unless @columns.key?(o)
39
71
  if n != o && @columns.key?(n)
40
72
  raise ArgumentError, "rename target #{n.inspect} already exists"
@@ -46,39 +78,478 @@ class CAFrame
46
78
  rebuild(rebuilt)
47
79
  end
48
80
 
81
+ # Split a text column at +sep+ into the columns named by +into:+,
82
+ # returning a new frame where they take the column's place:
83
+ #
84
+ # df.split_column("code", "-", into: ["kind", "num"]) # "A-12" -> "A", "12"
85
+ #
86
+ # A cell is split into at most +into.size+ pieces, so the rest of a cell
87
+ # with more separators stays in the last column rather than being dropped,
88
+ # and the columns a cell with fewer separators does not reach are UNDEF. A
89
+ # masked cell is UNDEF in every new column. +sep+ is a String or a Regexp,
90
+ # as String#split takes it; a group in the Regexp would add pieces, so use
91
+ # (?:...). The new columns are CAString, ready for cast.
92
+ #
93
+ # @param name [String] the text column to split.
94
+ # @param sep [String, Regexp] where to split.
95
+ # @param into [Array<String>] the names of the new columns, two or more.
96
+ # @return [CAFrame] a new frame; the other columns are shared.
97
+ # @raise [ArgumentError] when the column is not text or 1-D, +into:+ names
98
+ # fewer than two columns or one twice, or names a column already there.
99
+ def split_column(name, sep, into:)
100
+ key = column_key(name, "split_column")
101
+ col = @columns.fetch(key) { raise KeyError, "no column #{key.inspect}" }
102
+ unless string_column?(col) && col.ndim == 1
103
+ raise ArgumentError, "split_column: #{key.inspect} is not a one-dimensional text column"
104
+ end
105
+ unless sep.is_a?(String) || sep.is_a?(Regexp)
106
+ raise ArgumentError, "split_column: sep must be a String or a Regexp (got #{sep.inspect})"
107
+ end
108
+ names = Array(into).map { |n| column_name_arg(n, "split_column") }
109
+ if names.size < 2
110
+ raise ArgumentError,
111
+ "split_column: into: names two or more columns (got #{names.inspect})"
112
+ end
113
+ CAFrame.__send__(:refuse_repeated_names, names, "split_column", "into: names")
114
+ taken = names & (@columns.keys - [key])
115
+ unless taken.empty?
116
+ raise ArgumentError, "split_column: #{taken.inspect} already a column"
117
+ end
118
+
119
+ n = names.size
120
+ pieces = Array.new(n) { CArray.object(col.elements) }
121
+ pieces.each { |p| p[] = UNDEF }
122
+ time_text_of(col).to_a.each_with_index do |cell, i|
123
+ next if UNDEF.equal?(cell) || cell.nil?
124
+ cell.split(sep, n).each_with_index { |part, k| pieces[k][i] = part }
125
+ end
126
+ rebuilt = {}
127
+ @columns.each do |k, v|
128
+ if k == key
129
+ names.each_with_index { |nm, j| rebuilt[nm] = CArray.string(pieces[j]) }
130
+ else
131
+ rebuilt[k] = v
132
+ end
133
+ end
134
+ rebuild(rebuilt)
135
+ end
136
+
137
+ # Replace a categorical column by numeric columns, one per category, for a
138
+ # model that takes numbers (see CACategorical#contrast):
139
+ #
140
+ # df.contrast_columns("weather") # "weather_cloud", "weather_rain"
141
+ # df.contrast_columns("weather", base: "rain")
142
+ # df.contrast_columns("weather", full: true) # every category kept
143
+ #
144
+ # The new columns take the column's place and are named after it and the
145
+ # label, "weather_cloud"; +into:+ names them instead. A column that is not
146
+ # a CACategorical is categorized first with its labels in ascending order,
147
+ # so the base -- the first label unless +base:+ says otherwise -- does not
148
+ # depend on the order of the rows. A masked cell is UNDEF in every new
149
+ # column.
150
+ #
151
+ # @param name [String] the column to expand.
152
+ # @param scheme [Symbol] the coding; only :dummy is defined.
153
+ # @param base [Object] the label whose column is left out.
154
+ # @param full [Boolean] keep every column.
155
+ # @param into [Array<String>, nil] the names of the new columns.
156
+ # @return [CAFrame] a new frame; the other columns are shared.
157
+ # @raise [ArgumentError] when the column is not one-dimensional, +into:+
158
+ # names a different number of columns or one twice, or a new name is
159
+ # already a column; and as CACategorical#contrast raises.
160
+ def contrast_columns(name, scheme = :dummy, base: nil, full: false, into: nil)
161
+ key = column_key(name, "contrast_columns")
162
+ col = @columns.fetch(key) { raise KeyError, "no column #{key.inspect}" }
163
+ unless col.ndim == 1
164
+ raise ArgumentError, "contrast_columns: #{key.inspect} is not a one-dimensional column"
165
+ end
166
+ # The labels are sorted as categorize(sort_labels: true) sorts them;
167
+ # labels of an object column that cannot be put in order are named here.
168
+ if col.object? && !col.is_a?(CACategorical)
169
+ col.unique.to_a.sort do |a, b|
170
+ CArray.__send__(:object_order_cmp, a, b) or
171
+ raise ArgumentError,
172
+ "contrast_columns: #{key.inspect} has the labels #{a.inspect} and " \
173
+ "#{b.inspect}, which cannot be put in order; categorize it with labels: first"
174
+ end
175
+ end
176
+ cat = col.is_a?(CACategorical) ? col : col.categorize(sort_labels: true)
177
+ coded = cat.contrast(scheme, base: base, full: full)
178
+ m = coded.shape[1]
179
+ # The base is found among the labels as contrast finds it.
180
+ dropped = full ? nil : (base.nil? ? 0 : cat.__send__(:label_position, base))
181
+ labels = cat.labels.reject.with_index { |_, i| i == dropped }
182
+ names =
183
+ if into
184
+ Array(into).map { |n| column_name_arg(n, "contrast_columns") }
185
+ else
186
+ labels.map { |label| "#{key}_#{label}" }
187
+ end
188
+ unless names.size == m
189
+ raise ArgumentError,
190
+ "contrast_columns: into: names #{names.size} columns for #{m} (got #{names.inspect})"
191
+ end
192
+ if into
193
+ CAFrame.__send__(:refuse_repeated_names, names, "contrast_columns", "into: names")
194
+ else
195
+ CAFrame.__send__(:refuse_repeated_names, names, "contrast_columns", "column names",
196
+ hint: "name them with into:")
197
+ end
198
+ taken = names & (@columns.keys - [key])
199
+ unless taken.empty?
200
+ message = "contrast_columns: #{taken.inspect} already a column"
201
+ message += "; name them with into:" unless into
202
+ raise ArgumentError, message
203
+ end
204
+
205
+ rebuilt = {}
206
+ @columns.each do |k, v|
207
+ if k == key
208
+ names.each_with_index { |nm, j| rebuilt[nm] = coded[nil, j] }
209
+ else
210
+ rebuilt[k] = v
211
+ end
212
+ end
213
+ rebuild(rebuilt)
214
+ end
215
+
216
+ # Stack several columns into one N-D column: the columns side by side in a
217
+ # file -- a value per month, say -- become one column whose trailing axis
218
+ # (or axes, with +shape:+) runs over them. The columns are given in the
219
+ # frame's order:
220
+ #
221
+ # df.stack_columns("G02_002".."G02_013", into: "precip") # 12 columns
222
+ # df.stack_columns(%w[t_max t_min t_mean], into: "temp")
223
+ # df.stack_columns(/\Ahour_\d+\z/, into: "temp")
224
+ # df.stack_columns("G02_015".."G02_050", into: "temp", shape: [12, 3])
225
+ #
226
+ # A Range of names means the columns of this frame from the first name to
227
+ # the last, in the frame's order -- not the names Ruby would count between
228
+ # the two -- and an exclusive Range leaves the last one out. An Array is
229
+ # taken in its own order, a Regexp in the frame's. +shape:+ arranges the
230
+ # stacked columns in row-major order (the last axis runs fastest) and must
231
+ # hold exactly that many. Columns that are themselves N-D keep their axes
232
+ # after the new ones. +into:+ is the new column's name, a String.
233
+ #
234
+ # The new column is a view of the old ones, so writes reach them; it takes
235
+ # the place of the first of them, and the others leave the frame. The
236
+ # trailing axes carry no labels: keep what position k means (the months)
237
+ # alongside. +unstack_column+ goes the other way; +stack_columns+ of the
238
+ # columns it gives, into the same name, gives this column back.
239
+ #
240
+ # @return [CAFrame] a new frame; the other columns are shared.
241
+ def stack_columns(selection, into:, shape: nil)
242
+ names = columns_selected(selection, "stack_columns")
243
+ target = column_name_arg(into, "stack_columns")
244
+ taken = [target] & (@columns.keys - names)
245
+ unless taken.empty?
246
+ raise ArgumentError, "stack_columns: #{target.inspect} is already a column"
247
+ end
248
+ parts = names.map { |n| @columns[n] }
249
+ trailing = parts.first.shape[1..]
250
+ parts.each_with_index do |c, i|
251
+ next if c.shape[1..] == trailing
252
+ raise ArgumentError,
253
+ "stack_columns: #{names[i].inspect} has shape #{c.shape.inspect}, " \
254
+ "#{names.first.inspect} has #{parts.first.shape.inspect}"
255
+ end
256
+ layer = shape ? Array(shape) : [names.size]
257
+ unless layer.all? { |d| d.is_a?(Integer) && d > 0 } && layer.inject(:*) == names.size
258
+ raise ArgumentError,
259
+ "stack_columns: shape: #{layer.inspect} does not hold the #{names.size} columns"
260
+ end
261
+ col = CArray.stack(parts, axis: 1)
262
+ col = col.reshape(nrow, *layer, *trailing) if layer.size > 1
263
+ rebuilt = {}
264
+ @columns.each do |k, v|
265
+ if k == names.first
266
+ rebuilt[target] = col
267
+ elsif ! names.include?(k)
268
+ rebuilt[k] = v
269
+ end
270
+ end
271
+ rebuild(rebuilt)
272
+ end
273
+
274
+ # Split an N-D column into one column per position on its trailing axes --
275
+ # the other way from +stack_columns+. The new columns are views of the N-D
276
+ # column, in row-major order, and take its place in the frame. +into:+
277
+ # names them (as many names as positions); without it they are named
278
+ # after the column and the position, "temp_0", or "temp_6_2" with two
279
+ # axes. The frame does not remember the names the columns had before
280
+ # +stack_columns+, so pass them as +into:+ to get them back.
281
+ #
282
+ # When +into:+ names as many columns as the first trailing axis is long,
283
+ # the column is split along that axis only, and each new column keeps the
284
+ # axes after it: a (n, 2, 3) column with two names gives two (n, 3)
285
+ # columns. This undoes +stack_columns+ of columns that were N-D.
286
+ #
287
+ # A column whose trailing axes hold no position has nothing to split into
288
+ # and is refused.
289
+ #
290
+ # @return [CAFrame] a new frame; the other columns are shared.
291
+ def unstack_column(name, into: nil)
292
+ key = column_key(name, "unstack_column")
293
+ col = @columns.fetch(key) { raise KeyError, "no column #{key.inspect}" }
294
+ if col.ndim < 2
295
+ raise ArgumentError, "unstack_column: #{key.inspect} is one-dimensional; there is nothing to unstack"
296
+ end
297
+ trailing = col.shape[1..]
298
+ count = trailing.inject(:*)
299
+ if count == 0
300
+ raise ArgumentError,
301
+ "unstack_column: #{key.inspect} has shape #{col.shape.inspect}; " \
302
+ "its trailing axes hold no position to split into"
303
+ end
304
+ given = into && Array(into).map { |n| column_name_arg(n, "unstack_column") }
305
+ if given && trailing.size > 1 && given.size == trailing.first
306
+ positions = (0...trailing.first).map { |k| [k] }
307
+ count = trailing.first
308
+ else
309
+ positions = trailing.size == 1 ? (0...count).map { |k| [k] } :
310
+ trailing.map { |d| (0...d).to_a }.inject { |a, b| a.product(b).map(&:flatten) }
311
+ end
312
+ names = given || positions.map { |pos| "#{key}_#{pos.join('_')}" }
313
+ unless names.size == count
314
+ raise ArgumentError,
315
+ "unstack_column: into: names #{names.size} columns; #{key.inspect} has #{count} positions"
316
+ end
317
+ CAFrame.__send__(:refuse_repeated_names, names, "unstack_column", "into: names")
318
+ taken = names & (@columns.keys - [key])
319
+ unless taken.empty?
320
+ raise ArgumentError, "unstack_column: #{taken.inspect} already a column"
321
+ end
322
+ rebuilt = {}
323
+ @columns.each do |k, v|
324
+ if k == key
325
+ rest = [nil] * (v.ndim - 1 - positions.first.size)
326
+ positions.each_with_index { |pos, j| rebuilt[names[j]] = v[nil, *pos, *rest] }
327
+ else
328
+ rebuilt[k] = v
329
+ end
330
+ end
331
+ rebuild(rebuilt)
332
+ end
333
+
334
+ # A column name given as an argument for a column to make: a non-empty
335
+ # String, as the frame's column keys are.
336
+ private def column_name_arg(name, verb)
337
+ column_key(name, verb)
338
+ raise ArgumentError, "#{verb}: a column name can not be empty" if name.empty?
339
+ name
340
+ end
341
+
342
+ # The names a column selection picks: a Range of names spans the frame's
343
+ # columns from one to the other, an Array is taken as given, a Regexp
344
+ # matches in the frame's order.
345
+ private def columns_selected(selection, verb)
346
+ keys = @columns.keys
347
+ names = case selection
348
+ when Range
349
+ from, to = selection.begin, selection.end
350
+ unless from.is_a?(String) && to.is_a?(String)
351
+ raise ArgumentError, "#{verb}: a Range of column names takes two names (got #{selection.inspect})"
352
+ 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}"
355
+ if j < i
356
+ raise ArgumentError, "#{verb}: #{to.inspect} comes before #{from.inspect} in the frame"
357
+ end
358
+ keys[i..(selection.exclude_end? ? j - 1 : j)]
359
+ when Array
360
+ selection.map { |n| column_key(n, verb) }.each do |n|
361
+ raise KeyError, "#{verb}: no column #{n.inspect}" unless @columns.key?(n)
362
+ end
363
+ when Regexp
364
+ keys.grep(selection)
365
+ else
366
+ raise ArgumentError,
367
+ "#{verb}: columns are a Range of names, an Array of names or a Regexp (got #{selection.class})"
368
+ end
369
+ if names.empty?
370
+ raise ArgumentError, "#{verb}: #{selection.inspect} selects no column"
371
+ end
372
+ CAFrame.__send__(:refuse_repeated_names, names, verb)
373
+ end
374
+
49
375
  # Mask cells of a column that equal +value+ (sentinel -> mask, memo §11.4).
50
376
  # In-place on the column (write-through, §4.3): numeric/object columns
51
377
  # mask in place; a categorical column's codes are read-only (§13.4) so this
52
378
  # raises — recode by rebuilding and rebinding instead.
53
379
  def mask_eq(name, value)
54
- self[name][:eq, value] = UNDEF
380
+ refuse_if_frozen
381
+ self[column_key(name, "mask_eq")][:eq, value] = UNDEF
55
382
  self
56
383
  end
57
384
 
58
- # Cast columns to a data type and rebind them (memo §11.4). Uses to_type,
59
- # so parse failures on string columns become UNDEF (parse-mask, §6-2).
60
- # Three call shapes, disambiguated by the fact that column names are always
385
+ # Cast columns to a data type and rebind them (memo §11.4). A text column
386
+ # cast to an integer or float type is read with a decimal grammar ("010" is
387
+ # ten, "0x1F" and "1_000" are not numbers); a cell that does not read, or a
388
+ # value the type cannot hold, becomes UNDEF (parse-mask, §6-2). A text
389
+ # column cast to :time is parsed into a CATime column, in the finest unit
390
+ # its text shows (:D for dates alone, :s with a time of day, :ms / :us /
391
+ # :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
393
+ # 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
395
+ # cell. Other targets use to_type.
396
+ # The call shapes are told apart by the fact that column names are always
61
397
  # Strings and types always Symbols (§3.7):
62
398
  #
63
399
  # cast("temp", :float64) # one column (chains)
64
400
  # cast("temp" => :float64, "rh" => :int32) # name => type map
65
401
  # cast(["temp", "rh"] => :float64) # names sharing one type
402
+ # cast(:infer) # what infer_types finds
403
+ # cast(default: :infer, "code" => :int32) # a default for the rest
66
404
  #
67
405
  # A map key may be a single name or an Array of names; the value is the
68
- # target type. Returns self so calls chain.
69
- def cast(name_or_map, type = nil)
70
- if name_or_map.is_a?(Hash)
71
- unless type.nil?
72
- raise ArgumentError, "cast(map) takes no positional type argument"
406
+ # target type, or nil to leave the column as it is. The map may be given
407
+ # with or without braces.
408
+ #
409
+ # +default:+ in the map covers the columns the map does not name: :infer
410
+ # casts the ones among them that +infer_types+ finds a type for, and a type
411
+ # casts all of them. A named column takes its own entry, which replaces
412
+ # the default. +:infer+ alone is +{ default: :infer }+.
413
+ #
414
+ # cast(default: :infer, "id" => nil) # infer, but leave "id"
415
+ # cast(default: :float64, "station" => nil) # every other column to float64
416
+ #
417
+ # A name that is not a column raises KeyError, and a Symbol key other than
418
+ # +: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.
421
+ #
422
+ # +on_error:+ says what to do with a text cell that holds something but
423
+ # does not read as the number type ("x", "1.5" for an integer, "300" for
424
+ # :int8). Blank, nil and masked cells are missing values, not errors, and
425
+ # are UNDEF under every policy.
426
+ #
427
+ # :mask the cell becomes UNDEF (default)
428
+ # :warn as :mask, plus one warning per column with the count and the
429
+ # first few cells
430
+ # :raise CAFrame::UnreadableColumn naming the column, the row and the
431
+ # cell; no column is rebound
432
+ #
433
+ # +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
435
+ # about the map then names that option instead of cast, and the map must
436
+ # be a Hash or :infer:
437
+ #
438
+ # 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)
440
+ refuse_if_frozen
441
+ label = option_name ? "#{option_name}:" : "cast"
442
+ # A brace-less map (cast("temp" => :float64, on_error: :warn)) arrives as
443
+ # keywords next to on_error:.
444
+ unless map.empty?
445
+ unless name_or_map.nil?
446
+ # Symbol keys next to a name or a map are a misspelt keyword
447
+ # (on_erorr:), not a second map; :default is a map key.
448
+ if map.keys.all?(Symbol) && !map.key?(:default)
449
+ raise ArgumentError,
450
+ "#{label}: unknown keyword#{map.size > 1 ? 's' : ''}: " \
451
+ "#{map.keys.map(&:inspect).join(', ')} (the keyword is on_error:)"
452
+ end
453
+ raise ArgumentError, "#{label} takes a name and a type, or a map, not both"
73
454
  end
74
- name_or_map.each do |names, t|
75
- Array(names).each { |name| cast_one(name, t) }
455
+ name_or_map = map
456
+ end
457
+ raise ArgumentError, "#{label} needs a column name or a map" if name_or_map.nil?
458
+ unless CAST_ON_ERROR.include?(on_error)
459
+ raise ArgumentError,
460
+ "on_error: must be :mask, :warn or :raise (got #{on_error.inspect})"
461
+ end
462
+ name_or_map = { default: :infer } if name_or_map == :infer && type.nil?
463
+ if option_name && !name_or_map.is_a?(Hash)
464
+ raise ArgumentError, "#{label} takes a map of column types or :infer " \
465
+ "(got #{name_or_map.inspect})"
466
+ end
467
+ types =
468
+ if name_or_map.is_a?(Hash)
469
+ unless type.nil?
470
+ raise ArgumentError, "#{label} takes no positional type argument with a map"
471
+ end
472
+ resolve_cast_map(name_or_map, label)
473
+ else
474
+ { column_key(name_or_map, label) => type }
76
475
  end
77
- else
78
- cast_one(name_or_map, type)
476
+ # Read every column before rebinding any, so a raise leaves the frame as
477
+ # it was.
478
+ # A conversion keeps what the column is, so the column's attributes
479
+ # come along whatever the target type.
480
+ casted = types.map do |key, t|
481
+ [key, @columns[key].__send__(:carry_attrs, cast_column(key, t, on_error))]
79
482
  end
483
+ casted.each { |key, col| @columns[key] = col }
80
484
  self
81
485
  end
486
+
487
+ # The name => type pairs a cast map asks for: the named columns with a
488
+ # type, then the columns default: covers. Checks the keys and the names
489
+ # before anything is read.
490
+ private def resolve_cast_map(map, label)
491
+ default = nil
492
+ named = {}
493
+ map.each do |key, t|
494
+ if key == :default
495
+ default = t
496
+ elsif key.is_a?(Symbol)
497
+ CAFrame.__send__(:column_key, key, label, hint: ":default is the only Symbol key")
498
+ else
499
+ Array(key).each { |name| named[column_key(name, label)] = t }
500
+ end
501
+ end
502
+ named.each_key do |name|
503
+ raise KeyError, "#{label} names no column #{name.inspect}" unless @columns.key?(name)
504
+ end
505
+ rest = @columns.keys - named.keys
506
+ types =
507
+ case default
508
+ when nil then {}
509
+ when :infer then rest.empty? ? {} : select(*rest).infer_types
510
+ else rest.to_h { |name| [name, default] }
511
+ end
512
+ types.merge!(named.compact)
513
+ end
514
+
515
+ CAST_ON_ERROR = %i[mask warn raise].freeze
516
+
517
+ # The targets a text column is read as numbers for.
518
+ CAST_NUMBER_TYPES = %w[int8 int16 int32 int64 uint8 uint16 uint32 uint64
519
+ float32 float64 cmplx64 cmplx128].freeze
520
+ Ractor.make_shareable(CAST_NUMBER_TYPES)
521
+ private_constant :CAST_ON_ERROR, :CAST_NUMBER_TYPES
522
+
523
+ # The types the text columns read as, as a map +cast+ takes:
524
+ # { "temp" => :float64, "count" => :int64, "time" => :time }. A column is
525
+ # listed only when every cell that is not missing reads as one type:
526
+ # :int64 when they are all integers that fit, :float64 when they are all
527
+ # numbers, :boolean when they are all true / false (in any case), :time
528
+ # when they are all year-first dates or times ("2024-01-01",
529
+ # "2024-01-01 12:00:00", "2024-01-01T12:00:00.5Z"). 0 / 1 is :int64: a
530
+ # column of those is read as the integers it could equally be, and
531
+ # cast("name" => :boolean) turns it into a boolean.
532
+ # Blank, nil and masked cells are missing and say nothing; a column with
533
+ # none present is not listed.
534
+ #
535
+ # A number with a leading zero ("007") is a code and keeps its column as
536
+ # text, and so does an integer too long for int64 (an identifier), which a
537
+ # float would round. A day-first or month-first date ("01/02/2024") is not
538
+ # read as time, since which one it is cannot be told. Only text columns
539
+ # are looked at -- an object column or a string Face (CAString,
540
+ # CAConstString); columns that already have a type are not listed.
541
+ #
542
+ # df.cast(df.infer_types) # what cast(:infer) does
543
+ # df.cast(df.infer_types.merge("code" => :int32)) # as cast(default: :infer, "code" => :int32)
544
+ def infer_types
545
+ @columns.each_with_object({}) do |(key, col), types|
546
+ next unless string_column?(col)
547
+ type = text_for_decimal_reading(col).__infer_number_type_of_text__
548
+ type ||= :boolean if boolean_word_column?(col)
549
+ type ||= :time if first_present_cell_can_be_time_text?(col) && time_text_column?(time_text_of(col))
550
+ types[key] = type if type
551
+ end
552
+ end
82
553
 
83
554
  # Bring every column to one common data type and rebind them (memo §11.4).
84
555
  # The frame-level counterpart of +CArray.promote_list+: where +cast+ forces
@@ -104,6 +575,7 @@ class CAFrame
104
575
  # Face-typed and numeric columns becomes a single-type table (and so the
105
576
  # way +to_ca+ can hand back a matrix for it).
106
577
  def promote(type = nil)
578
+ refuse_if_frozen
107
579
  return self if @columns.empty?
108
580
  type.nil? ? promote_to_common : promote_to_type(type)
109
581
  self
@@ -112,25 +584,61 @@ class CAFrame
112
584
  # Parse a string column into a time column and rebind it (memo §11.2).
113
585
  # The column must be string-bearing (an object CArray of Strings, or a
114
586
  # CAString / CAConstString / CAFixlenString) — this is the "text -> time"
115
- # mode, distinct from the integer-serial mode of +to_time+. Delegates to
116
- # +CArray.time(col, on_error: :mask)+: +format+ picks strptime parsing
117
- # (auto-detect when nil), +unit+ the storage resolution; masked / nil and
118
- # unparseable cells become UNDEF (bulk column parse tolerates bad cells).
119
- # Make it the index with +set_index+ afterward.
587
+ # mode, distinct from the integer-serial mode of +to_time+.
588
+ #
589
+ # Without +format+ only text written year first is read ("2024-01-01",
590
+ # "2024/1/2 3:04", "2024-01-01T12:00:00.5Z"); a date in another order is
591
+ # not, since whether "01/02/2024" is January or February cannot be told.
592
+ # For those, +format+ is a strptime format, :infer to find the one format
593
+ # the column is written in (see infer_time_format; a cell not in it
594
+ # raises CAFrame::UnreadableColumn, whatever +on_error+ says), or :mixed
595
+ # to guess at each cell (much slower). +unit+ is the storage resolution,
596
+ # by default the finest the text shows -- with a format, the finest field
597
+ # the format writes ("%d/%m/%Y" :D, "%H:%M" :s, "%L" :ms), so a fraction
598
+ # of a second is not cut off. Masked / nil and unparseable cells become
599
+ # UNDEF; +on_error:+ :warn / :raise reports the unparseable ones as +cast+
600
+ # does. Make it the index with +set_index+ afterward.
601
+ # +cast(name => :time)+ is the call without a format.
120
602
  #
121
603
  # df.parse_to_time("time").set_index("time")
122
- def parse_to_time(name, format = nil, unit: :s)
123
- key = name.to_s
604
+ # df.parse_to_time("date", "%d/%m/%Y")
605
+ # df.parse_to_time("date", :infer)
606
+ def parse_to_time(name, format = nil, unit: nil, on_error: :mask)
607
+ refuse_if_frozen
608
+ unless CAST_ON_ERROR.include?(on_error)
609
+ raise ArgumentError,
610
+ "on_error: must be :mask, :warn or :raise (got #{on_error.inspect})"
611
+ end
612
+ key = column_key(name, "parse_to_time")
124
613
  col = @columns.fetch(key) { raise KeyError, "no column #{key.inspect}" }
125
614
  unless string_column?(col)
126
615
  raise ArgumentError,
127
616
  "parse_to_time needs a string column (object / CAString / " \
128
617
  "CAConstString / CAFixlenString); #{key.inspect} is #{col.data_type}"
129
618
  end
130
- @columns[key] = CArray.time(col, format: format, unit: unit, on_error: :mask)
619
+ @columns[key] = col.__send__(:carry_attrs, parse_time_column(key, col, format, unit, on_error))
131
620
  self
132
621
  end
133
622
 
623
+ # The strptime format parse_to_time(name, :infer) reads the column with,
624
+ # or nil when the text is written year first and needs none. The first
625
+ # present cell gives the candidates and each later cell drops those it
626
+ # does not fit, until one is left. CAFrame::UnreadableColumn when none fits
627
+ # the first cell or none is left; CAFrame::AmbiguousTimeFormat when more
628
+ # than one is left at the end (as for "01/02/2024" when no cell has a day
629
+ # above 12), whose +formats+ lists them. Write the answer into the code to
630
+ # read the column with a format from then on.
631
+ #
632
+ # df.infer_time_format("date") # => "%d/%m/%Y"
633
+ def infer_time_format(name)
634
+ key = column_key(name, "infer_time_format")
635
+ col = @columns.fetch(key) { raise KeyError, "no column #{key.inspect}" }
636
+ unless string_column?(col)
637
+ raise ArgumentError, "column #{key.inspect} is #{col.data_type}, not text"
638
+ end
639
+ inferred_time_format(key, col)&.first&.format
640
+ end
641
+
134
642
  # Reinterpret an integer column as time serial counts and rebind it (memo
135
643
  # §11.2). This is the "serial -> time" mode: each value is a count of
136
644
  # +unit+ resolution since +epoch+ (default the Unix epoch, 1970-01-01 UTC).
@@ -139,9 +647,9 @@ class CAFrame
139
647
  # Excel serial date (epoch "1899-12-30", unit :D) — converts directly.
140
648
  #
141
649
  # A float column is accepted only when every value is whole (no fractional
142
- # part); a fractional serial has sub-unit precision that a finer +unit+ should
143
- # carry, so it raises rather than silently truncate. Make it the index with
144
- # +set_index+ afterward.
650
+ # part); a fractional serial has sub-unit precision that a finer +unit+
651
+ # should carry, so it raises CAFrame::UnreadableColumn rather than
652
+ # silently truncate. Make it the index with +set_index+ afterward.
145
653
  #
146
654
  # A +CATime::Grid+ carries the same (unit, epoch) pair as one value, so a
147
655
  # netCDF +units+ attribute goes straight in. It also carries a phase the
@@ -153,12 +661,13 @@ class CAFrame
153
661
  # df.to_time("time", CATime::Grid.parse("hours since 1990-01-01"))
154
662
  # df.to_time("time", CATime::Grid.parse("days since 1980-01-01 12:00"))
155
663
  def to_time(name, grid = nil, unit: :s, epoch: nil)
156
- key = name.to_s
664
+ refuse_if_frozen
665
+ key = column_key(name, "to_time")
157
666
  col = @columns.fetch(key) { raise KeyError, "no column #{key.inspect}" }
158
667
  raw = integer_serial_column(col, key)
159
668
  grid = unit if unit.is_a?(CATime::Grid)
160
669
  if grid.is_a?(CATime::Grid)
161
- @columns[key] = grid.at(raw)
670
+ @columns[key] = col.__send__(:carry_attrs, grid.at(raw))
162
671
  return self
163
672
  end
164
673
  unless grid.nil?
@@ -166,9 +675,10 @@ class CAFrame
166
675
  "the positional argument must be a CATime::Grid (got #{grid.class})"
167
676
  end
168
677
  if epoch
169
- raw = raw + CArray.time(epoch, unit: unit).ticks[0]
678
+ # An Integer epoch is an instant in Unix seconds, whatever the unit.
679
+ raw = raw + CATimeLiteral.to_time_array(epoch, CATime::Resolution.parse(unit), nil, :raise).ticks[0]
170
680
  end
171
- @columns[key] = raw.time(unit: unit)
681
+ @columns[key] = col.__send__(:carry_attrs, raw.time(unit: unit))
172
682
  self
173
683
  end
174
684
 
@@ -189,12 +699,16 @@ class CAFrame
189
699
  # (memo §13.4), so any fill on it raises — rebind a filled copy instead:
190
700
  # `df.append(name, df[name].strip_mask(method: :forward))`.
191
701
  def fill(name, method_or_value)
192
- key = name.to_s
702
+ refuse_if_frozen
703
+ key = column_key(name, "fill")
193
704
  raise KeyError, "no column #{key.inspect}" unless @columns.key?(key)
194
705
  col = @columns[key]
706
+ # An N-D column is filled down the rows: each position on its trailing
707
+ # axes is a series of its own.
708
+ along = col.ndim > 1 ? { axis: 0 } : {}
195
709
  case method_or_value
196
- when :ffill then col.unmask(method: :forward)
197
- when :bfill then col.unmask(method: :backward)
710
+ when :ffill then col.unmask(method: :forward, **along)
711
+ when :bfill then col.unmask(method: :backward, **along)
198
712
  when :linear then fill_linear_column(key, col)
199
713
  when Symbol
200
714
  raise ArgumentError,
@@ -207,38 +721,330 @@ class CAFrame
207
721
  end
208
722
 
209
723
  private def fill_linear_column(key, col)
210
- # A time column interpolates through CATime#linear_fetch /
211
- # CATimedelta#linear_fetch, which keep the Face and its unit.
212
- time_face = col.is_a?(CATime) || col.is_a?(CATimedelta)
213
- unless col.numeric? || time_face
724
+ # A Face column interpolates through its own linear_fetch (a time
725
+ # column's keeps the Face and its unit), as the core gap fill does.
726
+ unless col.__send__(:interpolable?)
214
727
  raise ArgumentError,
215
728
  "fill(#{key.inspect}, :linear): numeric or time column required " \
216
729
  "(got #{col.data_type_name})"
217
730
  end
218
731
  # Without an index coordinate x is the cell position, which is exactly what
219
732
  # the core scan already interpolates against -- time columns included.
220
- return col.unmask(method: :linear) if @index.nil?
221
- present = col.is_not_masked
222
- return if present.count(true) < 2
223
- addr = @index[present].linear_section(@index)
224
- if time_face
225
- filled = col[present].linear_fetch(addr) # UNDEF beyond the valid span
226
- # Write through the storage: a bulk store into the Face itself would try
227
- # to cast int64 ticks to its fixlen surface. Both sides carry the same
228
- # unit (selection preserves it), so the ticks land exactly, mask included.
229
- col.parent[] = filled.parent
733
+ if @index.nil?
734
+ return col.unmask(method: :linear, **(col.ndim > 1 ? { axis: 0 } : {}))
735
+ end
736
+ unless @index.numeric? || @index.is_a?(CATime) || @index.is_a?(CATimedelta)
737
+ along = col.ndim > 1 ? ", axis: 0" : ""
738
+ raise ArgumentError,
739
+ "fill(#{key.inspect}, :linear): the index #{@axis_name.inspect} is " \
740
+ "#{@index.data_type_name}, not numeric or time, so it cannot be the x of " \
741
+ "the interpolation; to interpolate by row position, use " \
742
+ "df[#{key.inspect}].unmask(method: :linear#{along})"
743
+ end
744
+ # With the index as x, an N-D column is filled one trailing position at a
745
+ # time; each position is a 1-D view, so the fill writes through to it.
746
+ if col.ndim > 1
747
+ 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)]) }
230
749
  return
231
750
  end
232
- yvalid = col.value.float64[present]
233
- # mask_invalid before the cast: linear_fetch marks out of range with NaN,
234
- # and casting back to an integer column would turn it into a plausible 0.
235
- col[] = yvalid.linear_fetch(addr).mask_invalid.to_type(col.data_type) # write-through
751
+ # The same fill as the core, with the index as x.
752
+ col[] = col.send(:linear_fill_along, @index) # write-through
236
753
  end
237
754
 
238
- private def cast_one(name, type)
755
+ # Text cells are data, not Ruby literals: an object column cast to a
756
+ # number type is read as decimal numbers ("010" is ten, "0x1F" and "1_000"
757
+ # are not numbers). Other targets go through to_type.
758
+ private def cast_column(name, type, on_error)
239
759
  key = name.to_s
240
- raise KeyError, "no column #{key.inspect}" unless @columns.key?(key)
241
- @columns[key] = @columns[key].to_type(type)
760
+ col = @columns.fetch(key) { raise KeyError, "no column #{key.inspect}" }
761
+ return cast_time_column(key, col, on_error) if type == :time
762
+ if type == :categorical
763
+ return col.is_a?(CACategorical) ? col : col.categorize
764
+ end
765
+ if CArray.data_type_name(type) == "boolean"
766
+ return read_boolean_text(key, col, on_error) if string_column?(col)
767
+ if !col.face? && INTEGER_TYPES.include?(col.data_type)
768
+ return read_boolean_integers(key, col, on_error)
769
+ end
770
+ end
771
+ unless string_column?(col) && CAST_NUMBER_TYPES.include?(CArray.data_type_name(type))
772
+ return col.to_type(type)
773
+ end
774
+ # Text cast to a number -- from an object column or a string Face -- is
775
+ # read the same way, so the decimal grammar and on_error: hold for all.
776
+ text = text_for_decimal_reading(col)
777
+ unreadable = on_error == :mask ? nil : []
778
+ parsed = text.__read_text_as_number__(type, unreadable)
779
+ unless parsed
780
+ # A target the decimal reader does not take (complex) goes through
781
+ # to_type; a cell that held something and came back masked did not read.
782
+ text = time_text_of(col)
783
+ parsed = text.to_type(type)
784
+ if unreadable
785
+ cells = text.flatten.to_a
786
+ masked = parsed.flatten.is_masked.to_a
787
+ unreadable = cells.each_index.select { |i| masked[i] && !missing_text?(cells[i]) }
788
+ end
789
+ end
790
+ if unreadable && !unreadable.empty?
791
+ report_unreadable(key, col, type, unreadable, on_error)
792
+ end
793
+ parsed
794
+ end
795
+
796
+ # Text a boolean column is read from: what to_csv writes (0 / 1) and the
797
+ # words other tools write (true / false in any case). Surrounding spaces
798
+ # are ignored; a missing cell is UNDEF; anything else does not read.
799
+ BOOLEAN_TEXT = { "1" => true, "0" => false, "true" => true, "false" => false }.freeze
800
+ private_constant :BOOLEAN_TEXT
801
+
802
+ # cast(name => :boolean) on a text column. The distinct texts are judged
803
+ # once each and the answer is spread over the cells, so a column of many
804
+ # rows is not walked in Ruby.
805
+ private def read_boolean_text(key, col, on_error)
806
+ text = time_text_of(col)
807
+ trues, known, bad = [], [], []
808
+ text.unique.to_a.each do |v|
809
+ next if missing_text?(v)
810
+ case BOOLEAN_TEXT[v.to_s.strip.downcase]
811
+ when true then trues << v; known << v
812
+ when false then known << v
813
+ else bad << v
814
+ end
815
+ end
816
+ out = text.is_in(trues)
817
+ unknown = text.is_in(known).not
818
+ unknown = unknown.strip_mask(false) if unknown.has_mask?
819
+ if on_error != :mask && !bad.empty?
820
+ addrs = text.is_in(bad)
821
+ addrs = addrs.strip_mask(false) if addrs.has_mask?
822
+ report_unreadable(key, col, :boolean, addrs.flatten.where.to_a, on_error)
823
+ end
824
+ out[unknown] = UNDEF if unknown.any
825
+ out
826
+ end
827
+
828
+ # cast(name => :boolean) on an integer column: 0 is false, 1 is true, and
829
+ # any other value does not read (on_error: says what becomes of it).
830
+ private def read_boolean_integers(key, col, on_error)
831
+ bad = col.ne(0) & col.ne(1)
832
+ bad = bad.strip_mask(false) if bad.has_mask?
833
+ out = col.eq(1)
834
+ if bad.any
835
+ report_unreadable(key, col, :boolean, bad.flatten.where.to_a, on_error) unless on_error == :mask
836
+ out[bad] = UNDEF
837
+ end
838
+ out
839
+ end
840
+
841
+ # cast(name => :time): year-first text to CATime, in the finest unit the
842
+ # text shows.
843
+ private def cast_time_column(key, col, on_error)
844
+ unless string_column?(col)
845
+ raise ArgumentError,
846
+ "cast: column #{key.inspect} is #{col.data_type}, not text; " \
847
+ "to_time reads a column of serial counts as time"
848
+ end
849
+ parse_time_column(key, col, nil, nil, on_error)
850
+ end
851
+
852
+ # Text to CATime. Without a format only year-first text is read; a
853
+ # strptime format, or :mixed for a guess at each cell, goes through
854
+ # CArray.time. Cells that hold something but do not parse are reported as
855
+ # on_error says.
856
+ private def parse_time_column(key, col, format, unit, on_error)
857
+ case format
858
+ when nil then read_time_text(key, col, unit, on_error)
859
+ when String
860
+ parse_time_by_format(key, col, format, unit || CATimeLiteral.format_unit(format), on_error)
861
+ when :infer then parse_time_inferred(key, col, unit)
862
+ when :mixed
863
+ unit ||= CATimeLiteral.unit_shown(time_text_of(col), nil)
864
+ parse_time_by_format(key, col, nil, unit, on_error)
865
+ else
866
+ raise ArgumentError,
867
+ "time format must be a strptime String, :infer or :mixed " \
868
+ "(got #{format.inspect})"
869
+ end
870
+ rescue RangeError => e
871
+ raise unless e.message.include?("does not fit int64 ticks")
872
+ raise RangeError, "column #{key.inspect}: #{e.message}; parse_to_time takes " \
873
+ "unit: to read it in a coarser unit (:us spans 292,000 years)"
874
+ end
875
+
876
+ # :infer reads the column in the one format its text is written in; a cell
877
+ # that is not in it raises.
878
+ private def parse_time_inferred(key, col, unit)
879
+ f, fields, bad = inferred_time_format(key, col)
880
+ return read_time_text(key, col, unit, :raise) if f.nil?
881
+ unless fields[8].empty?
882
+ cell = col.flatten[fields[8].first]
883
+ raise UnreadableColumn, "column #{key.inspect} holds #{cell.inspect}, not text"
884
+ end
885
+ report_unreadable(key, col, f.format.inspect, [bad], :raise) if bad
886
+ unit ||= case f.kind
887
+ when :date then :D
888
+ when :clock then :s
889
+ else
890
+ digits = fields[9]
891
+ digits > 6 ? :ns : digits > 3 ? :us : :ms
892
+ end
893
+ res = CATime::Resolution.parse(unit)
894
+ unless CATimeLiteral.tick_kind(res)
895
+ return parse_time_by_format(key, col, f.format, unit, :raise)
896
+ end
897
+ CATimeLiteral.ticks_from_fields(fields, col, res, f.format, :raise).time(unit: res)
898
+ end
899
+
900
+ # [format, fields, address of the first cell not in it] for :infer, or nil
901
+ # when the text is written year first (or no cell is present), which
902
+ # needs no format.
903
+ private def inferred_time_format(key, col)
904
+ text = time_text_of(col)
905
+ first = text.flatten.to_a.find { |cell| !missing_text?(cell) }
906
+ return nil if first.nil?
907
+ unless first.is_a?(String)
908
+ raise UnreadableColumn, "column #{key.inspect} holds #{first.inspect}, not text"
909
+ end
910
+ first = first.strip
911
+ unreadable = []
912
+ begin
913
+ CA_OBJECT([first]).__parse_time_text__(nil, unreadable)
914
+ rescue RangeError
915
+ return nil # year first, only too far out for its unit
916
+ end
917
+ return nil if unreadable.empty?
918
+ CATimeLiteral.infer_time_format(text, first)
919
+ end
920
+
921
+ # The column as an object array of its text. A CAString already holds one,
922
+ # as its parent; other string Faces convert.
923
+ # The column as the decimal readers take it: a CAConstString is read from
924
+ # its buffer, the rest as time_text_of gives them.
925
+ private def text_for_decimal_reading(col)
926
+ col.is_a?(CAConstString) ? col : time_text_of(col)
927
+ end
928
+
929
+ private def time_text_of(col)
930
+ return col if col.data_type == CA_OBJECT && !col.face?
931
+ return col.parent if col.is_a?(CAString)
932
+ col.to_type(:object)
933
+ end
934
+
935
+ # Year-first text, read in C. A unit the reader does not write itself is
936
+ # reached by reading in the finest unit the text shows and converting.
937
+ # The reader is asked only for a unit of one base tick (:s, :ms, ...), given
938
+ # as a Symbol; any unit spelling (a String, a CATime::Resolution) is
939
+ # normalized first.
940
+ private def read_time_text(key, col, unit, on_error)
941
+ text = time_text_of(col)
942
+ res = unit && CATime::Resolution.parse(unit)
943
+ base = res && res.count == 1 ? res.base : nil
944
+ unreadable = on_error == :mask ? nil : []
945
+ # Read wide: the column is time by the caller's word, so a year past
946
+ # 9999 and a date that stops at the month or the year (what to_csv writes
947
+ # for those) read too. Trying text as time (infer_types) reads strictly.
948
+ ticks, read_unit = (base && text.__parse_time_text__(base, unreadable, true)) ||
949
+ text.__parse_time_text__(nil, unreadable, true)
950
+ if unreadable && !unreadable.empty?
951
+ report_unreadable(key, col, :time, unreadable, on_error)
952
+ end
953
+ times = ticks.time(unit: read_unit)
954
+ (res.nil? || read_unit == base) ? times : times.to_unit(res)
955
+ end
956
+
957
+ # A strptime format, or nil for :mixed (a guess at each cell). The cells
958
+ # that do not parse are reported as on_error says; blank text is missing.
959
+ private def parse_time_by_format(key, col, format, unit, on_error)
960
+ res = CATime::Resolution.parse(unit)
961
+ text = time_text_of(col)
962
+ fields = format && CATimeLiteral.tick_kind(res) &&
963
+ text.__strptime_fields__(format, false)
964
+ if fields
965
+ parsed = CATimeLiteral.ticks_from_fields(fields, text, res, format, :mask)
966
+ .time(unit: res)
967
+ return parsed if on_error == :mask
968
+ cells = text.flatten.to_a
969
+ masked = parsed.flatten
970
+ addrs = (fields[7] + fields[8].select { |i| UNDEF.equal?(masked[i]) })
971
+ .reject { |i| missing_text?(cells[i]) }.sort
972
+ else
973
+ parsed = CArray.time(col, format: format, unit: unit, on_error: :mask)
974
+ return parsed if on_error == :mask
975
+ failed = col.flatten.to_a.zip(parsed.flatten.is_masked.to_a)
976
+ addrs = failed.each_index.select do |i|
977
+ cell, masked = failed[i]
978
+ masked && !missing_text?(cell)
979
+ end
980
+ end
981
+ report_unreadable(key, col, :time, addrs, on_error) unless addrs.empty?
982
+ parsed
983
+ end
984
+
985
+ # nil, UNDEF and a blank String are missing values.
986
+ # Every present cell is true or false in some case, and one is present.
987
+ # The first present cell settles most columns, so the distinct words are
988
+ # gathered only when it is one.
989
+ private def boolean_word_column?(col)
990
+ first = nil
991
+ col.each { |v| unless missing_text?(v) then first = v; break end }
992
+ return false unless first && boolean_word?(first)
993
+ words = time_text_of(col).unique.to_a.reject { |v| missing_text?(v) }
994
+ words.all? { |v| boolean_word?(v) }
995
+ end
996
+
997
+ private def boolean_word?(cell)
998
+ %w[true false].include?(cell.to_s.strip.downcase)
999
+ end
1000
+
1001
+ private def missing_text?(cell)
1002
+ cell.nil? || UNDEF.equal?(cell) || (cell.is_a?(String) && cell.strip.empty?)
1003
+ end
1004
+
1005
+ # A column is time text only if every present cell is, so one cell that
1006
+ # holds something and does not read settles it without reading the rest.
1007
+ # Only a refusal is taken from it: a blank cell says nothing.
1008
+ private def first_present_cell_can_be_time_text?(col)
1009
+ i = 0
1010
+ if col.has_mask?
1011
+ present = col.is_not_masked.where
1012
+ return true if present.elements == 0
1013
+ i = present[0]
1014
+ end
1015
+ return true if col.elements == 0
1016
+ unreadable = []
1017
+ time_text_of(col[i..i]).__parse_time_text__(nil, unreadable)
1018
+ unreadable.empty?
1019
+ rescue RangeError
1020
+ true
1021
+ end
1022
+
1023
+ # Whether every present cell of a text column is year-first text, and one
1024
+ # is present. A time too far out for the unit its text shows is still time
1025
+ # text; the cast that follows says it does not fit.
1026
+ private def time_text_column?(col)
1027
+ unreadable = []
1028
+ ticks, = col.__parse_time_text__(nil, unreadable)
1029
+ unreadable.empty? && ticks.count_not_masked > 0
1030
+ rescue RangeError
1031
+ unreadable.empty? # the reader lists unreadable cells before it raises
1032
+ end
1033
+
1034
+ # +addrs+ are flat addresses into +col+; a row holds elements / nrow cells.
1035
+ private def report_unreadable(key, col, type, addrs, on_error)
1036
+ cells = col.flatten
1037
+ per_row = col.elements / col.shape[0]
1038
+ describe = ->(addr) { "row #{addr / per_row} #{cells[addr].inspect}" }
1039
+ if on_error == :raise
1040
+ raise UnreadableColumn,
1041
+ "column #{key.inspect}, #{describe[addrs[0]]} cannot be read as #{type}"
1042
+ end
1043
+ shown = addrs.first(3).map(&describe)
1044
+ shown << "..." if addrs.size > 3
1045
+ noun, verb = addrs.size == 1 ? %w[cell is] : %w[cells are]
1046
+ warn "CAFrame#cast: column #{key.inspect}: #{addrs.size} #{noun} cannot be " \
1047
+ "read as #{type} and #{verb} UNDEF (#{shown.join(', ')})"
242
1048
  end
243
1049
 
244
1050
  # +promote+ with no target: let +promote_list+ decide the common type --
@@ -249,7 +1055,7 @@ class CAFrame
249
1055
  cols = @columns.values
250
1056
  begin
251
1057
  promoted = CArray.promote_list(cols)
252
- rescue ArgumentError, RuntimeError => e
1058
+ rescue ArgumentError, TypeError, CArray::DataTypeError => e
253
1059
  raise ArgumentError,
254
1060
  "#{e.message} -- promote(:object) brings every column to its " \
255
1061
  "surface values, which any column set can share; a numeric target " \
@@ -258,6 +1064,7 @@ class CAFrame
258
1064
  @columns.keys.each_with_index do |key, i|
259
1065
  coerced = promoted[i]
260
1066
  next if coerced.equal?(cols[i])
1067
+ # coerce: the type promote_list chose
261
1068
  @columns[key] = cols[i].to_type(coerced.data_type)
262
1069
  end
263
1070
  end
@@ -306,7 +1113,7 @@ class CAFrame
306
1113
  col.to_type(:int64)
307
1114
  elsif col.data_type == :float32 || col.data_type == :float64
308
1115
  unless col.floor.eq(col).all
309
- raise ArgumentError,
1116
+ raise UnreadableColumn,
310
1117
  "to_time: float column #{key.inspect} has fractional values; " \
311
1118
  "use a finer unit: or convert to integer counts explicitly"
312
1119
  end