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.



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

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



267
268
269
270
271
272
273
274
275
276
277
# File 'lib/carray/time.rb', line 267

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



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

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.


254
255
256
257
258
259
260
261
262
263
# File 'lib/carray/time.rb', line 254

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.



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

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.



297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
# File 'lib/carray/time.rb', line 297

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.



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

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



339
340
341
342
343
344
345
346
347
348
349
350
351
352
# File 'lib/carray/time.rb', line 339

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.



194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/carray/time.rb', line 194

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



223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/carray/time.rb', line 223

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.



124
125
126
127
128
129
# File 'lib/carray/time.rb', line 124

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.



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/carray/time.rb', line 147

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.



134
135
136
137
138
# File 'lib/carray/time.rb', line 134

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



88
89
90
# File 'lib/carray/time.rb', line 88

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



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

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)


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

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.



240
241
242
243
# File 'lib/carray/time.rb', line 240

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.



174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/carray/time.rb', line 174

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