Thursday, May 16, 2013

Ruby Times & Dates: The Good, The Bad and so on


The Good

The value of a Time object is a measure of epoch seconds (including nanoseconds). Time objects with the same value but different UTC offsets are considered equivalent.

    t  = Time.now # a local Time
    tu = t.getutc

    t.to_i       == tu.to_i      # true
    t            == tu           # true

The value of a Date object is its chronological Julian day. Date objects have a Gregorian calendar start date which is analogous to the UTC offset of a time object. It determines whether the Julian or Gregorian calendar will be used to calculate the calendar year, month and day. Date objects with the same value but different Gregorian calendar start dates are considered equivalent.

    d  = Date.today # a Gregorian Date
    dj = d.julian

    d.jd    == dj.jd     # true
    d       == dj        # true

Ruby 1.9 introduced a means of converting between Time and Date: Time#to_date.

Time#to_date works by calculating a Time object's UTC offset year, month and day according to the Gregorian calendar. Offset Time values before the introduction of the Gregorian calendar use the "proleptic" Gregorian calendar, a logical extension of the Gregorian calendar into the past.

Since we use the Gregorian calendar today, we can say without equivocation that the first day in the Gregorian calendar was Friday, 1582-10-15. This corresponds to Julian day 2299161.

    Time.new(1582, 10, 15).to_date == Date.jd(2299161)       # true
    Time.new(1582, 10, 15).to_date.gregorian?                # true
    Time.new(1582, 10, 15).to_date.to_s                      # "1582-10-15"
    Time.new(1582, 10, 15).to_date == Date.new(1582, 10, 15) # true

The day before the Gregorian calendar was introduced, Julian day 2299160, is better known by its Julian calendar date, Thursday, 1582-10-04. This day is equivalent to the proleptic Gregorian date, 1582-10-14. For values prior to the introduction of the Gregorian calendar, Time#to_date returns a Julian Date object.

    Time.new(1582, 10, 14).to_date == Date.jd(2299160)      # true
    Time.new(1582, 10, 14).to_date.julian?                  # true
    Time.new(1582, 10, 14).to_date.to_s                     # "1582-10-04"
    Time.new(1582, 10, 14).to_date == Date.new(1582, 10, 4) # true

Ruby 1.9 also introduced a means of converting between Date and Time: Date#to_time. Date#to_time returns a local Time object corresponding to midnight on the calendar day of the view.

   Date.new(1582, 10, 15).to_time == Time.new(1582, 10, 15) # true

The Bad and the Ugly

Unlike Time#to_date, Date#to_time doesn't use a fixed calendar to perform the conversion. As a result, conversions involving dates before the introduction of the Gregorian calendar exhibit surprising behaviors.

Converted to a Time, this Date purports to be equivalent to a Time value which actually occurred 10 days earlier.

   Date.new(1582, 10, 4).jd                               # 2299160
   Time.new(1582, 10, 4).to_date.jd                       # 2299150
   Date.new(1582, 10, 4).to_time == Time.new(1582, 10, 4) # true

Such Dates also have the property of not being equivalent to themselves after round-trip conversion:

    d = Date.new(1582, 10, 4)
    d.to_time.to_date == d    # false


Can we do better?

The Ruby 1.9 API is new enough, and the manipulation of pre-Gregorian dates is rare enough that perhaps it's not too late to amend the behavior of Date#to_time. The obscurity of this API is underscored by the sparse documentation:
"Returns a Time object which denotes self."
The behavior of this method is not yet specified at all in the executable Rubyspec project which the maintainers of various Ruby implementations use as their Rosetta Stone.

Time#to_date depends on a Time object's UTC offset to determine the Gregorian calendar day. This is intuitive and supports the obvious use for the conversion: To manipulate the calendar date relative to an instant in time. The value of the Date object is clearly related to the value of the Time object.

The role of the calendar reform start date in Date#to_time does not offer a parallel benefit. The value of the a Time object returned by Date#to_time does not have a clear relationship with the value of the Date object that produced it; instead the objects are related by secondary calculations which are not even guaranteed to be denominated in the same unit.

A more useful and predictable Date#to_time conversion would acknowledge that unlike the Date class, the Time class is bound to the Gregorian calendar. A Time object which is posing as a Julian calendar date is doing so in contravention of its actual value: Its meaning has been corrupted and its use is limited.

I would like to propose that Date#to_time be amended to return a local Time object corresponding to the chronological julian day of the Date value being converted. In Ruby 1.9-2.0, here's what it would look like:

diff --git a/ext/date/date_core.c b/ext/date/date_core.c
index f898c46..8cdff41 100644
--- a/ext/date/date_core.c
+++ b/ext/date/date_core.c
@@ -8600,12 +8600,21 @@ time_to_datetime(VALUE self)
 static VALUE
 date_to_time(VALUE self)
 {
-    get_d1(self);
-
-    return f_local3(rb_cTime,
-                   m_real_year(dat),
-                   INT2FIX(m_mon(dat)),
-                   INT2FIX(m_mday(dat)));
+    get_d1a(self);
+    if (m_julian_p(adat)) {
+      VALUE tmp = d_lite_gregorian(self);
+      get_d1b(tmp);
+      return f_local3(rb_cTime,
+        m_real_year(bdat),
+        INT2FIX(m_mon(bdat)),
+        INT2FIX(m_mday(bdat)));
+    }
+    else {
+      return f_local3(rb_cTime,
+        m_real_year(adat),
+        INT2FIX(m_mon(adat)),
+        INT2FIX(m_mday(adat)));
+    }
 }

Anyone with me on this?