carray 3.0.0 → 3.0.1

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 (180) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +2 -1
  3. data/CHANGELOG.md +325 -3
  4. data/{NEWS.md → CHANGELOG.v1.md} +1 -1
  5. data/README.md +32 -32
  6. data/carray.gemspec +12 -8
  7. data/ext/ca_axis_group.c +55 -19
  8. data/ext/ca_binop_dispatch.c +5 -6
  9. data/ext/ca_binop_dispatch.h +0 -7
  10. data/ext/ca_categorical_iterator.c +13 -13
  11. data/ext/ca_for_each_element.h +6 -8
  12. data/ext/ca_group_iter.c +1 -1
  13. data/ext/ca_kernel_iterator.c +69 -23
  14. data/ext/ca_kernel_iterator.h +3 -10
  15. data/ext/ca_obj_bincmp.c +57 -27
  16. data/ext/ca_obj_binop.c +86 -30
  17. data/ext/ca_obj_const_string.c +3 -1
  18. data/ext/ca_obj_face.c +80 -0
  19. data/ext/ca_obj_face.h +33 -1
  20. data/ext/ca_obj_fake.c +11 -0
  21. data/ext/ca_obj_fixlen_string.c +3 -1
  22. data/ext/ca_obj_grid.c +11 -3
  23. data/ext/ca_obj_meld.c +13 -8
  24. data/ext/ca_obj_moncmp.c +33 -14
  25. data/ext/ca_obj_monop.c +19 -7
  26. data/ext/ca_obj_object.c +99 -4
  27. data/ext/ca_obj_record.c +3 -1
  28. data/ext/ca_obj_refer.c +30 -6
  29. data/ext/ca_obj_roll.c +13 -5
  30. data/ext/ca_obj_select_axis.c +16 -6
  31. data/ext/ca_obj_shift.c +3 -3
  32. data/ext/ca_obj_stride.c +106 -23
  33. data/ext/ca_obj_string.c +3 -1
  34. data/ext/ca_obj_tile.c +13 -5
  35. data/ext/ca_obj_time.c +3 -1
  36. data/ext/ca_obj_timedelta.c +3 -1
  37. data/ext/ca_obj_transpose.c +1 -1
  38. data/ext/ca_obj_triop.c +62 -23
  39. data/ext/ca_obj_window.c +86 -17
  40. data/ext/ca_op_cmplx64.h +123 -0
  41. data/ext/ca_op_ipower.c +0 -3
  42. data/ext/ca_sort_kernels.h +5 -5
  43. data/ext/ca_sweep_engine.c +78 -35
  44. data/ext/ca_sweep_engine.h +13 -4
  45. data/ext/ca_transform_common.c +7 -0
  46. data/ext/carray.h +127 -130
  47. data/ext/carray_access.c +56 -34
  48. data/ext/carray_bincount.c +8 -8
  49. data/ext/carray_broadcast.c +99 -6
  50. data/ext/carray_build_flags.h +3 -0
  51. data/ext/carray_call_cfunc.c +1491 -0
  52. data/ext/carray_call_cfunc.h +153 -0
  53. data/ext/carray_cast.c +69 -31
  54. data/ext/carray_conversion.c +22 -22
  55. data/ext/carray_copy.c +12 -2
  56. data/ext/carray_core.c +57 -10
  57. data/ext/carray_count.c +1 -1
  58. data/ext/carray_element.c +7 -7
  59. data/ext/carray_factorize.c +28 -28
  60. data/ext/carray_hold.c +1 -1
  61. data/ext/carray_index_classifier.c +7 -21
  62. data/ext/carray_internal.h +19 -3
  63. data/ext/carray_kernels_bincmp.c +1 -0
  64. data/ext/carray_kernels_binop.c +320 -298
  65. data/ext/carray_kernels_init.c +1095 -0
  66. data/ext/carray_kernels_map.c +1 -0
  67. data/ext/carray_kernels_moncmp.c +1 -0
  68. data/ext/carray_kernels_monop.c +412 -411
  69. data/ext/carray_kernels_reduce_aggregate.c +88 -87
  70. data/ext/carray_kernels_reduce_boolean.c +1 -0
  71. data/ext/carray_kernels_reduce_cumulative.c +54 -53
  72. data/ext/carray_kernels_reduce_extreme.c +51 -50
  73. data/ext/carray_kernels_reduce_variance.c +1 -0
  74. data/ext/carray_kernels_scan.c +1 -0
  75. data/ext/carray_kernels_search.c +93 -92
  76. data/ext/carray_kernels_sort.c +1 -0
  77. data/ext/carray_kernels_triop.c +1 -0
  78. data/ext/carray_lazy.c +175 -5
  79. data/ext/carray_mask.c +6 -5
  80. data/ext/carray_median_percentile.c +1 -1
  81. data/ext/carray_memory_view.c +46 -21
  82. data/ext/carray_operator.c +46 -49
  83. data/ext/carray_partition.c +4 -4
  84. data/ext/carray_random.c +7 -7
  85. data/ext/carray_scatter.c +1 -1
  86. data/ext/carray_sort.c +3 -3
  87. data/ext/carray_sort_kernel.c +10 -10
  88. data/ext/carray_test.c +1 -1
  89. data/ext/extconf.rb +18 -0
  90. data/ext/mk_call_cfunc.rb +243 -52
  91. data/ext/mkkernel.rb +354 -100
  92. data/ext/ruby_carray.c +18 -16
  93. data/ext/version.h +4 -4
  94. data/lib/carray/autoload_carray.rb +19 -0
  95. data/lib/carray/autoload_method_extension.rb +2 -1
  96. data/lib/carray/axis_group.rb +7 -7
  97. data/lib/carray/basics.rb +59 -59
  98. data/lib/carray/bincount_nd.rb +12 -12
  99. data/lib/carray/block_iterator.rb +24 -14
  100. data/lib/carray/categorical.rb +4 -4
  101. data/lib/carray/categorical_iterator.rb +98 -76
  102. data/lib/carray/conditional.rb +14 -14
  103. data/lib/carray/construct.rb +21 -0
  104. data/lib/carray/core_extensions.rb +62 -5
  105. data/lib/carray/data_type_extension.rb +18 -1
  106. data/lib/carray/frame/concat.rb +3 -3
  107. data/lib/carray/frame/convert.rb +1 -1
  108. data/lib/carray/frame/frame.rb +4 -11
  109. data/lib/carray/frame/io.rb +159 -2
  110. data/lib/carray/frame/sort.rb +1 -1
  111. data/lib/carray/frame/verbs.rb +18 -1
  112. data/lib/carray/fuse_source.rb +123 -0
  113. data/lib/carray/fusion.rb +218 -0
  114. data/lib/carray/histogram.rb +16 -16
  115. data/lib/carray/inspect.rb +1 -7
  116. data/lib/carray/iterator.rb +4 -3
  117. data/lib/carray/lazy.rb +125 -73
  118. data/lib/carray/meld_reduce.rb +2 -2
  119. data/lib/carray/methods/align_addr.rb +1 -1
  120. data/lib/carray/methods/composition.rb +1 -1
  121. data/lib/carray/methods/is_in.rb +12 -12
  122. data/lib/carray/methods/locate_addr.rb +6 -1
  123. data/lib/carray/methods/mask_duplicates.rb +1 -1
  124. data/lib/carray/methods/meshgrid.rb +4 -5
  125. data/lib/carray/methods/mode.rb +2 -2
  126. data/lib/carray/methods/nunique.rb +1 -1
  127. data/lib/carray/methods/snap.rb +7 -2
  128. data/lib/carray/methods/unique.rb +3 -3
  129. data/lib/carray/methods/value_counts.rb +2 -2
  130. data/lib/carray/runtime.rb +0 -19
  131. data/lib/carray/slab_iterator.rb +20 -7
  132. data/lib/carray/string_operation_extension.rb +5 -5
  133. data/lib/carray/time.rb +890 -491
  134. data/lib/carray/window_iterator.rb +285 -13
  135. data/lib/carray.rb +5 -5
  136. data/yard-stubs/ca_obj_array.rb +385 -0
  137. data/yard-stubs/ca_obj_bitarray.rb +38 -0
  138. data/yard-stubs/ca_obj_bitfield.rb +43 -0
  139. data/yard-stubs/ca_obj_block.rb +73 -0
  140. data/yard-stubs/ca_obj_byte_swap.rb +56 -0
  141. data/yard-stubs/ca_obj_fake.rb +31 -0
  142. data/yard-stubs/ca_obj_farray.rb +32 -0
  143. data/yard-stubs/ca_obj_field.rb +45 -0
  144. data/yard-stubs/ca_obj_grid.rb +35 -0
  145. data/yard-stubs/ca_obj_refer.rb +72 -0
  146. data/yard-stubs/ca_obj_roll.rb +45 -0
  147. data/yard-stubs/ca_obj_shift.rb +43 -0
  148. data/yard-stubs/ca_obj_stride.rb +181 -0
  149. data/yard-stubs/ca_obj_tile.rb +29 -0
  150. data/yard-stubs/ca_obj_transpose.rb +40 -0
  151. data/yard-stubs/ca_obj_window.rb +49 -0
  152. data/yard-stubs/carray_access.rb +131 -0
  153. data/yard-stubs/carray_attribute.rb +246 -0
  154. data/yard-stubs/carray_broadcast.rb +37 -0
  155. data/yard-stubs/carray_cast.rb +489 -0
  156. data/yard-stubs/carray_class.rb +65 -0
  157. data/yard-stubs/carray_conversion.rb +76 -0
  158. data/yard-stubs/carray_copy.rb +79 -0
  159. data/yard-stubs/carray_core.rb +114 -0
  160. data/yard-stubs/carray_count.rb +79 -0
  161. data/yard-stubs/carray_element.rb +108 -0
  162. data/yard-stubs/carray_generate.rb +66 -0
  163. data/yard-stubs/carray_lazy.rb +23 -0
  164. data/yard-stubs/carray_loop.rb +140 -0
  165. data/yard-stubs/carray_mask.rb +259 -0
  166. data/yard-stubs/carray_math.rb +132 -0
  167. data/yard-stubs/carray_mathfunc.rb +45 -0
  168. data/yard-stubs/carray_median_percentile.rb +89 -0
  169. data/yard-stubs/carray_memory_view.rb +163 -0
  170. data/yard-stubs/carray_order.rb +312 -0
  171. data/yard-stubs/carray_random.rb +89 -0
  172. data/yard-stubs/carray_scatter.rb +106 -0
  173. data/yard-stubs/carray_slab.rb +57 -0
  174. data/yard-stubs/carray_sort.rb +163 -0
  175. data/yard-stubs/carray_test.rb +85 -0
  176. data/yard-stubs/carray_undef.rb +64 -0
  177. data/yard-stubs/carray_utils.rb +97 -0
  178. data/yard-stubs/ruby_carray.rb +193 -0
  179. metadata +59 -10
  180. data/ext/ca_obj_unbound_repeat.c +0 -496
data/lib/carray/time.rb CHANGED
@@ -22,6 +22,45 @@
22
22
  # Within a group any pair is an exact integer ratio (coarse = fine * N), so a
23
23
  # coarse->fine cast is lossless (multiply), and a fine->coarse cast is lossless
24
24
  # only when every value is divisible by the divisor.
25
+ # Civil-date arithmetic on int64 CArrays: the calendar kernel every time
26
+ # grid is built on (Howard Hinnant's days<->civil algorithms, vectorized).
27
+ # Internal to the time surface: nothing here is part of the public API.
28
+ module CATimeCivil
29
+ module_function
30
+
31
+ # Vectorized Howard Hinnant civil-date algebra (proleptic Gregorian,
32
+ # negative days supported). days since the Unix epoch -> [year, month,
33
+ # day] int64 CArrays. The algorithm needs the era to floor toward the
34
+ # past, which CArray `/` does; Hinnant's published form pre-biases the
35
+ # negative operand instead because C truncates.
36
+ def civil_from_days(z)
37
+ z = z + 719468
38
+ era = z / 146097
39
+ doe = z - era * 146097
40
+ yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
41
+ y = yoe + era * 400
42
+ doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
43
+ mp = (5 * doy + 2) / 153
44
+ d = doy - (153 * mp + 2) / 5 + 1 # day of month, 1..31
45
+ m = mp + (3 - 12 * mp.ge(10)) # mp<10 ? +3 : -9
46
+ [y + m.le(2), m, d]
47
+ end
48
+
49
+ # [year, month, day] int64 CArrays -> days since the Unix epoch.
50
+ def days_from_civil(y, m, d)
51
+ y = y - m.le(2)
52
+ era = y / 400
53
+ yoe = y - era * 400
54
+ doy = (153 * (m + (9 - 12 * m.gt(2))) + 2) / 5 + (d - 1) # m>2 ? -3 : +9
55
+ doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
56
+ era * 146097 + doe - 719468
57
+ end
58
+ end
59
+
60
+ # Unit algebra for the time surface: how two units relate (same group, which
61
+ # is finer, what a difference is measured in) and how a tick count moves
62
+ # between them -- by scaling for a duration, on the calendar for an instant.
63
+ # Internal to the time surface: nothing here is part of the public API.
25
64
  module CATimeUnitAlgebra
26
65
  # seconds per base unit (Rational)
27
66
  FIXED = {
@@ -78,7 +117,10 @@ module CATimeUnitAlgebra
78
117
  # grid; cross-group -> the fixed-group resolution (a :M/:Y difference only
79
118
  # arises from two calendar operands), coarsened to `(1,:D)` when the fixed
80
119
  # side is a week (a week is not calendar-alignable, but both sides convert
81
- # into days exactly).
120
+ # into days exactly). :W is the only such coarsening: a fixed resolution
121
+ # whose tick does not tile a day (e.g. "7 hours") is returned as-is, and
122
+ # the calendar side then fails to convert into it, so the subtraction
123
+ # raises downstream rather than being silently coarsened.
82
124
  def diff_unit(u1, u2)
83
125
  a = res(u1); b = res(u2)
84
126
  return common(a, b) if same_group?(a, b)
@@ -124,19 +166,21 @@ module CATimeUnitAlgebra
124
166
  # instant / duration. Checks the extremes (they bound every element), then
125
167
  # multiplies. Shared by every coarse->fine conversion (arithmetic,
126
168
  # comparison, search).
169
+ #
170
+ # min / max skip masked cells, so a masked cell does not decide the range:
171
+ # it carries no value to convert, and whatever bits sit under the mask are
172
+ # not a time. They answer UNDEF when there is nothing to bound (empty, or
173
+ # every cell masked), and then there is nothing to guard either.
127
174
  def widen(storage, factor)
128
175
  return storage if factor == 1
129
- if storage.elements > 0
130
- raw = storage.has_mask? ? storage.value : storage
131
- lo = raw.min
132
- unless lo == UNDEF
133
- lim = 2**63 - 1
134
- [lo, raw.max].each do |x|
135
- next if (Integer(x) * factor).abs <= lim
136
- raise RangeError,
137
- "time unit conversion overflows int64: the time range is " \
138
- "too wide to widen into this resolution (x#{factor})"
139
- end
176
+ lo = storage.min
177
+ unless lo == UNDEF
178
+ lim = 2**63 - 1
179
+ [lo, storage.max].each do |x|
180
+ next if (Integer(x) * factor).abs <= lim
181
+ raise RangeError,
182
+ "time unit conversion overflows int64: the time range is " \
183
+ "too wide to widen into this resolution (x#{factor})"
140
184
  end
141
185
  end
142
186
  storage * factor
@@ -157,25 +201,24 @@ module CATimeUnitAlgebra
157
201
  "(:Y/:M) and fixed-length units (:W/:D/:h/:s/...) have no fixed " \
158
202
  "ratio (a month / year is calendar-variable)"
159
203
  end
160
- if r.denominator == 1
161
- widen(storage, r.numerator) # coarse -> fine: lossless multiply
162
- else
163
- divisor = r.denominator # fine -> coarse: exact only
164
- unless (storage % divisor).eq(0).all
165
- raise ArgumentError,
166
- "cannot scale duration #{a} to #{b} without loss: " \
167
- "some values are not a whole multiple of #{b} " \
168
- "(finer resolution would be truncated)"
169
- end
170
- storage / divisor
204
+ scaled = widen(storage, r.numerator) # coarse -> fine: lossless multiply
205
+ return scaled if r.denominator == 1
206
+ unless (scaled % r.denominator).eq(0).all
207
+ raise ArgumentError,
208
+ "cannot scale duration #{a} to #{b} without loss: " \
209
+ "some values are not a whole multiple of #{b} " \
210
+ "(finer resolution would be truncated)"
171
211
  end
212
+ scaled / r.denominator
172
213
  end
173
214
 
174
215
  # SCALE conversion with truncation: like convert_scale! but a fine->coarse
175
216
  # conversion drops the sub-`to` remainder (truncating toward zero) instead of
176
- # raising. Used for dt +/- td, where the result keeps the time's unit
177
- # and a finer duration is truncated to it (a :D time + a 5 h duration is
178
- # + 0 days; + 30 h is + 1 day). Cross-group still raises (a calendar
217
+ # raising. A duration is a magnitude, so it shrinks toward zero rather than
218
+ # flooring toward the past the way an instant does. Used by
219
+ # CATimedelta#to_unit and by dt +/- td, where the result keeps the time's
220
+ # unit and a finer duration is truncated to it (a :D time + a 5 h duration
221
+ # is + 0 days; + 30 h is + 1 day). Cross-group still raises (a calendar
179
222
  # duration has no fixed ratio to a fixed unit).
180
223
  def convert_scale_trunc(storage, from, to)
181
224
  a = res(from); b = res(to)
@@ -183,10 +226,20 @@ module CATimeUnitAlgebra
183
226
  r = ratio(a, b)
184
227
  if r.nil?
185
228
  raise ArgumentError,
186
- "cannot combine a #{a} duration with a #{b} time across the " \
187
- "calendar/fixed boundary (a calendar duration has no fixed ratio)"
229
+ "cannot scale duration #{a} to #{b}: calendar units " \
230
+ "(:Y/:M) and fixed-length units (:W/:D/:h/:s/...) have no fixed " \
231
+ "ratio (a month / year is calendar-variable)"
188
232
  end
189
- r.denominator == 1 ? widen(storage, r.numerator) : storage / r.denominator
233
+ scaled = widen(storage, r.numerator)
234
+ r.denominator == 1 ? scaled : trunc_divide(scaled, r.denominator)
235
+ end
236
+
237
+ # Truncating integer division of an int64 CArray (toward zero). CArray
238
+ # `/` floors toward the past, which is what an instant wants; a duration
239
+ # is a magnitude and shrinks toward zero instead.
240
+ def trunc_divide(a, b)
241
+ q = a / b
242
+ q + (q.lt(0) & (a - q * b).ne(0))
190
243
  end
191
244
 
192
245
  # INSTANT conversion (absolute datetimes): unlike a duration, a time :M
@@ -203,23 +256,23 @@ module CATimeUnitAlgebra
203
256
  return storage if a == b
204
257
  return convert_scale!(storage, a, b) if ratio(a, b) # same group
205
258
  if CALENDAR.key?(a.base)
206
- _instant_cal_to_fixed(storage, a, b) # widen
259
+ convert_instant_calendar_to_fixed(storage, a, b) # widen
207
260
  else
208
- _instant_fixed_to_cal(storage, a, b) # coarsen (exact-or-raise)
261
+ convert_instant_fixed_to_calendar(storage, a, b) # coarsen (exact-or-raise)
209
262
  end
210
263
  end
211
264
 
212
265
  # calendar time (Resolution `from`) -> days since the epoch (int64
213
266
  # CArray). Folds the resolution count (value = count-Y/M buckets).
214
- def _cal_days(storage, from)
267
+ def calendar_days_since_epoch(storage, from)
215
268
  ones = CArray.int64(*storage.shape) { 1 }
216
269
  if from.base == :M
217
270
  abs = storage * from.count + 1970 * 12 # absolute month ordinal
218
- y = CATime.send(:_floordiv_i, abs, 12)
271
+ y = abs / 12
219
272
  m = abs - y * 12 + 1
220
- CATime.send(:_days_from_civil, y, m, ones)
273
+ CATimeCivil.days_from_civil(y, m, ones)
221
274
  else # :Y
222
- CATime.send(:_days_from_civil, storage * from.count + 1970, ones, ones)
275
+ CATimeCivil.days_from_civil(storage * from.count + 1970, ones, ones)
223
276
  end
224
277
  end
225
278
 
@@ -227,21 +280,21 @@ module CATimeUnitAlgebra
227
280
  # convert_instant!. Goes through the day count, so the target grid has
228
281
  # to tile a day exactly (:W is rejected -- month starts are not
229
282
  # week-aligned). Always exact once that holds.
230
- def _instant_cal_to_fixed(storage, from, to)
283
+ def convert_instant_calendar_to_fixed(storage, from, to)
231
284
  r = ratio(CATime::Resolution.new(1, :D), to) # ticks of `to` per day
232
285
  unless r.denominator == 1
233
286
  raise ArgumentError,
234
287
  "cannot convert calendar time #{from} to #{to} " \
235
288
  "(a day boundary is not aligned to the #{to} grid)"
236
289
  end
237
- widen(_cal_days(storage, from), r.numerator)
290
+ widen(calendar_days_since_epoch(storage, from), r.numerator)
238
291
  end
239
292
 
240
293
  # fixed-length grid -> calendar time: the coarsening half of
241
294
  # convert_instant!, exact-or-raise. Every instant must land on a day
242
295
  # boundary and then on the calendar boundary itself (the 1st, and
243
296
  # January too for :Y), since a mid-month instant has no :M value.
244
- def _instant_fixed_to_cal(storage, from, to)
297
+ def convert_instant_fixed_to_calendar(storage, from, to)
245
298
  rd = ratio(CATime::Resolution.new(1, :D), from) # `from` ticks per day
246
299
  days =
247
300
  if rd.denominator == 1
@@ -255,7 +308,7 @@ module CATimeUnitAlgebra
255
308
  else # coarser than a day (:W)
256
309
  storage * (from.tick_ratio / 86400r).to_i
257
310
  end
258
- y, m, d = CATime.send(:_civil_from_days, days)
311
+ y, m, d = CATimeCivil.civil_from_days(days)
259
312
  on_boundary = d.eq(1)
260
313
  on_boundary &= m.eq(1) if to.base == :Y
261
314
  unless on_boundary.all
@@ -270,10 +323,49 @@ module CATimeUnitAlgebra
270
323
  "cannot convert time #{from} to #{to} without loss " \
271
324
  "(instant is not on a #{to} boundary)"
272
325
  end
273
- ord = CATime.send(:_floordiv_i, ord, to.count)
326
+ ord = ord / to.count
274
327
  end
275
328
  ord
276
329
  end
330
+
331
+ # INSTANT conversion that floors instead of raising: the storage-resolution
332
+ # change behind CATime#to_unit. A coarser target keeps the bucket the
333
+ # instant falls in (floor toward the past, the direction construction and
334
+ # `floor` already use), so no element is rejected for sitting off the
335
+ # target grid -- a resolution change is a cast, not an assertion. The
336
+ # widening half is untouched and stays exact, and :Y/:M -> :W still raises
337
+ # (a month head is not week-aligned, so widening there would move the
338
+ # instant).
339
+ def convert_instant_floor(storage, from, to)
340
+ a = res(from); b = res(to)
341
+ return storage if a == b
342
+ r = ratio(a, b)
343
+ if r # same group
344
+ scaled = widen(storage, r.numerator)
345
+ r.denominator == 1 ? scaled
346
+ : scaled / r.denominator
347
+ elsif CALENDAR.key?(a.base)
348
+ convert_instant_calendar_to_fixed(storage, a, b) # widen, always exact
349
+ else
350
+ convert_instant_fixed_to_calendar_floor(storage, a, b)
351
+ end
352
+ end
353
+
354
+ # fixed-length grid -> calendar time, flooring: the instant's own year /
355
+ # month, whatever day and time it carries. The flooring counterpart of
356
+ # convert_instant_fixed_to_calendar.
357
+ def convert_instant_fixed_to_calendar_floor(storage, from, to)
358
+ rd = ratio(CATime::Resolution.new(1, :D), from) # `from` ticks per day
359
+ days =
360
+ if rd.denominator == 1
361
+ storage / rd.numerator
362
+ else # coarser than a day (:W)
363
+ storage * (from.tick_ratio / 86400r).to_i
364
+ end
365
+ y, m, = CATimeCivil.civil_from_days(days)
366
+ ord = to.base == :M ? (y * 12 + (m - 1) - 1970 * 12) : (y - 1970)
367
+ to.count > 1 ? ord / to.count : ord
368
+ end
277
369
  end
278
370
 
279
371
  class CATime
@@ -332,8 +424,8 @@ class CATime
332
424
  require 'time'
333
425
  require 'date'
334
426
  case unit.base
335
- when :Y then d = _epoch_date.next_year(value * unit.count); Time.utc(d.year, d.month, d.day)
336
- when :M then d = _epoch_date.next_month(value * unit.count); Time.utc(d.year, d.month, d.day)
427
+ when :Y then d = epoch_date.next_year(value * unit.count); Time.utc(d.year, d.month, d.day)
428
+ when :M then d = epoch_date.next_month(value * unit.count); Time.utc(d.year, d.month, d.day)
337
429
  else
338
430
  Time.at(Rational(value) * unit.tick_ratio, in: 'UTC') # exact seconds
339
431
  end
@@ -344,11 +436,11 @@ class CATime
344
436
  def to_date
345
437
  require 'date'
346
438
  case unit.base
347
- when :Y then _epoch_date.next_year(value * unit.count)
348
- when :M then _epoch_date.next_month(value * unit.count)
439
+ when :Y then epoch_date.next_year(value * unit.count)
440
+ when :M then epoch_date.next_month(value * unit.count)
349
441
  when :W then Date.jd(EPOCH_JD + value * unit.count * 7, Date::GREGORIAN)
350
442
  when :D then Date.jd(EPOCH_JD + value * unit.count, Date::GREGORIAN)
351
- else Date.jd(EPOCH_JD + _floor_days, Date::GREGORIAN) # sub-day: floor to day
443
+ else Date.jd(EPOCH_JD + floor_to_days_since_epoch, Date::GREGORIAN) # sub-day: floor to day
352
444
  end
353
445
  end
354
446
 
@@ -366,7 +458,7 @@ class CATime
366
458
  when :Y then format("%04d", to_date.year)
367
459
  when :M then to_date.strftime("%Y-%m")
368
460
  when :W, :D then to_date.strftime("%Y-%m-%d")
369
- else to_time.iso8601(_precision_digits) # :h .. :as (time shown)
461
+ else to_time.iso8601(fractional_second_digits) # :h .. :as (time shown)
370
462
  end
371
463
  end
372
464
 
@@ -386,7 +478,7 @@ class CATime
386
478
  case other
387
479
  when Element
388
480
  u = CATimeUnitAlgebra.diff_unit(unit, other.unit)
389
- _instant_in(u) <=> other._instant_in(u)
481
+ instant_in(u) <=> other.instant_in(u)
390
482
  when Time
391
483
  to_time <=> other.getutc
392
484
  when (defined?(DateTime) ? DateTime : nil)
@@ -409,16 +501,19 @@ class CATime
409
501
  end
410
502
 
411
503
  # @overload +(td)
412
- # Returns this instant advanced by a {CATimedelta::Element}. The unit
413
- # promotes to the finer of the two (numpy-style: :D + :h -> :h). Only a
414
- # duration in the SAME group (both calendar or both fixed) is accepted --
415
- # a cross-group step (a :s time + a :M duration) is calendar
416
- # arithmetic; use `to_date` + `Date#next_month` / `#next_year` for that.
504
+ # Returns this instant advanced by a {CATimedelta::Element}. The time
505
+ # is the anchor: the result keeps `self`'s unit and the duration is
506
+ # converted into it, truncated toward zero when finer (a :D time + a
507
+ # 5 h duration is + 0 days; + 30 h is + 1 day) -- the same rule as the
508
+ # array {CATime#+}. Only a duration in the SAME group (both calendar
509
+ # or both fixed) is accepted -- a cross-group step (a :s time + a :M
510
+ # duration) is calendar arithmetic; use `to_date` +
511
+ # `Date#next_month` / `#next_year` for that.
417
512
  # @param td [CATimedelta::Element]
418
513
  # @return [CATime::Element]
419
514
  # @raise [TypeError, ArgumentError] on a non-timedelta / cross-group operand.
420
515
  def +(td)
421
- _combine(td, 1)
516
+ shifted_by_duration(td, 1)
422
517
  end
423
518
 
424
519
  # @overload -(other)
@@ -432,9 +527,9 @@ class CATime
432
527
  case other
433
528
  when Element
434
529
  u = CATimeUnitAlgebra.diff_unit(unit, other.unit)
435
- CATimedelta::Element.new(_instant_in(u) - other.send(:_instant_in, u), u)
530
+ CATimedelta::Element.new(instant_in(u) - other.instant_in(u), u)
436
531
  when CATimedelta::Element
437
- _combine(other, -1)
532
+ shifted_by_duration(other, -1)
438
533
  else
439
534
  raise TypeError, "CATime::Element - #{other.class} is not allowed"
440
535
  end
@@ -444,7 +539,7 @@ class CATime
444
539
 
445
540
  # This instant's storage value expressed in `to` unit (exact; raises if the
446
541
  # instant does not land on `to`'s grid).
447
- def _instant_in(to)
542
+ def instant_in(to)
448
543
  return value if to == unit
449
544
  CATimeUnitAlgebra.convert_instant!(CA_INT64([value]), unit, to)[0]
450
545
  end
@@ -454,7 +549,7 @@ class CATime
454
549
  # s +/- td: the time is the anchor, so the result keeps self's unit and
455
550
  # the duration is converted into it (truncated toward zero when finer; a
456
551
  # cross-group calendar duration raises). Mirrors the array CATime +/-.
457
- def _combine(td, sign)
552
+ def shifted_by_duration(td, sign)
458
553
  unless td.is_a?(CATimedelta::Element)
459
554
  raise TypeError,
460
555
  "CATime::Element #{sign > 0 ? '+' : '-'} #{td.class} is not " \
@@ -467,18 +562,18 @@ class CATime
467
562
  # Base epoch as a Date. next_month / next_year clamp end-of-month
468
563
  # (Jan 31 -> Feb 28), but the base is day 1, so no clamping ever occurs --
469
564
  # do not move the base off the first of the month without revisiting.
470
- def _epoch_date
565
+ def epoch_date
471
566
  require 'date'
472
567
  Date.new(1970, 1, 1, Date::GREGORIAN) # proleptic Gregorian, matching to_time
473
568
  end
474
569
 
475
570
  # Days since the epoch for a fixed sub-day unit (floored toward the past).
476
- def _floor_days
571
+ def floor_to_days_since_epoch
477
572
  sec = Rational(value) * unit.tick_ratio # exact seconds
478
573
  (sec / 86400).floor
479
574
  end
480
575
 
481
- def _precision_digits
576
+ def fractional_second_digits
482
577
  case unit.base
483
578
  when :ms then 3
484
579
  when :us then 6
@@ -560,7 +655,7 @@ class CATimedelta
560
655
  return nil unless other.is_a?(Element)
561
656
  return nil unless CATimeUnitAlgebra.same_group?(unit, other.unit)
562
657
  u = CATimeUnitAlgebra.finer(unit, other.unit)
563
- _scale_in(u) <=> other._scale_in(u)
658
+ duration_in(u) <=> other.duration_in(u)
564
659
  rescue ArgumentError
565
660
  nil
566
661
  end
@@ -580,13 +675,13 @@ class CATimedelta
580
675
  # raises.
581
676
  # @param other [CATimedelta::Element]
582
677
  # @return [CATimedelta::Element]
583
- def +(other) = _combine(other, 1)
678
+ def +(other) = combined_with_duration(other, 1)
584
679
 
585
680
  # @overload -(other)
586
681
  # Returns the difference of two durations (finer unit; cross-group raises).
587
682
  # @param other [CATimedelta::Element]
588
683
  # @return [CATimedelta::Element]
589
- def -(other) = _combine(other, -1)
684
+ def -(other) = combined_with_duration(other, -1)
590
685
 
591
686
  # @overload *(n)
592
687
  # Returns this duration scaled by an Integer.
@@ -598,17 +693,20 @@ class CATimedelta
598
693
  end
599
694
 
600
695
  # @overload /(other)
601
- # By an Integer -> a scaled {CATimedelta::Element}; by another
696
+ # By an Integer -> a scaled {CATimedelta::Element}, the quotient
697
+ # truncated toward zero (a duration is a magnitude, so it shrinks
698
+ # toward zero -- the same direction {CATimedelta#/} and #to_unit
699
+ # take, not Ruby Integer division's floor); by another
602
700
  # {CATimedelta::Element} -> their dimensionless ratio as a `Rational`
603
701
  # (both brought to the finer unit; cross-group raises).
604
702
  # @param other [Integer, CATimedelta::Element]
605
703
  # @return [CATimedelta::Element, Rational]
606
704
  def /(other)
607
705
  case other
608
- when Integer then Element.new(value / other, unit)
706
+ when Integer then Element.new(Rational(value, other).truncate, unit)
609
707
  when Element
610
708
  u = CATimeUnitAlgebra.finer(unit, other.unit)
611
- Rational(_scale_in(u), other.send(:_scale_in, u))
709
+ Rational(duration_in(u), other.duration_in(u))
612
710
  else
613
711
  raise TypeError, "CATimedelta::Element / #{other.class} is not allowed"
614
712
  end
@@ -618,20 +716,20 @@ class CATimedelta
618
716
 
619
717
  # This duration's value expressed in `to` unit (exact scale; raises on a
620
718
  # cross-group or non-whole conversion).
621
- def _scale_in(to)
719
+ def duration_in(to)
622
720
  return value if to == unit
623
721
  CATimeUnitAlgebra.convert_scale!(CA_INT64([value]), unit, to)[0]
624
722
  end
625
723
 
626
724
  private
627
725
 
628
- def _combine(other, sign)
726
+ def combined_with_duration(other, sign)
629
727
  unless other.is_a?(Element)
630
728
  raise TypeError,
631
729
  "CATimedelta::Element #{sign > 0 ? '+' : '-'} #{other.class} is not allowed"
632
730
  end
633
731
  u = CATimeUnitAlgebra.finer(unit, other.unit)
634
- Element.new(_scale_in(u) + sign * other.send(:_scale_in, u), u)
732
+ Element.new(duration_in(u) + sign * other.duration_in(u), u)
635
733
  end
636
734
  end
637
735
  end
@@ -665,23 +763,33 @@ class CATime
665
763
  end
666
764
 
667
765
  # @overload to_unit(unit)
668
- # Returns the same instants re-expressed on a finer grid: a new
669
- # {CATime} whose storage is `self`'s ticks widened into `unit`.
670
- # Accepted only when `self`'s tick is a whole multiple of `unit`'s, so
671
- # every element lands exactly on the new grid and no instant moves
672
- # (`:D` -> `:h`, `"1 hour"` -> `"10 minutes"`, `:Y` -> `:M`). A coarser
673
- # or partially-overlapping target raises rather than rounding silently;
674
- # use {#floor} / {#ceil} / {#round} to move to a coarser grid explicitly.
766
+ # Returns the same instants re-expressed on the `unit` grid: a new
767
+ # {CATime} whose storage resolution is `unit`. Changing the resolution
768
+ # is a cast, not an assertion -- the same relation `CArray.time` has to
769
+ # its input:
770
+ #
771
+ # - **Finer target** (`:D` -> `:h`, `:Y` -> `:M`, `:M` -> `:s`): exact.
772
+ # Every instant lands on the new grid unchanged.
773
+ # - **Coarser target** (`:h` -> `:D`, `:D` -> `:M`): each instant floors
774
+ # to the head of the `unit` tick it falls in -- toward the past, so a
775
+ # pre-epoch instant floors the same way a post-epoch one does. Use
776
+ # {#ceil} / {#round} first to land on a different boundary.
777
+ #
778
+ # The calendar / fixed-length boundary is crossed by civil-date algebra,
779
+ # not by a ratio: a `:M` value is a real instant (the month's first
780
+ # midnight), so `:M` -> `:D` widens exactly and `:D` -> `:M` floors to
781
+ # the containing month. `:Y` / `:M` -> `:W` is the one refusal, since a
782
+ # month head is not week-aligned and widening would move the instant.
783
+ #
784
+ # A *duration* has no such conversion ({CATimedelta#to_unit} truncates
785
+ # toward zero and refuses to cross the boundary at all).
675
786
  # @param unit [Resolution, Symbol, String] target resolution.
676
787
  # @return [CATime]
677
- # @raise [ArgumentError] when `self`'s tick is not a whole multiple of
678
- # `unit`'s (including any calendar / fixed-length pair, where no fixed
679
- # ratio exists).
788
+ # @raise [ArgumentError] on `:Y` / `:M` -> `:W`.
680
789
  # @raise [RangeError] when the widened ticks overflow int64.
681
790
  def to_unit(unit)
682
791
  to = CATime::Resolution.parse(unit)
683
- CATimeUnitAlgebra.widen(parent,
684
- CATimeUnitAlgebra.multiple_factor(self.unit, to)).time(unit: to)
792
+ CATimeUnitAlgebra.convert_instant_floor(parent, self.unit, to).time(unit: to)
685
793
  end
686
794
 
687
795
  # @overload +(other)
@@ -754,17 +862,17 @@ class CATime
754
862
  # @return [Array(Element, Element), Array(CATime, CATime)]
755
863
  def minmax(*args, **opts)
756
864
  lo, hi = parent.minmax(*args, **opts)
757
- [_lift_extremum(lo), _lift_extremum(hi)]
865
+ [relift_extremum(lo), relift_extremum(hi)]
758
866
  end
759
867
 
760
- def _lift_extremum(r)
868
+ def relift_extremum(r)
761
869
  case r
762
870
  when Integer then Element.new(r, unit)
763
871
  when CArray then r.time(unit: unit)
764
872
  else r
765
873
  end
766
874
  end
767
- private :_lift_extremum
875
+ private :relift_extremum
768
876
 
769
877
  # The centroid / spread / order reductions report on the array's own grid:
770
878
  # they run on the storage ticks and round the result back to the nearest
@@ -778,7 +886,7 @@ class CATime
778
886
  # nearest tick.
779
887
  # @return [Element, CATime]
780
888
  def mean(*args, **opts)
781
- _reduce_dt(:mean, :time, args, opts)
889
+ reduce_on_own_grid(:mean, :time, args, opts)
782
890
  end
783
891
 
784
892
  # @overload median(axis: nil, **opts)
@@ -787,7 +895,7 @@ class CATime
787
895
  # cases interpolate and round to the nearest tick.
788
896
  # @return [Element, CATime]
789
897
  def median(*args, **opts)
790
- _reduce_dt(:median, :time, args, opts)
898
+ reduce_on_own_grid(:median, :time, args, opts)
791
899
  end
792
900
 
793
901
  # @overload percentile(*p, axis: nil, **opts)
@@ -797,7 +905,7 @@ class CATime
797
905
  # Array of those.
798
906
  # @return [Element, CATime, Array<Element>, Array<CATime>]
799
907
  def percentile(*args, **opts)
800
- _reduce_dt(:percentile, :time, args, opts)
908
+ reduce_on_own_grid(:percentile, :time, args, opts)
801
909
  end
802
910
 
803
911
  # @overload quantile(axis: nil, **opts)
@@ -805,7 +913,7 @@ class CATime
805
913
  # `self`'s unit (shorthand for `percentile(0, 25, 50, 75, 100)`).
806
914
  # @return [Array<Element>, Array<CATime>]
807
915
  def quantile(*args, **opts)
808
- _reduce_dt(:quantile, :time, args, opts)
916
+ reduce_on_own_grid(:quantile, :time, args, opts)
809
917
  end
810
918
 
811
919
  # @overload stddev(axis: nil, **opts)
@@ -815,7 +923,7 @@ class CATime
815
923
  # `t.to_unit(:h).stddev` when the precision matters (§8).
816
924
  # @return [CATimedelta::Element, CATimedelta]
817
925
  def stddev(*args, **opts)
818
- _reduce_dt(:stddev, :timedelta, args, opts)
926
+ reduce_on_own_grid(:stddev, :timedelta, args, opts)
819
927
  end
820
928
 
821
929
  # @overload stddevp(axis: nil, **opts)
@@ -823,7 +931,7 @@ class CATime
823
931
  # as {#stddev}.
824
932
  # @return [CATimedelta::Element, CATimedelta]
825
933
  def stddevp(*args, **opts)
826
- _reduce_dt(:stddevp, :timedelta, args, opts)
934
+ reduce_on_own_grid(:stddevp, :timedelta, args, opts)
827
935
  end
828
936
 
829
937
  # @overload sum(*)
@@ -989,7 +1097,7 @@ class CATime
989
1097
  f = unit.tick_ratio # exact seconds / tick (Rational)
990
1098
  parent.convert(:object) {|v| Time.at(v * f, in: 'UTC')}
991
1099
  else # calendar: exact granule midnight
992
- (_field_days * 86400).convert(:object) {|v| Time.at(v, in: 'UTC')}
1100
+ (days_since_epoch * 86400).convert(:object) {|v| Time.at(v, in: 'UTC')}
993
1101
  end
994
1102
  end
995
1103
 
@@ -1001,7 +1109,7 @@ class CATime
1001
1109
  def to_date
1002
1110
  require 'date'
1003
1111
  # 2440588 = JD of 1970-01-01; proleptic Gregorian to match to_time and the field accessors.
1004
- _field_days.convert(:object) {|d| Date.jd(2440588 + d, Date::GREGORIAN)}
1112
+ days_since_epoch.convert(:object) {|d| Date.jd(2440588 + d, Date::GREGORIAN)}
1005
1113
  end
1006
1114
 
1007
1115
  # @overload to_datetime
@@ -1024,8 +1132,8 @@ class CATime
1024
1132
  def year
1025
1133
  case unit.base
1026
1134
  when :Y then parent * unit.count + 1970
1027
- when :M then self.class.send(:_floordiv_i, parent * unit.count + 1970 * 12, 12)
1028
- else self.class.send(:_civil_from_days, _field_days)[0]
1135
+ when :M then (parent * unit.count + 1970 * 12) / 12
1136
+ else CATimeCivil.civil_from_days(days_since_epoch)[0]
1029
1137
  end
1030
1138
  end
1031
1139
 
@@ -1037,8 +1145,8 @@ class CATime
1037
1145
  when :Y then parent * 0 + 1
1038
1146
  when :M
1039
1147
  mo = parent * unit.count + 1970 * 12
1040
- mo - self.class.send(:_floordiv_i, mo, 12) * 12 + 1
1041
- else self.class.send(:_civil_from_days, _field_days)[1]
1148
+ mo % 12 + 1
1149
+ else CATimeCivil.civil_from_days(days_since_epoch)[1]
1042
1150
  end
1043
1151
  end
1044
1152
 
@@ -1048,35 +1156,35 @@ class CATime
1048
1156
  def day
1049
1157
  case unit.base
1050
1158
  when :Y, :M then parent * 0 + 1
1051
- else self.class.send(:_civil_from_days, _field_days)[2]
1159
+ else CATimeCivil.civil_from_days(days_since_epoch)[2]
1052
1160
  end
1053
1161
  end
1054
1162
 
1055
1163
  # Hour of the day, 0..23; 0 when the storage unit is coarser than an hour.
1056
1164
  # @return [CArray] integer.
1057
- def hour; _clock_field(:h); end
1165
+ def hour; clock_field(:h); end
1058
1166
  # Minute of the hour, 0..59; 0 when the storage unit is coarser than a minute.
1059
1167
  # @return [CArray] integer.
1060
- def minute; _clock_field(:m); end
1168
+ def minute; clock_field(:m); end
1061
1169
  # Second of the minute, 0..59; 0 when the storage unit is coarser than a second.
1062
1170
  # @return [CArray] integer.
1063
- def second; _clock_field(:s); end
1171
+ def second; clock_field(:s); end
1064
1172
 
1065
1173
  # Day of the week, Sunday = 0 .. Saturday = 6.
1066
1174
  # @return [CArray] integer.
1067
1175
  def weekday
1068
1176
  # 1970-01-01 is a Thursday (wday 4); Sun=0..Sat=6.
1069
- n = _field_days + 4
1070
- n - self.class.send(:_floordiv_i, n, 7) * 7 # floor-mod 7
1177
+ n = days_since_epoch + 4
1178
+ n % 7
1071
1179
  end
1072
1180
 
1073
1181
  # Day of the year, 1..366.
1074
1182
  # @return [CArray] integer.
1075
1183
  def yday
1076
- d = _field_days
1077
- y = self.class.send(:_civil_from_days, d)[0]
1184
+ d = days_since_epoch
1185
+ y = CATimeCivil.civil_from_days(d)[0]
1078
1186
  ones = CArray.int64(*shape) { 1 }
1079
- d - self.class.send(:_days_from_civil, y, ones, ones) + 1
1187
+ d - CATimeCivil.days_from_civil(y, ones, ones) + 1
1080
1188
  end
1081
1189
  # @!endgroup
1082
1190
 
@@ -1122,14 +1230,14 @@ class CATime
1122
1230
  # calendar unit that means the month / year ordinal, so a :M centroid is
1123
1231
  # the centroid of month numbers (a caller wanting the day-space answer
1124
1232
  # writes to_unit(:D) first) -- and the result is rounded back to a tick.
1125
- def _reduce_dt(op, kind, args, opts)
1126
- _lift_reduced(parent.send(op, *args, **opts), kind)
1233
+ def reduce_on_own_grid(op, kind, args, opts)
1234
+ round_and_relift(parent.public_send(op, *args, **opts), kind)
1127
1235
  end
1128
1236
 
1129
1237
  # Round-to-nearest onto the storage grid, then wear the Face again. An
1130
1238
  # Array arrives from the multi-p percentile / quantile shapes; UNDEF and
1131
1239
  # nil (empty or all-masked) pass through untouched.
1132
- def _lift_reduced(r, kind)
1240
+ def round_and_relift(r, kind)
1133
1241
  case r
1134
1242
  when Numeric
1135
1243
  v = r.round
@@ -1142,7 +1250,7 @@ class CATime
1142
1250
  iv = r.round.mask_invalid.int64
1143
1251
  kind == :timedelta ? iv.timedelta(unit: unit) : iv.time(unit: unit)
1144
1252
  when Array
1145
- r.map {|x| _lift_reduced(x, kind)}
1253
+ r.map {|x| round_and_relift(x, kind)}
1146
1254
  else
1147
1255
  r # UNDEF / nil passthrough (empty or all-masked reduction)
1148
1256
  end
@@ -1168,21 +1276,25 @@ class CATimedelta
1168
1276
  end
1169
1277
 
1170
1278
  # @overload to_unit(unit)
1171
- # Returns the same durations re-expressed on a finer grid: a new
1172
- # {CATimedelta} whose storage is `self`'s ticks widened into `unit`.
1173
- # Accepted only when `self`'s tick is a whole multiple of `unit`'s
1174
- # (`:D` -> `:h`, `"1 hour"` -> `"10 minutes"`), so no duration changes.
1175
- # A coarser target raises rather than truncating silently; a calendar /
1176
- # fixed-length pair raises because a month has no fixed length.
1279
+ # Returns the same durations re-expressed on the `unit` grid: a new
1280
+ # {CATimedelta} whose storage resolution is `unit`. A finer target is
1281
+ # exact (`:D` -> `:h`); a coarser one drops the sub-`unit` remainder
1282
+ # **toward zero** (`+30 h` -> `+1 D`, `-30 h` -> `-1 D`), the same
1283
+ # truncation `dt + td` already applies.
1284
+ #
1285
+ # The direction differs from {CATime#to_unit} on purpose: a time is a
1286
+ # point on an axis, so it floors toward the past; a duration is a
1287
+ # magnitude, so it shrinks toward zero.
1288
+ #
1289
+ # A calendar / fixed-length pair always raises -- unlike an instant, a
1290
+ # duration of one month has no length in days.
1177
1291
  # @param unit [Resolution, Symbol, String] target resolution.
1178
1292
  # @return [CATimedelta]
1179
- # @raise [ArgumentError] when `self`'s tick is not a whole multiple of
1180
- # `unit`'s.
1293
+ # @raise [ArgumentError] on a calendar / fixed-length pair.
1181
1294
  # @raise [RangeError] when the widened ticks overflow int64.
1182
1295
  def to_unit(unit)
1183
1296
  to = CATime::Resolution.parse(unit)
1184
- CATimeUnitAlgebra.widen(parent,
1185
- CATimeUnitAlgebra.multiple_factor(self.unit, to)).timedelta(unit: to)
1297
+ CATimeUnitAlgebra.convert_scale_trunc(parent, self.unit, to).timedelta(unit: to)
1186
1298
  end
1187
1299
 
1188
1300
  # @overload +(other)
@@ -1195,7 +1307,7 @@ class CATimedelta
1195
1307
  def +(other)
1196
1308
  case other
1197
1309
  when CATimedelta
1198
- u = _finer_duration(other.unit)
1310
+ u = common_duration_unit(other.unit)
1199
1311
  a = CATimeUnitAlgebra.convert_scale!(parent, unit, u)
1200
1312
  b = CATimeUnitAlgebra.convert_scale!(other.parent, other.unit, u)
1201
1313
  (a + b).timedelta(unit: u)
@@ -1215,7 +1327,7 @@ class CATimedelta
1215
1327
  def -(other)
1216
1328
  case other
1217
1329
  when CATimedelta
1218
- u = _finer_duration(other.unit)
1330
+ u = common_duration_unit(other.unit)
1219
1331
  a = CATimeUnitAlgebra.convert_scale!(parent, unit, u)
1220
1332
  b = CATimeUnitAlgebra.convert_scale!(other.parent, other.unit, u)
1221
1333
  (a - b).timedelta(unit: u)
@@ -1260,11 +1372,13 @@ class CATimedelta
1260
1372
  # @raise [TypeError] on incompatible operands.
1261
1373
  def /(other)
1262
1374
  case other
1263
- when Integer then (parent / other).timedelta(unit: unit)
1375
+ when Integer
1376
+ CATimeUnitAlgebra.trunc_divide(parent, other).timedelta(unit: unit)
1264
1377
  when CATimedelta
1265
- u = _finer_duration(other.unit)
1266
- CATimeUnitAlgebra.convert_scale!(parent, unit, u) /
1267
- CATimeUnitAlgebra.convert_scale!(other.parent, other.unit, u)
1378
+ u = common_duration_unit(other.unit)
1379
+ CATimeUnitAlgebra.trunc_divide(
1380
+ CATimeUnitAlgebra.convert_scale!(parent, unit, u),
1381
+ CATimeUnitAlgebra.convert_scale!(other.parent, other.unit, u))
1268
1382
  else raise TypeError, "CATimedelta / #{other.class} is not allowed"
1269
1383
  end
1270
1384
  end
@@ -1281,14 +1395,14 @@ class CATimedelta
1281
1395
  # or as a {CATimedelta} view for per-axis reduction.
1282
1396
  # @return [Element, CATimedelta]
1283
1397
  def sum(*args, **opts)
1284
- _lift_reduced(parent.sum(*args, **opts))
1398
+ round_and_relift(parent.sum(*args, **opts))
1285
1399
  end
1286
1400
 
1287
1401
  # @overload mean(axis: nil, **opts)
1288
1402
  # Returns the mean duration rounded to the nearest unit count.
1289
1403
  # @return [Element, CATimedelta]
1290
1404
  def mean(*args, **opts)
1291
- _lift_reduced(parent.mean(*args, **opts))
1405
+ round_and_relift(parent.mean(*args, **opts))
1292
1406
  end
1293
1407
 
1294
1408
  # median / percentile / quantile / stddev / stddevp report on this array's
@@ -1300,7 +1414,7 @@ class CATimedelta
1300
1414
  # Returns the median duration on `self`'s unit.
1301
1415
  # @return [Element, CATimedelta]
1302
1416
  def median(*args, **opts)
1303
- _lift_reduced(parent.median(*args, **opts))
1417
+ round_and_relift(parent.median(*args, **opts))
1304
1418
  end
1305
1419
 
1306
1420
  # @overload percentile(*p, axis: nil, **opts)
@@ -1309,7 +1423,7 @@ class CATimedelta
1309
1423
  # two or more give an Array).
1310
1424
  # @return [Element, CATimedelta, Array<Element>, Array<CATimedelta>]
1311
1425
  def percentile(*args, **opts)
1312
- _lift_reduced(parent.percentile(*args, **opts))
1426
+ round_and_relift(parent.percentile(*args, **opts))
1313
1427
  end
1314
1428
 
1315
1429
  # @overload quantile(axis: nil, **opts)
@@ -1317,7 +1431,7 @@ class CATimedelta
1317
1431
  # `self`'s unit.
1318
1432
  # @return [Array<Element>, Array<CATimedelta>]
1319
1433
  def quantile(*args, **opts)
1320
- _lift_reduced(parent.quantile(*args, **opts))
1434
+ round_and_relift(parent.quantile(*args, **opts))
1321
1435
  end
1322
1436
 
1323
1437
  # @overload stddev(axis: nil, **opts)
@@ -1326,7 +1440,7 @@ class CATimedelta
1326
1440
  # the precision matters.
1327
1441
  # @return [Element, CATimedelta]
1328
1442
  def stddev(*args, **opts)
1329
- _lift_reduced(parent.stddev(*args, **opts))
1443
+ round_and_relift(parent.stddev(*args, **opts))
1330
1444
  end
1331
1445
 
1332
1446
  # @overload stddevp(axis: nil, **opts)
@@ -1334,29 +1448,19 @@ class CATimedelta
1334
1448
  # {#stddev}.
1335
1449
  # @return [Element, CATimedelta]
1336
1450
  def stddevp(*args, **opts)
1337
- _lift_reduced(parent.stddevp(*args, **opts))
1451
+ round_and_relift(parent.stddevp(*args, **opts))
1338
1452
  end
1339
1453
 
1340
- # variance / variancep are the one pair in this family that cannot carry a
1341
- # unit: their value is in squared ticks, and no Face represents squared
1342
- # time. They are left as the inherited plain Float (unlike
1343
- # {CATime#variance}, which raises -- a squared instant is meaningless,
1344
- # while a squared duration is a real quantity, s^2). Read the result as
1345
- # "in units of `self.unit` squared", or take {#stddev} for a typed answer.
1346
-
1347
- def _lift_reduced(r)
1454
+ def round_and_relift(r)
1348
1455
  case r
1349
1456
  when Numeric then Element.new(r.round, unit)
1350
1457
  when CArray then r.round.mask_invalid.int64.timedelta(unit: unit)
1351
- when Array then r.map {|x| _lift_reduced(x)}
1458
+ when Array then r.map {|x| round_and_relift(x)}
1352
1459
  else r
1353
1460
  end
1354
1461
  end
1355
- private :_lift_reduced
1462
+ private :round_and_relift
1356
1463
 
1357
- # min / max ride the core reduce Face gate (ORDERABLE storage descent +
1358
- # output re-lift, see ext/mkkernel.rb face_gate: :relift); the inherited
1359
- # CArray#min / #max return the Element / CATimedelta shapes directly.
1360
1464
  # @overload variance(*)
1361
1465
  # Not supported: the variance of durations has squared-time units, which
1362
1466
  # no type represents -- the same reason {CATime#variance} refuses. Use
@@ -1373,6 +1477,9 @@ class CATimedelta
1373
1477
  raise TypeError, "CATimedelta#variancep is ill-defined (squared-time units); use stddevp"
1374
1478
  end
1375
1479
 
1480
+ # min / max ride the core reduce Face gate (ORDERABLE storage descent +
1481
+ # output re-lift, see ext/mkkernel.rb face_gate: :relift); the inherited
1482
+ # CArray#min / #max return the Element / CATimedelta shapes directly.
1376
1483
  # minmax has no core gate, so re-lift both extremes here.
1377
1484
 
1378
1485
  # @overload minmax(*axes, **opts)
@@ -1380,17 +1487,17 @@ class CATimedelta
1380
1487
  # @return [Array(Element, Element), Array(CATimedelta, CATimedelta)]
1381
1488
  def minmax(*args, **opts)
1382
1489
  lo, hi = parent.minmax(*args, **opts)
1383
- [_lift_extremum(lo), _lift_extremum(hi)]
1490
+ [relift_extremum(lo), relift_extremum(hi)]
1384
1491
  end
1385
1492
 
1386
- def _lift_extremum(r)
1493
+ def relift_extremum(r)
1387
1494
  case r
1388
1495
  when Integer then Element.new(r, unit)
1389
1496
  when CArray then r.timedelta(unit: unit)
1390
1497
  else r
1391
1498
  end
1392
1499
  end
1393
- private :_lift_extremum
1500
+ private :relift_extremum
1394
1501
  # @!endgroup
1395
1502
 
1396
1503
  # sort / partition ride the core sort Face gate (ORDERABLE storage
@@ -1477,7 +1584,7 @@ class CATimedelta
1477
1584
  # The finer of `self`'s unit and `ou` for a duration + duration / ratio. A
1478
1585
  # cross-group pair (a month vs a second) has no common duration grid, so it
1479
1586
  # raises rather than mixing calendar and fixed durations.
1480
- def _finer_duration(ou)
1587
+ def common_duration_unit(ou)
1481
1588
  unless CATimeUnitAlgebra.same_group?(unit, ou)
1482
1589
  raise ArgumentError,
1483
1590
  "cannot combine a #{unit} duration with a #{ou} duration " \
@@ -1487,30 +1594,68 @@ class CATimedelta
1487
1594
  end
1488
1595
  end
1489
1596
 
1490
- class CArray
1491
- # Time is stored as an int64 tick index on a grid whose resolution is
1492
- # `unit` (= a {CATime::Resolution}, tick = count * base). The value
1493
- # is the k-th tick since the Unix epoch (1970-01-01 UTC): e.g. unit
1494
- # `"10 minutes"` value 3 = 1970-01-01T00:30:00Z. All parsing is UTC (an
1495
- # explicit offset is honoured; otherwise UTC), DateTime-independent
1496
- # (Date._parse + civil kernel).
1597
+ # Reads a time literal (Time / DateTime / Integer unix-seconds / String) into
1598
+ # the pieces the CATime constructors need. All parsing is UTC (an explicit
1599
+ # offset is honoured; otherwise UTC) and DateTime-independent (Date._parse +
1600
+ # a civil-date kernel). Internal to the time surface: nothing here is part
1601
+ # of the public API.
1602
+ module CATimeLiteral
1603
+ module_function
1604
+
1605
+ # Date fields of a String literal, UTC. Ruby's Date._parse does not read
1606
+ # the calendar-grid forms CATime#to_s prints -- "2019-09" comes back as a
1607
+ # month of 20 with a zone, and a bare "2019" as a month and a day -- so
1608
+ # those two are read here and everything else is left to Date._parse.
1609
+ #
1610
+ # What Date._parse returns is then checked, because an out-of-range field
1611
+ # used to flow into the civil kernel and normalise into a different date:
1612
+ # "201909" is a valid YYMMDD to Ruby (2020-19-09, which landed on 2021-07),
1613
+ # and "2019-02-31" landed on 2019-03-03. A missing finer field is not an
1614
+ # error -- a year or a year-month names the head of that period.
1615
+ def parse_date_fields(spec, format)
1616
+ h =
1617
+ if format then Date._strptime(spec, format)
1618
+ elsif spec =~ /\A(\d{4})-(\d{1,2})\z/ then { year: $1.to_i, mon: $2.to_i }
1619
+ elsif spec =~ /\A(\d{4})\z/ then { year: $1.to_i }
1620
+ else Date._parse(spec)
1621
+ end
1622
+ unless h && h[:year]
1623
+ raise ArgumentError, "cannot parse time #{spec.inspect}"
1624
+ end
1625
+ y, m, d = h[:year], h[:mon] || 1, h[:mday] || 1
1626
+ unless (1..12).cover?(m) && Date.valid_date?(y, m, d, Date::GREGORIAN)
1627
+ raise ArgumentError,
1628
+ "cannot parse time #{spec.inspect}: it reads as year #{y}, " \
1629
+ "month #{m}, day #{d}, which is not a date"
1630
+ end
1631
+ h
1632
+ end
1633
+ private_class_method :parse_date_fields
1497
1634
 
1498
1635
  # Exact Rational seconds since the Unix epoch for a start literal (Time /
1499
1636
  # DateTime / Integer unix-seconds / String). UTC default.
1500
- def self._epoch_seconds_exact(spec, format = nil)
1637
+ def epoch_seconds(spec, format = nil)
1501
1638
  require 'date'
1502
1639
  require 'time'
1503
1640
  case spec
1504
1641
  when Time then spec.to_r
1505
1642
  when Integer then Rational(spec)
1506
- when String
1507
- h = format ? Date._strptime(spec, format) : Date._parse(spec)
1508
- unless h && h[:year] && h[:mon] && h[:mday]
1509
- raise ArgumentError, "cannot parse time #{spec.inspect}"
1643
+ when CATime::Element
1644
+ # A time element is already an instant, so it needs no parsing: a
1645
+ # fixed unit counts seconds directly, and a calendar one names the
1646
+ # first midnight of its granule (the same instant convert_instant!
1647
+ # gives it). This is what lets floor / ceil / to_unit output feed
1648
+ # straight back into time_range / time_series / .time.
1649
+ if CATimeUnitAlgebra::FIXED.key?(spec.unit.base)
1650
+ Rational(spec.value) * spec.unit.tick_ratio
1651
+ else
1652
+ Rational(CATimeUnitAlgebra.calendar_days_since_epoch(CA_INT64([spec.value]),
1653
+ spec.unit)[0] * 86400)
1510
1654
  end
1511
- days = CATime.send(:_days_from_civil,
1512
- CA_INT64([h[:year]]), CA_INT64([h[:mon]]),
1513
- CA_INT64([h[:mday]]))[0]
1655
+ when String
1656
+ h = parse_date_fields(spec, format)
1657
+ days = CATimeCivil.days_from_civil(CA_INT64([h[:year]]), CA_INT64([h[:mon] || 1]),
1658
+ CA_INT64([h[:mday] || 1]))[0]
1514
1659
  sec = Rational(days * 86400 + (h[:hour] || 0) * 3600 +
1515
1660
  (h[:min] || 0) * 60 + (h[:sec] || 0))
1516
1661
  sec += h[:sec_fraction] if h[:sec_fraction]
@@ -1526,17 +1671,25 @@ class CArray
1526
1671
  end
1527
1672
 
1528
1673
  # [year, month] (UTC) of a start literal, for a calendar-resolution grid.
1529
- def self._epoch_year_month(spec, format = nil)
1674
+ def year_month(spec, format = nil)
1530
1675
  require 'date'
1531
1676
  require 'time'
1532
1677
  case spec
1533
1678
  when Time then t = spec.utc; [t.year, t.month]
1534
1679
  when Integer then t = Time.at(spec, in: 'UTC'); [t.year, t.month]
1535
- when String
1536
- h = format ? Date._strptime(spec, format) : Date._parse(spec)
1537
- unless h && h[:year]
1538
- raise ArgumentError, "cannot parse time #{spec.inspect}"
1680
+ when CATime::Element
1681
+ # A calendar element is a month ordinal already, so read it as one
1682
+ # rather than going out through Time (which a :Y / :M element would
1683
+ # have to re-derive). Integer / and % floor, so a pre-epoch value
1684
+ # lands in the right month.
1685
+ case spec.unit.base
1686
+ when :M then mo = spec.value * spec.unit.count + 1970 * 12
1687
+ [mo / 12, mo % 12 + 1]
1688
+ when :Y then [spec.value * spec.unit.count + 1970, 1]
1689
+ else t = spec.to_time.utc; [t.year, t.month]
1539
1690
  end
1691
+ when String
1692
+ h = parse_date_fields(spec, format)
1540
1693
  [h[:year], h[:mon] || 1]
1541
1694
  else
1542
1695
  if defined?(DateTime) && spec.is_a?(DateTime)
@@ -1548,16 +1701,37 @@ class CArray
1548
1701
  end
1549
1702
 
1550
1703
  # Tick index of `spec`'s instant on the `res` grid (floor toward the past).
1551
- def self._epoch_tick_index(spec, res, format = nil)
1704
+ def tick_index(spec, res, format = nil)
1552
1705
  if CATimeUnitAlgebra::FIXED.key?(res.base)
1553
- (_epoch_seconds_exact(spec, format) / res.tick_ratio).floor
1706
+ (epoch_seconds(spec, format) / res.tick_ratio).floor
1554
1707
  else
1555
- y, m = _epoch_year_month(spec, format)
1708
+ y, m = year_month(spec, format)
1556
1709
  months = (y - 1970) * 12 + (m - 1)
1557
1710
  (Rational(months) / res.tick_ratio).floor
1558
1711
  end
1559
1712
  end
1560
1713
 
1714
+ # Single-literal build for {.time}: a 1-element CATime, honouring
1715
+ # the on_error policy (raise, or a masked cell).
1716
+ def to_time_array(literal, res, format, on_error)
1717
+ raw = CArray.int64(1)
1718
+ begin
1719
+ raw[0] = tick_index(literal, res, format)
1720
+ rescue ArgumentError, TypeError
1721
+ raise if on_error == :raise
1722
+ raw[0] = UNDEF
1723
+ end
1724
+ raw.time(unit: res)
1725
+ end
1726
+ end
1727
+
1728
+ class CArray
1729
+ # Time is stored as an int64 tick index on a grid whose resolution is
1730
+ # `unit` (= a {CATime::Resolution}, tick = count * base). The value
1731
+ # is the k-th tick since the Unix epoch (1970-01-01 UTC): e.g. unit
1732
+ # `"10 minutes"` value 3 = 1970-01-01T00:30:00Z. Literals are read by
1733
+ # {CATimeLiteral} (UTC).
1734
+
1561
1735
  # @overload time_range(start, last, unit:, step: nil, format: nil)
1562
1736
  # Returns a {CATime} from `start` to `last` inclusive on the `unit`
1563
1737
  # grid, spaced `step` apart. `unit` is the resolution the result is
@@ -1567,8 +1741,10 @@ class CArray
1567
1741
  # bucket head (toward the past); the phase is anchored at `start`, and
1568
1742
  # `last` is a bound rather than a member -- the series stops at the last
1569
1743
  # step at or before it.
1570
- # @param start [Time, String, Integer, DateTime] first instant.
1571
- # @param last [Time, String, Integer, DateTime] last instant (inclusive).
1744
+ # @param start [Time, String, Integer, DateTime, CATime::Element] first
1745
+ # instant.
1746
+ # @param last [Time, String, Integer, DateTime, CATime::Element] last
1747
+ # instant (inclusive).
1572
1748
  # @param unit [Resolution, Symbol, String] grid resolution (tick).
1573
1749
  # @param step [Resolution, Symbol, String, nil] spacing between elements
1574
1750
  # (default: one `unit` tick). Must be a whole multiple of `unit`.
@@ -1581,8 +1757,8 @@ class CArray
1581
1757
  res = CATime::Resolution.parse(unit)
1582
1758
  stride = step.nil? ? 1 :
1583
1759
  CATimeUnitAlgebra.multiple_factor(CATime::Resolution.parse(step), res)
1584
- s = _epoch_tick_index(start, res, format)
1585
- e = _epoch_tick_index(last, res, format)
1760
+ s = CATimeLiteral.tick_index(start, res, format)
1761
+ e = CATimeLiteral.tick_index(last, res, format)
1586
1762
  n = e < s ? 0 : (e - s) / stride + 1
1587
1763
  CArray.int64(n) {|i| s + i * stride }.time(unit: res)
1588
1764
  end
@@ -1593,7 +1769,8 @@ class CArray
1593
1769
  # is stored on and `step` is the spacing, so an hourly grid sampled once
1594
1770
  # a day is `unit: :h, step: "1 day"`. With no `step` the spacing is one
1595
1771
  # `unit` tick (consecutive ticks, as before).
1596
- # @param start [Time, String, Integer, DateTime] first instant.
1772
+ # @param start [Time, String, Integer, DateTime, CATime::Element] first
1773
+ # instant.
1597
1774
  # @param count [Integer] number of elements.
1598
1775
  # @param unit [Resolution, Symbol, String] grid resolution (tick).
1599
1776
  # @param step [Resolution, Symbol, String, nil] spacing between elements
@@ -1607,7 +1784,7 @@ class CArray
1607
1784
  res = CATime::Resolution.parse(unit)
1608
1785
  stride = step.nil? ? 1 :
1609
1786
  CATimeUnitAlgebra.multiple_factor(CATime::Resolution.parse(step), res)
1610
- s = _epoch_tick_index(start, res, format)
1787
+ s = CATimeLiteral.tick_index(start, res, format)
1611
1788
  CArray.int64(count) {|i| s + i * stride }.time(unit: res)
1612
1789
  end
1613
1790
 
@@ -1622,7 +1799,7 @@ class CArray
1622
1799
  # pass `on_error: :mask` to make it an UNDEF cell instead. A masked /
1623
1800
  # `nil` input cell is a *missing* value (not a parse failure) and always
1624
1801
  # becomes UNDEF, regardless of `on_error`.
1625
- # @param x [Time, String, Integer, DateTime, Array, CArray] a literal, a
1802
+ # @param x [Time, String, Integer, DateTime, CATime::Element, Array, CArray] a literal, a
1626
1803
  # Ruby Array of literals, or a CArray of literals.
1627
1804
  # @param unit [Resolution, Symbol, String] target grid resolution.
1628
1805
  # @param format [String, nil] optional strptime format for String input.
@@ -1641,7 +1818,7 @@ class CArray
1641
1818
  end
1642
1819
  x = CA_OBJECT(x) if x.is_a?(Array) # Ruby Array of literals -> object CArray
1643
1820
  unless x.is_a?(CArray)
1644
- return _time_cell(x, res, format, on_error)
1821
+ return CATimeLiteral.to_time_array(x, res, format, on_error)
1645
1822
  end
1646
1823
  raw = CArray.int64(*x.shape)
1647
1824
  x.each_index do |*idx|
@@ -1651,7 +1828,7 @@ class CArray
1651
1828
  next
1652
1829
  end
1653
1830
  begin
1654
- raw[*idx] = _epoch_tick_index(s, res, format)
1831
+ raw[*idx] = CATimeLiteral.tick_index(s, res, format)
1655
1832
  rescue ArgumentError, TypeError
1656
1833
  raise if on_error == :raise
1657
1834
  raw[*idx] = UNDEF # opt-in parse-mask
@@ -1660,19 +1837,6 @@ class CArray
1660
1837
  raw.time(unit: res)
1661
1838
  end
1662
1839
 
1663
- # Single-literal build for {.time}: a 1-element CATime, honouring
1664
- # the on_error policy (raise, or a masked cell).
1665
- def self._time_cell(literal, res, format, on_error)
1666
- raw = CArray.int64(1)
1667
- begin
1668
- raw[0] = _epoch_tick_index(literal, res, format)
1669
- rescue ArgumentError, TypeError
1670
- raise if on_error == :raise
1671
- raw[0] = UNDEF
1672
- end
1673
- raw.time(unit: res)
1674
- end
1675
-
1676
1840
  # @overload time(unit: :ns, origin: nil)
1677
1841
  # Returns `self` as a {CATime} on the `unit` grid. With `origin`
1678
1842
  # nil, `self`'s int64 values are taken as tick indices already anchored
@@ -1680,7 +1844,7 @@ class CArray
1680
1844
  # indices are relative to `origin` and are rebased to the epoch (a new
1681
1845
  # int64 array is built; the origin is not stored).
1682
1846
  # @param unit [Resolution, Symbol, String] grid resolution.
1683
- # @param origin [Time, String, Integer, DateTime, nil] base instant that
1847
+ # @param origin [Time, String, Integer, DateTime, CATime::Element, nil] base instant that
1684
1848
  # `self`'s indices are counted from (default: the Unix epoch).
1685
1849
  # @return [CATime]
1686
1850
  # @note An `int64` receiver is wrapped zero-copy. A narrower integer type
@@ -1688,11 +1852,11 @@ class CArray
1688
1852
  # raises (cast it explicitly if the truncation is intended).
1689
1853
  def time(unit: :ns, origin: nil)
1690
1854
  res = CATime::Resolution.parse(unit)
1691
- src = _time_int64_storage
1855
+ src = as_int64_time_storage
1692
1856
  if origin.nil?
1693
1857
  CATime.wrap(src, unit: res)
1694
1858
  else
1695
- o = CArray._epoch_tick_index(origin, res)
1859
+ o = CATimeLiteral.tick_index(origin, res)
1696
1860
  (src + o).time(unit: res)
1697
1861
  end
1698
1862
  end
@@ -1705,13 +1869,13 @@ class CArray
1705
1869
  # @note An `int64` receiver is wrapped zero-copy; a narrower integer type
1706
1870
  # is widened to `int64` first (a copy); a `Float` / non-integer raises.
1707
1871
  def timedelta(unit: :ns)
1708
- CATimedelta.wrap(_time_int64_storage, unit: unit)
1872
+ CATimedelta.wrap(as_int64_time_storage, unit: unit)
1709
1873
  end
1710
1874
 
1711
1875
  # Coerce the receiver to the int64 storage a time / timedelta Face needs:
1712
1876
  # int64 passes through (zero-copy), a narrower integer type widens losslessly
1713
1877
  # to int64 (a copy), and any non-integer (Float, boolean, object, ...) raises.
1714
- def _time_int64_storage
1878
+ def as_int64_time_storage
1715
1879
  return self if data_type == CA_INT64
1716
1880
  unless integer?
1717
1881
  raise TypeError,
@@ -1721,7 +1885,7 @@ class CArray
1721
1885
  end
1722
1886
  int64
1723
1887
  end
1724
- private :_time_int64_storage
1888
+ private :as_int64_time_storage
1725
1889
  end
1726
1890
 
1727
1891
  # ============================================================================
@@ -1740,6 +1904,198 @@ end
1740
1904
  # See devel/PROPOSAL_DATETIME64_STEP_SYSTEM.md.
1741
1905
  # ============================================================================
1742
1906
 
1907
+ # Places a bucket grid on a storage resolution: resolves a timestep into whole
1908
+ # storage ticks, resolves an origin into a tick count (or a month ordinal on
1909
+ # the calendar path), and guards both against int64 overflow. Internal to the
1910
+ # time surface: nothing here is part of the public API.
1911
+ module CATimeGrid
1912
+ module_function
1913
+
1914
+ # Base units that are day-or-finer (a period head is representable exactly;
1915
+ # the civil path targets these for a calendar bucket).
1916
+ SU_LE_DAY = %i[D h m s ms us ns ps fs as].freeze
1917
+
1918
+ # Smallest value an int64 storage tick can hold.
1919
+ INT64_MIN = -(2**63)
1920
+ # Largest value an int64 storage tick can hold.
1921
+ INT64_MAX = 2**63 - 1
1922
+
1923
+ # Raise (rather than let an int64 CArray operand silently wrap) if a
1924
+ # Ruby-domain quantity is out of int64 range. Returns the value on success
1925
+ # so it composes inline.
1926
+ def check_int64_range(v, what)
1927
+ if v < INT64_MIN || v > INT64_MAX
1928
+ raise RangeError,
1929
+ "time step: #{what} = #{v} overflows int64 " \
1930
+ "(the storage unit is too fine for this step / origin / span; " \
1931
+ "use a coarser unit)"
1932
+ end
1933
+ v
1934
+ end
1935
+
1936
+ # Return [mul, step_ticks, origin_ticks] (all Integer) for the integer
1937
+ # path -- step_ticks and origin_ticks in numerator ticks, the finer of
1938
+ # the storage and step grids (see #resolve_timestep_grid). The calendar path is
1939
+ # handled by the caller.
1940
+ def resolve_integer_grid(step_res, storage_res, origin)
1941
+ mul, step_ticks = resolve_step_scale(step_res, storage_res)
1942
+ [mul, step_ticks, resolve_origin_ticks(origin, step_res,
1943
+ mul == 1 ? storage_res : step_res)]
1944
+ end
1945
+
1946
+ # Storage ticks per day of `storage_res` (must be a whole number, else the
1947
+ # storage grid does not tile a day -- a calendar bucket is unrepresentable).
1948
+ def ticks_per_day(storage_res)
1949
+ r = CATimeUnitAlgebra.ratio(CATime::Resolution.new(1, :D), storage_res)
1950
+ unless r.denominator == 1
1951
+ raise ArgumentError,
1952
+ "cannot place a calendar bucket on storage resolution " \
1953
+ "#{storage_res} (its tick does not tile a day)"
1954
+ end
1955
+ check_int64_range(r.numerator, "ticks per day for #{storage_res}")
1956
+ end
1957
+
1958
+ # Storage ticks of the bucket head at timestep `k` (int64 CArray) for a
1959
+ # calendar bucket: ym0 + k*count months -> day 1 of that month -> ticks.
1960
+ def calendar_bucket_head_ticks(k, st, storage_res, origin)
1961
+ tpd = ticks_per_day(storage_res)
1962
+ count = st.count * (st.base == :Y ? 12 : 1)
1963
+ ym0 = origin_month_ordinal(origin)
1964
+ ymk = ym0 + k * count
1965
+ yy = ymk / 12
1966
+ mm = ymk - yy * 12 + 1
1967
+ CATimeCivil.days_from_civil(yy, mm, CArray.int64(*k.shape) { 1 }) * tpd
1968
+ end
1969
+
1970
+ # Month ordinal (year*12 + month-1) of origin; day / time ignored.
1971
+ # Default (nil) is the epoch month 1970-01.
1972
+ def origin_month_ordinal(origin)
1973
+ return 1970 * 12 if origin.nil?
1974
+ y, m = check_calendar_origin(origin, nil)
1975
+ y * 12 + (m - 1)
1976
+ end
1977
+
1978
+ # [mul, step_ticks] for the integer path, or :civil (calendar bucket on
1979
+ # day-or-finer storage), or raise. Bucket arithmetic runs in the finer of
1980
+ # the two grids, so exactly one of the pair is > 1:
1981
+ # - bucket at or coarser than the storage tick (a :h bucket on :s
1982
+ # storage) -> [1, N]: N storage ticks per bucket.
1983
+ # - bucket finer than the storage tick (a :h bucket on :D storage) ->
1984
+ # [N, 1]: one storage tick spans N buckets exactly, so a timestep is a
1985
+ # plain widening. This needs the storage tick to be a *whole* multiple
1986
+ # of the bucket tick; a partial multiple ("90 minutes" storage against
1987
+ # an :h bucket) has no integer timestep and raises.
1988
+ def resolve_step_scale(step_res, storage_res)
1989
+ r = CATimeUnitAlgebra.ratio(step_res, storage_res) # storage ticks / step tick
1990
+ if r
1991
+ what = "bucket #{step_res} in ticks of #{storage_res}"
1992
+ return [1, check_int64_range(r.numerator, what)] if r.denominator == 1
1993
+ return [check_int64_range(r.denominator, what), 1] if r.numerator == 1
1994
+ end
1995
+ if CATimeUnitAlgebra::CALENDAR.key?(step_res.base) && SU_LE_DAY.include?(storage_res.base)
1996
+ return :civil
1997
+ end
1998
+ raise ArgumentError,
1999
+ "cannot express bucket #{step_res} on storage resolution #{storage_res} " \
2000
+ "(neither is a whole multiple of the other)"
2001
+ end
2002
+
2003
+ # origin -> integer tick count in storage ticks. Default (nil) is the
2004
+ # epoch, except a week bucket defaults to ISO Monday (1970-01-05). A lossy
2005
+ # conversion (origin not landing exactly on the storage grid) raises; a
2006
+ # bare Integer is rejected (epoch-dependent, ambiguous).
2007
+ def resolve_origin_ticks(origin, step_res, storage_res)
2008
+ if origin.nil?
2009
+ if step_res.base == :W && SU_LE_DAY.include?(storage_res.base)
2010
+ return check_int64_range(ticks_per_day(storage_res) * 4,
2011
+ "ISO-Monday week origin in ticks of #{storage_res}")
2012
+ end
2013
+ return 0
2014
+ end
2015
+ if CATimeUnitAlgebra::FIXED.key?(storage_res.base)
2016
+ secs = origin_seconds_exact(origin)
2017
+ tick = secs / storage_res.tick_ratio # Rational
2018
+ unless tick.denominator == 1
2019
+ raise ArgumentError,
2020
+ "origin #{origin.inspect} is not representable losslessly " \
2021
+ "in storage resolution #{storage_res} (would truncate the grid phase)"
2022
+ end
2023
+ check_int64_range(tick.numerator, "origin #{origin.inspect} in ticks of #{storage_res}")
2024
+ else # :Y / :M storage
2025
+ y, m = check_calendar_origin(origin, storage_res)
2026
+ ord = storage_res.base == :Y ? (y - 1970) : (y * 12 + (m - 1) - 1970 * 12)
2027
+ if storage_res.count > 1
2028
+ unless (ord % storage_res.count).zero?
2029
+ raise ArgumentError,
2030
+ "origin #{origin.inspect} is not on the #{storage_res} grid"
2031
+ end
2032
+ ord /= storage_res.count
2033
+ end
2034
+ ord
2035
+ end
2036
+ end
2037
+
2038
+ # Exact Rational seconds since the Unix epoch for a fixed-storage origin.
2039
+ # Everything but a bare Integer goes through the shared entry point, so
2040
+ # an origin reads the same here as it does for a start literal.
2041
+ def origin_seconds_exact(origin)
2042
+ case origin
2043
+ when Integer
2044
+ raise ArgumentError,
2045
+ "origin: a bare Integer is ambiguous (epoch-dependent); pass a " \
2046
+ "Time / String / CATime scalar"
2047
+ else
2048
+ CATimeLiteral.epoch_seconds(origin) # Time / String / DateTime / Element
2049
+ end
2050
+ end
2051
+
2052
+ # Whether `origin` sits exactly on the head of its month (the 1st at
2053
+ # 00:00 UTC). A calendar grid is addressed by month ordinal, so its
2054
+ # bucket heads are month heads and nothing else; an origin anywhere in
2055
+ # between names a bucket that does not exist.
2056
+ def origin_on_month_head?(origin)
2057
+ # A calendar element is a month head by construction, and the shared
2058
+ # entry point says so by handing back that midnight -- no branch here
2059
+ # has to know it.
2060
+ secs = CATimeLiteral.epoch_seconds(origin)
2061
+ y, m = origin_year_month(origin)
2062
+ head = CATimeCivil.days_from_civil(CA_INT64([y]), CA_INT64([m]), CA_INT64([1]))[0]
2063
+ secs == head * 86400
2064
+ end
2065
+
2066
+ # Raise unless `origin` can be the head of bucket 0 on a calendar grid:
2067
+ # it has to be a month head, and a :Y tick starts in January. The day
2068
+ # and time would otherwise be dropped silently -- the same loss a fixed
2069
+ # unit already refuses (see resolve_origin_ticks).
2070
+ def check_calendar_origin(origin, storage_res)
2071
+ return if origin.nil?
2072
+ y, m = origin_year_month(origin)
2073
+ unless origin_on_month_head?(origin)
2074
+ raise ArgumentError,
2075
+ "origin #{origin.inspect} is not a month head: a calendar grid " \
2076
+ "is addressed by month, so its origin must be the 1st at 00:00"
2077
+ end
2078
+ if storage_res && storage_res.base == :Y && m != 1
2079
+ raise ArgumentError,
2080
+ "origin #{origin.inspect} is not on the #{storage_res} grid " \
2081
+ "(a #{storage_res} tick starts in January)"
2082
+ end
2083
+ [y, m]
2084
+ end
2085
+
2086
+ # [year, month] of a calendar-storage origin (finer fields ignored).
2087
+ def origin_year_month(origin)
2088
+ case origin
2089
+ when Integer
2090
+ raise ArgumentError,
2091
+ "origin: a bare Integer is ambiguous (epoch-dependent); pass a " \
2092
+ "Time / String / CATime scalar"
2093
+ else
2094
+ CATimeLiteral.year_month(origin) # Time / String / DateTime / Element
2095
+ end
2096
+ end
2097
+ end
2098
+
1743
2099
  class CATime
1744
2100
  # A tick resolution: `count` ticks of a `base` unit. The human surface is
1745
2101
  # a String ("3 hours" / "1 month"); a bare Symbol (:h) is the count-1
@@ -1802,9 +2158,10 @@ class CATime
1802
2158
 
1803
2159
  def initialize(count, base)
1804
2160
  raise ArgumentError, "unit count must be >= 1 (got #{count})" if count < 1
1805
- # calendar (Y/M) bases are month-ordinal linear, so only integer
1806
- # multiples are well-defined; a fractional multiple has no meaning.
1807
- # (count is already Integer here; the guard documents the rule.)
2161
+ # Only integer multiples are well-defined (calendar Y/M bases are
2162
+ # month-ordinal linear, so a fractional multiple has no meaning).
2163
+ # Resolution.parse always hands in an Integer; a fractional count via
2164
+ # a direct `new` is not guarded here.
1808
2165
  @count = count
1809
2166
  @base = base
1810
2167
  freeze
@@ -1826,9 +2183,180 @@ class CATime
1826
2183
  def inspect = "#<CATime::Resolution #{count} #{base}>"
1827
2184
  end
1828
2185
 
1829
- # Base units that are day-or-finer (a period head is representable exactly;
1830
- # the civil path targets these for a calendar bucket).
1831
- SU_LE_DAY = %i[D h m s ms us ns ps fs as].freeze
2186
+ # A tick resolution *and where its ticks are anchored*: the `(unit:,
2187
+ # origin:)` pair that {#timesteps}, {#snap} and {.from_timesteps} already
2188
+ # take, given a name and a value.
2189
+ #
2190
+ # {Resolution} says how wide a tick is; Grid says that and where tick 0
2191
+ # starts. A CATime is stored as epoch-anchored int64 either way, so this
2192
+ # reifies a calling convention -- it does not add a second time
2193
+ # representation, and the reference-time epoch stays out of storage.
2194
+ #
2195
+ # g = CATime::Grid.parse("12 hours since 2017-11-30 09:00")
2196
+ # t.snap(g, direction: :floor) # the pair, passed once
2197
+ # t.timesteps(g) # which tick each element falls in
2198
+ # g.at(CA_INT64([0, 1, 2])) # and back
2199
+ #
2200
+ # Proleptic Gregorian, UTC. There is no calendar seam here on purpose: a
2201
+ # swappable calendar taxes every operation that touches a time, and the
2202
+ # benefit does not follow.
2203
+ class Grid
2204
+
2205
+ # udunits' origin-shift operators, all equivalent: the words `since`,
2206
+ # `after`, `from`, `ref` (whitespace-separated) and the `@` sign, which
2207
+ # needs none ("seconds@1970-01-01"). Generous on input because a
2208
+ # `units` attribute is written by whoever wrote the file; {#to_s} only
2209
+ # ever emits `since`, which is what CF-conforming files use.
2210
+ ORIGIN_SHIFT = /\A(.+?)(?:\s+(?:since|after|from|ref)\s+|\s*@\s*)(.+)\z/i
2211
+
2212
+ # Plural unit words, the spelling {Resolution.parse} reads back. (Bare
2213
+ # Resolution#to_s does not round-trip: it prints "h" / "12 h", and
2214
+ # Resolution.parse takes neither.)
2215
+ WORD = { Y: "years", M: "months", W: "weeks", D: "days", h: "hours",
2216
+ m: "minutes", s: "seconds", ms: "milliseconds",
2217
+ us: "microseconds", ns: "nanoseconds", ps: "picoseconds",
2218
+ fs: "femtoseconds", as: "attoseconds" }.freeze
2219
+
2220
+ # Storage resolutions to fall back on when the unit's own tick cannot
2221
+ # hold the origin exactly (a day grid anchored at 12:00 needs seconds).
2222
+ FALLBACK_BASES = %i[s ms us ns].freeze
2223
+
2224
+ # @param spec ["<unit> since <instant>", unit spec, Grid]
2225
+ # @param origin [Time, String, DateTime, Element, nil] when `spec`
2226
+ # carries no origin-shift clause.
2227
+ # @return [Grid]
2228
+ def self.parse(spec, origin: nil)
2229
+ return spec if spec.is_a?(Grid)
2230
+ if spec.is_a?(String) and (fields = ORIGIN_SHIFT.match(spec))
2231
+ new(fields[1], fields[2])
2232
+ else
2233
+ new(spec, origin)
2234
+ end
2235
+ end
2236
+
2237
+ # Normalizes the three ways a grid reaches a method -- positionally, as
2238
+ # `unit:`, or as the loose `(unit:, origin:)` pair -- into that pair.
2239
+ # One call at the head of a method is the whole Grid arm.
2240
+ # @return [Array(Object, Object)] (unit, origin).
2241
+ def self.resolve(grid, unit, origin)
2242
+ return [grid.unit, grid.origin] if grid.is_a?(Grid)
2243
+ return [unit.unit, unit.origin] if unit.is_a?(Grid)
2244
+ unless grid.nil?
2245
+ raise ArgumentError,
2246
+ "the positional argument must be a CATime::Grid (got #{grid.class})"
2247
+ end
2248
+ [unit, origin]
2249
+ end
2250
+
2251
+ def initialize(unit, origin = nil)
2252
+ @unit = Resolution.parse(unit)
2253
+ @origin = origin
2254
+ @storage = resolve_storage
2255
+ @origin = CArray.time(origin, unit: @storage)[0] if origin
2256
+ freeze
2257
+ end
2258
+
2259
+ # @return [Resolution] the tick this grid counts in.
2260
+ attr_reader :unit
2261
+
2262
+ # @return [Element, nil] the instant tick 0 starts at. nil is the epoch,
2263
+ # which is what every `origin:` keyword already defaults to.
2264
+ attr_reader :origin
2265
+
2266
+ # @return [Resolution] the resolution an array on this grid is stored on:
2267
+ # the unit itself, or finer when the origin's phase needs it. Derived
2268
+ # from (unit, origin) and memoized here, so a grid's identity stays the
2269
+ # (count, unit, origin) triple.
2270
+ attr_reader :storage
2271
+
2272
+ # @return [Boolean] whether the unit is a calendar one (:Y / :M), whose
2273
+ # ticks are month ordinals rather than a fixed number of seconds.
2274
+ def calendar? = %i[Y M].include?(@unit.base)
2275
+
2276
+ # @return [Integer] storage ticks per unit tick.
2277
+ def step_ticks = Integer(@unit.tick_ratio / @storage.tick_ratio)
2278
+
2279
+ # @return [Integer] storage ticks from the epoch to the origin.
2280
+ def origin_ticks
2281
+ @origin ? Integer(CATimeLiteral.epoch_seconds(@origin) / @storage.tick_ratio) : 0
2282
+ end
2283
+
2284
+ # Tick indices -> instants: the {CATime.from_timesteps} direction, but
2285
+ # answering on {#storage} so a phased origin survives.
2286
+ # @param k [CArray, Integer]
2287
+ # @return [CATime, Element]
2288
+ def at(k)
2289
+ return at(CArray.int64(1) { Integer(k) })[0] unless k.is_a?(CArray)
2290
+ return CATime.from_timesteps(k.int64, unit: @unit, origin: @origin) if calendar?
2291
+ (origin_ticks + k.int64 * step_ticks).time(unit: @storage)
2292
+ end
2293
+
2294
+ # Instants -> tick indices: the {CATime#timesteps} direction. Off-grid
2295
+ # elements floor toward the past; ask {#on} when that would be a lie.
2296
+ # @return [CArray] int64.
2297
+ def index(time) = time.timesteps(self)
2298
+
2299
+ # @return [CArray] boolean, flagging elements that land on a tick.
2300
+ def on(time) = time.is_righttime(self)
2301
+
2302
+ # @return [Boolean] whether every element lands on a tick.
2303
+ def on?(time) = on(time).all
2304
+
2305
+ # A regular series on this grid.
2306
+ def range(start, last) = CArray.time_range(start, last, unit: @storage, step: @unit)
2307
+ # A regular series on this grid: +count+ elements from +start+, one per
2308
+ # tick.
2309
+ def series(start, count:) = CArray.time_series(start, count: count, unit: @storage, step: @unit)
2310
+
2311
+ # @return [String] "12 hours since 2017-11-30 09:00:00", which {.parse}
2312
+ # reads back into an equal grid.
2313
+ def to_s
2314
+ word = WORD.fetch(@unit.base)
2315
+ spec = @unit.count == 1 ? word : "#{@unit.count} #{word}"
2316
+ @origin ? "#{spec} since #{@origin.to_time.strftime('%Y-%m-%d %H:%M:%S')}" : spec
2317
+ end
2318
+
2319
+ # @return [String] the {#to_s} spec, wrapped for the console.
2320
+ def inspect = "#<CATime::Grid #{self}>"
2321
+
2322
+ # Two grids are equal when they place ticks in the same places: same
2323
+ # timestep, same origin.
2324
+ # @return [Boolean]
2325
+ def ==(other) = other.is_a?(Grid) && other.unit == @unit && other.to_s == to_s
2326
+ alias eql? ==
2327
+
2328
+ # Hashes with {#==}, so a grid works as a Hash key.
2329
+ # @return [Integer]
2330
+ def hash = [@unit, to_s].hash
2331
+
2332
+ private
2333
+
2334
+ # The coarsest storage holding both the unit's tick and the origin's
2335
+ # phase without truncation. A calendar grid is addressed by month, so
2336
+ # its origin is already pinned to a month head and the unit serves.
2337
+ def resolve_storage
2338
+ return @unit if calendar?
2339
+ candidates = [@unit, *FALLBACK_BASES.map {|base| Resolution.new(1, base) }]
2340
+ candidates.each do |res|
2341
+ next unless (@unit.tick_ratio / res.tick_ratio).denominator == 1
2342
+ next unless @origin.nil? ||
2343
+ (CATimeLiteral.epoch_seconds(@origin) / res.tick_ratio).denominator == 1
2344
+ return res
2345
+ end
2346
+ raise ArgumentError,
2347
+ "origin #{@origin.inspect} cannot be held exactly on any storage " \
2348
+ "resolution for unit #{@unit}"
2349
+ end
2350
+ end
2351
+
2352
+ # Rounding rules {#snap} accepts, the same three CArray#snap takes.
2353
+ SNAP_DIRECTIONS = %i[round floor ceil].freeze
2354
+
2355
+ # The grid this array is stored on: its own tick, anchored at the epoch.
2356
+ # `t.timesteps` is `t.timesteps(t.grid)`, which is what the `unit:` default
2357
+ # has always meant.
2358
+ # @return [Grid]
2359
+ def grid = Grid.new(unit)
1832
2360
 
1833
2361
  # @overload timesteps(unit: self.unit, origin: nil)
1834
2362
  # Returns the integer timestep of every element: the k-th `unit`-wide
@@ -1840,17 +2368,23 @@ class CATime
1840
2368
  # {#ticks}, but a fresh array rather than the live storage).
1841
2369
  # @param unit [String, Symbol, Resolution] bucket resolution
1842
2370
  # (default: this array's own storage resolution).
1843
- # @param origin [Time, String, CATime::Element, DateTime, nil] grid
1844
- # phase (default: the Unix epoch, or ISO Monday for a week bucket).
2371
+ # @param origin [Time, String, CATime::Element, DateTime, nil] head of
2372
+ # bucket 0 (default: the Unix epoch, or ISO Monday for a week bucket
2373
+ # on day-or-finer storage -- a week grid counts from the epoch
2374
+ # Thursday and cannot hold a Monday, so it keeps its own ticks).
2375
+ # It has to be a bucket head itself: on a calendar grid, the 1st at
2376
+ # 00:00.
1845
2377
  # @return [CArray] int64 timesteps.
1846
2378
  # @raise [ArgumentError] on a sub-resolution / unrepresentable (unit,
1847
2379
  # storage-resolution) pair or a lossy origin.
1848
- def timesteps(unit: self.unit, origin: nil)
1849
- g = _step_grid(unit, origin)
1850
- return _civil(:index, g[1], origin) if g[0] == :civil
2380
+ def timesteps(grid = nil, unit: nil, origin: nil)
2381
+ unit, origin = Grid.resolve(grid, unit, origin)
2382
+ unit = self.unit if unit.nil?
2383
+ g = resolve_timestep_grid(unit, origin)
2384
+ return calendar_bucket(:index, g[1], origin) if g[0] == :civil
1851
2385
  su, mul, step_ticks, o = g
1852
- _guard_overflow(su, mul, o)
1853
- d = _num_ticks(mul) - o
2386
+ raise_if_int64_overflow(su, mul, o)
2387
+ d = storage_ticks_in_numerator_grid(mul) - o
1854
2388
  q = d / step_ticks
1855
2389
  q - (d - q * step_ticks).lt(0) # floor-div correction (%-independent)
1856
2390
  end
@@ -1859,54 +2393,83 @@ class CATime
1859
2393
  # Returns each element floored to its bucket head (toward the past), as a
1860
2394
  # {CATime} in the same storage resolution.
1861
2395
  # @param unit [String, Symbol, Resolution] bucket resolution.
1862
- # @param origin [Time, String, CATime::Element, DateTime, nil] grid phase.
2396
+ # @param origin [Time, String, CATime::Element, DateTime, nil] head of
2397
+ # bucket 0 (default: the Unix epoch, or ISO Monday for a week bucket
2398
+ # on day-or-finer storage -- a week grid counts from the epoch
2399
+ # Thursday and cannot hold a Monday, so it keeps its own ticks).
2400
+ # It has to be a bucket head itself: on a calendar grid, the 1st at
2401
+ # 00:00.
2402
+ # @return [CATime]
2403
+ # @overload snap(grid = nil, unit:, origin: nil, direction: :round)
2404
+ # Returns each element snapped to a point on the bucket grid, as a
2405
+ # {CATime} in the same storage resolution. This is the time-domain twin
2406
+ # of {CArray#snap}: there, `(step, offset:, direction:)`; here a {Grid}
2407
+ # (= tick + origin), or the same pair spelled as `unit:` / `origin:`,
2408
+ # and the same three directions.
2409
+ #
2410
+ # `:round` picks the nearest bucket head, ties toward the future;
2411
+ # `:floor` the head at or before; `:ceil` the head at or after.
2412
+ # {#floor}, {#ceil} and {#round} are this method with `direction:` fixed.
2413
+ # @param grid [Grid, nil] the bucket grid, passed as one value.
2414
+ # @param unit [String, Symbol, Resolution, Grid] bucket resolution.
2415
+ # @param origin [Time, String, CATime::Element, DateTime, nil] head of
2416
+ # bucket 0 (default: the Unix epoch, or ISO Monday for a week bucket
2417
+ # on day-or-finer storage -- a week grid counts from the epoch
2418
+ # Thursday and cannot hold a Monday, so it keeps its own ticks).
2419
+ # It has to be a bucket head itself: on a calendar grid, the 1st at
2420
+ # 00:00.
2421
+ # @param direction [:round, :floor, :ceil] rounding rule.
1863
2422
  # @return [CATime]
1864
- def floor(unit:, origin: nil)
1865
- g = _step_grid(unit, origin)
1866
- return _civil(:floor, g[1], origin) if g[0] == :civil
2423
+ def snap(grid = nil, unit: nil, origin: nil, direction: :round)
2424
+ unless SNAP_DIRECTIONS.include?(direction)
2425
+ raise ArgumentError,
2426
+ "snap: direction must be :round / :floor / :ceil " \
2427
+ "(got #{direction.inspect})"
2428
+ end
2429
+ unit, origin = Grid.resolve(grid, unit, origin)
2430
+ g = resolve_timestep_grid(unit, origin)
2431
+ return calendar_bucket(direction, g[1], origin) if g[0] == :civil
1867
2432
  su, mul, step_ticks, o = g
1868
- _guard_overflow(su, mul, o, step_ticks, :floor)
1869
- d = _num_ticks(mul) - o
2433
+ raise_if_int64_overflow(su, mul, o, step_ticks, direction)
2434
+ n = storage_ticks_in_numerator_grid(mul)
2435
+ d = n - o
1870
2436
  q = d / step_ticks
1871
- q = q - (d - q * step_ticks).lt(0)
1872
- _head_time(o + q * step_ticks, mul, su)
2437
+ q = q - (d - q * step_ticks).lt(0) # floor bucket
2438
+ case direction
2439
+ when :ceil
2440
+ fl = o + q * step_ticks
2441
+ return bucket_head_as_time(fl + step_ticks * n.ne(fl), mul, su)
2442
+ when :round
2443
+ r = d - q * step_ticks # 0 <= r < step_ticks
2444
+ q = q + r.ge((step_ticks + 1) / 2) # ties -> future; no 2*d / 2*r
2445
+ end
2446
+ bucket_head_as_time(o + q * step_ticks, mul, su)
1873
2447
  end
1874
2448
 
1875
- # @overload ceil(unit:, origin: nil)
1876
- # Returns each element raised to its bucket head at or after it (an
1877
- # element already on a boundary maps to itself), as a {CATime}.
2449
+ # @overload floor(grid = nil, unit:, origin: nil)
2450
+ # {#snap} with `direction: :floor` -- each element at its bucket head
2451
+ # (toward the past), as a {CATime} in the same storage resolution.
1878
2452
  # @return [CATime]
1879
- def ceil(unit:, origin: nil)
1880
- g = _step_grid(unit, origin)
1881
- return _civil(:ceil, g[1], origin) if g[0] == :civil
1882
- su, mul, step_ticks, o = g
1883
- _guard_overflow(su, mul, o, step_ticks, :ceil)
1884
- n = _num_ticks(mul)
1885
- d = n - o
1886
- q = d / step_ticks
1887
- q = q - (d - q * step_ticks).lt(0)
1888
- fl = o + q * step_ticks
1889
- _head_time(fl + step_ticks * n.ne(fl), mul, su)
1890
- end
1891
-
1892
- # @overload round(unit:, origin: nil)
1893
- # Returns each element rounded to its nearest bucket head (ties toward
1894
- # the future, matching `snap` :round), as a {CATime}. Exact for
1895
- # odd `step_ticks` (no half-tick loss). For a calendar bucket the nearest
1896
- # head is by absolute tick distance (month lengths vary), ties toward the
1897
- # future.
2453
+ def floor(grid = nil, unit: nil, origin: nil)
2454
+ snap(grid, unit: unit, origin: origin, direction: :floor)
2455
+ end
2456
+
2457
+ # @overload ceil(grid = nil, unit:, origin: nil)
2458
+ # {#snap} with `direction: :ceil` -- each element at the bucket head at
2459
+ # or after it (an element already on a boundary maps to itself).
1898
2460
  # @return [CATime]
1899
- def round(unit:, origin: nil)
1900
- g = _step_grid(unit, origin)
1901
- return _civil(:round, g[1], origin) if g[0] == :civil
1902
- su, mul, step_ticks, o = g
1903
- _guard_overflow(su, mul, o, step_ticks, :round)
1904
- d = _num_ticks(mul) - o
1905
- q = d / step_ticks
1906
- q = q - (d - q * step_ticks).lt(0) # floor bucket
1907
- r = d - q * step_ticks # 0 <= r < step_ticks
1908
- q = q + r.ge((step_ticks + 1) / 2) # ties -> future; no 2*d / 2*r
1909
- _head_time(o + q * step_ticks, mul, su)
2461
+ def ceil(grid = nil, unit: nil, origin: nil)
2462
+ snap(grid, unit: unit, origin: origin, direction: :ceil)
2463
+ end
2464
+
2465
+ # @overload round(grid = nil, unit:, origin: nil)
2466
+ # {#snap} with `direction: :round` -- each element at its nearest bucket
2467
+ # head, ties toward the future. Exact for odd `step_ticks` (no half-tick
2468
+ # loss). For a calendar bucket the nearest head is by absolute tick
2469
+ # distance (month lengths vary), ties toward the future.
2470
+ # @return [CATime]
2471
+ def round(grid = nil, unit: nil, origin: nil)
2472
+ snap(grid, unit: unit, origin: origin, direction: :round)
1910
2473
  end
1911
2474
 
1912
2475
  # @overload is_righttime(unit:, origin: nil)
@@ -1914,33 +2477,57 @@ class CATime
1914
2477
  # head. Use as an assertion before matching to catch off-grid series (a
1915
2478
  # timesteps match is "same bucket", not "same instant").
1916
2479
  # @param unit [String, Symbol, Resolution] bucket resolution.
1917
- # @param origin [Time, String, CATime::Element, DateTime, nil] grid phase.
2480
+ # @param origin [Time, String, CATime::Element, DateTime, nil] head of
2481
+ # bucket 0 (default: the Unix epoch, or ISO Monday for a week bucket
2482
+ # on day-or-finer storage -- a week grid counts from the epoch
2483
+ # Thursday and cannot hold a Monday, so it keeps its own ticks).
2484
+ # It has to be a bucket head itself: on a calendar grid, the 1st at
2485
+ # 00:00.
1918
2486
  # @return [CArray] boolean.
1919
- def is_righttime(unit:, origin: nil)
1920
- g = _step_grid(unit, origin)
1921
- return _civil(:on, g[1], origin) if g[0] == :civil
2487
+ def is_righttime(grid = nil, unit: nil, origin: nil)
2488
+ unit, origin = Grid.resolve(grid, unit, origin)
2489
+ g = resolve_timestep_grid(unit, origin)
2490
+ return calendar_bucket(:on, g[1], origin) if g[0] == :civil
1922
2491
  su, mul, step_ticks, o = g
1923
- _guard_overflow(su, mul, o)
1924
- d = _num_ticks(mul) - o
2492
+ raise_if_int64_overflow(su, mul, o)
2493
+ d = storage_ticks_in_numerator_grid(mul) - o
1925
2494
  q = d / step_ticks
1926
2495
  (d - q * step_ticks).eq(0)
1927
2496
  end
1928
2497
 
1929
2498
  # @overload from_timesteps(k, unit:, origin: nil)
1930
2499
  # Inverse of {#timesteps}: returns the bucket-head time for timestep
1931
- # `k`, stored on the `unit` grid. Use to relabel a `group_by(timesteps)`
1932
- # result, generate a regular grid, or as a timesteps round-trip oracle.
2500
+ # `k`. Use to relabel a `group_by(timesteps)` result, generate a
2501
+ # regular grid, or as a timesteps round-trip oracle.
1933
2502
  # A scalar `k` returns a {Element}; a CArray `k` returns a {CATime}.
2503
+ #
2504
+ # The result is stored on the `unit` grid, except a week bucket, which
2505
+ # is stored on `:D`. A week grid counts from the epoch (a Thursday) and
2506
+ # so cannot hold its own bucket head: the head is the ISO Monday, four
2507
+ # days off every week tick. Days hold it exactly, so a `:W` bucket
2508
+ # answers on the day grid and the round trip against a day-or-finer
2509
+ # series is exact. `unit:` names the bucket here, the way it does for
2510
+ # {#floor} / {#ceil} -- not the storage the answer lands on.
1934
2511
  # @param k [Integer, CArray] timestep / timesteps.
1935
- # @param unit [String, Symbol, Resolution] grid resolution of the result.
1936
- # @param origin [Time, String, CATime::Element, DateTime, nil] grid phase.
1937
- # @return [Element, CATime]
1938
- def self.from_timesteps(k, unit:, origin: nil)
2512
+ # @param unit [String, Symbol, Resolution] bucket resolution.
2513
+ # @param origin [Time, String, CATime::Element, DateTime, nil] head of
2514
+ # bucket 0 (default: the Unix epoch, or ISO Monday for a week bucket).
2515
+ # It has to be a bucket head itself: on a calendar grid, the 1st at
2516
+ # 00:00.
2517
+ # @return [Element, CATime] on the `unit` grid (`:D` for a week bucket).
2518
+ def self.from_timesteps(k, grid = nil, unit: nil, origin: nil)
2519
+ grid = unit if unit.is_a?(Grid)
2520
+ # A Grid answers on its own storage, so an origin off the unit grid
2521
+ # ("12 hours since ... 09:00") survives; the keyword form keeps the
2522
+ # narrower rule it always had, where the origin sits on the unit itself.
2523
+ return grid.at(k) if grid.is_a?(Grid)
2524
+ unit, origin = Grid.resolve(grid, unit, origin)
1939
2525
  res = Resolution.parse(unit)
2526
+ out = res.base == :W ? Resolution.new(1, :D) : res
1940
2527
  kk = k.is_a?(CArray) ? k.int64 : CArray.int64(1) { Integer(k) }
1941
- _mul, step_ticks, o = _resolve_grid(res, res, origin) # step_ticks = 1 (same res)
2528
+ _mul, step_ticks, o = CATimeGrid.resolve_integer_grid(res, out, origin)
1942
2529
  raw = o + kk * step_ticks
1943
- k.is_a?(CArray) ? raw.time(unit: res) : Element.new(raw[0], res)
2530
+ k.is_a?(CArray) ? raw.time(unit: out) : Element.new(raw[0], out)
1944
2531
  end
1945
2532
 
1946
2533
  private
@@ -1952,19 +2539,19 @@ class CATime
1952
2539
  # per numerator tick) is 1 for a bucket at or coarser than the storage
1953
2540
  # tick, and the widening factor for a finer bucket. `origin_ticks` is
1954
2541
  # likewise in numerator ticks.
1955
- def _step_grid(step, origin)
2542
+ def resolve_timestep_grid(step, origin)
1956
2543
  storage_res = unit
1957
2544
  step_res = Resolution.parse(step)
1958
- scale = self.class.send(:_resolve_step_scale, step_res, storage_res)
2545
+ scale = CATimeGrid.resolve_step_scale(step_res, storage_res)
1959
2546
  return [:civil, step_res] if scale == :civil
1960
2547
  mul, step_ticks = scale
1961
2548
  num_res = mul == 1 ? storage_res : step_res
1962
- o = self.class.send(:_resolve_origin_ticks, origin, step_res, num_res)
2549
+ o = CATimeGrid.resolve_origin_ticks(origin, step_res, num_res)
1963
2550
  [storage_res, mul, step_ticks, o]
1964
2551
  end
1965
2552
 
1966
- # Storage ticks lifted into the numerator grid (see {#_step_grid}).
1967
- def _num_ticks(mul)
2553
+ # Storage ticks lifted into the numerator grid (see {#resolve_timestep_grid}).
2554
+ def storage_ticks_in_numerator_grid(mul)
1968
2555
  mul == 1 ? parent : CATimeUnitAlgebra.widen(parent, mul)
1969
2556
  end
1970
2557
 
@@ -1972,7 +2559,7 @@ class CATime
1972
2559
  # storage resolution. The division is exact: a numerator grid finer than
1973
2560
  # the storage tick only arises when every element already sits on it, so
1974
2561
  # each head is a whole number of storage ticks.
1975
- def _head_time(head, mul, su)
2562
+ def bucket_head_as_time(head, mul, su)
1976
2563
  (mul == 1 ? head : head / mul).time(unit: su)
1977
2564
  end
1978
2565
 
@@ -1993,20 +2580,24 @@ class CATime
1993
2580
  # widened (`mul` > 1) grid, where a coarse storage unit is lifted into a
1994
2581
  # much finer one; a pathological raw value wrapped in a coarse-unit Face is
1995
2582
  # not guarded (the gate is a value-range heuristic, not a proof).
1996
- def _guard_overflow(su, mul, o, step_ticks = nil, kind = nil)
2583
+ #
2584
+ # min / max skip masked cells, so a masked cell does not decide the range
2585
+ # (it carries no time), and answer UNDEF when there is nothing to bound --
2586
+ # an empty array, or one whose every cell is masked.
2587
+ def raise_if_int64_overflow(su, mul, o, step_ticks = nil, kind = nil)
1997
2588
  return unless mul > 1 or FINE_UNITS.include?(su.base)
1998
- return if parent.elements == 0
1999
- raw = parent.has_mask? ? parent.value : parent
2000
- lo = Integer(raw.min) * mul
2001
- hi = Integer(raw.max) * mul
2002
- chk = ->(v, w) { self.class.send(:_chk64, v, w) }
2589
+ min = parent.min
2590
+ return if min == UNDEF
2591
+ lo = Integer(min) * mul
2592
+ hi = Integer(parent.max) * mul
2593
+ chk = ->(v, w) { CATimeGrid.check_int64_range(v, w) }
2003
2594
  chk.(lo, "ticks in bucket resolution")
2004
2595
  chk.(hi, "ticks in bucket resolution")
2005
2596
  chk.(lo - o, "parent - origin")
2006
2597
  chk.(hi - o, "parent - origin")
2007
2598
  if kind
2008
- chk.(_bucket_head(lo, o, step_ticks, kind), "bucket head")
2009
- chk.(_bucket_head(hi, o, step_ticks, kind), "bucket head")
2599
+ chk.(bucket_head_ticks(lo, o, step_ticks, kind), "bucket head")
2600
+ chk.(bucket_head_ticks(hi, o, step_ticks, kind), "bucket head")
2010
2601
  end
2011
2602
  end
2012
2603
 
@@ -2014,7 +2605,7 @@ class CATime
2014
2605
  # scalar mirror of the array floor / ceil / round, used only by the overflow
2015
2606
  # guard. Ruby Integer `/` and `%` floor, so `(v - o) / s` is the floor
2016
2607
  # bucket directly.
2017
- def _bucket_head(v, o, s, kind)
2608
+ def bucket_head_ticks(v, o, s, kind)
2018
2609
  fq = (v - o) / s
2019
2610
  case kind
2020
2611
  when :floor then o + fq * s
@@ -2026,27 +2617,27 @@ class CATime
2026
2617
  # Days since the Unix epoch of the date each element represents (mask
2027
2618
  # propagates). Calendar units (:Y / :M) resolve to day 1 of their year /
2028
2619
  # month; a week is 7 days; day-or-finer units floor to the day.
2029
- def _field_days
2620
+ def days_since_epoch
2030
2621
  res = unit
2031
2622
  case res.base
2032
2623
  when :Y
2033
2624
  ones = CArray.int64(*shape) { 1 }
2034
- self.class.send(:_days_from_civil, parent * res.count + 1970, ones, ones)
2625
+ CATimeCivil.days_from_civil(parent * res.count + 1970, ones, ones)
2035
2626
  when :M
2036
2627
  mo = parent * res.count + 1970 * 12
2037
- y = self.class.send(:_floordiv_i, mo, 12)
2628
+ y = mo / 12
2038
2629
  m = mo - y * 12 + 1
2039
- self.class.send(:_days_from_civil, y, m, CArray.int64(*shape) { 1 })
2630
+ CATimeCivil.days_from_civil(y, m, CArray.int64(*shape) { 1 })
2040
2631
  when :W then parent * res.count * 7
2041
2632
  when :D then parent * res.count
2042
2633
  else # fixed sub-day storage
2043
2634
  day = CATime::Resolution.new(1, :D)
2044
2635
  r = CATimeUnitAlgebra.ratio(day, res) # storage ticks per day
2045
2636
  if r.denominator == 1
2046
- self.class.send(:_floordiv_i, parent, r.numerator)
2637
+ parent / r.numerator
2047
2638
  else # non-day-aligned tick
2048
2639
  tr = res.tick_ratio # seconds / tick
2049
- self.class.send(:_floordiv_i, parent * tr.numerator, 86400 * tr.denominator)
2640
+ parent * tr.numerator / (86400 * tr.denominator)
2050
2641
  end
2051
2642
  end
2052
2643
  end
@@ -2057,13 +2648,13 @@ class CATime
2057
2648
  # coarser than `fu` both resolve exactly (a 10-minute grid gives minute 0 /
2058
2649
  # 10 / 20 / ...; a day grid collapses hour / minute / second to 0). A
2059
2650
  # calendar-storage element has no intra-day field, so it collapses to 0.
2060
- def _clock_field(fu)
2651
+ def clock_field(fu)
2061
2652
  r = CATimeUnitAlgebra.ratio(unit, CATime::Resolution.new(1, fu)) # fu ticks / storage tick
2062
2653
  return parent * 0 if r.nil? # calendar storage: no clock field
2063
2654
  fi = r.denominator == 1 ? parent * r.numerator # epoch-relative fu index
2064
- : self.class.send(:_floordiv_i, parent * r.numerator, r.denominator)
2655
+ : parent * r.numerator / r.denominator
2065
2656
  cycle = fu == :h ? 24 : 60 # hour 0..23; min/sec 0..59
2066
- fi - self.class.send(:_floordiv_i, fi, cycle) * cycle # floor-mod (handles pre-epoch)
2657
+ fi % cycle
2067
2658
  end
2068
2659
 
2069
2660
  # Calendar path (Y/M step on day-or-finer storage) via civil-date integer
@@ -2071,19 +2662,19 @@ class CATime
2071
2662
  # min-max, O(N) branch-free, origin-absolute k (negative pre-origin). The
2072
2663
  # origin's day / time is ignored (month ordinal only, per the design).
2073
2664
  # `kind`: :index / :floor / :ceil / :round / :on.
2074
- def _civil(kind, st, origin)
2665
+ def calendar_bucket(kind, st, origin)
2075
2666
  su = unit # storage Resolution
2076
2667
  count = st.count * (st.base == :Y ? 12 : 1) # bucket in months
2077
- ym0 = self.class.send(:_origin_month_ordinal, origin)
2078
- days = _field_days # count-folded day index
2079
- y, m = self.class.send(:_civil_from_days, days)
2668
+ ym0 = CATimeGrid.origin_month_ordinal(origin)
2669
+ days = days_since_epoch # count-folded day index
2670
+ y, m = CATimeCivil.civil_from_days(days)
2080
2671
  ym = y * 12 + (m - 1)
2081
- k = self.class.send(:_floordiv_i, ym - ym0, count)
2672
+ k = (ym - ym0) / count
2082
2673
  return k if kind == :index
2083
- head = self.class.send(:_civil_head_ticks, k, st, su, origin)
2674
+ head = CATimeGrid.calendar_bucket_head_ticks(k, st, su, origin)
2084
2675
  return head.time(unit: su) if kind == :floor
2085
2676
  return parent.eq(head) if kind == :on
2086
- head_next = self.class.send(:_civil_head_ticks, k + 1, st, su, origin)
2677
+ head_next = CATimeGrid.calendar_bucket_head_ticks(k + 1, st, su, origin)
2087
2678
  sel = # boolean 0/1: pick head_next?
2088
2679
  case kind
2089
2680
  when :ceil then parent.ne(head) # on-boundary keeps head
@@ -2092,198 +2683,6 @@ class CATime
2092
2683
  (head + (head_next - head) * sel).time(unit: su)
2093
2684
  end
2094
2685
 
2095
- class << self
2096
- private
2097
-
2098
- INT64_MIN = -(2**63)
2099
- INT64_MAX = 2**63 - 1
2100
-
2101
- # Raise (rather than let an int64 CArray operand silently wrap) if a
2102
- # Ruby-domain quantity is out of int64 range. Returns the value on success
2103
- # so it composes inline.
2104
- def _chk64(v, what)
2105
- if v < INT64_MIN || v > INT64_MAX
2106
- raise RangeError,
2107
- "time step: #{what} = #{v} overflows int64 " \
2108
- "(the storage unit is too fine for this step / origin / span; " \
2109
- "use a coarser unit)"
2110
- end
2111
- v
2112
- end
2113
-
2114
- # Return [step_ticks, origin_ticks] (both Integer, in storage ticks) for
2115
- # the integer path. The calendar path is handled by the caller.
2116
- def _resolve_grid(step_res, storage_res, origin)
2117
- mul, step_ticks = _resolve_step_scale(step_res, storage_res)
2118
- [mul, step_ticks, _resolve_origin_ticks(origin, step_res,
2119
- mul == 1 ? storage_res : step_res)]
2120
- end
2121
-
2122
- # Storage ticks per day of `storage_res` (must be a whole number, else the
2123
- # storage grid does not tile a day -- a calendar bucket is unrepresentable).
2124
- def _ticks_per_day(storage_res)
2125
- r = CATimeUnitAlgebra.ratio(Resolution.new(1, :D), storage_res)
2126
- unless r.denominator == 1
2127
- raise ArgumentError,
2128
- "cannot place a calendar bucket on storage resolution " \
2129
- "#{storage_res} (its tick does not tile a day)"
2130
- end
2131
- _chk64(r.numerator, "ticks per day for #{storage_res}")
2132
- end
2133
-
2134
- # Storage ticks of the bucket head at timestep `k` (int64 CArray) for a
2135
- # calendar bucket: ym0 + k*count months -> day 1 of that month -> ticks.
2136
- def _civil_head_ticks(k, st, storage_res, origin)
2137
- tpd = _ticks_per_day(storage_res)
2138
- count = st.count * (st.base == :Y ? 12 : 1)
2139
- ym0 = _origin_month_ordinal(origin)
2140
- ymk = ym0 + k * count
2141
- yy = _floordiv_i(ymk, 12)
2142
- mm = ymk - yy * 12 + 1
2143
- _days_from_civil(yy, mm, CArray.int64(*k.shape) { 1 }) * tpd
2144
- end
2145
-
2146
- # Month ordinal (year*12 + month-1) of origin; day / time ignored.
2147
- # Default (nil) is the epoch month 1970-01.
2148
- def _origin_month_ordinal(origin)
2149
- return 1970 * 12 if origin.nil?
2150
- y, m = _origin_year_month(origin)
2151
- y * 12 + (m - 1)
2152
- end
2153
-
2154
- # Floored integer division of an int64 CArray by a positive Integer
2155
- # (toward -inf; CArray `/` truncates toward zero).
2156
- def _floordiv_i(a, b)
2157
- q = a / b
2158
- q - (a - q * b).lt(0)
2159
- end
2160
-
2161
- # Vectorized Howard Hinnant civil-date algebra (proleptic Gregorian,
2162
- # negative days supported). days since the Unix epoch -> [year, month,
2163
- # day] int64 CArrays. Truncating division is what the algorithm assumes;
2164
- # the only negative operand (the 400-year era) is pre-adjusted so
2165
- # truncation behaves like floor.
2166
- def _civil_from_days(z)
2167
- z = z + 719468
2168
- era = (z - 146096 * z.lt(0)) / 146097
2169
- doe = z - era * 146097
2170
- yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
2171
- y = yoe + era * 400
2172
- doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
2173
- mp = (5 * doy + 2) / 153
2174
- d = doy - (153 * mp + 2) / 5 + 1 # day of month, 1..31
2175
- m = mp + (3 - 12 * mp.ge(10)) # mp<10 ? +3 : -9
2176
- [y + m.le(2), m, d]
2177
- end
2178
-
2179
- # [year, month, day] int64 CArrays -> days since the Unix epoch.
2180
- def _days_from_civil(y, m, d)
2181
- y = y - m.le(2)
2182
- era = (y - 399 * y.lt(0)) / 400
2183
- yoe = y - era * 400
2184
- doy = (153 * (m + (9 - 12 * m.gt(2))) + 2) / 5 + (d - 1) # m>2 ? -3 : +9
2185
- doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
2186
- era * 146097 + doe - 719468
2187
- end
2188
-
2189
- # [mul, step_ticks] for the integer path, or :civil (calendar bucket on
2190
- # day-or-finer storage), or raise. Bucket arithmetic runs in the finer of
2191
- # the two grids, so exactly one of the pair is > 1:
2192
- # - bucket at or coarser than the storage tick (a :h bucket on :s
2193
- # storage) -> [1, N]: N storage ticks per bucket.
2194
- # - bucket finer than the storage tick (a :h bucket on :D storage) ->
2195
- # [N, 1]: one storage tick spans N buckets exactly, so a timestep is a
2196
- # plain widening. This needs the storage tick to be a *whole* multiple
2197
- # of the bucket tick; a partial multiple ("90 minutes" storage against
2198
- # an :h bucket) has no integer timestep and raises.
2199
- def _resolve_step_scale(step_res, storage_res)
2200
- r = CATimeUnitAlgebra.ratio(step_res, storage_res) # storage ticks / step tick
2201
- if r
2202
- what = "bucket #{step_res} in ticks of #{storage_res}"
2203
- return [1, _chk64(r.numerator, what)] if r.denominator == 1
2204
- return [_chk64(r.denominator, what), 1] if r.numerator == 1
2205
- end
2206
- if CATimeUnitAlgebra::CALENDAR.key?(step_res.base) && SU_LE_DAY.include?(storage_res.base)
2207
- return :civil
2208
- end
2209
- raise ArgumentError,
2210
- "cannot express bucket #{step_res} on storage resolution #{storage_res} " \
2211
- "(neither is a whole multiple of the other)"
2212
- end
2213
-
2214
- # origin -> integer tick count in storage ticks. Default (nil) is the
2215
- # epoch, except a week bucket defaults to ISO Monday (1970-01-05). A lossy
2216
- # conversion (origin not landing exactly on the storage grid) raises; a
2217
- # bare Integer is rejected (epoch-dependent, ambiguous).
2218
- def _resolve_origin_ticks(origin, step_res, storage_res)
2219
- if origin.nil?
2220
- if step_res.base == :W && SU_LE_DAY.include?(storage_res.base)
2221
- return _chk64(_ticks_per_day(storage_res) * 4,
2222
- "ISO-Monday week origin in ticks of #{storage_res}")
2223
- end
2224
- return 0
2225
- end
2226
- if CATimeUnitAlgebra::FIXED.key?(storage_res.base)
2227
- secs = _origin_seconds_exact(origin)
2228
- tick = secs / storage_res.tick_ratio # Rational
2229
- unless tick.denominator == 1
2230
- raise ArgumentError,
2231
- "origin #{origin.inspect} is not representable losslessly " \
2232
- "in storage resolution #{storage_res} (would truncate the grid phase)"
2233
- end
2234
- _chk64(tick.numerator, "origin #{origin.inspect} in ticks of #{storage_res}")
2235
- else # :Y / :M storage
2236
- y, m = _origin_year_month(origin) # day / time ignored
2237
- ord = storage_res.base == :Y ? (y - 1970) : (y * 12 + (m - 1) - 1970 * 12)
2238
- if storage_res.count > 1
2239
- unless (ord % storage_res.count).zero?
2240
- raise ArgumentError,
2241
- "origin #{origin.inspect} is not on the #{storage_res} grid"
2242
- end
2243
- ord /= storage_res.count
2244
- end
2245
- ord
2246
- end
2247
- end
2248
-
2249
- # Exact Rational seconds since the Unix epoch for a fixed-storage origin.
2250
- # Time / String / DateTime go through the DateTime-independent parser.
2251
- def _origin_seconds_exact(origin)
2252
- case origin
2253
- when CATime::Element
2254
- unless CATimeUnitAlgebra::FIXED.key?(origin.unit.base)
2255
- raise ArgumentError,
2256
- "origin scalar in calendar unit #{origin.unit} has no exact " \
2257
- "seconds; use a fixed-unit scalar / Time / String"
2258
- end
2259
- Rational(origin.value) * origin.unit.tick_ratio
2260
- when Integer
2261
- raise ArgumentError,
2262
- "origin: a bare Integer is ambiguous (epoch-dependent); pass a " \
2263
- "Time / String / CATime scalar"
2264
- else
2265
- CArray._epoch_seconds_exact(origin) # Time / String / DateTime
2266
- end
2267
- end
2268
-
2269
- # [year, month] of a calendar-storage origin (finer fields ignored).
2270
- def _origin_year_month(origin)
2271
- case origin
2272
- when CATime::Element
2273
- case origin.unit.base
2274
- when :M then mo = origin.value * origin.unit.count + 1970 * 12; [mo / 12, mo % 12 + 1]
2275
- when :Y then [origin.value * origin.unit.count + 1970, 1]
2276
- else t = origin.to_time.utc; [t.year, t.month]
2277
- end
2278
- when Integer
2279
- raise ArgumentError,
2280
- "origin: a bare Integer is ambiguous (epoch-dependent); pass a " \
2281
- "Time / String / CATime scalar"
2282
- else
2283
- CArray._epoch_year_month(origin) # Time / String / DateTime
2284
- end
2285
- end
2286
- end
2287
2686
  end
2288
2687
 
2289
2688
  # The view-creating method lift hook lives in the C layer (= subclass-