Horologium
Horologium is a Ruby library dedicated to scientific time: the time scales (TAI, TT, TDB, and UTC so far), high-precision instants, Julian Dates, intervals, and rigorous conversions between scales that astronomy and physics require.
Ruby already has Time, Date, DateTime, and ActiveSupport for civil time:
time zones, calendars, human formatting. None of them knows the difference
between UTC and a continuous scale, the TAI, TT, and TDB scales an ephemeris
needs, or a Julian Date kept precise to the nanosecond. That is the gap
Horologium fills.
Content
Installation
Install the gem and add it to the application's Gemfile by executing:
$ bundle add horologium
If Bundler is not being used to manage dependencies, install the gem by executing:
$ gem install horologium
Usage
An Instant is a single point on the timeline, kept internally as a TAI Julian
Date. A Duration is an amount of time in SI seconds, with no date and no scale
attached. You shift an instant by a duration, and you subtract two instants to
get the duration between them.
require "horologium"
a = Horologium::Instant.from_julian_date(2_460_000.5, scale: :tai)
b = Horologium::Instant.from_julian_date(2_460_001.5, scale: :tai)
a + Horologium::Duration.days(1) == b # => true
a < b # => true
b - a == Horologium::Duration.days(1) # => true
An instant has no scale of its own. You give it a Julian Date read in a scale,
and you read it back in any scale the library knows: to chooses the scale, and
as chooses the shape it comes out in.
instant = Horologium::Instant.from_julian_date(2_443_144.5, scale: :tai)
instant.to(:tt).as(:julian_date) # => 2443144.5003725
instant.as(:modified_julian_date, scale: :tt) # => 43144.0003725
A calendar date is a shape too, and the one a person reads. It comes out as a
CivilTime, whose fields are the ones a clock and a calendar show, in the
proleptic Gregorian calendar.
civil = instant.as(:civil, scale: :tt)
civil.year # => 1977
civil.month # => 1
civil.second # => 32
You can build an instant from those fields as well, and nothing is lost on the
way in: the date becomes a whole number of days and the time of day an exact
fraction of one. Give a fractional second as a Rational to say it exactly.
Horologium::Instant.from_civil(2025, 5, 1, 12, 0, 0, scale: :tt)
Horologium::Instant.from_civil(2025, 5, 1, 12, 0, Rational(1, 4), scale: :tt)
Each scale has its own shortcut, so the scale is in the name instead of a keyword.
Horologium::Instant.from_tt(2025, 5, 1, 12, 0, 0)
Horologium::Instant.from_tai(2025, 5, 1, 12, 0, 0)
Horologium::Instant.from_tdb(2025, 5, 1, 12, 0, 0)
A date that does not exist is refused rather than rolled over, and the message says which field is wrong.
Horologium::Instant.from_civil(1900, 2, 29, scale: :tt)
# => raises Horologium::InvalidCivilTimeError
The same date and time write out as an extended ISO 8601 string, and read back
from one. The scale is not written into the string: there is no ISO 8601
designator for TAI or TT, and Z means UTC, so a bare time is a coordinate in
the scale you asked for.
instant.as(:iso8601, scale: :tt) # => "1977-01-01T00:00:32.184000000"
Horologium::Instant.from_iso8601("2025-05-01T12:00:00", scale: :tt)
Horologium::Instant.from_iso8601("2025-05-01", scale: :tt) # midnight
The parser reads a strict subset: a calendar date, an optional time of day
after a T, a fraction of a second kept to every digit, and an optional Z or
numeric offset applied as plain arithmetic, not a time zone. A week date, an
ordinal date, or anything outside the subset is refused with a ParseError.
UTC is the scale of civil clocks, the one that holds a leap second now and
then to keep step with the Earth's rotation. from_utc reads a UTC date, and
a leap second is a legal reading: the second is 60 on a day that holds one, and
that moment really existed.
Horologium::Instant.from_utc(2025, 5, 1, 12, 0, 0)
leap = Horologium::Instant.from_utc(2016, 12, 31, 23, 59, 60)
leap.as(:iso8601, scale: :utc) # => "2016-12-31T23:59:60.000000000Z"
A leap second is a real second on the timeline, so the arithmetic is right across it: the second before 23:59:60, the leap second, and the next midnight are one SI second apart each. Second 60 on a day with no leap second is refused.
before = Horologium::Instant.from_utc(
2016, 12, 31, 23, 59, 59,
precision: :exact
)
leap = Horologium::Instant.from_utc(
2016, 12, 31, 23, 59, 60,
precision: :exact
)
after = Horologium::Instant.from_utc(
2017, 1, 1, 0, 0, 0,
precision: :exact
)
leap - before == Horologium::Duration.seconds(1) # => true
after - leap == Horologium::Duration.seconds(1) # => true
Horologium::Instant.from_utc(2020, 6, 15, 23, 59, 60)
# => raises Horologium::InvalidCivilTimeError
The :exact above is what makes == the right question to ask. At the default
:standard precision the same three instants land a rounding step apart, well
under a nanosecond but not zero, so compare those with equal_within?.
UTC runs from 1961, whole leap seconds from 1972 and the earlier
rate-adjustment drift before that, where a UTC second was fractionally longer
than an SI one. An earlier UTC date raises Horologium::OutOfRangeError. The
instant is still reachable, only its UTC label is not, so the error names the
continuous scales, which have no lower bound. The leap seconds and the drift
come from the [iers] gem, with no network access: the data ships with the gem.
Horologium::Instant.from_utc(1960, 12, 31) # => OutOfRangeError
Horologium::Instant.from_civil(1960, 12, 31, scale: :tt) # reaches any date
Leap seconds are announced about six months ahead, so past the date its data
vouches for, the last known offset is the best there is. A UTC reading says
which it rests on: :measured up to that date, :extrapolated after, where a
leap second announced since would not be known.
instant.to(:utc).provenance # => :measured, or :extrapolated past the horizon
A pipeline that must not rest on an offset a leap second could overturn sets a strict horizon, and a reading past it raises instead.
Horologium.configure { |c| c.leap_second_horizon = :raise }
# => reading a date past the data horizon raises OutOfDataRangeError
The configuration is set once, in a single Horologium.configure block, and
frozen when the block returns. See Precision.
A Julian Date is around 2.46 million, which leaves a single Float about 40
microseconds for the fraction of a day, and the loss is already in the literal
before Horologium sees it. So the lossless shapes come first: a String and a
Rational say the Julian Date exactly, and a high and a low part say it to
about twice what one Float holds.
Horologium::Instant.from_julian_date("2456463.052272", scale: :tt)
Horologium::Instant.from_julian_date(
Rational(2_456_463_052_272, 1_000_000),
scale: :tt
)
Horologium::Instant.from_julian_date(2_456_463.0, 0.052272, scale: :tt)
Horologium::Instant.from_modified_julian_date(60_796.0, scale: :tai)
A Duration counts SI seconds, so Duration.days(1) is always 86,400 SI
seconds. Because of leap seconds a civil day can be a second longer or shorter,
so a duration and a calendar day are different things.
Horologium::Duration.days(1) == Horologium::Duration.seconds(86_400) # => true
Horologium::Duration.nanoseconds(1_000_000_000) ==
Horologium::Duration.seconds(1) # => true
Durations add, subtract, and negate among themselves, and read back out in SI seconds.
Horologium::Duration.seconds(30) + Horologium::Duration.seconds(12)
Horologium::Duration.seconds(30) - Horologium::Duration.seconds(42) # negative
-Horologium::Duration.seconds(3)
Horologium::Duration.days(1).to_r # => (86400/1), the whole value
Horologium::Duration.days(1).to_f # => 86400.0
Adding a duration to an instant makes sense, but adding two instants together does not, so it raises an error.
instant + instant # => raises Horologium::DimensionalError
Exact equality is rarely what scientific code wants, so you can compare within a tolerance:
a = Horologium::Instant.from_julian_date(2_460_000.5, scale: :tai)
near = a + Horologium::Duration.nanoseconds(1)
a.equal_within?(near, Horologium::Duration.nanoseconds(2)) # => true
Precision
A modern Julian Date is around 2.46 million. A single Float spends most of its
digits on that large number and has only tens of microseconds left for the
fraction of a day. That is too coarse for scientific time. Horologium stores an
instant across 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
uses, it keeps the precision below a nanosecond for any date, and it does so
with ordinary floating-point arithmetic.
Every value carries one of two precisions, fixed when it is built:
:standard, the default, keeps the value as a two-part float. It is fast and stays within a few nanoseconds of the true value.:exactkeeps the value as aRational, with no rounding. The test suite uses it to check that:standardstays within its stated precision.
Set the default once at boot. Horologium.configure freezes the configuration
when its block returns, so it is called once and everything is set in the one
block. A second call raises Horologium::ConfigurationError.
Horologium.configure do |c|
c.default_precision = :exact
c.leap_second_horizon = :raise
end
Choose it for a single value, or for a scoped block:
Horologium::Instant.from_julian_date(
2_460_000.5,
scale: :tai,
precision: :exact
)
Horologium.with_precision(:exact) do
# instants and durations built here default to :exact
end
Exactness is contagious. An operation between two :standard values stays
:standard. Mixing a :standard and an :exact value gives an :exact
result, so precision is not quietly lost. :exact guarantees the arithmetic
Horologium performs. It cannot bring back precision that an input already lost
when it was built.
Status
This library is in early development, before its first public release. The public API is not stable, so new versions will probably introduce breaking changes until a 1.0 release. Changes are documented in the CHANGELOG.
Development
After checking out the repo, run bin/setup to install dependencies. Then, run
rake to run the tests and RuboCop, or rake steep to type-check the
signatures in sig/. Run COVERAGE=true rake test to measure test coverage,
which is enforced at 100% of lines and branches in CI. You can also run
bin/console for an interactive prompt that will allow you to experiment.
sig/ holds Horologium's own signatures and ships with the gem. sig-vendor/
holds stubs for gems that ship none of their own, and stays out of the gem so
it cannot clash with a downstream RBS collection.
Run bin/ci to run every check that GitHub Actions runs (RuboCop, Steep, YARD
documentation coverage, and the tests with coverage) in a single pass. It runs
each check even when an earlier one fails, so you see everything that needs
fixing at once.
To install this gem onto your local machine, run bundle exec rake install. To
release a new version, update the version number in version.rb, and then run
bundle exec rake release, which will create a git tag for the version, push
git commits and the created tag, and push the .gem file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/rhannequin/horologium.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the Horologium project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.