carray 3.0.1 → 3.0.3

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