horologium 0.0.4 → 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 +75 -0
- data/README.md +166 -5
- data/Rakefile +13 -1
- data/lib/horologium/configuration.rb +73 -1
- data/lib/horologium/data/eop.rb +93 -0
- data/lib/horologium/duration.rb +242 -8
- data/lib/horologium/error.rb +16 -0
- data/lib/horologium/instant.rb +232 -4
- data/lib/horologium/interval.rb +267 -0
- data/lib/horologium/numeric/exact.rb +11 -5
- data/lib/horologium/numeric/precision.rb +105 -14
- data/lib/horologium/numeric/two_part_float.rb +32 -6
- data/lib/horologium/precise_value.rb +1 -1
- data/lib/horologium/representations/civil.rb +45 -24
- data/lib/horologium/representations/iso8601.rb +3 -3
- data/lib/horologium/representations/julian_date.rb +13 -9
- data/lib/horologium/representations/modified_julian_date.rb +4 -21
- data/lib/horologium/scale_reading.rb +1 -1
- 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 -20
- data/lib/horologium/scales/ut1.rb +260 -0
- data/lib/horologium/version.rb +1 -1
- data/lib/horologium.rb +6 -0
- data/sig/horologium/configuration.rbs +10 -0
- data/sig/horologium/data/eop.rbs +13 -0
- data/sig/horologium/duration.rbs +24 -0
- data/sig/horologium/instant.rbs +20 -0
- data/sig/horologium/interval.rbs +39 -0
- data/sig/horologium/numeric/precision.rbs +10 -0
- data/sig/horologium/numeric/two_part_float.rbs +4 -0
- data/sig/horologium/representations/civil.rbs +4 -0
- data/sig/horologium/representations/modified_julian_date.rbs +0 -2
- 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.rbs +6 -0
- metadata +15 -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,80 @@
|
|
|
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
|
+
|
|
3
78
|
## 0.0.4 - 2026-09-05
|
|
4
79
|
|
|
5
80
|
The epochs astronomy counts from arrive as instants, a duration reads back in
|
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,12 +120,68 @@ 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)
|
|
95
127
|
```
|
|
96
128
|
|
|
97
129
|
TDB is the one scale that rests on a model rather than a definition. Horologium
|
|
98
130
|
runs the full 787-term Fairhead and Bretagnon series, the one [ERFA] evaluates
|
|
99
131
|
in `dtdb`, rather than a truncation of it.
|
|
100
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 }
|
|
183
|
+
```
|
|
184
|
+
|
|
101
185
|
A date that does not exist is refused rather than rolled over, and the message
|
|
102
186
|
says which field is wrong.
|
|
103
187
|
|
|
@@ -238,18 +322,48 @@ Horologium::Duration.julian_years(1) ==
|
|
|
238
322
|
Horologium::Duration.julian_centuries(0.25)
|
|
239
323
|
```
|
|
240
324
|
|
|
241
|
-
Durations add, subtract, and negate among themselves,
|
|
242
|
-
seconds.
|
|
325
|
+
Durations add, subtract, and negate among themselves, scale by a plain number,
|
|
326
|
+
and read back out in SI seconds.
|
|
243
327
|
|
|
244
328
|
```rb
|
|
245
329
|
Horologium::Duration.seconds(30) + Horologium::Duration.seconds(12)
|
|
246
330
|
Horologium::Duration.seconds(30) - Horologium::Duration.seconds(42) # negative
|
|
247
331
|
-Horologium::Duration.seconds(3)
|
|
248
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
|
+
|
|
249
340
|
Horologium::Duration.days(1).to_r # => (86400/1), the whole value
|
|
250
341
|
Horologium::Duration.days(1).to_f # => 86400.0
|
|
251
342
|
```
|
|
252
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
|
+
|
|
253
367
|
A duration also reads in a unit. The division happens inside the precision the
|
|
254
368
|
duration is held in, so a `:standard` duration keeps the digits a collapsed
|
|
255
369
|
`Float` would lose, and an `:exact` one reads as a `Rational`.
|
|
@@ -304,6 +418,51 @@ near = a + Horologium::Duration.nanoseconds(1)
|
|
|
304
418
|
a.equal_within?(near, Horologium::Duration.nanoseconds(2)) # => true
|
|
305
419
|
```
|
|
306
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
|
+
|
|
307
466
|
## Precision
|
|
308
467
|
|
|
309
468
|
A modern Julian Date is around 2.46 million. A single `Float` spends most of its
|
|
@@ -420,6 +579,8 @@ rooms and mailing lists is expected to follow the [code of conduct].
|
|
|
420
579
|
|
|
421
580
|
[Bundler]: https://bundler.io
|
|
422
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
|
|
423
584
|
[CHANGELOG]: https://github.com/rhannequin/horologium/blob/main/CHANGELOG.md
|
|
424
585
|
[rubygems.org]: https://rubygems.org
|
|
425
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
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "iers"
|
|
4
|
+
|
|
5
|
+
module Horologium
|
|
6
|
+
module Data
|
|
7
|
+
# The Earth orientation data, from the iers gem. {Scales::UT1} reads this
|
|
8
|
+
# to convert between UT1 and the continuous scales, and it is the default
|
|
9
|
+
# source. A caller with its own data, a frozen bulletin or an alternative
|
|
10
|
+
# feed, can put another source in its place through
|
|
11
|
+
# {Configuration#eop_source}, as long as it answers {delta_t_at} and
|
|
12
|
+
# {provenance_at}.
|
|
13
|
+
#
|
|
14
|
+
# The quantity is delta T, TT - UT1, in SI seconds. It is not a constant
|
|
15
|
+
# and not a model: the Earth's rotation is irregular, so delta T is
|
|
16
|
+
# measured and published, and where the measurements do not reach it is
|
|
17
|
+
# estimated from a polynomial fit to eclipse records and old observations.
|
|
18
|
+
module Eop
|
|
19
|
+
# The days between a Julian Date and a Modified Julian Date, the shape
|
|
20
|
+
# iers reads. Unlike {LeapSeconds}, which asks for a day and gets a
|
|
21
|
+
# value that steps at its 0h, delta T is interpolated, so the fraction
|
|
22
|
+
# of the day is kept and this is the plain offset rather than one that
|
|
23
|
+
# also lands on midnight.
|
|
24
|
+
#
|
|
25
|
+
# @api private
|
|
26
|
+
MJD_OFFSET = 2_400_000.5
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# TT - UT1 in SI seconds at a point in time, given as a Julian Date.
|
|
30
|
+
#
|
|
31
|
+
# The Julian Date is read in UTC where UTC is defined, because that is
|
|
32
|
+
# what the published series is tabulated against; {Scales::UT1} is
|
|
33
|
+
# what decides that and passes the right one. The value is
|
|
34
|
+
# interpolated between daily entries, so a fraction of a day counts.
|
|
35
|
+
#
|
|
36
|
+
# @param julian_date [Float] the Julian Date to read at
|
|
37
|
+
# @return [Float] TT - UT1 in seconds
|
|
38
|
+
# @raise [IERS::OutOfRangeError] where neither source reaches the
|
|
39
|
+
# date: before the polynomial starts in 1800, and after the
|
|
40
|
+
# published series ends, the two together covering everything in
|
|
41
|
+
# between
|
|
42
|
+
def delta_t_at(julian_date)
|
|
43
|
+
IERS::DeltaT.at(mjd: julian_date - MJD_OFFSET).delta_t
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The Julian Date the published series vouches through, its last
|
|
47
|
+
# entry. Past it there is no delta T to read, and {Scales::UT1} holds
|
|
48
|
+
# the last one rather than refusing, the way UTC holds the last leap
|
|
49
|
+
# second offset. It is nil when the series is empty and there is no
|
|
50
|
+
# horizon to speak of.
|
|
51
|
+
#
|
|
52
|
+
# Written to be total rather than guarded: iers treats a finals file
|
|
53
|
+
# that parses to no rows as a series covering nothing, and +last(1)+
|
|
54
|
+
# carries that through as nil without a branch no bundled data can
|
|
55
|
+
# reach.
|
|
56
|
+
#
|
|
57
|
+
# @return [Float, nil] the Julian Date of the last entry, or nil where
|
|
58
|
+
# the series has no entries and there is no horizon to report
|
|
59
|
+
def covers_until
|
|
60
|
+
IERS::Data.finals_entries
|
|
61
|
+
.last(1)
|
|
62
|
+
.map { |entry| entry.mjd + MJD_OFFSET }
|
|
63
|
+
.first
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# How the delta T at a point was arrived at. +:measured+ where the
|
|
67
|
+
# published series observed it, +:extrapolated+ where the series
|
|
68
|
+
# predicts it, and +:estimated+ where the series does not reach and
|
|
69
|
+
# the polynomial fit answers instead.
|
|
70
|
+
#
|
|
71
|
+
# Which of the two answered is asked of iers rather than worked out
|
|
72
|
+
# from a date here, because the seam sits wherever the loaded series
|
|
73
|
+
# happens to start and moves when that data is replaced.
|
|
74
|
+
#
|
|
75
|
+
# It is read only when it is asked for, not on every reading, so the
|
|
76
|
+
# conversion pays for one lookup rather than two.
|
|
77
|
+
#
|
|
78
|
+
# @param julian_date [Float] the Julian Date to read at
|
|
79
|
+
# @return [Symbol] +:measured+, +:extrapolated+ or +:estimated+
|
|
80
|
+
# @raise [IERS::OutOfRangeError] where delta T is not available
|
|
81
|
+
def provenance_at(julian_date)
|
|
82
|
+
mjd = julian_date - MJD_OFFSET
|
|
83
|
+
return :estimated unless IERS::DeltaT.at(mjd: mjd).measured?
|
|
84
|
+
|
|
85
|
+
case IERS::UT1.at(mjd: mjd).data_quality
|
|
86
|
+
when :predicted then :extrapolated
|
|
87
|
+
else :measured
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|