ruby science time 6 min
Tardis in a country-side field

Introducing Horologium

Handle scientific and high precision time in Ruby

I am excited to present a brand new gem for the Ruby community: Horologium, a library dedicated to scientific time.

What is Horologium? 

Horologium provides the time scales, high-precision instants, Julian Dates, durations and intervals that astronomy and physics need. It was extracted from Astronoby, where the time handling had grown into something that clearly could be its own library.

Quick glossary:

  • A time scale is a way of measuring the passage of time. UTC is one, and there are several others that astronomy uses
  • An instant is a single point on the timeline, independent of any scale
  • A Julian Date is a count of days since noon on 1 January 4713 BC, which is the form most astronomical calculations expect

Why? 

Ruby already has Time, Date, DateTime and ActiveSupport on top of them. Between them they cover civil time comprehensively: time zones, calendars, formatting, parsing what a human typed.

But none of them knows the difference between UTC and a time scale that never stops. None of them knows what Terrestrial Time is, or that a UTC day is not always 86,400 seconds long, or how to hold a Julian Date without losing precious precision.

Astronomical and more generally scientific calculations need a stable, uniform time scale, free from leap seconds and from the irregular rotation of the Earth. There are eight scales in common use, they all provide different values, and the differences are meaningful.

An instant is not the same as its name 

This is the idea the library is built on.

"2025-05-01 12:00:00 UTC" is not a moment in time. It is a name for a moment, in one particular scale, and the same moment has other names in other scales. We usually treat the name and the moment as the same thing, because in everyday life the name is all we need.

In Horologium an instant has no scale of its own. You build it from a name in some scale, and you read it back in whichever scale you want.

ruby
instant = Horologium::Instant.from_utc(2025, 5, 1, 12)

instant.as(:iso8601, scale: :tai)
# => "2025-05-01T12:00:37.000000000"

instant.as(:julian_date, scale: :tt)
# => 2460797.000800741

This is what makes arithmetic on instants safe. You never add seconds to a UTC timestamp, because a UTC day is not always the same length and the addition would be meaningless. You operate on the instant and convert only when something has to be displayed.

Leap seconds 

A leap second was inserted at the end of 2016. Horologium counts it, so that day is a second longer than any other.

ruby
start = Horologium::Instant.from_utc(2016, 12, 31)
finish = Horologium::Instant.from_utc(2017, 1, 1)

(finish - start).in_seconds
# => 86401.0

The leap second is a real moment, so it has a name as well:

ruby
leap = Horologium::Instant.from_utc(2016, 12, 31, 23, 59, 60)

leap.as(:iso8601, scale: :utc)
# => "2016-12-31T23:59:60.000000000Z"

Second 60 is legal there. Ruby's own Time cannot hold it: Time.utc(2016, 12, 31, 23, 59, 60) silently becomes the first moment of 2017, because POSIX time treats every day as 86,400 seconds long. Asking Horologium for second 60 on a day that did not have one raises an error that says which field is wrong.

The eight scales 

Horologium supports TAI, TT, TDB, TCG, TCB, GPS, UTC and UT1. Six of them convert by definition or by a bundled model, so they need no external data, but two read published data: UT1 follows the actual rotation of the Earth, which is irregular, so it cannot be computed and has to be measured; UTC needs the leap seconds, which are decided and announced. Both come from the iers gem, a dependency of the library. The data is shipped with the gem, so there is no network access.

ruby
instant = Horologium::Instant.from_ut1(1955, 1, 1, 12)

instant.as(:iso8601, scale: :tt)
# => "1955-01-01T12:00:31.047050952"

instant.as(:iso8601, scale: :utc)
# => raises OutOfRangeError

UTC is not defined before 1961, so it refuses the date. The instant still exists and still has a UT1 value and a TT value. It just cannot have a UTC value, because there was not one at the time.

Precision 

A modern Julian Date is around 2.46 million. A Float holds about sixteen significant digits, of which seven are spent on the day count, and what is left gives only tens of microseconds for the fraction of a day. This is plenty for many use cases but too coarse for some scientific work.

Horologium stores an instant as two Floats whose sum is the Julian Date, so the second one starts where the first runs out of digits. This is the representation ERFA and astropy use, and it keeps the value below a nanosecond for any date. There is also an :exact mode that holds a Rational and rounds nothing, which the test suite uses to check the default one.

The interesting part is that the precision is usually lost before the library is involved:

ruby
Horologium::Instant.from_julian_date(2_456_463.052272, scale: :tt)

A Julian Date counts days, and that one cannot be stored exactly as a Float. Ruby keeps the closest value it can, 2456463.0522719998..., which is a bit less than what I wrote. The gap looks harmless, but if you remember the unit, a day is 86,400 seconds, so it is worth about ten microseconds. The value is already off by then, and the library has no way of knowing what it was supposed to be.

So the lossless forms come first: a String, a Rational, or a high and a low part. Better still, a calendar time or an ISO 8601 string is exact, because neither spends its digits on a day count.

Conclusion 

Horologium is at v0.1.0 so I'll consider that breaking changes can still happen before v1.0, but the gem is already quite stable in my opinion. All eight scales are implemented, along with three value objects: Instant for a point in time, Duration for a quantity of SI seconds, and Interval for the span between two points.

The conversions are checked against astropy and pyerfa through golden files: every leap second, the rate adjustments back to 1961 that came before them, and dates across the range for the other scales.

If you work with time in a way Time and ActiveSupport do not cover, I would like to hear where Horologium falls short.

The gem is open source and hosted on GitHub: https://github.com/rhannequin/horologium. Feedback, ideas, issues and pull requests are all welcome.

Cheers! 🌌

Back to all articles