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