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
-
._cal_days(storage, from) ⇒ Object
calendar time (Resolution
from) -> days since the epoch (int64 CArray). -
._instant_cal_to_fixed(storage, from, to) ⇒ Object
calendar time -> fixed-length grid: the widening half of convert_instant!.
-
._instant_fixed_to_cal(storage, from, to) ⇒ Object
fixed-length grid -> calendar time: the coarsening half of convert_instant!, exact-or-raise.
-
.base_ratio(base) ⇒ Object
seconds- (fixed) or months- (calendar) per base tick.
-
.common(u1, u2) ⇒ Object
(also: 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).
-
.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).
-
.convert_scale!(storage, from, to) ⇒ Object
SCALE conversion (durations / timedelta): convert an int64 storage CArray from
fromunit totounit by the fixed ratio. -
.convert_scale_trunc(storage, from, to) ⇒ Object
SCALE conversion with truncation: like convert_scale! but a fine->coarse conversion drops the sub-
toremainder (truncating toward zero) instead of raising. -
.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). -
.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.
-
.ratio(from, to) ⇒ Object
Tick ratio from
fromtoto(how manytoticks perfromtick), or nil if they are in different groups (not inter-convertible by a fixed ratio). -
.res(u) ⇒ Object
Normalize a unit spec (Resolution / Symbol / String) to a Resolution.
-
.rgcd(a, b) ⇒ Object
Greatest common divisor of two positive Rationals (both in lowest terms).
-
.same_group?(u1, u2) ⇒ Boolean
Whether two units are in the same group (both calendar or both fixed).
-
.widen(storage, factor) ⇒ Object
storage * factorwith 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.
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).
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 |