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