carray 3.0.3 → 3.0.5

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 (221) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +469 -0
  3. data/README.md +3 -2
  4. data/carray.gemspec +1 -1
  5. data/ext/ca_array_pool.c +25 -1
  6. data/ext/ca_axis_descriptor.h +27 -24
  7. data/ext/ca_axis_dispatch.c +14 -26
  8. data/ext/ca_axis_group.c +27 -27
  9. data/ext/ca_binop_dispatch.c +1 -29
  10. data/ext/ca_binop_dispatch.h +0 -1
  11. data/ext/ca_categorical_iterator.c +231 -153
  12. data/ext/ca_compare.c +9 -20
  13. data/ext/ca_composite_dispatch.c +9 -9
  14. data/ext/ca_group_iter.c +16 -17
  15. data/ext/ca_kernel_iterator.c +1240 -2000
  16. data/ext/ca_kernel_iterator.h +502 -519
  17. data/ext/ca_monop_dispatch.c +82 -59
  18. data/ext/ca_monop_dispatch.h +1 -0
  19. data/ext/ca_obj_array.c +152 -45
  20. data/ext/ca_obj_bincmp.c +25 -21
  21. data/ext/ca_obj_binop.c +16 -26
  22. data/ext/ca_obj_bitarray.c +8 -3
  23. data/ext/ca_obj_bitfield.c +63 -49
  24. data/ext/ca_obj_block.c +18 -9
  25. data/ext/ca_obj_byte_swap.c +7 -7
  26. data/ext/ca_obj_const_string.c +188 -22
  27. data/ext/ca_obj_face.c +280 -58
  28. data/ext/ca_obj_face.h +19 -0
  29. data/ext/ca_obj_fake.c +15 -29
  30. data/ext/ca_obj_farray.c +6 -3
  31. data/ext/ca_obj_field.c +30 -8
  32. data/ext/ca_obj_fixlen_string.c +2 -3
  33. data/ext/ca_obj_grid.c +455 -134
  34. data/ext/ca_obj_meld.c +172 -34
  35. data/ext/ca_obj_moncmp.c +23 -8
  36. data/ext/ca_obj_monop.c +113 -67
  37. data/ext/ca_obj_object.c +210 -52
  38. data/ext/ca_obj_record.c +15 -7
  39. data/ext/ca_obj_reduce.c +17 -8
  40. data/ext/ca_obj_refer.c +42 -35
  41. data/ext/ca_obj_remap.c +30 -9
  42. data/ext/ca_obj_repeat.c +9 -10
  43. data/ext/ca_obj_roll.c +31 -10
  44. data/ext/ca_obj_select.c +48 -14
  45. data/ext/ca_obj_select_axis.c +15 -47
  46. data/ext/ca_obj_shift.c +41 -7
  47. data/ext/ca_obj_stack.c +180 -34
  48. data/ext/ca_obj_stride.c +190 -134
  49. data/ext/ca_obj_string.c +80 -4
  50. data/ext/ca_obj_tile.c +32 -11
  51. data/ext/ca_obj_time.c +22 -6
  52. data/ext/ca_obj_timedelta.c +22 -6
  53. data/ext/ca_obj_transpose.c +13 -7
  54. data/ext/ca_obj_triop.c +26 -32
  55. data/ext/ca_obj_window.c +267 -74
  56. data/ext/ca_op_ipower.c +4 -4
  57. data/ext/ca_sort_kernels.h +6 -3
  58. data/ext/ca_sweep_engine.c +2 -2
  59. data/ext/caframe_csv_reader.c +80 -1
  60. data/ext/carray.h +214 -75
  61. data/ext/carray_access.c +200 -99
  62. data/ext/carray_address_basis.c +343 -87
  63. data/ext/carray_attribute.c +113 -6
  64. data/ext/carray_bincount.c +10 -8
  65. data/ext/carray_broadcast.c +13 -8
  66. data/ext/carray_build_flags.h +3 -0
  67. data/ext/carray_call_cfunc.c +108 -0
  68. data/ext/carray_cast.c +375 -96
  69. data/ext/carray_cast_func.rb +90 -20
  70. data/ext/carray_class.c +4 -3
  71. data/ext/carray_config.h +28 -0
  72. data/ext/carray_conversion.c +18 -9
  73. data/ext/carray_copy.c +51 -14
  74. data/ext/carray_core.c +150 -55
  75. data/ext/carray_count.c +56 -20
  76. data/ext/carray_element.c +81 -33
  77. data/ext/carray_factorize.c +138 -84
  78. data/ext/carray_generate.c +199 -52
  79. data/ext/carray_histogram.c +63 -18
  80. data/ext/carray_hold.c +12 -15
  81. data/ext/carray_index_classifier.c +388 -176
  82. data/ext/carray_internal.h +348 -29
  83. data/ext/carray_kernels_bincmp.c +4453 -0
  84. data/ext/carray_kernels_binop.c +11368 -0
  85. data/ext/carray_kernels_init.c +1734 -0
  86. data/ext/carray_kernels_moncmp.c +2104 -0
  87. data/ext/carray_kernels_monop.c +18369 -0
  88. data/ext/carray_kernels_reduce_aggregate.c +23818 -0
  89. data/ext/carray_kernels_reduce_boolean.c +406 -0
  90. data/ext/carray_kernels_reduce_cumulative.c +10086 -0
  91. data/ext/carray_kernels_reduce_extreme.c +17634 -0
  92. data/ext/carray_kernels_reduce_variance.c +3849 -0
  93. data/ext/carray_kernels_scan.c +4302 -0
  94. data/ext/carray_kernels_search.c +33829 -0
  95. data/ext/carray_kernels_sort.c +10897 -0
  96. data/ext/carray_kernels_triop.c +1864 -0
  97. data/ext/carray_key_windows.c +220 -0
  98. data/ext/carray_lazy.c +170 -111
  99. data/ext/carray_mask.c +337 -94
  100. data/ext/carray_median_percentile.c +82 -28
  101. data/ext/carray_memory_view.c +669 -561
  102. data/ext/carray_operator.c +129 -52
  103. data/ext/carray_order.c +604 -77
  104. data/ext/carray_partition.c +28 -27
  105. data/ext/carray_random.c +85 -31
  106. data/ext/carray_round.c +273 -0
  107. data/ext/carray_scatter.c +87 -45
  108. data/ext/carray_segment.c +3 -3
  109. data/ext/carray_slab.c +123 -37
  110. data/ext/carray_sort.c +259 -51
  111. data/ext/carray_sort_kernel.c +5 -7
  112. data/ext/carray_struct.c +54 -61
  113. data/ext/carray_test.c +26 -5
  114. data/ext/carray_time_text.c +4 -2
  115. data/ext/carray_undef.c +4 -0
  116. data/ext/carray_utils.c +309 -66
  117. data/ext/mk_call_cfunc.rb +19 -2
  118. data/ext/mkkernel.rb +403 -350
  119. data/ext/ruby_carray.c +10 -4
  120. data/ext/version.h +4 -4
  121. data/lib/carray/attribute.rb +139 -22
  122. data/lib/carray/autoload_carray.rb +8 -31
  123. data/lib/carray/autoload_method_extension.rb +14 -5
  124. data/lib/carray/axis_group.rb +255 -106
  125. data/lib/carray/basics.rb +105 -17
  126. data/lib/carray/bincount_nd.rb +12 -2
  127. data/lib/carray/block_iterator.rb +126 -23
  128. data/lib/carray/boolean_reduce.rb +17 -17
  129. data/lib/carray/categorical.rb +195 -59
  130. data/lib/carray/categorical_iterator.rb +132 -29
  131. data/lib/carray/complex.rb +5 -5
  132. data/lib/carray/conditional.rb +67 -18
  133. data/lib/carray/const_string.rb +24 -5
  134. data/lib/carray/construct.rb +15 -11
  135. data/lib/carray/data_type_extension.rb +9 -1
  136. data/lib/carray/distance_limit.rb +77 -0
  137. data/lib/carray/fixlen_string.rb +48 -3
  138. data/lib/carray/frame/column_verbs.rb +71 -0
  139. data/lib/carray/frame/concat.rb +17 -3
  140. data/lib/carray/frame/convert.rb +69 -0
  141. data/lib/carray/frame/csv_parser.rb +111 -17
  142. data/lib/carray/frame/describe.rb +3 -2
  143. data/lib/carray/frame/frame.rb +252 -28
  144. data/lib/carray/frame/group.rb +603 -101
  145. data/lib/carray/frame/io.rb +117 -28
  146. data/lib/carray/frame/join.rb +312 -51
  147. data/lib/carray/frame/records.rb +83 -7
  148. data/lib/carray/frame/reshape.rb +42 -35
  149. data/lib/carray/frame/sort.rb +7 -19
  150. data/lib/carray/frame/verbs.rb +188 -48
  151. data/lib/carray/frame/windows.rb +117 -0
  152. data/lib/carray/frame.rb +2 -0
  153. data/lib/carray/fuse_source.rb +42 -6
  154. data/lib/carray/fusion.rb +40 -16
  155. data/lib/carray/histogram.rb +6 -1
  156. data/lib/carray/inspect.rb +31 -8
  157. data/lib/carray/iterator.rb +85 -7
  158. data/lib/carray/key_windows.rb +620 -0
  159. data/lib/carray/lazy.rb +217 -183
  160. data/lib/carray/mask_gap_fill.rb +63 -57
  161. data/lib/carray/meld_reduce.rb +12 -14
  162. data/lib/carray/methods/bin.rb +26 -18
  163. data/lib/carray/methods/bincount.rb +16 -0
  164. data/lib/carray/methods/bit_string.rb +1 -1
  165. data/lib/carray/methods/choose.rb +16 -3
  166. data/lib/carray/methods/composition.rb +3 -1
  167. data/lib/carray/methods/gather_nd.rb +14 -5
  168. data/lib/carray/methods/insert_block.rb +2 -2
  169. data/lib/carray/methods/is_between.rb +59 -0
  170. data/lib/carray/methods/is_in.rb +61 -37
  171. data/lib/carray/methods/join.rb +1 -6
  172. data/lib/carray/methods/lag.rb +49 -0
  173. data/lib/carray/methods/locate_addr.rb +47 -51
  174. data/lib/carray/methods/mode.rb +1 -1
  175. data/lib/carray/methods/nunique.rb +11 -6
  176. data/lib/carray/methods/resize.rb +3 -3
  177. data/lib/carray/methods/value_counts.rb +4 -11
  178. data/lib/carray/record_order.rb +3 -3
  179. data/lib/carray/rng.rb +3 -0
  180. data/lib/carray/runtime.rb +53 -8
  181. data/lib/carray/segment_iterator.rb +323 -88
  182. data/lib/carray/serialize.rb +45 -14
  183. data/lib/carray/slab_iterator.rb +50 -0
  184. data/lib/carray/stack.rb +15 -12
  185. data/lib/carray/string.rb +113 -5
  186. data/lib/carray/string_operation_extension.rb +21 -6
  187. data/lib/carray/struct.rb +9 -21
  188. data/lib/carray/struct_builder.rb +24 -11
  189. data/lib/carray/time.rb +819 -158
  190. data/lib/carray/window_iterator.rb +122 -16
  191. data/lib/carray.rb +7 -0
  192. data/yard-stubs/ca_obj_bitfield.rb +1 -1
  193. data/yard-stubs/ca_obj_field.rb +4 -0
  194. data/yard-stubs/ca_obj_grid.rb +21 -10
  195. data/yard-stubs/ca_obj_refer.rb +2 -1
  196. data/yard-stubs/ca_obj_roll.rb +2 -2
  197. data/yard-stubs/ca_obj_shift.rb +5 -1
  198. data/yard-stubs/ca_obj_tile.rb +1 -1
  199. data/yard-stubs/ca_obj_transpose.rb +5 -4
  200. data/yard-stubs/ca_obj_window.rb +8 -3
  201. data/yard-stubs/carray_access.rb +6 -1
  202. data/yard-stubs/carray_attribute.rb +3 -6
  203. data/yard-stubs/carray_cast.rb +12 -0
  204. data/yard-stubs/carray_conversion.rb +7 -2
  205. data/yard-stubs/carray_copy.rb +4 -2
  206. data/yard-stubs/carray_count.rb +4 -4
  207. data/yard-stubs/carray_element.rb +17 -10
  208. data/yard-stubs/carray_generate.rb +21 -1
  209. data/yard-stubs/carray_mask.rb +32 -3
  210. data/yard-stubs/carray_math.rb +41 -5
  211. data/yard-stubs/carray_median_percentile.rb +5 -2
  212. data/yard-stubs/carray_memory_view.rb +40 -23
  213. data/yard-stubs/carray_order.rb +129 -22
  214. data/yard-stubs/carray_random.rb +13 -4
  215. data/yard-stubs/carray_scatter.rb +17 -10
  216. data/yard-stubs/carray_slab.rb +9 -2
  217. data/yard-stubs/carray_sort.rb +6 -5
  218. data/yard-stubs/carray_test.rb +5 -3
  219. metadata +25 -3
  220. data/lib/carray/arrow_tensor.rb +0 -401
  221. 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
 
@@ -106,6 +113,61 @@ module CATimeUnitAlgebra
106
113
  end
107
114
  end
108
115
 
116
+ # How a refusal names an operand: a Face by its class, any other array as
117
+ # a CArray (a lazy expression's node classes are not the user's), anything
118
+ # else by its class.
119
+ def operand_name(x)
120
+ ( x.is_a?(CArray) && !x.face? ) ? "CArray" : x.class.to_s
121
+ end
122
+
123
+ # A single value an operator meets an array with, as a one-cell array
124
+ # marked scalar (a CScalar) of its kind, or nil when it is not one: a
125
+ # duration -- a CATimedelta::Element, or a String that reads as one
126
+ # ("3 days") -- or an instant -- a CATime::Element, a Time, a Date, or any
127
+ # other String. An instant that lands on the +unit+ grid is given in that
128
+ # unit, so a day array minus "2024-01-02" answers in days.
129
+ def scalar_operand(x, unit)
130
+ case x
131
+ when CATimedelta::Element
132
+ CATimedelta.wrap(CScalar.int64 { x.value }, unit: x.unit)
133
+ when String
134
+ res = begin
135
+ CATime::Resolution.parse(x)
136
+ rescue ArgumentError
137
+ nil
138
+ end
139
+ return CATimedelta.wrap(CScalar.int64 { res.count }, unit: res.base) if res
140
+ scalar_instant(CATimeLiteral.exact_instant(x), unit)
141
+ when CATime::Element
142
+ CATime.wrap(CScalar.int64 { x.value }, unit: x.unit)
143
+ when Time, Date
144
+ scalar_instant(CATimeLiteral.exact_instant(x), unit)
145
+ end
146
+ end
147
+
148
+ # A duration String added to or subtracted from a time, as one step of its
149
+ # own resolution: "15 minutes" is one 15-minute step, the duration
150
+ # CArray.int64(1).timedelta(unit: "15 minutes") is, so it lands on a
151
+ # "10 minutes" grid or is refused in the same way. nil for anything else.
152
+ def step_operand(x)
153
+ return nil unless x.is_a?(String)
154
+ res = begin
155
+ CATime::Resolution.parse(x)
156
+ rescue ArgumentError
157
+ return nil
158
+ end
159
+ CATimedelta.wrap(CScalar.int64 { 1 }, unit: res)
160
+ end
161
+
162
+ def scalar_instant(t, unit)
163
+ v = begin
164
+ convert_instant!(t.parent, t.unit, unit)[0]
165
+ rescue ArgumentError
166
+ return CATime.wrap(CScalar.int64 { t.parent[0] }, unit: t.unit)
167
+ end
168
+ CATime.wrap(CScalar.int64 { v }, unit: unit)
169
+ end
170
+
109
171
  # seconds- (fixed) or months- (calendar) per base tick.
110
172
  def base_ratio(base)
111
173
  FIXED[base] || CALENDAR[base]
@@ -254,7 +316,8 @@ module CATimeUnitAlgebra
254
316
  # CATimedelta#to_unit and by dt +/- td, where the result keeps the time's
255
317
  # unit and a finer duration is truncated to it (a :D time + a 5 h duration
256
318
  # is + 0 days; + 30 h is + 1 day). Cross-group still raises (a calendar
257
- # duration has no fixed ratio to a fixed unit).
319
+ # duration has no fixed ratio to a fixed unit; dt +/- a month moves the date
320
+ # through shift_months instead).
258
321
  def convert_scale_trunc(storage, from, to)
259
322
  a = res(from); b = res(to)
260
323
  return storage if a == b
@@ -269,6 +332,67 @@ module CATimeUnitAlgebra
269
332
  r.denominator == 1 ? scaled : trunc_divide(scaled, r.denominator)
270
333
  end
271
334
 
335
+ # A duration brought onto a time's grid for dt +/- td. A duration finer
336
+ # than the grid is truncated toward zero (a :D time + 5 h is + 0 days), but
337
+ # one whose tick is coarser and not a whole number of grid ticks ("1 day"
338
+ # on a "7 hours" grid, "15 minutes" on a "10 minutes" grid) must land on
339
+ # the grid exactly: cutting it would move the time by part of its own
340
+ # tick, which no rounding of the time asked for.
341
+ def duration_on_grid(storage, from, to)
342
+ r = ratio(res(from), res(to))
343
+ return convert_scale_trunc(storage, from, to) if r.nil? || r <= 1 || r.denominator == 1
344
+ scaled = widen(storage, r.numerator)
345
+ unless (scaled % r.denominator).eq(0).all
346
+ raise ArgumentError,
347
+ "a duration in #{res(from)} does not land on the #{res(to)} grid " \
348
+ "(it is not a whole number of #{res(to)} ticks); convert the time " \
349
+ "to a grid both share first"
350
+ end
351
+ scaled / r.denominator
352
+ end
353
+
354
+ # a + b and a - b on int64 storage (b a CArray or an Integer), raising
355
+ # instead of wrapping around when a result leaves int64. An overflow is a
356
+ # result whose sign differs from both terms of the sum (a and b for +, a
357
+ # and -b for -).
358
+ def checked_add(a, b)
359
+ check_term(b, "+")
360
+ r = a + b
361
+ overflow_raise("+") if ((a ^ r) & (b ^ r)).lt(0).any
362
+ r
363
+ end
364
+
365
+ def checked_sub(a, b)
366
+ check_term(b, "-")
367
+ r = a - b
368
+ overflow_raise("-") if ((a ^ b) & (a ^ r)).lt(0).any
369
+ r
370
+ end
371
+
372
+ def checked_mul(a, k)
373
+ check_term(k, "*")
374
+ lo = a.min
375
+ unless lo == UNDEF
376
+ [lo, a.max].each do |x|
377
+ overflow_raise("*") unless (INT64_MIN..INT64_MAX).cover?(Integer(x) * k)
378
+ end
379
+ end
380
+ a * k
381
+ end
382
+
383
+ def check_term(b, op)
384
+ overflow_raise(op) if b.is_a?(Integer) && !(INT64_MIN..INT64_MAX).cover?(b)
385
+ end
386
+
387
+ def overflow_raise(op)
388
+ raise RangeError,
389
+ "time arithmetic overflows int64 (#{op}): the result is outside " \
390
+ "the range of the storage unit; use a coarser unit"
391
+ end
392
+
393
+ INT64_MIN = -2**63
394
+ INT64_MAX = 2**63 - 1
395
+
272
396
  # Truncating integer division of an int64 CArray (toward zero). CArray
273
397
  # `/` floors toward the past, which is what an instant wants; a duration
274
398
  # is a magnitude and shrinks toward zero instead.
@@ -297,6 +421,40 @@ module CATimeUnitAlgebra
297
421
  end
298
422
  end
299
423
 
424
+ # A time on a fixed grid moved by whole months, as Ruby's Date#>> moves a
425
+ # date: the year and the month move, the day stays unless the new month is
426
+ # shorter (then it is that month's last day), and the time of day stays.
427
+ # The grid has to tile a day, so the result lands on it; on any other grid
428
+ # (:W, "3 days", "7 hours") a month moves a time off its grid, and that
429
+ # raises. +months+ is an Integer or an int64 CArray of the time's shape.
430
+ def shift_months(storage, from, months)
431
+ rd = ratio(CATime::Resolution.new(1, :D), from) # ticks of `from` per day
432
+ unless rd && rd.denominator == 1
433
+ raise ArgumentError,
434
+ "cannot move a time on the #{from} grid by months: a day is not a " \
435
+ "whole number of its ticks, so the result would be off the grid"
436
+ end
437
+ n = rd.numerator
438
+ days = storage / n # floors toward the past
439
+ rest = storage - days * n # the time of day, in ticks
440
+ y, m, d = CATimeCivil.civil_from_days(days)
441
+ ord = y * 12 + (m - 1) + months # month ordinal after the move
442
+ ny = ord / 12
443
+ nm = ord - ny * 12 + 1
444
+ ones = CArray.int64(*storage.shape) { 1 }
445
+ head = CATimeCivil.days_from_civil(ny, nm, ones)
446
+ ord1 = ord + 1
447
+ ny1 = ord1 / 12
448
+ last = CATimeCivil.days_from_civil(ny1, ord1 - ny1 * 12 + 1, ones) - head
449
+ widen(head + d.pmin(last) - 1, n) + rest
450
+ end
451
+
452
+ # Whole months in a calendar duration of +res+: its count of months or of
453
+ # years.
454
+ def months_per_tick(res)
455
+ (res.count * CALENDAR.fetch(res.base)).to_i
456
+ end
457
+
300
458
  # calendar time (Resolution `from`) -> days since the epoch (int64
301
459
  # CArray). Folds the resolution count (value = count-Y/M buckets).
302
460
  def calendar_days_since_epoch(storage, from)
@@ -325,25 +483,29 @@ module CATimeUnitAlgebra
325
483
  widen(calendar_days_since_epoch(storage, from), r.numerator)
326
484
  end
327
485
 
486
+ # Whole days since the epoch (floored) of fixed-length ticks, and the
487
+ # remainder that says whether each instant sits on a day boundary. A tick
488
+ # need not divide a day, nor be a whole number of days ("7 hours",
489
+ # "36 hours", "7 seconds"): the count is scaled to a grid both share.
490
+ def fixed_storage_days(storage, from)
491
+ rd = ratio(CATime::Resolution.new(1, :D), from) # `from` ticks per day
492
+ scaled = widen(storage, rd.denominator)
493
+ n = rd.numerator
494
+ { days: scaled / n, rest: scaled % n }
495
+ end
496
+
328
497
  # fixed-length grid -> calendar time: the coarsening half of
329
498
  # convert_instant!, exact-or-raise. Every instant must land on a day
330
499
  # boundary and then on the calendar boundary itself (the 1st, and
331
500
  # January too for :Y), since a mid-month instant has no :M value.
332
501
  def convert_instant_fixed_to_calendar(storage, from, to)
333
- rd = ratio(CATime::Resolution.new(1, :D), from) # `from` ticks per day
334
- days =
335
- if rd.denominator == 1
336
- n = rd.numerator
337
- unless (storage % n).eq(0).all
338
- raise ArgumentError,
339
- "cannot convert time #{from} to #{to} without loss " \
340
- "(instant is not on a day boundary)"
341
- end
342
- storage / n
343
- else # coarser than a day (:W)
344
- storage * (from.tick_ratio / 86400r).to_i
345
- end
346
- y, m, d = CATimeCivil.civil_from_days(days)
502
+ days = fixed_storage_days(storage, from)
503
+ unless (days[:rest]).eq(0).all
504
+ raise ArgumentError,
505
+ "cannot convert time #{from} to #{to} without loss " \
506
+ "(instant is not on a day boundary)"
507
+ end
508
+ y, m, d = CATimeCivil.civil_from_days(days[:days])
347
509
  on_boundary = d.eq(1)
348
510
  on_boundary &= m.eq(1) if to.base == :Y
349
511
  unless on_boundary.all
@@ -390,14 +552,7 @@ module CATimeUnitAlgebra
390
552
  # month, whatever day and time it carries. The flooring counterpart of
391
553
  # convert_instant_fixed_to_calendar.
392
554
  def convert_instant_fixed_to_calendar_floor(storage, from, to)
393
- rd = ratio(CATime::Resolution.new(1, :D), from) # `from` ticks per day
394
- days =
395
- if rd.denominator == 1
396
- storage / rd.numerator
397
- else # coarser than a day (:W)
398
- storage * (from.tick_ratio / 86400r).to_i
399
- end
400
- y, m, = CATimeCivil.civil_from_days(days)
555
+ y, m, = CATimeCivil.civil_from_days(fixed_storage_days(storage, from)[:days])
401
556
  ord = to.base == :M ? (y * 12 + (m - 1) - 1970 * 12) : (y - 1970)
402
557
  to.count > 1 ? ord / to.count : ord
403
558
  end
@@ -443,8 +598,64 @@ module CATimeSurfaceFill
443
598
 
444
599
  end
445
600
 
601
+ # The part of to_comparable the two time Faces share: values written one by
602
+ # one (a Ruby Array) and an array that is not of this kind (an object column,
603
+ # a string column) are each read as a value of this Face by to_comparable, an
604
+ # UNDEF or a masked cell kept masked. Nil for any other operand, which the Face's own
605
+ # to_comparable decides.
606
+ module CATimeOperandValues
607
+
608
+ # The reference and the operand of a comparison (eq, <, count, search,
609
+ # linear_section, pmax ...) brought to one unit: the operand read in its
610
+ # own unit, then the reference's unit when the operand sits on its grid
611
+ # (the reference is not converted), else the finer unit the two share, as
612
+ # is_in and the set operations meet. A value that is not of this kind is
613
+ # refused as to_comparable refuses it. An assignment, which has to land on
614
+ # the reference's grid, reconciles with to_comparable instead.
615
+ # @api private
616
+ def comparable_pair (operand)
617
+ lifted = lift_operand(operand)
618
+ return [self, lifted] if lifted.unit == unit
619
+ narrowed = lifted.to_unit(unit)
620
+ if narrowed.to_unit(lifted.unit).parent.eq(lifted.parent).all
621
+ return [self, narrowed]
622
+ end
623
+ in_common_unit_with(lifted)
624
+ end
625
+
626
+ private
627
+
628
+ # The operand as an array of this kind in its own unit: one value read as
629
+ # it is written, an Array or an array of another kind read value by value
630
+ # in the unit they share, an UNDEF or a masked cell kept masked.
631
+ def lift_operand (operand)
632
+ return operand if operand.is_a?(self.class)
633
+ case operand
634
+ when Array
635
+ # A nested Array keeps its shape; reading it row by row would land
636
+ # each row as one value.
637
+ return lift_operand(CA_OBJECT(operand)) if operand.any?(Array)
638
+ lifted = operand.map { |v| UNDEF.equal?(v) ? nil : lift_operand(v) }
639
+ units = lifted.compact.map(&:unit)
640
+ u = units.empty? ? unit : units.reduce { |a, b| common_unit(a, b) }
641
+ self.class.wrap(CA_INT64(lifted.map { |x| x ? x.to_unit(u).parent[0] : UNDEF }),
642
+ unit: u)
643
+ when CArray
644
+ lift_operand(operand.to_a.flatten).reshape(*operand.shape)
645
+ else
646
+ lift_value(operand) || to_comparable(operand)
647
+ end
648
+ end
649
+
650
+ def reconcile_values (operand)
651
+ to_comparable(lift_operand(operand))
652
+ end
653
+
654
+ end
655
+
446
656
  class CATime
447
657
  include CATimeSurfaceFill
658
+ include CATimeOperandValues
448
659
  # @overload new(*shape, unit: :ns)
449
660
  # Allocates a new int64 storage CArray of the given `shape` and
450
661
  # wraps it as a CATime Face with the given `unit`. The
@@ -465,7 +676,7 @@ class CATime
465
676
  # @param unit [Resolution, Symbol, String] tick resolution.
466
677
  # @return [CATime]
467
678
  def self.wrap(raw, unit: :ns)
468
- __wrap__(raw, unit)
679
+ wrap_storage(raw, unit)
469
680
  end
470
681
 
471
682
  # Element-return class: a single time element carries both its
@@ -497,8 +708,6 @@ class CATime
497
708
  # @return [Time] the instant this scalar denotes (UTC). For a calendar
498
709
  # unit this is the first instant (midnight of day 1) of the granule.
499
710
  def to_time
500
- require 'time'
501
- require 'date'
502
711
  case unit.base
503
712
  when :Y then d = epoch_date.next_year(value * unit.count); Time.utc(d.year, d.month, d.day)
504
713
  when :M then d = epoch_date.next_month(value * unit.count); Time.utc(d.year, d.month, d.day)
@@ -510,7 +719,6 @@ class CATime
510
719
  # @return [Date] the date this scalar denotes (UTC). A sub-day unit is
511
720
  # floored to its day.
512
721
  def to_date
513
- require 'date'
514
722
  case unit.base
515
723
  when :Y then epoch_date.next_year(value * unit.count)
516
724
  when :M then epoch_date.next_month(value * unit.count)
@@ -559,6 +767,10 @@ class CATime
559
767
  to_time <=> other.getutc
560
768
  when (defined?(DateTime) ? DateTime : nil)
561
769
  to_time <=> other.to_time.getutc
770
+ when (defined?(Date) ? Date : nil)
771
+ # a Date names the midnight (UTC) that begins it, as it does when an
772
+ # array is compared with it
773
+ to_time.to_r <=> CATimeLiteral.epoch_seconds(other)
562
774
  end
563
775
  rescue ArgumentError
564
776
  nil
@@ -576,18 +788,27 @@ class CATime
576
788
  [value, unit].hash
577
789
  end
578
790
 
791
+ # Marshal carries the tick and its resolution.
792
+ def marshal_dump = [value, unit.count, unit.base]
793
+
794
+ def marshal_load(data)
795
+ v, count, base = data
796
+ initialize(v, CATime::Resolution.new(count, base))
797
+ end
798
+
579
799
  # @overload +(td)
580
800
  # Returns this instant advanced by a {CATimedelta::Element}. The time
581
801
  # is the anchor: the result keeps `self`'s unit and the duration is
582
802
  # converted into it, truncated toward zero when finer (a :D time + a
583
803
  # 5 h duration is + 0 days; + 30 h is + 1 day) -- the same rule as the
584
- # array {CATime#+}. Only a duration in the SAME group (both calendar
585
- # or both fixed) is accepted -- a cross-group step (a :s time + a :M
586
- # duration) is calendar arithmetic; use `to_date` +
587
- # `Date#next_month` / `#next_year` for that.
804
+ # array {CATime#+}. A month or year duration on a fixed grid moves
805
+ # the date as Ruby's `Date#>>` does (the day stays unless the month is
806
+ # shorter, the time of day stays); a fixed duration on a month or year
807
+ # grid raises, as does a month on a grid a day does not divide.
588
808
  # @param td [CATimedelta::Element]
589
809
  # @return [CATime::Element]
590
- # @raise [TypeError, ArgumentError] on a non-timedelta / cross-group operand.
810
+ # @raise [TypeError, ArgumentError] on a non-timedelta operand, or a
811
+ # pair that has no answer on self's grid.
591
812
  def +(td)
592
813
  shifted_by_duration(td, 1)
593
814
  end
@@ -603,9 +824,13 @@ class CATime
603
824
  case other
604
825
  when Element
605
826
  u = CATimeUnitAlgebra.diff_unit(unit, other.unit)
606
- CATimedelta::Element.new(instant_in(u) - other.instant_in(u), u)
607
- when CATimedelta::Element
827
+ v = instant_in(u) - other.instant_in(u)
828
+ CATimeUnitAlgebra.check_term(v, "-")
829
+ CATimedelta::Element.new(v, u)
830
+ when CATimedelta::Element, CATimedelta
608
831
  shifted_by_duration(other, -1)
832
+ when CATime
833
+ -(other - self)
609
834
  else
610
835
  raise TypeError, "CATime::Element - #{other.class} is not allowed"
611
836
  end
@@ -624,22 +849,33 @@ class CATime
624
849
 
625
850
  # s +/- td: the time is the anchor, so the result keeps self's unit and
626
851
  # the duration is converted into it (truncated toward zero when finer; a
627
- # cross-group calendar duration raises). Mirrors the array CATime +/-.
852
+ # month or year duration on a fixed grid moves the date by months).
853
+ # Mirrors the array CATime +/-.
628
854
  def shifted_by_duration(td, sign)
855
+ # An array of durations gives an array of instants, anchored here.
856
+ return sign > 0 ? td + self : (-td) + self if td.is_a?(CATimedelta)
629
857
  unless td.is_a?(CATimedelta::Element)
630
858
  raise TypeError,
631
859
  "CATime::Element #{sign > 0 ? '+' : '-'} #{td.class} is not " \
632
860
  "allowed (add / subtract a CATimedelta)"
633
861
  end
634
- tv = CATimeUnitAlgebra.convert_scale_trunc(CA_INT64([td.value]), td.unit, unit)[0]
635
- Element.new(value + sign * tv, unit)
862
+ if CATimeUnitAlgebra::CALENDAR.key?(td.unit.base) &&
863
+ !CATimeUnitAlgebra::CALENDAR.key?(unit.base)
864
+ # months on a fixed grid move the date as Date#>> does, as the
865
+ # array does
866
+ months = sign * td.value * CATimeUnitAlgebra.months_per_tick(td.unit)
867
+ return Element.new(CATimeUnitAlgebra.shift_months(CA_INT64([value]), unit, months)[0], unit)
868
+ end
869
+ tv = CATimeUnitAlgebra.duration_on_grid(CA_INT64([td.value]), td.unit, unit)[0]
870
+ v = value + sign * tv
871
+ CATimeUnitAlgebra.check_term(v, sign > 0 ? "+" : "-")
872
+ Element.new(v, unit)
636
873
  end
637
874
 
638
875
  # Base epoch as a Date. next_month / next_year clamp end-of-month
639
876
  # (Jan 31 -> Feb 28), but the base is day 1, so no clamping ever occurs --
640
877
  # do not move the base off the first of the month without revisiting.
641
878
  def epoch_date
642
- require 'date'
643
879
  Date.new(1970, 1, 1, Date::GREGORIAN) # proleptic Gregorian, matching to_time
644
880
  end
645
881
 
@@ -662,6 +898,7 @@ end
662
898
 
663
899
  class CATimedelta
664
900
  include CATimeSurfaceFill
901
+ include CATimeOperandValues
665
902
  # @overload new(*shape, unit: :ns)
666
903
  # Allocates a new int64 storage CArray of the given `shape` and
667
904
  # wraps it as a CATimedelta Face with the given `unit`.
@@ -679,7 +916,7 @@ class CATimedelta
679
916
  # @param unit [CATime::Resolution, Symbol, String] tick resolution.
680
917
  # @return [CATimedelta]
681
918
  def self.wrap(raw, unit: :ns)
682
- __wrap__(raw, unit)
919
+ wrap_storage(raw, unit)
683
920
  end
684
921
 
685
922
  # A single duration: an integer tick count plus the resolution those ticks
@@ -746,6 +983,14 @@ class CATimedelta
746
983
  [value, unit].hash
747
984
  end
748
985
 
986
+ # Marshal carries the tick and its resolution.
987
+ def marshal_dump = [value, unit.count, unit.base]
988
+
989
+ def marshal_load(data)
990
+ v, count, base = data
991
+ initialize(v, CATime::Resolution.new(count, base))
992
+ end
993
+
749
994
  # @overload +(other)
750
995
  # Returns the sum of two durations, promoting to the finer unit. A
751
996
  # cross-group operand (a :M + a :s duration) has no fixed relation and
@@ -766,7 +1011,9 @@ class CATimedelta
766
1011
  # @return [CATimedelta::Element]
767
1012
  def *(n)
768
1013
  raise TypeError, "CATimedelta::Element * #{n.class} is not allowed (Integer only)" unless n.is_a?(Integer)
769
- Element.new(value * n, unit)
1014
+ v = value * n
1015
+ CATimeUnitAlgebra.check_term(v, "*")
1016
+ Element.new(v, unit)
770
1017
  end
771
1018
 
772
1019
  # @overload /(other)
@@ -801,12 +1048,21 @@ class CATimedelta
801
1048
  private
802
1049
 
803
1050
  def combined_with_duration(other, sign)
1051
+ # An array gives an array: durations by durations, and instants for a
1052
+ # duration added to times (subtracting times from a duration is not
1053
+ # defined).
1054
+ case other
1055
+ when CATimedelta then return sign > 0 ? other + self : -(other - self)
1056
+ when CATime then return other + self if sign > 0
1057
+ end
804
1058
  unless other.is_a?(Element)
805
1059
  raise TypeError,
806
1060
  "CATimedelta::Element #{sign > 0 ? '+' : '-'} #{other.class} is not allowed"
807
1061
  end
808
1062
  u = CATimeUnitAlgebra.finer(unit, other.unit)
809
- Element.new(duration_in(u) + sign * other.duration_in(u), u)
1063
+ v = duration_in(u) + sign * other.duration_in(u)
1064
+ CATimeUnitAlgebra.check_term(v, sign > 0 ? "+" : "-")
1065
+ Element.new(v, u)
810
1066
  end
811
1067
  end
812
1068
  end
@@ -836,7 +1092,7 @@ class CATime
836
1092
  # resolution (see §4 of docs/CATime.md).
837
1093
  # @return [CArray]
838
1094
  def ticks
839
- parent
1095
+ face_storage
840
1096
  end
841
1097
 
842
1098
  # @overload to_unit(unit)
@@ -866,29 +1122,46 @@ class CATime
866
1122
  # @raise [RangeError] when the widened ticks overflow int64.
867
1123
  def to_unit(unit)
868
1124
  to = CATime::Resolution.parse(unit)
869
- CATimeUnitAlgebra.convert_instant_floor(parent, self.unit, to).time(unit: to)
1125
+ CATimeUnitAlgebra.convert_instant_floor(face_storage, self.unit, to).time(unit: to)
870
1126
  end
871
1127
 
872
1128
  # @overload +(other)
873
1129
  # Returns `self + other` for a {CATimedelta}: the time is the
874
1130
  # anchor, so the result keeps `self`'s unit and the duration is converted
875
1131
  # into it (a duration finer than `self`'s unit is truncated to it; a
876
- # cross-group calendar duration raises). Adding two datetimes is
877
- # ill-defined.
878
- # @param other [CATimedelta]
1132
+ # coarser one that is not a whole number of `self`'s ticks raises). A
1133
+ # month or year duration on a fixed grid moves each date as Ruby's
1134
+ # `Date#>>` does: the day stays unless the new month is shorter (then it
1135
+ # is that month's last day), and the time of day stays, so
1136
+ # `2024-01-31 + 1 month` is `2024-02-29`. The grid has to divide a day
1137
+ # into whole ticks; on any other (`:W`, `"3 days"`) a month raises, as a
1138
+ # fixed duration on a month or year grid does. Adding two datetimes is
1139
+ # ill-defined. A single duration -- a {CATimedelta::Element} or a String
1140
+ # such as `"3 days"` -- is added to every cell; a String is one step of
1141
+ # its own resolution, so `"15 minutes"` on a `"10 minutes"` grid raises
1142
+ # as `CArray.int64(1).timedelta(unit: "15 minutes")` does.
1143
+ # @param other [CATimedelta, CATimedelta::Element, String]
879
1144
  # (or a MemoryView producer whose +to_ca+ gives one, such as an Arrow
880
1145
  # duration array)
881
1146
  # @return [CATime]
882
1147
  # @raise [TypeError] on a non-timedelta operand.
1148
+ # @raise [ArgumentError] when the duration does not land on `self`'s grid.
1149
+ # @raise [RangeError] when a result leaves the range of int64 ticks.
883
1150
  def +(other)
884
1151
  given, other = other, CATimeUnitAlgebra.operand(other)
1152
+ other = CATimeUnitAlgebra.step_operand(other) ||
1153
+ CATimeUnitAlgebra.scalar_operand(other, unit) || other
885
1154
  case other
886
1155
  when CATimedelta
887
- (parent + CATimeUnitAlgebra.convert_scale_trunc(other.parent, other.unit, unit)).time(unit: unit)
1156
+ return shifted_by_months(other, 1) if moved_by_months?(other)
1157
+ d = CATimeUnitAlgebra.duration_on_grid(other.parent, other.unit, unit)
1158
+ d = d[0] if other.scalar?
1159
+ CATimeUnitAlgebra.checked_add(face_storage, d).time(unit: unit)
888
1160
  when CATime
889
1161
  raise TypeError, "CATime + CATime is ill-defined"
890
1162
  else
891
- raise TypeError, "CATime + #{given.class} is not allowed (use CATimedelta)"
1163
+ raise TypeError, "CATime + #{CATimeUnitAlgebra.operand_name(given)} is not allowed: add a duration " \
1164
+ "(a CATimedelta, a CATimedelta::Element or a String such as \"3 days\")"
892
1165
  end
893
1166
  end
894
1167
 
@@ -896,27 +1169,92 @@ class CATime
896
1169
  # Subtracting a {CATimedelta} yields a {CATime} at `self`'s unit (the
897
1170
  # duration is converted into it, truncated when finer); subtracting
898
1171
  # another {CATime} yields a {CATimedelta} at the finer of the two
899
- # units (cross-group falls to the fixed unit).
900
- # @param other [CATimedelta, CATime]
1172
+ # units (cross-group falls to the fixed unit). A single duration (a
1173
+ # {CATimedelta::Element}, `"3 days"`) or time (a {CATime::Element}, a
1174
+ # `Time`, a `Date`, `"2024-01-02"`) is subtracted from every cell.
1175
+ # @param other [CATimedelta, CATime, CATimedelta::Element, CATime::Element, Time, Date, String]
901
1176
  # (or a MemoryView producer whose +to_ca+ gives one, such as an Arrow
902
1177
  # duration or timestamp array)
903
1178
  # @return [CATime, CATimedelta]
904
1179
  # @raise [TypeError] on an unsupported operand.
905
1180
  def -(other)
906
1181
  given, other = other, CATimeUnitAlgebra.operand(other)
1182
+ other = CATimeUnitAlgebra.step_operand(other) ||
1183
+ CATimeUnitAlgebra.scalar_operand(other, unit) || other
907
1184
  case other
908
1185
  when CATimedelta
909
- (parent - CATimeUnitAlgebra.convert_scale_trunc(other.parent, other.unit, unit)).time(unit: unit)
1186
+ return shifted_by_months(other, -1) if moved_by_months?(other)
1187
+ d = CATimeUnitAlgebra.duration_on_grid(other.parent, other.unit, unit)
1188
+ d = d[0] if other.scalar?
1189
+ CATimeUnitAlgebra.checked_sub(face_storage, d).time(unit: unit)
910
1190
  when CATime
911
1191
  u = CATimeUnitAlgebra.diff_unit(unit, other.unit)
912
- a = CATimeUnitAlgebra.convert_instant!(parent, unit, u)
1192
+ a = CATimeUnitAlgebra.convert_instant!(face_storage, unit, u)
913
1193
  b = CATimeUnitAlgebra.convert_instant!(other.parent, other.unit, u)
914
- (a - b).timedelta(unit: u)
1194
+ b = b[0] if other.scalar?
1195
+ CATimeUnitAlgebra.checked_sub(a, b).timedelta(unit: u)
915
1196
  else
916
- raise TypeError, "CATime - #{given.class} is not allowed"
1197
+ raise TypeError, "CATime - #{CATimeUnitAlgebra.operand_name(given)} is not allowed: subtract a duration " \
1198
+ "(a CATimedelta, a CATimedelta::Element or a String such as \"3 days\") " \
1199
+ "or a time (a CATime, a CATime::Element, a Time, a Date or a String)"
917
1200
  end
918
1201
  end
919
1202
 
1203
+ # Whether a duration moves this time by calendar months: a month or year
1204
+ # duration on a time whose grid is fixed-length. A time on a month or year
1205
+ # grid adds it as months already, and a fixed duration on a calendar grid
1206
+ # is refused by the scale conversion.
1207
+ private def moved_by_months? (td)
1208
+ CATimeUnitAlgebra::CALENDAR.key?(td.unit.base) &&
1209
+ !CATimeUnitAlgebra::CALENDAR.key?(unit.base)
1210
+ end
1211
+
1212
+ # self moved by the months of +td+ (+sign+ 1 to add, -1 to subtract), as
1213
+ # Ruby's Date#>> moves a date: the day is kept unless the month is shorter.
1214
+ private def shifted_by_months (td, sign)
1215
+ months = td.parent.int64 * (sign * CATimeUnitAlgebra.months_per_tick(td.unit))
1216
+ months = months[0] if td.scalar?
1217
+ CATimeUnitAlgebra.shift_months(face_storage, unit, months).time(unit: unit)
1218
+ end
1219
+
1220
+ # @overload pmax(other)
1221
+ # The later of each cell and `other`, cell by cell: `other` is a time
1222
+ # array of the same shape, or one time (a {CATime::Element}, a `Time`, a
1223
+ # `Date`, `"2024-01-02"`) for every cell. The two meet as a comparison
1224
+ # does, so the result is in `self`'s unit when `other` sits on its grid
1225
+ # and in the finer unit the two share otherwise. A masked cell on either
1226
+ # side gives a masked cell.
1227
+ # @param other [CATime, CATime::Element, Time, Date, String]
1228
+ # @return [CATime]
1229
+ # @raise [TypeError] when `other` is not a time.
1230
+ def pmax (other)
1231
+ pick_by_order(other) { |a, b| a < b }
1232
+ end
1233
+
1234
+ # @overload pmin(other)
1235
+ # The earlier of each cell and `other`; see {#pmax}.
1236
+ # @param other [CATime, CATime::Element, Time, Date, String]
1237
+ # @return [CATime]
1238
+ # @raise [TypeError] when `other` is not a time.
1239
+ def pmin (other)
1240
+ pick_by_order(other) { |a, b| a > b }
1241
+ end
1242
+
1243
+ # A time has no NaN, so maximum / minimum (which let a NaN win) are pmax /
1244
+ # pmin, and fmax / fmin are their other names.
1245
+ alias_method :maximum, :pmax
1246
+ alias_method :minimum, :pmin
1247
+ alias_method :fmax, :pmax
1248
+ alias_method :fmin, :pmin
1249
+
1250
+ # `other` where the block says self's cell loses to it, else self's cell.
1251
+ private def pick_by_order (other)
1252
+ return copy.tap { |out| out[] = UNDEF } if UNDEF.equal?(other)
1253
+ a, b = comparable_pair(other)
1254
+ b = b[0] unless other.is_a?(CArray) || other.is_a?(Array)
1255
+ yield(a, b).then_else(b, a)
1256
+ end
1257
+
920
1258
  # Element-wise comparison (< <= > >= <=>) rides the core comparison Face
921
1259
  # gate (ca_face_reconcile_comparison in ext/ca_obj_face.c): the inherited
922
1260
  # CArray operators descend to storage and reconcile a CATime RHS in a
@@ -942,8 +1280,9 @@ class CATime
942
1280
  # they run on the storage ticks and round the result back to the nearest
943
1281
  # tick of `unit`. A caller who needs a finer answer declares the grid
944
1282
  # first (`t.to_unit(:ms).mean`) -- the same rule linear_fetch follows.
945
- # Rounding is to nearest, not toward zero: a truncating round would flip
946
- # the direction of the error at the epoch.
1283
+ # Rounding is to nearest, and a tie goes to the later tick: rounding half
1284
+ # away from zero would send a tie later after 1970 and earlier before it,
1285
+ # so moving the data by whole days would change the answer.
947
1286
 
948
1287
  # @overload mean(axis: nil, **opts)
949
1288
  # Returns the centroid time on `self`'s unit (§8), rounded to the
@@ -1088,8 +1427,21 @@ class CATime
1088
1427
  [to_unit(u), other.to_unit(u)]
1089
1428
  end
1090
1429
 
1430
+ private def common_unit (a, b)
1431
+ CATimeUnitAlgebra.diff_unit(a, b)
1432
+ end
1433
+
1434
+ # One instant as a length-1 time array in its own unit, or nil.
1435
+ private def lift_value (operand)
1436
+ case operand
1437
+ when CATime::Element
1438
+ CATime.wrap(CA_INT64([operand.value]), unit: operand.unit)
1439
+ when Time, String, Date
1440
+ CATimeLiteral.exact_instant(operand)
1441
+ end
1442
+ end
1443
+
1091
1444
  def to_comparable (operand)
1092
- require 'date' # Date is one of the operand kinds below
1093
1445
  case operand
1094
1446
  when CATime
1095
1447
  return operand if operand.unit == unit
@@ -1100,21 +1452,15 @@ class CATime
1100
1452
  to_comparable(lifted)
1101
1453
  when Time, String, Date
1102
1454
  to_comparable(CATimeLiteral.exact_instant(operand))
1103
- when Array
1104
- # A set of instants written one by one (is_in(["2024-01-02", ...])).
1105
- CATime.wrap(CA_INT64(operand.map { |v| to_comparable(v).parent[0] }),
1106
- unit: unit)
1107
- when CArray
1108
- # An array that is not a time array: each value read as an instant (an
1109
- # object column of Time values, a string column), a masked cell kept
1110
- # masked.
1111
- require "carray/methods/is_in"
1112
- send(:values_in_face, self, operand) ||
1113
- CArray.int64(*operand.shape).fill(UNDEF).time(unit: unit)
1455
+ when Array, CArray
1456
+ # A set of instants written one by one (is_in(["2024-01-02", ...])),
1457
+ # or an array that is not a time array (an object column of Time
1458
+ # values, a string column), read value by value.
1459
+ reconcile_values(operand)
1114
1460
  else
1115
1461
  raise TypeError,
1116
1462
  "CATime cannot reconcile #{operand.class} " \
1117
- "(use ca.parent to compare the raw int64 storage directly)"
1463
+ "(compare #ticks for the raw int64 storage)"
1118
1464
  end
1119
1465
  end
1120
1466
 
@@ -1172,7 +1518,7 @@ class CATime
1172
1518
  # @return [Element, CATime] an {Element} for a scalar `addr`, `nil` when
1173
1519
  # that single answer is undetermined.
1174
1520
  def linear_fetch (addr, **opts)
1175
- r = parent.float64.linear_fetch(addr, **opts)
1521
+ r = face_storage.float64.linear_fetch(addr, **opts)
1176
1522
  case r
1177
1523
  when CArray
1178
1524
  # mask_invalid before the int64 cast: the cast turns a NaN into 0, which
@@ -1201,10 +1547,9 @@ class CATime
1201
1547
  # CArray. Array-level counterpart to {Element#to_time}.
1202
1548
  # @return [CArray]
1203
1549
  def to_time
1204
- require 'time'
1205
1550
  if CATimeUnitAlgebra::FIXED.key?(unit.base)
1206
1551
  f = unit.tick_ratio # exact seconds / tick (Rational)
1207
- parent.convert(:object) {|v| Time.at(v * f, in: 'UTC')}
1552
+ face_storage.convert(:object) {|v| Time.at(v * f, in: 'UTC')}
1208
1553
  else # calendar: exact granule midnight
1209
1554
  (days_since_epoch * 86400).convert(:object) {|v| Time.at(v, in: 'UTC')}
1210
1555
  end
@@ -1216,7 +1561,6 @@ class CATime
1216
1561
  # {Element#to_date}.
1217
1562
  # @return [CArray]
1218
1563
  def to_date
1219
- require 'date'
1220
1564
  # 2440588 = JD of 1970-01-01; proleptic Gregorian to match to_time and the field accessors.
1221
1565
  days_since_epoch.convert(:object) {|d| Date.jd(2440588 + d, Date::GREGORIAN)}
1222
1566
  end
@@ -1226,7 +1570,6 @@ class CATime
1226
1570
  # as an object CArray.
1227
1571
  # @return [CArray]
1228
1572
  def to_datetime
1229
- require 'date'
1230
1573
  to_time.convert(:object) {|t| t.to_datetime}
1231
1574
  end
1232
1575
 
@@ -1240,8 +1583,8 @@ class CATime
1240
1583
  # @return [CArray]
1241
1584
  def year
1242
1585
  case unit.base
1243
- when :Y then parent * unit.count + 1970
1244
- when :M then (parent * unit.count + 1970 * 12) / 12
1586
+ when :Y then face_storage * unit.count + 1970
1587
+ when :M then (face_storage * unit.count + 1970 * 12) / 12
1245
1588
  else CATimeCivil.civil_from_days(days_since_epoch)[0]
1246
1589
  end
1247
1590
  end
@@ -1251,9 +1594,9 @@ class CATime
1251
1594
  # @return [CArray] integer.
1252
1595
  def month
1253
1596
  case unit.base
1254
- when :Y then parent * 0 + 1
1597
+ when :Y then face_storage * 0 + 1
1255
1598
  when :M
1256
- mo = parent * unit.count + 1970 * 12
1599
+ mo = face_storage * unit.count + 1970 * 12
1257
1600
  mo % 12 + 1
1258
1601
  else CATimeCivil.civil_from_days(days_since_epoch)[1]
1259
1602
  end
@@ -1264,7 +1607,7 @@ class CATime
1264
1607
  # @return [CArray] integer.
1265
1608
  def day
1266
1609
  case unit.base
1267
- when :Y, :M then parent * 0 + 1
1610
+ when :Y, :M then face_storage * 0 + 1
1268
1611
  else CATimeCivil.civil_from_days(days_since_epoch)[2]
1269
1612
  end
1270
1613
  end
@@ -1279,6 +1622,54 @@ class CATime
1279
1622
  # @return [CArray] integer.
1280
1623
  def second; clock_field(:s); end
1281
1624
 
1625
+ # Quarter of the year, 1..4 (January to March is 1). For a fiscal year,
1626
+ # count quarters with `timesteps(unit: "3 months", origin:)`.
1627
+ # @return [CArray] integer.
1628
+ def quarter
1629
+ (month - 1) / 3 + 1
1630
+ end
1631
+
1632
+ # ISO 8601 week number, 1..53. A week runs Monday to Sunday and belongs
1633
+ # to the year its Thursday is in, so the first days of January can be in
1634
+ # the last week of the year before, and the last days of December in
1635
+ # week 1 of the next; {#iso_year} is that year.
1636
+ # @return [CArray] integer.
1637
+ def iso_week
1638
+ thursday, year = iso_thursday
1639
+ ones = CArray.int64(*shape) { 1 }
1640
+ (thursday - CATimeCivil.days_from_civil(year, ones, ones)) / 7 + 1
1641
+ end
1642
+
1643
+ # The ISO 8601 year of each time's week (see {#iso_week}): the calendar
1644
+ # year except in the few days around New Year whose week is counted in the
1645
+ # neighbouring year.
1646
+ # @return [CArray] integer.
1647
+ def iso_year
1648
+ iso_thursday[1]
1649
+ end
1650
+
1651
+ # The Thursday of each time's ISO week, in days since the epoch, and the
1652
+ # year it falls in.
1653
+ private def iso_thursday
1654
+ days = days_since_epoch
1655
+ iso_weekday = (days + 3) % 7 + 1 # Monday 1 .. Sunday 7; 1970-01-01 is a Thursday
1656
+ thursday = days - iso_weekday + 4
1657
+ [thursday, CATimeCivil.civil_from_days(thursday)[0]]
1658
+ end
1659
+
1660
+ # Number of days in the month, 28..31 (January's for `:Y` storage, which
1661
+ # does not resolve months). `t.day.eq(t.days_in_month)` is the last day of
1662
+ # the month.
1663
+ # @return [CArray] integer.
1664
+ def days_in_month
1665
+ y, m, = CATimeCivil.civil_from_days(days_since_epoch)
1666
+ ones = CArray.int64(*shape) { 1 }
1667
+ ord = y * 12 + m # the next month's ordinal (m is 1..12)
1668
+ ny = ord / 12
1669
+ CATimeCivil.days_from_civil(ny, ord - ny * 12 + 1, ones) -
1670
+ CATimeCivil.days_from_civil(y, m, ones)
1671
+ end
1672
+
1282
1673
  # Day of the week, Sunday = 0 .. Saturday = 6.
1283
1674
  # @return [CArray] integer.
1284
1675
  def weekday
@@ -1301,7 +1692,6 @@ class CATime
1301
1692
  # Returns the Julian Day Number for each element.
1302
1693
  # @return [CArray]
1303
1694
  def jd
1304
- require 'date'
1305
1695
  to_time.convert(:int) {|t| t.to_date.jd}
1306
1696
  end
1307
1697
 
@@ -1310,7 +1700,6 @@ class CATime
1310
1700
  # day) for each element.
1311
1701
  # @return [CArray]
1312
1702
  def ajd
1313
- require 'date'
1314
1703
  to_time.convert(:double) {|t| t.to_datetime.ajd.to_f}
1315
1704
  end
1316
1705
 
@@ -1341,7 +1730,7 @@ class CATime
1341
1730
  # writes to_unit(:D) first) -- and the result is rounded back to a tick.
1342
1731
  def reduce_on_own_grid(op, kind, args, opts)
1343
1732
  with_surface_fill(opts) do |o|
1344
- round_and_relift(parent.public_send(op, *args, **o), kind)
1733
+ round_and_relift(face_storage.public_send(op, *args, **o), kind)
1345
1734
  end
1346
1735
  end
1347
1736
 
@@ -1351,14 +1740,15 @@ class CATime
1351
1740
  def round_and_relift(r, kind)
1352
1741
  case r
1353
1742
  when Numeric
1354
- v = r.round
1743
+ v = kind == :timedelta ? r.round : (r + 0.5).floor
1355
1744
  kind == :timedelta ? CATimedelta::Element.new(v, unit) : Element.new(v, unit)
1356
1745
  when CArray
1357
1746
  # A cell with no answer arrives masked and the mask survives the cast.
1358
1747
  # NaN does not: it has no int64 image, so a `fill_value: Float::NAN`
1359
1748
  # cell would cast to garbage bits. mask_invalid turns it into UNDEF,
1360
1749
  # which is what "no answer" means here.
1361
- iv = r.round.mask_invalid.int64
1750
+ near = kind == :timedelta ? r.round : (r + 0.5).floor
1751
+ iv = near.mask_invalid.int64
1362
1752
  kind == :timedelta ? iv.timedelta(unit: unit) : iv.time(unit: unit)
1363
1753
  when Array
1364
1754
  r.map {|x| round_and_relift(x, kind)}
@@ -1418,7 +1808,7 @@ class CATimedelta
1418
1808
  # measured in this array's resolution (see {CATime#ticks}).
1419
1809
  # @return [CArray]
1420
1810
  def ticks
1421
- parent
1811
+ face_storage
1422
1812
  end
1423
1813
 
1424
1814
  # @overload to_unit(unit)
@@ -1440,7 +1830,7 @@ class CATimedelta
1440
1830
  # @raise [RangeError] when the widened ticks overflow int64.
1441
1831
  def to_unit(unit)
1442
1832
  to = CATime::Resolution.parse(unit)
1443
- CATimeUnitAlgebra.convert_scale_trunc(parent, self.unit, to).timedelta(unit: to)
1833
+ CATimeUnitAlgebra.convert_scale_trunc(face_storage, self.unit, to).timedelta(unit: to)
1444
1834
  end
1445
1835
 
1446
1836
  # @overload +(other)
@@ -1454,16 +1844,20 @@ class CATimedelta
1454
1844
  # @raise [TypeError] on incompatible operands.
1455
1845
  def +(other)
1456
1846
  given, other = other, CATimeUnitAlgebra.operand(other)
1847
+ other = CATimeUnitAlgebra.scalar_operand(other, unit) || other
1457
1848
  case other
1458
1849
  when CATimedelta
1459
1850
  u = common_duration_unit(other.unit)
1460
- a = CATimeUnitAlgebra.convert_scale!(parent, unit, u)
1851
+ a = CATimeUnitAlgebra.convert_scale!(face_storage, unit, u)
1461
1852
  b = CATimeUnitAlgebra.convert_scale!(other.parent, other.unit, u)
1462
- (a + b).timedelta(unit: u)
1853
+ b = b[0] if other.scalar?
1854
+ CATimeUnitAlgebra.checked_add(a, b).timedelta(unit: u)
1463
1855
  when CATime
1464
- other + self # commutative -> time's unit
1856
+ return other + self unless other.scalar? # commutative -> time's unit
1857
+ d = CATimeUnitAlgebra.duration_on_grid(face_storage, unit, other.unit)
1858
+ CATimeUnitAlgebra.checked_add(d, other.parent[0]).time(unit: other.unit)
1465
1859
  else
1466
- raise TypeError, "CATimedelta + #{given.class} is not allowed"
1860
+ raise TypeError, "CATimedelta + #{CATimeUnitAlgebra.operand_name(given)} is not allowed"
1467
1861
  end
1468
1862
  end
1469
1863
 
@@ -1477,14 +1871,16 @@ class CATimedelta
1477
1871
  # @raise [TypeError] on a non-timedelta operand.
1478
1872
  def -(other)
1479
1873
  given, other = other, CATimeUnitAlgebra.operand(other)
1874
+ other = CATimeUnitAlgebra.scalar_operand(other, unit) || other
1480
1875
  case other
1481
1876
  when CATimedelta
1482
1877
  u = common_duration_unit(other.unit)
1483
- a = CATimeUnitAlgebra.convert_scale!(parent, unit, u)
1878
+ a = CATimeUnitAlgebra.convert_scale!(face_storage, unit, u)
1484
1879
  b = CATimeUnitAlgebra.convert_scale!(other.parent, other.unit, u)
1485
- (a - b).timedelta(unit: u)
1880
+ b = b[0] if other.scalar?
1881
+ CATimeUnitAlgebra.checked_sub(a, b).timedelta(unit: u)
1486
1882
  else
1487
- raise TypeError, "CATimedelta - #{given.class} is not allowed"
1883
+ raise TypeError, "CATimedelta - #{CATimeUnitAlgebra.operand_name(given)} is not allowed"
1488
1884
  end
1489
1885
  end
1490
1886
 
@@ -1493,14 +1889,14 @@ class CATimedelta
1493
1889
  # unit.
1494
1890
  # @return [CATimedelta]
1495
1891
  def abs
1496
- parent.abs.timedelta(unit: unit)
1892
+ face_storage.abs.timedelta(unit: unit)
1497
1893
  end
1498
1894
 
1499
1895
  # @overload -@
1500
1896
  # Returns each duration with its sign reversed, on the same unit.
1501
1897
  # @return [CATimedelta]
1502
1898
  def -@
1503
- (-parent).timedelta(unit: unit)
1899
+ (-face_storage).timedelta(unit: unit)
1504
1900
  end
1505
1901
 
1506
1902
  # @overload *(other)
@@ -1510,15 +1906,16 @@ class CATimedelta
1510
1906
  # @raise [TypeError] when `other` is not an Integer.
1511
1907
  def *(other)
1512
1908
  case other
1513
- when Integer then (parent * other).timedelta(unit: unit)
1514
- else raise TypeError, "CATimedelta * #{other.class} is not allowed (Integer only)"
1909
+ when Integer then CATimeUnitAlgebra.checked_mul(face_storage, other).timedelta(unit: unit)
1910
+ else raise TypeError, "CATimedelta * #{CATimeUnitAlgebra.operand_name(other)} is not allowed (Integer only)"
1515
1911
  end
1516
1912
  end
1517
1913
 
1518
1914
  # @overload /(other)
1519
1915
  # Returns element-wise division: by Integer yields a
1520
- # {CATimedelta}, by another {CATimedelta} with matching `unit`
1521
- # yields a dimensionless CArray.
1916
+ # {CATimedelta} (truncated toward zero to the unit), by another
1917
+ # {CATimedelta} their ratio as a float64 CArray (90 s / 60 s is 1.5),
1918
+ # the two brought to a common unit first.
1522
1919
  # @param other [Integer, CATimedelta]
1523
1920
  # (or a MemoryView producer whose +to_ca+ gives one, such as an Arrow
1524
1921
  # duration array)
@@ -1528,12 +1925,14 @@ class CATimedelta
1528
1925
  given, other = other, CATimeUnitAlgebra.operand(other)
1529
1926
  case other
1530
1927
  when Integer
1531
- CATimeUnitAlgebra.trunc_divide(parent, other).timedelta(unit: unit)
1928
+ CATimeUnitAlgebra.trunc_divide(face_storage, other).timedelta(unit: unit)
1532
1929
  when CATimedelta
1930
+ # A ratio of durations is a number, not a count of whole ticks: 90 s /
1931
+ # 60 s is 1.5. Both are brought to one unit first, so the ratio does
1932
+ # not depend on the units they are held in.
1533
1933
  u = common_duration_unit(other.unit)
1534
- CATimeUnitAlgebra.trunc_divide(
1535
- CATimeUnitAlgebra.convert_scale!(parent, unit, u),
1536
- CATimeUnitAlgebra.convert_scale!(other.parent, other.unit, u))
1934
+ CATimeUnitAlgebra.convert_scale!(face_storage, unit, u).to_type(:float64) /
1935
+ CATimeUnitAlgebra.convert_scale!(other.parent, other.unit, u)
1537
1936
  else raise TypeError, "CATimedelta / #{given.class} is not allowed"
1538
1937
  end
1539
1938
  end
@@ -1547,26 +1946,55 @@ class CATimedelta
1547
1946
 
1548
1947
  # @overload sum(*args, **opts)
1549
1948
  # Returns the sum of durations as a {Element} for full reduction
1550
- # or as a {CATimedelta} view for per-axis reduction.
1949
+ # or as a {CATimedelta} view for per-axis reduction. The sum is exact:
1950
+ # the unit counts are added in int64, not in float64.
1551
1951
  # @return [Element, CATimedelta]
1952
+ # @raise [RangeError] if a sum does not fit int64 (for a per-axis
1953
+ # reduction, if any of its cells does not).
1552
1954
  def sum(*args, **opts)
1553
- with_surface_fill(opts) { |o| round_and_relift(parent.sum(*args, **o)) }
1955
+ with_surface_fill(opts) { |o|
1956
+ exact_sum_relift(face_storage.accumulate(*args, **o), face_storage.sum(*args, **o))
1957
+ }
1958
+ end
1959
+
1960
+ # int64 addition is exact modulo 2**64, so the int64 sum is the true sum
1961
+ # whenever the true sum fits int64, whatever order the cells were added
1962
+ # in. Whether it fits is read from the float64 sum, whose error is far
1963
+ # inside the factor of 2 between 2**62 and 2**63.
1964
+ def exact_sum_relift (exact, approx)
1965
+ past = case approx
1966
+ when Numeric then approx.abs > 2.0**62
1967
+ when CArray then (approx.abs > 2.0**62).any
1968
+ else false
1969
+ end
1970
+ raise RangeError, "sum of durations does not fit int64 (in #{unit})" if past
1971
+ case exact
1972
+ when Integer then Element.new(exact, unit)
1973
+ when CArray then exact.timedelta(unit: unit)
1974
+ else exact
1975
+ end
1554
1976
  end
1977
+ private :exact_sum_relift
1555
1978
 
1556
1979
  # @overload accumulate(*args, **opts)
1557
- # Returns the sum of durations in the storage's own integer type, as
1558
- # {#sum} does in float64.
1980
+ # Returns the sum of durations in the storage's integer type (int64),
1981
+ # added without a range check: a sum past int64 wraps. {#sum} gives
1982
+ # the same answer when it fits and raises RangeError when it does not.
1559
1983
  # @return [Element, CATimedelta]
1560
1984
  def accumulate(*args, **opts)
1561
- with_surface_fill(opts) { |o| round_and_relift(parent.accumulate(*args, **o)) }
1985
+ with_surface_fill(opts) { |o| round_and_relift(face_storage.accumulate(*args, **o)) }
1562
1986
  end
1563
1987
 
1564
1988
  # @overload cumsum(axis: nil)
1565
1989
  # Returns the running sum of durations as a {CATimedelta} on `self`'s
1566
1990
  # unit. A masked cell holds the running sum, as {CArray#cumsum} does.
1991
+ # The running sum is exact: the unit counts are added in int64, as
1992
+ # {#sum} adds them.
1567
1993
  # @return [CATimedelta]
1994
+ # @raise [RangeError] if any cell of the running sum does not fit int64.
1568
1995
  def cumsum(*args, **opts)
1569
- round_and_relift(parent.cumsum(*args, **opts))
1996
+ exact_sum_relift(face_storage.send(:cumsum_in_own_type, *args, **opts),
1997
+ face_storage.cumsum(*args, **opts))
1570
1998
  end
1571
1999
 
1572
2000
  # @overload prod(*)
@@ -1584,7 +2012,7 @@ class CATimedelta
1584
2012
  # Returns the mean duration rounded to the nearest unit count.
1585
2013
  # @return [Element, CATimedelta]
1586
2014
  def mean(*args, **opts)
1587
- with_surface_fill(opts) { |o| round_and_relift(parent.mean(*args, **o)) }
2015
+ with_surface_fill(opts) { |o| round_and_relift(face_storage.mean(*args, **o)) }
1588
2016
  end
1589
2017
 
1590
2018
  # median / percentile / quantile / stddev / stddevp report on this array's
@@ -1596,7 +2024,7 @@ class CATimedelta
1596
2024
  # Returns the median duration on `self`'s unit.
1597
2025
  # @return [Element, CATimedelta]
1598
2026
  def median(*args, **opts)
1599
- with_surface_fill(opts) { |o| round_and_relift(parent.median(*args, **o)) }
2027
+ with_surface_fill(opts) { |o| round_and_relift(face_storage.median(*args, **o)) }
1600
2028
  end
1601
2029
 
1602
2030
  # @overload percentile(*p, axis: nil, **opts)
@@ -1605,7 +2033,7 @@ class CATimedelta
1605
2033
  # two or more give an Array).
1606
2034
  # @return [Element, CATimedelta, Array<Element>, Array<CATimedelta>]
1607
2035
  def percentile(*args, **opts)
1608
- with_surface_fill(opts) { |o| round_and_relift(parent.percentile(*args, **o)) }
2036
+ with_surface_fill(opts) { |o| round_and_relift(face_storage.percentile(*args, **o)) }
1609
2037
  end
1610
2038
 
1611
2039
  # @overload quantile(axis: nil, **opts)
@@ -1613,7 +2041,7 @@ class CATimedelta
1613
2041
  # `self`'s unit.
1614
2042
  # @return [Array<Element>, Array<CATimedelta>]
1615
2043
  def quantile(*args, **opts)
1616
- with_surface_fill(opts) { |o| round_and_relift(parent.quantile(*args, **o)) }
2044
+ with_surface_fill(opts) { |o| round_and_relift(face_storage.quantile(*args, **o)) }
1617
2045
  end
1618
2046
 
1619
2047
  # @overload stddev(axis: nil, **opts)
@@ -1622,7 +2050,7 @@ class CATimedelta
1622
2050
  # the precision matters.
1623
2051
  # @return [Element, CATimedelta]
1624
2052
  def stddev(*args, **opts)
1625
- with_surface_fill(opts) { |o| round_and_relift(parent.stddev(*args, **o)) }
2053
+ with_surface_fill(opts) { |o| round_and_relift(face_storage.stddev(*args, **o)) }
1626
2054
  end
1627
2055
 
1628
2056
  # @overload stddevp(axis: nil, **opts)
@@ -1630,7 +2058,7 @@ class CATimedelta
1630
2058
  # {#stddev}.
1631
2059
  # @return [Element, CATimedelta]
1632
2060
  def stddevp(*args, **opts)
1633
- with_surface_fill(opts) { |o| round_and_relift(parent.stddevp(*args, **o)) }
2061
+ with_surface_fill(opts) { |o| round_and_relift(face_storage.stddevp(*args, **o)) }
1634
2062
  end
1635
2063
 
1636
2064
  def round_and_relift(r)
@@ -1699,6 +2127,17 @@ class CATimedelta
1699
2127
  [to_unit(u), other.to_unit(u)]
1700
2128
  end
1701
2129
 
2130
+ private def common_unit (a, b)
2131
+ CATimeUnitAlgebra.finer(a, b)
2132
+ end
2133
+
2134
+ # One duration as a length-1 duration array in its own unit, or nil.
2135
+ private def lift_value (operand)
2136
+ if operand.is_a?(CATimedelta::Element)
2137
+ CATimedelta.wrap(CA_INT64([operand.value]), unit: operand.unit)
2138
+ end
2139
+ end
2140
+
1702
2141
  def to_comparable (operand)
1703
2142
  case operand
1704
2143
  when CATimedelta
@@ -1708,10 +2147,14 @@ class CATimedelta
1708
2147
  when CATimedelta::Element
1709
2148
  lifted = CATimedelta.wrap(CA_INT64([operand.value]), unit: operand.unit)
1710
2149
  to_comparable(lifted)
2150
+ when Array, CArray
2151
+ # Durations written one by one, or an array that is not a duration
2152
+ # array (an object column of elements), read value by value.
2153
+ reconcile_values(operand)
1711
2154
  else
1712
2155
  raise TypeError,
1713
2156
  "CATimedelta cannot reconcile #{operand.class} " \
1714
- "(use ca.parent to compare the raw int64 storage directly)"
2157
+ "(compare #ticks for the raw int64 storage)"
1715
2158
  end
1716
2159
  end
1717
2160
 
@@ -1752,7 +2195,7 @@ class CATimedelta
1752
2195
  # @return [Element, CATimedelta] an {Element} for a scalar `addr`, `nil`
1753
2196
  # when that single answer is undetermined.
1754
2197
  def linear_fetch (addr, **opts)
1755
- r = parent.float64.linear_fetch(addr, **opts)
2198
+ r = face_storage.float64.linear_fetch(addr, **opts)
1756
2199
  case r
1757
2200
  when CArray then r.mask_invalid.round.int64.timedelta(unit: unit)
1758
2201
  when Numeric then r.to_f.nan? ? nil : Element.new(r.round, unit)
@@ -1808,14 +2251,25 @@ module CATimeLiteral
1808
2251
  # "201909" is a valid YYMMDD to Ruby (2020-19-09, which landed on 2021-07),
1809
2252
  # and "2019-02-31" landed on 2019-03-03. A missing finer field is not an
1810
2253
  # error -- a year or a year-month names the head of that period.
2254
+ # %s and %Q give the seconds since the epoch, not the date's fields;
2255
+ # they are spelled out as the UTC date and time they name.
2256
+ def epoch_fields(h)
2257
+ return h unless h && h[:seconds] && !h[:year]
2258
+ t = Time.at(h[:seconds]).utc
2259
+ frac = h[:seconds] - h[:seconds].floor
2260
+ { year: t.year, mon: t.month, mday: t.day, hour: t.hour, min: t.min,
2261
+ sec: t.sec, sec_fraction: frac.zero? ? nil : Rational(frac), leftover: h[:leftover] }.compact
2262
+ end
2263
+
1811
2264
  def parse_date_fields(spec, format)
1812
2265
  h =
1813
- if format then Date._strptime(spec, format)
2266
+ if format then epoch_fields(Date._strptime(spec, format))
1814
2267
  elsif spec =~ /\A(\d{4})-(\d{1,2})\z/ then { year: $1.to_i, mon: $2.to_i }
1815
2268
  elsif spec =~ /\A(\d{4})\z/ then { year: $1.to_i }
1816
2269
  elsif (hour = hour_only_fields(spec)) then hour
1817
2270
  else Date._parse(spec)
1818
2271
  end
2272
+ h = resolve_day_of_year(h, spec) if h
1819
2273
  unless h && h[:year]
1820
2274
  raise ArgumentError, "cannot parse time #{spec.inspect}"
1821
2275
  end
@@ -1840,10 +2294,63 @@ module CATimeLiteral
1840
2294
  raise ArgumentError,
1841
2295
  "cannot parse time #{spec.inspect}: hour 24 is only 24:00:00"
1842
2296
  end
2297
+ # A clock field out of range is refused rather than carried into the
2298
+ # next one ("10:75" is not 11:15). Second 60 is the leap second.
2299
+ { hour: 24, min: 59, sec: 60 }.each do |key, top|
2300
+ v = h[key]
2301
+ next if v.nil? || (0..top).cover?(v)
2302
+ raise ArgumentError,
2303
+ "cannot parse time #{spec.inspect}: #{key} #{v} is out of range"
2304
+ end
1843
2305
  h
1844
2306
  end
1845
2307
  private_class_method :parse_date_fields
1846
2308
 
2309
+ # The month and day of a date written by its day of the year ("2024-015",
2310
+ # %j), its ISO week ("2024-W03-1", %G-W%V-%u) or its week of the year
2311
+ # (%U / %W with a weekday). Date._parse and Date._strptime return those
2312
+ # fields as they are written; left alone, the date would fall back to
2313
+ # January 1.
2314
+ def resolve_day_of_year(h, spec)
2315
+ return h if h[:mon] || h[:mday]
2316
+ date =
2317
+ if h[:yday] && h[:year]
2318
+ unless Date.valid_ordinal?(h[:year], h[:yday], Date::GREGORIAN)
2319
+ raise ArgumentError,
2320
+ "cannot parse time #{spec.inspect}: year #{h[:year]} has no day #{h[:yday]}"
2321
+ end
2322
+ Date.ordinal(h[:year], h[:yday], Date::GREGORIAN)
2323
+ elsif h[:cwyear] && h[:cweek]
2324
+ wd = h[:cwday] || 1
2325
+ unless Date.valid_commercial?(h[:cwyear], h[:cweek], wd, Date::GREGORIAN)
2326
+ raise ArgumentError,
2327
+ "cannot parse time #{spec.inspect}: ISO year #{h[:cwyear]} " \
2328
+ "has no week #{h[:cweek]}, day #{wd}"
2329
+ end
2330
+ Date.commercial(h[:cwyear], h[:cweek], wd, Date::GREGORIAN)
2331
+ elsif h[:year] && (h[:wnum0] || h[:wnum1])
2332
+ jan1 = Date.new(h[:year], 1, 1, Date::GREGORIAN)
2333
+ wday = h[:wday] || (h[:cwday] && h[:cwday] % 7)
2334
+ # Week 1 begins on the first Sunday (%U) or Monday (%W) of the year;
2335
+ # the days before it are week 0.
2336
+ if h[:wnum0]
2337
+ first = jan1 + (7 - jan1.wday) % 7
2338
+ d = first + (h[:wnum0] - 1) * 7 + (wday || 0)
2339
+ else
2340
+ first = jan1 + (8 - jan1.wday) % 7
2341
+ d = first + (h[:wnum1] - 1) * 7 + ((wday || 1) + 6) % 7
2342
+ end
2343
+ unless d.year == h[:year]
2344
+ raise ArgumentError,
2345
+ "cannot parse time #{spec.inspect}: the week and weekday fall outside #{h[:year]}"
2346
+ end
2347
+ d
2348
+ end
2349
+ return h unless date
2350
+ h.merge(year: date.year, mon: date.month, mday: date.day)
2351
+ end
2352
+ private_class_method :resolve_day_of_year
2353
+
1847
2354
  # "YYYY-MM-DDThh" with an optional zone ("Z", "+hh", "+hhmm", "+hh:mm").
1848
2355
  def hour_only_fields(spec)
1849
2356
  m = /\A(\d{4})-(\d{2})-(\d{2})T(\d{2})(?:(Z)|([+-])(\d{2})(?::?(\d{2}))?)?\z/.match(spec)
@@ -1879,8 +2386,6 @@ module CATimeLiteral
1879
2386
  # Exact Rational seconds since the Unix epoch for a start literal (Time /
1880
2387
  # Date / DateTime / Integer unix-seconds / String). UTC default.
1881
2388
  def epoch_seconds(spec, format = nil)
1882
- require 'date'
1883
- require 'time'
1884
2389
  case spec
1885
2390
  when Time then spec.to_r
1886
2391
  when Integer then Rational(spec)
@@ -1917,8 +2422,6 @@ module CATimeLiteral
1917
2422
 
1918
2423
  # [year, month] (UTC) of a start literal, for a calendar-resolution grid.
1919
2424
  def year_month(spec, format = nil)
1920
- require 'date'
1921
- require 'time'
1922
2425
  case spec
1923
2426
  when Time then t = spec.utc; [t.year, t.month]
1924
2427
  when Integer then t = Time.at(spec, in: 'UTC'); [t.year, t.month]
@@ -1963,12 +2466,14 @@ module CATimeLiteral
1963
2466
 
1964
2467
  INFER_DATES = %w[%d/%m/%Y %m/%d/%Y %d-%m-%Y %m-%d-%Y %d.%m.%Y %d/%m/%y %m/%d/%y] +
1965
2468
  ["%d %b %Y", "%d-%b-%Y", "%d-%b-%y", "%b %d, %Y", "%b %d %Y", "%Y%m%d"]
2469
+ Ractor.make_shareable(INFER_DATES)
1966
2470
 
1967
2471
  # A space in a format matches any run of spaces, none included.
1968
2472
  INFER_CLOCKS = [
1969
2473
  ["%H:%M", :clock], ["%H:%M:%S", :clock], ["%H:%M:%S.%N", :fraction],
1970
2474
  ["%I:%M %p", :clock], ["%I:%M:%S %p", :clock],
1971
2475
  ].freeze
2476
+ Ractor.make_shareable(INFER_CLOCKS)
1972
2477
 
1973
2478
  # A time after a date: none, or a clock with or without a zone. %z takes
1974
2479
  # an offset, Z, UTC or GMT, not a zone named for a place (some of those
@@ -1976,16 +2481,19 @@ module CATimeLiteral
1976
2481
  INFER_TIMES = ([["", :date]] +
1977
2482
  INFER_CLOCKS.flat_map { |clock, kind| [[" #{clock}", kind], [" #{clock} %z", kind]] }
1978
2483
  ).freeze
2484
+ Ractor.make_shareable(INFER_TIMES)
1979
2485
 
1980
2486
  # A date written in Japanese, year first ("2024<year>1<month>2<day>"),
1981
2487
  # takes a time in the same markers ("3<hour>4<minute>", "...5<second>")
1982
2488
  # or with colons.
1983
2489
  JA_YEAR, JA_MONTH, JA_DAY = "年", "月", "日"
1984
2490
  JA_HOUR, JA_MINUTE, JA_SECOND = "時", "分", "秒"
2491
+ [JA_YEAR, JA_MONTH, JA_DAY, JA_HOUR, JA_MINUTE, JA_SECOND].each { |s| Ractor.make_shareable(s) }
1985
2492
  INFER_JA_TIMES = ([["", :date]] +
1986
2493
  ([["%H#{JA_HOUR}%M#{JA_MINUTE}", :clock],
1987
2494
  ["%H#{JA_HOUR}%M#{JA_MINUTE}%S#{JA_SECOND}", :clock]] + INFER_CLOCKS)
1988
2495
  .map { |clock, kind| [" #{clock}", kind] }).freeze
2496
+ Ractor.make_shareable(INFER_JA_TIMES)
1989
2497
 
1990
2498
  INFER_FORMATS = (
1991
2499
  INFER_DATES.product(INFER_TIMES).map { |date, (time, kind)|
@@ -1995,7 +2503,9 @@ module CATimeLiteral
1995
2503
  TimeFormat.new("%Y#{JA_YEAR}%m#{JA_MONTH}%d#{JA_DAY}#{time}", kind)
1996
2504
  }
1997
2505
  ).freeze
2506
+ Ractor.make_shareable(INFER_FORMATS)
1998
2507
  INFER_FORMAT_STRINGS = INFER_FORMATS.map(&:format).freeze
2508
+ Ractor.make_shareable(INFER_FORMAT_STRINGS)
1999
2509
 
2000
2510
  # The one format of INFER_FORMATS that the text of object array `x` is
2001
2511
  # written in, `first` being its first present cell, as
@@ -2061,8 +2571,7 @@ module CATimeLiteral
2061
2571
  # otherwise each String is parsed into fields here. Either way the fields
2062
2572
  # become ticks for the whole array at once, and a cell that is not a
2063
2573
  # String goes through tick_index.
2064
- def ticks_from_cells(x, res, format, on_error)
2065
- require 'date'
2574
+ def ticks_from_cells(x, res, format, on_error, seconds: false)
2066
2575
  kind, per = tick_kind(res)
2067
2576
  return nil unless kind
2068
2577
  if format.is_a?(String)
@@ -2092,6 +2601,10 @@ module CATimeLiteral
2092
2601
  frac[i] = (h[:sec_fraction] * 1_000_000_000).floor if h[:sec_fraction]
2093
2602
  off[i] = h[:offset] if h[:offset]
2094
2603
  present[i] = 1
2604
+ elsif s.is_a?(Integer) && format.nil?
2605
+ # an Integer counts ticks of the unit given, or seconds when the
2606
+ # unit is chosen from the values
2607
+ direct[i] = seconds ? tick_index(s, res) : s
2095
2608
  else
2096
2609
  direct[i] = tick_index(s, res, format)
2097
2610
  end
@@ -2139,6 +2652,80 @@ module CATimeLiteral
2139
2652
  raise RangeError, "time #{cells[i].inspect} does not fit int64 ticks of #{res}"
2140
2653
  end
2141
2654
 
2655
+ # The unit CArray.time reads +x+ in when none is given: the finest any of
2656
+ # its values shows (see the unit: of CArray.time), :s when none shows one.
2657
+ def unit_shown(x, format)
2658
+ cells = x.is_a?(CArray) ? x.flatten.to_a : [x]
2659
+ text = cells.select { |v| v.is_a?(String) }
2660
+ units = []
2661
+ unless text.empty?
2662
+ if format
2663
+ units << format_unit(format)
2664
+ else
2665
+ unreadable = []
2666
+ _, read = CA_OBJECT(text).__parse_time_text__(nil, unreadable, true)
2667
+ units << read if read
2668
+ unreadable.each do |i|
2669
+ u = text_unit(text[i])
2670
+ units << u if u
2671
+ end
2672
+ end
2673
+ end
2674
+ cells.each do |v|
2675
+ case v
2676
+ when Integer then units << :s
2677
+ when CATime::Element then units << v.unit
2678
+ when Time then units << CATimeLiteral.exact_instant(v).unit
2679
+ when Date
2680
+ units << (defined?(DateTime) && v.is_a?(DateTime) ? CATimeLiteral.exact_instant(v.to_time).unit : :D)
2681
+ end
2682
+ end
2683
+ # A month beside a day is read on the fixed grid both instants reach,
2684
+ # as two elements are compared (diff_unit); finer covers one group only.
2685
+ units.map { |u| CATime::Resolution.parse(u) }
2686
+ .reduce { |a, b| CATimeUnitAlgebra.diff_unit(a, b) } || CATime::Resolution.parse(:s)
2687
+ end
2688
+
2689
+ # The unit a String read with Date._parse shows, or nil when it does not
2690
+ # read (the reading raises or masks it later).
2691
+ def text_unit(str)
2692
+ h = Date._parse(str)
2693
+ return nil unless h && h[:year]
2694
+ return fraction_unit(h[:sec_fraction].denominator) if h[:sec_fraction]
2695
+ (h[:hour] || h[:min] || h[:sec]) ? :s : :D
2696
+ end
2697
+
2698
+ # The finest field a strptime format writes.
2699
+ def format_unit(format)
2700
+ if (m = format.match(/%(\d*)([LNQ])/))
2701
+ return :ms if m[2] == "L" || m[2] == "Q"
2702
+ digits = m[1].empty? ? 9 : m[1].to_i
2703
+ return fraction_unit(10 ** digits)
2704
+ end
2705
+ format.match?(/%[-_0^#]*[sSTHIklMRrXcp]/) ? :s : :D
2706
+ end
2707
+
2708
+ def fraction_unit(denominator)
2709
+ digits = Math.log10(denominator).ceil
2710
+ digits <= 3 ? :ms : digits <= 6 ? :us : :ns
2711
+ end
2712
+
2713
+ # +x+ with each Integer replaced by its decimal digits as a String, for a
2714
+ # format to read; a masked cell stays masked.
2715
+ def integers_as_text(x)
2716
+ case x
2717
+ when Integer then x.to_s
2718
+ when CArray
2719
+ return x if x.face? || !(x.integer? || x.data_type == CA_OBJECT)
2720
+ text = CArray.object(*x.shape)
2721
+ cells = x.flatten.to_a
2722
+ cells.each_with_index { |v, i| text[i] = v.is_a?(Integer) ? v.to_s : v }
2723
+ text = text.reshape(*x.shape) if x.ndim > 1
2724
+ text
2725
+ else x
2726
+ end
2727
+ end
2728
+
2142
2729
  # Single-literal build for {.time}: a 1-element CATime, honouring
2143
2730
  # the on_error policy (raise, or a masked cell).
2144
2731
  def to_time_array(literal, res, format, on_error)
@@ -2218,10 +2805,13 @@ class CArray
2218
2805
 
2219
2806
  # @overload time(x, unit: :s, format: nil, on_error: :raise)
2220
2807
  # Builds a {CATime} on the `unit` grid from time value(s). `x`
2221
- # is either a single literal (Time / ISO 8601 String / Unix-seconds
2222
- # Integer / DateTime) — giving a 1-element result — or a CArray of such
2808
+ # is either a single literal (Time / ISO 8601 String / Integer /
2809
+ # DateTime) — giving a 1-element result — or a CArray of such
2223
2810
  # literals — giving a same-shape result parsed per cell. Parsing is UTC
2224
- # and DateTime-independent.
2811
+ # and DateTime-independent. An Integer counts ticks of `unit` from
2812
+ # the epoch, so it is Unix seconds when `unit` is not given (also among
2813
+ # values that make the chosen unit finer):
2814
+ # `CArray.time(CA_INT64([0, 10]), unit: :m)` is 00:00 and 00:10.
2225
2815
  #
2226
2816
  # A value that cannot be parsed raises by default (`on_error: :raise`);
2227
2817
  # pass `on_error: :mask` to make it an UNDEF cell instead. A masked /
@@ -2229,8 +2819,16 @@ class CArray
2229
2819
  # becomes UNDEF, regardless of `on_error`.
2230
2820
  # @param x [Time, String, Integer, DateTime, CATime::Element, Array, CArray] a literal, a
2231
2821
  # Ruby Array of literals, or a CArray of literals.
2232
- # @param unit [Resolution, Symbol, String] target grid resolution.
2822
+ # @param unit [Resolution, Symbol, String, nil] target grid resolution;
2823
+ # by default the finest the values show, so nothing they hold is cut
2824
+ # off: text written to the day is `:D`, to the second `:s`, with a
2825
+ # fraction `:ms` / `:us` / `:ns` by its digits (as
2826
+ # `CAFrame#parse_to_time` reads text); with a format, the finest field
2827
+ # the format writes; an Integer `:s`; a Time the finest
2828
+ # its value needs; a Date `:D`; an element its own unit.
2233
2829
  # @param format [String, nil] optional strptime format for String input.
2830
+ # With a format, an Integer is read as its decimal digits
2831
+ # (`CArray.time(20240101, format: "%Y%m%d")`), not as a count of ticks.
2234
2832
  # @param on_error [:raise, :mask] parse-failure policy (default `:raise`).
2235
2833
  # @return [CATime] shape `[1]` for a literal, else `x`'s shape.
2236
2834
  # @raise [ArgumentError] on an unparseable value when `on_error: :raise`.
@@ -2239,16 +2837,25 @@ class CArray
2239
2837
  # CArray.time("2024-06-15", unit: :D) # 1-element
2240
2838
  # CArray.time(%w[2024-01-01 2024-02-01], unit: :D) # Ruby Array
2241
2839
  # CArray.time(CA_OBJECT(["2024-01-01", "oops"]), unit: :D, on_error: :mask)
2242
- def self.time(x, unit: :s, format: nil, on_error: :raise)
2243
- res = CATime::Resolution.parse(unit)
2840
+ def self.time(x, unit: nil, format: nil, on_error: :raise)
2244
2841
  unless %i[raise mask].include?(on_error)
2245
2842
  raise ArgumentError, "on_error: must be :raise or :mask (got #{on_error.inspect})"
2246
2843
  end
2247
2844
  x = CA_OBJECT(x) if x.is_a?(Array) # Ruby Array of literals -> object CArray
2845
+ # With a format, an Integer is the digits it is written with (20240101
2846
+ # read as "%Y%m%d"), not a count of ticks: a format describes text.
2847
+ x = CATimeLiteral.integers_as_text(x) if format
2848
+ res = unit ? CATime::Resolution.parse(unit) : CATimeLiteral.unit_shown(x, format)
2849
+ # Without a format, an Integer counts ticks of the unit from the epoch:
2850
+ # seconds by default, minutes with unit: :m.
2851
+ if format.nil?
2852
+ return CA_INT64([x]).time(unit: res) if x.is_a?(Integer)
2853
+ return (x.data_type == CA_INT64 ? x.copy : x).time(unit: res) if x.is_a?(CArray) && !x.face? && x.integer?
2854
+ end
2248
2855
  unless x.is_a?(CArray)
2249
2856
  return CATimeLiteral.to_time_array(x, res, format, on_error)
2250
2857
  end
2251
- raw = CATimeLiteral.ticks_from_cells(x, res, format, on_error)
2858
+ raw = CATimeLiteral.ticks_from_cells(x, res, format, on_error, seconds: unit.nil?)
2252
2859
  return raw.time(unit: res) if raw
2253
2860
  raw = CArray.int64(*x.shape)
2254
2861
  x.each_index do |*idx|
@@ -2258,7 +2865,7 @@ class CArray
2258
2865
  next
2259
2866
  end
2260
2867
  begin
2261
- raw[*idx] = CATimeLiteral.tick_index(s, res, format)
2868
+ raw[*idx] = s.is_a?(Integer) && format.nil? && unit ? s : CATimeLiteral.tick_index(s, res, format)
2262
2869
  rescue ArgumentError, TypeError
2263
2870
  raise if on_error == :raise
2264
2871
  raw[*idx] = UNDEF # opt-in parse-mask
@@ -2275,8 +2882,10 @@ class CArray
2275
2882
  # int64 array is built; the origin is not stored).
2276
2883
  # @param unit [Resolution, Symbol, String] grid resolution.
2277
2884
  # @param origin [Time, String, Integer, DateTime, CATime::Element, nil] base instant that
2278
- # `self`'s indices are counted from (default: the Unix epoch).
2885
+ # `self`'s indices are counted from (default: the Unix epoch); an
2886
+ # Integer is Unix seconds. It has to be a point of the `unit` grid.
2279
2887
  # @return [CATime]
2888
+ # @raise [ArgumentError] when `origin` falls between two ticks of `unit`.
2280
2889
  # @note An `int64` receiver is wrapped zero-copy. A narrower integer type
2281
2890
  # is widened to `int64` first (a copy); a `Float` / non-integer type
2282
2891
  # raises (cast it explicitly if the truncation is intended).
@@ -2286,8 +2895,11 @@ class CArray
2286
2895
  if origin.nil?
2287
2896
  CATime.wrap(src, unit: res)
2288
2897
  else
2289
- o = CATimeLiteral.tick_index(origin, res)
2290
- (src + o).time(unit: res)
2898
+ # The origin is a point of the grid: one between two ticks has no
2899
+ # index, so it raises rather than moving to the tick before it.
2900
+ origin = Time.at(origin, in: "UTC") if origin.is_a?(Integer)
2901
+ o = CATimeGrid.resolve_origin_ticks(origin, res, res)
2902
+ CATimeUnitAlgebra.checked_add(src, o).time(unit: res)
2291
2903
  end
2292
2904
  end
2293
2905
 
@@ -2313,6 +2925,13 @@ class CArray
2313
2925
  "array cannot be wrapped (cast it explicitly, e.g. " \
2314
2926
  "arr.int64.time(...), if a lossy conversion is intended)"
2315
2927
  end
2928
+ if data_type == CA_UINT64
2929
+ hi = max
2930
+ if hi != UNDEF && hi > 2**63 - 1
2931
+ raise RangeError,
2932
+ "CATime / CATimedelta storage is int64; #{hi} does not fit"
2933
+ end
2934
+ end
2316
2935
  int64
2317
2936
  end
2318
2937
  private :as_int64_time_storage
@@ -2646,6 +3265,7 @@ class CATime
2646
3265
  m: "minutes", s: "seconds", ms: "milliseconds",
2647
3266
  us: "microseconds", ns: "nanoseconds", ps: "picoseconds",
2648
3267
  fs: "femtoseconds", as: "attoseconds" }.freeze
3268
+ Ractor.make_shareable(WORD)
2649
3269
 
2650
3270
  # Storage resolutions to fall back on when the unit's own tick cannot
2651
3271
  # hold the origin exactly (a day grid anchored at 12:00 needs seconds).
@@ -2682,7 +3302,8 @@ class CATime
2682
3302
  @unit = Resolution.parse(unit)
2683
3303
  @origin = origin
2684
3304
  @storage = resolve_storage
2685
- @origin = CArray.time(origin, unit: @storage)[0] if origin
3305
+ # An Integer origin is an instant in Unix seconds, whatever the unit.
3306
+ @origin = CATimeLiteral.to_time_array(origin, @storage, nil, :raise)[0] if origin
2686
3307
  freeze
2687
3308
  end
2688
3309
 
@@ -2870,8 +3491,18 @@ class CATime
2870
3491
  fl = o + q * step_ticks
2871
3492
  return bucket_head_as_time(fl + step_ticks * n.ne(fl), mul, su)
2872
3493
  when :round
2873
- r = d - q * step_ticks # 0 <= r < step_ticks
2874
- q = q + r.ge((step_ticks + 1) / 2) # ties -> future; no 2*d / 2*r
3494
+ if mul == 1 and [:M, :Y].include?(su.base)
3495
+ # A month or year tick is the start of its month or year, so the
3496
+ # nearest head is by the instant, measured in days.
3497
+ fl = o + q * step_ticks
3498
+ here = days_since_epoch
3499
+ lo = fl.time(unit: su).send(:days_since_epoch)
3500
+ hi = (fl + step_ticks).time(unit: su).send(:days_since_epoch)
3501
+ q = q + (hi - here).le(here - lo) # ties -> future
3502
+ else
3503
+ r = d - q * step_ticks # 0 <= r < step_ticks
3504
+ q = q + r.ge((step_ticks + 1) / 2) # ties -> future; no 2*d / 2*r
3505
+ end
2875
3506
  end
2876
3507
  bucket_head_as_time(o + q * step_ticks, mul, su)
2877
3508
  end
@@ -2884,6 +3515,34 @@ class CATime
2884
3515
  snap(grid, unit: unit, origin: origin, direction: :floor)
2885
3516
  end
2886
3517
 
3518
+ # @overload end_of(grid = nil, unit:, origin: nil)
3519
+ # Each element at the last tick of its bucket, on the array's own grid:
3520
+ # the bucket {#floor} gives the head of, one tick before the next head.
3521
+ # A day array's month ends on the month's last day, a second array's at
3522
+ # 23:59:59 of it:
3523
+ #
3524
+ # CArray.time(%w[2024-02-10 2024-03-31], unit: :D).end_of(unit: :M)
3525
+ # # => 2024-02-29, 2024-03-31
3526
+ #
3527
+ # The bucket has to be a whole number of the array's ticks, since its
3528
+ # end is counted on that grid.
3529
+ # @return [CATime]
3530
+ # @raise [ArgumentError] when the bucket is finer than the grid or not a
3531
+ # whole number of its ticks.
3532
+ def end_of(grid = nil, unit: nil, origin: nil)
3533
+ step, origin = Grid.resolve(grid, unit, origin)
3534
+ step = CATime::Resolution.parse(step)
3535
+ per = CATimeUnitAlgebra.ratio(step, self.unit) # ticks of the grid per bucket
3536
+ if per && !(per.denominator == 1 && per >= 1)
3537
+ raise ArgumentError,
3538
+ "end_of: a bucket of #{step} is not a whole number of the #{self.unit} " \
3539
+ "grid's ticks, so its last tick is not on the grid"
3540
+ end
3541
+ head = floor(unit: step, origin: origin)
3542
+ head + CATimedelta::Element.new(step.count, step.base) -
3543
+ CATimedelta::Element.new(self.unit.count, self.unit.base)
3544
+ end
3545
+
2887
3546
  # @overload ceil(grid = nil, unit:, origin: nil)
2888
3547
  # {#snap} with `direction: :ceil` -- each element at the bucket head at
2889
3548
  # or after it (an element already on a boundary maps to itself).
@@ -2895,8 +3554,10 @@ class CATime
2895
3554
  # @overload round(grid = nil, unit:, origin: nil)
2896
3555
  # {#snap} with `direction: :round` -- each element at its nearest bucket
2897
3556
  # head, ties toward the future. Exact for odd `step_ticks` (no half-tick
2898
- # loss). For a calendar bucket the nearest head is by absolute tick
2899
- # distance (month lengths vary), ties toward the future.
3557
+ # loss). For a calendar bucket the nearest head is by the instant (month
3558
+ # lengths vary), ties toward the future. A month or year storage tick
3559
+ # stands for the start of its month or year, so a `:M` array rounds as
3560
+ # the same dates on `:D` do: 1889-07 to the year is 1889.
2900
3561
  # @return [CATime]
2901
3562
  def round(grid = nil, unit: nil, origin: nil)
2902
3563
  snap(grid, unit: unit, origin: origin, direction: :round)
@@ -2982,7 +3643,7 @@ class CATime
2982
3643
 
2983
3644
  # Storage ticks lifted into the numerator grid (see {#resolve_timestep_grid}).
2984
3645
  def storage_ticks_in_numerator_grid(mul)
2985
- mul == 1 ? parent : CATimeUnitAlgebra.widen(parent, mul)
3646
+ mul == 1 ? face_storage : CATimeUnitAlgebra.widen(face_storage, mul)
2986
3647
  end
2987
3648
 
2988
3649
  # A bucket head computed in numerator ticks, back as a {CATime} in the
@@ -3016,15 +3677,15 @@ class CATime
3016
3677
  # an empty array, or one whose every cell is masked.
3017
3678
  def raise_if_int64_overflow(su, mul, o, step_ticks = nil, kind = nil)
3018
3679
  return unless mul > 1 or FINE_UNITS.include?(su.base)
3019
- min = parent.min
3680
+ min = face_storage.min
3020
3681
  return if min == UNDEF
3021
3682
  lo = Integer(min) * mul
3022
- hi = Integer(parent.max) * mul
3683
+ hi = Integer(face_storage.max) * mul
3023
3684
  chk = ->(v, w) { CATimeGrid.check_int64_range(v, w) }
3024
3685
  chk.(lo, "ticks in bucket resolution")
3025
3686
  chk.(hi, "ticks in bucket resolution")
3026
- chk.(lo - o, "parent - origin")
3027
- chk.(hi - o, "parent - origin")
3687
+ chk.(lo - o, "face_storage - origin")
3688
+ chk.(hi - o, "face_storage - origin")
3028
3689
  if kind
3029
3690
  chk.(bucket_head_ticks(lo, o, step_ticks, kind), "bucket head")
3030
3691
  chk.(bucket_head_ticks(hi, o, step_ticks, kind), "bucket head")
@@ -3052,22 +3713,22 @@ class CATime
3052
3713
  case res.base
3053
3714
  when :Y
3054
3715
  ones = CArray.int64(*shape) { 1 }
3055
- CATimeCivil.days_from_civil(parent * res.count + 1970, ones, ones)
3716
+ CATimeCivil.days_from_civil(face_storage * res.count + 1970, ones, ones)
3056
3717
  when :M
3057
- mo = parent * res.count + 1970 * 12
3718
+ mo = face_storage * res.count + 1970 * 12
3058
3719
  y = mo / 12
3059
3720
  m = mo - y * 12 + 1
3060
3721
  CATimeCivil.days_from_civil(y, m, CArray.int64(*shape) { 1 })
3061
- when :W then parent * res.count * 7
3062
- when :D then parent * res.count
3722
+ when :W then face_storage * res.count * 7
3723
+ when :D then face_storage * res.count
3063
3724
  else # fixed sub-day storage
3064
3725
  day = CATime::Resolution.new(1, :D)
3065
3726
  r = CATimeUnitAlgebra.ratio(day, res) # storage ticks per day
3066
3727
  if r.denominator == 1
3067
- parent / r.numerator
3728
+ face_storage / r.numerator
3068
3729
  else # non-day-aligned tick
3069
3730
  tr = res.tick_ratio # seconds / tick
3070
- parent * tr.numerator / (86400 * tr.denominator)
3731
+ face_storage * tr.numerator / (86400 * tr.denominator)
3071
3732
  end
3072
3733
  end
3073
3734
  end
@@ -3080,9 +3741,9 @@ class CATime
3080
3741
  # calendar-storage element has no intra-day field, so it collapses to 0.
3081
3742
  def clock_field(fu)
3082
3743
  r = CATimeUnitAlgebra.ratio(unit, CATime::Resolution.new(1, fu)) # fu ticks / storage tick
3083
- return parent * 0 if r.nil? # calendar storage: no clock field
3084
- fi = r.denominator == 1 ? parent * r.numerator # epoch-relative fu index
3085
- : parent * r.numerator / r.denominator
3744
+ return face_storage * 0 if r.nil? # calendar storage: no clock field
3745
+ fi = r.denominator == 1 ? face_storage * r.numerator # epoch-relative fu index
3746
+ : face_storage * r.numerator / r.denominator
3086
3747
  cycle = fu == :h ? 24 : 60 # hour 0..23; min/sec 0..59
3087
3748
  fi % cycle
3088
3749
  end
@@ -3103,12 +3764,12 @@ class CATime
3103
3764
  return k if kind == :index
3104
3765
  head = CATimeGrid.calendar_bucket_head_ticks(k, st, su, origin)
3105
3766
  return head.time(unit: su) if kind == :floor
3106
- return parent.eq(head) if kind == :on
3767
+ return face_storage.eq(head) if kind == :on
3107
3768
  head_next = CATimeGrid.calendar_bucket_head_ticks(k + 1, st, su, origin)
3108
3769
  sel = # boolean 0/1: pick head_next?
3109
3770
  case kind
3110
- when :ceil then parent.ne(head) # on-boundary keeps head
3111
- when :round then (head_next - parent).le(parent - head) # nearest, ties -> future
3771
+ when :ceil then face_storage.ne(head) # on-boundary keeps head
3772
+ when :round then (head_next - face_storage).le(face_storage - head) # nearest, ties -> future
3112
3773
  end
3113
3774
  (head + (head_next - head) * sel).time(unit: su)
3114
3775
  end