Module: CATimeUnitAlgebra

Defined in:
lib/carray/time.rb

Overview

Unit algebra for the time surface: how two units relate (same group, which is finer, what a difference is measured in) and how a tick count moves between them -- by scaling for a duration, on the calendar for an instant. Internal to the time surface: nothing here is part of the public API.

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

.base_ratio(base) ⇒ Object

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



97
98
99
# File 'lib/carray/time.rb', line 97

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

.calendar_days_since_epoch(storage, from) ⇒ Object

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



284
285
286
287
288
289
290
291
292
293
294
# File 'lib/carray/time.rb', line 284

def calendar_days_since_epoch(storage, from)
  ones = CArray.int64(*storage.shape) { 1 }
  if from.base == :M
    abs = storage * from.count + 1970 * 12           # absolute month ordinal
    y   = abs / 12
    m   = abs - y * 12 + 1
    CATimeCivil.days_from_civil(y, m, ones)
  else                                               # :Y
    CATimeCivil.days_from_civil(storage * from.count + 1970, ones, ones)
  end
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).



122
123
124
125
126
127
# File 'lib/carray/time.rb', line 122

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.


271
272
273
274
275
276
277
278
279
280
# File 'lib/carray/time.rb', line 271

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)
    convert_instant_calendar_to_fixed(storage, a, b)   # widen
  else
    convert_instant_fixed_to_calendar(storage, a, b)   # coarsen (exact-or-raise)
  end
end

.convert_instant_calendar_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.



300
301
302
303
304
305
306
307
308
# File 'lib/carray/time.rb', line 300

def convert_instant_calendar_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(calendar_days_since_epoch(storage, from), r.numerator)
end

.convert_instant_fixed_to_calendar(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.



314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
# File 'lib/carray/time.rb', line 314

def convert_instant_fixed_to_calendar(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 = CATimeCivil.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 = ord / to.count
  end
  ord
end

.convert_instant_fixed_to_calendar_floor(storage, from, to) ⇒ Object

fixed-length grid -> calendar time, flooring: the instant's own year / month, whatever day and time it carries. The flooring counterpart of convert_instant_fixed_to_calendar.



374
375
376
377
378
379
380
381
382
383
384
385
# File 'lib/carray/time.rb', line 374

def convert_instant_fixed_to_calendar_floor(storage, from, to)
  rd   = ratio(CATime::Resolution.new(1, :D), from)   # `from` ticks per day
  days =
    if rd.denominator == 1
      storage / rd.numerator
    else                                             # coarser than a day (:W)
      storage * (from.tick_ratio / 86400r).to_i
    end
  y, m, = CATimeCivil.civil_from_days(days)
  ord = to.base == :M ? (y * 12 + (m - 1) - 1970 * 12) : (y - 1970)
  to.count > 1 ? ord / to.count : ord
end

.convert_instant_floor(storage, from, to) ⇒ Object

INSTANT conversion that floors instead of raising: the storage-resolution change behind CATime#to_unit. A coarser target keeps the bucket the instant falls in (floor toward the past, the direction construction and floor already use), so no element is rejected for sitting off the target grid -- a resolution change is a cast, not an assertion. The widening half is untouched and stays exact, and :Y/:M -> :W still raises (a month head is not week-aligned, so widening there would move the instant).



356
357
358
359
360
361
362
363
364
365
366
367
368
369
# File 'lib/carray/time.rb', line 356

def convert_instant_floor(storage, from, to)
  a = res(from); b = res(to)
  return storage if a == b
  r = ratio(a, b)
  if r                                                # same group
    scaled = widen(storage, r.numerator)
    r.denominator == 1 ? scaled
                       : scaled / r.denominator
  elsif CALENDAR.key?(a.base)
    convert_instant_calendar_to_fixed(storage, a, b)   # widen, always exact
  else
    convert_instant_fixed_to_calendar_floor(storage, a, b)
  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.



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
# File 'lib/carray/time.rb', line 211

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
  scaled = widen(storage, r.numerator)    # coarse -> fine: lossless multiply
  return scaled if r.denominator == 1
  unless (scaled % r.denominator).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
  scaled / r.denominator
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. A duration is a magnitude, so it shrinks toward zero rather than flooring toward the past the way an instant does. Used by CATimedelta#to_unit and by 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).



240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/carray/time.rb', line 240

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 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
  scaled = widen(storage, r.numerator)
  r.denominator == 1 ? scaled : trunc_divide(scaled, 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). :W is the only such coarsening: a fixed resolution whose tick does not tile a day (e.g. "7 hours") is returned as-is, and the calendar side then fails to convert into it, so the subtraction raises downstream rather than being silently coarsened.



141
142
143
144
145
146
# File 'lib/carray/time.rb', line 141

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.



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/carray/time.rb', line 164

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.



151
152
153
154
155
# File 'lib/carray/time.rb', line 151

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).



103
104
105
# File 'lib/carray/time.rb', line 103

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).



108
109
110
# File 'lib/carray/time.rb', line 108

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)


113
114
115
# File 'lib/carray/time.rb', line 113

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

.trunc_divide(a, b) ⇒ Object

Truncating integer division of an int64 CArray (toward zero). CArray / floors toward the past, which is what an instant wants; a duration is a magnitude and shrinks toward zero instead.



257
258
259
260
# File 'lib/carray/time.rb', line 257

def trunc_divide(a, b)
  q = a / b
  q + (q.lt(0) & (a - q * b).ne(0))
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).

min / max skip masked cells, so a masked cell does not decide the range: it carries no value to convert, and whatever bits sit under the mask are not a time. They answer UNDEF when there is nothing to bound (empty, or every cell masked), and then there is nothing to guard either.



191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/carray/time.rb', line 191

def widen(storage, factor)
  return storage if factor == 1
  lo = storage.min
  unless lo == UNDEF
    lim = 2**63 - 1
    [lo, storage.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
  storage * factor
end