carray 3.0.0 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +2 -1
  3. data/CHANGELOG.md +845 -3
  4. data/{NEWS.md → CHANGELOG.v1.md} +1 -1
  5. data/README.md +33 -33
  6. data/carray.gemspec +12 -8
  7. data/ext/ca_axis_dispatch.c +33 -4
  8. data/ext/ca_axis_group.c +256 -114
  9. data/ext/ca_binop_dispatch.c +5 -6
  10. data/ext/ca_binop_dispatch.h +0 -7
  11. data/ext/ca_categorical_iterator.c +121 -67
  12. data/ext/ca_for_each_element.h +6 -8
  13. data/ext/ca_group_iter.c +1 -1
  14. data/ext/ca_kernel_iterator.c +386 -74
  15. data/ext/ca_kernel_iterator.h +145 -45
  16. data/ext/ca_obj_array.c +62 -20
  17. data/ext/ca_obj_bincmp.c +57 -27
  18. data/ext/ca_obj_binop.c +86 -30
  19. data/ext/ca_obj_block.c +4 -4
  20. data/ext/ca_obj_const_string.c +88 -27
  21. data/ext/ca_obj_face.c +104 -0
  22. data/ext/ca_obj_face.h +48 -1
  23. data/ext/ca_obj_fake.c +11 -0
  24. data/ext/ca_obj_fixlen_string.c +21 -6
  25. data/ext/ca_obj_grid.c +11 -3
  26. data/ext/ca_obj_meld.c +125 -22
  27. data/ext/ca_obj_moncmp.c +33 -14
  28. data/ext/ca_obj_monop.c +19 -7
  29. data/ext/ca_obj_object.c +107 -4
  30. data/ext/ca_obj_record.c +3 -1
  31. data/ext/ca_obj_refer.c +30 -6
  32. data/ext/ca_obj_roll.c +13 -5
  33. data/ext/ca_obj_select.c +49 -34
  34. data/ext/ca_obj_select_axis.c +16 -6
  35. data/ext/ca_obj_shift.c +3 -3
  36. data/ext/ca_obj_stack.c +3 -8
  37. data/ext/ca_obj_stride.c +178 -24
  38. data/ext/ca_obj_string.c +11 -5
  39. data/ext/ca_obj_tile.c +13 -5
  40. data/ext/ca_obj_time.c +3 -1
  41. data/ext/ca_obj_timedelta.c +3 -1
  42. data/ext/ca_obj_transpose.c +1 -1
  43. data/ext/ca_obj_triop.c +62 -23
  44. data/ext/ca_obj_window.c +94 -19
  45. data/ext/ca_op_cmplx64.h +123 -0
  46. data/ext/ca_op_ipower.c +1 -5
  47. data/ext/ca_rng_normal.h +42 -0
  48. data/ext/ca_rng_xoshiro256pp.h +105 -0
  49. data/ext/ca_sort_kernels.h +5 -5
  50. data/ext/ca_sweep_engine.c +362 -155
  51. data/ext/ca_sweep_engine.h +39 -9
  52. data/ext/ca_transform_common.c +7 -0
  53. data/ext/carray.h +148 -132
  54. data/ext/carray_access.c +83 -49
  55. data/ext/carray_address_basis.c +590 -0
  56. data/ext/carray_bincount.c +8 -8
  57. data/ext/carray_broadcast.c +102 -9
  58. data/ext/carray_build_flags.h +3 -0
  59. data/ext/carray_call_cfunc.c +2010 -335
  60. data/ext/carray_call_cfunc.h +153 -0
  61. data/ext/carray_cast.c +184 -72
  62. data/ext/carray_conversion.c +22 -22
  63. data/ext/carray_copy.c +67 -32
  64. data/ext/carray_core.c +140 -13
  65. data/ext/carray_count.c +10 -11
  66. data/ext/carray_element.c +7 -7
  67. data/ext/carray_factorize.c +72 -51
  68. data/ext/carray_hold.c +1 -1
  69. data/ext/carray_index_classifier.c +7 -21
  70. data/ext/carray_internal.h +36 -3
  71. data/ext/carray_kernels_bincmp.c +1 -0
  72. data/ext/carray_kernels_binop.c +320 -298
  73. data/ext/carray_kernels_init.c +1095 -0
  74. data/ext/carray_kernels_map.c +1 -0
  75. data/ext/carray_kernels_moncmp.c +1 -0
  76. data/ext/carray_kernels_monop.c +412 -411
  77. data/ext/carray_kernels_reduce_aggregate.c +256 -87
  78. data/ext/carray_kernels_reduce_boolean.c +1 -0
  79. data/ext/carray_kernels_reduce_cumulative.c +324 -54
  80. data/ext/carray_kernels_reduce_extreme.c +605 -58
  81. data/ext/carray_kernels_reduce_variance.c +1 -0
  82. data/ext/carray_kernels_scan.c +5 -4
  83. data/ext/carray_kernels_search.c +187 -106
  84. data/ext/carray_kernels_sort.c +1 -0
  85. data/ext/carray_kernels_triop.c +1 -0
  86. data/ext/carray_lazy.c +175 -5
  87. data/ext/carray_loop.c +7 -1
  88. data/ext/carray_mask.c +29 -13
  89. data/ext/carray_median_percentile.c +56 -1
  90. data/ext/carray_memory_view.c +46 -21
  91. data/ext/carray_operator.c +50 -53
  92. data/ext/carray_order.c +1 -1
  93. data/ext/carray_partition.c +4 -4
  94. data/ext/carray_random.c +391 -47
  95. data/ext/carray_scatter.c +1 -1
  96. data/ext/carray_slab.c +13 -0
  97. data/ext/carray_sort.c +22 -24
  98. data/ext/carray_sort_kernel.c +10 -10
  99. data/ext/carray_test.c +1 -1
  100. data/ext/extconf.rb +18 -0
  101. data/ext/mk_call_cfunc.rb +323 -145
  102. data/ext/mkkernel.rb +651 -129
  103. data/ext/ruby_carray.c +28 -17
  104. data/ext/version.h +4 -4
  105. data/lib/carray/autoload_carray.rb +24 -3
  106. data/lib/carray/autoload_method_extension.rb +14 -1
  107. data/lib/carray/axis_group.rb +84 -7
  108. data/lib/carray/basics.rb +63 -59
  109. data/lib/carray/bincount_nd.rb +12 -12
  110. data/lib/carray/block_iterator.rb +111 -25
  111. data/lib/carray/categorical.rb +154 -37
  112. data/lib/carray/categorical_iterator.rb +300 -151
  113. data/lib/carray/conditional.rb +14 -14
  114. data/lib/carray/const_string.rb +131 -27
  115. data/lib/carray/construct.rb +61 -0
  116. data/lib/carray/core_extensions.rb +62 -5
  117. data/lib/carray/data_type_extension.rb +21 -1
  118. data/lib/carray/data_type_limits.rb +91 -0
  119. data/lib/carray/fixlen_string.rb +1 -1
  120. data/lib/carray/frame/concat.rb +3 -3
  121. data/lib/carray/frame/convert.rb +1 -1
  122. data/lib/carray/frame/csv_parser.rb +11 -4
  123. data/lib/carray/frame/frame.rb +85 -21
  124. data/lib/carray/frame/group.rb +36 -3
  125. data/lib/carray/frame/io.rb +224 -15
  126. data/lib/carray/frame/records.rb +18 -4
  127. data/lib/carray/frame/sort.rb +1 -1
  128. data/lib/carray/frame/verbs.rb +32 -12
  129. data/lib/carray/fuse_source.rb +123 -0
  130. data/lib/carray/fusion.rb +218 -0
  131. data/lib/carray/histogram.rb +16 -16
  132. data/lib/carray/inspect.rb +43 -16
  133. data/lib/carray/iterator.rb +147 -3
  134. data/lib/carray/lazy.rb +88 -73
  135. data/lib/carray/mask_gap_fill.rb +3 -1
  136. data/lib/carray/meld_reduce.rb +2 -2
  137. data/lib/carray/methods/align_addr.rb +1 -1
  138. data/lib/carray/methods/composition.rb +1 -1
  139. data/lib/carray/methods/discovery_along.rb +74 -0
  140. data/lib/carray/methods/factorize.rb +50 -0
  141. data/lib/carray/methods/is_in.rb +25 -14
  142. data/lib/carray/methods/locate_addr.rb +81 -3
  143. data/lib/carray/methods/mask_duplicates.rb +36 -2
  144. data/lib/carray/methods/meshgrid.rb +4 -5
  145. data/lib/carray/methods/mode.rb +2 -2
  146. data/lib/carray/methods/nunique.rb +23 -2
  147. data/lib/carray/methods/repeat.rb +110 -0
  148. data/lib/carray/methods/snap.rb +7 -2
  149. data/lib/carray/methods/unique.rb +44 -4
  150. data/lib/carray/methods/value_counts.rb +2 -2
  151. data/lib/carray/rng.rb +86 -0
  152. data/lib/carray/runtime.rb +0 -19
  153. data/lib/carray/slab_iterator.rb +77 -19
  154. data/lib/carray/string_operation_extension.rb +10 -6
  155. data/lib/carray/time.rb +906 -491
  156. data/lib/carray/window_iterator.rb +421 -27
  157. data/lib/carray.rb +7 -5
  158. data/yard-stubs/ca_obj_array.rb +385 -0
  159. data/yard-stubs/ca_obj_bitarray.rb +38 -0
  160. data/yard-stubs/ca_obj_bitfield.rb +43 -0
  161. data/yard-stubs/ca_obj_block.rb +68 -0
  162. data/yard-stubs/ca_obj_byte_swap.rb +56 -0
  163. data/yard-stubs/ca_obj_fake.rb +31 -0
  164. data/yard-stubs/ca_obj_farray.rb +32 -0
  165. data/yard-stubs/ca_obj_field.rb +45 -0
  166. data/yard-stubs/ca_obj_grid.rb +35 -0
  167. data/yard-stubs/ca_obj_refer.rb +72 -0
  168. data/yard-stubs/ca_obj_roll.rb +45 -0
  169. data/yard-stubs/ca_obj_shift.rb +43 -0
  170. data/yard-stubs/ca_obj_stride.rb +181 -0
  171. data/yard-stubs/ca_obj_tile.rb +29 -0
  172. data/yard-stubs/ca_obj_transpose.rb +40 -0
  173. data/yard-stubs/ca_obj_window.rb +57 -0
  174. data/yard-stubs/carray_access.rb +131 -0
  175. data/yard-stubs/carray_attribute.rb +246 -0
  176. data/yard-stubs/carray_broadcast.rb +37 -0
  177. data/yard-stubs/carray_cast.rb +489 -0
  178. data/yard-stubs/carray_class.rb +65 -0
  179. data/yard-stubs/carray_conversion.rb +76 -0
  180. data/yard-stubs/carray_copy.rb +79 -0
  181. data/yard-stubs/carray_core.rb +34 -0
  182. data/yard-stubs/carray_count.rb +84 -0
  183. data/yard-stubs/carray_element.rb +108 -0
  184. data/yard-stubs/carray_generate.rb +66 -0
  185. data/yard-stubs/carray_lazy.rb +228 -0
  186. data/yard-stubs/carray_loop.rb +140 -0
  187. data/yard-stubs/carray_mask.rb +259 -0
  188. data/yard-stubs/carray_math.rb +1615 -0
  189. data/yard-stubs/carray_mathfunc.rb +45 -0
  190. data/yard-stubs/carray_median_percentile.rb +103 -0
  191. data/yard-stubs/carray_memory_view.rb +163 -0
  192. data/yard-stubs/carray_order.rb +252 -0
  193. data/yard-stubs/carray_random.rb +89 -0
  194. data/yard-stubs/carray_scatter.rb +106 -0
  195. data/yard-stubs/carray_slab.rb +59 -0
  196. data/yard-stubs/carray_sort.rb +165 -0
  197. data/yard-stubs/carray_test.rb +85 -0
  198. data/yard-stubs/carray_undef.rb +64 -0
  199. data/yard-stubs/carray_utils.rb +97 -0
  200. data/yard-stubs/ruby_carray.rb +193 -0
  201. metadata +67 -10
  202. data/ext/ca_obj_unbound_repeat.c +0 -496
@@ -11,12 +11,27 @@ class CAFrame
11
11
  # later. Broken cells fail to_type and become UNDEF automatically
12
12
  # (parse-mask, §6-2).
13
13
  #
14
+ # +source+ is a path, or an open IO -- anything answering +gets+, which a
15
+ # StringIO is. So CSV that is already in memory does not have to go to a
16
+ # temporary file first:
17
+ #
18
+ # CAFrame.from_csv("obs.csv") # a path
19
+ # CAFrame.from_csv(StringIO.new(body)) # text already in hand
20
+ # File.open("obs.csv") { |io| CAFrame.from_csv(io) }
21
+ #
22
+ # A String is always read as a path, never as CSV text: guessing between the
23
+ # two by looking for a newline is the kind of guess that is right until it is
24
+ # not, and StringIO says which one you meant in six characters. An IO is read
25
+ # from where it is and left open -- the caller opened it and closes it.
26
+ #
14
27
  # Parsing uses the built-in fast tokenizer (CSVParser). Options:
15
28
  # sep: field separator (default ",")
16
29
  # quote: quote character (default '"')
17
30
  # strip: trim spaces from unquoted fields (default false, RFC spacing)
18
- # +encoding+: IO open-mode encoding (default "bom|utf-8", strips a BOM)
19
- # parser: a callable path -> [headers, rows] to inject another parser
31
+ # +encoding+: open-mode encoding for a path (default "bom|utf-8", strips a
32
+ # BOM). It has nothing to open when +source+ is an IO, so there
33
+ # the IO's own encoding governs and a BOM is the caller's.
34
+ # parser: a callable source -> [headers, rows] to inject another parser
20
35
  # (e.g. the stdlib +csv+, or a typed-table source); when given,
21
36
  # sep/quote/strip/encoding and any block are that parser's concern.
22
37
  #
@@ -28,25 +43,25 @@ class CAFrame
28
43
  # skip 2; header; skip 1; body
29
44
  # end
30
45
  #
46
+ # A missing field -- an unquoted empty one, or a cell a short row never
47
+ # reached -- is UNDEF in the frame, whether or not the column is cast by
48
+ # +types:+. A quoted empty field ("") is the empty string, which is a value.
49
+ # So the mask +to_csv+ writes comes back as a mask.
50
+ #
31
51
  # Columns are handed to the frame as CABlock views over one backing object
32
52
  # array (§3.6 view-by-default); casting a column materializes it, and +copy+
33
53
  # gives an independent frame.
34
- def self.from_csv(path, types: nil,
54
+ def self.from_csv(source, types: nil,
35
55
  sep: ",", quote: '"', strip: false,
36
56
  encoding: "bom|utf-8", parser: nil, &block)
37
57
  names, rows =
38
58
  if parser
39
- parser.call(path)
59
+ parser.call(source)
60
+ elsif source.respond_to?(:gets)
61
+ read_csv(source, sep: sep, quote: quote, strip: strip, &block)
40
62
  else
41
- File.open(path, "r:#{encoding}") do |io|
42
- reader = CSVReader.new(io, sep: sep, quote: quote, strip: strip)
43
- if block
44
- block.arity == 1 ? block.call(reader) : reader.instance_exec(&block)
45
- else
46
- reader.header
47
- reader.body
48
- end
49
- reader.result
63
+ File.open(source, "r:#{encoding}") do |io|
64
+ read_csv(io, sep: sep, quote: quote, strip: strip, &block)
50
65
  end
51
66
  end
52
67
 
@@ -55,6 +70,22 @@ class CAFrame
55
70
  frame
56
71
  end
57
72
 
73
+ # Drive the reading-control DSL over one open IO and hand back
74
+ # [names, rows]. Shared by the path and the IO source, so the two cannot
75
+ # come to read a file differently.
76
+ def self.read_csv (io, sep:, quote:, strip:, &block)
77
+ reader = CSVReader.new(io, sep: sep, quote: quote, strip: strip)
78
+ if block
79
+ block.arity == 1 ? block.call(reader) : reader.instance_exec(&block)
80
+ else
81
+ reader.header
82
+ reader.body
83
+ end
84
+ reader.result
85
+ end
86
+
87
+ private_class_method :read_csv
88
+
58
89
  # Build a frame from parsed [names, rows]. When names is nil (headerless and
59
90
  # no column_names) positional names "c0".."cN" are generated from the widest
60
91
  # row. Rows are squared off to the column count (short rows padded with nil,
@@ -62,8 +93,8 @@ class CAFrame
62
93
  # is a view into it (§3.6).
63
94
  # Write the frame as CSV. CSV is a flat table of scalar cells, so this is the
64
95
  # text form of the same all-scalar subset +to_ca+ requires (§11.9): every
65
- # column must be 1-D. Unlike +to_ca+ it does not promote to a common dtype --
66
- # each column is formatted to text independently, so mixed dtypes (numbers,
96
+ # column must be 1-D. Unlike +to_ca+ it does not promote to a common data type --
97
+ # each column is formatted to text independently, so mixed data types (numbers,
67
98
  # strings, datetime / categorical Faces) sit side by side. An N-D column has
68
99
  # no flat CSV cell and raises; export it per column, or use +to_records+ +
69
100
  # JSON for the structured shape (memo §11.9, the N-D escape).
@@ -115,6 +146,177 @@ class CAFrame
115
146
  end
116
147
  end
117
148
 
149
+ # Render the frame as an aligned text table for reading:
150
+ #
151
+ # puts df.to_table
152
+ #
153
+ # time temp station
154
+ # ---------- ---- -------
155
+ # 2026-01-01 1.5 Tokyo
156
+ # 2026-01-02 _ Osaka
157
+ #
158
+ # This is the display counterpart of +to_csv+ and shares none of its
159
+ # constraints: it is text meant to be looked at, not read back. Numeric
160
+ # columns are right-aligned, everything else left-aligned; a masked cell
161
+ # shows as +_+, the same marker CArray's own inspect uses. An N-D column
162
+ # (which +to_csv+ rejects, having no flat cell) shows each row's slice as
163
+ # an Array literal.
164
+ #
165
+ # Float cells are rounded to +precision+ decimal places for display only
166
+ # (default 6); +precision: nil+ prints them at full precision, which is
167
+ # faithful but lets one long value set the column width.
168
+ #
169
+ # Long frames are truncated in the middle: +rows+ caps how many rows are
170
+ # printed (default 20, split evenly around an ellipsis row), and
171
+ # +rows: nil+ prints every row. +index: false+ drops the index column.
172
+ def to_table(rows: 20, index: true, precision: 6)
173
+ head = rows && (rows + 1) / 2
174
+ render_table(head: head, tail: rows && rows - head,
175
+ index: index, precision: precision, footer: true)
176
+ end
177
+
178
+ # +to_s+ is the whole frame, +inspect+ the middle-elided one -- so +puts df+
179
+ # dumps everything and +p df+ stays a screenful. +inspect+ leads with the
180
+ # same summary line it always had (nrow, variable data types, index), so the
181
+ # table under it needs no row-count footer.
182
+ def to_s
183
+ to_table(rows: nil)
184
+ end
185
+
186
+ # The summary line (nrow, variable data types, index) followed by the
187
+ # middle-elided table.
188
+ # @return [String]
189
+ def inspect
190
+ parts = @columns.map { |k, v| "#{k}:#{v.data_type}#{v.ndim > 1 ? v.shape[1..].inspect : ''}" }
191
+ idx = @index ? " index=#{@axis_name.inspect}" : ""
192
+ head = "#<CAFrame nrow=#{@nrow} vars=[#{parts.join(', ')}]#{idx}>"
193
+ # The table counts the index as a column, so a frame whose only data is its
194
+ # index has one to show. Gate on the same thing render_table does.
195
+ return head if @columns.empty? && @index.nil?
196
+ head + "\n" + render_table(head: 8, tail: 2, index: true, precision: 6,
197
+ footer: false)
198
+ end
199
+
200
+ private def render_table(head:, tail:, index:, precision:, footer:)
201
+ names = []
202
+ columns = []
203
+ aligns = []
204
+
205
+ if index && @index
206
+ names << @axis_name
207
+ columns << @index
208
+ aligns << (@index.numeric? ? :right : :left)
209
+ end
210
+ @columns.each do |name, col|
211
+ names << name
212
+ columns << col
213
+ aligns << (col.ndim == 1 && col.numeric? ? :right : :left)
214
+ end
215
+ return "" if names.empty?
216
+
217
+ positions = table_row_positions(head, tail)
218
+ body = positions.map do |i|
219
+ if i.nil?
220
+ Array.new(columns.size, ":") # vertical ellipsis for the elided middle
221
+ else
222
+ columns.map { |col| format_table_cell(col, i, precision) }
223
+ end
224
+ end
225
+
226
+ widths = names.each_with_index.map do |name, j|
227
+ [display_width(name), *body.map { |cells| display_width(cells[j]) }].max
228
+ end
229
+
230
+ out = +""
231
+ out << table_row(names, widths, aligns) << "\n"
232
+ out << table_row(widths.map { |w| "-" * w }, widths, aligns) << "\n"
233
+ body.each { |cells| out << table_row(cells, widths, aligns) << "\n" }
234
+ if footer && positions.size - positions.count(nil) < @nrow
235
+ out << "(#{plural(@nrow, 'row')}, #{plural(@columns.size, 'variable')})\n"
236
+ end
237
+ out
238
+ end
239
+
240
+ # Row indices to print, with nil marking the elided middle. A nil head means
241
+ # no cap; a frame that already fits in head + tail is listed whole.
242
+ private def table_row_positions(head, tail)
243
+ return (0...@nrow).to_a if head.nil? || @nrow <= head + tail
244
+ (0...head).to_a + [nil] + ((@nrow - tail)...@nrow).to_a
245
+ end
246
+
247
+ private def table_row(cells, widths, aligns)
248
+ line = cells.each_with_index.map do |text, j|
249
+ pad = " " * (widths[j] - display_width(text))
250
+ aligns[j] == :right ? pad + text : text + pad
251
+ end.join(" ")
252
+ line.rstrip
253
+ end
254
+
255
+ # Column widths are counted in terminal cells, not characters: a CJK
256
+ # ideograph, kana, or full-width form occupies two cells, so counting
257
+ # characters would leave every column holding such a name ragged. The
258
+ # ranges below are the East Asian Wide / Fullwidth blocks; a combining
259
+ # mark takes no cell of its own.
260
+ WIDE_CHAR_RANGES = [
261
+ 0x1100..0x115F, 0x2E80..0x303E, 0x3041..0x33FF, 0x3400..0x4DBF,
262
+ 0x4E00..0x9FFF, 0xA000..0xA4CF, 0xA960..0xA97F, 0xAC00..0xD7A3,
263
+ 0xF900..0xFAFF, 0xFE10..0xFE19, 0xFE30..0xFE6F, 0xFF00..0xFF60,
264
+ 0xFFE0..0xFFE6, 0x1F300..0x1F64F, 0x1F900..0x1F9FF, 0x20000..0x3FFFD,
265
+ ].freeze
266
+ private_constant :WIDE_CHAR_RANGES
267
+
268
+ COMBINING_RANGES = [0x0300..0x036F, 0x1AB0..0x1AFF, 0x20D0..0x20F0].freeze
269
+ private_constant :COMBINING_RANGES
270
+
271
+ private def display_width(text)
272
+ text.each_char.sum do |ch|
273
+ cp = ch.ord
274
+ if COMBINING_RANGES.any? { |r| r.cover?(cp) }
275
+ 0
276
+ elsif WIDE_CHAR_RANGES.any? { |r| r.cover?(cp) }
277
+ 2
278
+ else
279
+ 1
280
+ end
281
+ end
282
+ end
283
+
284
+ private def plural(n, noun)
285
+ "#{n} #{noun}#{n == 1 ? '' : 's'}"
286
+ end
287
+
288
+ # Render one N-D cell the way Array#inspect would, except that a masked
289
+ # element prints as the table's missing marker rather than as UNDEF.
290
+ private def format_nested_cell(v)
291
+ case v
292
+ when Array then "[" + v.map { |x| format_nested_cell(x) }.join(", ") + "]"
293
+ else UNDEF.equal?(v) || v.nil? ? "_" : v.inspect
294
+ end
295
+ end
296
+
297
+ private def format_table_cell(col, i, precision)
298
+ e = elem_at(col, i)
299
+ if UNDEF.equal?(e) || e.nil?
300
+ "_"
301
+ elsif e.is_a?(Float) && precision
302
+ # Display rounding only: a full-precision float (141.67833333333334)
303
+ # sets the column width for every other row and makes the table hard
304
+ # to read. precision: nil prints the value as Ruby renders it.
305
+ e.round(precision).to_s
306
+ elsif e.is_a?(CArray)
307
+ # An N-D cell renders its elements, and a masked element among them
308
+ # takes the same marker a masked scalar does -- UNDEF's own inspect
309
+ # would put a second spelling of "missing" in the same table.
310
+ format_nested_cell(e.to_a)
311
+ elsif e.is_a?(String)
312
+ e
313
+ elsif e.respond_to?(:iso8601)
314
+ e.iso8601
315
+ else
316
+ e.to_s
317
+ end
318
+ end
319
+
118
320
  private def format_csv_column(col)
119
321
  col.to_a.map do |e|
120
322
  if UNDEF.equal?(e) || e.nil?
@@ -157,6 +359,13 @@ class CAFrame
157
359
  end
158
360
  end
159
361
  table = CArray.object(rows.size, ncol) { rows }
362
+ # A missing field is UNDEF, not a Ruby nil sitting in a cell. The
363
+ # tokenizer says missing with nil (an unquoted empty field; a quoted
364
+ # one is the empty string and stays a value), and an object array will
365
+ # hold that nil quite happily -- so a column read without `types:` used
366
+ # to carry nil where the same column read with one carried UNDEF, and
367
+ # the mask a to_csv had written did not survive the trip back.
368
+ table[:eq, nil] = UNDEF
160
369
  names.each_with_index { |name, j| cols[name] = table[nil, j] }
161
370
  new(cols)
162
371
  end
@@ -59,30 +59,44 @@ class CAFrame
59
59
  # native scalar column (nil -> UNDEF); anything else -> object column.
60
60
  def self.build_record_column(values, n)
61
61
  present = values.reject(&:nil?)
62
- return CArray.object(n) { values } if present.empty?
62
+ return mask_missing(CArray.object(n) { values }) if present.empty?
63
63
 
64
64
  if present.all? { |v| v.is_a?(Array) || v.is_a?(CArray) }
65
65
  build_nd_column(values, present, n)
66
66
  else
67
67
  type = numeric_leaf_type(present)
68
68
  col = CArray.object(n) { values }
69
- type ? col.to_type(type) : col
69
+ type ? col.to_type(type) : mask_missing(col)
70
70
  end
71
71
  end
72
72
  private_class_method :build_record_column
73
73
 
74
+ # A missing cell is UNDEF, not a Ruby nil sitting in a cell. to_records
75
+ # writes a masked cell as nil, so nil on the way back in is the only
76
+ # spelling a missing cell has; to_type does this conversion for a numeric
77
+ # column, and an object column would otherwise keep the nil as a value --
78
+ # a row with no label coming back as a row labelled nil. The CSV reader
79
+ # takes the same position (see build_frame in io.rb).
80
+ def self.mask_missing(col)
81
+ col[:eq, nil] = UNDEF
82
+ col
83
+ end
84
+ private_class_method :mask_missing
85
+
74
86
  # Stack equal-length array cells into an (N, L) column via an object 2-D fill
75
87
  # + to_type (nil rows -> UNDEF, int/float by leaf). Ragged lengths or
76
88
  # non-numeric leaves fall back to a 1-D object column of the raw cells.
77
89
  def self.build_nd_column(values, present, n)
78
90
  lengths = present.map { |v| v.is_a?(CArray) ? v.shape[0] : v.size }
79
91
  len = lengths.first
80
- return CArray.object(n) { values } unless lengths.all? { |x| x == len }
92
+ unless lengths.all? { |x| x == len }
93
+ return mask_missing(CArray.object(n) { values })
94
+ end
81
95
 
82
96
  nested = values.map { |v| v.nil? ? Array.new(len) : (v.is_a?(CArray) ? v.to_a : v) }
83
97
  type = numeric_leaf_type(nested.flatten.compact)
84
98
  table = CArray.object(n, len) { nested }
85
- type ? table.to_type(type) : table
99
+ type ? table.to_type(type) : mask_missing(table)
86
100
  end
87
101
  private_class_method :build_nd_column
88
102
 
@@ -13,7 +13,7 @@
13
13
  #
14
14
  # sort_addr is ascending, so a descending key is expressed by replacing it with
15
15
  # its dense descending rank (CArray#order(descending: true, method: :dense)):
16
- # this works for every dtype (unlike negation, which cannot reverse a string and
16
+ # this works for every data type (unlike negation, which cannot reverse a string and
17
17
  # silently wraps an unsigned integer), and the *dense* rank keeps equal values
18
18
  # on one rank so ties fall through to later keys in a multi-key sort.
19
19
  # masked_position (CArray.sort_addr's kwarg) places masked key rows first or
@@ -143,11 +143,28 @@ class CAFrame
143
143
  # carry, so it raises rather than silently truncate. Make it the index with
144
144
  # +set_index+ afterward.
145
145
  #
146
+ # A +CATime::Grid+ carries the same (unit, epoch) pair as one value, so a
147
+ # netCDF +units+ attribute goes straight in. It also carries a phase the
148
+ # keyword form cannot: the keyword +epoch+ is read on the +unit+ grid, so
149
+ # an epoch off that grid ("days since 1980-01-01 12:00") loses its
150
+ # time-of-day, while a grid resolves the finer storage that holds it.
151
+ #
146
152
  # df.to_time("time", unit: :h, epoch: "1990-01-01").set_index("time")
147
- def to_time(name, unit: :s, epoch: nil)
153
+ # df.to_time("time", CATime::Grid.parse("hours since 1990-01-01"))
154
+ # df.to_time("time", CATime::Grid.parse("days since 1980-01-01 12:00"))
155
+ def to_time(name, grid = nil, unit: :s, epoch: nil)
148
156
  key = name.to_s
149
157
  col = @columns.fetch(key) { raise KeyError, "no column #{key.inspect}" }
150
158
  raw = integer_serial_column(col, key)
159
+ grid = unit if unit.is_a?(CATime::Grid)
160
+ if grid.is_a?(CATime::Grid)
161
+ @columns[key] = grid.at(raw)
162
+ return self
163
+ end
164
+ unless grid.nil?
165
+ raise ArgumentError,
166
+ "the positional argument must be a CATime::Grid (got #{grid.class})"
167
+ end
151
168
  if epoch
152
169
  raw = raw + CArray.time(epoch, unit: unit).ticks[0]
153
170
  end
@@ -213,7 +230,9 @@ class CAFrame
213
230
  return
214
231
  end
215
232
  yvalid = col.value.float64[present]
216
- col[] = yvalid.linear_fetch(addr).to_type(col.data_type).mask_invalid # write-through
233
+ # mask_invalid before the cast: linear_fetch marks out of range with NaN,
234
+ # and casting back to an integer column would turn it into a plausible 0.
235
+ col[] = yvalid.linear_fetch(addr).mask_invalid.to_type(col.data_type) # write-through
217
236
  end
218
237
 
219
238
  private def cast_one(name, type)
@@ -250,16 +269,17 @@ class CAFrame
250
269
  "promote takes a data type Symbol (got #{type.class}); " \
251
270
  "class-shaped targets are not promotion destinations"
252
271
  end
253
- @columns.each_key do |key|
254
- col = @columns[key]
255
- # A Face column answers for itself: :object is its surface values, a
256
- # numeric target is whatever it declares in #to_numeric (and a TypeError
257
- # naming that method when it declares nothing). result_type has nothing
258
- # to say about a surface it cannot read, so the widening check -- which
259
- # is about primitive promotion -- applies to plain columns only.
260
- refuse_narrowing(key, col, type) unless col.face?
261
- @columns[key] = col.to_type(type)
262
- end
272
+ # Check every column before rebinding any. A column that would narrow
273
+ # rejects the whole promote, and rejecting part way through would leave the
274
+ # frame promoted in whichever columns happened to come first.
275
+ #
276
+ # A Face column answers for itself: :object is its surface values, a
277
+ # numeric target is whatever it declares in #to_numeric (and a TypeError
278
+ # naming that method when it declares nothing). result_type has nothing
279
+ # to say about a surface it cannot read, so the widening check -- which
280
+ # is about primitive promotion -- applies to plain columns only.
281
+ @columns.each { |key, col| refuse_narrowing(key, col, type) unless col.face? }
282
+ @columns.each_key { |key| @columns[key] = @columns[key].to_type(type) }
263
283
  end
264
284
 
265
285
  private def refuse_narrowing(key, col, type)
@@ -0,0 +1,123 @@
1
+ # ---------------------------------------------------------------------------
2
+ # Reading `CArray.fuse { a + b * c }`.
3
+ #
4
+ # The block is not called. Its `a` is the array itself, so calling it would
5
+ # evaluate the expression eagerly -- which is the thing fuse exists to avoid.
6
+ # The source is read instead, every name that holds a CArray is given `.lazy`,
7
+ # and the result is evaluated back in the block's own binding, so `self`,
8
+ # instance variables, methods and constants are what they were.
9
+ #
10
+ # Ruby has no macro, so the alternative was to pass the arrays in and take
11
+ # shadows back -- `fuse(a, b) { |x, y| ... }` -- which names each of them
12
+ # twice. Julia writes `@.` for the same reason and does the same thing to
13
+ # the expression underneath.
14
+ # ---------------------------------------------------------------------------
15
+
16
+ require "prism"
17
+
18
+ class CArray
19
+
20
+ module FuseSource
21
+
22
+ # Runtime coercion, so the rewrite never has to work out what a name
23
+ # holds: anything that is not an array passes through untouched.
24
+ def self.shadow (value)
25
+ value.is_a?(CArray) ? value.lazy : value
26
+ end
27
+
28
+ def self.evaluate (block)
29
+ result = eval(rewrite(body_source(block)), block.binding,
30
+ *block.source_location)
31
+ # An expression that is just an array is that array; the shadow put
32
+ # around it has nothing to fuse.
33
+ result.is_a?(CALazyMarker) ? result.parent : result
34
+ end
35
+
36
+ # -- the block's own text ---------------------------------------------
37
+
38
+ def self.body_source (block)
39
+ text = extract(block)
40
+ wrapped = "proc " + text
41
+ node = Prism.parse(wrapped).value
42
+ .breadth_first_search { |n| n.is_a?(Prism::BlockNode) }
43
+ inner = node && node.body
44
+ unless inner
45
+ raise ArgumentError,
46
+ "CArray.fuse could not read an expression out of this block"
47
+ end
48
+ wrapped.byteslice(inner.location.start_offset...inner.location.end_offset)
49
+ end
50
+
51
+ def self.extract (block)
52
+ sequence = RubyVM::InstructionSequence.of(block) rescue nil
53
+ location = sequence && sequence.to_a[4][:code_location]
54
+ path = sequence && (sequence.absolute_path || sequence.path)
55
+ unless location && path && File.readable?(path)
56
+ raise ArgumentError,
57
+ "CArray.fuse cannot read this block's source (defined in irb, " \
58
+ "eval, or a file that is no longer there). Write `.lazy` on " \
59
+ "the operands instead: `a.lazy + b.lazy`."
60
+ end
61
+ lines = File.readlines(path)
62
+ first_line, first_column, last_line, last_column = location
63
+ # The columns count bytes, not characters, so a line with anything
64
+ # multi-byte on it slices in the wrong place unless this does too.
65
+ if first_line == last_line
66
+ lines[first_line - 1].byteslice(first_column...last_column)
67
+ else
68
+ [lines[first_line - 1].byteslice(first_column..),
69
+ *lines[first_line...(last_line - 1)],
70
+ lines[last_line - 1].byteslice(0...last_column)].join
71
+ end
72
+ end
73
+
74
+ # -- the rewrite -------------------------------------------------------
75
+
76
+ # The leaves are the names being read. Everything else keeps its shape:
77
+ # calls are inserted around leaves and the expression they sit in is
78
+ # left alone.
79
+ class Leaves < Prism::Visitor
80
+ attr_reader :spots
81
+
82
+ def initialize
83
+ @spots = []
84
+ end
85
+
86
+ def visit_local_variable_read_node (node) = mark(node)
87
+ def visit_instance_variable_read_node (node) = mark(node)
88
+ def visit_constant_read_node (node) = mark(node)
89
+
90
+ # `Math::PI` is one name, not `Math` with something after it.
91
+ def visit_constant_path_node (node)
92
+ mark(node)
93
+ end
94
+
95
+ def visit_call_node (node)
96
+ if node.name == :[] || node.name == :[]=
97
+ # An index is a position, not a value to fuse: `a[i]` shadows `a`
98
+ # and leaves `i` alone.
99
+ visit(node.receiver)
100
+ return
101
+ end
102
+ mark(node) if node.receiver.nil? && node.arguments.nil? && node.block.nil?
103
+ super
104
+ end
105
+
106
+ private
107
+
108
+ def mark (node)
109
+ @spots << [node.location.start_offset, node.location.end_offset]
110
+ end
111
+ end
112
+
113
+ def self.rewrite (source)
114
+ visitor = Leaves.new
115
+ Prism.parse(source).value.accept(visitor)
116
+ out = source.dup
117
+ visitor.spots.sort_by { |start, _| -start }.each do |start, stop|
118
+ out[start...stop] = "::CArray::FuseSource.shadow(#{source[start...stop]})"
119
+ end
120
+ out
121
+ end
122
+ end
123
+ end