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.
Files changed (247) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +568 -0
  3. data/README.md +6 -6
  4. data/carray.gemspec +1 -1
  5. data/ext/ca_axis_descriptor.h +27 -24
  6. data/ext/ca_axis_dispatch.c +220 -0
  7. data/ext/ca_axis_group.c +201 -123
  8. data/ext/ca_binop_dispatch.c +46 -52
  9. data/ext/ca_binop_dispatch.h +1 -1
  10. data/ext/ca_categorical_iterator.c +249 -183
  11. data/ext/ca_compare.c +7 -12
  12. data/ext/ca_for_buffer.h +21 -68
  13. data/ext/ca_group_iter.c +38 -23
  14. data/ext/ca_kernel_iterator.c +1765 -2076
  15. data/ext/ca_kernel_iterator.h +584 -566
  16. data/ext/ca_monop_dispatch.c +86 -63
  17. data/ext/ca_monop_dispatch.h +5 -0
  18. data/ext/ca_obj_array.c +223 -56
  19. data/ext/ca_obj_bincmp.c +172 -138
  20. data/ext/ca_obj_binop.c +116 -230
  21. data/ext/ca_obj_bitarray.c +40 -26
  22. data/ext/ca_obj_bitfield.c +100 -68
  23. data/ext/ca_obj_block.c +19 -9
  24. data/ext/ca_obj_byte_swap.c +45 -46
  25. data/ext/ca_obj_const_string.c +269 -44
  26. data/ext/ca_obj_face.c +612 -89
  27. data/ext/ca_obj_face.h +111 -47
  28. data/ext/ca_obj_fake.c +36 -79
  29. data/ext/ca_obj_farray.c +7 -3
  30. data/ext/ca_obj_field.c +33 -8
  31. data/ext/ca_obj_fixlen_string.c +23 -24
  32. data/ext/ca_obj_grid.c +541 -210
  33. data/ext/ca_obj_meld.c +331 -111
  34. data/ext/ca_obj_moncmp.c +80 -65
  35. data/ext/ca_obj_monop.c +177 -149
  36. data/ext/ca_obj_object.c +664 -248
  37. data/ext/ca_obj_record.c +63 -23
  38. data/ext/ca_obj_reduce.c +18 -16
  39. data/ext/ca_obj_refer.c +140 -58
  40. data/ext/ca_obj_remap.c +85 -24
  41. data/ext/ca_obj_repeat.c +24 -9
  42. data/ext/ca_obj_roll.c +121 -16
  43. data/ext/ca_obj_select.c +61 -58
  44. data/ext/ca_obj_select_axis.c +73 -107
  45. data/ext/ca_obj_shift.c +32 -102
  46. data/ext/ca_obj_stack.c +372 -122
  47. data/ext/ca_obj_stride.c +348 -140
  48. data/ext/ca_obj_string.c +88 -22
  49. data/ext/ca_obj_tile.c +38 -21
  50. data/ext/ca_obj_time.c +45 -31
  51. data/ext/ca_obj_timedelta.c +43 -29
  52. data/ext/ca_obj_transpose.c +17 -6
  53. data/ext/ca_obj_triop.c +125 -141
  54. data/ext/ca_obj_window.c +330 -44
  55. data/ext/ca_op_ipower.c +24 -11
  56. data/ext/ca_op_powi.h +57 -30
  57. data/ext/ca_pow5_table.h +656 -0
  58. data/ext/ca_sort_kernels.h +70 -0
  59. data/ext/ca_sweep_engine.c +104 -81
  60. data/ext/ca_sweep_engine.h +7 -20
  61. data/ext/ca_triop_dispatch.c +4 -1
  62. data/ext/ca_triop_dispatch.h +5 -2
  63. data/ext/caframe_csv_reader.c +542 -0
  64. data/ext/carray.h +241 -92
  65. data/ext/carray_access.c +608 -276
  66. data/ext/carray_address_basis.c +494 -97
  67. data/ext/carray_attribute.c +129 -4
  68. data/ext/carray_bincount.c +39 -7
  69. data/ext/carray_broadcast.c +56 -19
  70. data/ext/carray_build_flags.h +1 -1
  71. data/ext/carray_call_cfunc.c +108 -28
  72. data/ext/carray_cast.c +1576 -223
  73. data/ext/carray_cast_func.rb +77 -24
  74. data/ext/carray_class.c +4 -3
  75. data/ext/carray_conversion.c +208 -75
  76. data/ext/carray_copy.c +82 -24
  77. data/ext/carray_core.c +646 -162
  78. data/ext/carray_count.c +171 -88
  79. data/ext/carray_element.c +94 -60
  80. data/ext/carray_factorize.c +670 -324
  81. data/ext/carray_generate.c +260 -83
  82. data/ext/carray_histogram.c +209 -106
  83. data/ext/carray_hold.c +14 -16
  84. data/ext/carray_index_classifier.c +422 -243
  85. data/ext/carray_internal.h +692 -3
  86. data/ext/carray_kernels_bincmp.c +7 -0
  87. data/ext/carray_kernels_binop.c +487 -120
  88. data/ext/carray_kernels_init.c +653 -50
  89. data/ext/carray_kernels_moncmp.c +15 -8
  90. data/ext/carray_kernels_monop.c +99 -43
  91. data/ext/carray_kernels_reduce_aggregate.c +3925 -6112
  92. data/ext/carray_kernels_reduce_boolean.c +112 -36
  93. data/ext/carray_kernels_reduce_cumulative.c +2127 -6903
  94. data/ext/carray_kernels_reduce_extreme.c +3032 -2892
  95. data/ext/carray_kernels_reduce_variance.c +439 -500
  96. data/ext/carray_kernels_scan.c +1097 -488
  97. data/ext/carray_kernels_search.c +3787 -2176
  98. data/ext/carray_kernels_sort.c +1200 -920
  99. data/ext/carray_kernels_triop.c +479 -7
  100. data/ext/carray_key_windows.c +216 -0
  101. data/ext/carray_lazy.c +967 -153
  102. data/ext/carray_loop.c +81 -65
  103. data/ext/carray_mask.c +910 -324
  104. data/ext/carray_math_kernel.h +1 -0
  105. data/ext/carray_median_percentile.c +299 -100
  106. data/ext/carray_memory_view.c +872 -758
  107. data/ext/carray_operator.c +1171 -364
  108. data/ext/carray_order.c +505 -133
  109. data/ext/carray_partition.c +112 -65
  110. data/ext/carray_random.c +327 -239
  111. data/ext/carray_round.c +273 -0
  112. data/ext/carray_scatter.c +346 -78
  113. data/ext/carray_segment.c +257 -0
  114. data/ext/carray_slab.c +249 -86
  115. data/ext/carray_sort.c +224 -227
  116. data/ext/carray_sort_kernel.c +8 -1
  117. data/ext/carray_struct.c +118 -39
  118. data/ext/carray_test.c +193 -69
  119. data/ext/carray_time_text.c +1182 -0
  120. data/ext/carray_undef.c +4 -0
  121. data/ext/carray_utils.c +579 -113
  122. data/ext/extconf.rb +27 -2
  123. data/ext/mk_call_cfunc.rb +19 -4
  124. data/ext/mk_pow5_table.rb +35 -0
  125. data/ext/mkkernel.rb +1202 -1014
  126. data/ext/ruby_carray.c +17 -5
  127. data/ext/version.h +4 -4
  128. data/ext/version.rb +4 -2
  129. data/lib/carray/attribute.rb +160 -17
  130. data/lib/carray/autoload_carray.rb +23 -27
  131. data/lib/carray/autoload_method_extension.rb +20 -5
  132. data/lib/carray/axis_group.rb +360 -63
  133. data/lib/carray/basics.rb +162 -28
  134. data/lib/carray/bincount_nd.rb +18 -2
  135. data/lib/carray/block_iterator.rb +231 -58
  136. data/lib/carray/boolean_reduce.rb +34 -42
  137. data/lib/carray/categorical.rb +242 -52
  138. data/lib/carray/categorical_iterator.rb +437 -827
  139. data/lib/carray/complex.rb +41 -34
  140. data/lib/carray/conditional.rb +150 -14
  141. data/lib/carray/const_string.rb +59 -4
  142. data/lib/carray/construct.rb +15 -11
  143. data/lib/carray/data_type_extension.rb +1 -0
  144. data/lib/carray/distance_limit.rb +77 -0
  145. data/lib/carray/fixlen_string.rb +7 -1
  146. data/lib/carray/frame/column_verbs.rb +70 -0
  147. data/lib/carray/frame/concat.rb +157 -6
  148. data/lib/carray/frame/convert.rb +82 -7
  149. data/lib/carray/frame/csv_parser.rb +688 -87
  150. data/lib/carray/frame/describe.rb +80 -0
  151. data/lib/carray/frame/frame.rb +369 -45
  152. data/lib/carray/frame/group.rb +631 -90
  153. data/lib/carray/frame/io.rb +506 -79
  154. data/lib/carray/frame/join.rb +411 -62
  155. data/lib/carray/frame/records.rb +116 -19
  156. data/lib/carray/frame/reshape.rb +455 -0
  157. data/lib/carray/frame/sort.rb +30 -18
  158. data/lib/carray/frame/verbs.rb +868 -61
  159. data/lib/carray/frame/windows.rb +117 -0
  160. data/lib/carray/frame.rb +4 -0
  161. data/lib/carray/fuse_source.rb +118 -2
  162. data/lib/carray/fusion.rb +257 -42
  163. data/lib/carray/histogram.rb +6 -1
  164. data/lib/carray/inspect.rb +54 -13
  165. data/lib/carray/iterator.rb +99 -9
  166. data/lib/carray/key_windows.rb +620 -0
  167. data/lib/carray/lazy.rb +216 -193
  168. data/lib/carray/mask_gap_fill.rb +112 -92
  169. data/lib/carray/math.rb +32 -19
  170. data/lib/carray/meld_reduce.rb +52 -21
  171. data/lib/carray/methods/align_addr.rb +8 -4
  172. data/lib/carray/methods/bin.rb +26 -18
  173. data/lib/carray/methods/bincount.rb +18 -2
  174. data/lib/carray/methods/bit_string.rb +15 -4
  175. data/lib/carray/methods/choose.rb +16 -3
  176. data/lib/carray/methods/composition.rb +18 -10
  177. data/lib/carray/methods/factorize.rb +2 -2
  178. data/lib/carray/methods/gather_nd.rb +25 -5
  179. data/lib/carray/methods/insert_block.rb +9 -14
  180. data/lib/carray/methods/is_between.rb +59 -0
  181. data/lib/carray/methods/is_in.rb +140 -10
  182. data/lib/carray/methods/join.rb +2 -7
  183. data/lib/carray/methods/lag.rb +49 -0
  184. data/lib/carray/methods/locate_addr.rb +182 -45
  185. data/lib/carray/methods/mode.rb +18 -1
  186. data/lib/carray/methods/nunique.rb +11 -6
  187. data/lib/carray/methods/repeat.rb +1 -20
  188. data/lib/carray/methods/resize.rb +21 -23
  189. data/lib/carray/methods/snap.rb +34 -12
  190. data/lib/carray/methods/unique.rb +11 -2
  191. data/lib/carray/methods/value_counts.rb +4 -11
  192. data/lib/carray/record_order.rb +208 -0
  193. data/lib/carray/rng.rb +3 -0
  194. data/lib/carray/runtime.rb +65 -17
  195. data/lib/carray/segment_iterator.rb +1221 -0
  196. data/lib/carray/serialize.rb +214 -30
  197. data/lib/carray/slab_iterator.rb +101 -27
  198. data/lib/carray/stack.rb +10 -4
  199. data/lib/carray/string.rb +105 -5
  200. data/lib/carray/string_operation_extension.rb +12 -4
  201. data/lib/carray/struct.rb +137 -58
  202. data/lib/carray/struct_builder.rb +124 -17
  203. data/lib/carray/time.rb +1222 -186
  204. data/lib/carray/window_iterator.rb +242 -81
  205. data/lib/carray.rb +9 -2
  206. data/yard-stubs/ca_obj_array.rb +4 -4
  207. data/yard-stubs/ca_obj_bitarray.rb +7 -0
  208. data/yard-stubs/ca_obj_bitfield.rb +23 -12
  209. data/yard-stubs/ca_obj_fake.rb +1 -1
  210. data/yard-stubs/ca_obj_field.rb +5 -1
  211. data/yard-stubs/ca_obj_grid.rb +21 -10
  212. data/yard-stubs/ca_obj_refer.rb +5 -4
  213. data/yard-stubs/ca_obj_roll.rb +2 -3
  214. data/yard-stubs/ca_obj_shift.rb +5 -1
  215. data/yard-stubs/ca_obj_stride.rb +10 -7
  216. data/yard-stubs/ca_obj_tile.rb +1 -1
  217. data/yard-stubs/ca_obj_transpose.rb +1 -1
  218. data/yard-stubs/ca_obj_window.rb +9 -5
  219. data/yard-stubs/carray_access.rb +11 -5
  220. data/yard-stubs/carray_attribute.rb +0 -5
  221. data/yard-stubs/carray_broadcast.rb +1 -1
  222. data/yard-stubs/carray_cast.rb +88 -43
  223. data/yard-stubs/carray_conversion.rb +15 -2
  224. data/yard-stubs/carray_copy.rb +12 -5
  225. data/yard-stubs/carray_core.rb +3 -3
  226. data/yard-stubs/carray_count.rb +9 -0
  227. data/yard-stubs/carray_element.rb +10 -4
  228. data/yard-stubs/carray_generate.rb +21 -1
  229. data/yard-stubs/carray_lazy.rb +5 -1
  230. data/yard-stubs/carray_loop.rb +4 -1
  231. data/yard-stubs/carray_mask.rb +60 -12
  232. data/yard-stubs/carray_math.rb +52 -14
  233. data/yard-stubs/carray_median_percentile.rb +15 -8
  234. data/yard-stubs/carray_memory_view.rb +40 -23
  235. data/yard-stubs/carray_order.rb +61 -29
  236. data/yard-stubs/carray_random.rb +13 -4
  237. data/yard-stubs/carray_scatter.rb +21 -9
  238. data/yard-stubs/carray_segment.rb +72 -0
  239. data/yard-stubs/carray_slab.rb +10 -2
  240. data/yard-stubs/carray_sort.rb +49 -8
  241. data/yard-stubs/carray_test.rb +15 -3
  242. data/yard-stubs/carray_utils.rb +0 -26
  243. metadata +19 -5
  244. data/ext/ca_for_each_element.h +0 -239
  245. data/ext/carray_kernels_map.c +0 -3467
  246. data/lib/carray/arrow_tensor.rb +0 -401
  247. data/lib/carray/table.rb +0 -108
data/lib/carray/time.rb CHANGED
@@ -11,7 +11,9 @@
11
11
  # - Same pattern for CATimedelta
12
12
  #
13
13
  # Base epoch fixed to 1970-01-01 (Unix epoch).
14
- # NetCDF CF convention (= reference time epoch) is delegated to external gems (timesteps etc.).
14
+ # Counts from another reference time (a NetCDF CF units attribute) convert in
15
+ # core through CATime::Grid; only non-standard calendars are left to external
16
+ # gems.
15
17
 
16
18
  # Internal: unit-conversion algebra shared by CATime / CATimedelta
17
19
  # (used by to_comparable to align a search query to the reference unit).
@@ -25,6 +27,11 @@
25
27
  # Civil-date arithmetic on int64 CArrays: the calendar kernel every time
26
28
  # grid is built on (Howard Hinnant's days<->civil algorithms, vectorized).
27
29
  # Internal to the time surface: nothing here is part of the public API.
30
+ # Loaded with the file, not inside the methods: a require is a lock that
31
+ # Ractors running these methods at the same time can deadlock on.
32
+ require 'date'
33
+ require 'time'
34
+
28
35
  module CATimeCivil
29
36
  module_function
30
37
 
@@ -93,6 +100,53 @@ module CATimeUnitAlgebra
93
100
 
94
101
  module_function
95
102
 
103
+ # An operand of a time or duration operator, taken through its to_ca when
104
+ # it exports MemoryView and defines one (an Apache Arrow duration array, given
105
+ # a bridge that adds to_ca), as the core binary operators take it; other
106
+ # objects are returned as they are, for the operator to accept or refuse.
107
+ def operand(other)
108
+ if !other.is_a?(CArray) && other.respond_to?(:to_ca) &&
109
+ CArray.memory_view_available?(other)
110
+ other.to_ca
111
+ else
112
+ other
113
+ end
114
+ end
115
+
116
+ # A single value an operator meets an array with, as a one-cell array
117
+ # marked scalar (a CScalar) of its kind, or nil when it is not one: a
118
+ # duration -- a CATimedelta::Element, or a String that reads as one
119
+ # ("3 days") -- or an instant -- a CATime::Element, a Time, a Date, or any
120
+ # other String. An instant that lands on the +unit+ grid is given in that
121
+ # unit, so a day array minus "2024-01-02" answers in days.
122
+ def scalar_operand(x, unit)
123
+ case x
124
+ when CATimedelta::Element
125
+ CATimedelta.wrap(CScalar.int64 { x.value }, unit: x.unit)
126
+ when String
127
+ res = begin
128
+ CATime::Resolution.parse(x)
129
+ rescue ArgumentError
130
+ nil
131
+ end
132
+ return CATimedelta.wrap(CScalar.int64 { res.count }, unit: res.base) if res
133
+ scalar_instant(CATimeLiteral.exact_instant(x), unit)
134
+ when CATime::Element
135
+ CATime.wrap(CScalar.int64 { x.value }, unit: x.unit)
136
+ when Time, Date
137
+ scalar_instant(CATimeLiteral.exact_instant(x), unit)
138
+ end
139
+ end
140
+
141
+ def scalar_instant(t, unit)
142
+ v = begin
143
+ convert_instant!(t.parent, t.unit, unit)[0]
144
+ rescue ArgumentError
145
+ return CATime.wrap(CScalar.int64 { t.parent[0] }, unit: t.unit)
146
+ end
147
+ CATime.wrap(CScalar.int64 { v }, unit: unit)
148
+ end
149
+
96
150
  # seconds- (fixed) or months- (calendar) per base tick.
97
151
  def base_ratio(base)
98
152
  FIXED[base] || CALENDAR[base]
@@ -208,7 +262,7 @@ module CATimeUnitAlgebra
208
262
  # fine->coarse divides only when every value is exact; cross-group ALWAYS
209
263
  # raises -- a :M / :Y duration has no fixed ratio to days (a month is
210
264
  # calendar-variable), so it genuinely cannot scale to seconds.
211
- def convert_scale!(storage, from, to)
265
+ def convert_scale!(storage, from, to, instant: false)
212
266
  a = res(from); b = res(to)
213
267
  return storage if a == b
214
268
  r = ratio(a, b)
@@ -221,6 +275,11 @@ module CATimeUnitAlgebra
221
275
  scaled = widen(storage, r.numerator) # coarse -> fine: lossless multiply
222
276
  return scaled if r.denominator == 1
223
277
  unless (scaled % r.denominator).eq(0).all
278
+ if instant
279
+ raise ArgumentError,
280
+ "cannot convert times in #{a} to #{b} without loss: " \
281
+ "some are not on the #{b} grid (they would be moved to it)"
282
+ end
224
283
  raise ArgumentError,
225
284
  "cannot scale duration #{a} to #{b} without loss: " \
226
285
  "some values are not a whole multiple of #{b} " \
@@ -236,7 +295,8 @@ module CATimeUnitAlgebra
236
295
  # CATimedelta#to_unit and by dt +/- td, where the result keeps the time's
237
296
  # unit and a finer duration is truncated to it (a :D time + a 5 h duration
238
297
  # is + 0 days; + 30 h is + 1 day). Cross-group still raises (a calendar
239
- # duration has no fixed ratio to a fixed unit).
298
+ # duration has no fixed ratio to a fixed unit; dt +/- a month moves the date
299
+ # through shift_months instead).
240
300
  def convert_scale_trunc(storage, from, to)
241
301
  a = res(from); b = res(to)
242
302
  return storage if a == b
@@ -251,6 +311,67 @@ module CATimeUnitAlgebra
251
311
  r.denominator == 1 ? scaled : trunc_divide(scaled, r.denominator)
252
312
  end
253
313
 
314
+ # A duration brought onto a time's grid for dt +/- td. A duration finer
315
+ # than the grid is truncated toward zero (a :D time + 5 h is + 0 days), but
316
+ # one whose tick is coarser and not a whole number of grid ticks ("1 day"
317
+ # on a "7 hours" grid, "15 minutes" on a "10 minutes" grid) must land on
318
+ # the grid exactly: cutting it would move the time by part of its own
319
+ # tick, which no rounding of the time asked for.
320
+ def duration_on_grid(storage, from, to)
321
+ r = ratio(res(from), res(to))
322
+ return convert_scale_trunc(storage, from, to) if r.nil? || r <= 1 || r.denominator == 1
323
+ scaled = widen(storage, r.numerator)
324
+ unless (scaled % r.denominator).eq(0).all
325
+ raise ArgumentError,
326
+ "a duration in #{res(from)} does not land on the #{res(to)} grid " \
327
+ "(it is not a whole number of #{res(to)} ticks); convert the time " \
328
+ "to a grid both share first"
329
+ end
330
+ scaled / r.denominator
331
+ end
332
+
333
+ # a + b and a - b on int64 storage (b a CArray or an Integer), raising
334
+ # instead of wrapping around when a result leaves int64. An overflow is a
335
+ # result whose sign differs from both terms of the sum (a and b for +, a
336
+ # and -b for -).
337
+ def checked_add(a, b)
338
+ check_term(b, "+")
339
+ r = a + b
340
+ overflow_raise("+") if ((a ^ r) & (b ^ r)).lt(0).any
341
+ r
342
+ end
343
+
344
+ def checked_sub(a, b)
345
+ check_term(b, "-")
346
+ r = a - b
347
+ overflow_raise("-") if ((a ^ b) & (a ^ r)).lt(0).any
348
+ r
349
+ end
350
+
351
+ def checked_mul(a, k)
352
+ check_term(k, "*")
353
+ lo = a.min
354
+ unless lo == UNDEF
355
+ [lo, a.max].each do |x|
356
+ overflow_raise("*") unless (INT64_MIN..INT64_MAX).cover?(Integer(x) * k)
357
+ end
358
+ end
359
+ a * k
360
+ end
361
+
362
+ def check_term(b, op)
363
+ overflow_raise(op) if b.is_a?(Integer) && !(INT64_MIN..INT64_MAX).cover?(b)
364
+ end
365
+
366
+ def overflow_raise(op)
367
+ raise RangeError,
368
+ "time arithmetic overflows int64 (#{op}): the result is outside " \
369
+ "the range of the storage unit; use a coarser unit"
370
+ end
371
+
372
+ INT64_MIN = -2**63
373
+ INT64_MAX = 2**63 - 1
374
+
254
375
  # Truncating integer division of an int64 CArray (toward zero). CArray
255
376
  # `/` floors toward the past, which is what an instant wants; a duration
256
377
  # is a magnitude and shrinks toward zero instead.
@@ -271,7 +392,7 @@ module CATimeUnitAlgebra
271
392
  def convert_instant!(storage, from, to)
272
393
  a = res(from); b = res(to)
273
394
  return storage if a == b
274
- return convert_scale!(storage, a, b) if ratio(a, b) # same group
395
+ return convert_scale!(storage, a, b, instant: true) if ratio(a, b) # same group
275
396
  if CALENDAR.key?(a.base)
276
397
  convert_instant_calendar_to_fixed(storage, a, b) # widen
277
398
  else
@@ -279,6 +400,40 @@ module CATimeUnitAlgebra
279
400
  end
280
401
  end
281
402
 
403
+ # A time on a fixed grid moved by whole months, as Ruby's Date#>> moves a
404
+ # date: the year and the month move, the day stays unless the new month is
405
+ # shorter (then it is that month's last day), and the time of day stays.
406
+ # The grid has to tile a day, so the result lands on it; on any other grid
407
+ # (:W, "3 days", "7 hours") a month moves a time off its grid, and that
408
+ # raises. +months+ is an Integer or an int64 CArray of the time's shape.
409
+ def shift_months(storage, from, months)
410
+ rd = ratio(CATime::Resolution.new(1, :D), from) # ticks of `from` per day
411
+ unless rd && rd.denominator == 1
412
+ raise ArgumentError,
413
+ "cannot move a time on the #{from} grid by months: a day is not a " \
414
+ "whole number of its ticks, so the result would be off the grid"
415
+ end
416
+ n = rd.numerator
417
+ days = storage / n # floors toward the past
418
+ rest = storage - days * n # the time of day, in ticks
419
+ y, m, d = CATimeCivil.civil_from_days(days)
420
+ ord = y * 12 + (m - 1) + months # month ordinal after the move
421
+ ny = ord / 12
422
+ nm = ord - ny * 12 + 1
423
+ ones = CArray.int64(*storage.shape) { 1 }
424
+ head = CATimeCivil.days_from_civil(ny, nm, ones)
425
+ ord1 = ord + 1
426
+ ny1 = ord1 / 12
427
+ last = CATimeCivil.days_from_civil(ny1, ord1 - ny1 * 12 + 1, ones) - head
428
+ widen(head + d.pmin(last) - 1, n) + rest
429
+ end
430
+
431
+ # Whole months in a calendar duration of +res+: its count of months or of
432
+ # years.
433
+ def months_per_tick(res)
434
+ (res.count * CALENDAR.fetch(res.base)).to_i
435
+ end
436
+
282
437
  # calendar time (Resolution `from`) -> days since the epoch (int64
283
438
  # CArray). Folds the resolution count (value = count-Y/M buckets).
284
439
  def calendar_days_since_epoch(storage, from)
@@ -307,25 +462,29 @@ module CATimeUnitAlgebra
307
462
  widen(calendar_days_since_epoch(storage, from), r.numerator)
308
463
  end
309
464
 
465
+ # Whole days since the epoch (floored) of fixed-length ticks, and the
466
+ # remainder that says whether each instant sits on a day boundary. A tick
467
+ # need not divide a day, nor be a whole number of days ("7 hours",
468
+ # "36 hours", "7 seconds"): the count is scaled to a grid both share.
469
+ def fixed_storage_days(storage, from)
470
+ rd = ratio(CATime::Resolution.new(1, :D), from) # `from` ticks per day
471
+ scaled = widen(storage, rd.denominator)
472
+ n = rd.numerator
473
+ { days: scaled / n, rest: scaled % n }
474
+ end
475
+
310
476
  # fixed-length grid -> calendar time: the coarsening half of
311
477
  # convert_instant!, exact-or-raise. Every instant must land on a day
312
478
  # boundary and then on the calendar boundary itself (the 1st, and
313
479
  # January too for :Y), since a mid-month instant has no :M value.
314
480
  def convert_instant_fixed_to_calendar(storage, from, to)
315
- rd = ratio(CATime::Resolution.new(1, :D), from) # `from` ticks per day
316
- days =
317
- if rd.denominator == 1
318
- n = rd.numerator
319
- unless (storage % n).eq(0).all
320
- raise ArgumentError,
321
- "cannot convert time #{from} to #{to} without loss " \
322
- "(instant is not on a day boundary)"
323
- end
324
- storage / n
325
- else # coarser than a day (:W)
326
- storage * (from.tick_ratio / 86400r).to_i
327
- end
328
- y, m, d = CATimeCivil.civil_from_days(days)
481
+ days = fixed_storage_days(storage, from)
482
+ unless (days[:rest]).eq(0).all
483
+ raise ArgumentError,
484
+ "cannot convert time #{from} to #{to} without loss " \
485
+ "(instant is not on a day boundary)"
486
+ end
487
+ y, m, d = CATimeCivil.civil_from_days(days[:days])
329
488
  on_boundary = d.eq(1)
330
489
  on_boundary &= m.eq(1) if to.base == :Y
331
490
  unless on_boundary.all
@@ -372,20 +531,110 @@ module CATimeUnitAlgebra
372
531
  # month, whatever day and time it carries. The flooring counterpart of
373
532
  # convert_instant_fixed_to_calendar.
374
533
  def convert_instant_fixed_to_calendar_floor(storage, from, to)
375
- rd = ratio(CATime::Resolution.new(1, :D), from) # `from` ticks per day
376
- days =
377
- if rd.denominator == 1
378
- storage / rd.numerator
379
- else # coarser than a day (:W)
380
- storage * (from.tick_ratio / 86400r).to_i
381
- end
382
- y, m, = CATimeCivil.civil_from_days(days)
534
+ y, m, = CATimeCivil.civil_from_days(fixed_storage_days(storage, from)[:days])
383
535
  ord = to.base == :M ? (y * 12 + (m - 1) - 1970 * 12) : (y - 1970)
384
536
  to.count > 1 ? ord / to.count : ord
385
537
  end
386
538
  end
387
539
 
540
+ # A reduction of a time Face takes `fill_value:` as a value of the Face (an
541
+ # Element, or what an assignment to the Face accepts), the same as
542
+ # `strip_mask` and `[]=` read it. The reduction runs on the storage, so the
543
+ # fill goes in after the result is a Face again: a whole reduction keeps its
544
+ # axes for that, so the fill lands in a typed cell, and is then taken out.
545
+ module CATimeSurfaceFill
546
+
547
+ private
548
+
549
+ def with_surface_fill (opts)
550
+ fill = opts[:fill_value]
551
+ return yield(opts) if fill.nil? || fill.equal?(UNDEF)
552
+ o = opts.reject { |k, _| k == :fill_value }
553
+ o[:keep_axis] = true
554
+ r = yield(o)
555
+ kept = nil
556
+ unless opts[:keep_axis]
557
+ axes = opts[:axis].nil? ? (0...ndim).to_a :
558
+ Array(opts[:axis]).map { |a| a < 0 ? a + ndim : a }
559
+ kept = shape.each_with_index.reject { |_, i| axes.include?(i) }.map(&:first)
560
+ end
561
+ surface_fill(r, fill, kept)
562
+ end
563
+
564
+ # `kept` is the shape to return to, or nil to keep the reduced axes.
565
+ def surface_fill (r, fill, kept)
566
+ case r
567
+ when Array then r.map { |x| surface_fill(x, fill, kept) }
568
+ when CArray
569
+ r = r.strip_mask(fill) if r.has_mask?
570
+ if kept.nil? then r
571
+ elsif kept.empty? then r[*Array.new(r.ndim, 0)]
572
+ else r.reshape(*kept)
573
+ end
574
+ else r
575
+ end
576
+ end
577
+
578
+ end
579
+
580
+ # The part of to_comparable the two time Faces share: values written one by
581
+ # one (a Ruby Array) and an array that is not of this kind (an object column,
582
+ # a string column) are each read as a value of this Face by to_comparable, an
583
+ # UNDEF or a masked cell kept masked. Nil for any other operand, which the Face's own
584
+ # to_comparable decides.
585
+ module CATimeOperandValues
586
+
587
+ # The reference and the operand of a comparison (eq, <, count, search,
588
+ # linear_section, pmax ...) brought to one unit: the operand read in its
589
+ # own unit, then the reference's unit when the operand sits on its grid
590
+ # (the reference is not converted), else the finer unit the two share, as
591
+ # is_in and the set operations meet. A value that is not of this kind is
592
+ # refused as to_comparable refuses it. An assignment, which has to land on
593
+ # the reference's grid, reconciles with to_comparable instead.
594
+ # @api private
595
+ def comparable_pair (operand)
596
+ lifted = lift_operand(operand)
597
+ return [self, lifted] if lifted.unit == unit
598
+ narrowed = lifted.to_unit(unit)
599
+ if narrowed.to_unit(lifted.unit).parent.eq(lifted.parent).all
600
+ return [self, narrowed]
601
+ end
602
+ in_common_unit_with(lifted)
603
+ end
604
+
605
+ private
606
+
607
+ # The operand as an array of this kind in its own unit: one value read as
608
+ # it is written, an Array or an array of another kind read value by value
609
+ # in the unit they share, an UNDEF or a masked cell kept masked.
610
+ def lift_operand (operand)
611
+ return operand if operand.is_a?(self.class)
612
+ case operand
613
+ when Array
614
+ # A nested Array keeps its shape; reading it row by row would land
615
+ # each row as one value.
616
+ return lift_operand(CA_OBJECT(operand)) if operand.any?(Array)
617
+ lifted = operand.map { |v| UNDEF.equal?(v) ? nil : lift_operand(v) }
618
+ units = lifted.compact.map(&:unit)
619
+ u = units.empty? ? unit : units.reduce { |a, b| common_unit(a, b) }
620
+ self.class.wrap(CA_INT64(lifted.map { |x| x ? x.to_unit(u).parent[0] : UNDEF }),
621
+ unit: u)
622
+ when CArray
623
+ lift_operand(operand.to_a.flatten).reshape(*operand.shape)
624
+ else
625
+ lift_value(operand) || to_comparable(operand)
626
+ end
627
+ end
628
+
629
+ def reconcile_values (operand)
630
+ to_comparable(lift_operand(operand))
631
+ end
632
+
633
+ end
634
+
388
635
  class CATime
636
+ include CATimeSurfaceFill
637
+ include CATimeOperandValues
389
638
  # @overload new(*shape, unit: :ns)
390
639
  # Allocates a new int64 storage CArray of the given `shape` and
391
640
  # wraps it as a CATime Face with the given `unit`. The
@@ -406,7 +655,7 @@ class CATime
406
655
  # @param unit [Resolution, Symbol, String] tick resolution.
407
656
  # @return [CATime]
408
657
  def self.wrap(raw, unit: :ns)
409
- __wrap__(raw, unit)
658
+ wrap_storage(raw, unit)
410
659
  end
411
660
 
412
661
  # Element-return class: a single time element carries both its
@@ -438,8 +687,6 @@ class CATime
438
687
  # @return [Time] the instant this scalar denotes (UTC). For a calendar
439
688
  # unit this is the first instant (midnight of day 1) of the granule.
440
689
  def to_time
441
- require 'time'
442
- require 'date'
443
690
  case unit.base
444
691
  when :Y then d = epoch_date.next_year(value * unit.count); Time.utc(d.year, d.month, d.day)
445
692
  when :M then d = epoch_date.next_month(value * unit.count); Time.utc(d.year, d.month, d.day)
@@ -451,7 +698,6 @@ class CATime
451
698
  # @return [Date] the date this scalar denotes (UTC). A sub-day unit is
452
699
  # floored to its day.
453
700
  def to_date
454
- require 'date'
455
701
  case unit.base
456
702
  when :Y then epoch_date.next_year(value * unit.count)
457
703
  when :M then epoch_date.next_month(value * unit.count)
@@ -472,7 +718,7 @@ class CATime
472
718
  # @return [String]
473
719
  def to_s
474
720
  case unit.base
475
- when :Y then format("%04d", to_date.year)
721
+ when :Y then to_date.strftime("%Y")
476
722
  when :M then to_date.strftime("%Y-%m")
477
723
  when :W, :D then to_date.strftime("%Y-%m-%d")
478
724
  else to_time.iso8601(fractional_second_digits) # :h .. :as (time shown)
@@ -500,6 +746,10 @@ class CATime
500
746
  to_time <=> other.getutc
501
747
  when (defined?(DateTime) ? DateTime : nil)
502
748
  to_time <=> other.to_time.getutc
749
+ when (defined?(Date) ? Date : nil)
750
+ # a Date names the midnight (UTC) that begins it, as it does when an
751
+ # array is compared with it
752
+ to_time.to_r <=> CATimeLiteral.epoch_seconds(other)
503
753
  end
504
754
  rescue ArgumentError
505
755
  nil
@@ -517,18 +767,27 @@ class CATime
517
767
  [value, unit].hash
518
768
  end
519
769
 
770
+ # Marshal carries the tick and its resolution.
771
+ def marshal_dump = [value, unit.count, unit.base]
772
+
773
+ def marshal_load(data)
774
+ v, count, base = data
775
+ initialize(v, CATime::Resolution.new(count, base))
776
+ end
777
+
520
778
  # @overload +(td)
521
779
  # Returns this instant advanced by a {CATimedelta::Element}. The time
522
780
  # is the anchor: the result keeps `self`'s unit and the duration is
523
781
  # converted into it, truncated toward zero when finer (a :D time + a
524
782
  # 5 h duration is + 0 days; + 30 h is + 1 day) -- the same rule as the
525
- # array {CATime#+}. Only a duration in the SAME group (both calendar
526
- # or both fixed) is accepted -- a cross-group step (a :s time + a :M
527
- # duration) is calendar arithmetic; use `to_date` +
528
- # `Date#next_month` / `#next_year` for that.
783
+ # array {CATime#+}. A month or year duration on a fixed grid moves
784
+ # the date as Ruby's `Date#>>` does (the day stays unless the month is
785
+ # shorter, the time of day stays); a fixed duration on a month or year
786
+ # grid raises, as does a month on a grid a day does not divide.
529
787
  # @param td [CATimedelta::Element]
530
788
  # @return [CATime::Element]
531
- # @raise [TypeError, ArgumentError] on a non-timedelta / cross-group operand.
789
+ # @raise [TypeError, ArgumentError] on a non-timedelta operand, or a
790
+ # pair that has no answer on self's grid.
532
791
  def +(td)
533
792
  shifted_by_duration(td, 1)
534
793
  end
@@ -544,9 +803,13 @@ class CATime
544
803
  case other
545
804
  when Element
546
805
  u = CATimeUnitAlgebra.diff_unit(unit, other.unit)
547
- CATimedelta::Element.new(instant_in(u) - other.instant_in(u), u)
548
- when CATimedelta::Element
806
+ v = instant_in(u) - other.instant_in(u)
807
+ CATimeUnitAlgebra.check_term(v, "-")
808
+ CATimedelta::Element.new(v, u)
809
+ when CATimedelta::Element, CATimedelta
549
810
  shifted_by_duration(other, -1)
811
+ when CATime
812
+ -(other - self)
550
813
  else
551
814
  raise TypeError, "CATime::Element - #{other.class} is not allowed"
552
815
  end
@@ -565,22 +828,33 @@ class CATime
565
828
 
566
829
  # s +/- td: the time is the anchor, so the result keeps self's unit and
567
830
  # the duration is converted into it (truncated toward zero when finer; a
568
- # cross-group calendar duration raises). Mirrors the array CATime +/-.
831
+ # month or year duration on a fixed grid moves the date by months).
832
+ # Mirrors the array CATime +/-.
569
833
  def shifted_by_duration(td, sign)
834
+ # An array of durations gives an array of instants, anchored here.
835
+ return sign > 0 ? td + self : (-td) + self if td.is_a?(CATimedelta)
570
836
  unless td.is_a?(CATimedelta::Element)
571
837
  raise TypeError,
572
838
  "CATime::Element #{sign > 0 ? '+' : '-'} #{td.class} is not " \
573
839
  "allowed (add / subtract a CATimedelta)"
574
840
  end
575
- tv = CATimeUnitAlgebra.convert_scale_trunc(CA_INT64([td.value]), td.unit, unit)[0]
576
- Element.new(value + sign * tv, unit)
841
+ if CATimeUnitAlgebra::CALENDAR.key?(td.unit.base) &&
842
+ !CATimeUnitAlgebra::CALENDAR.key?(unit.base)
843
+ # months on a fixed grid move the date as Date#>> does, as the
844
+ # array does
845
+ months = sign * td.value * CATimeUnitAlgebra.months_per_tick(td.unit)
846
+ return Element.new(CATimeUnitAlgebra.shift_months(CA_INT64([value]), unit, months)[0], unit)
847
+ end
848
+ tv = CATimeUnitAlgebra.duration_on_grid(CA_INT64([td.value]), td.unit, unit)[0]
849
+ v = value + sign * tv
850
+ CATimeUnitAlgebra.check_term(v, sign > 0 ? "+" : "-")
851
+ Element.new(v, unit)
577
852
  end
578
853
 
579
854
  # Base epoch as a Date. next_month / next_year clamp end-of-month
580
855
  # (Jan 31 -> Feb 28), but the base is day 1, so no clamping ever occurs --
581
856
  # do not move the base off the first of the month without revisiting.
582
857
  def epoch_date
583
- require 'date'
584
858
  Date.new(1970, 1, 1, Date::GREGORIAN) # proleptic Gregorian, matching to_time
585
859
  end
586
860
 
@@ -602,6 +876,8 @@ class CATime
602
876
  end
603
877
 
604
878
  class CATimedelta
879
+ include CATimeSurfaceFill
880
+ include CATimeOperandValues
605
881
  # @overload new(*shape, unit: :ns)
606
882
  # Allocates a new int64 storage CArray of the given `shape` and
607
883
  # wraps it as a CATimedelta Face with the given `unit`.
@@ -619,7 +895,7 @@ class CATimedelta
619
895
  # @param unit [CATime::Resolution, Symbol, String] tick resolution.
620
896
  # @return [CATimedelta]
621
897
  def self.wrap(raw, unit: :ns)
622
- __wrap__(raw, unit)
898
+ wrap_storage(raw, unit)
623
899
  end
624
900
 
625
901
  # A single duration: an integer tick count plus the resolution those ticks
@@ -686,6 +962,14 @@ class CATimedelta
686
962
  [value, unit].hash
687
963
  end
688
964
 
965
+ # Marshal carries the tick and its resolution.
966
+ def marshal_dump = [value, unit.count, unit.base]
967
+
968
+ def marshal_load(data)
969
+ v, count, base = data
970
+ initialize(v, CATime::Resolution.new(count, base))
971
+ end
972
+
689
973
  # @overload +(other)
690
974
  # Returns the sum of two durations, promoting to the finer unit. A
691
975
  # cross-group operand (a :M + a :s duration) has no fixed relation and
@@ -706,7 +990,9 @@ class CATimedelta
706
990
  # @return [CATimedelta::Element]
707
991
  def *(n)
708
992
  raise TypeError, "CATimedelta::Element * #{n.class} is not allowed (Integer only)" unless n.is_a?(Integer)
709
- Element.new(value * n, unit)
993
+ v = value * n
994
+ CATimeUnitAlgebra.check_term(v, "*")
995
+ Element.new(v, unit)
710
996
  end
711
997
 
712
998
  # @overload /(other)
@@ -741,12 +1027,21 @@ class CATimedelta
741
1027
  private
742
1028
 
743
1029
  def combined_with_duration(other, sign)
1030
+ # An array gives an array: durations by durations, and instants for a
1031
+ # duration added to times (subtracting times from a duration is not
1032
+ # defined).
1033
+ case other
1034
+ when CATimedelta then return sign > 0 ? other + self : -(other - self)
1035
+ when CATime then return other + self if sign > 0
1036
+ end
744
1037
  unless other.is_a?(Element)
745
1038
  raise TypeError,
746
1039
  "CATimedelta::Element #{sign > 0 ? '+' : '-'} #{other.class} is not allowed"
747
1040
  end
748
1041
  u = CATimeUnitAlgebra.finer(unit, other.unit)
749
- Element.new(duration_in(u) + sign * other.duration_in(u), u)
1042
+ v = duration_in(u) + sign * other.duration_in(u)
1043
+ CATimeUnitAlgebra.check_term(v, sign > 0 ? "+" : "-")
1044
+ Element.new(v, u)
750
1045
  end
751
1046
  end
752
1047
  end
@@ -813,19 +1108,36 @@ class CATime
813
1108
  # Returns `self + other` for a {CATimedelta}: the time is the
814
1109
  # anchor, so the result keeps `self`'s unit and the duration is converted
815
1110
  # into it (a duration finer than `self`'s unit is truncated to it; a
816
- # cross-group calendar duration raises). Adding two datetimes is
817
- # ill-defined.
818
- # @param other [CATimedelta]
1111
+ # coarser one that is not a whole number of `self`'s ticks raises). A
1112
+ # month or year duration on a fixed grid moves each date as Ruby's
1113
+ # `Date#>>` does: the day stays unless the new month is shorter (then it
1114
+ # is that month's last day), and the time of day stays, so
1115
+ # `2024-01-31 + 1 month` is `2024-02-29`. The grid has to divide a day
1116
+ # into whole ticks; on any other (`:W`, `"3 days"`) a month raises, as a
1117
+ # fixed duration on a month or year grid does. Adding two datetimes is
1118
+ # ill-defined. A single duration -- a {CATimedelta::Element} or a String
1119
+ # such as `"3 days"` -- is added to every cell.
1120
+ # @param other [CATimedelta, CATimedelta::Element, String]
1121
+ # (or a MemoryView producer whose +to_ca+ gives one, such as an Arrow
1122
+ # duration array)
819
1123
  # @return [CATime]
820
1124
  # @raise [TypeError] on a non-timedelta operand.
1125
+ # @raise [ArgumentError] when the duration does not land on `self`'s grid.
1126
+ # @raise [RangeError] when a result leaves the range of int64 ticks.
821
1127
  def +(other)
1128
+ given, other = other, CATimeUnitAlgebra.operand(other)
1129
+ other = CATimeUnitAlgebra.scalar_operand(other, unit) || other
822
1130
  case other
823
1131
  when CATimedelta
824
- (parent + CATimeUnitAlgebra.convert_scale_trunc(other.parent, other.unit, unit)).time(unit: unit)
1132
+ return shifted_by_months(other, 1) if moved_by_months?(other)
1133
+ d = CATimeUnitAlgebra.duration_on_grid(other.parent, other.unit, unit)
1134
+ d = d[0] if other.scalar?
1135
+ CATimeUnitAlgebra.checked_add(parent, d).time(unit: unit)
825
1136
  when CATime
826
1137
  raise TypeError, "CATime + CATime is ill-defined"
827
1138
  else
828
- raise TypeError, "CATime + #{other.class} is not allowed (use CATimedelta)"
1139
+ raise TypeError, "CATime + #{given.class} is not allowed: add a duration " \
1140
+ "(a CATimedelta, a CATimedelta::Element or a String such as \"3 days\")"
829
1141
  end
830
1142
  end
831
1143
 
@@ -833,24 +1145,91 @@ class CATime
833
1145
  # Subtracting a {CATimedelta} yields a {CATime} at `self`'s unit (the
834
1146
  # duration is converted into it, truncated when finer); subtracting
835
1147
  # another {CATime} yields a {CATimedelta} at the finer of the two
836
- # units (cross-group falls to the fixed unit).
837
- # @param other [CATimedelta, CATime]
1148
+ # units (cross-group falls to the fixed unit). A single duration (a
1149
+ # {CATimedelta::Element}, `"3 days"`) or time (a {CATime::Element}, a
1150
+ # `Time`, a `Date`, `"2024-01-02"`) is subtracted from every cell.
1151
+ # @param other [CATimedelta, CATime, CATimedelta::Element, CATime::Element, Time, Date, String]
1152
+ # (or a MemoryView producer whose +to_ca+ gives one, such as an Arrow
1153
+ # duration or timestamp array)
838
1154
  # @return [CATime, CATimedelta]
839
1155
  # @raise [TypeError] on an unsupported operand.
840
1156
  def -(other)
1157
+ given, other = other, CATimeUnitAlgebra.operand(other)
1158
+ other = CATimeUnitAlgebra.scalar_operand(other, unit) || other
841
1159
  case other
842
1160
  when CATimedelta
843
- (parent - CATimeUnitAlgebra.convert_scale_trunc(other.parent, other.unit, unit)).time(unit: unit)
1161
+ return shifted_by_months(other, -1) if moved_by_months?(other)
1162
+ d = CATimeUnitAlgebra.duration_on_grid(other.parent, other.unit, unit)
1163
+ d = d[0] if other.scalar?
1164
+ CATimeUnitAlgebra.checked_sub(parent, d).time(unit: unit)
844
1165
  when CATime
845
1166
  u = CATimeUnitAlgebra.diff_unit(unit, other.unit)
846
1167
  a = CATimeUnitAlgebra.convert_instant!(parent, unit, u)
847
1168
  b = CATimeUnitAlgebra.convert_instant!(other.parent, other.unit, u)
848
- (a - b).timedelta(unit: u)
1169
+ b = b[0] if other.scalar?
1170
+ CATimeUnitAlgebra.checked_sub(a, b).timedelta(unit: u)
849
1171
  else
850
- raise TypeError, "CATime - #{other.class} is not allowed"
1172
+ raise TypeError, "CATime - #{given.class} is not allowed: subtract a duration " \
1173
+ "(a CATimedelta, a CATimedelta::Element or a String such as \"3 days\") " \
1174
+ "or a time (a CATime, a CATime::Element, a Time, a Date or a String)"
851
1175
  end
852
1176
  end
853
1177
 
1178
+ # Whether a duration moves this time by calendar months: a month or year
1179
+ # duration on a time whose grid is fixed-length. A time on a month or year
1180
+ # grid adds it as months already, and a fixed duration on a calendar grid
1181
+ # is refused by the scale conversion.
1182
+ private def moved_by_months? (td)
1183
+ CATimeUnitAlgebra::CALENDAR.key?(td.unit.base) &&
1184
+ !CATimeUnitAlgebra::CALENDAR.key?(unit.base)
1185
+ end
1186
+
1187
+ # self moved by the months of +td+ (+sign+ 1 to add, -1 to subtract), as
1188
+ # Ruby's Date#>> moves a date: the day is kept unless the month is shorter.
1189
+ private def shifted_by_months (td, sign)
1190
+ months = td.parent.int64 * (sign * CATimeUnitAlgebra.months_per_tick(td.unit))
1191
+ months = months[0] if td.scalar?
1192
+ CATimeUnitAlgebra.shift_months(parent, unit, months).time(unit: unit)
1193
+ end
1194
+
1195
+ # @overload pmax(other)
1196
+ # The later of each cell and `other`, cell by cell: `other` is a time
1197
+ # array of the same shape, or one time (a {CATime::Element}, a `Time`, a
1198
+ # `Date`, `"2024-01-02"`) for every cell. The two meet as a comparison
1199
+ # does, so the result is in `self`'s unit when `other` sits on its grid
1200
+ # and in the finer unit the two share otherwise. A masked cell on either
1201
+ # side gives a masked cell.
1202
+ # @param other [CATime, CATime::Element, Time, Date, String]
1203
+ # @return [CATime]
1204
+ # @raise [TypeError] when `other` is not a time.
1205
+ def pmax (other)
1206
+ pick_by_order(other) { |a, b| a < b }
1207
+ end
1208
+
1209
+ # @overload pmin(other)
1210
+ # The earlier of each cell and `other`; see {#pmax}.
1211
+ # @param other [CATime, CATime::Element, Time, Date, String]
1212
+ # @return [CATime]
1213
+ # @raise [TypeError] when `other` is not a time.
1214
+ def pmin (other)
1215
+ pick_by_order(other) { |a, b| a > b }
1216
+ end
1217
+
1218
+ # A time has no NaN, so maximum / minimum (which let a NaN win) are pmax /
1219
+ # pmin, and fmax / fmin are their other names.
1220
+ alias_method :maximum, :pmax
1221
+ alias_method :minimum, :pmin
1222
+ alias_method :fmax, :pmax
1223
+ alias_method :fmin, :pmin
1224
+
1225
+ # `other` where the block says self's cell loses to it, else self's cell.
1226
+ private def pick_by_order (other)
1227
+ return copy.tap { |out| out[] = UNDEF } if UNDEF.equal?(other)
1228
+ a, b = comparable_pair(other)
1229
+ b = b[0] unless other.is_a?(CArray) || other.is_a?(Array)
1230
+ yield(a, b).then_else(b, a)
1231
+ end
1232
+
854
1233
  # Element-wise comparison (< <= > >= <=>) rides the core comparison Face
855
1234
  # gate (ca_face_reconcile_comparison in ext/ca_obj_face.c): the inherited
856
1235
  # CArray operators descend to storage and reconcile a CATime RHS in a
@@ -858,38 +1237,19 @@ class CATime
858
1237
  # base CArray#<=> composed from the (gated) > and <.
859
1238
 
860
1239
  # Element decode convention via the C-layer macro
861
- # `CA_FACE_STORAGE_TO_SCALAR_IF_FACE`: no Ruby `[]` override is needed, a
1240
+ # `CA_FACE_STORAGE_TO_ELEMENT_IF_FACE`: no Ruby `[]` override is needed, a
862
1241
  # scalar auto-decodes on all scalar-return paths such as `ca[i,j,k]` /
863
- # `ca[k]`. storage_to_scalar itself lives in C (ext/ca_obj_datetime.c)
1242
+ # `ca[k]`. storage_to_element itself lives in C (ext/ca_obj_datetime.c)
864
1243
  # so the hot path stays off Ruby method dispatch. Its write counterpart
865
- # is scalar_to_storage (below, surface -> storage on store).
1244
+ # is element_to_storage (below, surface -> storage on store).
866
1245
 
867
1246
  # @!group Reductions
868
1247
 
869
- # min / max are order-structure reductions: they ride the core reduce
870
- # Face gate (ORDERABLE storage descent + output re-lift, see
871
- # ext/mkkernel.rb face_gate: :relift), so no Ruby override is needed --
872
- # the inherited CArray#min / #max handle the Element / CATime return
873
- # shapes directly. minmax is the paired form; it has no core gate, so
874
- # re-lift both extremes here. They are actual elements, so they keep the
875
- # storage unit (no rounding, unlike mean).
876
-
877
- # @overload minmax(*axes, **opts)
878
- # Returns the earliest and latest time as a `[min, max]` pair (§8).
879
- # @return [Array(Element, Element), Array(CATime, CATime)]
880
- def minmax(*args, **opts)
881
- lo, hi = parent.minmax(*args, **opts)
882
- [relift_extremum(lo), relift_extremum(hi)]
883
- end
884
-
885
- def relift_extremum(r)
886
- case r
887
- when Integer then Element.new(r, unit)
888
- when CArray then r.time(unit: unit)
889
- else r
890
- end
891
- end
892
- private :relift_extremum
1248
+ # min / max / minmax are order-structure reductions: they ride the core
1249
+ # reduce Face gate (ORDERABLE storage descent + output re-lift, see
1250
+ # ext/mkkernel.rb face_gate: :relift), so the inherited methods return the
1251
+ # Element / CATime shapes directly. The extremes are actual elements, so
1252
+ # they keep the storage unit (no rounding, unlike mean).
893
1253
 
894
1254
  # The centroid / spread / order reductions report on the array's own grid:
895
1255
  # they run on the storage ticks and round the result back to the nearest
@@ -953,22 +1313,43 @@ class CATime
953
1313
 
954
1314
  # @overload sum(*)
955
1315
  # Not supported; use {#mean} for a centroid.
956
- # @raise [TypeError] always.
1316
+ # @raise [CArray::DataTypeError] always.
957
1317
  def sum(*)
958
- raise TypeError, "CATime#sum is ill-defined; use mean for centroid"
1318
+ raise CArray::DataTypeError, "CATime#sum is ill-defined; use mean for centroid"
959
1319
  end
960
1320
 
1321
+ # @overload accumulate(*)
1322
+ # Not supported, for the same reason as {#sum}.
1323
+ # @raise [CArray::DataTypeError] always.
1324
+ def accumulate(*); raise CArray::DataTypeError, "CATime#accumulate is ill-defined (a sum of instants)"; end
1325
+
1326
+ # @overload cumsum(*)
1327
+ # Not supported, for the same reason as {#sum}. The running position
1328
+ # relative to the first instant is `(t - t[0])` cumulated as durations.
1329
+ # @raise [CArray::DataTypeError] always.
1330
+ def cumsum(*); raise CArray::DataTypeError, "CATime#cumsum is ill-defined (a sum of instants)"; end
1331
+
1332
+ # @overload prod(*)
1333
+ # Not supported: a product of instants has no meaning.
1334
+ # @raise [CArray::DataTypeError] always.
1335
+ def prod(*); raise CArray::DataTypeError, "CATime#prod is ill-defined (a product of instants)"; end
1336
+
1337
+ # @overload cumprod(*)
1338
+ # Not supported, for the same reason as {#prod}.
1339
+ # @raise [CArray::DataTypeError] always.
1340
+ def cumprod(*); raise CArray::DataTypeError, "CATime#cumprod is ill-defined (a product of instants)"; end
1341
+
961
1342
  # @overload variance(*)
962
1343
  # Not supported: the variance of instants has squared-time units, which
963
1344
  # no type represents (ill-defined, like {#sum}). Use {#stddev} for the
964
1345
  # spread as a duration.
965
- # @raise [TypeError] always.
966
- def variance(*); raise TypeError, "CATime#variance is ill-defined (squared-time units); use stddev"; end
1346
+ # @raise [CArray::DataTypeError] always.
1347
+ def variance(*); raise CArray::DataTypeError, "CATime#variance is ill-defined (squared-time units); use stddev"; end
967
1348
 
968
1349
  # @overload variancep(*)
969
1350
  # Not supported, for the same reason as {#variance}. Use {#stddevp}.
970
- # @raise [TypeError] always.
971
- def variancep(*); raise TypeError, "CATime#variancep is ill-defined (squared-time units); use stddevp"; end
1351
+ # @raise [CArray::DataTypeError] always.
1352
+ def variancep(*); raise CArray::DataTypeError, "CATime#variancep is ill-defined (squared-time units); use stddevp"; end
972
1353
  # @!endgroup
973
1354
 
974
1355
  # sort / partition ride the core sort Face gate (ORDERABLE storage
@@ -986,11 +1367,15 @@ class CATime
986
1367
  # different unit or shape), so the gate routes the operand through here.
987
1368
  #
988
1369
  # Accepted operands: another {CATime} (unit-rescaled to self),
989
- # a {Element} (lifted to a length-1 CATime), a Ruby `Time`, and a
990
- # Ruby `DateTime` (both absolute instants converted to self's unit,
991
- # Unix epoch, UTC). A `String` is out of scope (parsing is a separate,
992
- # opposite-direction mechanism). A bare Integer / other type raises;
993
- # descend to `ca.parent` to compare the hidden storage directly.
1370
+ # a {Element} (lifted to a length-1 CATime), and an instant written
1371
+ # outside CArray -- a Ruby `Time`, `Date`, `DateTime`, or a `String` in
1372
+ # the grammar {CArray.time} reads (UTC unless it carries an offset).
1373
+ # Those are read at the precision they carry, not at self's unit, and
1374
+ # then go through the same conversion as a CATime: "2024-01-02 09:00"
1375
+ # against a day-unit array raises rather than matching 2024-01-02. A
1376
+ # bare Integer / other type raises; descend to `ca.parent` to compare
1377
+ # the hidden storage directly. An Array of any of these is a 1-D set
1378
+ # of instants (the value set of {#is_in}).
994
1379
  #
995
1380
  # The rescale is an INSTANT conversion (convert_instant!), lossless: a
996
1381
  # coarser->finer unit always converts; a finer->coarser unit converts only
@@ -1000,9 +1385,36 @@ class CATime
1000
1385
  # :D and finer, and a fixed operand coarsens to :M/:Y only when it sits on
1001
1386
  # the calendar boundary (:W is the one exception -- month/year starts are
1002
1387
  # not week-aligned, so :Y/:M <-> :W raises).
1003
- # @param operand [CATime, CATime::Element, Time, DateTime]
1388
+ # @param operand [CATime, CATime::Element, Time, Date, DateTime, String, Array]
1004
1389
  # @return [CATime] in self's unit.
1005
1390
  # @raise [TypeError, ArgumentError] on an unreconcilable operand / unit.
1391
+ # Returns `[self, other]` in one unit that holds every value of both
1392
+ # exactly: the finer grid of two same-group units, or the fixed-length one
1393
+ # of a calendar / fixed pair (a month begins on a day). A lookup between
1394
+ # two arrays of different units (is_in, the set operations, locate_addr and
1395
+ # the joins on them) meets there, so it does not depend on which array is
1396
+ # the receiver.
1397
+ # @api private
1398
+ def in_common_unit_with (other)
1399
+ return [self, other] if other.unit == unit
1400
+ u = CATimeUnitAlgebra.diff_unit(unit, other.unit)
1401
+ [to_unit(u), other.to_unit(u)]
1402
+ end
1403
+
1404
+ private def common_unit (a, b)
1405
+ CATimeUnitAlgebra.diff_unit(a, b)
1406
+ end
1407
+
1408
+ # One instant as a length-1 time array in its own unit, or nil.
1409
+ private def lift_value (operand)
1410
+ case operand
1411
+ when CATime::Element
1412
+ CATime.wrap(CA_INT64([operand.value]), unit: operand.unit)
1413
+ when Time, String, Date
1414
+ CATimeLiteral.exact_instant(operand)
1415
+ end
1416
+ end
1417
+
1006
1418
  def to_comparable (operand)
1007
1419
  case operand
1008
1420
  when CATime
@@ -1012,36 +1424,36 @@ class CATime
1012
1424
  when CATime::Element
1013
1425
  lifted = CATime.wrap(CA_INT64([operand.value]), unit: operand.unit)
1014
1426
  to_comparable(lifted)
1015
- when Time
1016
- # Reuse the single-literal builder; it yields a length-1 CATime
1017
- # in the requested (= self's) resolution.
1018
- CArray.time(operand, unit: unit)
1019
- when defined?(DateTime) && DateTime
1020
- to_comparable(operand.to_time.utc)
1427
+ when Time, String, Date
1428
+ to_comparable(CATimeLiteral.exact_instant(operand))
1429
+ when Array, CArray
1430
+ # A set of instants written one by one (is_in(["2024-01-02", ...])),
1431
+ # or an array that is not a time array (an object column of Time
1432
+ # values, a string column), read value by value.
1433
+ reconcile_values(operand)
1021
1434
  else
1022
1435
  raise TypeError,
1023
1436
  "CATime cannot reconcile #{operand.class} " \
1024
- "(use ca.parent to compare the raw int64 storage directly)"
1437
+ "(compare #ticks for the raw int64 storage)"
1025
1438
  end
1026
1439
  end
1027
1440
 
1028
- # @overload scalar_to_storage(surface)
1029
- # Write-direction counterpart of storage_to_scalar (the store hook fired
1441
+ # @overload element_to_storage(surface)
1442
+ # Write-direction counterpart of storage_to_element (the store hook fired
1030
1443
  # from rb_ca_obj2ptr): brings a surface value object into this Face's
1031
1444
  # int64 storage (count in self's unit since the Unix epoch) so a scalar
1032
- # store round-trips with a fetch. A {Element} / `Time` / `DateTime` is
1033
- # reconciled to self's unit via {#to_comparable} (same lossless
1034
- # discipline: a cross-group unit or a non-exact finer->coarser cast
1035
- # raises). A bare Integer (the documented `.parent` raw-storage escape)
1036
- # and a String (parsing is a separate, opposite-direction mechanism)
1037
- # pass through unchanged to the storage cast.
1038
- # @param surface [Element, Time, DateTime, Integer, String]
1445
+ # store round-trips with a fetch. A {Element} / `Time` / `Date` /
1446
+ # `DateTime` / `String` is reconciled to self's unit via {#to_comparable}
1447
+ # (same lossless discipline: a cross-group unit or a non-exact
1448
+ # finer->coarser cast raises). A bare Integer (the documented `.parent`
1449
+ # raw-storage escape) passes through unchanged to the storage cast.
1450
+ # @param surface [Element, Time, Date, DateTime, Integer, String]
1039
1451
  # @return [Integer, Object] the storage-domain value, or `surface`
1040
1452
  # unchanged for a pass-through type.
1041
1453
  # @raise [TypeError, ArgumentError] on an unreconcilable surface / unit.
1042
- def scalar_to_storage (surface)
1454
+ def element_to_storage (surface)
1043
1455
  case surface
1044
- when Integer, String
1456
+ when Integer
1045
1457
  surface
1046
1458
  else
1047
1459
  to_comparable(surface).parent[0]
@@ -1109,7 +1521,6 @@ class CATime
1109
1521
  # CArray. Array-level counterpart to {Element#to_time}.
1110
1522
  # @return [CArray]
1111
1523
  def to_time
1112
- require 'time'
1113
1524
  if CATimeUnitAlgebra::FIXED.key?(unit.base)
1114
1525
  f = unit.tick_ratio # exact seconds / tick (Rational)
1115
1526
  parent.convert(:object) {|v| Time.at(v * f, in: 'UTC')}
@@ -1124,7 +1535,6 @@ class CATime
1124
1535
  # {Element#to_date}.
1125
1536
  # @return [CArray]
1126
1537
  def to_date
1127
- require 'date'
1128
1538
  # 2440588 = JD of 1970-01-01; proleptic Gregorian to match to_time and the field accessors.
1129
1539
  days_since_epoch.convert(:object) {|d| Date.jd(2440588 + d, Date::GREGORIAN)}
1130
1540
  end
@@ -1134,7 +1544,6 @@ class CATime
1134
1544
  # as an object CArray.
1135
1545
  # @return [CArray]
1136
1546
  def to_datetime
1137
- require 'date'
1138
1547
  to_time.convert(:object) {|t| t.to_datetime}
1139
1548
  end
1140
1549
 
@@ -1187,6 +1596,54 @@ class CATime
1187
1596
  # @return [CArray] integer.
1188
1597
  def second; clock_field(:s); end
1189
1598
 
1599
+ # Quarter of the year, 1..4 (January to March is 1). For a fiscal year,
1600
+ # count quarters with `timesteps(unit: "3 months", origin:)`.
1601
+ # @return [CArray] integer.
1602
+ def quarter
1603
+ (month - 1) / 3 + 1
1604
+ end
1605
+
1606
+ # ISO 8601 week number, 1..53. A week runs Monday to Sunday and belongs
1607
+ # to the year its Thursday is in, so the first days of January can be in
1608
+ # the last week of the year before, and the last days of December in
1609
+ # week 1 of the next; {#iso_year} is that year.
1610
+ # @return [CArray] integer.
1611
+ def iso_week
1612
+ thursday, year = iso_thursday
1613
+ ones = CArray.int64(*shape) { 1 }
1614
+ (thursday - CATimeCivil.days_from_civil(year, ones, ones)) / 7 + 1
1615
+ end
1616
+
1617
+ # The ISO 8601 year of each time's week (see {#iso_week}): the calendar
1618
+ # year except in the few days around New Year whose week is counted in the
1619
+ # neighbouring year.
1620
+ # @return [CArray] integer.
1621
+ def iso_year
1622
+ iso_thursday[1]
1623
+ end
1624
+
1625
+ # The Thursday of each time's ISO week, in days since the epoch, and the
1626
+ # year it falls in.
1627
+ private def iso_thursday
1628
+ days = days_since_epoch
1629
+ iso_weekday = (days + 3) % 7 + 1 # Monday 1 .. Sunday 7; 1970-01-01 is a Thursday
1630
+ thursday = days - iso_weekday + 4
1631
+ [thursday, CATimeCivil.civil_from_days(thursday)[0]]
1632
+ end
1633
+
1634
+ # Number of days in the month, 28..31 (January's for `:Y` storage, which
1635
+ # does not resolve months). `t.day.eq(t.days_in_month)` is the last day of
1636
+ # the month.
1637
+ # @return [CArray] integer.
1638
+ def days_in_month
1639
+ y, m, = CATimeCivil.civil_from_days(days_since_epoch)
1640
+ ones = CArray.int64(*shape) { 1 }
1641
+ ord = y * 12 + m # the next month's ordinal (m is 1..12)
1642
+ ny = ord / 12
1643
+ CATimeCivil.days_from_civil(ny, ord - ny * 12 + 1, ones) -
1644
+ CATimeCivil.days_from_civil(y, m, ones)
1645
+ end
1646
+
1190
1647
  # Day of the week, Sunday = 0 .. Saturday = 6.
1191
1648
  # @return [CArray] integer.
1192
1649
  def weekday
@@ -1209,7 +1666,6 @@ class CATime
1209
1666
  # Returns the Julian Day Number for each element.
1210
1667
  # @return [CArray]
1211
1668
  def jd
1212
- require 'date'
1213
1669
  to_time.convert(:int) {|t| t.to_date.jd}
1214
1670
  end
1215
1671
 
@@ -1218,7 +1674,6 @@ class CATime
1218
1674
  # day) for each element.
1219
1675
  # @return [CArray]
1220
1676
  def ajd
1221
- require 'date'
1222
1677
  to_time.convert(:double) {|t| t.to_datetime.ajd.to_f}
1223
1678
  end
1224
1679
 
@@ -1248,7 +1703,9 @@ class CATime
1248
1703
  # the centroid of month numbers (a caller wanting the day-space answer
1249
1704
  # writes to_unit(:D) first) -- and the result is rounded back to a tick.
1250
1705
  def reduce_on_own_grid(op, kind, args, opts)
1251
- round_and_relift(parent.public_send(op, *args, **opts), kind)
1706
+ with_surface_fill(opts) do |o|
1707
+ round_and_relift(parent.public_send(op, *args, **o), kind)
1708
+ end
1252
1709
  end
1253
1710
 
1254
1711
  # Round-to-nearest onto the storage grid, then wear the Face again. An
@@ -1273,6 +1730,41 @@ class CATime
1273
1730
  end
1274
1731
  end
1275
1732
 
1733
+ # inspect's name for the array: the class and the tick, which the storage
1734
+ # type name (fixlen[8]) does not tell.
1735
+ private def inspect_label
1736
+ "#{self.class}[#{unit}]"
1737
+ end
1738
+
1739
+ # inspect's cell: the instant as Element#to_s writes it, but with the
1740
+ # digits of a second that some cell of the array needs, the same in every
1741
+ # cell so that the cells line up; the unit is in the label.
1742
+ private def inspect_cell_formatter
1743
+ digits = inspect_second_digits
1744
+ lambda do |e|
1745
+ case e.unit.base
1746
+ when :Y, :M, :W, :D then e.to_s
1747
+ else e.to_time.iso8601(digits)
1748
+ end
1749
+ end
1750
+ end
1751
+
1752
+ # The digits of a second inspect writes: 0, 3, 6 or 9, the fewest that
1753
+ # show every present cell exactly (9 at most, as Time holds).
1754
+ private def inspect_second_digits
1755
+ base_digits = { ms: 3, us: 6, ns: 9, ps: 12, fs: 15, as: 18 }[unit.base]
1756
+ return 0 unless base_digits
1757
+ count = unit.count
1758
+ [0, 3, 6, 9].each do |d|
1759
+ break if d >= base_digits
1760
+ m = 10**(base_digits - d)
1761
+ # ticks * count is a multiple of m when ticks is one of m / gcd(m, count),
1762
+ # which does not multiply out of int64.
1763
+ return d if (ticks % (m / m.gcd(count))).eq(0).all
1764
+ end
1765
+ [base_digits, 9].min
1766
+ end
1767
+
1276
1768
  end
1277
1769
 
1278
1770
  # ============================================================================
@@ -1319,19 +1811,26 @@ class CATimedelta
1319
1811
  # {CATimedelta}; a {CATime} operand delegates to
1320
1812
  # {CATime#+} (commutative).
1321
1813
  # @param other [CATimedelta, CATime]
1814
+ # (or a MemoryView producer whose +to_ca+ gives one, such as an Arrow
1815
+ # duration or timestamp array)
1322
1816
  # @return [CATimedelta, CATime]
1323
1817
  # @raise [TypeError] on incompatible operands.
1324
1818
  def +(other)
1819
+ given, other = other, CATimeUnitAlgebra.operand(other)
1820
+ other = CATimeUnitAlgebra.scalar_operand(other, unit) || other
1325
1821
  case other
1326
1822
  when CATimedelta
1327
1823
  u = common_duration_unit(other.unit)
1328
1824
  a = CATimeUnitAlgebra.convert_scale!(parent, unit, u)
1329
1825
  b = CATimeUnitAlgebra.convert_scale!(other.parent, other.unit, u)
1330
- (a + b).timedelta(unit: u)
1826
+ b = b[0] if other.scalar?
1827
+ CATimeUnitAlgebra.checked_add(a, b).timedelta(unit: u)
1331
1828
  when CATime
1332
- other + self # commutative -> time's unit
1829
+ return other + self unless other.scalar? # commutative -> time's unit
1830
+ d = CATimeUnitAlgebra.duration_on_grid(parent, unit, other.unit)
1831
+ CATimeUnitAlgebra.checked_add(d, other.parent[0]).time(unit: other.unit)
1333
1832
  else
1334
- raise TypeError, "CATimedelta + #{other.class} is not allowed"
1833
+ raise TypeError, "CATimedelta + #{given.class} is not allowed"
1335
1834
  end
1336
1835
  end
1337
1836
 
@@ -1339,17 +1838,22 @@ class CATimedelta
1339
1838
  # Returns the difference of two {CATimedelta} at the finer of the two
1340
1839
  # units (a cross-group pair raises).
1341
1840
  # @param other [CATimedelta]
1841
+ # (or a MemoryView producer whose +to_ca+ gives one, such as an Arrow
1842
+ # duration array)
1342
1843
  # @return [CATimedelta]
1343
1844
  # @raise [TypeError] on a non-timedelta operand.
1344
1845
  def -(other)
1846
+ given, other = other, CATimeUnitAlgebra.operand(other)
1847
+ other = CATimeUnitAlgebra.scalar_operand(other, unit) || other
1345
1848
  case other
1346
1849
  when CATimedelta
1347
1850
  u = common_duration_unit(other.unit)
1348
1851
  a = CATimeUnitAlgebra.convert_scale!(parent, unit, u)
1349
1852
  b = CATimeUnitAlgebra.convert_scale!(other.parent, other.unit, u)
1350
- (a - b).timedelta(unit: u)
1853
+ b = b[0] if other.scalar?
1854
+ CATimeUnitAlgebra.checked_sub(a, b).timedelta(unit: u)
1351
1855
  else
1352
- raise TypeError, "CATimedelta - #{other.class} is not allowed"
1856
+ raise TypeError, "CATimedelta - #{given.class} is not allowed"
1353
1857
  end
1354
1858
  end
1355
1859
 
@@ -1375,28 +1879,34 @@ class CATimedelta
1375
1879
  # @raise [TypeError] when `other` is not an Integer.
1376
1880
  def *(other)
1377
1881
  case other
1378
- when Integer then (parent * other).timedelta(unit: unit)
1882
+ when Integer then CATimeUnitAlgebra.checked_mul(parent, other).timedelta(unit: unit)
1379
1883
  else raise TypeError, "CATimedelta * #{other.class} is not allowed (Integer only)"
1380
1884
  end
1381
1885
  end
1382
1886
 
1383
1887
  # @overload /(other)
1384
1888
  # Returns element-wise division: by Integer yields a
1385
- # {CATimedelta}, by another {CATimedelta} with matching `unit`
1386
- # yields a dimensionless CArray.
1889
+ # {CATimedelta} (truncated toward zero to the unit), by another
1890
+ # {CATimedelta} their ratio as a float64 CArray (90 s / 60 s is 1.5),
1891
+ # the two brought to a common unit first.
1387
1892
  # @param other [Integer, CATimedelta]
1893
+ # (or a MemoryView producer whose +to_ca+ gives one, such as an Arrow
1894
+ # duration array)
1388
1895
  # @return [CATimedelta, CArray]
1389
1896
  # @raise [TypeError] on incompatible operands.
1390
1897
  def /(other)
1898
+ given, other = other, CATimeUnitAlgebra.operand(other)
1391
1899
  case other
1392
1900
  when Integer
1393
1901
  CATimeUnitAlgebra.trunc_divide(parent, other).timedelta(unit: unit)
1394
1902
  when CATimedelta
1903
+ # A ratio of durations is a number, not a count of whole ticks: 90 s /
1904
+ # 60 s is 1.5. Both are brought to one unit first, so the ratio does
1905
+ # not depend on the units they are held in.
1395
1906
  u = common_duration_unit(other.unit)
1396
- CATimeUnitAlgebra.trunc_divide(
1397
- CATimeUnitAlgebra.convert_scale!(parent, unit, u),
1398
- CATimeUnitAlgebra.convert_scale!(other.parent, other.unit, u))
1399
- else raise TypeError, "CATimedelta / #{other.class} is not allowed"
1907
+ CATimeUnitAlgebra.convert_scale!(parent, unit, u).to_type(:float64) /
1908
+ CATimeUnitAlgebra.convert_scale!(other.parent, other.unit, u)
1909
+ else raise TypeError, "CATimedelta / #{given.class} is not allowed"
1400
1910
  end
1401
1911
  end
1402
1912
 
@@ -1409,17 +1919,73 @@ class CATimedelta
1409
1919
 
1410
1920
  # @overload sum(*args, **opts)
1411
1921
  # Returns the sum of durations as a {Element} for full reduction
1412
- # or as a {CATimedelta} view for per-axis reduction.
1922
+ # or as a {CATimedelta} view for per-axis reduction. The sum is exact:
1923
+ # the unit counts are added in int64, not in float64.
1413
1924
  # @return [Element, CATimedelta]
1925
+ # @raise [RangeError] if a sum does not fit int64 (for a per-axis
1926
+ # reduction, if any of its cells does not).
1414
1927
  def sum(*args, **opts)
1415
- round_and_relift(parent.sum(*args, **opts))
1928
+ with_surface_fill(opts) { |o|
1929
+ exact_sum_relift(parent.accumulate(*args, **o), parent.sum(*args, **o))
1930
+ }
1931
+ end
1932
+
1933
+ # int64 addition is exact modulo 2**64, so the int64 sum is the true sum
1934
+ # whenever the true sum fits int64, whatever order the cells were added
1935
+ # in. Whether it fits is read from the float64 sum, whose error is far
1936
+ # inside the factor of 2 between 2**62 and 2**63.
1937
+ def exact_sum_relift (exact, approx)
1938
+ past = case approx
1939
+ when Numeric then approx.abs > 2.0**62
1940
+ when CArray then (approx.abs > 2.0**62).any
1941
+ else false
1942
+ end
1943
+ raise RangeError, "sum of durations does not fit int64 (in #{unit})" if past
1944
+ case exact
1945
+ when Integer then Element.new(exact, unit)
1946
+ when CArray then exact.timedelta(unit: unit)
1947
+ else exact
1948
+ end
1949
+ end
1950
+ private :exact_sum_relift
1951
+
1952
+ # @overload accumulate(*args, **opts)
1953
+ # Returns the sum of durations in the storage's integer type (int64),
1954
+ # added without a range check: a sum past int64 wraps. {#sum} gives
1955
+ # the same answer when it fits and raises RangeError when it does not.
1956
+ # @return [Element, CATimedelta]
1957
+ def accumulate(*args, **opts)
1958
+ with_surface_fill(opts) { |o| round_and_relift(parent.accumulate(*args, **o)) }
1416
1959
  end
1417
1960
 
1961
+ # @overload cumsum(axis: nil)
1962
+ # Returns the running sum of durations as a {CATimedelta} on `self`'s
1963
+ # unit. A masked cell holds the running sum, as {CArray#cumsum} does.
1964
+ # The running sum is exact: the unit counts are added in int64, as
1965
+ # {#sum} adds them.
1966
+ # @return [CATimedelta]
1967
+ # @raise [RangeError] if any cell of the running sum does not fit int64.
1968
+ def cumsum(*args, **opts)
1969
+ exact_sum_relift(parent.send(:cumsum_in_own_type, *args, **opts),
1970
+ parent.cumsum(*args, **opts))
1971
+ end
1972
+
1973
+ # @overload prod(*)
1974
+ # Not supported: a product of durations has the units of a power of
1975
+ # time, which no type represents.
1976
+ # @raise [CArray::DataTypeError] always.
1977
+ def prod(*); raise CArray::DataTypeError, "CATimedelta#prod is ill-defined (powers of time units)"; end
1978
+
1979
+ # @overload cumprod(*)
1980
+ # Not supported, for the same reason as {#prod}.
1981
+ # @raise [CArray::DataTypeError] always.
1982
+ def cumprod(*); raise CArray::DataTypeError, "CATimedelta#cumprod is ill-defined (powers of time units)"; end
1983
+
1418
1984
  # @overload mean(axis: nil, **opts)
1419
1985
  # Returns the mean duration rounded to the nearest unit count.
1420
1986
  # @return [Element, CATimedelta]
1421
1987
  def mean(*args, **opts)
1422
- round_and_relift(parent.mean(*args, **opts))
1988
+ with_surface_fill(opts) { |o| round_and_relift(parent.mean(*args, **o)) }
1423
1989
  end
1424
1990
 
1425
1991
  # median / percentile / quantile / stddev / stddevp report on this array's
@@ -1431,7 +1997,7 @@ class CATimedelta
1431
1997
  # Returns the median duration on `self`'s unit.
1432
1998
  # @return [Element, CATimedelta]
1433
1999
  def median(*args, **opts)
1434
- round_and_relift(parent.median(*args, **opts))
2000
+ with_surface_fill(opts) { |o| round_and_relift(parent.median(*args, **o)) }
1435
2001
  end
1436
2002
 
1437
2003
  # @overload percentile(*p, axis: nil, **opts)
@@ -1440,7 +2006,7 @@ class CATimedelta
1440
2006
  # two or more give an Array).
1441
2007
  # @return [Element, CATimedelta, Array<Element>, Array<CATimedelta>]
1442
2008
  def percentile(*args, **opts)
1443
- round_and_relift(parent.percentile(*args, **opts))
2009
+ with_surface_fill(opts) { |o| round_and_relift(parent.percentile(*args, **o)) }
1444
2010
  end
1445
2011
 
1446
2012
  # @overload quantile(axis: nil, **opts)
@@ -1448,7 +2014,7 @@ class CATimedelta
1448
2014
  # `self`'s unit.
1449
2015
  # @return [Array<Element>, Array<CATimedelta>]
1450
2016
  def quantile(*args, **opts)
1451
- round_and_relift(parent.quantile(*args, **opts))
2017
+ with_surface_fill(opts) { |o| round_and_relift(parent.quantile(*args, **o)) }
1452
2018
  end
1453
2019
 
1454
2020
  # @overload stddev(axis: nil, **opts)
@@ -1457,7 +2023,7 @@ class CATimedelta
1457
2023
  # the precision matters.
1458
2024
  # @return [Element, CATimedelta]
1459
2025
  def stddev(*args, **opts)
1460
- round_and_relift(parent.stddev(*args, **opts))
2026
+ with_surface_fill(opts) { |o| round_and_relift(parent.stddev(*args, **o)) }
1461
2027
  end
1462
2028
 
1463
2029
  # @overload stddevp(axis: nil, **opts)
@@ -1465,7 +2031,7 @@ class CATimedelta
1465
2031
  # {#stddev}.
1466
2032
  # @return [Element, CATimedelta]
1467
2033
  def stddevp(*args, **opts)
1468
- round_and_relift(parent.stddevp(*args, **opts))
2034
+ with_surface_fill(opts) { |o| round_and_relift(parent.stddevp(*args, **o)) }
1469
2035
  end
1470
2036
 
1471
2037
  def round_and_relift(r)
@@ -1482,39 +2048,21 @@ class CATimedelta
1482
2048
  # Not supported: the variance of durations has squared-time units, which
1483
2049
  # no type represents -- the same reason {CATime#variance} refuses. Use
1484
2050
  # {#stddev} for the spread as a duration.
1485
- # @raise [TypeError] always.
2051
+ # @raise [CArray::DataTypeError] always.
1486
2052
  def variance(*)
1487
- raise TypeError, "CATimedelta#variance is ill-defined (squared-time units); use stddev"
2053
+ raise CArray::DataTypeError, "CATimedelta#variance is ill-defined (squared-time units); use stddev"
1488
2054
  end
1489
2055
 
1490
2056
  # @overload variancep(*)
1491
2057
  # Not supported, for the same reason as {#variance}. Use {#stddevp}.
1492
- # @raise [TypeError] always.
2058
+ # @raise [CArray::DataTypeError] always.
1493
2059
  def variancep(*)
1494
- raise TypeError, "CATimedelta#variancep is ill-defined (squared-time units); use stddevp"
1495
- end
1496
-
1497
- # min / max ride the core reduce Face gate (ORDERABLE storage descent +
1498
- # output re-lift, see ext/mkkernel.rb face_gate: :relift); the inherited
1499
- # CArray#min / #max return the Element / CATimedelta shapes directly.
1500
- # minmax has no core gate, so re-lift both extremes here.
1501
-
1502
- # @overload minmax(*axes, **opts)
1503
- # Returns the shortest and longest duration as a `[min, max]` pair.
1504
- # @return [Array(Element, Element), Array(CATimedelta, CATimedelta)]
1505
- def minmax(*args, **opts)
1506
- lo, hi = parent.minmax(*args, **opts)
1507
- [relift_extremum(lo), relift_extremum(hi)]
2060
+ raise CArray::DataTypeError, "CATimedelta#variancep is ill-defined (squared-time units); use stddevp"
1508
2061
  end
1509
2062
 
1510
- def relift_extremum(r)
1511
- case r
1512
- when Integer then Element.new(r, unit)
1513
- when CArray then r.timedelta(unit: unit)
1514
- else r
1515
- end
1516
- end
1517
- private :relift_extremum
2063
+ # min / max / minmax ride the core reduce Face gate (ORDERABLE storage
2064
+ # descent + output re-lift); the inherited methods return the Element /
2065
+ # CATimedelta shapes directly.
1518
2066
  # @!endgroup
1519
2067
 
1520
2068
  # sort / partition ride the core sort Face gate (ORDERABLE storage
@@ -1535,6 +2083,34 @@ class CATimedelta
1535
2083
  # @param operand [CATimedelta, CATimedelta::Element]
1536
2084
  # @return [CATimedelta] in self's unit.
1537
2085
  # @raise [TypeError, ArgumentError] on an unreconcilable operand / unit.
2086
+ # Returns `[self, other]` in one unit, the finer grid of the two, which
2087
+ # holds every value of both exactly. A lookup between two duration
2088
+ # arrays of different units meets there, so it does not depend on which
2089
+ # array is the receiver. A calendar and a fixed-length duration have no
2090
+ # common unit and raise.
2091
+ # @api private
2092
+ def in_common_unit_with (other)
2093
+ return [self, other] if other.unit == unit
2094
+ unless CATimeUnitAlgebra.same_group?(unit, other.unit)
2095
+ raise ArgumentError,
2096
+ "cannot compare a #{unit} duration with a #{other.unit} duration: " \
2097
+ "a calendar length (months, years) has no fixed length in days"
2098
+ end
2099
+ u = CATimeUnitAlgebra.finer(unit, other.unit)
2100
+ [to_unit(u), other.to_unit(u)]
2101
+ end
2102
+
2103
+ private def common_unit (a, b)
2104
+ CATimeUnitAlgebra.finer(a, b)
2105
+ end
2106
+
2107
+ # One duration as a length-1 duration array in its own unit, or nil.
2108
+ private def lift_value (operand)
2109
+ if operand.is_a?(CATimedelta::Element)
2110
+ CATimedelta.wrap(CA_INT64([operand.value]), unit: operand.unit)
2111
+ end
2112
+ end
2113
+
1538
2114
  def to_comparable (operand)
1539
2115
  case operand
1540
2116
  when CATimedelta
@@ -1544,15 +2120,19 @@ class CATimedelta
1544
2120
  when CATimedelta::Element
1545
2121
  lifted = CATimedelta.wrap(CA_INT64([operand.value]), unit: operand.unit)
1546
2122
  to_comparable(lifted)
2123
+ when Array, CArray
2124
+ # Durations written one by one, or an array that is not a duration
2125
+ # array (an object column of elements), read value by value.
2126
+ reconcile_values(operand)
1547
2127
  else
1548
2128
  raise TypeError,
1549
2129
  "CATimedelta cannot reconcile #{operand.class} " \
1550
- "(use ca.parent to compare the raw int64 storage directly)"
2130
+ "(compare #ticks for the raw int64 storage)"
1551
2131
  end
1552
2132
  end
1553
2133
 
1554
- # @overload scalar_to_storage(surface)
1555
- # Write-direction counterpart of storage_to_scalar: brings a surface
2134
+ # @overload element_to_storage(surface)
2135
+ # Write-direction counterpart of storage_to_element: brings a surface
1556
2136
  # value object into this Face's int64 storage (count in self's unit) so a
1557
2137
  # scalar store round-trips with a fetch. A {Element} is reconciled to
1558
2138
  # self's unit via {#to_comparable} (lossless discipline; a `Time` /
@@ -1563,7 +2143,7 @@ class CATimedelta
1563
2143
  # @return [Integer, Object] the storage-domain value, or `surface`
1564
2144
  # unchanged for a pass-through type.
1565
2145
  # @raise [TypeError, ArgumentError] on an unreconcilable surface / unit.
1566
- def scalar_to_storage (surface)
2146
+ def element_to_storage (surface)
1567
2147
  case surface
1568
2148
  when Integer, String
1569
2149
  surface
@@ -1609,6 +2189,17 @@ class CATimedelta
1609
2189
  end
1610
2190
  CATimeUnitAlgebra.finer(unit, ou)
1611
2191
  end
2192
+
2193
+ # inspect's name for the array: the class and the tick, which the storage
2194
+ # type name (fixlen[8]) does not tell.
2195
+ private def inspect_label
2196
+ "#{self.class}[#{unit}]"
2197
+ end
2198
+
2199
+ # inspect's cell: the duration in the base unit of the tick.
2200
+ private def inspect_cell_formatter
2201
+ lambda { |e| "#{e.value * e.unit.count}#{e.unit.base}" }
2202
+ end
1612
2203
  end
1613
2204
 
1614
2205
  # Reads a time literal (Time / DateTime / Integer unix-seconds / String) into
@@ -1621,39 +2212,153 @@ module CATimeLiteral
1621
2212
 
1622
2213
  # Date fields of a String literal, UTC. Ruby's Date._parse does not read
1623
2214
  # the calendar-grid forms CATime#to_s prints -- "2019-09" comes back as a
1624
- # month of 20 with a zone, and a bare "2019" as a month and a day -- so
1625
- # those two are read here and everything else is left to Date._parse.
2215
+ # month of 20 with a zone, and a bare "2019" as a month and a day -- nor an
2216
+ # hour alone after the date ("2024-01-01T05", ISO 8601 reduced to the
2217
+ # hour), so those are read here and everything else is left to Date._parse.
2218
+ # Date._parse passes over what it cannot read, so text with a time after
2219
+ # the date that comes back without an hour is refused rather than read as
2220
+ # midnight.
1626
2221
  #
1627
2222
  # What Date._parse returns is then checked, because an out-of-range field
1628
2223
  # used to flow into the civil kernel and normalise into a different date:
1629
2224
  # "201909" is a valid YYMMDD to Ruby (2020-19-09, which landed on 2021-07),
1630
2225
  # and "2019-02-31" landed on 2019-03-03. A missing finer field is not an
1631
2226
  # error -- a year or a year-month names the head of that period.
2227
+ # %s and %Q give the seconds since the epoch, not the date's fields;
2228
+ # they are spelled out as the UTC date and time they name.
2229
+ def epoch_fields(h)
2230
+ return h unless h && h[:seconds] && !h[:year]
2231
+ t = Time.at(h[:seconds]).utc
2232
+ frac = h[:seconds] - h[:seconds].floor
2233
+ { year: t.year, mon: t.month, mday: t.day, hour: t.hour, min: t.min,
2234
+ sec: t.sec, sec_fraction: frac.zero? ? nil : Rational(frac), leftover: h[:leftover] }.compact
2235
+ end
2236
+
1632
2237
  def parse_date_fields(spec, format)
1633
2238
  h =
1634
- if format then Date._strptime(spec, format)
2239
+ if format then epoch_fields(Date._strptime(spec, format))
1635
2240
  elsif spec =~ /\A(\d{4})-(\d{1,2})\z/ then { year: $1.to_i, mon: $2.to_i }
1636
2241
  elsif spec =~ /\A(\d{4})\z/ then { year: $1.to_i }
2242
+ elsif (hour = hour_only_fields(spec)) then hour
1637
2243
  else Date._parse(spec)
1638
2244
  end
2245
+ h = resolve_day_of_year(h, spec) if h
1639
2246
  unless h && h[:year]
1640
2247
  raise ArgumentError, "cannot parse time #{spec.inspect}"
1641
2248
  end
2249
+ if !format && !h[:hour] &&
2250
+ (spec =~ /\d\s*T\s*\d/ || spec =~ /\A\s*[+-]?\d{4,}[-\/]\d{1,2}[-\/]\d{1,2}\s+\d/)
2251
+ raise ArgumentError, "cannot parse time #{spec.inspect}: the time after the date is not read"
2252
+ end
2253
+ # Text left over after the format is not part of a time in that format.
2254
+ if h[:leftover] && !h[:leftover].strip.empty?
2255
+ raise ArgumentError,
2256
+ "cannot parse time #{spec.inspect}: #{h[:leftover].inspect} is " \
2257
+ "left over after #{format.inspect}"
2258
+ end
1642
2259
  y, m, d = h[:year], h[:mon] || 1, h[:mday] || 1
1643
2260
  unless (1..12).cover?(m) && Date.valid_date?(y, m, d, Date::GREGORIAN)
1644
2261
  raise ArgumentError,
1645
2262
  "cannot parse time #{spec.inspect}: it reads as year #{y}, " \
1646
2263
  "month #{m}, day #{d}, which is not a date"
1647
2264
  end
2265
+ # Hour 24 is the end of the day, 24:00:00, and nothing past it.
2266
+ if h[:hour] == 24 && [h[:min], h[:sec], h[:sec_fraction]].any? { |v| v && v != 0 }
2267
+ raise ArgumentError,
2268
+ "cannot parse time #{spec.inspect}: hour 24 is only 24:00:00"
2269
+ end
2270
+ # A clock field out of range is refused rather than carried into the
2271
+ # next one ("10:75" is not 11:15). Second 60 is the leap second.
2272
+ { hour: 24, min: 59, sec: 60 }.each do |key, top|
2273
+ v = h[key]
2274
+ next if v.nil? || (0..top).cover?(v)
2275
+ raise ArgumentError,
2276
+ "cannot parse time #{spec.inspect}: #{key} #{v} is out of range"
2277
+ end
1648
2278
  h
1649
2279
  end
1650
2280
  private_class_method :parse_date_fields
1651
2281
 
2282
+ # The month and day of a date written by its day of the year ("2024-015",
2283
+ # %j), its ISO week ("2024-W03-1", %G-W%V-%u) or its week of the year
2284
+ # (%U / %W with a weekday). Date._parse and Date._strptime return those
2285
+ # fields as they are written; left alone, the date would fall back to
2286
+ # January 1.
2287
+ def resolve_day_of_year(h, spec)
2288
+ return h if h[:mon] || h[:mday]
2289
+ date =
2290
+ if h[:yday] && h[:year]
2291
+ unless Date.valid_ordinal?(h[:year], h[:yday], Date::GREGORIAN)
2292
+ raise ArgumentError,
2293
+ "cannot parse time #{spec.inspect}: year #{h[:year]} has no day #{h[:yday]}"
2294
+ end
2295
+ Date.ordinal(h[:year], h[:yday], Date::GREGORIAN)
2296
+ elsif h[:cwyear] && h[:cweek]
2297
+ wd = h[:cwday] || 1
2298
+ unless Date.valid_commercial?(h[:cwyear], h[:cweek], wd, Date::GREGORIAN)
2299
+ raise ArgumentError,
2300
+ "cannot parse time #{spec.inspect}: ISO year #{h[:cwyear]} " \
2301
+ "has no week #{h[:cweek]}, day #{wd}"
2302
+ end
2303
+ Date.commercial(h[:cwyear], h[:cweek], wd, Date::GREGORIAN)
2304
+ elsif h[:year] && (h[:wnum0] || h[:wnum1])
2305
+ jan1 = Date.new(h[:year], 1, 1, Date::GREGORIAN)
2306
+ wday = h[:wday] || (h[:cwday] && h[:cwday] % 7)
2307
+ # Week 1 begins on the first Sunday (%U) or Monday (%W) of the year;
2308
+ # the days before it are week 0.
2309
+ if h[:wnum0]
2310
+ first = jan1 + (7 - jan1.wday) % 7
2311
+ d = first + (h[:wnum0] - 1) * 7 + (wday || 0)
2312
+ else
2313
+ first = jan1 + (8 - jan1.wday) % 7
2314
+ d = first + (h[:wnum1] - 1) * 7 + ((wday || 1) + 6) % 7
2315
+ end
2316
+ unless d.year == h[:year]
2317
+ raise ArgumentError,
2318
+ "cannot parse time #{spec.inspect}: the week and weekday fall outside #{h[:year]}"
2319
+ end
2320
+ d
2321
+ end
2322
+ return h unless date
2323
+ h.merge(year: date.year, mon: date.month, mday: date.day)
2324
+ end
2325
+ private_class_method :resolve_day_of_year
2326
+
2327
+ # "YYYY-MM-DDThh" with an optional zone ("Z", "+hh", "+hhmm", "+hh:mm").
2328
+ def hour_only_fields(spec)
2329
+ m = /\A(\d{4})-(\d{2})-(\d{2})T(\d{2})(?:(Z)|([+-])(\d{2})(?::?(\d{2}))?)?\z/.match(spec)
2330
+ return nil unless m
2331
+ h = { year: m[1].to_i, mon: m[2].to_i, mday: m[3].to_i, hour: m[4].to_i }
2332
+ raise ArgumentError, "cannot parse time #{spec.inspect}: hour #{h[:hour]}" if h[:hour] > 23
2333
+ if m[6]
2334
+ oh, om = m[7].to_i, m[8].to_i
2335
+ raise ArgumentError, "cannot parse time #{spec.inspect}: offset" if oh > 23 || om > 59
2336
+ h[:offset] = (oh * 3600 + om * 60) * (m[6] == "-" ? -1 : 1)
2337
+ elsif m[5]
2338
+ h[:offset] = 0
2339
+ end
2340
+ h
2341
+ end
2342
+ private_class_method :hour_only_fields
2343
+
2344
+ # An instant written outside CArray (Time / Date / DateTime / String) as a
2345
+ # length-1 CATime in the coarsest of :s / :ms / :us / :ns that holds it
2346
+ # exactly, so a comparison sees the instant it names and not that instant
2347
+ # floored onto some other grid. Finer than a nanosecond raises.
2348
+ def exact_instant(spec)
2349
+ sec = epoch_seconds(spec)
2350
+ [:s, :ms, :us, :ns].each_with_index do |u, k|
2351
+ ticks = sec * (10 ** (3 * k))
2352
+ if ticks.denominator == 1
2353
+ return CATime.wrap(CA_INT64([ticks.to_i]), unit: u)
2354
+ end
2355
+ end
2356
+ raise ArgumentError, "#{spec.inspect} is finer than a nanosecond"
2357
+ end
2358
+
1652
2359
  # Exact Rational seconds since the Unix epoch for a start literal (Time /
1653
- # DateTime / Integer unix-seconds / String). UTC default.
2360
+ # Date / DateTime / Integer unix-seconds / String). UTC default.
1654
2361
  def epoch_seconds(spec, format = nil)
1655
- require 'date'
1656
- require 'time'
1657
2362
  case spec
1658
2363
  when Time then spec.to_r
1659
2364
  when Integer then Rational(spec)
@@ -1680,6 +2385,8 @@ module CATimeLiteral
1680
2385
  else
1681
2386
  if defined?(DateTime) && spec.is_a?(DateTime)
1682
2387
  spec.to_time.to_r
2388
+ elsif defined?(Date) && spec.is_a?(Date)
2389
+ Rational(CATimeCivil.days_from_civil_1(spec.year, spec.month, spec.day) * 86400)
1683
2390
  else
1684
2391
  raise ArgumentError, "cannot parse time #{spec.class}"
1685
2392
  end
@@ -1688,8 +2395,6 @@ module CATimeLiteral
1688
2395
 
1689
2396
  # [year, month] (UTC) of a start literal, for a calendar-resolution grid.
1690
2397
  def year_month(spec, format = nil)
1691
- require 'date'
1692
- require 'time'
1693
2398
  case spec
1694
2399
  when Time then t = spec.utc; [t.year, t.month]
1695
2400
  when Integer then t = Time.at(spec, in: 'UTC'); [t.year, t.month]
@@ -1727,6 +2432,273 @@ module CATimeLiteral
1727
2432
  end
1728
2433
  end
1729
2434
 
2435
+ # A format :infer can choose: a strptime format, read strictly (see
2436
+ # __strptime_fields__), and what the text carries (:date, :clock, or
2437
+ # :fraction of a second).
2438
+ TimeFormat = Struct.new(:format, :kind)
2439
+
2440
+ INFER_DATES = %w[%d/%m/%Y %m/%d/%Y %d-%m-%Y %m-%d-%Y %d.%m.%Y %d/%m/%y %m/%d/%y] +
2441
+ ["%d %b %Y", "%d-%b-%Y", "%d-%b-%y", "%b %d, %Y", "%b %d %Y", "%Y%m%d"]
2442
+ Ractor.make_shareable(INFER_DATES)
2443
+
2444
+ # A space in a format matches any run of spaces, none included.
2445
+ INFER_CLOCKS = [
2446
+ ["%H:%M", :clock], ["%H:%M:%S", :clock], ["%H:%M:%S.%N", :fraction],
2447
+ ["%I:%M %p", :clock], ["%I:%M:%S %p", :clock],
2448
+ ].freeze
2449
+ Ractor.make_shareable(INFER_CLOCKS)
2450
+
2451
+ # A time after a date: none, or a clock with or without a zone. %z takes
2452
+ # an offset, Z, UTC or GMT, not a zone named for a place (some of those
2453
+ # names mean different offsets in different places).
2454
+ INFER_TIMES = ([["", :date]] +
2455
+ INFER_CLOCKS.flat_map { |clock, kind| [[" #{clock}", kind], [" #{clock} %z", kind]] }
2456
+ ).freeze
2457
+ Ractor.make_shareable(INFER_TIMES)
2458
+
2459
+ # A date written in Japanese, year first ("2024<year>1<month>2<day>"),
2460
+ # takes a time in the same markers ("3<hour>4<minute>", "...5<second>")
2461
+ # or with colons.
2462
+ JA_YEAR, JA_MONTH, JA_DAY = "年", "月", "日"
2463
+ JA_HOUR, JA_MINUTE, JA_SECOND = "時", "分", "秒"
2464
+ [JA_YEAR, JA_MONTH, JA_DAY, JA_HOUR, JA_MINUTE, JA_SECOND].each { |s| Ractor.make_shareable(s) }
2465
+ INFER_JA_TIMES = ([["", :date]] +
2466
+ ([["%H#{JA_HOUR}%M#{JA_MINUTE}", :clock],
2467
+ ["%H#{JA_HOUR}%M#{JA_MINUTE}%S#{JA_SECOND}", :clock]] + INFER_CLOCKS)
2468
+ .map { |clock, kind| [" #{clock}", kind] }).freeze
2469
+ Ractor.make_shareable(INFER_JA_TIMES)
2470
+
2471
+ INFER_FORMATS = (
2472
+ INFER_DATES.product(INFER_TIMES).map { |date, (time, kind)|
2473
+ TimeFormat.new(date + time, kind)
2474
+ } +
2475
+ INFER_JA_TIMES.map { |time, kind|
2476
+ TimeFormat.new("%Y#{JA_YEAR}%m#{JA_MONTH}%d#{JA_DAY}#{time}", kind)
2477
+ }
2478
+ ).freeze
2479
+ Ractor.make_shareable(INFER_FORMATS)
2480
+ INFER_FORMAT_STRINGS = INFER_FORMATS.map(&:format).freeze
2481
+ Ractor.make_shareable(INFER_FORMAT_STRINGS)
2482
+
2483
+ # The one format of INFER_FORMATS that the text of object array `x` is
2484
+ # written in, `first` being its first present cell, as
2485
+ # [format, fields, address]: the fields __strptime_fields__ read in it,
2486
+ # and the address of the first cell not in it, or nil.
2487
+ #
2488
+ # The first cell gives the candidates and each later cell drops those it
2489
+ # does not fit, until one is left. Each candidate is read over the whole
2490
+ # array once, and the candidate a later cell leaves last is the one left:
2491
+ # no candidate for the first cell or none left (the last ones dropped by
2492
+ # one cell) raises CAFrame::UnreadableColumn, and more than one left at the
2493
+ # end raises CAFrame::AmbiguousTimeFormat (CAFrame is its only caller). A cell
2494
+ # after the choice that is not in the format is returned for the caller
2495
+ # to report.
2496
+ def infer_time_format(x, first)
2497
+ candidates = CArray.__strptime_fits__(first, INFER_FORMAT_STRINGS)
2498
+ if candidates.empty?
2499
+ raise CAFrame::UnreadableColumn,
2500
+ "cannot infer a time format from #{first.inspect}; " \
2501
+ "pass a strptime format, or :mixed to guess at each cell"
2502
+ end
2503
+ runs = candidates.map do |i|
2504
+ fields = x.__strptime_fields__(INFER_FORMATS[i].format, true)
2505
+ [INFER_FORMATS[i], fields, fields[7].first || Float::INFINITY]
2506
+ end
2507
+ last = runs.map(&:last).max
2508
+ left = runs.select { |run| run[2] == last }
2509
+ if left.size > 1
2510
+ if last == Float::INFINITY
2511
+ formats = left.map { |run| run[0].format }
2512
+ raise CAFrame::AmbiguousTimeFormat.new(
2513
+ "#{first.inspect} could be read as " \
2514
+ "#{formats.map(&:inspect).join(' or ')}, " \
2515
+ "and no cell tells which; pass the format",
2516
+ formats: formats)
2517
+ end
2518
+ raise CAFrame::UnreadableColumn,
2519
+ "#{x.flatten[last].inspect} is not in the format of #{first.inspect}; " \
2520
+ "pass a strptime format, or :mixed to guess at each cell"
2521
+ end
2522
+ f, fields, bad = left.first
2523
+ [f, fields, bad == Float::INFINITY ? nil : bad]
2524
+ end
2525
+
2526
+ # [kind, per] for __time_ticks_from_fields__: ticks of `per` nanoseconds
2527
+ # (:fixed) or months (:calendar), or nil when `res` is finer than a
2528
+ # nanosecond.
2529
+ def tick_kind(res)
2530
+ if CATimeUnitAlgebra::FIXED.key?(res.base)
2531
+ per = res.tick_ratio * 1_000_000_000
2532
+ kind = :fixed
2533
+ else
2534
+ per = res.tick_ratio
2535
+ kind = :calendar
2536
+ end
2537
+ (per.denominator == 1 && per >= 1) ? [kind, per.to_i] : nil
2538
+ end
2539
+
2540
+ # The ticks of every cell of `x` on the `res` grid, as an int64 array of
2541
+ # x's shape (missing and, under on_error: :mask, unparseable cells
2542
+ # masked), or nil when `res` is finer than a nanosecond. A strptime
2543
+ # format that __strptime_fields__ reads is read for the whole array in C;
2544
+ # otherwise each String is parsed into fields here. Either way the fields
2545
+ # become ticks for the whole array at once, and a cell that is not a
2546
+ # String goes through tick_index.
2547
+ def ticks_from_cells(x, res, format, on_error, seconds: false)
2548
+ kind, per = tick_kind(res)
2549
+ return nil unless kind
2550
+ if format.is_a?(String)
2551
+ text = (x.data_type == CA_OBJECT && !x.face?) ? x : x.to_type(:object)
2552
+ fields = text.__strptime_fields__(format, false)
2553
+ return ticks_from_fields(fields, x, res, format, on_error) if fields
2554
+ end
2555
+ cells = x.flatten.to_a
2556
+ n = cells.size
2557
+ year = Array.new(n, 1970)
2558
+ mon = Array.new(n, 1)
2559
+ day = Array.new(n, 1)
2560
+ sod = Array.new(n, 0)
2561
+ frac = Array.new(n, 0)
2562
+ off = Array.new(n, 0)
2563
+ present = Array.new(n, 0)
2564
+ direct = {}
2565
+ cells.each_with_index do |s, i|
2566
+ next if s.nil? || UNDEF.equal?(s)
2567
+ begin
2568
+ if s.is_a?(String)
2569
+ h = parse_date_fields(s, format)
2570
+ year[i] = h[:year]
2571
+ mon[i] = h[:mon] || 1
2572
+ day[i] = h[:mday] || 1
2573
+ sod[i] = (h[:hour] || 0) * 3600 + (h[:min] || 0) * 60 + (h[:sec] || 0)
2574
+ frac[i] = (h[:sec_fraction] * 1_000_000_000).floor if h[:sec_fraction]
2575
+ off[i] = h[:offset] if h[:offset]
2576
+ present[i] = 1
2577
+ elsif s.is_a?(Integer) && format.nil?
2578
+ # an Integer counts ticks of the unit given, or seconds when the
2579
+ # unit is chosen from the values
2580
+ direct[i] = seconds ? tick_index(s, res) : s
2581
+ else
2582
+ direct[i] = tick_index(s, res, format)
2583
+ end
2584
+ rescue ArgumentError, TypeError
2585
+ raise if on_error == :raise
2586
+ end
2587
+ end
2588
+ raw = CArray.__time_ticks_from_fields__(
2589
+ CA_INT64(year), CA_INT64(mon), CA_INT64(day), CA_INT64(sod),
2590
+ CA_INT64(frac), CA_INT64(off), CA_UINT8(present), kind, per)
2591
+ check_ticks_fit(raw, present, present.count(1), res) { cells }
2592
+ direct.each { |i, t| raw[i] = t }
2593
+ raw.reshape(*x.shape)
2594
+ end
2595
+
2596
+ # Ticks from what __strptime_fields__ read; the cells it deferred are
2597
+ # read one by one through tick_index.
2598
+ def ticks_from_fields(fields, x, res, format, on_error)
2599
+ kind, per = tick_kind(res)
2600
+ year, mon, day, sod, frac, off, present, unreadable, deferred, = fields
2601
+ cells = nil
2602
+ if on_error == :raise && !unreadable.empty?
2603
+ cells = x.flatten.to_a
2604
+ raise ArgumentError, "cannot parse time #{cells[unreadable.first].inspect}"
2605
+ end
2606
+ raw = CArray.__time_ticks_from_fields__(year, mon, day, sod, frac, off,
2607
+ present, kind, per)
2608
+ check_ticks_fit(raw, present, present.sum, res) { cells ||= x.flatten.to_a }
2609
+ unless deferred.empty?
2610
+ cells ||= x.flatten.to_a
2611
+ deferred.each do |i|
2612
+ raw[i] = tick_index(cells[i], res, format)
2613
+ rescue ArgumentError, TypeError
2614
+ raise if on_error == :raise
2615
+ end
2616
+ end
2617
+ raw.reshape(*x.shape)
2618
+ end
2619
+
2620
+ # A tick that does not fit int64 is out of range, not unparseable.
2621
+ def check_ticks_fit(raw, present, present_count, res)
2622
+ return if raw.count_not_masked >= present_count
2623
+ cells = yield
2624
+ i = (0...raw.elements).find { |k| present[k] == 1 && UNDEF.equal?(raw[k]) }
2625
+ raise RangeError, "time #{cells[i].inspect} does not fit int64 ticks of #{res}"
2626
+ end
2627
+
2628
+ # The unit CArray.time reads +x+ in when none is given: the finest any of
2629
+ # its values shows (see the unit: of CArray.time), :s when none shows one.
2630
+ def unit_shown(x, format)
2631
+ cells = x.is_a?(CArray) ? x.flatten.to_a : [x]
2632
+ text = cells.select { |v| v.is_a?(String) }
2633
+ units = []
2634
+ unless text.empty?
2635
+ if format
2636
+ units << format_unit(format)
2637
+ else
2638
+ unreadable = []
2639
+ _, read = CA_OBJECT(text).__parse_time_text__(nil, unreadable, true)
2640
+ units << read if read
2641
+ unreadable.each do |i|
2642
+ u = text_unit(text[i])
2643
+ units << u if u
2644
+ end
2645
+ end
2646
+ end
2647
+ cells.each do |v|
2648
+ case v
2649
+ when Integer then units << :s
2650
+ when CATime::Element then units << v.unit
2651
+ when Time then units << CATimeLiteral.exact_instant(v).unit
2652
+ when Date
2653
+ units << (defined?(DateTime) && v.is_a?(DateTime) ? CATimeLiteral.exact_instant(v.to_time).unit : :D)
2654
+ end
2655
+ end
2656
+ # A month beside a day is read on the fixed grid both instants reach,
2657
+ # as two elements are compared (diff_unit); finer covers one group only.
2658
+ units.map { |u| CATime::Resolution.parse(u) }
2659
+ .reduce { |a, b| CATimeUnitAlgebra.diff_unit(a, b) } || CATime::Resolution.parse(:s)
2660
+ end
2661
+
2662
+ # The unit a String read with Date._parse shows, or nil when it does not
2663
+ # read (the reading raises or masks it later).
2664
+ def text_unit(str)
2665
+ h = Date._parse(str)
2666
+ return nil unless h && h[:year]
2667
+ return fraction_unit(h[:sec_fraction].denominator) if h[:sec_fraction]
2668
+ (h[:hour] || h[:min] || h[:sec]) ? :s : :D
2669
+ end
2670
+
2671
+ # The finest field a strptime format writes.
2672
+ def format_unit(format)
2673
+ if (m = format.match(/%(\d*)([LNQ])/))
2674
+ return :ms if m[2] == "L" || m[2] == "Q"
2675
+ digits = m[1].empty? ? 9 : m[1].to_i
2676
+ return fraction_unit(10 ** digits)
2677
+ end
2678
+ format.match?(/%[-_0^#]*[sSTHIklMRrXcp]/) ? :s : :D
2679
+ end
2680
+
2681
+ def fraction_unit(denominator)
2682
+ digits = Math.log10(denominator).ceil
2683
+ digits <= 3 ? :ms : digits <= 6 ? :us : :ns
2684
+ end
2685
+
2686
+ # +x+ with each Integer replaced by its decimal digits as a String, for a
2687
+ # format to read; a masked cell stays masked.
2688
+ def integers_as_text(x)
2689
+ case x
2690
+ when Integer then x.to_s
2691
+ when CArray
2692
+ return x if x.face? || !(x.integer? || x.data_type == CA_OBJECT)
2693
+ text = CArray.object(*x.shape)
2694
+ cells = x.flatten.to_a
2695
+ cells.each_with_index { |v, i| text[i] = v.is_a?(Integer) ? v.to_s : v }
2696
+ text = text.reshape(*x.shape) if x.ndim > 1
2697
+ text
2698
+ else x
2699
+ end
2700
+ end
2701
+
1730
2702
  # Single-literal build for {.time}: a 1-element CATime, honouring
1731
2703
  # the on_error policy (raise, or a masked cell).
1732
2704
  def to_time_array(literal, res, format, on_error)
@@ -1806,10 +2778,13 @@ class CArray
1806
2778
 
1807
2779
  # @overload time(x, unit: :s, format: nil, on_error: :raise)
1808
2780
  # Builds a {CATime} on the `unit` grid from time value(s). `x`
1809
- # is either a single literal (Time / ISO 8601 String / Unix-seconds
1810
- # Integer / DateTime) — giving a 1-element result — or a CArray of such
2781
+ # is either a single literal (Time / ISO 8601 String / Integer /
2782
+ # DateTime) — giving a 1-element result — or a CArray of such
1811
2783
  # literals — giving a same-shape result parsed per cell. Parsing is UTC
1812
- # and DateTime-independent.
2784
+ # and DateTime-independent. An Integer counts ticks of `unit` from
2785
+ # the epoch, so it is Unix seconds when `unit` is not given (also among
2786
+ # values that make the chosen unit finer):
2787
+ # `CArray.time(CA_INT64([0, 10]), unit: :m)` is 00:00 and 00:10.
1813
2788
  #
1814
2789
  # A value that cannot be parsed raises by default (`on_error: :raise`);
1815
2790
  # pass `on_error: :mask` to make it an UNDEF cell instead. A masked /
@@ -1817,8 +2792,16 @@ class CArray
1817
2792
  # becomes UNDEF, regardless of `on_error`.
1818
2793
  # @param x [Time, String, Integer, DateTime, CATime::Element, Array, CArray] a literal, a
1819
2794
  # Ruby Array of literals, or a CArray of literals.
1820
- # @param unit [Resolution, Symbol, String] target grid resolution.
2795
+ # @param unit [Resolution, Symbol, String, nil] target grid resolution;
2796
+ # by default the finest the values show, so nothing they hold is cut
2797
+ # off: text written to the day is `:D`, to the second `:s`, with a
2798
+ # fraction `:ms` / `:us` / `:ns` by its digits (as
2799
+ # `CAFrame#parse_to_time` reads text); with a format, the finest field
2800
+ # the format writes; an Integer `:s`; a Time the finest
2801
+ # its value needs; a Date `:D`; an element its own unit.
1821
2802
  # @param format [String, nil] optional strptime format for String input.
2803
+ # With a format, an Integer is read as its decimal digits
2804
+ # (`CArray.time(20240101, format: "%Y%m%d")`), not as a count of ticks.
1822
2805
  # @param on_error [:raise, :mask] parse-failure policy (default `:raise`).
1823
2806
  # @return [CATime] shape `[1]` for a literal, else `x`'s shape.
1824
2807
  # @raise [ArgumentError] on an unparseable value when `on_error: :raise`.
@@ -1827,15 +2810,26 @@ class CArray
1827
2810
  # CArray.time("2024-06-15", unit: :D) # 1-element
1828
2811
  # CArray.time(%w[2024-01-01 2024-02-01], unit: :D) # Ruby Array
1829
2812
  # CArray.time(CA_OBJECT(["2024-01-01", "oops"]), unit: :D, on_error: :mask)
1830
- def self.time(x, unit: :s, format: nil, on_error: :raise)
1831
- res = CATime::Resolution.parse(unit)
2813
+ def self.time(x, unit: nil, format: nil, on_error: :raise)
1832
2814
  unless %i[raise mask].include?(on_error)
1833
2815
  raise ArgumentError, "on_error: must be :raise or :mask (got #{on_error.inspect})"
1834
2816
  end
1835
2817
  x = CA_OBJECT(x) if x.is_a?(Array) # Ruby Array of literals -> object CArray
2818
+ # With a format, an Integer is the digits it is written with (20240101
2819
+ # read as "%Y%m%d"), not a count of ticks: a format describes text.
2820
+ x = CATimeLiteral.integers_as_text(x) if format
2821
+ res = unit ? CATime::Resolution.parse(unit) : CATimeLiteral.unit_shown(x, format)
2822
+ # Without a format, an Integer counts ticks of the unit from the epoch:
2823
+ # seconds by default, minutes with unit: :m.
2824
+ if format.nil?
2825
+ return CA_INT64([x]).time(unit: res) if x.is_a?(Integer)
2826
+ return (x.data_type == CA_INT64 ? x.copy : x).time(unit: res) if x.is_a?(CArray) && !x.face? && x.integer?
2827
+ end
1836
2828
  unless x.is_a?(CArray)
1837
2829
  return CATimeLiteral.to_time_array(x, res, format, on_error)
1838
2830
  end
2831
+ raw = CATimeLiteral.ticks_from_cells(x, res, format, on_error, seconds: unit.nil?)
2832
+ return raw.time(unit: res) if raw
1839
2833
  raw = CArray.int64(*x.shape)
1840
2834
  x.each_index do |*idx|
1841
2835
  s = x[*idx]
@@ -1844,7 +2838,7 @@ class CArray
1844
2838
  next
1845
2839
  end
1846
2840
  begin
1847
- raw[*idx] = CATimeLiteral.tick_index(s, res, format)
2841
+ raw[*idx] = s.is_a?(Integer) && format.nil? && unit ? s : CATimeLiteral.tick_index(s, res, format)
1848
2842
  rescue ArgumentError, TypeError
1849
2843
  raise if on_error == :raise
1850
2844
  raw[*idx] = UNDEF # opt-in parse-mask
@@ -1861,8 +2855,10 @@ class CArray
1861
2855
  # int64 array is built; the origin is not stored).
1862
2856
  # @param unit [Resolution, Symbol, String] grid resolution.
1863
2857
  # @param origin [Time, String, Integer, DateTime, CATime::Element, nil] base instant that
1864
- # `self`'s indices are counted from (default: the Unix epoch).
2858
+ # `self`'s indices are counted from (default: the Unix epoch); an
2859
+ # Integer is Unix seconds. It has to be a point of the `unit` grid.
1865
2860
  # @return [CATime]
2861
+ # @raise [ArgumentError] when `origin` falls between two ticks of `unit`.
1866
2862
  # @note An `int64` receiver is wrapped zero-copy. A narrower integer type
1867
2863
  # is widened to `int64` first (a copy); a `Float` / non-integer type
1868
2864
  # raises (cast it explicitly if the truncation is intended).
@@ -1872,8 +2868,11 @@ class CArray
1872
2868
  if origin.nil?
1873
2869
  CATime.wrap(src, unit: res)
1874
2870
  else
1875
- o = CATimeLiteral.tick_index(origin, res)
1876
- (src + o).time(unit: res)
2871
+ # The origin is a point of the grid: one between two ticks has no
2872
+ # index, so it raises rather than moving to the tick before it.
2873
+ origin = Time.at(origin, in: "UTC") if origin.is_a?(Integer)
2874
+ o = CATimeGrid.resolve_origin_ticks(origin, res, res)
2875
+ CATimeUnitAlgebra.checked_add(src, o).time(unit: res)
1877
2876
  end
1878
2877
  end
1879
2878
 
@@ -1899,6 +2898,13 @@ class CArray
1899
2898
  "array cannot be wrapped (cast it explicitly, e.g. " \
1900
2899
  "arr.int64.time(...), if a lossy conversion is intended)"
1901
2900
  end
2901
+ if data_type == CA_UINT64
2902
+ hi = max
2903
+ if hi != UNDEF && hi > 2**63 - 1
2904
+ raise RangeError,
2905
+ "CATime / CATimedelta storage is int64; #{hi} does not fit"
2906
+ end
2907
+ end
1902
2908
  int64
1903
2909
  end
1904
2910
  private :as_int64_time_storage
@@ -2232,6 +3238,7 @@ class CATime
2232
3238
  m: "minutes", s: "seconds", ms: "milliseconds",
2233
3239
  us: "microseconds", ns: "nanoseconds", ps: "picoseconds",
2234
3240
  fs: "femtoseconds", as: "attoseconds" }.freeze
3241
+ Ractor.make_shareable(WORD)
2235
3242
 
2236
3243
  # Storage resolutions to fall back on when the unit's own tick cannot
2237
3244
  # hold the origin exactly (a day grid anchored at 12:00 needs seconds).
@@ -2268,7 +3275,8 @@ class CATime
2268
3275
  @unit = Resolution.parse(unit)
2269
3276
  @origin = origin
2270
3277
  @storage = resolve_storage
2271
- @origin = CArray.time(origin, unit: @storage)[0] if origin
3278
+ # An Integer origin is an instant in Unix seconds, whatever the unit.
3279
+ @origin = CATimeLiteral.to_time_array(origin, @storage, nil, :raise)[0] if origin
2272
3280
  freeze
2273
3281
  end
2274
3282
 
@@ -2470,6 +3478,34 @@ class CATime
2470
3478
  snap(grid, unit: unit, origin: origin, direction: :floor)
2471
3479
  end
2472
3480
 
3481
+ # @overload end_of(grid = nil, unit:, origin: nil)
3482
+ # Each element at the last tick of its bucket, on the array's own grid:
3483
+ # the bucket {#floor} gives the head of, one tick before the next head.
3484
+ # A day array's month ends on the month's last day, a second array's at
3485
+ # 23:59:59 of it:
3486
+ #
3487
+ # CArray.time(%w[2024-02-10 2024-03-31], unit: :D).end_of(unit: :M)
3488
+ # # => 2024-02-29, 2024-03-31
3489
+ #
3490
+ # The bucket has to be a whole number of the array's ticks, since its
3491
+ # end is counted on that grid.
3492
+ # @return [CATime]
3493
+ # @raise [ArgumentError] when the bucket is finer than the grid or not a
3494
+ # whole number of its ticks.
3495
+ def end_of(grid = nil, unit: nil, origin: nil)
3496
+ step, origin = Grid.resolve(grid, unit, origin)
3497
+ step = CATime::Resolution.parse(step)
3498
+ per = CATimeUnitAlgebra.ratio(step, self.unit) # ticks of the grid per bucket
3499
+ if per && !(per.denominator == 1 && per >= 1)
3500
+ raise ArgumentError,
3501
+ "end_of: a bucket of #{step} is not a whole number of the #{self.unit} " \
3502
+ "grid's ticks, so its last tick is not on the grid"
3503
+ end
3504
+ head = floor(unit: step, origin: origin)
3505
+ head + CATimedelta::Element.new(step.count, step.base) -
3506
+ CATimedelta::Element.new(self.unit.count, self.unit.base)
3507
+ end
3508
+
2473
3509
  # @overload ceil(grid = nil, unit:, origin: nil)
2474
3510
  # {#snap} with `direction: :ceil` -- each element at the bucket head at
2475
3511
  # or after it (an element already on a boundary maps to itself).