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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +824 -0
- data/README.md +5 -6
- data/carray.gemspec +1 -1
- data/ext/ca_axis_dispatch.c +258 -4
- data/ext/ca_axis_group.c +368 -184
- data/ext/ca_binop_dispatch.c +54 -32
- data/ext/ca_binop_dispatch.h +1 -0
- data/ext/ca_categorical_iterator.c +90 -41
- data/ext/ca_compare.c +8 -2
- data/ext/ca_for_buffer.h +21 -68
- data/ext/ca_group_iter.c +27 -11
- data/ext/ca_kernel_iterator.c +960 -245
- data/ext/ca_kernel_iterator.h +352 -181
- data/ext/ca_monop_dispatch.c +4 -4
- data/ext/ca_monop_dispatch.h +4 -0
- data/ext/ca_obj_array.c +194 -42
- data/ext/ca_obj_bincmp.c +152 -121
- data/ext/ca_obj_binop.c +107 -210
- data/ext/ca_obj_bitarray.c +37 -24
- data/ext/ca_obj_bitfield.c +95 -62
- data/ext/ca_obj_block.c +5 -4
- data/ext/ca_obj_byte_swap.c +40 -40
- data/ext/ca_obj_const_string.c +180 -50
- data/ext/ca_obj_face.c +428 -62
- data/ext/ca_obj_face.h +107 -47
- data/ext/ca_obj_fake.c +21 -50
- data/ext/ca_obj_farray.c +1 -0
- data/ext/ca_obj_field.c +16 -3
- data/ext/ca_obj_fixlen_string.c +39 -26
- data/ext/ca_obj_grid.c +181 -124
- data/ext/ca_obj_meld.c +235 -55
- data/ext/ca_obj_moncmp.c +61 -61
- data/ext/ca_obj_monop.c +125 -107
- data/ext/ca_obj_object.c +551 -233
- data/ext/ca_obj_record.c +51 -16
- data/ext/ca_obj_reduce.c +5 -8
- data/ext/ca_obj_refer.c +117 -33
- data/ext/ca_obj_remap.c +57 -17
- data/ext/ca_obj_repeat.c +21 -5
- data/ext/ca_obj_roll.c +115 -11
- data/ext/ca_obj_select.c +88 -75
- data/ext/ca_obj_select_axis.c +58 -60
- data/ext/ca_obj_shift.c +22 -102
- data/ext/ca_obj_stack.c +223 -123
- data/ext/ca_obj_stride.c +267 -35
- data/ext/ca_obj_string.c +25 -22
- data/ext/ca_obj_tile.c +32 -16
- data/ext/ca_obj_time.c +23 -25
- data/ext/ca_obj_timedelta.c +21 -23
- data/ext/ca_obj_transpose.c +12 -3
- data/ext/ca_obj_triop.c +110 -117
- data/ext/ca_obj_window.c +157 -29
- data/ext/ca_op_ipower.c +25 -13
- data/ext/ca_op_powi.h +57 -30
- data/ext/ca_pow5_table.h +656 -0
- data/ext/ca_rng_normal.h +42 -0
- data/ext/ca_rng_xoshiro256pp.h +105 -0
- data/ext/ca_sort_kernels.h +70 -0
- data/ext/ca_sweep_engine.c +385 -198
- data/ext/ca_sweep_engine.h +31 -23
- data/ext/ca_triop_dispatch.c +4 -1
- data/ext/ca_triop_dispatch.h +5 -2
- data/ext/caframe_csv_reader.c +463 -0
- data/ext/carray.h +90 -28
- data/ext/carray_access.c +487 -231
- data/ext/carray_address_basis.c +731 -0
- data/ext/carray_attribute.c +45 -3
- data/ext/carray_bincount.c +32 -2
- data/ext/carray_broadcast.c +49 -17
- data/ext/carray_call_cfunc.c +667 -511
- data/ext/carray_cast.c +1556 -247
- data/ext/carray_cast_func.rb +34 -21
- data/ext/carray_conversion.c +197 -73
- data/ext/carray_copy.c +84 -38
- data/ext/carray_core.c +591 -119
- data/ext/carray_count.c +148 -96
- data/ext/carray_element.c +18 -27
- data/ext/carray_factorize.c +604 -291
- data/ext/carray_generate.c +76 -40
- data/ext/carray_histogram.c +152 -94
- data/ext/carray_hold.c +2 -1
- data/ext/carray_index_classifier.c +63 -96
- data/ext/carray_internal.h +400 -1
- data/ext/carray_lazy.c +834 -79
- data/ext/carray_loop.c +88 -66
- data/ext/carray_mask.c +705 -324
- data/ext/carray_math_kernel.h +1 -0
- data/ext/carray_median_percentile.c +263 -63
- data/ext/carray_memory_view.c +254 -248
- data/ext/carray_operator.c +1093 -363
- data/ext/carray_order.c +133 -69
- data/ext/carray_partition.c +102 -56
- data/ext/carray_random.c +582 -204
- data/ext/carray_scatter.c +308 -65
- data/ext/carray_segment.c +257 -0
- data/ext/carray_slab.c +183 -70
- data/ext/carray_sort.c +235 -240
- data/ext/carray_sort_kernel.c +8 -1
- data/ext/carray_struct.c +99 -21
- data/ext/carray_test.c +167 -64
- data/ext/carray_time_text.c +1182 -0
- data/ext/carray_utils.c +363 -98
- data/ext/extconf.rb +27 -2
- data/ext/mk_call_cfunc.rb +103 -118
- data/ext/mk_pow5_table.rb +35 -0
- data/ext/mkkernel.rb +1349 -851
- data/ext/ruby_carray.c +17 -2
- data/ext/version.h +4 -4
- data/ext/version.rb +4 -2
- data/lib/carray/attribute.rb +39 -0
- data/lib/carray/autoload_carray.rb +24 -3
- data/lib/carray/autoload_method_extension.rb +18 -0
- data/lib/carray/axis_group.rb +245 -20
- data/lib/carray/basics.rb +95 -25
- data/lib/carray/bincount_nd.rb +6 -0
- data/lib/carray/block_iterator.rb +200 -54
- data/lib/carray/boolean_reduce.rb +21 -29
- data/lib/carray/categorical.rb +244 -59
- data/lib/carray/categorical_iterator.rb +391 -757
- data/lib/carray/complex.rb +36 -29
- data/lib/carray/conditional.rb +94 -7
- data/lib/carray/const_string.rb +177 -27
- data/lib/carray/construct.rb +40 -0
- data/lib/carray/data_type_extension.rb +3 -0
- data/lib/carray/data_type_limits.rb +91 -0
- data/lib/carray/fixlen_string.rb +1 -1
- data/lib/carray/frame/concat.rb +157 -6
- data/lib/carray/frame/convert.rb +13 -7
- data/lib/carray/frame/csv_parser.rb +590 -76
- data/lib/carray/frame/describe.rb +79 -0
- data/lib/carray/frame/frame.rb +205 -35
- data/lib/carray/frame/group.rb +199 -40
- data/lib/carray/frame/io.rb +452 -60
- data/lib/carray/frame/join.rb +134 -46
- data/lib/carray/frame/records.rb +54 -19
- data/lib/carray/frame/reshape.rb +448 -0
- data/lib/carray/frame/sort.rb +26 -2
- data/lib/carray/frame/verbs.rb +726 -56
- data/lib/carray/frame.rb +2 -0
- data/lib/carray/fuse_source.rb +111 -2
- data/lib/carray/fusion.rb +229 -38
- data/lib/carray/inspect.rb +71 -20
- data/lib/carray/iterator.rb +157 -2
- data/lib/carray/lazy.rb +54 -67
- data/lib/carray/mask_gap_fill.rb +66 -50
- data/lib/carray/math.rb +32 -19
- data/lib/carray/meld_reduce.rb +40 -7
- data/lib/carray/methods/align_addr.rb +8 -4
- data/lib/carray/methods/bincount.rb +2 -2
- data/lib/carray/methods/bit_string.rb +14 -3
- data/lib/carray/methods/composition.rb +16 -10
- data/lib/carray/methods/discovery_along.rb +74 -0
- data/lib/carray/methods/factorize.rb +50 -0
- data/lib/carray/methods/gather_nd.rb +13 -2
- data/lib/carray/methods/insert_block.rb +9 -14
- data/lib/carray/methods/is_in.rb +130 -6
- data/lib/carray/methods/join.rb +1 -1
- data/lib/carray/methods/locate_addr.rb +235 -21
- data/lib/carray/methods/mask_duplicates.rb +35 -1
- data/lib/carray/methods/mode.rb +18 -1
- data/lib/carray/methods/nunique.rb +22 -1
- data/lib/carray/methods/repeat.rb +91 -0
- data/lib/carray/methods/resize.rb +21 -23
- data/lib/carray/methods/snap.rb +34 -12
- data/lib/carray/methods/unique.rb +52 -3
- data/lib/carray/record_order.rb +208 -0
- data/lib/carray/rng.rb +86 -0
- data/lib/carray/runtime.rb +14 -11
- data/lib/carray/segment_iterator.rb +990 -0
- data/lib/carray/serialize.rb +197 -27
- data/lib/carray/slab_iterator.rb +109 -40
- data/lib/carray/stack.rb +10 -4
- data/lib/carray/string_operation_extension.rb +9 -4
- data/lib/carray/struct.rb +142 -51
- data/lib/carray/struct_builder.rb +110 -14
- data/lib/carray/table.rb +2 -2
- data/lib/carray/time.rb +535 -105
- data/lib/carray/window_iterator.rb +259 -82
- data/lib/carray.rb +4 -2
- data/yard-stubs/ca_obj_array.rb +4 -4
- data/yard-stubs/ca_obj_bitarray.rb +7 -0
- data/yard-stubs/ca_obj_bitfield.rb +23 -12
- data/yard-stubs/ca_obj_block.rb +2 -7
- data/yard-stubs/ca_obj_fake.rb +1 -1
- data/yard-stubs/ca_obj_field.rb +1 -1
- data/yard-stubs/ca_obj_refer.rb +3 -3
- data/yard-stubs/ca_obj_roll.rb +0 -1
- data/yard-stubs/ca_obj_stride.rb +10 -7
- data/yard-stubs/ca_obj_transpose.rb +1 -1
- data/yard-stubs/ca_obj_window.rb +11 -4
- data/yard-stubs/carray_access.rb +6 -5
- data/yard-stubs/carray_broadcast.rb +2 -2
- data/yard-stubs/carray_cast.rb +78 -43
- data/yard-stubs/carray_conversion.rb +13 -1
- data/yard-stubs/carray_copy.rb +8 -3
- data/yard-stubs/carray_core.rb +3 -83
- data/yard-stubs/carray_count.rb +16 -2
- data/yard-stubs/carray_lazy.rb +210 -1
- data/yard-stubs/carray_loop.rb +4 -1
- data/yard-stubs/carray_mask.rb +34 -10
- data/yard-stubs/carray_math.rb +1499 -4
- data/yard-stubs/carray_median_percentile.rb +26 -8
- data/yard-stubs/carray_order.rb +43 -82
- data/yard-stubs/carray_scatter.rb +10 -3
- data/yard-stubs/carray_segment.rb +72 -0
- data/yard-stubs/carray_slab.rb +11 -8
- data/yard-stubs/carray_sort.rb +50 -8
- data/yard-stubs/carray_test.rb +10 -0
- data/yard-stubs/carray_utils.rb +0 -26
- metadata +19 -19
- data/ext/ca_for_each_element.h +0 -239
- data/ext/carray_build_flags.h +0 -3
- data/ext/carray_config.h +0 -28
- data/ext/carray_kernels_bincmp.c +0 -4446
- data/ext/carray_kernels_binop.c +0 -11001
- data/ext/carray_kernels_init.c +0 -1131
- data/ext/carray_kernels_map.c +0 -3467
- data/ext/carray_kernels_moncmp.c +0 -2097
- data/ext/carray_kernels_monop.c +0 -18313
- data/ext/carray_kernels_reduce_aggregate.c +0 -25837
- data/ext/carray_kernels_reduce_boolean.c +0 -330
- data/ext/carray_kernels_reduce_cumulative.c +0 -14593
- data/ext/carray_kernels_reduce_extreme.c +0 -16948
- data/ext/carray_kernels_reduce_variance.c +0 -3910
- data/ext/carray_kernels_scan.c +0 -3693
- data/ext/carray_kernels_search.c +0 -32138
- data/ext/carray_kernels_sort.c +0 -10626
- data/ext/carray_kernels_triop.c +0 -1392
data/lib/carray/frame/verbs.rb
CHANGED
|
@@ -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).
|
|
59
|
-
#
|
|
60
|
-
#
|
|
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.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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+.
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
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
|
-
|
|
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] =
|
|
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+
|
|
143
|
-
# carry, so it raises rather than
|
|
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
|
|
211
|
-
#
|
|
212
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
233
|
-
col[] =
|
|
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
|
-
|
|
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}"
|
|
239
|
-
|
|
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,
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
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
|