carray 3.0.0 → 3.0.2

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