horologium 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +111 -0
- data/README.md +247 -7
- data/Rakefile +13 -1
- data/lib/horologium/configuration.rb +73 -1
- data/lib/horologium/data/eop.rb +93 -0
- data/lib/horologium/duration.rb +396 -8
- data/lib/horologium/epochs.rb +49 -0
- data/lib/horologium/error.rb +16 -0
- data/lib/horologium/instant.rb +233 -10
- data/lib/horologium/interval.rb +267 -0
- data/lib/horologium/numeric/exact.rb +26 -5
- data/lib/horologium/numeric/precision.rb +156 -18
- data/lib/horologium/numeric/two_part_float.rb +178 -65
- data/lib/horologium/precise_value.rb +10 -7
- data/lib/horologium/representations/civil.rb +45 -24
- data/lib/horologium/representations/iso8601.rb +5 -5
- data/lib/horologium/representations/julian_date.rb +38 -9
- data/lib/horologium/representations/modified_julian_date.rb +4 -21
- data/lib/horologium/scale_reading.rb +25 -10
- data/lib/horologium/scales/base.rb +7 -0
- data/lib/horologium/scales/gps.rb +62 -0
- data/lib/horologium/scales/tcb.rb +118 -0
- data/lib/horologium/scales/tcg.rb +97 -0
- data/lib/horologium/scales/tt.rb +5 -15
- data/lib/horologium/scales/ut1.rb +260 -0
- data/lib/horologium/scales/utc.rb +82 -48
- data/lib/horologium/version.rb +1 -1
- data/lib/horologium.rb +7 -0
- data/sig/horologium/configuration.rbs +10 -0
- data/sig/horologium/data/eop.rbs +13 -0
- data/sig/horologium/duration.rbs +56 -0
- data/sig/horologium/epochs.rbs +13 -0
- data/sig/horologium/instant.rbs +20 -0
- data/sig/horologium/interval.rbs +39 -0
- data/sig/horologium/numeric/exact.rbs +6 -0
- data/sig/horologium/numeric/precision.rbs +14 -0
- data/sig/horologium/numeric/two_part_float.rbs +20 -7
- data/sig/horologium/precise_value.rbs +1 -3
- data/sig/horologium/representations/civil.rbs +4 -0
- data/sig/horologium/representations/julian_date.rbs +2 -0
- data/sig/horologium/representations/modified_julian_date.rbs +0 -2
- data/sig/horologium/scale_reading.rbs +5 -3
- data/sig/horologium/scales/base.rbs +2 -0
- data/sig/horologium/scales/gps.rbs +15 -0
- data/sig/horologium/scales/tcb.rbs +25 -0
- data/sig/horologium/scales/tcg.rbs +19 -0
- data/sig/horologium/scales/tt.rbs +0 -2
- data/sig/horologium/scales/ut1.rbs +35 -0
- data/sig/horologium/scales/utc.rbs +9 -5
- data/sig/horologium.rbs +6 -0
- metadata +17 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 36835b6c549b41c5f598c2ed04052c7ef7ee21c6cc9265f21fadedce465ecfe5
|
|
4
|
+
data.tar.gz: 012c847982be87b762829bc0634a9c363847da2e57f7c6c12e3a5496202da517
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: be4aa86367a43374d70fced595fca0726abda0f6ee55ef781bb94bb2caef9b3203a537ba2c5282b89cca260f682fe7742cc55f14bdce231703b4146f904f2d63
|
|
7
|
+
data.tar.gz: 549ae0c37882e78f29f566634b46b57e529e3aab38f9f11aa1d0b1a00b003716340c69baad274217380c93b2933847435d0c5a07a8b6f239219c973fe956ce1e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,116 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.5 - 2026-09-08
|
|
4
|
+
|
|
5
|
+
All eight scales are in, so an instant reads in any of them, and the domain
|
|
6
|
+
model is complete: the point, the quantity, and the span between two points.
|
|
7
|
+
A `Time` bridges in from the standard library, a duration scales and reads
|
|
8
|
+
ISO 8601, and everything the library cannot compute with is refused by name
|
|
9
|
+
rather than by whatever Ruby happened to raise.
|
|
10
|
+
|
|
11
|
+
### Breaking changes
|
|
12
|
+
|
|
13
|
+
- A value the library cannot read raises `InvalidValueError` instead of
|
|
14
|
+
`ArgumentError`. `Error`'s promise is that a caller can rescue Horologium as
|
|
15
|
+
a unit, and it did not hold: a Float that was not finite came back as
|
|
16
|
+
`FloatDomainError` and a Symbol given to a `Duration` constructor as
|
|
17
|
+
`NoMethodError`. Dividing by zero is the one error left as Ruby's own
|
|
18
|
+
- The civil calendar is bounded above as well as below, from -4799 to 2733193,
|
|
19
|
+
the range ERFA documents for its calendar routines. Julian Date 5e9 used to
|
|
20
|
+
read back as the year 13684822 rather than being refused
|
|
21
|
+
|
|
22
|
+
### Features
|
|
23
|
+
|
|
24
|
+
- Add `TCG` and `TCB`, the coordinate times of the Earth-centred and
|
|
25
|
+
barycentric frames, each running ahead of the scale it is defined on at a
|
|
26
|
+
rate fixed by definition and counted from 1977-01-01 00:00:00 TAI. TCG
|
|
27
|
+
inverts exactly at `:exact`; TCB's own edge does too, but the way to TAI
|
|
28
|
+
goes through TDB's floating-point model
|
|
29
|
+
- Add `GPS`, a fixed 19 SI seconds behind TAI, which is where it stays because
|
|
30
|
+
it counts SI seconds and never takes a leap second
|
|
31
|
+
- Add `UT1`, the scale the rotation of the Earth keeps and the only one here
|
|
32
|
+
that is measured rather than defined. It converts as TT minus delta T, which
|
|
33
|
+
is what lets it reach back to 1800: UTC is undefined before 1961 and refuses
|
|
34
|
+
the date, so a pre-1961 instant has a UT1 name where it can never have a UTC
|
|
35
|
+
one. A reading says whether the value was observed, predicted, or fitted
|
|
36
|
+
- Add `Configuration#eop_source`, the Earth orientation data UT1 reads, and
|
|
37
|
+
`#ut1_horizon`, which chooses between reading past the end of the published
|
|
38
|
+
data with the last known delta T and refusing to
|
|
39
|
+
- Add `Instant.now`, `.from_time`, `.from_unix` and `.from_offset`. A `Time`
|
|
40
|
+
is read in UTC and every field it carries is used, so nothing is rounded on
|
|
41
|
+
the way in. Unix time is read the way POSIX reads it, which is not a count
|
|
42
|
+
of elapsed seconds: it has no leap seconds, so reaching a UTC date costs an
|
|
43
|
+
extra SI second for each one inserted along the way
|
|
44
|
+
- Add `Duration#*` and `#/`, which scale a duration by a plain number, and
|
|
45
|
+
`Duration.mean`, which averages a list of them in the split
|
|
46
|
+
- Add `Duration.parse` and `Duration#to_iso8601`, reading and writing the
|
|
47
|
+
subset of ISO 8601 that is a quantity of time. Years and months are refused
|
|
48
|
+
because a duration cannot say how long they are; weeks are seven days
|
|
49
|
+
exactly but sit outside the subset all the same
|
|
50
|
+
- Add `Interval`, a span between two instants, with `duration`, `cover?`,
|
|
51
|
+
`overlap?`, `intersection` and ISO 8601 either way. It holds its start and
|
|
52
|
+
excludes its end, so one window runs into the next without the two
|
|
53
|
+
overlapping on the moment they share. Its length is elapsed SI seconds, so a
|
|
54
|
+
two-hour window across the 2016 leap second is 7,201 seconds long
|
|
55
|
+
- Add `InvalidValueError` and `InvalidIntervalError`
|
|
56
|
+
|
|
57
|
+
### Fixes
|
|
58
|
+
|
|
59
|
+
- Refuse a number that does not fit a Float where it has to become one, rather
|
|
60
|
+
than carrying on with an Infinity or a zero. Building a `:standard` value
|
|
61
|
+
from an Integer too large for a Float used to fail later, when the Infinity
|
|
62
|
+
had no rational form, and scaling a duration by a number too small used to
|
|
63
|
+
answer with no duration at all
|
|
64
|
+
- Read every part of a two-part Julian Date. A NaN in either part used to
|
|
65
|
+
build an instant and surface later, in a reading, in a civil time, or in a
|
|
66
|
+
comparison
|
|
67
|
+
- Read UT1 back into TAI within delta T of the earliest date the data covers.
|
|
68
|
+
Converting out subtracts delta T, so an instant that close to the edge
|
|
69
|
+
landed on a UT1 coordinate just before it, and reading that back refused
|
|
70
|
+
- Report no overlap between a span of no time and anything, including a span
|
|
71
|
+
that surrounds it. It covers no instant, so there is no instant for the two
|
|
72
|
+
of them to share
|
|
73
|
+
- Stop writing warnings to stderr. `Integer#to_f` warns on its way out of
|
|
74
|
+
range, so the guard that refuses such a number announced it first
|
|
75
|
+
|
|
76
|
+
**Full Changelog**: https://github.com/rhannequin/horologium/compare/v0.0.4...v0.0.5
|
|
77
|
+
|
|
78
|
+
## 0.0.4 - 2026-09-05
|
|
79
|
+
|
|
80
|
+
The epochs astronomy counts from arrive as instants, a duration reads back in
|
|
81
|
+
the unit you want it in, and the conversions cost a good deal less than they
|
|
82
|
+
did.
|
|
83
|
+
|
|
84
|
+
### Features
|
|
85
|
+
|
|
86
|
+
- Add `Epochs`, with `J2000`, `J1900`, `GPS_ZERO`, `UNIX` and
|
|
87
|
+
`TT_TCG_TCB_ORIGIN`. An epoch is an ordinary `Instant`, so the time elapsed
|
|
88
|
+
since one is a subtraction and no Julian Date is involved
|
|
89
|
+
- Add the `Duration` constructors `minutes`, `hours`, `julian_years`,
|
|
90
|
+
`julian_centuries` and `zero`, where a Julian year is exactly 365.25 days and
|
|
91
|
+
a Julian century 36,525
|
|
92
|
+
- Add `Duration#in_seconds`, `#in_minutes`, `#in_hours`, `#in_days`,
|
|
93
|
+
`#in_julian_years` and `#in_julian_centuries`, which come out as a Float at
|
|
94
|
+
`:standard` and a Rational at `:exact`
|
|
95
|
+
- Add `zero?`, `negative?` and `positive?` to `Numeric::TwoPartFloat` and
|
|
96
|
+
`Numeric::Exact`, and `Numeric::Precision.compare`, which orders two values
|
|
97
|
+
by the number they denote whatever precision each is held in
|
|
98
|
+
|
|
99
|
+
### Improvements
|
|
100
|
+
|
|
101
|
+
- Build an instant in about half the time at `:standard` and a third less at
|
|
102
|
+
`:exact`, allocating 3 objects where it allocated 30. An instant no longer
|
|
103
|
+
works out its exact Rational value when it is built, the two-part arithmetic
|
|
104
|
+
keeps its intermediate parts in Floats, and a Julian Date given as a single
|
|
105
|
+
Float skips a step it does not need
|
|
106
|
+
- Read UTC twice as fast at `:standard` and a third faster at `:exact`. The
|
|
107
|
+
conversion reads each leap second offset once, and settles the day without
|
|
108
|
+
spelling the value out as a Rational
|
|
109
|
+
- Read `ScaleReading#provenance` from the scale that took the reading, when it
|
|
110
|
+
is asked rather than on every reading
|
|
111
|
+
|
|
112
|
+
**Full Changelog**: https://github.com/rhannequin/horologium/compare/v0.0.3...v0.0.4
|
|
113
|
+
|
|
3
114
|
## 0.0.3 - 2026-08-22
|
|
4
115
|
|
|
5
116
|
The scale conversions arrive. An instant is now a point with no scale of its
|
data/README.md
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
[](https://github.com/rhannequin/horologium/actions?query=workflow%3ACI)
|
|
4
4
|
|
|
5
5
|
Horologium is a Ruby library dedicated to **scientific time**: the time scales
|
|
6
|
-
(TAI, TT, TDB,
|
|
7
|
-
intervals, and rigorous conversions between scales that astronomy and
|
|
8
|
-
require.
|
|
6
|
+
(TAI, TT, TDB, TCG, TCB, GPS, UTC and UT1), high-precision instants, Julian
|
|
7
|
+
Dates, intervals, and rigorous conversions between scales that astronomy and
|
|
8
|
+
physics require.
|
|
9
9
|
|
|
10
10
|
Ruby already has `Time`, `Date`, `DateTime`, and `ActiveSupport` for civil time:
|
|
11
11
|
time zones, calendars, human formatting. None of them knows the difference
|
|
@@ -17,6 +17,7 @@ Horologium fills.
|
|
|
17
17
|
|
|
18
18
|
- [Installation](#installation)
|
|
19
19
|
- [Usage](#usage)
|
|
20
|
+
- [Intervals](#intervals)
|
|
20
21
|
- [Precision](#precision)
|
|
21
22
|
- [Status](#status)
|
|
22
23
|
- [Development](#development)
|
|
@@ -85,6 +86,33 @@ Horologium::Instant.from_civil(2025, 5, 1, 12, 0, 0, scale: :tt)
|
|
|
85
86
|
Horologium::Instant.from_civil(2025, 5, 1, 12, 0, Rational(1, 4), scale: :tt)
|
|
86
87
|
```
|
|
87
88
|
|
|
89
|
+
Ruby's own `Time` is a bridge in, and the system clock is one line. A `Time`
|
|
90
|
+
is read in UTC and every field it carries is used, so nothing is rounded on
|
|
91
|
+
the way: `subsec` is a `Rational`, not a count of nanoseconds.
|
|
92
|
+
|
|
93
|
+
```rb
|
|
94
|
+
Horologium::Instant.now
|
|
95
|
+
Horologium::Instant.from_time(Time.utc(2025, 5, 1, 12))
|
|
96
|
+
Horologium::Instant.from_unix(1_370_351_716.32)
|
|
97
|
+
|
|
98
|
+
Horologium::Instant.from_offset(
|
|
99
|
+
Horologium::Epochs::J2000,
|
|
100
|
+
Horologium::Duration.julian_centuries(0.25)
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Unix time is not a count of elapsed seconds. It has no leap seconds, so it is
|
|
105
|
+
a way of writing a UTC date, and reaching a UTC date takes one more SI second
|
|
106
|
+
for every leap second inserted along the way. `from_unix(1_700_000_000)` and
|
|
107
|
+
`Epochs::UNIX + Duration.seconds(1_700_000_000)` are about 29 seconds apart,
|
|
108
|
+
and the first one is the later.
|
|
109
|
+
|
|
110
|
+
A `Time` cannot hold a leap second either. `Time.utc(2016, 12, 31, 23, 59, 60)`
|
|
111
|
+
is silently the first moment of 2017, because POSIX time has no leap seconds
|
|
112
|
+
and every day in it is 86,400 seconds long. `from_time` is exact for
|
|
113
|
+
everything a `Time` can carry, but a timestamp that held a second 60 lost it
|
|
114
|
+
before Horologium saw it; read that one with `from_utc` or `from_iso8601`.
|
|
115
|
+
|
|
88
116
|
Each scale has its own shortcut, so the scale is in the name instead of a
|
|
89
117
|
keyword.
|
|
90
118
|
|
|
@@ -92,6 +120,66 @@ keyword.
|
|
|
92
120
|
Horologium::Instant.from_tt(2025, 5, 1, 12, 0, 0)
|
|
93
121
|
Horologium::Instant.from_tai(2025, 5, 1, 12, 0, 0)
|
|
94
122
|
Horologium::Instant.from_tdb(2025, 5, 1, 12, 0, 0)
|
|
123
|
+
Horologium::Instant.from_tcg(2025, 5, 1, 12, 0, 0)
|
|
124
|
+
Horologium::Instant.from_tcb(2025, 5, 1, 12, 0, 0)
|
|
125
|
+
Horologium::Instant.from_gps(2025, 5, 1, 12, 0, 0)
|
|
126
|
+
Horologium::Instant.from_ut1(2025, 5, 1, 12, 0, 0)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
TDB is the one scale that rests on a model rather than a definition. Horologium
|
|
130
|
+
runs the full 787-term Fairhead and Bretagnon series, the one [ERFA] evaluates
|
|
131
|
+
in `dtdb`, rather than a truncation of it.
|
|
132
|
+
|
|
133
|
+
TCG and TCB are the coordinate times of the Earth-centred and barycentric
|
|
134
|
+
frames. Each runs ahead of the scale it is defined on at a fixed rate set by
|
|
135
|
+
definition, TCG on TT by about 22 milliseconds a year and TCB on TDB by about
|
|
136
|
+
half a second, both counted from 1977-01-01 00:00:00 TAI. GPS time is a fixed
|
|
137
|
+
19 SI seconds behind TAI and stays there, because it counts SI seconds and
|
|
138
|
+
never takes a leap second.
|
|
139
|
+
|
|
140
|
+
```rb
|
|
141
|
+
instant = Horologium::Instant.from_julian_date(2_451_545.0, scale: :tt)
|
|
142
|
+
|
|
143
|
+
instant.to(:tcg).as(:julian_date) # => 2451545.0000058548
|
|
144
|
+
instant.to(:tcb).as(:julian_date) # => 2451545.000130251
|
|
145
|
+
instant.to(:gps).as(:julian_date) # => 2451544.9994075927
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
UT1 follows the actual rotation of the Earth, which is irregular, so it is the
|
|
149
|
+
one scale here that is measured rather than defined. Horologium reads the
|
|
150
|
+
difference from the [iers] gem and converts as `UT1 = TT - delta T`, not as
|
|
151
|
+
`UTC + delta UT1`. That is what lets it reach back past 1961: UTC is not
|
|
152
|
+
defined before then and refuses the date, while delta T is estimated from a
|
|
153
|
+
polynomial fit as far back as 1800, so the instant still has a UT1 name.
|
|
154
|
+
|
|
155
|
+
```rb
|
|
156
|
+
instant = Horologium::Instant.from_ut1(1955, 1, 1, 12)
|
|
157
|
+
|
|
158
|
+
instant.as(:iso8601, scale: :tt) # => "1955-01-01T12:00:31.047050952"
|
|
159
|
+
instant.as(:iso8601, scale: :utc) # => raises OutOfRangeError
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
A UT1 reading says where its number came from, so you can tell an observation
|
|
163
|
+
from a prediction from a fit. It is `:measured` where the published series
|
|
164
|
+
observed it, `:extrapolated` where the series predicts it, and `:estimated`
|
|
165
|
+
where the series does not reach and the polynomial answered instead.
|
|
166
|
+
|
|
167
|
+
```rb
|
|
168
|
+
Horologium::Instant.from_utc(2020, 1, 1).to(:ut1).provenance # => :measured
|
|
169
|
+
Horologium::Instant.from_ut1(1900, 1, 1).to(:ut1).provenance # => :estimated
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Past the end of the published data the last known delta T is held rather than
|
|
173
|
+
refused, and the reading says `:extrapolated`. Holding it is a projection, not
|
|
174
|
+
a bounded value: delta T follows the Earth's rotation, which drifts, and every
|
|
175
|
+
leap second nobody has announced yet adds a second of its own. Over the months
|
|
176
|
+
the data usually runs ahead it is worth milliseconds, but the error grows the
|
|
177
|
+
further out you go, and over years it reaches seconds. The recent drift has
|
|
178
|
+
been about 0.04 seconds a year, the last half century about 0.47. A pipeline
|
|
179
|
+
that must not compute on it turns the reading into a refusal.
|
|
180
|
+
|
|
181
|
+
```rb
|
|
182
|
+
Horologium.configure { |c| c.ut1_horizon = :raise }
|
|
95
183
|
```
|
|
96
184
|
|
|
97
185
|
A date that does not exist is refused rather than rolled over, and the message
|
|
@@ -218,20 +306,77 @@ so a duration and a calendar day are different things.
|
|
|
218
306
|
Horologium::Duration.days(1) == Horologium::Duration.seconds(86_400) # => true
|
|
219
307
|
Horologium::Duration.nanoseconds(1_000_000_000) ==
|
|
220
308
|
Horologium::Duration.seconds(1) # => true
|
|
309
|
+
|
|
310
|
+
Horologium::Duration.minutes(90)
|
|
311
|
+
Horologium::Duration.hours(6)
|
|
312
|
+
Horologium::Duration.zero
|
|
221
313
|
```
|
|
222
314
|
|
|
223
|
-
|
|
224
|
-
|
|
315
|
+
A Julian year is exactly 365.25 days and a Julian century is 36,525 days. They
|
|
316
|
+
are astronomical constants. A calendar year holds 365 or 366 days, so a Julian
|
|
317
|
+
year lands a few hours away from the same date next year.
|
|
318
|
+
|
|
319
|
+
```rb
|
|
320
|
+
Horologium::Duration.julian_years(1) ==
|
|
321
|
+
Horologium::Duration.days(365.25) # => true
|
|
322
|
+
Horologium::Duration.julian_centuries(0.25)
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Durations add, subtract, and negate among themselves, scale by a plain number,
|
|
326
|
+
and read back out in SI seconds.
|
|
225
327
|
|
|
226
328
|
```rb
|
|
227
329
|
Horologium::Duration.seconds(30) + Horologium::Duration.seconds(12)
|
|
228
330
|
Horologium::Duration.seconds(30) - Horologium::Duration.seconds(42) # negative
|
|
229
331
|
-Horologium::Duration.seconds(3)
|
|
230
332
|
|
|
333
|
+
Horologium::Duration.hours(1) * 1.5 # => 90 minutes
|
|
334
|
+
Horologium::Duration.hours(1) / 2 # => 30 minutes
|
|
335
|
+
|
|
336
|
+
Horologium::Duration.mean(
|
|
337
|
+
[Horologium::Duration.seconds(1), Horologium::Duration.seconds(3)]
|
|
338
|
+
) # => 2 seconds
|
|
339
|
+
|
|
231
340
|
Horologium::Duration.days(1).to_r # => (86400/1), the whole value
|
|
232
341
|
Horologium::Duration.days(1).to_f # => 86400.0
|
|
233
342
|
```
|
|
234
343
|
|
|
344
|
+
Scaling matters more than it looks. A quantity you can only add to another of
|
|
345
|
+
its kind sends you back to raw seconds the moment you need half of one, and
|
|
346
|
+
the precision the type exists to protect goes with you.
|
|
347
|
+
|
|
348
|
+
A duration reads and writes ISO 8601, in the subset that is a quantity of time
|
|
349
|
+
rather than a walk through a calendar. Years, months and weeks are refused: a
|
|
350
|
+
year is 365 days or 366 and a month is anywhere from 28 to 31, so `P1Y` names
|
|
351
|
+
a span the calendar resolves and a duration cannot.
|
|
352
|
+
|
|
353
|
+
```rb
|
|
354
|
+
Horologium::Duration.parse("PT4H5M6S").in_seconds # => 14706.0
|
|
355
|
+
Horologium::Duration.parse("P3D").in_seconds # => 259200.0
|
|
356
|
+
Horologium::Duration.seconds(14_706).to_iso8601 # => "PT4H5M6S"
|
|
357
|
+
|
|
358
|
+
Horologium::Duration.parse("P1Y") # => raises ParseError
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
The string holds nanoseconds, so a duration on that grid reads back into
|
|
362
|
+
itself and one finer than it does not: an exact third of a second writes as
|
|
363
|
+
`PT0.333333333S`, and a quarter of a nanosecond writes as `PT0S`. A third of a
|
|
364
|
+
second has no finite decimal form at any resolution, so that is the format
|
|
365
|
+
rather than the grid. Use `to_r` where the whole value has to survive.
|
|
366
|
+
|
|
367
|
+
A duration also reads in a unit. The division happens inside the precision the
|
|
368
|
+
duration is held in, so a `:standard` duration keeps the digits a collapsed
|
|
369
|
+
`Float` would lose, and an `:exact` one reads as a `Rational`.
|
|
370
|
+
|
|
371
|
+
```rb
|
|
372
|
+
Horologium::Duration.days(1).in_hours # => 24.0
|
|
373
|
+
Horologium::Duration.hours(12).in_days # => 0.5
|
|
374
|
+
Horologium::Duration.days(36_525).in_julian_centuries # => 1.0
|
|
375
|
+
|
|
376
|
+
exact = Horologium::Duration.julian_years(1, precision: :exact)
|
|
377
|
+
exact.in_days # => (1461/4)
|
|
378
|
+
```
|
|
379
|
+
|
|
235
380
|
Adding a duration to an instant makes sense, but adding two instants together
|
|
236
381
|
does not, so it raises an error.
|
|
237
382
|
|
|
@@ -239,6 +384,30 @@ does not, so it raises an error.
|
|
|
239
384
|
instant + instant # => raises Horologium::DimensionalError
|
|
240
385
|
```
|
|
241
386
|
|
|
387
|
+
The astronomical epochs are built as instants, so the elapsed time since one is
|
|
388
|
+
a subtraction and no Julian Date is involved.
|
|
389
|
+
|
|
390
|
+
```rb
|
|
391
|
+
instant = Horologium::Instant.from_tt(2026, 1, 1)
|
|
392
|
+
|
|
393
|
+
(instant - Horologium::Epochs::J2000).to_f # => 820497600.0 SI seconds
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
The scale is part of an epoch's definition. `J2000` is noon TT, which is 64.184
|
|
397
|
+
seconds away from noon UTC on the same day.
|
|
398
|
+
|
|
399
|
+
```rb
|
|
400
|
+
Horologium::Epochs::J2000 # 2000-01-01 12:00:00 TT
|
|
401
|
+
Horologium::Epochs::J1900 # 1899-12-31 12:00:00 TT
|
|
402
|
+
Horologium::Epochs::GPS_ZERO # 1980-01-06 00:00:00 UTC
|
|
403
|
+
Horologium::Epochs::UNIX # 1970-01-01 00:00:00 UTC
|
|
404
|
+
Horologium::Epochs::TT_TCG_TCB_ORIGIN # 1977-01-01 00:00:00 TAI
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
An epoch is fixed at `:exact`, since it is a definition. Subtracting one from a
|
|
408
|
+
`:standard` instant gives an `:exact` duration, the way any other mix of the two
|
|
409
|
+
does.
|
|
410
|
+
|
|
242
411
|
Exact equality is rarely what scientific code wants, so you can compare within a
|
|
243
412
|
tolerance:
|
|
244
413
|
|
|
@@ -249,6 +418,51 @@ near = a + Horologium::Duration.nanoseconds(1)
|
|
|
249
418
|
a.equal_within?(near, Horologium::Duration.nanoseconds(2)) # => true
|
|
250
419
|
```
|
|
251
420
|
|
|
421
|
+
## Intervals
|
|
422
|
+
|
|
423
|
+
An interval is a span between two instants: an observation campaign, an
|
|
424
|
+
eclipse window, a satellite pass. It holds its start and excludes its end, so
|
|
425
|
+
one window runs into the next without the two overlapping on the moment they
|
|
426
|
+
share, and two windows that merely touch intersect in nothing.
|
|
427
|
+
|
|
428
|
+
```rb
|
|
429
|
+
window = Horologium::Interval.new(
|
|
430
|
+
Horologium::Instant.from_utc(2025, 5, 1),
|
|
431
|
+
Horologium::Instant.from_utc(2025, 5, 1, 2)
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
Horologium::Interval.from(
|
|
435
|
+
Horologium::Instant.from_utc(2025, 5, 1),
|
|
436
|
+
Horologium::Duration.hours(2)
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
window.start
|
|
440
|
+
window.end
|
|
441
|
+
window.duration
|
|
442
|
+
window.cover?(Horologium::Instant.from_utc(2025, 5, 1, 1))
|
|
443
|
+
window.overlap?(other)
|
|
444
|
+
window.intersection(other) # => an Interval, or nil
|
|
445
|
+
window.to_iso8601(scale: :utc)
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
Its duration is elapsed SI seconds, and that is not the difference the clock
|
|
449
|
+
shows. A two-hour window across the 2016 leap second is 7,201 seconds long,
|
|
450
|
+
and this is the one place in the library where leap seconds show up without
|
|
451
|
+
anyone having asked about them.
|
|
452
|
+
|
|
453
|
+
```rb
|
|
454
|
+
window = Horologium::Interval.parse(
|
|
455
|
+
"2016-12-31T23:00Z/2017-01-01T01:00Z",
|
|
456
|
+
scale: :utc,
|
|
457
|
+
precision: :exact
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
window.duration.in_seconds # => (7201/1)
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
Repeating intervals (`R5/…`) are not read. Repetition is scheduling, and
|
|
464
|
+
scheduling is [ice_cube]'s problem, not this library's.
|
|
465
|
+
|
|
252
466
|
## Precision
|
|
253
467
|
|
|
254
468
|
A modern Julian Date is around 2.46 million. A single `Float` spends most of its
|
|
@@ -261,8 +475,8 @@ with ordinary floating-point arithmetic.
|
|
|
261
475
|
|
|
262
476
|
Every value carries one of two precisions, fixed when it is built:
|
|
263
477
|
|
|
264
|
-
- `:standard`, the default, keeps the value as a two-part float. It
|
|
265
|
-
|
|
478
|
+
- `:standard`, the default, keeps the value as a two-part float. It stays
|
|
479
|
+
within a few nanoseconds of the true value.
|
|
266
480
|
- `:exact` keeps the value as a `Rational`, with no rounding. The test suite
|
|
267
481
|
uses it to check that `:standard` stays within its stated precision.
|
|
268
482
|
|
|
@@ -291,12 +505,30 @@ Horologium.with_precision(:exact) do
|
|
|
291
505
|
end
|
|
292
506
|
```
|
|
293
507
|
|
|
508
|
+
A library can depend on Horologium without taking the configuration away from
|
|
509
|
+
the application that depends on it. Reading the configuration does not freeze
|
|
510
|
+
it, so a gem that converts an instant while it loads still leaves
|
|
511
|
+
`Horologium.configure` open to the host. And a value built with an explicit
|
|
512
|
+
`precision:` ignores the default, so a gem that pins its own precision computes
|
|
513
|
+
the same thing whatever the host sets.
|
|
514
|
+
|
|
294
515
|
Exactness is contagious. An operation between two `:standard` values stays
|
|
295
516
|
`:standard`. Mixing a `:standard` and an `:exact` value gives an `:exact`
|
|
296
517
|
result, so precision is not quietly lost. `:exact` guarantees the arithmetic
|
|
297
518
|
Horologium performs. It cannot bring back precision that an input already lost
|
|
298
519
|
when it was built.
|
|
299
520
|
|
|
521
|
+
A `:standard` value turns a `Rational` into a pair of `Float`s on the way in and
|
|
522
|
+
back again on the way out, and that conversion costs more than the arithmetic it
|
|
523
|
+
feeds. So `:exact` is the faster one whenever a `Rational` goes in or comes out,
|
|
524
|
+
by up to about twice on a build and a read. With a `Float` in and a `Float` out
|
|
525
|
+
the two are close enough that the machine decides. `:standard` is the cheaper
|
|
526
|
+
one for a long run of arithmetic between values already held that way. Mixing
|
|
527
|
+
the two is the expensive case, because promoting a `:standard` value to a
|
|
528
|
+
`Rational` costs more than the operation it is promoted for, so inside a loop it
|
|
529
|
+
is worth keeping to one precision. `bin/benchmark` measures all of this on your
|
|
530
|
+
own machine.
|
|
531
|
+
|
|
300
532
|
## Status
|
|
301
533
|
|
|
302
534
|
This library is in early development, before its first public release. The
|
|
@@ -315,6 +547,12 @@ which is enforced at 100% of lines and branches in CI. You can also run
|
|
|
315
547
|
holds stubs for gems that ship none of their own, and stays out of the gem so
|
|
316
548
|
it cannot clash with a downstream RBS collection.
|
|
317
549
|
|
|
550
|
+
Run `bin/benchmark` to time the paths a consumer runs in a loop and count the
|
|
551
|
+
objects each one allocates. Timings drift by a few percent between runs, so it
|
|
552
|
+
rotates the order of the cases and reports the fastest round for each. The
|
|
553
|
+
allocation counts do not drift, so where a timing and a count disagree, trust
|
|
554
|
+
the count. Run it on a branch and on `main` to compare.
|
|
555
|
+
|
|
318
556
|
Run `bin/ci` to run every check that GitHub Actions runs (RuboCop, Steep, YARD
|
|
319
557
|
documentation coverage, and the tests with coverage) in a single pass. It runs
|
|
320
558
|
each check even when an earlier one fails, so you see everything that needs
|
|
@@ -341,6 +579,8 @@ rooms and mailing lists is expected to follow the [code of conduct].
|
|
|
341
579
|
|
|
342
580
|
[Bundler]: https://bundler.io
|
|
343
581
|
[ERFA]: https://github.com/liberfa/erfa
|
|
582
|
+
[ice_cube]: https://github.com/ice-cube-ruby/ice_cube
|
|
583
|
+
[iers]: https://github.com/rhannequin/iers
|
|
344
584
|
[CHANGELOG]: https://github.com/rhannequin/horologium/blob/main/CHANGELOG.md
|
|
345
585
|
[rubygems.org]: https://rubygems.org
|
|
346
586
|
[MIT License]: https://opensource.org/licenses/MIT
|
data/Rakefile
CHANGED
|
@@ -17,7 +17,7 @@ task :steep do
|
|
|
17
17
|
sh "steep check"
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
desc "Verify YARD documentation coverage is 100%"
|
|
20
|
+
desc "Verify YARD documentation coverage is 100% and parses without warning"
|
|
21
21
|
task :yard_coverage do
|
|
22
22
|
require "open3"
|
|
23
23
|
output, status = Open3.capture2e("yard", "stats", "--list-undoc")
|
|
@@ -25,6 +25,18 @@ task :yard_coverage do
|
|
|
25
25
|
unless status.success? && output.include?("100.00% documented")
|
|
26
26
|
abort "YARD documentation coverage is below 100%"
|
|
27
27
|
end
|
|
28
|
+
|
|
29
|
+
# Coverage counts a comment block, not the method it belongs to, so a block
|
|
30
|
+
# that landed above the wrong method still reads as documented while the
|
|
31
|
+
# method it was written for reads as documented too. The warnings are what
|
|
32
|
+
# notice: a @param naming a parameter the method does not have is a block
|
|
33
|
+
# that has come adrift.
|
|
34
|
+
warnings, = Open3.capture2e("yard", "--no-output")
|
|
35
|
+
lines = warnings.lines.grep(/\[warn\]/)
|
|
36
|
+
next if lines.empty?
|
|
37
|
+
|
|
38
|
+
puts lines
|
|
39
|
+
abort "YARD parsed #{lines.length} warning(s)"
|
|
28
40
|
end
|
|
29
41
|
|
|
30
42
|
task default: %i[test rubocop]
|
|
@@ -13,7 +13,11 @@ module Horologium
|
|
|
13
13
|
tai: Scales::TAI,
|
|
14
14
|
tt: Scales::TT,
|
|
15
15
|
tdb: Scales::TDB,
|
|
16
|
-
|
|
16
|
+
tcg: Scales::TCG,
|
|
17
|
+
tcb: Scales::TCB,
|
|
18
|
+
gps: Scales::GPS,
|
|
19
|
+
utc: Scales::UTC,
|
|
20
|
+
ut1: Scales::UT1
|
|
17
21
|
}.freeze
|
|
18
22
|
|
|
19
23
|
# @return [Symbol] the default precision, +:standard+ until configured
|
|
@@ -22,6 +26,9 @@ module Horologium
|
|
|
22
26
|
# The recognised ways to handle a leap second past the data's horizon.
|
|
23
27
|
LEAP_SECOND_HORIZONS = %i[extrapolate raise].freeze
|
|
24
28
|
|
|
29
|
+
# The recognised ways to handle a UT1 reading past the data's horizon.
|
|
30
|
+
UT1_HORIZONS = %i[extrapolate raise].freeze
|
|
31
|
+
|
|
25
32
|
# The source UTC reads its leap seconds from. It answers +tai_utc_at+ with
|
|
26
33
|
# TAI - UTC at a point in UTC, given a Julian Day Number: a day's 0h for a
|
|
27
34
|
# whole number, or part way through a day where a fraction is added, which
|
|
@@ -33,6 +40,23 @@ module Horologium
|
|
|
33
40
|
# @return [#tai_utc_at]
|
|
34
41
|
attr_reader :leap_second_source
|
|
35
42
|
|
|
43
|
+
# The source UT1 reads delta T, TT - UT1, from. It answers +delta_t_at+
|
|
44
|
+
# with the seconds at a Julian Date, and +provenance_at+ with how that
|
|
45
|
+
# value was arrived at. {Data::Eop}, over the iers gem, is the default; a
|
|
46
|
+
# caller with its own Earth orientation data can set another here.
|
|
47
|
+
#
|
|
48
|
+
# @return [#delta_t_at]
|
|
49
|
+
attr_reader :eop_source
|
|
50
|
+
|
|
51
|
+
# What UT1 does with a date past the point the Earth orientation data
|
|
52
|
+
# reaches. +:extrapolate+, the default, reads it with the last published
|
|
53
|
+
# delta T and marks the reading +:extrapolated+. +:raise+ refuses it with
|
|
54
|
+
# {OutOfDataRangeError}, for a pipeline that must not lean on a value the
|
|
55
|
+
# next bulletin could revise.
|
|
56
|
+
#
|
|
57
|
+
# @return [Symbol] +:extrapolate+ or +:raise+
|
|
58
|
+
attr_reader :ut1_horizon
|
|
59
|
+
|
|
36
60
|
# What UTC does with a date past the point the leap second data vouches
|
|
37
61
|
# for. +:extrapolate+, the default, reads it in UTC with the last known
|
|
38
62
|
# offset and marks the reading +:extrapolated+. +:raise+ refuses it with
|
|
@@ -45,6 +69,8 @@ module Horologium
|
|
|
45
69
|
def initialize
|
|
46
70
|
@default_precision = :standard
|
|
47
71
|
@scales = BUILT_IN_SCALES.dup
|
|
72
|
+
@eop_source = Data::Eop
|
|
73
|
+
@ut1_horizon = :extrapolate
|
|
48
74
|
@leap_second_source = Data::LeapSeconds
|
|
49
75
|
@leap_second_horizon = :extrapolate
|
|
50
76
|
end
|
|
@@ -93,6 +119,32 @@ module Horologium
|
|
|
93
119
|
@leap_second_source = source
|
|
94
120
|
end
|
|
95
121
|
|
|
122
|
+
# Sets the source UT1 reads delta T from. A source that does not answer
|
|
123
|
+
# both +delta_t_at+ and +provenance_at+ is refused here, at configuration
|
|
124
|
+
# time, rather than when an instant is first read in UT1.
|
|
125
|
+
#
|
|
126
|
+
# @param source [#delta_t_at] the source to read from
|
|
127
|
+
# @return [#delta_t_at] the source that was set
|
|
128
|
+
# @raise [ConfigurationError] once the configuration is frozen, or when
|
|
129
|
+
# the source does not answer both methods
|
|
130
|
+
def eop_source=(source)
|
|
131
|
+
if frozen?
|
|
132
|
+
raise ConfigurationError, "the configuration is already frozen"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
missing = %i[delta_t_at provenance_at].reject do |method|
|
|
136
|
+
source.respond_to?(method)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
unless missing.empty?
|
|
140
|
+
raise ConfigurationError,
|
|
141
|
+
"an Earth orientation source must respond to " \
|
|
142
|
+
"#{missing.join(" and ")}, got #{source.inspect}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
@eop_source = source
|
|
146
|
+
end
|
|
147
|
+
|
|
96
148
|
# Sets how UTC handles a date past the leap second data's horizon.
|
|
97
149
|
#
|
|
98
150
|
# @param horizon [Symbol] +:extrapolate+ or +:raise+
|
|
@@ -113,6 +165,26 @@ module Horologium
|
|
|
113
165
|
@leap_second_horizon = horizon
|
|
114
166
|
end
|
|
115
167
|
|
|
168
|
+
# Sets how UT1 handles a date past the Earth orientation data's horizon.
|
|
169
|
+
#
|
|
170
|
+
# @param horizon [Symbol] +:extrapolate+ or +:raise+
|
|
171
|
+
# @return [Symbol] the horizon that was set
|
|
172
|
+
# @raise [ConfigurationError] once the configuration is frozen, or when
|
|
173
|
+
# the horizon is not recognised
|
|
174
|
+
def ut1_horizon=(horizon)
|
|
175
|
+
if frozen?
|
|
176
|
+
raise ConfigurationError, "the configuration is already frozen"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
unless UT1_HORIZONS.include?(horizon)
|
|
180
|
+
raise ConfigurationError,
|
|
181
|
+
"ut1_horizon must be one of #{UT1_HORIZONS.join(", ")}, " \
|
|
182
|
+
"got #{horizon.inspect}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
@ut1_horizon = horizon
|
|
186
|
+
end
|
|
187
|
+
|
|
116
188
|
# Registers a time scale under a name, so an instant can be read in it
|
|
117
189
|
# with {Instant#to}. The scale is a class implementing {Scales::Base}: it
|
|
118
190
|
# says how to read TAI in the scale, and how to read the scale back in
|