Module: CATimeUnitAlgebra

Defined in:
lib/carray/time.rb

Overview

Internal: unit-conversion algebra shared by CATime / CATimedelta (used by to_comparable to align a search query to the reference unit).

The units split into two groups that are NOT inter-convertible by a fixed ratio: fixed-length (W/D/h/m/s/ms/.../as, ratios in seconds) and calendar (Y/M, ratio in months -- W and below are calendar-dependent in days). Within a group any pair is an exact integer ratio (coarse = fine * N), so a coarse->fine cast is lossless (multiply), and a fine->coarse cast is lossless only when every value is divisible by the divisor.

Constant Summary collapse

FIXED =

seconds per base unit (Rational)

{
  W: 604800r, D: 86400r, h: 3600r, m: 60r, s: 1r,
  ms: Rational(1, 10**3),  us: Rational(1, 10**6),  ns: Rational(1, 10**9),
  ps: Rational(1, 10**12), fs: Rational(1, 10**15), as: Rational(1, 10**18),
}.freeze
CALENDAR =

months per base unit (Rational)

{ Y: 12r, M: 1r }.freeze
GRANULARITY =

Base units from finest to coarsest granularity. Every fixed-length unit is finer than every calendar unit (a week < a month), so this is a total order used to pick the base that two operands both convert into exactly.

%i[as fs ps ns us ms s m h D W M Y].freeze

Class Method Summary collapse

Class Method Details

._cal_days(storage, from) ⇒ Object

calendar time (Resolution from) -> days since the epoch (int64 CArray). Folds the resolution count (value = count-Y/M buckets).



214
215
216
217
218
219
220
221
222
223
224
# File 'lib/carray/time.rb', line 214

def _cal_days(storage, from)
  ones = CArray.int64(*storage.shape) { 1 }
  if from.base == :M
    abs = storage * from.count + 1970 * 12           # absolute month ordinal
    y   = CATime.send(:_floordiv_i, abs, 12)
    m   = abs - y * 12 + 1
    CATime.send(:_days_from_civil, y, m, ones)
  else                                               # :Y
    CATime.send(:_days_from_civil, storage * from.count + 1970, ones, ones)
  end
end

._instant_cal_to_fixed(storage, from, to) ⇒ Object

calendar time -> fixed-length grid: the widening half of convert_instant!. Goes through the day count, so the target grid has to tile a day exactly (:W is rejected -- month starts are not week-aligned). Always exact once that holds.



230
231
232
233
234
235
236
237
238
# File 'lib/carray/time.rb', line 230

def _instant_cal_to_fixed(storage, from, to)
  r = ratio(CATime::Resolution.new(1, :D), to)   # ticks of `to` per day
  unless r.denominator == 1
    raise ArgumentError,
          "cannot convert calendar time #{from} to #{to} " \
          "(a day boundary is not aligned to the #{to} grid)"
  end
  widen(_cal_days(storage, from), r.numerator)
end

._instant_fixed_to_cal(storage, from, to) ⇒ Object

fixed-length grid -> calendar time: the coarsening half of convert_instant!, exact-or-raise. Every instant must land on a day boundary and then on the calendar boundary itself (the 1st, and January too for :Y), since a mid-month instant has no :M value.



244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
# File 'lib/carray/time.rb', line 244

def _instant_fixed_to_cal(storage, from, to)
  rd = ratio(CATime::Resolution.new(1, :D), from)  # `from` ticks per day
  days =
    if rd.denominator == 1
      n = rd.numerator
      unless (storage % n).eq(0).all
        raise ArgumentError,
              "cannot convert time #{from} to #{to} without loss " \
              "(instant is not on a day boundary)"
      end
      storage / n
    else                                             # coarser than a day (:W)
      storage * (from.tick_ratio / 86400r).to_i
    end
  y, m, d = CATime.send(:_civil_from_days, days)
  on_boundary = d.eq(1)
  on_boundary &= m.eq(1) if to.base == :Y
  unless on_boundary.all
    raise ArgumentError,
          "cannot convert time #{from} to #{to} without loss " \
          "(instant is not on a #{to} boundary)"
  end
  ord = to.base == :M ? (y * 12 + (m - 1) - 1970 * 12) : (y - 1970)
  if to.count > 1
    unless (ord % to.count).eq(0).all
      raise ArgumentError,
            "cannot convert time #{from} to #{to} without loss " \
            "(instant is not on a #{to} boundary)"
    end
    ord = CATime.send(:_floordiv_i, ord, to.count)
  end
  ord
end

.base_ratio(base) ⇒ Object

seconds- (fixed) or months- (calendar) per base tick.



43
44
45
# File 'lib/carray/time.rb', line 43

def base_ratio(base)
  FIXED[base] || CALENDAR[base]
end

.common(u1, u2) ⇒ Object Also known as: finer

The common grid two same-group resolutions both convert into exactly: the resolution whose tick is the gcd of the two ticks (finest common base + the whole multiplier). For equal resolutions this is the resolution itself; for (1,:D) & (1,:h) it is (1,:h); for (5,:m) & (2,:m) it is (1,:m). Both operands are same-group (checked by the caller).



68
69
70
71
72
73
# File 'lib/carray/time.rb', line 68

def common(u1, u2)
  a  = res(u1); b = res(u2)
  fb = GRANULARITY.index(a.base) <= GRANULARITY.index(b.base) ? a.base : b.base
  g  = rgcd(a.tick_ratio, b.tick_ratio)
  CATime::Resolution.new(Integer(g / base_ratio(fb)), fb)
end

.convert_instant!(storage, from, to) ⇒ Object

INSTANT conversion (absolute datetimes): unlike a duration, a time :M value HAS a well-defined instant (the month's first midnight), so a cross-group cast is possible via civil-date algebra even though no fixed ratio exists (a :M time casts to :s, a :M duration cannot). Same-group falls back to the ratio.

  • calendar (:M/:Y) -> fixed (<= :D): always exact (widen to the finer grid). :W is rejected (month / year starts are not week-aligned).
  • fixed -> calendar: exact only when the instant lands on the calendar boundary (midnight of the 1st), else raises.


201
202
203
204
205
206
207
208
209
210
# File 'lib/carray/time.rb', line 201

def convert_instant!(storage, from, to)
  a = res(from); b = res(to)
  return storage if a == b
  return convert_scale!(storage, a, b) if ratio(a, b)   # same group
  if CALENDAR.key?(a.base)
    _instant_cal_to_fixed(storage, a, b)                # widen
  else
    _instant_fixed_to_cal(storage, a, b)                # coarsen (exact-or-raise)
  end
end

.convert_scale!(storage, from, to) ⇒ Object

SCALE conversion (durations / timedelta): convert an int64 storage CArray from from unit to to unit by the fixed ratio. coarse->fine multiplies; fine->coarse divides only when every value is exact; cross-group ALWAYS raises -- a :M / :Y duration has no fixed ratio to days (a month is calendar-variable), so it genuinely cannot scale to seconds.



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/carray/time.rb', line 150

def convert_scale!(storage, from, to)
  a = res(from); b = res(to)
  return storage if a == b
  r = ratio(a, b)
  if r.nil?
    raise ArgumentError,
          "cannot scale duration #{a} to #{b}: calendar units " \
          "(:Y/:M) and fixed-length units (:W/:D/:h/:s/...) have no fixed " \
          "ratio (a month / year is calendar-variable)"
  end
  if r.denominator == 1
    widen(storage, r.numerator)           # coarse -> fine: lossless multiply
  else
    divisor = r.denominator               # fine -> coarse: exact only
    unless (storage % divisor).eq(0).all
      raise ArgumentError,
            "cannot scale duration #{a} to #{b} without loss: " \
            "some values are not a whole multiple of #{b} " \
            "(finer resolution would be truncated)"
    end
    storage / divisor
  end
end

.convert_scale_trunc(storage, from, to) ⇒ Object

SCALE conversion with truncation: like convert_scale! but a fine->coarse conversion drops the sub-to remainder (truncating toward zero) instead of raising. Used for dt +/- td, where the result keeps the time's unit and a finer duration is truncated to it (a :D time + a 5 h duration is

  • 0 days; + 30 h is + 1 day). Cross-group still raises (a calendar duration has no fixed ratio to a fixed unit).


180
181
182
183
184
185
186
187
188
189
190
# File 'lib/carray/time.rb', line 180

def convert_scale_trunc(storage, from, to)
  a = res(from); b = res(to)
  return storage if a == b
  r = ratio(a, b)
  if r.nil?
    raise ArgumentError,
          "cannot combine a #{a} duration with a #{b} time across the " \
          "calendar/fixed boundary (a calendar duration has no fixed ratio)"
  end
  r.denominator == 1 ? widen(storage, r.numerator) : storage / r.denominator
end

.diff_unit(u1, u2) ⇒ Object

Common resolution for a time difference (E): same group -> the common grid; cross-group -> the fixed-group resolution (a :M/:Y difference only arises from two calendar operands), coarsened to (1,:D) when the fixed side is a week (a week is not calendar-alignable, but both sides convert into days exactly).



82
83
84
85
86
87
# File 'lib/carray/time.rb', line 82

def diff_unit(u1, u2)
  a = res(u1); b = res(u2)
  return common(a, b) if same_group?(a, b)
  fx = CALENDAR.key?(a.base) ? b : a
  fx.base == :W ? CATime::Resolution.new(1, :D) : fx
end

.multiple_factor(from, to) ⇒ Object

Target ticks per source tick for the strict unit-change surface (CATime#to_unit / CATimedelta#to_unit): accepted only when the source tick is a whole multiple of the target tick, so every value re-expresses exactly on the finer grid. A coarser target (which would round) and a cross-group pair (no fixed ratio at all) both raise -- unlike convert_scale! / convert_instant!, which coarsen when the values happen to allow it, this decides on the units alone.



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/carray/time.rb', line 105

def multiple_factor(from, to)
  a = res(from); b = res(to)
  return 1 if a == b
  r = ratio(a, b)
  if r.nil?
    raise ArgumentError,
          "cannot express #{a} in #{b}: calendar units (:Y/:M) and " \
          "fixed-length units (:W/:D/:h/:s/...) have no fixed ratio"
  end
  unless r.denominator == 1
    raise ArgumentError,
          "cannot express #{a} in whole #{b} ticks " \
          "(a #{a} tick is not a whole multiple of a #{b} tick)"
  end
  r.numerator
end

.ratio(from, to) ⇒ Object

Tick ratio from from to to (how many to ticks per from tick), or nil if they are in different groups (not inter-convertible by a fixed ratio). Folds each resolution's count.



92
93
94
95
96
# File 'lib/carray/time.rb', line 92

def ratio(from, to)
  a = res(from); b = res(to)
  return nil unless same_group?(a, b)
  a.tick_ratio / b.tick_ratio
end

.res(u) ⇒ Object

Normalize a unit spec (Resolution / Symbol / String) to a Resolution. A bare Symbol / String routes through Resolution.parse (count-1 base).



49
50
51
# File 'lib/carray/time.rb', line 49

def res(u)
  u.is_a?(CATime::Resolution) ? u : CATime::Resolution.parse(u)
end

.rgcd(a, b) ⇒ Object

Greatest common divisor of two positive Rationals (both in lowest terms).



54
55
56
# File 'lib/carray/time.rb', line 54

def rgcd(a, b)
  Rational(a.numerator.gcd(b.numerator), a.denominator.lcm(b.denominator))
end

.same_group?(u1, u2) ⇒ Boolean

Whether two units are in the same group (both calendar or both fixed).

Returns:

  • (Boolean)


59
60
61
# File 'lib/carray/time.rb', line 59

def same_group?(u1, u2)
  CALENDAR.key?(res(u1).base) == CALENDAR.key?(res(u2).base)
end

.widen(storage, factor) ⇒ Object

storage * factor with a loud overflow guard: widening a wide time range into a fine unit can exceed int64, and a silent wrap would give a wrong instant / duration. Checks the extremes (they bound every element), then multiplies. Shared by every coarse->fine conversion (arithmetic, comparison, search).



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/carray/time.rb', line 127

def widen(storage, factor)
  return storage if factor == 1
  if storage.elements > 0
    raw = storage.has_mask? ? storage.value : storage
    lo  = raw.min
    unless lo == UNDEF
      lim = 2**63 - 1
      [lo, raw.max].each do |x|
        next if (Integer(x) * factor).abs <= lim
        raise RangeError,
              "time unit conversion overflows int64: the time range is " \
              "too wide to widen into this resolution (x#{factor})"
      end
    end
  end
  storage * factor
end