carray 2.0.1 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (386) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -25
  3. data/CHANGELOG.md +338 -0
  4. data/{NEWS.md → CHANGELOG.v1.md} +3 -0
  5. data/LICENSE +1 -1
  6. data/README.md +120 -36
  7. data/carray.gemspec +32 -30
  8. data/ext/ca_array_pool.c +91 -0
  9. data/ext/ca_axis_descriptor.h +186 -0
  10. data/ext/ca_axis_dispatch.c +924 -0
  11. data/ext/ca_axis_group.c +1244 -0
  12. data/ext/ca_bincmp_dispatch.c +76 -0
  13. data/ext/ca_bincmp_dispatch.h +85 -0
  14. data/ext/ca_binop_dispatch.c +124 -0
  15. data/ext/ca_binop_dispatch.h +152 -0
  16. data/ext/ca_categorical_iterator.c +1375 -0
  17. data/ext/ca_compare.c +94 -0
  18. data/ext/ca_compare.h +26 -0
  19. data/ext/ca_composite_dispatch.c +414 -0
  20. data/ext/ca_composite_dispatch.h +116 -0
  21. data/ext/ca_for_buffer.h +96 -0
  22. data/ext/ca_for_each_element.h +239 -0
  23. data/ext/ca_group_iter.c +304 -0
  24. data/ext/ca_iter_substrate.h +325 -0
  25. data/ext/ca_kernel_iterator.c +4367 -0
  26. data/ext/ca_kernel_iterator.h +2596 -0
  27. data/ext/ca_moncmp_dispatch.c +37 -0
  28. data/ext/ca_moncmp_dispatch.h +62 -0
  29. data/ext/ca_monop_dispatch.c +200 -0
  30. data/ext/ca_monop_dispatch.h +235 -0
  31. data/ext/ca_obj_array.c +355 -359
  32. data/ext/ca_obj_bincmp.c +839 -0
  33. data/ext/ca_obj_binop.c +948 -0
  34. data/ext/ca_obj_bitarray.c +369 -164
  35. data/ext/ca_obj_bitfield.c +294 -234
  36. data/ext/ca_obj_block.c +189 -711
  37. data/ext/ca_obj_byte_swap.c +766 -0
  38. data/ext/ca_obj_const_string.c +967 -0
  39. data/ext/ca_obj_face.c +750 -0
  40. data/ext/ca_obj_face.h +279 -0
  41. data/ext/ca_obj_fake.c +239 -100
  42. data/ext/ca_obj_farray.c +54 -441
  43. data/ext/ca_obj_field.c +82 -529
  44. data/ext/ca_obj_fixlen_string.c +308 -0
  45. data/ext/ca_obj_grid.c +866 -440
  46. data/ext/ca_obj_meld.c +1039 -0
  47. data/ext/ca_obj_moncmp.c +588 -0
  48. data/ext/ca_obj_monop.c +1123 -0
  49. data/ext/ca_obj_object.c +866 -296
  50. data/ext/ca_obj_record.c +470 -0
  51. data/ext/ca_obj_reduce.c +97 -82
  52. data/ext/ca_obj_refer.c +593 -459
  53. data/ext/ca_obj_remap.c +475 -0
  54. data/ext/ca_obj_repeat.c +92 -477
  55. data/ext/ca_obj_roll.c +624 -0
  56. data/ext/ca_obj_select.c +344 -296
  57. data/ext/ca_obj_select_axis.c +1306 -0
  58. data/ext/ca_obj_shift.c +231 -793
  59. data/ext/ca_obj_source.c +78 -0
  60. data/ext/ca_obj_stack.c +1173 -0
  61. data/ext/ca_obj_stride.c +2584 -0
  62. data/ext/ca_obj_string.c +270 -0
  63. data/ext/ca_obj_tile.c +622 -0
  64. data/ext/ca_obj_time.c +548 -0
  65. data/ext/ca_obj_timedelta.c +437 -0
  66. data/ext/ca_obj_transpose.c +62 -516
  67. data/ext/ca_obj_triop.c +785 -0
  68. data/ext/ca_obj_window.c +1202 -565
  69. data/ext/ca_op_byte_swap.c +175 -0
  70. data/ext/ca_op_cmplx64.h +123 -0
  71. data/ext/ca_op_ipower.c +316 -0
  72. data/ext/ca_op_powi.h +88 -0
  73. data/ext/ca_sort_kernels.h +132 -0
  74. data/ext/ca_sweep_engine.c +473 -0
  75. data/ext/ca_sweep_engine.h +166 -0
  76. data/ext/ca_transform_common.c +235 -0
  77. data/ext/ca_triop_dispatch.c +55 -0
  78. data/ext/ca_triop_dispatch.h +62 -0
  79. data/ext/carray.h +810 -420
  80. data/ext/carray_access.c +873 -731
  81. data/ext/carray_attribute.c +98 -329
  82. data/ext/carray_bincount.c +255 -0
  83. data/ext/carray_broadcast.c +376 -0
  84. data/ext/carray_build_flags.h +3 -0
  85. data/ext/carray_call_cfunc.c +2897 -874
  86. data/ext/carray_call_cfunc.h +313 -0
  87. data/ext/carray_cast.c +1264 -315
  88. data/ext/carray_cast_func.rb +81 -40
  89. data/ext/carray_class.c +53 -63
  90. data/ext/carray_config.h +28 -0
  91. data/ext/carray_conversion.c +350 -346
  92. data/ext/carray_copy.c +168 -270
  93. data/ext/carray_core.c +1396 -206
  94. data/ext/carray_count.c +312 -0
  95. data/ext/carray_data_type.c +43 -19
  96. data/ext/carray_element.c +585 -213
  97. data/ext/carray_factorize.c +2542 -0
  98. data/ext/carray_generate.c +230 -559
  99. data/ext/carray_histogram.c +490 -0
  100. data/ext/carray_hold.c +228 -0
  101. data/ext/carray_index_classifier.c +1021 -0
  102. data/ext/carray_index_classifier.h +27 -0
  103. data/ext/carray_internal.h +136 -0
  104. data/ext/carray_kernels_bincmp.c +4446 -0
  105. data/ext/carray_kernels_binop.c +11001 -0
  106. data/ext/carray_kernels_init.c +1131 -0
  107. data/ext/carray_kernels_map.c +3467 -0
  108. data/ext/carray_kernels_moncmp.c +2097 -0
  109. data/ext/carray_kernels_monop.c +18313 -0
  110. data/ext/carray_kernels_reduce_aggregate.c +25837 -0
  111. data/ext/carray_kernels_reduce_boolean.c +330 -0
  112. data/ext/carray_kernels_reduce_cumulative.c +14593 -0
  113. data/ext/carray_kernels_reduce_extreme.c +16948 -0
  114. data/ext/carray_kernels_reduce_variance.c +3910 -0
  115. data/ext/carray_kernels_scan.c +3693 -0
  116. data/ext/carray_kernels_search.c +32138 -0
  117. data/ext/carray_kernels_sort.c +10626 -0
  118. data/ext/carray_kernels_triop.c +1392 -0
  119. data/ext/carray_lazy.c +737 -0
  120. data/ext/carray_loop.c +88 -200
  121. data/ext/carray_mask.c +853 -158
  122. data/ext/carray_math_kernel.h +120 -0
  123. data/ext/carray_mathfunc.c +10 -241
  124. data/ext/carray_median_percentile.c +1257 -0
  125. data/ext/carray_memory_view.c +1650 -0
  126. data/ext/carray_operator.c +1525 -320
  127. data/ext/carray_order.c +664 -1394
  128. data/ext/carray_partition.c +416 -0
  129. data/ext/carray_random.c +518 -0
  130. data/ext/carray_scatter.c +357 -0
  131. data/ext/carray_slab.c +1219 -0
  132. data/ext/carray_slab.h +84 -0
  133. data/ext/carray_sort.c +829 -0
  134. data/ext/carray_sort_kernel.c +620 -0
  135. data/ext/carray_struct.c +695 -0
  136. data/ext/carray_test.c +343 -229
  137. data/ext/carray_undef.c +34 -17
  138. data/ext/carray_utils.c +175 -74
  139. data/ext/extconf.rb +234 -55
  140. data/ext/mk_call_cfunc.rb +671 -0
  141. data/ext/mkkernel.rb +9096 -0
  142. data/ext/ruby_carray.c +211 -108
  143. data/ext/version.h +4 -14
  144. data/ext/version.rb +5 -13
  145. data/lib/carray/arrow_tensor.rb +401 -0
  146. data/lib/carray/attribute.rb +166 -0
  147. data/lib/carray/autoload_carray.rb +239 -0
  148. data/lib/carray/autoload_method_extension.rb +45 -0
  149. data/lib/carray/axis_group.rb +711 -0
  150. data/lib/carray/basics.rb +481 -0
  151. data/lib/carray/bincount_nd.rb +358 -0
  152. data/lib/carray/block_iterator.rb +614 -0
  153. data/lib/carray/boolean_reduce.rb +109 -0
  154. data/lib/carray/categorical.rb +561 -0
  155. data/lib/carray/categorical_iterator.rb +1084 -0
  156. data/lib/carray/complex.rb +150 -0
  157. data/lib/carray/conditional.rb +216 -0
  158. data/lib/carray/const_string.rb +228 -0
  159. data/lib/carray/construct.rb +160 -328
  160. data/lib/carray/core_extensions.rb +297 -0
  161. data/lib/carray/data_type_extension.rb +250 -0
  162. data/lib/carray/fixlen_string.rb +95 -0
  163. data/lib/carray/frame/concat.rb +132 -0
  164. data/lib/carray/frame/convert.rb +95 -0
  165. data/lib/carray/frame/csv_parser.rb +211 -0
  166. data/lib/carray/frame/frame.rb +642 -0
  167. data/lib/carray/frame/group.rb +186 -0
  168. data/lib/carray/frame/io.rb +321 -0
  169. data/lib/carray/frame/join.rb +248 -0
  170. data/lib/carray/frame/records.rb +99 -0
  171. data/lib/carray/frame/sort.rb +113 -0
  172. data/lib/carray/frame/verbs.rb +316 -0
  173. data/lib/carray/frame.rb +16 -0
  174. data/lib/carray/fuse_source.rb +123 -0
  175. data/lib/carray/fusion.rb +218 -0
  176. data/lib/carray/histogram.rb +512 -0
  177. data/lib/carray/inspect.rb +37 -26
  178. data/lib/carray/iterator.rb +58 -349
  179. data/lib/carray/lazy.rb +941 -0
  180. data/lib/carray/mask_gap_fill.rb +200 -0
  181. data/lib/carray/math.rb +78 -342
  182. data/lib/carray/meld_reduce.rb +289 -0
  183. data/lib/carray/methods/align_addr.rb +116 -0
  184. data/lib/carray/methods/bin.rb +128 -0
  185. data/lib/carray/methods/bincount.rb +87 -0
  186. data/lib/carray/methods/bit_string.rb +92 -0
  187. data/lib/carray/methods/broadcast.rb +63 -0
  188. data/lib/carray/methods/choose.rb +39 -0
  189. data/lib/carray/methods/composition.rb +280 -0
  190. data/lib/carray/methods/gather_nd.rb +206 -0
  191. data/lib/carray/methods/index.rb +39 -0
  192. data/lib/carray/methods/insert_block.rb +99 -0
  193. data/lib/carray/methods/is_in.rb +141 -0
  194. data/lib/carray/methods/join.rb +90 -0
  195. data/lib/carray/methods/locate_addr.rb +52 -0
  196. data/lib/carray/methods/mask_duplicates.rb +41 -0
  197. data/lib/carray/methods/meshgrid.rb +90 -0
  198. data/lib/carray/methods/mode.rb +126 -0
  199. data/lib/carray/methods/nunique.rb +46 -0
  200. data/lib/carray/methods/resize.rb +56 -0
  201. data/lib/carray/methods/snap.rb +161 -0
  202. data/lib/carray/methods/string_format.rb +57 -0
  203. data/lib/carray/methods/unique.rb +47 -0
  204. data/lib/carray/methods/value_counts.rb +71 -0
  205. data/lib/carray/mkmf.rb +124 -101
  206. data/lib/carray/runtime.rb +89 -0
  207. data/lib/carray/serialize.rb +478 -167
  208. data/lib/carray/slab_iterator.rb +305 -0
  209. data/lib/carray/stack.rb +291 -0
  210. data/lib/carray/string.rb +56 -180
  211. data/lib/carray/string_operation_extension.rb +289 -0
  212. data/lib/carray/struct.rb +335 -323
  213. data/lib/carray/struct_builder.rb +697 -0
  214. data/lib/carray/table.rb +41 -2
  215. data/lib/carray/time.rb +2654 -38
  216. data/lib/carray/window_iterator.rb +927 -0
  217. data/lib/carray.rb +55 -57
  218. data/yard-stubs/ca_obj_array.rb +385 -0
  219. data/yard-stubs/ca_obj_bitarray.rb +38 -0
  220. data/yard-stubs/ca_obj_bitfield.rb +43 -0
  221. data/yard-stubs/ca_obj_block.rb +73 -0
  222. data/yard-stubs/ca_obj_byte_swap.rb +56 -0
  223. data/yard-stubs/ca_obj_fake.rb +31 -0
  224. data/yard-stubs/ca_obj_farray.rb +32 -0
  225. data/yard-stubs/ca_obj_field.rb +45 -0
  226. data/yard-stubs/ca_obj_grid.rb +35 -0
  227. data/yard-stubs/ca_obj_refer.rb +72 -0
  228. data/yard-stubs/ca_obj_roll.rb +45 -0
  229. data/yard-stubs/ca_obj_shift.rb +43 -0
  230. data/yard-stubs/ca_obj_stride.rb +181 -0
  231. data/yard-stubs/ca_obj_tile.rb +29 -0
  232. data/yard-stubs/ca_obj_transpose.rb +40 -0
  233. data/yard-stubs/ca_obj_window.rb +49 -0
  234. data/yard-stubs/carray_access.rb +131 -0
  235. data/yard-stubs/carray_attribute.rb +246 -0
  236. data/yard-stubs/carray_broadcast.rb +37 -0
  237. data/yard-stubs/carray_cast.rb +489 -0
  238. data/yard-stubs/carray_class.rb +65 -0
  239. data/yard-stubs/carray_conversion.rb +76 -0
  240. data/yard-stubs/carray_copy.rb +79 -0
  241. data/yard-stubs/carray_core.rb +114 -0
  242. data/yard-stubs/carray_count.rb +79 -0
  243. data/yard-stubs/carray_element.rb +108 -0
  244. data/yard-stubs/carray_generate.rb +66 -0
  245. data/yard-stubs/carray_lazy.rb +23 -0
  246. data/yard-stubs/carray_loop.rb +140 -0
  247. data/yard-stubs/carray_mask.rb +259 -0
  248. data/yard-stubs/carray_math.rb +132 -0
  249. data/yard-stubs/carray_mathfunc.rb +45 -0
  250. data/yard-stubs/carray_median_percentile.rb +89 -0
  251. data/yard-stubs/carray_memory_view.rb +163 -0
  252. data/yard-stubs/carray_order.rb +312 -0
  253. data/yard-stubs/carray_random.rb +89 -0
  254. data/yard-stubs/carray_scatter.rb +106 -0
  255. data/yard-stubs/carray_slab.rb +57 -0
  256. data/yard-stubs/carray_sort.rb +163 -0
  257. data/yard-stubs/carray_test.rb +85 -0
  258. data/yard-stubs/carray_undef.rb +64 -0
  259. data/yard-stubs/carray_utils.rb +97 -0
  260. data/yard-stubs/ruby_carray.rb +193 -0
  261. metadata +220 -138
  262. data/Rakefile +0 -51
  263. data/TODO.md +0 -18
  264. data/ext/ca_iter_block.c +0 -257
  265. data/ext/ca_iter_dimension.c +0 -299
  266. data/ext/ca_iter_window.c +0 -214
  267. data/ext/ca_obj_mapping.c +0 -644
  268. data/ext/ca_obj_unbound_repeat.c +0 -529
  269. data/ext/carray_iterator.c +0 -641
  270. data/ext/carray_math.rb +0 -850
  271. data/ext/carray_numeric.c +0 -259
  272. data/ext/carray_sort_addr.c +0 -254
  273. data/ext/carray_stat.c +0 -2100
  274. data/ext/carray_stat_proc.rb +0 -1999
  275. data/ext/mkmath.rb +0 -741
  276. data/ext/ruby_ccomplex.c +0 -509
  277. data/ext/ruby_float_func.c +0 -86
  278. data/lib/carray/array.rb +0 -8
  279. data/lib/carray/autoload/autoload_base.rb +0 -19
  280. data/lib/carray/autoload/autoload_gem_cairo.rb +0 -9
  281. data/lib/carray/autoload/autoload_gem_ffi.rb +0 -9
  282. data/lib/carray/autoload/autoload_gem_gnuplot.rb +0 -2
  283. data/lib/carray/autoload/autoload_gem_io_csv.rb +0 -14
  284. data/lib/carray/autoload/autoload_gem_io_pg.rb +0 -6
  285. data/lib/carray/autoload/autoload_gem_io_sqlite3.rb +0 -12
  286. data/lib/carray/autoload/autoload_gem_narray.rb +0 -10
  287. data/lib/carray/autoload/autoload_gem_numo_narray.rb +0 -15
  288. data/lib/carray/autoload/autoload_gem_opencv.rb +0 -16
  289. data/lib/carray/autoload/autoload_gem_random.rb +0 -8
  290. data/lib/carray/autoload/autoload_gem_rmagick.rb +0 -23
  291. data/lib/carray/autoload/autoload_gem_zimg.rb +0 -3
  292. data/lib/carray/autoload/autoload_io_imagemagick.rb +0 -6
  293. data/lib/carray/autoload/autoload_math_histogram.rb +0 -5
  294. data/lib/carray/autoload/autoload_math_recurrence.rb +0 -6
  295. data/lib/carray/autoload/autoload_object_iterator.rb +0 -1
  296. data/lib/carray/autoload/autoload_object_link.rb +0 -1
  297. data/lib/carray/autoload/autoload_object_pack.rb +0 -2
  298. data/lib/carray/autoload.rb +0 -141
  299. data/lib/carray/basic.rb +0 -191
  300. data/lib/carray/broadcast.rb +0 -101
  301. data/lib/carray/compose.rb +0 -315
  302. data/lib/carray/convert.rb +0 -115
  303. data/lib/carray/info.rb +0 -110
  304. data/lib/carray/io/imagemagick.rb +0 -235
  305. data/lib/carray/mask.rb +0 -102
  306. data/lib/carray/math/histogram.rb +0 -177
  307. data/lib/carray/math/recurrence.rb +0 -93
  308. data/lib/carray/object/ca_obj_iterator.rb +0 -50
  309. data/lib/carray/object/ca_obj_link.rb +0 -50
  310. data/lib/carray/object/ca_obj_pack.rb +0 -99
  311. data/lib/carray/obsolete.rb +0 -256
  312. data/lib/carray/ordering.rb +0 -181
  313. data/lib/carray/testing.rb +0 -51
  314. data/lib/carray/transform.rb +0 -109
  315. data/mailmap +0 -1
  316. data/misc/Methods.ja.md +0 -182
  317. data/misc/NOTE +0 -51
  318. data/spec/Classes/CABitfield_spec.rb +0 -58
  319. data/spec/Classes/CABlockIterator_spec.rb +0 -114
  320. data/spec/Classes/CABlock_spec.rb +0 -205
  321. data/spec/Classes/CAField_spec.rb +0 -39
  322. data/spec/Classes/CAGrid_spec.rb +0 -75
  323. data/spec/Classes/CAMap_spec.rb +0 -0
  324. data/spec/Classes/CAMapping_spec.rb +0 -105
  325. data/spec/Classes/CAObject_attribute_spec.rb +0 -33
  326. data/spec/Classes/CAObject_spec.rb +0 -33
  327. data/spec/Classes/CARefer_spec.rb +0 -93
  328. data/spec/Classes/CARepeat_spec.rb +0 -65
  329. data/spec/Classes/CASelect_spec.rb +0 -22
  330. data/spec/Classes/CAShift_spec.rb +0 -16
  331. data/spec/Classes/CAStruct_spec.rb +0 -71
  332. data/spec/Classes/CATranspose_spec.rb +0 -60
  333. data/spec/Classes/CAUnboudRepeat_spec.rb +0 -102
  334. data/spec/Classes/CAWindow_spec.rb +0 -54
  335. data/spec/Classes/CAWrap_spec.rb +0 -8
  336. data/spec/Classes/CArray_spec.rb +0 -184
  337. data/spec/Classes/CScalar_spec.rb +0 -55
  338. data/spec/Features/feature_130_spec.rb +0 -19
  339. data/spec/Features/feature_attributes_spec.rb +0 -280
  340. data/spec/Features/feature_boolean_spec.rb +0 -98
  341. data/spec/Features/feature_broadcast.rb +0 -116
  342. data/spec/Features/feature_cast_function.rb +0 -19
  343. data/spec/Features/feature_cast_spec.rb +0 -33
  344. data/spec/Features/feature_class_spec.rb +0 -84
  345. data/spec/Features/feature_complex_spec.rb +0 -42
  346. data/spec/Features/feature_composite_spec.rb +0 -124
  347. data/spec/Features/feature_convert_spec.rb +0 -46
  348. data/spec/Features/feature_copy_spec.rb +0 -123
  349. data/spec/Features/feature_creation_spec.rb +0 -84
  350. data/spec/Features/feature_element_spec.rb +0 -144
  351. data/spec/Features/feature_extream_spec.rb +0 -54
  352. data/spec/Features/feature_generate_spec.rb +0 -74
  353. data/spec/Features/feature_index_spec.rb +0 -69
  354. data/spec/Features/feature_mask_spec.rb +0 -580
  355. data/spec/Features/feature_math_spec.rb +0 -97
  356. data/spec/Features/feature_order_spec.rb +0 -146
  357. data/spec/Features/feature_ref_store_spec.rb +0 -209
  358. data/spec/Features/feature_serialization_spec.rb +0 -125
  359. data/spec/Features/feature_stat_spec.rb +0 -397
  360. data/spec/Features/feature_virtual_spec.rb +0 -48
  361. data/spec/Features/method_eq_spec.rb +0 -81
  362. data/spec/Features/method_is_nan_spec.rb +0 -12
  363. data/spec/Features/method_map_spec.rb +0 -54
  364. data/spec/Features/method_max_with.rb +0 -20
  365. data/spec/Features/method_min_with.rb +0 -19
  366. data/spec/Features/method_ne_spec.rb +0 -18
  367. data/spec/Features/method_project_spec.rb +0 -188
  368. data/spec/Features/method_ref_spec.rb +0 -27
  369. data/spec/Features/method_round_spec.rb +0 -11
  370. data/spec/Features/method_s_linspace_spec.rb +0 -48
  371. data/spec/Features/method_s_span_spec.rb +0 -14
  372. data/spec/Features/method_seq_spec.rb +0 -47
  373. data/spec/Features/method_sort_with.rb +0 -43
  374. data/spec/Features/method_sorted_with.rb +0 -29
  375. data/spec/Features/method_span_spec.rb +0 -42
  376. data/spec/Features/method_wrap_readonly_spec.rb +0 -43
  377. data/spec/UnitTest/test_CAVirtual.rb +0 -214
  378. data/spec/spec_all.rb +0 -10
  379. data/utils/ca_ase.rb +0 -21
  380. data/utils/ca_methods.rb +0 -15
  381. data/utils/cast_checker.rb +0 -30
  382. data/utils/convert_test.rb +0 -73
  383. data/utils/extract_yard.rb +0 -22
  384. data/utils/guess_shape.rb +0 -76
  385. data/utils/monkey_patch_methods.rb +0 -62
  386. data/utils/remove_resource_fork.sh +0 -5
data/lib/carray/time.rb CHANGED
@@ -1,76 +1,2692 @@
1
- # ----------------------------------------------------------------------------
1
+ # lib/carray/time.rb
2
2
  #
3
- # carray/time.rb
3
+ # Ruby surface for the CATime / CATimedelta Faces: construction
4
+ # entry points, arithmetic / comparison operators, reductions, and the
5
+ # pandas-style field accessors.
4
6
  #
5
- # This file is part of Ruby/CArray extension library.
7
+ # CATime / CATimedelta construction entry points:
8
+ # - `CATime.new(*shape, unit: :ns)` — new allocate + Face wrap
9
+ # - `CATime.wrap(raw_int64, unit: :ns)` — zero-copy mask over existing entity (C side)
10
+ # - `CArray#time(unit: :ns)` — instance-method Face-conversion sugar (= sugar over `.wrap`)
11
+ # - Same pattern for CATimedelta
6
12
  #
7
- # Copyright (C) 2005-2025 Hiroki Motoyoshi
13
+ # Base epoch fixed to 1970-01-01 (Unix epoch).
14
+ # NetCDF CF convention (= reference time epoch) is delegated to external gems (timesteps etc.).
15
+
16
+ # Internal: unit-conversion algebra shared by CATime / CATimedelta
17
+ # (used by to_comparable to align a search query to the reference unit).
8
18
  #
9
- # ----------------------------------------------------------------------------
19
+ # The units split into two groups that are NOT inter-convertible by a fixed
20
+ # ratio: fixed-length (W/D/h/m/s/ms/.../as, ratios in seconds) and calendar
21
+ # (Y/M, ratio in months -- W and below are calendar-dependent in days).
22
+ # Within a group any pair is an exact integer ratio (coarse = fine * N), so a
23
+ # coarse->fine cast is lossless (multiply), and a fine->coarse cast is lossless
24
+ # only when every value is divisible by the divisor.
25
+ # Civil-date arithmetic on int64 CArrays: the calendar kernel every time
26
+ # grid is built on (Howard Hinnant's days<->civil algorithms, vectorized).
27
+ # Internal to the time surface: nothing here is part of the public API.
28
+ module CATimeCivil
29
+ module_function
30
+
31
+ # Vectorized Howard Hinnant civil-date algebra (proleptic Gregorian,
32
+ # negative days supported). days since the Unix epoch -> [year, month,
33
+ # day] int64 CArrays. The algorithm needs the era to floor toward the
34
+ # past, which CArray `/` does; Hinnant's published form pre-biases the
35
+ # negative operand instead because C truncates.
36
+ def civil_from_days(z)
37
+ z = z + 719468
38
+ era = z / 146097
39
+ doe = z - era * 146097
40
+ yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
41
+ y = yoe + era * 400
42
+ doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
43
+ mp = (5 * doy + 2) / 153
44
+ d = doy - (153 * mp + 2) / 5 + 1 # day of month, 1..31
45
+ m = mp + (3 - 12 * mp.ge(10)) # mp<10 ? +3 : -9
46
+ [y + m.le(2), m, d]
47
+ end
48
+
49
+ # [year, month, day] int64 CArrays -> days since the Unix epoch.
50
+ def days_from_civil(y, m, d)
51
+ y = y - m.le(2)
52
+ era = y / 400
53
+ yoe = y - era * 400
54
+ doy = (153 * (m + (9 - 12 * m.gt(2))) + 2) / 5 + (d - 1) # m>2 ? -3 : +9
55
+ doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
56
+ era * 146097 + doe - 719468
57
+ end
58
+ end
59
+
60
+ # Unit algebra for the time surface: how two units relate (same group, which
61
+ # is finer, what a difference is measured in) and how a tick count moves
62
+ # between them -- by scaling for a duration, on the calendar for an instant.
63
+ # Internal to the time surface: nothing here is part of the public API.
64
+ module CATimeUnitAlgebra
65
+ # seconds per base unit (Rational)
66
+ FIXED = {
67
+ W: 604800r, D: 86400r, h: 3600r, m: 60r, s: 1r,
68
+ ms: Rational(1, 10**3), us: Rational(1, 10**6), ns: Rational(1, 10**9),
69
+ ps: Rational(1, 10**12), fs: Rational(1, 10**15), as: Rational(1, 10**18),
70
+ }.freeze
71
+ # months per base unit (Rational)
72
+ CALENDAR = { Y: 12r, M: 1r }.freeze
73
+
74
+ # Base units from finest to coarsest granularity. Every fixed-length unit is
75
+ # finer than every calendar unit (a week < a month), so this is a total
76
+ # order used to pick the base that two operands both convert into exactly.
77
+ GRANULARITY = %i[as fs ps ns us ms s m h D W M Y].freeze
78
+
79
+ module_function
80
+
81
+ # seconds- (fixed) or months- (calendar) per base tick.
82
+ def base_ratio(base)
83
+ FIXED[base] || CALENDAR[base]
84
+ end
85
+
86
+ # Normalize a unit spec (Resolution / Symbol / String) to a Resolution.
87
+ # A bare Symbol / String routes through Resolution.parse (count-1 base).
88
+ def res(u)
89
+ u.is_a?(CATime::Resolution) ? u : CATime::Resolution.parse(u)
90
+ end
91
+
92
+ # Greatest common divisor of two positive Rationals (both in lowest terms).
93
+ def rgcd(a, b)
94
+ Rational(a.numerator.gcd(b.numerator), a.denominator.lcm(b.denominator))
95
+ end
96
+
97
+ # Whether two units are in the same group (both calendar or both fixed).
98
+ def same_group?(u1, u2)
99
+ CALENDAR.key?(res(u1).base) == CALENDAR.key?(res(u2).base)
100
+ end
101
+
102
+ # The common grid two same-group resolutions both convert into exactly: the
103
+ # resolution whose tick is the gcd of the two ticks (finest common base +
104
+ # the whole multiplier). For equal resolutions this is the resolution
105
+ # itself; for `(1,:D)` & `(1,:h)` it is `(1,:h)`; for `(5,:m)` & `(2,:m)` it
106
+ # is `(1,:m)`. Both operands are same-group (checked by the caller).
107
+ def common(u1, u2)
108
+ a = res(u1); b = res(u2)
109
+ fb = GRANULARITY.index(a.base) <= GRANULARITY.index(b.base) ? a.base : b.base
110
+ g = rgcd(a.tick_ratio, b.tick_ratio)
111
+ CATime::Resolution.new(Integer(g / base_ratio(fb)), fb)
112
+ end
113
+ alias_method :finer, :common
114
+ module_function :finer
115
+
116
+ # Common resolution for a time difference (E): same group -> the common
117
+ # grid; cross-group -> the fixed-group resolution (a :M/:Y difference only
118
+ # arises from two calendar operands), coarsened to `(1,:D)` when the fixed
119
+ # side is a week (a week is not calendar-alignable, but both sides convert
120
+ # into days exactly). :W is the only such coarsening: a fixed resolution
121
+ # whose tick does not tile a day (e.g. "7 hours") is returned as-is, and
122
+ # the calendar side then fails to convert into it, so the subtraction
123
+ # raises downstream rather than being silently coarsened.
124
+ def diff_unit(u1, u2)
125
+ a = res(u1); b = res(u2)
126
+ return common(a, b) if same_group?(a, b)
127
+ fx = CALENDAR.key?(a.base) ? b : a
128
+ fx.base == :W ? CATime::Resolution.new(1, :D) : fx
129
+ end
130
+
131
+ # Tick ratio from `from` to `to` (how many `to` ticks per `from` tick), or
132
+ # nil if they are in different groups (not inter-convertible by a fixed
133
+ # ratio). Folds each resolution's count.
134
+ def ratio(from, to)
135
+ a = res(from); b = res(to)
136
+ return nil unless same_group?(a, b)
137
+ a.tick_ratio / b.tick_ratio
138
+ end
139
+
140
+ # Target ticks per source tick for the strict unit-change surface
141
+ # (CATime#to_unit / CATimedelta#to_unit): accepted only when the source tick
142
+ # is a whole multiple of the target tick, so every value re-expresses
143
+ # exactly on the finer grid. A coarser target (which would round) and a
144
+ # cross-group pair (no fixed ratio at all) both raise -- unlike
145
+ # convert_scale! / convert_instant!, which coarsen when the values happen to
146
+ # allow it, this decides on the units alone.
147
+ def multiple_factor(from, to)
148
+ a = res(from); b = res(to)
149
+ return 1 if a == b
150
+ r = ratio(a, b)
151
+ if r.nil?
152
+ raise ArgumentError,
153
+ "cannot express #{a} in #{b}: calendar units (:Y/:M) and " \
154
+ "fixed-length units (:W/:D/:h/:s/...) have no fixed ratio"
155
+ end
156
+ unless r.denominator == 1
157
+ raise ArgumentError,
158
+ "cannot express #{a} in whole #{b} ticks " \
159
+ "(a #{a} tick is not a whole multiple of a #{b} tick)"
160
+ end
161
+ r.numerator
162
+ end
163
+
164
+ # `storage * factor` with a loud overflow guard: widening a wide time range
165
+ # into a fine unit can exceed int64, and a silent wrap would give a wrong
166
+ # instant / duration. Checks the extremes (they bound every element), then
167
+ # multiplies. Shared by every coarse->fine conversion (arithmetic,
168
+ # comparison, search).
169
+ #
170
+ # min / max skip masked cells, so a masked cell does not decide the range:
171
+ # it carries no value to convert, and whatever bits sit under the mask are
172
+ # not a time. They answer UNDEF when there is nothing to bound (empty, or
173
+ # every cell masked), and then there is nothing to guard either.
174
+ def widen(storage, factor)
175
+ return storage if factor == 1
176
+ lo = storage.min
177
+ unless lo == UNDEF
178
+ lim = 2**63 - 1
179
+ [lo, storage.max].each do |x|
180
+ next if (Integer(x) * factor).abs <= lim
181
+ raise RangeError,
182
+ "time unit conversion overflows int64: the time range is " \
183
+ "too wide to widen into this resolution (x#{factor})"
184
+ end
185
+ end
186
+ storage * factor
187
+ end
188
+
189
+ # SCALE conversion (durations / timedelta): convert an int64 storage CArray
190
+ # from `from` unit to `to` unit by the fixed ratio. coarse->fine multiplies;
191
+ # fine->coarse divides only when every value is exact; cross-group ALWAYS
192
+ # raises -- a :M / :Y duration has no fixed ratio to days (a month is
193
+ # calendar-variable), so it genuinely cannot scale to seconds.
194
+ def convert_scale!(storage, from, to)
195
+ a = res(from); b = res(to)
196
+ return storage if a == b
197
+ r = ratio(a, b)
198
+ if r.nil?
199
+ raise ArgumentError,
200
+ "cannot scale duration #{a} to #{b}: calendar units " \
201
+ "(:Y/:M) and fixed-length units (:W/:D/:h/:s/...) have no fixed " \
202
+ "ratio (a month / year is calendar-variable)"
203
+ end
204
+ scaled = widen(storage, r.numerator) # coarse -> fine: lossless multiply
205
+ return scaled if r.denominator == 1
206
+ unless (scaled % r.denominator).eq(0).all
207
+ raise ArgumentError,
208
+ "cannot scale duration #{a} to #{b} without loss: " \
209
+ "some values are not a whole multiple of #{b} " \
210
+ "(finer resolution would be truncated)"
211
+ end
212
+ scaled / r.denominator
213
+ end
214
+
215
+ # SCALE conversion with truncation: like convert_scale! but a fine->coarse
216
+ # conversion drops the sub-`to` remainder (truncating toward zero) instead of
217
+ # raising. A duration is a magnitude, so it shrinks toward zero rather than
218
+ # flooring toward the past the way an instant does. Used by
219
+ # CATimedelta#to_unit and by dt +/- td, where the result keeps the time's
220
+ # unit and a finer duration is truncated to it (a :D time + a 5 h duration
221
+ # is + 0 days; + 30 h is + 1 day). Cross-group still raises (a calendar
222
+ # duration has no fixed ratio to a fixed unit).
223
+ def convert_scale_trunc(storage, from, to)
224
+ a = res(from); b = res(to)
225
+ return storage if a == b
226
+ r = ratio(a, b)
227
+ if r.nil?
228
+ raise ArgumentError,
229
+ "cannot scale duration #{a} to #{b}: calendar units " \
230
+ "(:Y/:M) and fixed-length units (:W/:D/:h/:s/...) have no fixed " \
231
+ "ratio (a month / year is calendar-variable)"
232
+ end
233
+ scaled = widen(storage, r.numerator)
234
+ r.denominator == 1 ? scaled : trunc_divide(scaled, r.denominator)
235
+ end
236
+
237
+ # Truncating integer division of an int64 CArray (toward zero). CArray
238
+ # `/` floors toward the past, which is what an instant wants; a duration
239
+ # is a magnitude and shrinks toward zero instead.
240
+ def trunc_divide(a, b)
241
+ q = a / b
242
+ q + (q.lt(0) & (a - q * b).ne(0))
243
+ end
244
+
245
+ # INSTANT conversion (absolute datetimes): unlike a duration, a time :M
246
+ # value HAS a well-defined instant (the month's first midnight), so a
247
+ # cross-group cast is possible via civil-date algebra even though no fixed
248
+ # ratio exists (a :M time casts to :s, a :M duration cannot). Same-group
249
+ # falls back to the ratio.
250
+ # - calendar (:M/:Y) -> fixed (<= :D): always exact (widen to the finer
251
+ # grid). :W is rejected (month / year starts are not week-aligned).
252
+ # - fixed -> calendar: exact only when the instant lands on the calendar
253
+ # boundary (midnight of the 1st), else raises.
254
+ def convert_instant!(storage, from, to)
255
+ a = res(from); b = res(to)
256
+ return storage if a == b
257
+ return convert_scale!(storage, a, b) if ratio(a, b) # same group
258
+ if CALENDAR.key?(a.base)
259
+ convert_instant_calendar_to_fixed(storage, a, b) # widen
260
+ else
261
+ convert_instant_fixed_to_calendar(storage, a, b) # coarsen (exact-or-raise)
262
+ end
263
+ end
264
+
265
+ # calendar time (Resolution `from`) -> days since the epoch (int64
266
+ # CArray). Folds the resolution count (value = count-Y/M buckets).
267
+ def calendar_days_since_epoch(storage, from)
268
+ ones = CArray.int64(*storage.shape) { 1 }
269
+ if from.base == :M
270
+ abs = storage * from.count + 1970 * 12 # absolute month ordinal
271
+ y = abs / 12
272
+ m = abs - y * 12 + 1
273
+ CATimeCivil.days_from_civil(y, m, ones)
274
+ else # :Y
275
+ CATimeCivil.days_from_civil(storage * from.count + 1970, ones, ones)
276
+ end
277
+ end
278
+
279
+ # calendar time -> fixed-length grid: the widening half of
280
+ # convert_instant!. Goes through the day count, so the target grid has
281
+ # to tile a day exactly (:W is rejected -- month starts are not
282
+ # week-aligned). Always exact once that holds.
283
+ def convert_instant_calendar_to_fixed(storage, from, to)
284
+ r = ratio(CATime::Resolution.new(1, :D), to) # ticks of `to` per day
285
+ unless r.denominator == 1
286
+ raise ArgumentError,
287
+ "cannot convert calendar time #{from} to #{to} " \
288
+ "(a day boundary is not aligned to the #{to} grid)"
289
+ end
290
+ widen(calendar_days_since_epoch(storage, from), r.numerator)
291
+ end
292
+
293
+ # fixed-length grid -> calendar time: the coarsening half of
294
+ # convert_instant!, exact-or-raise. Every instant must land on a day
295
+ # boundary and then on the calendar boundary itself (the 1st, and
296
+ # January too for :Y), since a mid-month instant has no :M value.
297
+ def convert_instant_fixed_to_calendar(storage, from, to)
298
+ rd = ratio(CATime::Resolution.new(1, :D), from) # `from` ticks per day
299
+ days =
300
+ if rd.denominator == 1
301
+ n = rd.numerator
302
+ unless (storage % n).eq(0).all
303
+ raise ArgumentError,
304
+ "cannot convert time #{from} to #{to} without loss " \
305
+ "(instant is not on a day boundary)"
306
+ end
307
+ storage / n
308
+ else # coarser than a day (:W)
309
+ storage * (from.tick_ratio / 86400r).to_i
310
+ end
311
+ y, m, d = CATimeCivil.civil_from_days(days)
312
+ on_boundary = d.eq(1)
313
+ on_boundary &= m.eq(1) if to.base == :Y
314
+ unless on_boundary.all
315
+ raise ArgumentError,
316
+ "cannot convert time #{from} to #{to} without loss " \
317
+ "(instant is not on a #{to} boundary)"
318
+ end
319
+ ord = to.base == :M ? (y * 12 + (m - 1) - 1970 * 12) : (y - 1970)
320
+ if to.count > 1
321
+ unless (ord % to.count).eq(0).all
322
+ raise ArgumentError,
323
+ "cannot convert time #{from} to #{to} without loss " \
324
+ "(instant is not on a #{to} boundary)"
325
+ end
326
+ ord = ord / to.count
327
+ end
328
+ ord
329
+ end
330
+
331
+ # INSTANT conversion that floors instead of raising: the storage-resolution
332
+ # change behind CATime#to_unit. A coarser target keeps the bucket the
333
+ # instant falls in (floor toward the past, the direction construction and
334
+ # `floor` already use), so no element is rejected for sitting off the
335
+ # target grid -- a resolution change is a cast, not an assertion. The
336
+ # widening half is untouched and stays exact, and :Y/:M -> :W still raises
337
+ # (a month head is not week-aligned, so widening there would move the
338
+ # instant).
339
+ def convert_instant_floor(storage, from, to)
340
+ a = res(from); b = res(to)
341
+ return storage if a == b
342
+ r = ratio(a, b)
343
+ if r # same group
344
+ scaled = widen(storage, r.numerator)
345
+ r.denominator == 1 ? scaled
346
+ : scaled / r.denominator
347
+ elsif CALENDAR.key?(a.base)
348
+ convert_instant_calendar_to_fixed(storage, a, b) # widen, always exact
349
+ else
350
+ convert_instant_fixed_to_calendar_floor(storage, a, b)
351
+ end
352
+ end
353
+
354
+ # fixed-length grid -> calendar time, flooring: the instant's own year /
355
+ # month, whatever day and time it carries. The flooring counterpart of
356
+ # convert_instant_fixed_to_calendar.
357
+ def convert_instant_fixed_to_calendar_floor(storage, from, to)
358
+ rd = ratio(CATime::Resolution.new(1, :D), from) # `from` ticks per day
359
+ days =
360
+ if rd.denominator == 1
361
+ storage / rd.numerator
362
+ else # coarser than a day (:W)
363
+ storage * (from.tick_ratio / 86400r).to_i
364
+ end
365
+ y, m, = CATimeCivil.civil_from_days(days)
366
+ ord = to.base == :M ? (y * 12 + (m - 1) - 1970 * 12) : (y - 1970)
367
+ to.count > 1 ? ord / to.count : ord
368
+ end
369
+ end
370
+
371
+ class CATime
372
+ # @overload new(*shape, unit: :ns)
373
+ # Allocates a new int64 storage CArray of the given `shape` and
374
+ # wraps it as a CATime Face with the given `unit`. The
375
+ # reference epoch is the Unix epoch (1970-01-01 UTC).
376
+ # @param shape [Array<Integer>] shape of the new CATime.
377
+ # @param unit [Symbol] resolution unit (`:Y`, `:M`, `:W`, `:D`,
378
+ # `:h`, `:m`, `:s`, `:ms`, `:us`, `:ns`, ...).
379
+ # @return [CATime]
380
+ def self.new(*shape, unit: :ns)
381
+ raw = CArray.int64(*shape)
382
+ wrap(raw, unit: unit)
383
+ end
384
+
385
+ # @overload wrap(raw, unit: :ns)
386
+ # Zero-copy Face wrap of an existing int64 CArray. `unit` is a
387
+ # {Resolution} (or a Symbol / String it parses from).
388
+ # @param raw [CArray] int64 storage.
389
+ # @param unit [Resolution, Symbol, String] tick resolution.
390
+ # @return [CATime]
391
+ def self.wrap(raw, unit: :ns)
392
+ __wrap__(raw, unit)
393
+ end
394
+
395
+ # Element-return class: a single time element carries both its
396
+ # int64 count and its resolution, so scalar access (ca[i,j]) round-trips
397
+ # through this rather than a bare Integer.
398
+ class Element
399
+ include Comparable
400
+
401
+ # @!visibility private
402
+ UNIT_TO_SECONDS = {
403
+ Y: 365.25 * 86400, M: 30.5 * 86400, W: 7 * 86400, D: 86400,
404
+ h: 3600, m: 60, s: 1,
405
+ ms: 1.0e-3, us: 1.0e-6, ns: 1.0e-9,
406
+ ps: 1.0e-12, fs: 1.0e-15, as: 1.0e-18
407
+ }.freeze
408
+
409
+ # storage + value/unit accessors + initialize are C-implemented in
410
+ # ext/ca_obj_datetime.c (= TypedData_Make_Struct fast path).
411
+ # Methods below are Ruby-side; they call value/unit which are C accessors.
412
+ #
413
+ # Calendar breakdown is delegated to Ruby Date / Time (the scalar pays one
414
+ # object's cost, unlike the vectorized array accessors), but exactly: a
415
+ # :M / :Y value decodes through Date#next_month / #next_year (NOT the
416
+ # 30.5-day UNIT_TO_SECONDS approximation, which drifts), and a fixed-unit
417
+ # value through an exact Rational-second Time.at. All UTC.
418
+
419
+ EPOCH_JD = 2440588 # Julian Day Number of 1970-01-01
420
+
421
+ # @return [Time] the instant this scalar denotes (UTC). For a calendar
422
+ # unit this is the first instant (midnight of day 1) of the granule.
423
+ def to_time
424
+ require 'time'
425
+ require 'date'
426
+ case unit.base
427
+ when :Y then d = epoch_date.next_year(value * unit.count); Time.utc(d.year, d.month, d.day)
428
+ when :M then d = epoch_date.next_month(value * unit.count); Time.utc(d.year, d.month, d.day)
429
+ else
430
+ Time.at(Rational(value) * unit.tick_ratio, in: 'UTC') # exact seconds
431
+ end
432
+ end
433
+
434
+ # @return [Date] the date this scalar denotes (UTC). A sub-day unit is
435
+ # floored to its day.
436
+ def to_date
437
+ require 'date'
438
+ case unit.base
439
+ when :Y then epoch_date.next_year(value * unit.count)
440
+ when :M then epoch_date.next_month(value * unit.count)
441
+ when :W then Date.jd(EPOCH_JD + value * unit.count * 7, Date::GREGORIAN)
442
+ when :D then Date.jd(EPOCH_JD + value * unit.count, Date::GREGORIAN)
443
+ else Date.jd(EPOCH_JD + floor_to_days_since_epoch, Date::GREGORIAN) # sub-day: floor to day
444
+ end
445
+ end
446
+
447
+ # @return [DateTime] the instant this scalar denotes (UTC).
448
+ def to_datetime
449
+ to_time.to_datetime
450
+ end
451
+
452
+ # Unit-aware string: coarse units print at their own granularity so the
453
+ # `(value, unit)` pair stays recoverable (a :M scalar is "2024-03", not
454
+ # "2024-03-01T00:00:00Z", which is indistinguishable from a :D).
455
+ # @return [String]
456
+ def to_s
457
+ case unit.base
458
+ when :Y then format("%04d", to_date.year)
459
+ when :M then to_date.strftime("%Y-%m")
460
+ when :W, :D then to_date.strftime("%Y-%m-%d")
461
+ else to_time.iso8601(fractional_second_digits) # :h .. :as (time shown)
462
+ end
463
+ end
464
+
465
+ # @return [String] the instant plus its storage unit.
466
+ def inspect
467
+ tag = unit.count == 1 ? "#{value}#{unit.base}" : "#{value} @ #{unit}"
468
+ "#<CATime::Element #{to_s} (#{tag})>"
469
+ end
470
+
471
+ # Three-way compare by INSTANT, reconciling a different unit (both are
472
+ # brought to a common unit both reach exactly, via `diff_unit`), so two
473
+ # datetimes are always ordered regardless of unit. Returns nil for a
474
+ # non-time operand (Time / DateTime are accepted); per the Comparable
475
+ # contract that makes `==` false and `<` raise, so no explicit `==` is
476
+ # defined (Comparable derives it: same instant -> equal, even cross-unit).
477
+ def <=>(other)
478
+ case other
479
+ when Element
480
+ u = CATimeUnitAlgebra.diff_unit(unit, other.unit)
481
+ instant_in(u) <=> other.instant_in(u)
482
+ when Time
483
+ to_time <=> other.getutc
484
+ when (defined?(DateTime) ? DateTime : nil)
485
+ to_time <=> other.to_time.getutc
486
+ end
487
+ rescue ArgumentError
488
+ nil
489
+ end
490
+
491
+ # Hash-key identity is unit-strict (mirrors Ruby: 1 == 1.0 but not
492
+ # 1.eql?(1.0)), so a :s and a :ms scalar at the same instant compare `==`
493
+ # yet key a Hash separately.
494
+ def eql?(other)
495
+ other.is_a?(Element) && other.unit == unit && other.value == value
496
+ end
497
+
498
+ # @return [Integer] a hash consistent with #eql? (unit-strict).
499
+ def hash
500
+ [value, unit].hash
501
+ end
502
+
503
+ # @overload +(td)
504
+ # Returns this instant advanced by a {CATimedelta::Element}. The time
505
+ # is the anchor: the result keeps `self`'s unit and the duration is
506
+ # converted into it, truncated toward zero when finer (a :D time + a
507
+ # 5 h duration is + 0 days; + 30 h is + 1 day) -- the same rule as the
508
+ # array {CATime#+}. Only a duration in the SAME group (both calendar
509
+ # or both fixed) is accepted -- a cross-group step (a :s time + a :M
510
+ # duration) is calendar arithmetic; use `to_date` +
511
+ # `Date#next_month` / `#next_year` for that.
512
+ # @param td [CATimedelta::Element]
513
+ # @return [CATime::Element]
514
+ # @raise [TypeError, ArgumentError] on a non-timedelta / cross-group operand.
515
+ def +(td)
516
+ shifted_by_duration(td, 1)
517
+ end
518
+
519
+ # @overload -(other)
520
+ # Subtracting a {CATimedelta::Element} yields a {CATime::Element}
521
+ # (same rule as `+`); subtracting another {CATime::Element} yields
522
+ # the elapsed duration as a {CATimedelta::Element}, in the `diff_unit`
523
+ # of the two (same group -> finer unit; cross-group -> the fixed unit).
524
+ # @param other [CATimedelta::Element, CATime::Element]
525
+ # @return [CATime::Element, CATimedelta::Element]
526
+ def -(other)
527
+ case other
528
+ when Element
529
+ u = CATimeUnitAlgebra.diff_unit(unit, other.unit)
530
+ CATimedelta::Element.new(instant_in(u) - other.instant_in(u), u)
531
+ when CATimedelta::Element
532
+ shifted_by_duration(other, -1)
533
+ else
534
+ raise TypeError, "CATime::Element - #{other.class} is not allowed"
535
+ end
536
+ end
537
+
538
+ protected
539
+
540
+ # This instant's storage value expressed in `to` unit (exact; raises if the
541
+ # instant does not land on `to`'s grid).
542
+ def instant_in(to)
543
+ return value if to == unit
544
+ CATimeUnitAlgebra.convert_instant!(CA_INT64([value]), unit, to)[0]
545
+ end
546
+
547
+ private
548
+
549
+ # s +/- td: the time is the anchor, so the result keeps self's unit and
550
+ # the duration is converted into it (truncated toward zero when finer; a
551
+ # cross-group calendar duration raises). Mirrors the array CATime +/-.
552
+ def shifted_by_duration(td, sign)
553
+ unless td.is_a?(CATimedelta::Element)
554
+ raise TypeError,
555
+ "CATime::Element #{sign > 0 ? '+' : '-'} #{td.class} is not " \
556
+ "allowed (add / subtract a CATimedelta)"
557
+ end
558
+ tv = CATimeUnitAlgebra.convert_scale_trunc(CA_INT64([td.value]), td.unit, unit)[0]
559
+ Element.new(value + sign * tv, unit)
560
+ end
561
+
562
+ # Base epoch as a Date. next_month / next_year clamp end-of-month
563
+ # (Jan 31 -> Feb 28), but the base is day 1, so no clamping ever occurs --
564
+ # do not move the base off the first of the month without revisiting.
565
+ def epoch_date
566
+ require 'date'
567
+ Date.new(1970, 1, 1, Date::GREGORIAN) # proleptic Gregorian, matching to_time
568
+ end
569
+
570
+ # Days since the epoch for a fixed sub-day unit (floored toward the past).
571
+ def floor_to_days_since_epoch
572
+ sec = Rational(value) * unit.tick_ratio # exact seconds
573
+ (sec / 86400).floor
574
+ end
575
+
576
+ def fractional_second_digits
577
+ case unit.base
578
+ when :ms then 3
579
+ when :us then 6
580
+ when :ns, :ps, :fs, :as then 9 # Time caps at nanoseconds; finer is not shown
581
+ else 0
582
+ end
583
+ end
584
+ end
585
+ end
586
+
587
+ class CATimedelta
588
+ # @overload new(*shape, unit: :ns)
589
+ # Allocates a new int64 storage CArray of the given `shape` and
590
+ # wraps it as a CATimedelta Face with the given `unit`.
591
+ # @param shape [Array<Integer>] shape of the new CATimedelta.
592
+ # @param unit [Symbol] duration unit.
593
+ # @return [CATimedelta]
594
+ def self.new(*shape, unit: :ns)
595
+ raw = CArray.int64(*shape)
596
+ wrap(raw, unit: unit)
597
+ end
598
+
599
+ # @overload wrap(raw, unit: :ns)
600
+ # Zero-copy Face wrap of an existing int64 CArray as a duration.
601
+ # @param raw [CArray] int64 storage.
602
+ # @param unit [CATime::Resolution, Symbol, String] tick resolution.
603
+ # @return [CATimedelta]
604
+ def self.wrap(raw, unit: :ns)
605
+ __wrap__(raw, unit)
606
+ end
607
+
608
+ # A single duration: an integer tick count plus the resolution those ticks
609
+ # are counted in. What a scalar read of a {CATimedelta} returns.
610
+ class Element
611
+ include Comparable
612
+ # @!visibility private
613
+ UNIT_TO_SECONDS = CATime::Element::UNIT_TO_SECONDS
614
+
615
+ # storage + value/unit accessors + initialize are C-implemented in
616
+ # ext/ca_obj_timedelta.c (= TypedData_Make_Struct fast path).
617
+ # Methods below are Ruby-side; they call value/unit which are C accessors.
618
+
619
+ # @return [Rational] the exact duration in seconds. A :Y / :M duration
620
+ # has no exact second count (a month / year is calendar-variable), so it
621
+ # raises rather than silently handing back a 30.5-day approximation --
622
+ # use {#to_seconds_approx} when an estimate is acceptable.
623
+ # @raise [ArgumentError] for a :Y / :M unit.
624
+ def to_seconds
625
+ unless CATimeUnitAlgebra::FIXED.key?(unit.base)
626
+ raise ArgumentError,
627
+ "CATimedelta #{unit} has no exact seconds (a :#{unit.base} is " \
628
+ "calendar-variable); use to_seconds_approx for an estimate"
629
+ end
630
+ Rational(value) * unit.tick_ratio # exact seconds
631
+ end
632
+
633
+ # @return [Float] an approximate duration in seconds, using nominal
634
+ # lengths (a month = 30.5 days, a year = 365.25 days). The name makes
635
+ # the approximation explicit at the call site.
636
+ def to_seconds_approx
637
+ value * unit.count * (UNIT_TO_SECONDS[unit.base] || 1)
638
+ end
639
+
640
+ # @return [String] the value followed by its unit.
641
+ def to_s
642
+ unit.count == 1 ? "#{value}#{unit.base}" : "#{value} @ #{unit}"
643
+ end
644
+
645
+ # @return [String]
646
+ def inspect
647
+ "#<CATimedelta::Element #{to_s}>"
648
+ end
649
+
650
+ # Three-way compare, reconciling a different unit by the fixed ratio. A
651
+ # cross-group pair (a :M vs a :s duration) has no order -- there is no fixed
652
+ # ratio -- so it returns nil (Comparable then makes `<` raise and `==`
653
+ # false). No explicit `==` is defined; Comparable derives it.
654
+ def <=>(other)
655
+ return nil unless other.is_a?(Element)
656
+ return nil unless CATimeUnitAlgebra.same_group?(unit, other.unit)
657
+ u = CATimeUnitAlgebra.finer(unit, other.unit)
658
+ duration_in(u) <=> other.duration_in(u)
659
+ rescue ArgumentError
660
+ nil
661
+ end
662
+
663
+ def eql?(other)
664
+ other.is_a?(Element) && other.unit == unit && other.value == value
665
+ end
666
+
667
+ # @return [Integer] a hash consistent with #eql? (unit-strict).
668
+ def hash
669
+ [value, unit].hash
670
+ end
671
+
672
+ # @overload +(other)
673
+ # Returns the sum of two durations, promoting to the finer unit. A
674
+ # cross-group operand (a :M + a :s duration) has no fixed relation and
675
+ # raises.
676
+ # @param other [CATimedelta::Element]
677
+ # @return [CATimedelta::Element]
678
+ def +(other) = combined_with_duration(other, 1)
679
+
680
+ # @overload -(other)
681
+ # Returns the difference of two durations (finer unit; cross-group raises).
682
+ # @param other [CATimedelta::Element]
683
+ # @return [CATimedelta::Element]
684
+ def -(other) = combined_with_duration(other, -1)
685
+
686
+ # @overload *(n)
687
+ # Returns this duration scaled by an Integer.
688
+ # @param n [Integer]
689
+ # @return [CATimedelta::Element]
690
+ def *(n)
691
+ raise TypeError, "CATimedelta::Element * #{n.class} is not allowed (Integer only)" unless n.is_a?(Integer)
692
+ Element.new(value * n, unit)
693
+ end
694
+
695
+ # @overload /(other)
696
+ # By an Integer -> a scaled {CATimedelta::Element}, the quotient
697
+ # truncated toward zero (a duration is a magnitude, so it shrinks
698
+ # toward zero -- the same direction {CATimedelta#/} and #to_unit
699
+ # take, not Ruby Integer division's floor); by another
700
+ # {CATimedelta::Element} -> their dimensionless ratio as a `Rational`
701
+ # (both brought to the finer unit; cross-group raises).
702
+ # @param other [Integer, CATimedelta::Element]
703
+ # @return [CATimedelta::Element, Rational]
704
+ def /(other)
705
+ case other
706
+ when Integer then Element.new(Rational(value, other).truncate, unit)
707
+ when Element
708
+ u = CATimeUnitAlgebra.finer(unit, other.unit)
709
+ Rational(duration_in(u), other.duration_in(u))
710
+ else
711
+ raise TypeError, "CATimedelta::Element / #{other.class} is not allowed"
712
+ end
713
+ end
714
+
715
+ protected
716
+
717
+ # This duration's value expressed in `to` unit (exact scale; raises on a
718
+ # cross-group or non-whole conversion).
719
+ def duration_in(to)
720
+ return value if to == unit
721
+ CATimeUnitAlgebra.convert_scale!(CA_INT64([value]), unit, to)[0]
722
+ end
723
+
724
+ private
725
+
726
+ def combined_with_duration(other, sign)
727
+ unless other.is_a?(Element)
728
+ raise TypeError,
729
+ "CATimedelta::Element #{sign > 0 ? '+' : '-'} #{other.class} is not allowed"
730
+ end
731
+ u = CATimeUnitAlgebra.finer(unit, other.unit)
732
+ Element.new(duration_in(u) + sign * other.duration_in(u), u)
733
+ end
734
+ end
735
+ end
736
+
737
+ # ============================================================================
738
+ # Ruby surface operators (CATime)
739
+ # ============================================================================
10
740
 
11
- require "date"
741
+ class CATime
742
+ # NOTE: for internal ops accessing the storage CArray (= int64), use `parent`
743
+ # directly. `value` is a user-facing canonical method (= mask-strip CARefer
744
+ # view) with a lift hook deployed in the C layer; do not shadow.
745
+
746
+ # Sanctioned external accessor that pairs with the existing `unit` reader.
747
+ # An interop bridge (carray-xarray, carray-pycall consumers, etc.) can
748
+ # read out the int64 count + unit without reaching into `parent` (= an
749
+ # internal-contract accessor). The reference epoch is the implicit Unix
750
+ # epoch (1970-01-01 UTC) baked into the convention at the top of this
751
+ # file and into the C layer's Time.at-based decoding; it is not a
752
+ # per-instance state and therefore is not exposed as an accessor.
753
+ #
754
+ # ca.ticks # => CArray (int64), tick counts since 1970-01-01 UTC
755
+ # ca.unit # => Symbol (:Y :M :W :D :h :m :s :ms :us :ns ...)
756
+ # @overload ticks
757
+ # Returns the underlying int64 CArray of tick counts since the
758
+ # Unix epoch (1970-01-01 UTC) — the k-th tick of this array's
759
+ # resolution (see §4 of docs/CATime.md).
760
+ # @return [CArray]
761
+ def ticks
762
+ parent
763
+ end
764
+
765
+ # @overload to_unit(unit)
766
+ # Returns the same instants re-expressed on the `unit` grid: a new
767
+ # {CATime} whose storage resolution is `unit`. Changing the resolution
768
+ # is a cast, not an assertion -- the same relation `CArray.time` has to
769
+ # its input:
770
+ #
771
+ # - **Finer target** (`:D` -> `:h`, `:Y` -> `:M`, `:M` -> `:s`): exact.
772
+ # Every instant lands on the new grid unchanged.
773
+ # - **Coarser target** (`:h` -> `:D`, `:D` -> `:M`): each instant floors
774
+ # to the head of the `unit` tick it falls in -- toward the past, so a
775
+ # pre-epoch instant floors the same way a post-epoch one does. Use
776
+ # {#ceil} / {#round} first to land on a different boundary.
777
+ #
778
+ # The calendar / fixed-length boundary is crossed by civil-date algebra,
779
+ # not by a ratio: a `:M` value is a real instant (the month's first
780
+ # midnight), so `:M` -> `:D` widens exactly and `:D` -> `:M` floors to
781
+ # the containing month. `:Y` / `:M` -> `:W` is the one refusal, since a
782
+ # month head is not week-aligned and widening would move the instant.
783
+ #
784
+ # A *duration* has no such conversion ({CATimedelta#to_unit} truncates
785
+ # toward zero and refuses to cross the boundary at all).
786
+ # @param unit [Resolution, Symbol, String] target resolution.
787
+ # @return [CATime]
788
+ # @raise [ArgumentError] on `:Y` / `:M` -> `:W`.
789
+ # @raise [RangeError] when the widened ticks overflow int64.
790
+ def to_unit(unit)
791
+ to = CATime::Resolution.parse(unit)
792
+ CATimeUnitAlgebra.convert_instant_floor(parent, self.unit, to).time(unit: to)
793
+ end
794
+
795
+ # @overload +(other)
796
+ # Returns `self + other` for a {CATimedelta}: the time is the
797
+ # anchor, so the result keeps `self`'s unit and the duration is converted
798
+ # into it (a duration finer than `self`'s unit is truncated to it; a
799
+ # cross-group calendar duration raises). Adding two datetimes is
800
+ # ill-defined.
801
+ # @param other [CATimedelta]
802
+ # @return [CATime]
803
+ # @raise [TypeError] on a non-timedelta operand.
804
+ def +(other)
805
+ case other
806
+ when CATimedelta
807
+ (parent + CATimeUnitAlgebra.convert_scale_trunc(other.parent, other.unit, unit)).time(unit: unit)
808
+ when CATime
809
+ raise TypeError, "CATime + CATime is ill-defined"
810
+ else
811
+ raise TypeError, "CATime + #{other.class} is not allowed (use CATimedelta)"
812
+ end
813
+ end
814
+
815
+ # @overload -(other)
816
+ # Subtracting a {CATimedelta} yields a {CATime} at `self`'s unit (the
817
+ # duration is converted into it, truncated when finer); subtracting
818
+ # another {CATime} yields a {CATimedelta} at the finer of the two
819
+ # units (cross-group falls to the fixed unit).
820
+ # @param other [CATimedelta, CATime]
821
+ # @return [CATime, CATimedelta]
822
+ # @raise [TypeError] on an unsupported operand.
823
+ def -(other)
824
+ case other
825
+ when CATimedelta
826
+ (parent - CATimeUnitAlgebra.convert_scale_trunc(other.parent, other.unit, unit)).time(unit: unit)
827
+ when CATime
828
+ u = CATimeUnitAlgebra.diff_unit(unit, other.unit)
829
+ a = CATimeUnitAlgebra.convert_instant!(parent, unit, u)
830
+ b = CATimeUnitAlgebra.convert_instant!(other.parent, other.unit, u)
831
+ (a - b).timedelta(unit: u)
832
+ else
833
+ raise TypeError, "CATime - #{other.class} is not allowed"
834
+ end
835
+ end
836
+
837
+ # Element-wise comparison (< <= > >= <=>) rides the core comparison Face
838
+ # gate (ca_face_reconcile_comparison in ext/ca_obj_face.c): the inherited
839
+ # CArray operators descend to storage and reconcile a CATime RHS in a
840
+ # different unit via to_comparable, returning a boolean CArray. <=> is the
841
+ # base CArray#<=> composed from the (gated) > and <.
842
+
843
+ # Element decode convention via the C-layer macro
844
+ # `CA_FACE_STORAGE_TO_SCALAR_IF_FACE`: no Ruby `[]` override is needed, a
845
+ # scalar auto-decodes on all scalar-return paths such as `ca[i,j,k]` /
846
+ # `ca[k]`. storage_to_scalar itself lives in C (ext/ca_obj_datetime.c)
847
+ # so the hot path stays off Ruby method dispatch. Its write counterpart
848
+ # is scalar_to_storage (below, surface -> storage on store).
849
+
850
+ # @!group Reductions
851
+
852
+ # min / max are order-structure reductions: they ride the core reduce
853
+ # Face gate (ORDERABLE storage descent + output re-lift, see
854
+ # ext/mkkernel.rb face_gate: :relift), so no Ruby override is needed --
855
+ # the inherited CArray#min / #max handle the Element / CATime return
856
+ # shapes directly. minmax is the paired form; it has no core gate, so
857
+ # re-lift both extremes here. They are actual elements, so they keep the
858
+ # storage unit (no rounding, unlike mean).
859
+
860
+ # @overload minmax(*axes, **opts)
861
+ # Returns the earliest and latest time as a `[min, max]` pair (§8).
862
+ # @return [Array(Element, Element), Array(CATime, CATime)]
863
+ def minmax(*args, **opts)
864
+ lo, hi = parent.minmax(*args, **opts)
865
+ [relift_extremum(lo), relift_extremum(hi)]
866
+ end
867
+
868
+ def relift_extremum(r)
869
+ case r
870
+ when Integer then Element.new(r, unit)
871
+ when CArray then r.time(unit: unit)
872
+ else r
873
+ end
874
+ end
875
+ private :relift_extremum
876
+
877
+ # The centroid / spread / order reductions report on the array's own grid:
878
+ # they run on the storage ticks and round the result back to the nearest
879
+ # tick of `unit`. A caller who needs a finer answer declares the grid
880
+ # first (`t.to_unit(:ms).mean`) -- the same rule linear_fetch follows.
881
+ # Rounding is to nearest, not toward zero: a truncating round would flip
882
+ # the direction of the error at the epoch.
883
+
884
+ # @overload mean(axis: nil, **opts)
885
+ # Returns the centroid time on `self`'s unit (§8), rounded to the
886
+ # nearest tick.
887
+ # @return [Element, CATime]
888
+ def mean(*args, **opts)
889
+ reduce_on_own_grid(:mean, :time, args, opts)
890
+ end
891
+
892
+ # @overload median(axis: nil, **opts)
893
+ # Returns the median time on `self`'s unit (§8). An odd-count full
894
+ # reduction is an actual element (exact); the even-count / per-axis
895
+ # cases interpolate and round to the nearest tick.
896
+ # @return [Element, CATime]
897
+ def median(*args, **opts)
898
+ reduce_on_own_grid(:median, :time, args, opts)
899
+ end
900
+
901
+ # @overload percentile(*p, axis: nil, **opts)
902
+ # Returns the percentile instants on `self`'s unit, in the shapes the
903
+ # plain {CArray#percentile} uses: one `p` reduces to a single value
904
+ # (an {Element}, or a {CATime} with `axis:`), two or more `p` give an
905
+ # Array of those.
906
+ # @return [Element, CATime, Array<Element>, Array<CATime>]
907
+ def percentile(*args, **opts)
908
+ reduce_on_own_grid(:percentile, :time, args, opts)
909
+ end
910
+
911
+ # @overload quantile(axis: nil, **opts)
912
+ # Returns the five quartile instants `[p0, p25, p50, p75, p100]` on
913
+ # `self`'s unit (shorthand for `percentile(0, 25, 50, 75, 100)`).
914
+ # @return [Array<Element>, Array<CATime>]
915
+ def quantile(*args, **opts)
916
+ reduce_on_own_grid(:quantile, :time, args, opts)
917
+ end
918
+
919
+ # @overload stddev(axis: nil, **opts)
920
+ # Returns the spread of the instants as a {CATimedelta} (a duration) on
921
+ # `self`'s unit. A spread is not a lattice point, so the rounding costs
922
+ # more here than for a centroid: on a coarse unit use
923
+ # `t.to_unit(:h).stddev` when the precision matters (§8).
924
+ # @return [CATimedelta::Element, CATimedelta]
925
+ def stddev(*args, **opts)
926
+ reduce_on_own_grid(:stddev, :timedelta, args, opts)
927
+ end
928
+
929
+ # @overload stddevp(axis: nil, **opts)
930
+ # Returns the population spread as a {CATimedelta}, in the same shapes
931
+ # as {#stddev}.
932
+ # @return [CATimedelta::Element, CATimedelta]
933
+ def stddevp(*args, **opts)
934
+ reduce_on_own_grid(:stddevp, :timedelta, args, opts)
935
+ end
936
+
937
+ # @overload sum(*)
938
+ # Not supported; use {#mean} for a centroid.
939
+ # @raise [TypeError] always.
940
+ def sum(*)
941
+ raise TypeError, "CATime#sum is ill-defined; use mean for centroid"
942
+ end
943
+
944
+ # @overload variance(*)
945
+ # Not supported: the variance of instants has squared-time units, which
946
+ # no type represents (ill-defined, like {#sum}). Use {#stddev} for the
947
+ # spread as a duration.
948
+ # @raise [TypeError] always.
949
+ def variance(*); raise TypeError, "CATime#variance is ill-defined (squared-time units); use stddev"; end
950
+
951
+ # @overload variancep(*)
952
+ # Not supported, for the same reason as {#variance}. Use {#stddevp}.
953
+ # @raise [TypeError] always.
954
+ def variancep(*); raise TypeError, "CATime#variancep is ill-defined (squared-time units); use stddevp"; end
955
+ # @!endgroup
956
+
957
+ # sort / partition ride the core sort Face gate (ORDERABLE storage
958
+ # descent + output re-lift): the inherited CArray#sort / #partition
959
+ # return a sorted CATime with the same unit, so no override is
960
+ # needed.
961
+
962
+ # @overload to_comparable(operand)
963
+ # Brings `operand` into `self`'s unit space for a direct storage
964
+ # comparison (comparison operators / search family / linear_section).
965
+ # `self` is the reference Face -- always one of our classes -- so it
966
+ # class-dispatches the operand rather than requiring every operand type
967
+ # to know every Face (which a core class like Time could not).
968
+ # CATime is ORDERABLE but not COMPARABLE (an operand may carry a
969
+ # different unit or shape), so the gate routes the operand through here.
970
+ #
971
+ # Accepted operands: another {CATime} (unit-rescaled to self),
972
+ # a {Element} (lifted to a length-1 CATime), a Ruby `Time`, and a
973
+ # Ruby `DateTime` (both absolute instants converted to self's unit,
974
+ # Unix epoch, UTC). A `String` is out of scope (parsing is a separate,
975
+ # opposite-direction mechanism). A bare Integer / other type raises;
976
+ # descend to `ca.parent` to compare the hidden storage directly.
977
+ #
978
+ # The rescale is an INSTANT conversion (convert_instant!), lossless: a
979
+ # coarser->finer unit always converts; a finer->coarser unit converts only
980
+ # when every value lands exactly on the coarser grid, else raises. Unlike
981
+ # a duration, a cross-group time cast IS possible via civil-date
982
+ # algebra (a :M value has a well-defined instant): :M/:Y widen exactly to
983
+ # :D and finer, and a fixed operand coarsens to :M/:Y only when it sits on
984
+ # the calendar boundary (:W is the one exception -- month/year starts are
985
+ # not week-aligned, so :Y/:M <-> :W raises).
986
+ # @param operand [CATime, CATime::Element, Time, DateTime]
987
+ # @return [CATime] in self's unit.
988
+ # @raise [TypeError, ArgumentError] on an unreconcilable operand / unit.
989
+ def to_comparable (operand)
990
+ case operand
991
+ when CATime
992
+ return operand if operand.unit == unit
993
+ CATimeUnitAlgebra.convert_instant!(operand.parent, operand.unit, unit)
994
+ .time(unit: unit)
995
+ when CATime::Element
996
+ lifted = CATime.wrap(CA_INT64([operand.value]), unit: operand.unit)
997
+ to_comparable(lifted)
998
+ when Time
999
+ # Reuse the single-literal builder; it yields a length-1 CATime
1000
+ # in the requested (= self's) resolution.
1001
+ CArray.time(operand, unit: unit)
1002
+ when defined?(DateTime) && DateTime
1003
+ to_comparable(operand.to_time.utc)
1004
+ else
1005
+ raise TypeError,
1006
+ "CATime cannot reconcile #{operand.class} " \
1007
+ "(use ca.parent to compare the raw int64 storage directly)"
1008
+ end
1009
+ end
1010
+
1011
+ # @overload scalar_to_storage(surface)
1012
+ # Write-direction counterpart of storage_to_scalar (the store hook fired
1013
+ # from rb_ca_obj2ptr): brings a surface value object into this Face's
1014
+ # int64 storage (count in self's unit since the Unix epoch) so a scalar
1015
+ # store round-trips with a fetch. A {Element} / `Time` / `DateTime` is
1016
+ # reconciled to self's unit via {#to_comparable} (same lossless
1017
+ # discipline: a cross-group unit or a non-exact finer->coarser cast
1018
+ # raises). A bare Integer (the documented `.parent` raw-storage escape)
1019
+ # and a String (parsing is a separate, opposite-direction mechanism)
1020
+ # pass through unchanged to the storage cast.
1021
+ # @param surface [Element, Time, DateTime, Integer, String]
1022
+ # @return [Integer, Object] the storage-domain value, or `surface`
1023
+ # unchanged for a pass-through type.
1024
+ # @raise [TypeError, ArgumentError] on an unreconcilable surface / unit.
1025
+ def scalar_to_storage (surface)
1026
+ case surface
1027
+ when Integer, String
1028
+ surface
1029
+ else
1030
+ to_comparable(surface).parent[0]
1031
+ end
1032
+ end
1033
+
1034
+ # NOTE: search (bsearch / search / bsearch_addr) and linear_section need NO
1035
+ # per-method override. The C kernel gate descends an ORDERABLE Face
1036
+ # reference to storage and, for a Face query, calls to_comparable itself to
1037
+ # reconcile it -- so declaring ORDERABLE (init flag) and defining
1038
+ # to_comparable (above) is the whole contract. linear_fetch is the one
1039
+ # exception: it returns a value rather than a position, so it re-lifts
1040
+ # (below).
1041
+
1042
+ # @overload linear_fetch(addr, axis: nil)
1043
+ # Returns the time at the fractional position `addr` on this array's
1044
+ # grid, interpolating between the two bracketing instants. The inverse
1045
+ # of `linear_section`, and the reason for the override: `linear_fetch`
1046
+ # returns a *value*, so the result is a {CATime} again, whereas
1047
+ # `linear_section` returns a position and stays a plain index.
1048
+ #
1049
+ # The result keeps `self`'s unit -- the array's grid is the output grid,
1050
+ # so an instant that lands between two ticks is rounded to the nearest
1051
+ # one. Widen the grid first when the interpolation needs finer
1052
+ # resolution: `t.to_unit(:ms).linear_fetch(addr)` interpolates on the
1053
+ # millisecond grid. (Unlike {#mean} / {#median}, which collapse the axis
1054
+ # and therefore have no output grid to preserve, so they refine the
1055
+ # resolution instead.)
1056
+ #
1057
+ # An out-of-range `addr` yields UNDEF rather than the NaN that a plain
1058
+ # float axis returns -- int64 storage has no NaN to carry a sentinel. A
1059
+ # masked `addr` cell stays UNDEF (the kernel's own rule: an undetermined
1060
+ # query gets an undetermined answer).
1061
+ # @param addr [Float, CArray] fractional position(s) into `self`.
1062
+ # @param axis [Integer, nil]
1063
+ # @return [Element, CATime] an {Element} for a scalar `addr`, `nil` when
1064
+ # that single answer is undetermined.
1065
+ def linear_fetch (addr, **opts)
1066
+ r = parent.float64.linear_fetch(addr, **opts)
1067
+ case r
1068
+ when CArray
1069
+ # mask_invalid before the int64 cast: the cast turns a NaN into 0, which
1070
+ # would read as the epoch instead of "no answer".
1071
+ r.mask_invalid.round.int64.time(unit: unit)
1072
+ when Numeric
1073
+ # Scalar query. Out of range -> nil, joining the nil the kernel already
1074
+ # returns for a masked scalar query: one "no answer" on this path.
1075
+ r.to_f.nan? ? nil : Element.new(r.round, unit)
1076
+ else
1077
+ r # nil (masked scalar query)
1078
+ end
1079
+ end
1080
+
1081
+ # ====================================================================
1082
+ # pandas .dt.* style field accessors
1083
+ # ====================================================================
1084
+ #
1085
+ # Face-strip semantic: each accessor returns a CArray of Integer / bool
1086
+ # (= year / month / etc. are not time, they descend to the storage
1087
+ # type), computed directly from the storage int64.
1088
+ # ====================================================================
1089
+
1090
+ # @overload to_time
1091
+ # Converts every element to a Ruby `Time` (UTC), returned as an object
1092
+ # CArray. Array-level counterpart to {Element#to_time}.
1093
+ # @return [CArray]
1094
+ def to_time
1095
+ require 'time'
1096
+ if CATimeUnitAlgebra::FIXED.key?(unit.base)
1097
+ f = unit.tick_ratio # exact seconds / tick (Rational)
1098
+ parent.convert(:object) {|v| Time.at(v * f, in: 'UTC')}
1099
+ else # calendar: exact granule midnight
1100
+ (days_since_epoch * 86400).convert(:object) {|v| Time.at(v, in: 'UTC')}
1101
+ end
1102
+ end
1103
+
1104
+ # @overload to_date
1105
+ # Converts every element to a Ruby `Date` (UTC), returned as an object
1106
+ # CArray. A sub-day unit floors to its day. Array-level counterpart to
1107
+ # {Element#to_date}.
1108
+ # @return [CArray]
1109
+ def to_date
1110
+ require 'date'
1111
+ # 2440588 = JD of 1970-01-01; proleptic Gregorian to match to_time and the field accessors.
1112
+ days_since_epoch.convert(:object) {|d| Date.jd(2440588 + d, Date::GREGORIAN)}
1113
+ end
1114
+
1115
+ # @overload to_datetime
1116
+ # Converts every element to a Ruby `DateTime` (UTC offset 0), returned
1117
+ # as an object CArray.
1118
+ # @return [CArray]
1119
+ def to_datetime
1120
+ require 'date'
1121
+ to_time.convert(:object) {|t| t.to_datetime}
1122
+ end
1123
+
1124
+ # @!group Field accessors
1125
+ # Each accessor returns an integer CArray with the requested calendar /
1126
+ # clock field extracted from every element (UTC), computed by vectorized
1127
+ # civil-date algebra directly on the int64 storage -- no per-cell Time.
1128
+ # Exact for every unit, including :M / :Y (where the old Time.at path drifted
1129
+ # by using 30.5-day / 365.25-day approximations). Fields finer than the
1130
+ # storage unit collapse to their zero point, and the input mask propagates.
1131
+ # @return [CArray]
1132
+ def year
1133
+ case unit.base
1134
+ when :Y then parent * unit.count + 1970
1135
+ when :M then (parent * unit.count + 1970 * 12) / 12
1136
+ else CATimeCivil.civil_from_days(days_since_epoch)[0]
1137
+ end
1138
+ end
1139
+
1140
+ # Calendar month, 1..12. Every cell is 1 for `:Y` storage, which does not
1141
+ # resolve months.
1142
+ # @return [CArray] integer.
1143
+ def month
1144
+ case unit.base
1145
+ when :Y then parent * 0 + 1
1146
+ when :M
1147
+ mo = parent * unit.count + 1970 * 12
1148
+ mo % 12 + 1
1149
+ else CATimeCivil.civil_from_days(days_since_epoch)[1]
1150
+ end
1151
+ end
1152
+
1153
+ # Day of the month, 1..31. Every cell is 1 for `:Y` / `:M` storage, which
1154
+ # does not resolve days.
1155
+ # @return [CArray] integer.
1156
+ def day
1157
+ case unit.base
1158
+ when :Y, :M then parent * 0 + 1
1159
+ else CATimeCivil.civil_from_days(days_since_epoch)[2]
1160
+ end
1161
+ end
1162
+
1163
+ # Hour of the day, 0..23; 0 when the storage unit is coarser than an hour.
1164
+ # @return [CArray] integer.
1165
+ def hour; clock_field(:h); end
1166
+ # Minute of the hour, 0..59; 0 when the storage unit is coarser than a minute.
1167
+ # @return [CArray] integer.
1168
+ def minute; clock_field(:m); end
1169
+ # Second of the minute, 0..59; 0 when the storage unit is coarser than a second.
1170
+ # @return [CArray] integer.
1171
+ def second; clock_field(:s); end
1172
+
1173
+ # Day of the week, Sunday = 0 .. Saturday = 6.
1174
+ # @return [CArray] integer.
1175
+ def weekday
1176
+ # 1970-01-01 is a Thursday (wday 4); Sun=0..Sat=6.
1177
+ n = days_since_epoch + 4
1178
+ n % 7
1179
+ end
1180
+
1181
+ # Day of the year, 1..366.
1182
+ # @return [CArray] integer.
1183
+ def yday
1184
+ d = days_since_epoch
1185
+ y = CATimeCivil.civil_from_days(d)[0]
1186
+ ones = CArray.int64(*shape) { 1 }
1187
+ d - CATimeCivil.days_from_civil(y, ones, ones) + 1
1188
+ end
1189
+ # @!endgroup
1190
+
1191
+ # @overload jd
1192
+ # Returns the Julian Day Number for each element.
1193
+ # @return [CArray]
1194
+ def jd
1195
+ require 'date'
1196
+ to_time.convert(:int) {|t| t.to_date.jd}
1197
+ end
1198
+
1199
+ # @overload ajd
1200
+ # Returns the Astronomical Julian Day (float, offset by half a
1201
+ # day) for each element.
1202
+ # @return [CArray]
1203
+ def ajd
1204
+ require 'date'
1205
+ to_time.convert(:double) {|t| t.to_datetime.ajd.to_f}
1206
+ end
1207
+
1208
+ # @overload is_leap
1209
+ # Returns a boolean CArray flagging elements that fall in a
1210
+ # leap year (UTC).
1211
+ # @return [CArray]
1212
+ def is_leap
1213
+ y = year
1214
+ ((y % 4).eq(0) & (y % 100).ne(0)) | (y % 400).eq(0)
1215
+ end
1216
+
1217
+ # @overload strftime(fmt)
1218
+ # Returns a {CAString} whose elements are the per-cell
1219
+ # `Time#strftime(fmt)` result (UTC). The input mask propagates.
1220
+ # @param fmt [String] strftime format string.
1221
+ # @return [CAString]
1222
+ def strftime(fmt)
1223
+ CAString.wrap(to_time.convert(:object) {|t| t.strftime(fmt)})
1224
+ end
1225
+
1226
+ private
1227
+
1228
+ # Reduce to `kind` (:time -> CATime, :timedelta -> CATimedelta) on the
1229
+ # array's own grid. The reduction runs on the storage ticks -- for a
1230
+ # calendar unit that means the month / year ordinal, so a :M centroid is
1231
+ # the centroid of month numbers (a caller wanting the day-space answer
1232
+ # writes to_unit(:D) first) -- and the result is rounded back to a tick.
1233
+ def reduce_on_own_grid(op, kind, args, opts)
1234
+ round_and_relift(parent.public_send(op, *args, **opts), kind)
1235
+ end
1236
+
1237
+ # Round-to-nearest onto the storage grid, then wear the Face again. An
1238
+ # Array arrives from the multi-p percentile / quantile shapes; UNDEF and
1239
+ # nil (empty or all-masked) pass through untouched.
1240
+ def round_and_relift(r, kind)
1241
+ case r
1242
+ when Numeric
1243
+ v = r.round
1244
+ kind == :timedelta ? CATimedelta::Element.new(v, unit) : Element.new(v, unit)
1245
+ when CArray
1246
+ # A cell with no answer arrives masked and the mask survives the cast.
1247
+ # NaN does not: it has no int64 image, so a `fill_value: Float::NAN`
1248
+ # cell would cast to garbage bits. mask_invalid turns it into UNDEF,
1249
+ # which is what "no answer" means here.
1250
+ iv = r.round.mask_invalid.int64
1251
+ kind == :timedelta ? iv.timedelta(unit: unit) : iv.time(unit: unit)
1252
+ when Array
1253
+ r.map {|x| round_and_relift(x, kind)}
1254
+ else
1255
+ r # UNDEF / nil passthrough (empty or all-masked reduction)
1256
+ end
1257
+ end
1258
+
1259
+ end
1260
+
1261
+ # ============================================================================
1262
+ # Ruby surface operators (CATimedelta)
1263
+ # ============================================================================
1264
+
1265
+ class CATimedelta
1266
+ # NOTE: internal ops use `parent` directly. `value` is a C-layer canonical
1267
+ # method (mask-strip) with a lift hook deployed in the C layer; do not
1268
+ # shadow.
1269
+
1270
+ # @overload ticks
1271
+ # Returns the underlying int64 CArray of tick counts — the duration
1272
+ # measured in this array's resolution (see {CATime#ticks}).
1273
+ # @return [CArray]
1274
+ def ticks
1275
+ parent
1276
+ end
1277
+
1278
+ # @overload to_unit(unit)
1279
+ # Returns the same durations re-expressed on the `unit` grid: a new
1280
+ # {CATimedelta} whose storage resolution is `unit`. A finer target is
1281
+ # exact (`:D` -> `:h`); a coarser one drops the sub-`unit` remainder
1282
+ # **toward zero** (`+30 h` -> `+1 D`, `-30 h` -> `-1 D`), the same
1283
+ # truncation `dt + td` already applies.
1284
+ #
1285
+ # The direction differs from {CATime#to_unit} on purpose: a time is a
1286
+ # point on an axis, so it floors toward the past; a duration is a
1287
+ # magnitude, so it shrinks toward zero.
1288
+ #
1289
+ # A calendar / fixed-length pair always raises -- unlike an instant, a
1290
+ # duration of one month has no length in days.
1291
+ # @param unit [Resolution, Symbol, String] target resolution.
1292
+ # @return [CATimedelta]
1293
+ # @raise [ArgumentError] on a calendar / fixed-length pair.
1294
+ # @raise [RangeError] when the widened ticks overflow int64.
1295
+ def to_unit(unit)
1296
+ to = CATime::Resolution.parse(unit)
1297
+ CATimeUnitAlgebra.convert_scale_trunc(parent, self.unit, to).timedelta(unit: to)
1298
+ end
1299
+
1300
+ # @overload +(other)
1301
+ # Returns `self + other`. A {CATimedelta} operand yields a
1302
+ # {CATimedelta}; a {CATime} operand delegates to
1303
+ # {CATime#+} (commutative).
1304
+ # @param other [CATimedelta, CATime]
1305
+ # @return [CATimedelta, CATime]
1306
+ # @raise [TypeError] on incompatible operands.
1307
+ def +(other)
1308
+ case other
1309
+ when CATimedelta
1310
+ u = common_duration_unit(other.unit)
1311
+ a = CATimeUnitAlgebra.convert_scale!(parent, unit, u)
1312
+ b = CATimeUnitAlgebra.convert_scale!(other.parent, other.unit, u)
1313
+ (a + b).timedelta(unit: u)
1314
+ when CATime
1315
+ other + self # commutative -> time's unit
1316
+ else
1317
+ raise TypeError, "CATimedelta + #{other.class} is not allowed"
1318
+ end
1319
+ end
1320
+
1321
+ # @overload -(other)
1322
+ # Returns the difference of two {CATimedelta} at the finer of the two
1323
+ # units (a cross-group pair raises).
1324
+ # @param other [CATimedelta]
1325
+ # @return [CATimedelta]
1326
+ # @raise [TypeError] on a non-timedelta operand.
1327
+ def -(other)
1328
+ case other
1329
+ when CATimedelta
1330
+ u = common_duration_unit(other.unit)
1331
+ a = CATimeUnitAlgebra.convert_scale!(parent, unit, u)
1332
+ b = CATimeUnitAlgebra.convert_scale!(other.parent, other.unit, u)
1333
+ (a - b).timedelta(unit: u)
1334
+ else
1335
+ raise TypeError, "CATimedelta - #{other.class} is not allowed"
1336
+ end
1337
+ end
1338
+
1339
+ # @overload abs
1340
+ # Returns the magnitude of each duration as a {CATimedelta} on the same
1341
+ # unit.
1342
+ # @return [CATimedelta]
1343
+ def abs
1344
+ parent.abs.timedelta(unit: unit)
1345
+ end
1346
+
1347
+ # @overload -@
1348
+ # Returns each duration with its sign reversed, on the same unit.
1349
+ # @return [CATimedelta]
1350
+ def -@
1351
+ (-parent).timedelta(unit: unit)
1352
+ end
1353
+
1354
+ # @overload *(other)
1355
+ # Returns `self` scaled by an Integer.
1356
+ # @param other [Integer]
1357
+ # @return [CATimedelta]
1358
+ # @raise [TypeError] when `other` is not an Integer.
1359
+ def *(other)
1360
+ case other
1361
+ when Integer then (parent * other).timedelta(unit: unit)
1362
+ else raise TypeError, "CATimedelta * #{other.class} is not allowed (Integer only)"
1363
+ end
1364
+ end
1365
+
1366
+ # @overload /(other)
1367
+ # Returns element-wise division: by Integer yields a
1368
+ # {CATimedelta}, by another {CATimedelta} with matching `unit`
1369
+ # yields a dimensionless CArray.
1370
+ # @param other [Integer, CATimedelta]
1371
+ # @return [CATimedelta, CArray]
1372
+ # @raise [TypeError] on incompatible operands.
1373
+ def /(other)
1374
+ case other
1375
+ when Integer
1376
+ CATimeUnitAlgebra.trunc_divide(parent, other).timedelta(unit: unit)
1377
+ when CATimedelta
1378
+ u = common_duration_unit(other.unit)
1379
+ CATimeUnitAlgebra.trunc_divide(
1380
+ CATimeUnitAlgebra.convert_scale!(parent, unit, u),
1381
+ CATimeUnitAlgebra.convert_scale!(other.parent, other.unit, u))
1382
+ else raise TypeError, "CATimedelta / #{other.class} is not allowed"
1383
+ end
1384
+ end
1385
+
1386
+ # Element-wise comparison (< <= > >= <=>) rides the core comparison Face
1387
+ # gate (ca_face_reconcile_comparison): the inherited CArray operators
1388
+ # descend to storage and reconcile a CATimedelta RHS in a different unit
1389
+ # via to_comparable, returning a boolean CArray.
1390
+
1391
+ # @!group Reductions
1392
+
1393
+ # @overload sum(*args, **opts)
1394
+ # Returns the sum of durations as a {Element} for full reduction
1395
+ # or as a {CATimedelta} view for per-axis reduction.
1396
+ # @return [Element, CATimedelta]
1397
+ def sum(*args, **opts)
1398
+ round_and_relift(parent.sum(*args, **opts))
1399
+ end
1400
+
1401
+ # @overload mean(axis: nil, **opts)
1402
+ # Returns the mean duration rounded to the nearest unit count.
1403
+ # @return [Element, CATimedelta]
1404
+ def mean(*args, **opts)
1405
+ round_and_relift(parent.mean(*args, **opts))
1406
+ end
1407
+
1408
+ # median / percentile / quantile / stddev / stddevp report on this array's
1409
+ # own unit, rounded to the nearest tick -- the same rule {CATime} follows.
1410
+ # A caller who needs a finer answer declares the grid first
1411
+ # (`td.to_unit(:ms).stddev`).
1412
+
1413
+ # @overload median(axis: nil, **opts)
1414
+ # Returns the median duration on `self`'s unit.
1415
+ # @return [Element, CATimedelta]
1416
+ def median(*args, **opts)
1417
+ round_and_relift(parent.median(*args, **opts))
1418
+ end
1419
+
1420
+ # @overload percentile(*p, axis: nil, **opts)
1421
+ # Returns the percentile durations on `self`'s unit, in the shapes the
1422
+ # plain {CArray#percentile} uses (one `p` reduces to a single value,
1423
+ # two or more give an Array).
1424
+ # @return [Element, CATimedelta, Array<Element>, Array<CATimedelta>]
1425
+ def percentile(*args, **opts)
1426
+ round_and_relift(parent.percentile(*args, **opts))
1427
+ end
1428
+
1429
+ # @overload quantile(axis: nil, **opts)
1430
+ # Returns the five quartile durations `[p0, p25, p50, p75, p100]` on
1431
+ # `self`'s unit.
1432
+ # @return [Array<Element>, Array<CATimedelta>]
1433
+ def quantile(*args, **opts)
1434
+ round_and_relift(parent.quantile(*args, **opts))
1435
+ end
1436
+
1437
+ # @overload stddev(axis: nil, **opts)
1438
+ # Returns the spread of the durations on `self`'s unit. A spread is not
1439
+ # a lattice point, so on a coarse unit use `td.to_unit(:h).stddev` when
1440
+ # the precision matters.
1441
+ # @return [Element, CATimedelta]
1442
+ def stddev(*args, **opts)
1443
+ round_and_relift(parent.stddev(*args, **opts))
1444
+ end
1445
+
1446
+ # @overload stddevp(axis: nil, **opts)
1447
+ # Returns the population spread on `self`'s unit, in the same shapes as
1448
+ # {#stddev}.
1449
+ # @return [Element, CATimedelta]
1450
+ def stddevp(*args, **opts)
1451
+ round_and_relift(parent.stddevp(*args, **opts))
1452
+ end
1453
+
1454
+ def round_and_relift(r)
1455
+ case r
1456
+ when Numeric then Element.new(r.round, unit)
1457
+ when CArray then r.round.mask_invalid.int64.timedelta(unit: unit)
1458
+ when Array then r.map {|x| round_and_relift(x)}
1459
+ else r
1460
+ end
1461
+ end
1462
+ private :round_and_relift
1463
+
1464
+ # @overload variance(*)
1465
+ # Not supported: the variance of durations has squared-time units, which
1466
+ # no type represents -- the same reason {CATime#variance} refuses. Use
1467
+ # {#stddev} for the spread as a duration.
1468
+ # @raise [TypeError] always.
1469
+ def variance(*)
1470
+ raise TypeError, "CATimedelta#variance is ill-defined (squared-time units); use stddev"
1471
+ end
1472
+
1473
+ # @overload variancep(*)
1474
+ # Not supported, for the same reason as {#variance}. Use {#stddevp}.
1475
+ # @raise [TypeError] always.
1476
+ def variancep(*)
1477
+ raise TypeError, "CATimedelta#variancep is ill-defined (squared-time units); use stddevp"
1478
+ end
1479
+
1480
+ # min / max ride the core reduce Face gate (ORDERABLE storage descent +
1481
+ # output re-lift, see ext/mkkernel.rb face_gate: :relift); the inherited
1482
+ # CArray#min / #max return the Element / CATimedelta shapes directly.
1483
+ # minmax has no core gate, so re-lift both extremes here.
1484
+
1485
+ # @overload minmax(*axes, **opts)
1486
+ # Returns the shortest and longest duration as a `[min, max]` pair.
1487
+ # @return [Array(Element, Element), Array(CATimedelta, CATimedelta)]
1488
+ def minmax(*args, **opts)
1489
+ lo, hi = parent.minmax(*args, **opts)
1490
+ [relift_extremum(lo), relift_extremum(hi)]
1491
+ end
1492
+
1493
+ def relift_extremum(r)
1494
+ case r
1495
+ when Integer then Element.new(r, unit)
1496
+ when CArray then r.timedelta(unit: unit)
1497
+ else r
1498
+ end
1499
+ end
1500
+ private :relift_extremum
1501
+ # @!endgroup
1502
+
1503
+ # sort / partition ride the core sort Face gate (ORDERABLE storage
1504
+ # descent + output re-lift); the inherited CArray#sort / #partition
1505
+ # return a sorted CATimedelta with the same unit.
1506
+
1507
+ # @overload to_comparable(operand)
1508
+ # Brings `operand` into `self`'s unit space for a direct storage
1509
+ # comparison (see {CATime#to_comparable} for the reference-side
1510
+ # contract). `self` is the reference Face and class-dispatches the
1511
+ # operand. Coverage is deliberately narrower than CATime: only
1512
+ # another {CATimedelta} (unit-rescaled to self) and a {Element} (lifted
1513
+ # to a length-1 CATimedelta) are accepted. `Time` / `DateTime` are
1514
+ # absolute instants, not durations, so they raise -- a Face owns its own
1515
+ # coverage. Auto-casts to self's unit when lossless (coarser->finer
1516
+ # always; finer->coarser only when every value is exact), otherwise
1517
+ # raises.
1518
+ # @param operand [CATimedelta, CATimedelta::Element]
1519
+ # @return [CATimedelta] in self's unit.
1520
+ # @raise [TypeError, ArgumentError] on an unreconcilable operand / unit.
1521
+ def to_comparable (operand)
1522
+ case operand
1523
+ when CATimedelta
1524
+ return operand if operand.unit == unit
1525
+ CATimeUnitAlgebra.convert_scale!(operand.parent, operand.unit, unit)
1526
+ .timedelta(unit: unit)
1527
+ when CATimedelta::Element
1528
+ lifted = CATimedelta.wrap(CA_INT64([operand.value]), unit: operand.unit)
1529
+ to_comparable(lifted)
1530
+ else
1531
+ raise TypeError,
1532
+ "CATimedelta cannot reconcile #{operand.class} " \
1533
+ "(use ca.parent to compare the raw int64 storage directly)"
1534
+ end
1535
+ end
1536
+
1537
+ # @overload scalar_to_storage(surface)
1538
+ # Write-direction counterpart of storage_to_scalar: brings a surface
1539
+ # value object into this Face's int64 storage (count in self's unit) so a
1540
+ # scalar store round-trips with a fetch. A {Element} is reconciled to
1541
+ # self's unit via {#to_comparable} (lossless discipline; a `Time` /
1542
+ # `DateTime` is an absolute instant, not a duration, so it raises there).
1543
+ # A bare Integer (the `.parent` raw-storage escape) and a String pass
1544
+ # through unchanged.
1545
+ # @param surface [Element, Integer, String]
1546
+ # @return [Integer, Object] the storage-domain value, or `surface`
1547
+ # unchanged for a pass-through type.
1548
+ # @raise [TypeError, ArgumentError] on an unreconcilable surface / unit.
1549
+ def scalar_to_storage (surface)
1550
+ case surface
1551
+ when Integer, String
1552
+ surface
1553
+ else
1554
+ to_comparable(surface).parent[0]
1555
+ end
1556
+ end
1557
+
1558
+ # Search / linear_section need no per-method override: the C kernel gate
1559
+ # descends an ORDERABLE Face reference and reconciles a Face query via
1560
+ # to_comparable. See {CATime}.
1561
+
1562
+ # @overload linear_fetch(addr, axis: nil)
1563
+ # Returns the duration at the fractional position `addr` on this array's
1564
+ # grid, interpolating between the two bracketing durations. Keeps
1565
+ # `self`'s unit and rounds to it (widen the grid with {#to_unit} first
1566
+ # when the interpolation needs finer resolution), which matches {#mean} /
1567
+ # {#sum} -- a duration reduction stays on the grid too. An out-of-range
1568
+ # `addr` yields UNDEF; see {CATime#linear_fetch} for the full contract.
1569
+ # @param addr [Float, CArray] fractional position(s) into `self`.
1570
+ # @param axis [Integer, nil]
1571
+ # @return [Element, CATimedelta] an {Element} for a scalar `addr`, `nil`
1572
+ # when that single answer is undetermined.
1573
+ def linear_fetch (addr, **opts)
1574
+ r = parent.float64.linear_fetch(addr, **opts)
1575
+ case r
1576
+ when CArray then r.mask_invalid.round.int64.timedelta(unit: unit)
1577
+ when Numeric then r.to_f.nan? ? nil : Element.new(r.round, unit)
1578
+ else r
1579
+ end
1580
+ end
1581
+
1582
+ private
1583
+
1584
+ # The finer of `self`'s unit and `ou` for a duration + duration / ratio. A
1585
+ # cross-group pair (a month vs a second) has no common duration grid, so it
1586
+ # raises rather than mixing calendar and fixed durations.
1587
+ def common_duration_unit(ou)
1588
+ unless CATimeUnitAlgebra.same_group?(unit, ou)
1589
+ raise ArgumentError,
1590
+ "cannot combine a #{unit} duration with a #{ou} duration " \
1591
+ "across the calendar/fixed boundary"
1592
+ end
1593
+ CATimeUnitAlgebra.finer(unit, ou)
1594
+ end
1595
+ end
1596
+
1597
+ # Reads a time literal (Time / DateTime / Integer unix-seconds / String) into
1598
+ # the pieces the CATime constructors need. All parsing is UTC (an explicit
1599
+ # offset is honoured; otherwise UTC) and DateTime-independent (Date._parse +
1600
+ # a civil-date kernel). Internal to the time surface: nothing here is part
1601
+ # of the public API.
1602
+ module CATimeLiteral
1603
+ module_function
1604
+
1605
+ # Date fields of a String literal, UTC. Ruby's Date._parse does not read
1606
+ # the calendar-grid forms CATime#to_s prints -- "2019-09" comes back as a
1607
+ # month of 20 with a zone, and a bare "2019" as a month and a day -- so
1608
+ # those two are read here and everything else is left to Date._parse.
1609
+ #
1610
+ # What Date._parse returns is then checked, because an out-of-range field
1611
+ # used to flow into the civil kernel and normalise into a different date:
1612
+ # "201909" is a valid YYMMDD to Ruby (2020-19-09, which landed on 2021-07),
1613
+ # and "2019-02-31" landed on 2019-03-03. A missing finer field is not an
1614
+ # error -- a year or a year-month names the head of that period.
1615
+ def parse_date_fields(spec, format)
1616
+ h =
1617
+ if format then Date._strptime(spec, format)
1618
+ elsif spec =~ /\A(\d{4})-(\d{1,2})\z/ then { year: $1.to_i, mon: $2.to_i }
1619
+ elsif spec =~ /\A(\d{4})\z/ then { year: $1.to_i }
1620
+ else Date._parse(spec)
1621
+ end
1622
+ unless h && h[:year]
1623
+ raise ArgumentError, "cannot parse time #{spec.inspect}"
1624
+ end
1625
+ y, m, d = h[:year], h[:mon] || 1, h[:mday] || 1
1626
+ unless (1..12).cover?(m) && Date.valid_date?(y, m, d, Date::GREGORIAN)
1627
+ raise ArgumentError,
1628
+ "cannot parse time #{spec.inspect}: it reads as year #{y}, " \
1629
+ "month #{m}, day #{d}, which is not a date"
1630
+ end
1631
+ h
1632
+ end
1633
+ private_class_method :parse_date_fields
1634
+
1635
+ # Exact Rational seconds since the Unix epoch for a start literal (Time /
1636
+ # DateTime / Integer unix-seconds / String). UTC default.
1637
+ def epoch_seconds(spec, format = nil)
1638
+ require 'date'
1639
+ require 'time'
1640
+ case spec
1641
+ when Time then spec.to_r
1642
+ when Integer then Rational(spec)
1643
+ when CATime::Element
1644
+ # A time element is already an instant, so it needs no parsing: a
1645
+ # fixed unit counts seconds directly, and a calendar one names the
1646
+ # first midnight of its granule (the same instant convert_instant!
1647
+ # gives it). This is what lets floor / ceil / to_unit output feed
1648
+ # straight back into time_range / time_series / .time.
1649
+ if CATimeUnitAlgebra::FIXED.key?(spec.unit.base)
1650
+ Rational(spec.value) * spec.unit.tick_ratio
1651
+ else
1652
+ Rational(CATimeUnitAlgebra.calendar_days_since_epoch(CA_INT64([spec.value]),
1653
+ spec.unit)[0] * 86400)
1654
+ end
1655
+ when String
1656
+ h = parse_date_fields(spec, format)
1657
+ days = CATimeCivil.days_from_civil(CA_INT64([h[:year]]), CA_INT64([h[:mon] || 1]),
1658
+ CA_INT64([h[:mday] || 1]))[0]
1659
+ sec = Rational(days * 86400 + (h[:hour] || 0) * 3600 +
1660
+ (h[:min] || 0) * 60 + (h[:sec] || 0))
1661
+ sec += h[:sec_fraction] if h[:sec_fraction]
1662
+ sec -= h[:offset] if h[:offset] # east-of-UTC offset -> UTC
1663
+ sec
1664
+ else
1665
+ if defined?(DateTime) && spec.is_a?(DateTime)
1666
+ spec.to_time.to_r
1667
+ else
1668
+ raise ArgumentError, "cannot parse time #{spec.class}"
1669
+ end
1670
+ end
1671
+ end
1672
+
1673
+ # [year, month] (UTC) of a start literal, for a calendar-resolution grid.
1674
+ def year_month(spec, format = nil)
1675
+ require 'date'
1676
+ require 'time'
1677
+ case spec
1678
+ when Time then t = spec.utc; [t.year, t.month]
1679
+ when Integer then t = Time.at(spec, in: 'UTC'); [t.year, t.month]
1680
+ when CATime::Element
1681
+ # A calendar element is a month ordinal already, so read it as one
1682
+ # rather than going out through Time (which a :Y / :M element would
1683
+ # have to re-derive). Integer / and % floor, so a pre-epoch value
1684
+ # lands in the right month.
1685
+ case spec.unit.base
1686
+ when :M then mo = spec.value * spec.unit.count + 1970 * 12
1687
+ [mo / 12, mo % 12 + 1]
1688
+ when :Y then [spec.value * spec.unit.count + 1970, 1]
1689
+ else t = spec.to_time.utc; [t.year, t.month]
1690
+ end
1691
+ when String
1692
+ h = parse_date_fields(spec, format)
1693
+ [h[:year], h[:mon] || 1]
1694
+ else
1695
+ if defined?(DateTime) && spec.is_a?(DateTime)
1696
+ t = spec.to_time.utc; [t.year, t.month]
1697
+ else
1698
+ raise ArgumentError, "cannot parse time #{spec.class}"
1699
+ end
1700
+ end
1701
+ end
1702
+
1703
+ # Tick index of `spec`'s instant on the `res` grid (floor toward the past).
1704
+ def tick_index(spec, res, format = nil)
1705
+ if CATimeUnitAlgebra::FIXED.key?(res.base)
1706
+ (epoch_seconds(spec, format) / res.tick_ratio).floor
1707
+ else
1708
+ y, m = year_month(spec, format)
1709
+ months = (y - 1970) * 12 + (m - 1)
1710
+ (Rational(months) / res.tick_ratio).floor
1711
+ end
1712
+ end
1713
+
1714
+ # Single-literal build for {.time}: a 1-element CATime, honouring
1715
+ # the on_error policy (raise, or a masked cell).
1716
+ def to_time_array(literal, res, format, on_error)
1717
+ raw = CArray.int64(1)
1718
+ begin
1719
+ raw[0] = tick_index(literal, res, format)
1720
+ rescue ArgumentError, TypeError
1721
+ raise if on_error == :raise
1722
+ raw[0] = UNDEF
1723
+ end
1724
+ raw.time(unit: res)
1725
+ end
1726
+ end
12
1727
 
13
1728
  class CArray
14
-
15
- def str_to_datetime (template = nil)
16
- if template
17
- return convert() {|v| DateTime.strptime(v, template) }
1729
+ # Time is stored as an int64 tick index on a grid whose resolution is
1730
+ # `unit` (= a {CATime::Resolution}, tick = count * base). The value
1731
+ # is the k-th tick since the Unix epoch (1970-01-01 UTC): e.g. unit
1732
+ # `"10 minutes"` value 3 = 1970-01-01T00:30:00Z. Literals are read by
1733
+ # {CATimeLiteral} (UTC).
1734
+
1735
+ # @overload time_range(start, last, unit:, step: nil, format: nil)
1736
+ # Returns a {CATime} from `start` to `last` inclusive on the `unit`
1737
+ # grid, spaced `step` apart. `unit` is the resolution the result is
1738
+ # stored on and `step` is the spacing, so an hourly grid sampled once a
1739
+ # day is `unit: :h, step: "1 day"`. With no `step` the spacing is one
1740
+ # `unit` tick (consecutive ticks). Off-grid endpoints floor to their
1741
+ # bucket head (toward the past); the phase is anchored at `start`, and
1742
+ # `last` is a bound rather than a member -- the series stops at the last
1743
+ # step at or before it.
1744
+ # @param start [Time, String, Integer, DateTime, CATime::Element] first
1745
+ # instant.
1746
+ # @param last [Time, String, Integer, DateTime, CATime::Element] last
1747
+ # instant (inclusive).
1748
+ # @param unit [Resolution, Symbol, String] grid resolution (tick).
1749
+ # @param step [Resolution, Symbol, String, nil] spacing between elements
1750
+ # (default: one `unit` tick). Must be a whole multiple of `unit`.
1751
+ # @param format [String, nil] optional strptime format for String inputs.
1752
+ # @return [CATime]
1753
+ # @raise [ArgumentError] when `step` is not a whole multiple of `unit`
1754
+ # (including a calendar `step` on a fixed-length `unit`, e.g. a month
1755
+ # step on an hour grid -- a month is not a fixed number of hours).
1756
+ def self.time_range(start, last, unit:, step: nil, format: nil)
1757
+ res = CATime::Resolution.parse(unit)
1758
+ stride = step.nil? ? 1 :
1759
+ CATimeUnitAlgebra.multiple_factor(CATime::Resolution.parse(step), res)
1760
+ s = CATimeLiteral.tick_index(start, res, format)
1761
+ e = CATimeLiteral.tick_index(last, res, format)
1762
+ n = e < s ? 0 : (e - s) / stride + 1
1763
+ CArray.int64(n) {|i| s + i * stride }.time(unit: res)
1764
+ end
1765
+
1766
+ # @overload time_series(start, count:, unit:, step: nil, format: nil)
1767
+ # Returns a {CATime} of `count` instants starting at `start` on the
1768
+ # `unit` grid, spaced `step` apart. `unit` is the resolution the result
1769
+ # is stored on and `step` is the spacing, so an hourly grid sampled once
1770
+ # a day is `unit: :h, step: "1 day"`. With no `step` the spacing is one
1771
+ # `unit` tick (consecutive ticks, as before).
1772
+ # @param start [Time, String, Integer, DateTime, CATime::Element] first
1773
+ # instant.
1774
+ # @param count [Integer] number of elements.
1775
+ # @param unit [Resolution, Symbol, String] grid resolution (tick).
1776
+ # @param step [Resolution, Symbol, String, nil] spacing between elements
1777
+ # (default: one `unit` tick). Must be a whole multiple of `unit`.
1778
+ # @param format [String, nil] optional strptime format for String inputs.
1779
+ # @return [CATime]
1780
+ # @raise [ArgumentError] when `step` is not a whole multiple of `unit`
1781
+ # (including a calendar `step` on a fixed-length `unit`, e.g. a month
1782
+ # step on an hour grid -- a month is not a fixed number of hours).
1783
+ def self.time_series(start, count:, unit:, step: nil, format: nil)
1784
+ res = CATime::Resolution.parse(unit)
1785
+ stride = step.nil? ? 1 :
1786
+ CATimeUnitAlgebra.multiple_factor(CATime::Resolution.parse(step), res)
1787
+ s = CATimeLiteral.tick_index(start, res, format)
1788
+ CArray.int64(count) {|i| s + i * stride }.time(unit: res)
1789
+ end
1790
+
1791
+ # @overload time(x, unit: :s, format: nil, on_error: :raise)
1792
+ # Builds a {CATime} on the `unit` grid from time value(s). `x`
1793
+ # is either a single literal (Time / ISO 8601 String / Unix-seconds
1794
+ # Integer / DateTime) — giving a 1-element result — or a CArray of such
1795
+ # literals — giving a same-shape result parsed per cell. Parsing is UTC
1796
+ # and DateTime-independent.
1797
+ #
1798
+ # A value that cannot be parsed raises by default (`on_error: :raise`);
1799
+ # pass `on_error: :mask` to make it an UNDEF cell instead. A masked /
1800
+ # `nil` input cell is a *missing* value (not a parse failure) and always
1801
+ # becomes UNDEF, regardless of `on_error`.
1802
+ # @param x [Time, String, Integer, DateTime, CATime::Element, Array, CArray] a literal, a
1803
+ # Ruby Array of literals, or a CArray of literals.
1804
+ # @param unit [Resolution, Symbol, String] target grid resolution.
1805
+ # @param format [String, nil] optional strptime format for String input.
1806
+ # @param on_error [:raise, :mask] parse-failure policy (default `:raise`).
1807
+ # @return [CATime] shape `[1]` for a literal, else `x`'s shape.
1808
+ # @raise [ArgumentError] on an unparseable value when `on_error: :raise`.
1809
+ #
1810
+ # @example
1811
+ # CArray.time("2024-06-15", unit: :D) # 1-element
1812
+ # CArray.time(%w[2024-01-01 2024-02-01], unit: :D) # Ruby Array
1813
+ # CArray.time(CA_OBJECT(["2024-01-01", "oops"]), unit: :D, on_error: :mask)
1814
+ def self.time(x, unit: :s, format: nil, on_error: :raise)
1815
+ res = CATime::Resolution.parse(unit)
1816
+ unless %i[raise mask].include?(on_error)
1817
+ raise ArgumentError, "on_error: must be :raise or :mask (got #{on_error.inspect})"
1818
+ end
1819
+ x = CA_OBJECT(x) if x.is_a?(Array) # Ruby Array of literals -> object CArray
1820
+ unless x.is_a?(CArray)
1821
+ return CATimeLiteral.to_time_array(x, res, format, on_error)
1822
+ end
1823
+ raw = CArray.int64(*x.shape)
1824
+ x.each_index do |*idx|
1825
+ s = x[*idx]
1826
+ if s == UNDEF || s.nil?
1827
+ raw[*idx] = UNDEF # missing input -> missing output (no phantom epoch)
1828
+ next
1829
+ end
1830
+ begin
1831
+ raw[*idx] = CATimeLiteral.tick_index(s, res, format)
1832
+ rescue ArgumentError, TypeError
1833
+ raise if on_error == :raise
1834
+ raw[*idx] = UNDEF # opt-in parse-mask
1835
+ end
1836
+ end
1837
+ raw.time(unit: res)
1838
+ end
1839
+
1840
+ # @overload time(unit: :ns, origin: nil)
1841
+ # Returns `self` as a {CATime} on the `unit` grid. With `origin`
1842
+ # nil, `self`'s int64 values are taken as tick indices already anchored
1843
+ # to the Unix epoch (zero-copy Face wrap). With `origin` given, `self`'s
1844
+ # indices are relative to `origin` and are rebased to the epoch (a new
1845
+ # int64 array is built; the origin is not stored).
1846
+ # @param unit [Resolution, Symbol, String] grid resolution.
1847
+ # @param origin [Time, String, Integer, DateTime, CATime::Element, nil] base instant that
1848
+ # `self`'s indices are counted from (default: the Unix epoch).
1849
+ # @return [CATime]
1850
+ # @note An `int64` receiver is wrapped zero-copy. A narrower integer type
1851
+ # is widened to `int64` first (a copy); a `Float` / non-integer type
1852
+ # raises (cast it explicitly if the truncation is intended).
1853
+ def time(unit: :ns, origin: nil)
1854
+ res = CATime::Resolution.parse(unit)
1855
+ src = as_int64_time_storage
1856
+ if origin.nil?
1857
+ CATime.wrap(src, unit: res)
18
1858
  else
19
- return convert() {|v| DateTime.parse(v) }
1859
+ o = CATimeLiteral.tick_index(origin, res)
1860
+ (src + o).time(unit: res)
1861
+ end
1862
+ end
1863
+
1864
+ # @overload timedelta(unit: :ns)
1865
+ # Returns `self` re-wrapped as a zero-copy {CATimedelta} view
1866
+ # with the given `unit`.
1867
+ # @param unit [Resolution, Symbol, String] duration resolution.
1868
+ # @return [CATimedelta]
1869
+ # @note An `int64` receiver is wrapped zero-copy; a narrower integer type
1870
+ # is widened to `int64` first (a copy); a `Float` / non-integer raises.
1871
+ def timedelta(unit: :ns)
1872
+ CATimedelta.wrap(as_int64_time_storage, unit: unit)
1873
+ end
1874
+
1875
+ # Coerce the receiver to the int64 storage a time / timedelta Face needs:
1876
+ # int64 passes through (zero-copy), a narrower integer type widens losslessly
1877
+ # to int64 (a copy), and any non-integer (Float, boolean, object, ...) raises.
1878
+ def as_int64_time_storage
1879
+ return self if data_type == CA_INT64
1880
+ unless integer?
1881
+ raise TypeError,
1882
+ "CATime / CATimedelta storage must be int64; a #{data_type} " \
1883
+ "array cannot be wrapped (cast it explicitly, e.g. " \
1884
+ "arr.int64.time(...), if a lossy conversion is intended)"
1885
+ end
1886
+ int64
1887
+ end
1888
+ private :as_int64_time_storage
1889
+ end
1890
+
1891
+ # ============================================================================
1892
+ # timestep system (P1/P2 integer path)
1893
+ #
1894
+ # Projects an absolute time onto an integer "step index" -- the k-th
1895
+ # fixed-width bucket of `step` counted from `origin` -- and the inverse /
1896
+ # rounding / on-grid operators built on it. All storage-domain int64 and
1897
+ # vectorized; the crown jewel is that bucketing, matching, and positional
1898
+ # addressing across differently-scaled series all become integer arithmetic.
1899
+ #
1900
+ # Integer path covers: fixed-length step on fixed-length storage, and Y/M
1901
+ # step on Y/M storage (:M storage already encodes a linear month ordinal, so
1902
+ # it floor-divides identically). The calendar path (Y/M step on sub-day
1903
+ # storage, via civil-date algebra) is P3 and currently raises.
1904
+ # See devel/PROPOSAL_DATETIME64_STEP_SYSTEM.md.
1905
+ # ============================================================================
1906
+
1907
+ # Places a bucket grid on a storage resolution: resolves a timestep into whole
1908
+ # storage ticks, resolves an origin into a tick count (or a month ordinal on
1909
+ # the calendar path), and guards both against int64 overflow. Internal to the
1910
+ # time surface: nothing here is part of the public API.
1911
+ module CATimeGrid
1912
+ module_function
1913
+
1914
+ # Base units that are day-or-finer (a period head is representable exactly;
1915
+ # the civil path targets these for a calendar bucket).
1916
+ SU_LE_DAY = %i[D h m s ms us ns ps fs as].freeze
1917
+
1918
+ # Smallest value an int64 storage tick can hold.
1919
+ INT64_MIN = -(2**63)
1920
+ # Largest value an int64 storage tick can hold.
1921
+ INT64_MAX = 2**63 - 1
1922
+
1923
+ # Raise (rather than let an int64 CArray operand silently wrap) if a
1924
+ # Ruby-domain quantity is out of int64 range. Returns the value on success
1925
+ # so it composes inline.
1926
+ def check_int64_range(v, what)
1927
+ if v < INT64_MIN || v > INT64_MAX
1928
+ raise RangeError,
1929
+ "time step: #{what} = #{v} overflows int64 " \
1930
+ "(the storage unit is too fine for this step / origin / span; " \
1931
+ "use a coarser unit)"
1932
+ end
1933
+ v
1934
+ end
1935
+
1936
+ # Return [mul, step_ticks, origin_ticks] (all Integer) for the integer
1937
+ # path -- step_ticks and origin_ticks in numerator ticks, the finer of
1938
+ # the storage and step grids (see #resolve_timestep_grid). The calendar path is
1939
+ # handled by the caller.
1940
+ def resolve_integer_grid(step_res, storage_res, origin)
1941
+ mul, step_ticks = resolve_step_scale(step_res, storage_res)
1942
+ [mul, step_ticks, resolve_origin_ticks(origin, step_res,
1943
+ mul == 1 ? storage_res : step_res)]
1944
+ end
1945
+
1946
+ # Storage ticks per day of `storage_res` (must be a whole number, else the
1947
+ # storage grid does not tile a day -- a calendar bucket is unrepresentable).
1948
+ def ticks_per_day(storage_res)
1949
+ r = CATimeUnitAlgebra.ratio(CATime::Resolution.new(1, :D), storage_res)
1950
+ unless r.denominator == 1
1951
+ raise ArgumentError,
1952
+ "cannot place a calendar bucket on storage resolution " \
1953
+ "#{storage_res} (its tick does not tile a day)"
1954
+ end
1955
+ check_int64_range(r.numerator, "ticks per day for #{storage_res}")
1956
+ end
1957
+
1958
+ # Storage ticks of the bucket head at timestep `k` (int64 CArray) for a
1959
+ # calendar bucket: ym0 + k*count months -> day 1 of that month -> ticks.
1960
+ def calendar_bucket_head_ticks(k, st, storage_res, origin)
1961
+ tpd = ticks_per_day(storage_res)
1962
+ count = st.count * (st.base == :Y ? 12 : 1)
1963
+ ym0 = origin_month_ordinal(origin)
1964
+ ymk = ym0 + k * count
1965
+ yy = ymk / 12
1966
+ mm = ymk - yy * 12 + 1
1967
+ CATimeCivil.days_from_civil(yy, mm, CArray.int64(*k.shape) { 1 }) * tpd
1968
+ end
1969
+
1970
+ # Month ordinal (year*12 + month-1) of origin; day / time ignored.
1971
+ # Default (nil) is the epoch month 1970-01.
1972
+ def origin_month_ordinal(origin)
1973
+ return 1970 * 12 if origin.nil?
1974
+ y, m = check_calendar_origin(origin, nil)
1975
+ y * 12 + (m - 1)
1976
+ end
1977
+
1978
+ # [mul, step_ticks] for the integer path, or :civil (calendar bucket on
1979
+ # day-or-finer storage), or raise. Bucket arithmetic runs in the finer of
1980
+ # the two grids, so exactly one of the pair is > 1:
1981
+ # - bucket at or coarser than the storage tick (a :h bucket on :s
1982
+ # storage) -> [1, N]: N storage ticks per bucket.
1983
+ # - bucket finer than the storage tick (a :h bucket on :D storage) ->
1984
+ # [N, 1]: one storage tick spans N buckets exactly, so a timestep is a
1985
+ # plain widening. This needs the storage tick to be a *whole* multiple
1986
+ # of the bucket tick; a partial multiple ("90 minutes" storage against
1987
+ # an :h bucket) has no integer timestep and raises.
1988
+ def resolve_step_scale(step_res, storage_res)
1989
+ r = CATimeUnitAlgebra.ratio(step_res, storage_res) # storage ticks / step tick
1990
+ if r
1991
+ what = "bucket #{step_res} in ticks of #{storage_res}"
1992
+ return [1, check_int64_range(r.numerator, what)] if r.denominator == 1
1993
+ return [check_int64_range(r.denominator, what), 1] if r.numerator == 1
1994
+ end
1995
+ if CATimeUnitAlgebra::CALENDAR.key?(step_res.base) && SU_LE_DAY.include?(storage_res.base)
1996
+ return :civil
1997
+ end
1998
+ raise ArgumentError,
1999
+ "cannot express bucket #{step_res} on storage resolution #{storage_res} " \
2000
+ "(neither is a whole multiple of the other)"
2001
+ end
2002
+
2003
+ # origin -> integer tick count in storage ticks. Default (nil) is the
2004
+ # epoch, except a week bucket defaults to ISO Monday (1970-01-05). A lossy
2005
+ # conversion (origin not landing exactly on the storage grid) raises; a
2006
+ # bare Integer is rejected (epoch-dependent, ambiguous).
2007
+ def resolve_origin_ticks(origin, step_res, storage_res)
2008
+ if origin.nil?
2009
+ if step_res.base == :W && SU_LE_DAY.include?(storage_res.base)
2010
+ return check_int64_range(ticks_per_day(storage_res) * 4,
2011
+ "ISO-Monday week origin in ticks of #{storage_res}")
2012
+ end
2013
+ return 0
2014
+ end
2015
+ if CATimeUnitAlgebra::FIXED.key?(storage_res.base)
2016
+ secs = origin_seconds_exact(origin)
2017
+ tick = secs / storage_res.tick_ratio # Rational
2018
+ unless tick.denominator == 1
2019
+ raise ArgumentError,
2020
+ "origin #{origin.inspect} is not representable losslessly " \
2021
+ "in storage resolution #{storage_res} (would truncate the grid phase)"
2022
+ end
2023
+ check_int64_range(tick.numerator, "origin #{origin.inspect} in ticks of #{storage_res}")
2024
+ else # :Y / :M storage
2025
+ y, m = check_calendar_origin(origin, storage_res)
2026
+ ord = storage_res.base == :Y ? (y - 1970) : (y * 12 + (m - 1) - 1970 * 12)
2027
+ if storage_res.count > 1
2028
+ unless (ord % storage_res.count).zero?
2029
+ raise ArgumentError,
2030
+ "origin #{origin.inspect} is not on the #{storage_res} grid"
2031
+ end
2032
+ ord /= storage_res.count
2033
+ end
2034
+ ord
20
2035
  end
21
2036
  end
22
2037
 
23
- def str_to_time (template = nil)
24
- if template
25
- return str_strptime(template)
2038
+ # Exact Rational seconds since the Unix epoch for a fixed-storage origin.
2039
+ # Everything but a bare Integer goes through the shared entry point, so
2040
+ # an origin reads the same here as it does for a start literal.
2041
+ def origin_seconds_exact(origin)
2042
+ case origin
2043
+ when Integer
2044
+ raise ArgumentError,
2045
+ "origin: a bare Integer is ambiguous (epoch-dependent); pass a " \
2046
+ "Time / String / CATime scalar"
26
2047
  else
27
- return convert() {|v| Time.parse(v) }
2048
+ CATimeLiteral.epoch_seconds(origin) # Time / String / DateTime / Element
28
2049
  end
29
2050
  end
30
2051
 
31
- def time_format (template = nil)
32
- if template
33
- return time_strftime(template)
2052
+ # Whether `origin` sits exactly on the head of its month (the 1st at
2053
+ # 00:00 UTC). A calendar grid is addressed by month ordinal, so its
2054
+ # bucket heads are month heads and nothing else; an origin anywhere in
2055
+ # between names a bucket that does not exist.
2056
+ def origin_on_month_head?(origin)
2057
+ # A calendar element is a month head by construction, and the shared
2058
+ # entry point says so by handing back that midnight -- no branch here
2059
+ # has to know it.
2060
+ secs = CATimeLiteral.epoch_seconds(origin)
2061
+ y, m = origin_year_month(origin)
2062
+ head = CATimeCivil.days_from_civil(CA_INT64([y]), CA_INT64([m]), CA_INT64([1]))[0]
2063
+ secs == head * 86400
2064
+ end
2065
+
2066
+ # Raise unless `origin` can be the head of bucket 0 on a calendar grid:
2067
+ # it has to be a month head, and a :Y tick starts in January. The day
2068
+ # and time would otherwise be dropped silently -- the same loss a fixed
2069
+ # unit already refuses (see resolve_origin_ticks).
2070
+ def check_calendar_origin(origin, storage_res)
2071
+ return if origin.nil?
2072
+ y, m = origin_year_month(origin)
2073
+ unless origin_on_month_head?(origin)
2074
+ raise ArgumentError,
2075
+ "origin #{origin.inspect} is not a month head: a calendar grid " \
2076
+ "is addressed by month, so its origin must be the 1st at 00:00"
2077
+ end
2078
+ if storage_res && storage_res.base == :Y && m != 1
2079
+ raise ArgumentError,
2080
+ "origin #{origin.inspect} is not on the #{storage_res} grid " \
2081
+ "(a #{storage_res} tick starts in January)"
2082
+ end
2083
+ [y, m]
2084
+ end
2085
+
2086
+ # [year, month] of a calendar-storage origin (finer fields ignored).
2087
+ def origin_year_month(origin)
2088
+ case origin
2089
+ when Integer
2090
+ raise ArgumentError,
2091
+ "origin: a bare Integer is ambiguous (epoch-dependent); pass a " \
2092
+ "Time / String / CATime scalar"
34
2093
  else
35
- return convert(&:to_s)
36
- end
2094
+ CATimeLiteral.year_month(origin) # Time / String / DateTime / Element
2095
+ end
2096
+ end
2097
+ end
2098
+
2099
+ class CATime
2100
+ # A tick resolution: `count` ticks of a `base` unit. The human surface is
2101
+ # a String ("3 hours" / "1 month"); a bare Symbol (:h) is the count-1
2102
+ # shorthand over the base-unit vocabulary. It names both a storage tick
2103
+ # (= the grid a CATime is stored on) and a coarser bucket for the
2104
+ # floor / timesteps family. Value object: frozen, value-equal, hashable.
2105
+ class Resolution
2106
+ attr_reader :count, :base
2107
+
2108
+ # Long / abbreviated unit words -> base unit symbol. Calendar words
2109
+ # (ayear etc.) are intentionally dropped (standard calendar only).
2110
+ WORDS = {
2111
+ "year" => :Y, "years" => :Y, "yr" => :Y, "yrs" => :Y,
2112
+ "month" => :M, "months" => :M, "mon" => :M, "mons" => :M,
2113
+ "week" => :W, "weeks" => :W,
2114
+ "day" => :D, "days" => :D,
2115
+ "hour" => :h, "hours" => :h, "hr" => :h, "hrs" => :h,
2116
+ "minute" => :m, "minutes" => :m, "min" => :m, "mins" => :m,
2117
+ "second" => :s, "seconds" => :s, "sec" => :s, "secs" => :s,
2118
+ "millisecond" => :ms, "milliseconds" => :ms, "msec" => :ms, "msecs" => :ms,
2119
+ "microsecond" => :us, "microseconds" => :us, "usec" => :us, "usecs" => :us,
2120
+ "nanosecond" => :ns, "nanoseconds" => :ns,
2121
+ "picosecond" => :ps, "picoseconds" => :ps,
2122
+ "femtosecond" => :fs, "femtoseconds" => :fs,
2123
+ "attosecond" => :as, "attoseconds" => :as,
2124
+ }.freeze
2125
+
2126
+ # Single-letter / short symbols accepted as the count-1 shorthand. These
2127
+ # are the base-unit letters (case-sensitive: :m minute vs :M month).
2128
+ SYMBOLS = %i[Y M W D h m s ms us ns ps fs as].freeze
2129
+
2130
+ # Coerces `spec` into a {Resolution}: a Resolution passes through, a base
2131
+ # unit Symbol becomes count 1, and a String such as `"10 minutes"` is parsed.
2132
+ # @param spec [Resolution, Symbol, String]
2133
+ # @return [Resolution]
2134
+ # @raise [ArgumentError] when `spec` names no known unit.
2135
+ def self.parse(spec)
2136
+ case spec
2137
+ when Resolution then spec
2138
+ when Symbol
2139
+ unless SYMBOLS.include?(spec)
2140
+ raise ArgumentError, "invalid unit #{spec.inspect} " \
2141
+ "(one of #{SYMBOLS.map(&:inspect).join(', ')})"
2142
+ end
2143
+ new(1, spec)
2144
+ when String
2145
+ # Strict grammar: "<unit>" (count 1) or "<n> <unit>" (whitespace
2146
+ # required -- compact "3h" is rejected).
2147
+ m = spec.strip.match(/\A(?:(\d+)\s+)?([A-Za-z]+)\z/)
2148
+ unless m && WORDS.key?(m[2].downcase)
2149
+ raise ArgumentError, "invalid unit spec #{spec.inspect} " \
2150
+ "(use e.g. \"3 hours\" / \"1 month\", or a unit Symbol)"
2151
+ end
2152
+ new(m[1] ? Integer(m[1]) : 1, WORDS[m[2].downcase])
2153
+ else
2154
+ raise ArgumentError, "unit spec must be a String / Symbol / Resolution " \
2155
+ "(got #{spec.class})"
2156
+ end
2157
+ end
2158
+
2159
+ def initialize(count, base)
2160
+ raise ArgumentError, "unit count must be >= 1 (got #{count})" if count < 1
2161
+ # Only integer multiples are well-defined (calendar Y/M bases are
2162
+ # month-ordinal linear, so a fractional multiple has no meaning).
2163
+ # Resolution.parse always hands in an Integer; a fractional count via
2164
+ # a direct `new` is not guarded here.
2165
+ @count = count
2166
+ @base = base
2167
+ freeze
2168
+ end
2169
+
2170
+ # seconds- (fixed base) or months- (calendar base) per tick (Rational).
2171
+ def tick_ratio
2172
+ count * CATimeUnitAlgebra.base_ratio(base)
2173
+ end
2174
+
2175
+ # @return [Boolean] whether `other` is the same count and base unit.
2176
+ def ==(other) = other.is_a?(Resolution) && other.count == count && other.base == base
2177
+ alias eql? ==
2178
+ # @return [Integer] a hash consistent with #==.
2179
+ def hash = [count, base].hash
2180
+ # @return [String] e.g. `"h"` for a count of 1, `"10 minutes"` otherwise.
2181
+ def to_s = count == 1 ? base.to_s : "#{count} #{base}"
2182
+ # @return [String]
2183
+ def inspect = "#<CATime::Resolution #{count} #{base}>"
2184
+ end
2185
+
2186
+ # A tick resolution *and where its ticks are anchored*: the `(unit:,
2187
+ # origin:)` pair that {#timesteps}, {#snap} and {.from_timesteps} already
2188
+ # take, given a name and a value.
2189
+ #
2190
+ # {Resolution} says how wide a tick is; Grid says that and where tick 0
2191
+ # starts. A CATime is stored as epoch-anchored int64 either way, so this
2192
+ # reifies a calling convention -- it does not add a second time
2193
+ # representation, and the reference-time epoch stays out of storage.
2194
+ #
2195
+ # g = CATime::Grid.parse("12 hours since 2017-11-30 09:00")
2196
+ # t.snap(g, direction: :floor) # the pair, passed once
2197
+ # t.timesteps(g) # which tick each element falls in
2198
+ # g.at(CA_INT64([0, 1, 2])) # and back
2199
+ #
2200
+ # Proleptic Gregorian, UTC. There is no calendar seam here on purpose: a
2201
+ # swappable calendar taxes every operation that touches a time, and the
2202
+ # benefit does not follow.
2203
+ class Grid
2204
+
2205
+ # udunits' origin-shift operators, all equivalent: the words `since`,
2206
+ # `after`, `from`, `ref` (whitespace-separated) and the `@` sign, which
2207
+ # needs none ("seconds@1970-01-01"). Generous on input because a
2208
+ # `units` attribute is written by whoever wrote the file; {#to_s} only
2209
+ # ever emits `since`, which is what CF-conforming files use.
2210
+ ORIGIN_SHIFT = /\A(.+?)(?:\s+(?:since|after|from|ref)\s+|\s*@\s*)(.+)\z/i
2211
+
2212
+ # Plural unit words, the spelling {Resolution.parse} reads back. (Bare
2213
+ # Resolution#to_s does not round-trip: it prints "h" / "12 h", and
2214
+ # Resolution.parse takes neither.)
2215
+ WORD = { Y: "years", M: "months", W: "weeks", D: "days", h: "hours",
2216
+ m: "minutes", s: "seconds", ms: "milliseconds",
2217
+ us: "microseconds", ns: "nanoseconds", ps: "picoseconds",
2218
+ fs: "femtoseconds", as: "attoseconds" }.freeze
2219
+
2220
+ # Storage resolutions to fall back on when the unit's own tick cannot
2221
+ # hold the origin exactly (a day grid anchored at 12:00 needs seconds).
2222
+ FALLBACK_BASES = %i[s ms us ns].freeze
2223
+
2224
+ # @param spec ["<unit> since <instant>", unit spec, Grid]
2225
+ # @param origin [Time, String, DateTime, Element, nil] when `spec`
2226
+ # carries no origin-shift clause.
2227
+ # @return [Grid]
2228
+ def self.parse(spec, origin: nil)
2229
+ return spec if spec.is_a?(Grid)
2230
+ if spec.is_a?(String) and (fields = ORIGIN_SHIFT.match(spec))
2231
+ new(fields[1], fields[2])
2232
+ else
2233
+ new(spec, origin)
2234
+ end
2235
+ end
2236
+
2237
+ # Normalizes the three ways a grid reaches a method -- positionally, as
2238
+ # `unit:`, or as the loose `(unit:, origin:)` pair -- into that pair.
2239
+ # One call at the head of a method is the whole Grid arm.
2240
+ # @return [Array(Object, Object)] (unit, origin).
2241
+ def self.resolve(grid, unit, origin)
2242
+ return [grid.unit, grid.origin] if grid.is_a?(Grid)
2243
+ return [unit.unit, unit.origin] if unit.is_a?(Grid)
2244
+ unless grid.nil?
2245
+ raise ArgumentError,
2246
+ "the positional argument must be a CATime::Grid (got #{grid.class})"
2247
+ end
2248
+ [unit, origin]
2249
+ end
2250
+
2251
+ def initialize(unit, origin = nil)
2252
+ @unit = Resolution.parse(unit)
2253
+ @origin = origin
2254
+ @storage = resolve_storage
2255
+ @origin = CArray.time(origin, unit: @storage)[0] if origin
2256
+ freeze
2257
+ end
2258
+
2259
+ # @return [Resolution] the tick this grid counts in.
2260
+ attr_reader :unit
2261
+
2262
+ # @return [Element, nil] the instant tick 0 starts at. nil is the epoch,
2263
+ # which is what every `origin:` keyword already defaults to.
2264
+ attr_reader :origin
2265
+
2266
+ # @return [Resolution] the resolution an array on this grid is stored on:
2267
+ # the unit itself, or finer when the origin's phase needs it. Derived
2268
+ # from (unit, origin) and memoized here, so a grid's identity stays the
2269
+ # (count, unit, origin) triple.
2270
+ attr_reader :storage
2271
+
2272
+ # @return [Boolean] whether the unit is a calendar one (:Y / :M), whose
2273
+ # ticks are month ordinals rather than a fixed number of seconds.
2274
+ def calendar? = %i[Y M].include?(@unit.base)
2275
+
2276
+ # @return [Integer] storage ticks per unit tick.
2277
+ def step_ticks = Integer(@unit.tick_ratio / @storage.tick_ratio)
2278
+
2279
+ # @return [Integer] storage ticks from the epoch to the origin.
2280
+ def origin_ticks
2281
+ @origin ? Integer(CATimeLiteral.epoch_seconds(@origin) / @storage.tick_ratio) : 0
2282
+ end
2283
+
2284
+ # Tick indices -> instants: the {CATime.from_timesteps} direction, but
2285
+ # answering on {#storage} so a phased origin survives.
2286
+ # @param k [CArray, Integer]
2287
+ # @return [CATime, Element]
2288
+ def at(k)
2289
+ return at(CArray.int64(1) { Integer(k) })[0] unless k.is_a?(CArray)
2290
+ return CATime.from_timesteps(k.int64, unit: @unit, origin: @origin) if calendar?
2291
+ (origin_ticks + k.int64 * step_ticks).time(unit: @storage)
2292
+ end
2293
+
2294
+ # Instants -> tick indices: the {CATime#timesteps} direction. Off-grid
2295
+ # elements floor toward the past; ask {#on} when that would be a lie.
2296
+ # @return [CArray] int64.
2297
+ def index(time) = time.timesteps(self)
2298
+
2299
+ # @return [CArray] boolean, flagging elements that land on a tick.
2300
+ def on(time) = time.is_righttime(self)
2301
+
2302
+ # @return [Boolean] whether every element lands on a tick.
2303
+ def on?(time) = on(time).all
2304
+
2305
+ # A regular series on this grid.
2306
+ def range(start, last) = CArray.time_range(start, last, unit: @storage, step: @unit)
2307
+ # A regular series on this grid: +count+ elements from +start+, one per
2308
+ # tick.
2309
+ def series(start, count:) = CArray.time_series(start, count: count, unit: @storage, step: @unit)
2310
+
2311
+ # @return [String] "12 hours since 2017-11-30 09:00:00", which {.parse}
2312
+ # reads back into an equal grid.
2313
+ def to_s
2314
+ word = WORD.fetch(@unit.base)
2315
+ spec = @unit.count == 1 ? word : "#{@unit.count} #{word}"
2316
+ @origin ? "#{spec} since #{@origin.to_time.strftime('%Y-%m-%d %H:%M:%S')}" : spec
2317
+ end
2318
+
2319
+ # @return [String] the {#to_s} spec, wrapped for the console.
2320
+ def inspect = "#<CATime::Grid #{self}>"
2321
+
2322
+ # Two grids are equal when they place ticks in the same places: same
2323
+ # timestep, same origin.
2324
+ # @return [Boolean]
2325
+ def ==(other) = other.is_a?(Grid) && other.unit == @unit && other.to_s == to_s
2326
+ alias eql? ==
2327
+
2328
+ # Hashes with {#==}, so a grid works as a Hash key.
2329
+ # @return [Integer]
2330
+ def hash = [@unit, to_s].hash
2331
+
2332
+ private
2333
+
2334
+ # The coarsest storage holding both the unit's tick and the origin's
2335
+ # phase without truncation. A calendar grid is addressed by month, so
2336
+ # its origin is already pinned to a month head and the unit serves.
2337
+ def resolve_storage
2338
+ return @unit if calendar?
2339
+ candidates = [@unit, *FALLBACK_BASES.map {|base| Resolution.new(1, base) }]
2340
+ candidates.each do |res|
2341
+ next unless (@unit.tick_ratio / res.tick_ratio).denominator == 1
2342
+ next unless @origin.nil? ||
2343
+ (CATimeLiteral.epoch_seconds(@origin) / res.tick_ratio).denominator == 1
2344
+ return res
2345
+ end
2346
+ raise ArgumentError,
2347
+ "origin #{@origin.inspect} cannot be held exactly on any storage " \
2348
+ "resolution for unit #{@unit}"
2349
+ end
2350
+ end
2351
+
2352
+ # Rounding rules {#snap} accepts, the same three CArray#snap takes.
2353
+ SNAP_DIRECTIONS = %i[round floor ceil].freeze
2354
+
2355
+ # The grid this array is stored on: its own tick, anchored at the epoch.
2356
+ # `t.timesteps` is `t.timesteps(t.grid)`, which is what the `unit:` default
2357
+ # has always meant.
2358
+ # @return [Grid]
2359
+ def grid = Grid.new(unit)
2360
+
2361
+ # @overload timesteps(unit: self.unit, origin: nil)
2362
+ # Returns the integer timestep of every element: the k-th `unit`-wide
2363
+ # bucket counted from `origin` (floor toward the past, so pre-`origin`
2364
+ # elements get a negative index -- a normal value, not masked). The
2365
+ # result is an int64 CArray; the input mask propagates. With no `unit`
2366
+ # the bucket is the storage resolution itself, so the result is a copy of
2367
+ # the raw tick indices since the epoch (the same values as
2368
+ # {#ticks}, but a fresh array rather than the live storage).
2369
+ # @param unit [String, Symbol, Resolution] bucket resolution
2370
+ # (default: this array's own storage resolution).
2371
+ # @param origin [Time, String, CATime::Element, DateTime, nil] head of
2372
+ # bucket 0 (default: the Unix epoch, or ISO Monday for a week bucket
2373
+ # on day-or-finer storage -- a week grid counts from the epoch
2374
+ # Thursday and cannot hold a Monday, so it keeps its own ticks).
2375
+ # It has to be a bucket head itself: on a calendar grid, the 1st at
2376
+ # 00:00.
2377
+ # @return [CArray] int64 timesteps.
2378
+ # @raise [ArgumentError] on a sub-resolution / unrepresentable (unit,
2379
+ # storage-resolution) pair or a lossy origin.
2380
+ def timesteps(grid = nil, unit: nil, origin: nil)
2381
+ unit, origin = Grid.resolve(grid, unit, origin)
2382
+ unit = self.unit if unit.nil?
2383
+ g = resolve_timestep_grid(unit, origin)
2384
+ return calendar_bucket(:index, g[1], origin) if g[0] == :civil
2385
+ su, mul, step_ticks, o = g
2386
+ raise_if_int64_overflow(su, mul, o)
2387
+ d = storage_ticks_in_numerator_grid(mul) - o
2388
+ q = d / step_ticks
2389
+ q - (d - q * step_ticks).lt(0) # floor-div correction (%-independent)
37
2390
  end
38
2391
 
39
- def time_year
40
- return convert(:int, &:year)
2392
+ # @overload floor(unit:, origin: nil)
2393
+ # Returns each element floored to its bucket head (toward the past), as a
2394
+ # {CATime} in the same storage resolution.
2395
+ # @param unit [String, Symbol, Resolution] bucket resolution.
2396
+ # @param origin [Time, String, CATime::Element, DateTime, nil] head of
2397
+ # bucket 0 (default: the Unix epoch, or ISO Monday for a week bucket
2398
+ # on day-or-finer storage -- a week grid counts from the epoch
2399
+ # Thursday and cannot hold a Monday, so it keeps its own ticks).
2400
+ # It has to be a bucket head itself: on a calendar grid, the 1st at
2401
+ # 00:00.
2402
+ # @return [CATime]
2403
+ # @overload snap(grid = nil, unit:, origin: nil, direction: :round)
2404
+ # Returns each element snapped to a point on the bucket grid, as a
2405
+ # {CATime} in the same storage resolution. This is the time-domain twin
2406
+ # of {CArray#snap}: there, `(step, offset:, direction:)`; here a {Grid}
2407
+ # (= tick + origin), or the same pair spelled as `unit:` / `origin:`,
2408
+ # and the same three directions.
2409
+ #
2410
+ # `:round` picks the nearest bucket head, ties toward the future;
2411
+ # `:floor` the head at or before; `:ceil` the head at or after.
2412
+ # {#floor}, {#ceil} and {#round} are this method with `direction:` fixed.
2413
+ # @param grid [Grid, nil] the bucket grid, passed as one value.
2414
+ # @param unit [String, Symbol, Resolution, Grid] bucket resolution.
2415
+ # @param origin [Time, String, CATime::Element, DateTime, nil] head of
2416
+ # bucket 0 (default: the Unix epoch, or ISO Monday for a week bucket
2417
+ # on day-or-finer storage -- a week grid counts from the epoch
2418
+ # Thursday and cannot hold a Monday, so it keeps its own ticks).
2419
+ # It has to be a bucket head itself: on a calendar grid, the 1st at
2420
+ # 00:00.
2421
+ # @param direction [:round, :floor, :ceil] rounding rule.
2422
+ # @return [CATime]
2423
+ def snap(grid = nil, unit: nil, origin: nil, direction: :round)
2424
+ unless SNAP_DIRECTIONS.include?(direction)
2425
+ raise ArgumentError,
2426
+ "snap: direction must be :round / :floor / :ceil " \
2427
+ "(got #{direction.inspect})"
2428
+ end
2429
+ unit, origin = Grid.resolve(grid, unit, origin)
2430
+ g = resolve_timestep_grid(unit, origin)
2431
+ return calendar_bucket(direction, g[1], origin) if g[0] == :civil
2432
+ su, mul, step_ticks, o = g
2433
+ raise_if_int64_overflow(su, mul, o, step_ticks, direction)
2434
+ n = storage_ticks_in_numerator_grid(mul)
2435
+ d = n - o
2436
+ q = d / step_ticks
2437
+ q = q - (d - q * step_ticks).lt(0) # floor bucket
2438
+ case direction
2439
+ when :ceil
2440
+ fl = o + q * step_ticks
2441
+ return bucket_head_as_time(fl + step_ticks * n.ne(fl), mul, su)
2442
+ when :round
2443
+ r = d - q * step_ticks # 0 <= r < step_ticks
2444
+ q = q + r.ge((step_ticks + 1) / 2) # ties -> future; no 2*d / 2*r
2445
+ end
2446
+ bucket_head_as_time(o + q * step_ticks, mul, su)
41
2447
  end
42
2448
 
43
- def time_month
44
- return convert(:int, &:month)
2449
+ # @overload floor(grid = nil, unit:, origin: nil)
2450
+ # {#snap} with `direction: :floor` -- each element at its bucket head
2451
+ # (toward the past), as a {CATime} in the same storage resolution.
2452
+ # @return [CATime]
2453
+ def floor(grid = nil, unit: nil, origin: nil)
2454
+ snap(grid, unit: unit, origin: origin, direction: :floor)
45
2455
  end
46
2456
 
47
- def time_day
48
- return convert(:int, &:day)
2457
+ # @overload ceil(grid = nil, unit:, origin: nil)
2458
+ # {#snap} with `direction: :ceil` -- each element at the bucket head at
2459
+ # or after it (an element already on a boundary maps to itself).
2460
+ # @return [CATime]
2461
+ def ceil(grid = nil, unit: nil, origin: nil)
2462
+ snap(grid, unit: unit, origin: origin, direction: :ceil)
49
2463
  end
50
2464
 
51
- def time_hour
52
- return convert(:int, &:hour)
2465
+ # @overload round(grid = nil, unit:, origin: nil)
2466
+ # {#snap} with `direction: :round` -- each element at its nearest bucket
2467
+ # head, ties toward the future. Exact for odd `step_ticks` (no half-tick
2468
+ # loss). For a calendar bucket the nearest head is by absolute tick
2469
+ # distance (month lengths vary), ties toward the future.
2470
+ # @return [CATime]
2471
+ def round(grid = nil, unit: nil, origin: nil)
2472
+ snap(grid, unit: unit, origin: origin, direction: :round)
53
2473
  end
54
2474
 
55
- def time_minute
56
- return convert(:int, &:min)
2475
+ # @overload is_righttime(unit:, origin: nil)
2476
+ # Returns a boolean CArray flagging elements that land exactly on a bucket
2477
+ # head. Use as an assertion before matching to catch off-grid series (a
2478
+ # timesteps match is "same bucket", not "same instant").
2479
+ # @param unit [String, Symbol, Resolution] bucket resolution.
2480
+ # @param origin [Time, String, CATime::Element, DateTime, nil] head of
2481
+ # bucket 0 (default: the Unix epoch, or ISO Monday for a week bucket
2482
+ # on day-or-finer storage -- a week grid counts from the epoch
2483
+ # Thursday and cannot hold a Monday, so it keeps its own ticks).
2484
+ # It has to be a bucket head itself: on a calendar grid, the 1st at
2485
+ # 00:00.
2486
+ # @return [CArray] boolean.
2487
+ def is_righttime(grid = nil, unit: nil, origin: nil)
2488
+ unit, origin = Grid.resolve(grid, unit, origin)
2489
+ g = resolve_timestep_grid(unit, origin)
2490
+ return calendar_bucket(:on, g[1], origin) if g[0] == :civil
2491
+ su, mul, step_ticks, o = g
2492
+ raise_if_int64_overflow(su, mul, o)
2493
+ d = storage_ticks_in_numerator_grid(mul) - o
2494
+ q = d / step_ticks
2495
+ (d - q * step_ticks).eq(0)
57
2496
  end
58
2497
 
59
- def time_second
60
- return convert(:double) {|d| d.second + d.second_fraction }
2498
+ # @overload from_timesteps(k, unit:, origin: nil)
2499
+ # Inverse of {#timesteps}: returns the bucket-head time for timestep
2500
+ # `k`. Use to relabel a `group_by(timesteps)` result, generate a
2501
+ # regular grid, or as a timesteps round-trip oracle.
2502
+ # A scalar `k` returns a {Element}; a CArray `k` returns a {CATime}.
2503
+ #
2504
+ # The result is stored on the `unit` grid, except a week bucket, which
2505
+ # is stored on `:D`. A week grid counts from the epoch (a Thursday) and
2506
+ # so cannot hold its own bucket head: the head is the ISO Monday, four
2507
+ # days off every week tick. Days hold it exactly, so a `:W` bucket
2508
+ # answers on the day grid and the round trip against a day-or-finer
2509
+ # series is exact. `unit:` names the bucket here, the way it does for
2510
+ # {#floor} / {#ceil} -- not the storage the answer lands on.
2511
+ # @param k [Integer, CArray] timestep / timesteps.
2512
+ # @param unit [String, Symbol, Resolution] bucket resolution.
2513
+ # @param origin [Time, String, CATime::Element, DateTime, nil] head of
2514
+ # bucket 0 (default: the Unix epoch, or ISO Monday for a week bucket).
2515
+ # It has to be a bucket head itself: on a calendar grid, the 1st at
2516
+ # 00:00.
2517
+ # @return [Element, CATime] on the `unit` grid (`:D` for a week bucket).
2518
+ def self.from_timesteps(k, grid = nil, unit: nil, origin: nil)
2519
+ grid = unit if unit.is_a?(Grid)
2520
+ # A Grid answers on its own storage, so an origin off the unit grid
2521
+ # ("12 hours since ... 09:00") survives; the keyword form keeps the
2522
+ # narrower rule it always had, where the origin sits on the unit itself.
2523
+ return grid.at(k) if grid.is_a?(Grid)
2524
+ unit, origin = Grid.resolve(grid, unit, origin)
2525
+ res = Resolution.parse(unit)
2526
+ out = res.base == :W ? Resolution.new(1, :D) : res
2527
+ kk = k.is_a?(CArray) ? k.int64 : CArray.int64(1) { Integer(k) }
2528
+ _mul, step_ticks, o = CATimeGrid.resolve_integer_grid(res, out, origin)
2529
+ raw = o + kk * step_ticks
2530
+ k.is_a?(CArray) ? raw.time(unit: out) : Element.new(raw[0], out)
61
2531
  end
62
2532
 
63
- def time_jd
64
- return convert(:int, &:jd)
2533
+ private
2534
+
2535
+ # Resolve the grid for an instance: [storage_res, mul, step_ticks,
2536
+ # origin_ticks] for the integer path, or [:civil, bucket_res] for the
2537
+ # calendar path. Bucket arithmetic runs in the *numerator* grid -- the
2538
+ # finer of the storage tick and the bucket tick -- so `mul` (storage ticks
2539
+ # per numerator tick) is 1 for a bucket at or coarser than the storage
2540
+ # tick, and the widening factor for a finer bucket. `origin_ticks` is
2541
+ # likewise in numerator ticks.
2542
+ def resolve_timestep_grid(step, origin)
2543
+ storage_res = unit
2544
+ step_res = Resolution.parse(step)
2545
+ scale = CATimeGrid.resolve_step_scale(step_res, storage_res)
2546
+ return [:civil, step_res] if scale == :civil
2547
+ mul, step_ticks = scale
2548
+ num_res = mul == 1 ? storage_res : step_res
2549
+ o = CATimeGrid.resolve_origin_ticks(origin, step_res, num_res)
2550
+ [storage_res, mul, step_ticks, o]
2551
+ end
2552
+
2553
+ # Storage ticks lifted into the numerator grid (see {#resolve_timestep_grid}).
2554
+ def storage_ticks_in_numerator_grid(mul)
2555
+ mul == 1 ? parent : CATimeUnitAlgebra.widen(parent, mul)
2556
+ end
2557
+
2558
+ # A bucket head computed in numerator ticks, back as a {CATime} in the
2559
+ # storage resolution. The division is exact: a numerator grid finer than
2560
+ # the storage tick only arises when every element already sits on it, so
2561
+ # each head is a whole number of storage ticks.
2562
+ def bucket_head_as_time(head, mul, su)
2563
+ (mul == 1 ? head : head / mul).time(unit: su)
2564
+ end
2565
+
2566
+ # Base units where int64 range is small enough that array-domain arithmetic
2567
+ # (parent - o, 2*d) can overflow for a realistic time span; coarser
2568
+ # bases cannot, so the range guard is skipped for them (zero cost on the
2569
+ # common :s / :h / :D / :M path).
2570
+ FINE_UNITS = %i[us ns ps fs as].freeze
2571
+
2572
+ # Raise (rather than silently wrap) if the integer-path arithmetic would
2573
+ # overflow int64. Checks `parent - o`, and -- when `kind` is a bucket op
2574
+ # (:floor / :ceil / :round) -- the reconstructed bucket head `o + q*step`,
2575
+ # which ceil / round can push up to one step beyond the input range. Both
2576
+ # extremes are computed in the Ruby (bignum) domain from the storage
2577
+ # min / max, so the check is O(1) beyond the two reduces.
2578
+ #
2579
+ # Gated to fine units, where the int64 span is genuinely tight, plus every
2580
+ # widened (`mul` > 1) grid, where a coarse storage unit is lifted into a
2581
+ # much finer one; a pathological raw value wrapped in a coarse-unit Face is
2582
+ # not guarded (the gate is a value-range heuristic, not a proof).
2583
+ #
2584
+ # min / max skip masked cells, so a masked cell does not decide the range
2585
+ # (it carries no time), and answer UNDEF when there is nothing to bound --
2586
+ # an empty array, or one whose every cell is masked.
2587
+ def raise_if_int64_overflow(su, mul, o, step_ticks = nil, kind = nil)
2588
+ return unless mul > 1 or FINE_UNITS.include?(su.base)
2589
+ min = parent.min
2590
+ return if min == UNDEF
2591
+ lo = Integer(min) * mul
2592
+ hi = Integer(parent.max) * mul
2593
+ chk = ->(v, w) { CATimeGrid.check_int64_range(v, w) }
2594
+ chk.(lo, "ticks in bucket resolution")
2595
+ chk.(hi, "ticks in bucket resolution")
2596
+ chk.(lo - o, "parent - origin")
2597
+ chk.(hi - o, "parent - origin")
2598
+ if kind
2599
+ chk.(bucket_head_ticks(lo, o, step_ticks, kind), "bucket head")
2600
+ chk.(bucket_head_ticks(hi, o, step_ticks, kind), "bucket head")
2601
+ end
2602
+ end
2603
+
2604
+ # Ruby-domain (exact bignum) bucket head for a single storage value -- the
2605
+ # scalar mirror of the array floor / ceil / round, used only by the overflow
2606
+ # guard. Ruby Integer `/` and `%` floor, so `(v - o) / s` is the floor
2607
+ # bucket directly.
2608
+ def bucket_head_ticks(v, o, s, kind)
2609
+ fq = (v - o) / s
2610
+ case kind
2611
+ when :floor then o + fq * s
2612
+ when :ceil then h = o + fq * s; v == h ? h : h + s
2613
+ when :round then r = (v - o) - fq * s; o + (fq + (r >= (s + 1) / 2 ? 1 : 0)) * s
2614
+ end
2615
+ end
2616
+
2617
+ # Days since the Unix epoch of the date each element represents (mask
2618
+ # propagates). Calendar units (:Y / :M) resolve to day 1 of their year /
2619
+ # month; a week is 7 days; day-or-finer units floor to the day.
2620
+ def days_since_epoch
2621
+ res = unit
2622
+ case res.base
2623
+ when :Y
2624
+ ones = CArray.int64(*shape) { 1 }
2625
+ CATimeCivil.days_from_civil(parent * res.count + 1970, ones, ones)
2626
+ when :M
2627
+ mo = parent * res.count + 1970 * 12
2628
+ y = mo / 12
2629
+ m = mo - y * 12 + 1
2630
+ CATimeCivil.days_from_civil(y, m, CArray.int64(*shape) { 1 })
2631
+ when :W then parent * res.count * 7
2632
+ when :D then parent * res.count
2633
+ else # fixed sub-day storage
2634
+ day = CATime::Resolution.new(1, :D)
2635
+ r = CATimeUnitAlgebra.ratio(day, res) # storage ticks per day
2636
+ if r.denominator == 1
2637
+ parent / r.numerator
2638
+ else # non-day-aligned tick
2639
+ tr = res.tick_ratio # seconds / tick
2640
+ parent * tr.numerator / (86400 * tr.denominator)
2641
+ end
2642
+ end
65
2643
  end
66
2644
 
67
- def time_ajd
68
- return convert(:double, &:ajd)
2645
+ # Clock field `fu` (:h / :m / :s): the field-of-its-parent value (hour of
2646
+ # day, minute of hour, second of minute). Computed as the epoch-relative
2647
+ # `fu` index of each element modulo its cycle, so a storage tick finer OR
2648
+ # coarser than `fu` both resolve exactly (a 10-minute grid gives minute 0 /
2649
+ # 10 / 20 / ...; a day grid collapses hour / minute / second to 0). A
2650
+ # calendar-storage element has no intra-day field, so it collapses to 0.
2651
+ def clock_field(fu)
2652
+ r = CATimeUnitAlgebra.ratio(unit, CATime::Resolution.new(1, fu)) # fu ticks / storage tick
2653
+ return parent * 0 if r.nil? # calendar storage: no clock field
2654
+ fi = r.denominator == 1 ? parent * r.numerator # epoch-relative fu index
2655
+ : parent * r.numerator / r.denominator
2656
+ cycle = fu == :h ? 24 : 60 # hour 0..23; min/sec 0..59
2657
+ fi % cycle
69
2658
  end
70
2659
 
71
- def time_is_leap
72
- return test(&:leap?)
2660
+ # Calendar path (Y/M step on day-or-finer storage) via civil-date integer
2661
+ # algebra: floor toward the past by month ordinal, no boundary array / no
2662
+ # min-max, O(N) branch-free, origin-absolute k (negative pre-origin). The
2663
+ # origin's day / time is ignored (month ordinal only, per the design).
2664
+ # `kind`: :index / :floor / :ceil / :round / :on.
2665
+ def calendar_bucket(kind, st, origin)
2666
+ su = unit # storage Resolution
2667
+ count = st.count * (st.base == :Y ? 12 : 1) # bucket in months
2668
+ ym0 = CATimeGrid.origin_month_ordinal(origin)
2669
+ days = days_since_epoch # count-folded day index
2670
+ y, m = CATimeCivil.civil_from_days(days)
2671
+ ym = y * 12 + (m - 1)
2672
+ k = (ym - ym0) / count
2673
+ return k if kind == :index
2674
+ head = CATimeGrid.calendar_bucket_head_ticks(k, st, su, origin)
2675
+ return head.time(unit: su) if kind == :floor
2676
+ return parent.eq(head) if kind == :on
2677
+ head_next = CATimeGrid.calendar_bucket_head_ticks(k + 1, st, su, origin)
2678
+ sel = # boolean 0/1: pick head_next?
2679
+ case kind
2680
+ when :ceil then parent.ne(head) # on-boundary keeps head
2681
+ when :round then (head_next - parent).le(parent - head) # nearest, ties -> future
2682
+ end
2683
+ (head + (head_next - head) * sel).time(unit: su)
73
2684
  end
74
2685
 
75
2686
  end
76
2687
 
2688
+ # The view-creating method lift hook lives in the C layer (= subclass-
2689
+ # agnostic): each view-creating C method (rb_ca_transpose / rb_ca_reshape
2690
+ # / etc.) ends with `if (ca_is_face(ca)) obj = ca_face_lift(obj, self);`,
2691
+ # so a view of a Face re-lifts to the same Face class. See the relevant C
2692
+ # source (ext/ca_obj_transpose.c etc.) for details.