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