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
-
.base_ratio(base) ⇒ Object
seconds- (fixed) or months- (calendar) per base tick.
-
.calendar_days_since_epoch(storage, from) ⇒ Object
calendar time (Resolution
from) -> days since the epoch (int64 CArray). -
.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_instant_calendar_to_fixed(storage, from, to) ⇒ Object
calendar time -> fixed-length grid: the widening half of convert_instant!.
-
.convert_instant_fixed_to_calendar(storage, from, to) ⇒ Object
fixed-length grid -> calendar time: the coarsening half of convert_instant!, exact-or-raise.
-
.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.
-
.convert_instant_floor(storage, from, to) ⇒ Object
INSTANT conversion that floors instead of raising: the storage-resolution change behind CATime#to_unit.
-
.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).
-
.trunc_divide(a, b) ⇒ Object
Truncating integer division of an int64 CArray (toward zero).
-
.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
.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).
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 |