horologium 0.0.2 → 0.0.3
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 +43 -0
- data/README.md +190 -12
- data/lib/horologium/configuration.rb +199 -8
- data/lib/horologium/data/barycentric_model.rb +901 -0
- data/lib/horologium/data/leap_seconds.rb +55 -0
- data/lib/horologium/duration.rb +87 -13
- data/lib/horologium/error.rb +86 -3
- data/lib/horologium/instant.rb +410 -62
- data/lib/horologium/numeric/exact.rb +46 -60
- data/lib/horologium/numeric/precision.rb +87 -4
- data/lib/horologium/numeric/two_part_float.rb +52 -68
- data/lib/horologium/precise_value.rb +4 -10
- data/lib/horologium/representations/civil.rb +518 -0
- data/lib/horologium/representations/civil_time.rb +162 -0
- data/lib/horologium/representations/iso8601.rb +286 -0
- data/lib/horologium/representations/julian_date.rb +227 -0
- data/lib/horologium/representations/modified_julian_date.rb +106 -0
- data/lib/horologium/scale_reading.rb +126 -0
- data/lib/horologium/scales/base.rb +118 -0
- data/lib/horologium/scales/tai.rb +41 -0
- data/lib/horologium/scales/tdb.rb +75 -0
- data/lib/horologium/scales/tt.rb +71 -0
- data/lib/horologium/scales/utc.rb +374 -0
- data/lib/horologium/version.rb +1 -1
- data/lib/horologium.rb +14 -1
- data/sig/horologium/configuration.rbs +32 -0
- data/sig/horologium/data/barycentric_model.rbs +19 -0
- data/sig/horologium/data/leap_seconds.rbs +11 -0
- data/sig/horologium/duration.rbs +18 -0
- data/sig/horologium/instant.rbs +34 -5
- data/sig/horologium/numeric/exact.rbs +6 -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 +63 -0
- data/sig/horologium/representations/civil_time.rbs +49 -0
- data/sig/horologium/representations/iso8601.rbs +27 -0
- data/sig/horologium/representations/julian_date.rbs +25 -0
- data/sig/horologium/representations/modified_julian_date.rbs +15 -0
- data/sig/horologium/scale_reading.rbs +33 -0
- data/sig/horologium/scales/base.rbs +17 -0
- data/sig/horologium/scales/tai.rbs +9 -0
- data/sig/horologium/scales/tdb.rbs +11 -0
- data/sig/horologium/scales/tt.rbs +17 -0
- data/sig/horologium/scales/utc.rbs +49 -0
- data/sig/horologium.rbs +32 -0
- metadata +49 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 513d825c2d141d84274c76333af312631c4b9a077803d2e2921033782af518ce
|
|
4
|
+
data.tar.gz: '0108ee60e9bf9c1f149baefafe6e78f8d735dd824148ed91c69267b5ca10aad8'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: edfa8b82fc1b2d4f795abbf0c01b0a9753cbfb31aa03c37de952b5ea64e62687207bb4efde740d05246ec9df63b6226d682d998ef5d917e401f48fae2cf4402d
|
|
7
|
+
data.tar.gz: a75b50d13419e546ae0b8cca4d0dce80d078a90578e3e80664a65b2f207ae5535900eb9f36a6291b5a303ce41ccd834b9cd5c226c46e4992d081cf1949b2ba56
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.3 - 2026-08-22
|
|
4
|
+
|
|
5
|
+
The scale conversions arrive. An instant is now a point with no scale of its
|
|
6
|
+
own, given in one scale and read back in another, and it comes out as a Julian
|
|
7
|
+
Date, a calendar date, or an ISO 8601 string.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- Add time scales, each converting to and from TAI, the scale an instant is
|
|
12
|
+
stored in: `TAI`, `TT` at its fixed 32.184 s, `TDB` over the full 787-term
|
|
13
|
+
Fairhead and Bretagnon model, and `UTC` with its leap seconds
|
|
14
|
+
- Add `Instant#to`, which reads an instant in a scale, and `ScaleReading`,
|
|
15
|
+
which is that reading, with `#as` for the shape it comes out in
|
|
16
|
+
- Add the representations a reading comes out as: `julian_date`,
|
|
17
|
+
`modified_julian_date`, `civil`, and `iso8601`, each as a Float, a Rational,
|
|
18
|
+
or a two-part Float where that makes sense
|
|
19
|
+
- Add `Instant.from_julian_date`, `from_modified_julian_date`, `from_civil`,
|
|
20
|
+
and `from_iso8601`, each reading its value in a named scale
|
|
21
|
+
- Add `Instant.from_tai`, `from_tt`, `from_tdb`, and `from_utc`, the same
|
|
22
|
+
calendar constructor with the scale in the name
|
|
23
|
+
- Add `CivilTime`, the calendar fields a clock and a calendar show, in the
|
|
24
|
+
proleptic Gregorian calendar with astronomical year numbering
|
|
25
|
+
- Read and write a leap second as second 60, on the days that hold one, and
|
|
26
|
+
refuse it on the days that do not
|
|
27
|
+
- Add `ScaleReading#provenance`, `:measured` up to the date the leap second
|
|
28
|
+
data vouches for and `:extrapolated` after it, with
|
|
29
|
+
`Configuration#leap_second_horizon` to refuse an extrapolation instead
|
|
30
|
+
- Add `Configuration#register_scale`, so a caller can add a scale of its own,
|
|
31
|
+
and `Configuration#leap_second_source`, so it can supply its own leap seconds
|
|
32
|
+
- Add duration arithmetic: `+`, `-`, unary `-`, `zero?`, `negative?`,
|
|
33
|
+
`positive?`, and `to_r` and `to_f` in SI seconds
|
|
34
|
+
- Add value equality to `ScaleReading`, and `inspect` to `Instant`, `Duration`,
|
|
35
|
+
and `ScaleReading`
|
|
36
|
+
- Refuse a calendar reading before -4799, where the conversion stops, so a
|
|
37
|
+
reading out always reads back in
|
|
38
|
+
|
|
39
|
+
### Fixes
|
|
40
|
+
|
|
41
|
+
- Build the configuration under a lock, so two threads reaching it at once
|
|
42
|
+
cannot each build one and lose the other's scales
|
|
43
|
+
|
|
44
|
+
**Full Changelog**: https://github.com/rhannequin/horologium/compare/v0.0.2...v0.0.3
|
|
45
|
+
|
|
3
46
|
## 0.0.2 - 2026-07-14
|
|
4
47
|
|
|
5
48
|
The first functional release. It ships the numeric core and the two value
|
data/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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
|
-
(
|
|
6
|
+
(TAI, TT, TDB, and UTC so far), high-precision instants, Julian Dates,
|
|
7
7
|
intervals, and rigorous conversions between scales that astronomy and physics
|
|
8
8
|
require.
|
|
9
9
|
|
|
@@ -45,14 +45,169 @@ get the duration between them.
|
|
|
45
45
|
```rb
|
|
46
46
|
require "horologium"
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
a = Horologium::Instant.from_julian_date(2_460_000.5, scale: :tai)
|
|
49
|
+
b = Horologium::Instant.from_julian_date(2_460_001.5, scale: :tai)
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
a + Horologium::Duration.days(1) == b # => true
|
|
52
|
+
a < b # => true
|
|
53
|
+
b - a == Horologium::Duration.days(1) # => true
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
An instant has no scale of its own. You give it a Julian Date read in a scale,
|
|
57
|
+
and you read it back in any scale the library knows: `to` chooses the scale, and
|
|
58
|
+
`as` chooses the shape it comes out in.
|
|
59
|
+
|
|
60
|
+
```rb
|
|
61
|
+
instant = Horologium::Instant.from_julian_date(2_443_144.5, scale: :tai)
|
|
62
|
+
|
|
63
|
+
instant.to(:tt).as(:julian_date) # => 2443144.5003725
|
|
64
|
+
instant.as(:modified_julian_date, scale: :tt) # => 43144.0003725
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
A calendar date is a shape too, and the one a person reads. It comes out as a
|
|
68
|
+
`CivilTime`, whose fields are the ones a clock and a calendar show, in the
|
|
69
|
+
proleptic Gregorian calendar.
|
|
70
|
+
|
|
71
|
+
```rb
|
|
72
|
+
civil = instant.as(:civil, scale: :tt)
|
|
73
|
+
|
|
74
|
+
civil.year # => 1977
|
|
75
|
+
civil.month # => 1
|
|
76
|
+
civil.second # => 32
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
You can build an instant from those fields as well, and nothing is lost on the
|
|
80
|
+
way in: the date becomes a whole number of days and the time of day an exact
|
|
81
|
+
fraction of one. Give a fractional second as a `Rational` to say it exactly.
|
|
82
|
+
|
|
83
|
+
```rb
|
|
84
|
+
Horologium::Instant.from_civil(2025, 5, 1, 12, 0, 0, scale: :tt)
|
|
85
|
+
Horologium::Instant.from_civil(2025, 5, 1, 12, 0, Rational(1, 4), scale: :tt)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Each scale has its own shortcut, so the scale is in the name instead of a
|
|
89
|
+
keyword.
|
|
90
|
+
|
|
91
|
+
```rb
|
|
92
|
+
Horologium::Instant.from_tt(2025, 5, 1, 12, 0, 0)
|
|
93
|
+
Horologium::Instant.from_tai(2025, 5, 1, 12, 0, 0)
|
|
94
|
+
Horologium::Instant.from_tdb(2025, 5, 1, 12, 0, 0)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A date that does not exist is refused rather than rolled over, and the message
|
|
98
|
+
says which field is wrong.
|
|
99
|
+
|
|
100
|
+
```rb
|
|
101
|
+
Horologium::Instant.from_civil(1900, 2, 29, scale: :tt)
|
|
102
|
+
# => raises Horologium::InvalidCivilTimeError
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The same date and time write out as an extended ISO 8601 string, and read back
|
|
106
|
+
from one. The scale is not written into the string: there is no ISO 8601
|
|
107
|
+
designator for TAI or TT, and `Z` means UTC, so a bare time is a coordinate in
|
|
108
|
+
the scale you asked for.
|
|
109
|
+
|
|
110
|
+
```rb
|
|
111
|
+
instant.as(:iso8601, scale: :tt) # => "1977-01-01T00:00:32.184000000"
|
|
112
|
+
|
|
113
|
+
Horologium::Instant.from_iso8601("2025-05-01T12:00:00", scale: :tt)
|
|
114
|
+
Horologium::Instant.from_iso8601("2025-05-01", scale: :tt) # midnight
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The parser reads a strict subset: a calendar date, an optional time of day
|
|
118
|
+
after a `T`, a fraction of a second kept to every digit, and an optional `Z` or
|
|
119
|
+
numeric offset applied as plain arithmetic, not a time zone. A week date, an
|
|
120
|
+
ordinal date, or anything outside the subset is refused with a `ParseError`.
|
|
121
|
+
|
|
122
|
+
UTC is the scale of civil clocks, the one that holds a leap second now and
|
|
123
|
+
then to keep step with the Earth's rotation. `from_utc` reads a UTC date, and
|
|
124
|
+
a leap second is a legal reading: the second is 60 on a day that holds one, and
|
|
125
|
+
that moment really existed.
|
|
53
126
|
|
|
54
|
-
|
|
55
|
-
|
|
127
|
+
```rb
|
|
128
|
+
Horologium::Instant.from_utc(2025, 5, 1, 12, 0, 0)
|
|
129
|
+
|
|
130
|
+
leap = Horologium::Instant.from_utc(2016, 12, 31, 23, 59, 60)
|
|
131
|
+
leap.as(:iso8601, scale: :utc) # => "2016-12-31T23:59:60.000000000Z"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
A leap second is a real second on the timeline, so the arithmetic is right
|
|
135
|
+
across it: the second before 23:59:60, the leap second, and the next midnight
|
|
136
|
+
are one SI second apart each. Second 60 on a day with no leap second is
|
|
137
|
+
refused.
|
|
138
|
+
|
|
139
|
+
```rb
|
|
140
|
+
before = Horologium::Instant.from_utc(
|
|
141
|
+
2016, 12, 31, 23, 59, 59,
|
|
142
|
+
precision: :exact
|
|
143
|
+
)
|
|
144
|
+
leap = Horologium::Instant.from_utc(
|
|
145
|
+
2016, 12, 31, 23, 59, 60,
|
|
146
|
+
precision: :exact
|
|
147
|
+
)
|
|
148
|
+
after = Horologium::Instant.from_utc(
|
|
149
|
+
2017, 1, 1, 0, 0, 0,
|
|
150
|
+
precision: :exact
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
leap - before == Horologium::Duration.seconds(1) # => true
|
|
154
|
+
after - leap == Horologium::Duration.seconds(1) # => true
|
|
155
|
+
|
|
156
|
+
Horologium::Instant.from_utc(2020, 6, 15, 23, 59, 60)
|
|
157
|
+
# => raises Horologium::InvalidCivilTimeError
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The `:exact` above is what makes `==` the right question to ask. At the default
|
|
161
|
+
`:standard` precision the same three instants land a rounding step apart, well
|
|
162
|
+
under a nanosecond but not zero, so compare those with `equal_within?`.
|
|
163
|
+
|
|
164
|
+
UTC runs from 1961, whole leap seconds from 1972 and the earlier
|
|
165
|
+
rate-adjustment drift before that, where a UTC second was fractionally longer
|
|
166
|
+
than an SI one. An earlier UTC date raises `Horologium::OutOfRangeError`. The
|
|
167
|
+
instant is still reachable, only its UTC label is not, so the error names the
|
|
168
|
+
continuous scales, which have no lower bound. The leap seconds and the drift
|
|
169
|
+
come from the [iers] gem, with no network access: the data ships with the gem.
|
|
170
|
+
|
|
171
|
+
```rb
|
|
172
|
+
Horologium::Instant.from_utc(1960, 12, 31) # => OutOfRangeError
|
|
173
|
+
Horologium::Instant.from_civil(1960, 12, 31, scale: :tt) # reaches any date
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Leap seconds are announced about six months ahead, so past the date its data
|
|
177
|
+
vouches for, the last known offset is the best there is. A UTC reading says
|
|
178
|
+
which it rests on: `:measured` up to that date, `:extrapolated` after, where a
|
|
179
|
+
leap second announced since would not be known.
|
|
180
|
+
|
|
181
|
+
```rb
|
|
182
|
+
instant.to(:utc).provenance # => :measured, or :extrapolated past the horizon
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
A pipeline that must not rest on an offset a leap second could overturn sets a
|
|
186
|
+
strict horizon, and a reading past it raises instead.
|
|
187
|
+
|
|
188
|
+
```rb
|
|
189
|
+
Horologium.configure { |c| c.leap_second_horizon = :raise }
|
|
190
|
+
# => reading a date past the data horizon raises OutOfDataRangeError
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The configuration is set once, in a single `Horologium.configure` block, and
|
|
194
|
+
frozen when the block returns. See [Precision](#precision).
|
|
195
|
+
|
|
196
|
+
A Julian Date is around 2.46 million, which leaves a single `Float` about 40
|
|
197
|
+
microseconds for the fraction of a day, and the loss is already in the literal
|
|
198
|
+
before Horologium sees it. So the lossless shapes come first: a `String` and a
|
|
199
|
+
`Rational` say the Julian Date exactly, and a high and a low part say it to
|
|
200
|
+
about twice what one `Float` holds.
|
|
201
|
+
|
|
202
|
+
```rb
|
|
203
|
+
Horologium::Instant.from_julian_date("2456463.052272", scale: :tt)
|
|
204
|
+
Horologium::Instant.from_julian_date(
|
|
205
|
+
Rational(2_456_463_052_272, 1_000_000),
|
|
206
|
+
scale: :tt
|
|
207
|
+
)
|
|
208
|
+
Horologium::Instant.from_julian_date(2_456_463.0, 0.052272, scale: :tt)
|
|
209
|
+
|
|
210
|
+
Horologium::Instant.from_modified_julian_date(60_796.0, scale: :tai)
|
|
56
211
|
```
|
|
57
212
|
|
|
58
213
|
A `Duration` counts SI seconds, so `Duration.days(1)` is always 86,400 SI
|
|
@@ -65,6 +220,18 @@ Horologium::Duration.nanoseconds(1_000_000_000) ==
|
|
|
65
220
|
Horologium::Duration.seconds(1) # => true
|
|
66
221
|
```
|
|
67
222
|
|
|
223
|
+
Durations add, subtract, and negate among themselves, and read back out in SI
|
|
224
|
+
seconds.
|
|
225
|
+
|
|
226
|
+
```rb
|
|
227
|
+
Horologium::Duration.seconds(30) + Horologium::Duration.seconds(12)
|
|
228
|
+
Horologium::Duration.seconds(30) - Horologium::Duration.seconds(42) # negative
|
|
229
|
+
-Horologium::Duration.seconds(3)
|
|
230
|
+
|
|
231
|
+
Horologium::Duration.days(1).to_r # => (86400/1), the whole value
|
|
232
|
+
Horologium::Duration.days(1).to_f # => 86400.0
|
|
233
|
+
```
|
|
234
|
+
|
|
68
235
|
Adding a duration to an instant makes sense, but adding two instants together
|
|
69
236
|
does not, so it raises an error.
|
|
70
237
|
|
|
@@ -76,7 +243,7 @@ Exact equality is rarely what scientific code wants, so you can compare within a
|
|
|
76
243
|
tolerance:
|
|
77
244
|
|
|
78
245
|
```rb
|
|
79
|
-
a = Horologium::Instant.
|
|
246
|
+
a = Horologium::Instant.from_julian_date(2_460_000.5, scale: :tai)
|
|
80
247
|
near = a + Horologium::Duration.nanoseconds(1)
|
|
81
248
|
|
|
82
249
|
a.equal_within?(near, Horologium::Duration.nanoseconds(2)) # => true
|
|
@@ -99,18 +266,25 @@ Every value carries one of two precisions, fixed when it is built:
|
|
|
99
266
|
- `:exact` keeps the value as a `Rational`, with no rounding. The test suite
|
|
100
267
|
uses it to check that `:standard` stays within its stated precision.
|
|
101
268
|
|
|
102
|
-
Set the default once at boot
|
|
269
|
+
Set the default once at boot. `Horologium.configure` freezes the configuration
|
|
270
|
+
when its block returns, so it is called once and everything is set in the one
|
|
271
|
+
block. A second call raises `Horologium::ConfigurationError`.
|
|
103
272
|
|
|
104
273
|
```rb
|
|
105
274
|
Horologium.configure do |c|
|
|
106
275
|
c.default_precision = :exact
|
|
276
|
+
c.leap_second_horizon = :raise
|
|
107
277
|
end
|
|
108
278
|
```
|
|
109
279
|
|
|
110
280
|
Choose it for a single value, or for a scoped block:
|
|
111
281
|
|
|
112
282
|
```rb
|
|
113
|
-
Horologium::Instant.
|
|
283
|
+
Horologium::Instant.from_julian_date(
|
|
284
|
+
2_460_000.5,
|
|
285
|
+
scale: :tai,
|
|
286
|
+
precision: :exact
|
|
287
|
+
)
|
|
114
288
|
|
|
115
289
|
Horologium.with_precision(:exact) do
|
|
116
290
|
# instants and durations built here default to :exact
|
|
@@ -134,8 +308,12 @@ changes until a 1.0 release. Changes are documented in the [CHANGELOG].
|
|
|
134
308
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run
|
|
135
309
|
`rake` to run the tests and RuboCop, or `rake steep` to type-check the
|
|
136
310
|
signatures in `sig/`. Run `COVERAGE=true rake test` to measure test coverage,
|
|
137
|
-
which is enforced at
|
|
138
|
-
for an interactive prompt that will allow you to experiment.
|
|
311
|
+
which is enforced at 100% of lines and branches in CI. You can also run
|
|
312
|
+
`bin/console` for an interactive prompt that will allow you to experiment.
|
|
313
|
+
|
|
314
|
+
`sig/` holds Horologium's own signatures and ships with the gem. `sig-vendor/`
|
|
315
|
+
holds stubs for gems that ship none of their own, and stays out of the gem so
|
|
316
|
+
it cannot clash with a downstream RBS collection.
|
|
139
317
|
|
|
140
318
|
Run `bin/ci` to run every check that GitHub Actions runs (RuboCop, Steep, YARD
|
|
141
319
|
documentation coverage, and the tests with coverage) in a single pass. It runs
|
|
@@ -1,16 +1,60 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Horologium
|
|
4
|
-
# Holds the library's settings
|
|
5
|
-
#
|
|
6
|
-
# once, inside {Horologium.configure}, and frozen
|
|
7
|
-
# does not depend on when in the process' life an
|
|
4
|
+
# Holds the library's settings: the default precision new instants and
|
|
5
|
+
# durations take when none is asked for, and the time scales an instant can
|
|
6
|
+
# be read in. Both are set once, inside {Horologium.configure}, and frozen
|
|
7
|
+
# afterwards, so behaviour does not depend on when in the process' life an
|
|
8
|
+
# object is read.
|
|
8
9
|
class Configuration
|
|
10
|
+
# The scales the library ships with. They are registered before the
|
|
11
|
+
# library is configured.
|
|
12
|
+
BUILT_IN_SCALES = {
|
|
13
|
+
tai: Scales::TAI,
|
|
14
|
+
tt: Scales::TT,
|
|
15
|
+
tdb: Scales::TDB,
|
|
16
|
+
utc: Scales::UTC
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
9
19
|
# @return [Symbol] the default precision, +:standard+ until configured
|
|
10
20
|
attr_reader :default_precision
|
|
11
21
|
|
|
22
|
+
# The recognised ways to handle a leap second past the data's horizon.
|
|
23
|
+
LEAP_SECOND_HORIZONS = %i[extrapolate raise].freeze
|
|
24
|
+
|
|
25
|
+
# The source UTC reads its leap seconds from. It answers +tai_utc_at+ with
|
|
26
|
+
# TAI - UTC at a point in UTC, given a Julian Day Number: a day's 0h for a
|
|
27
|
+
# whole number, or part way through a day where a fraction is added, which
|
|
28
|
+
# UTC asks for to read the pre-1972 drift within a day. A source that steps
|
|
29
|
+
# only at whole days can answer a fraction with the offset at its 0h.
|
|
30
|
+
# {Data::LeapSeconds}, over the iers gem, is the default; a caller with its
|
|
31
|
+
# own leap second data can set another here.
|
|
32
|
+
#
|
|
33
|
+
# @return [#tai_utc_at]
|
|
34
|
+
attr_reader :leap_second_source
|
|
35
|
+
|
|
36
|
+
# What UTC does with a date past the point the leap second data vouches
|
|
37
|
+
# for. +:extrapolate+, the default, reads it in UTC with the last known
|
|
38
|
+
# offset and marks the reading +:extrapolated+. +:raise+ refuses it with
|
|
39
|
+
# {OutOfDataRangeError}, for a pipeline that must not lean on an offset a
|
|
40
|
+
# new leap second could overturn.
|
|
41
|
+
#
|
|
42
|
+
# @return [Symbol] +:extrapolate+ or +:raise+
|
|
43
|
+
attr_reader :leap_second_horizon
|
|
44
|
+
|
|
12
45
|
def initialize
|
|
13
46
|
@default_precision = :standard
|
|
47
|
+
@scales = BUILT_IN_SCALES.dup
|
|
48
|
+
@leap_second_source = Data::LeapSeconds
|
|
49
|
+
@leap_second_horizon = :extrapolate
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The names an instant can be read in, the built-in scales and any scale
|
|
53
|
+
# {#register_scale} added.
|
|
54
|
+
#
|
|
55
|
+
# @return [Array<Symbol>]
|
|
56
|
+
def scale_names
|
|
57
|
+
@scales.keys
|
|
14
58
|
end
|
|
15
59
|
|
|
16
60
|
# Sets the default precision.
|
|
@@ -26,11 +70,149 @@ module Horologium
|
|
|
26
70
|
|
|
27
71
|
@default_precision = Numeric::Precision.validate!(precision)
|
|
28
72
|
end
|
|
73
|
+
|
|
74
|
+
# Sets the source UTC reads its leap seconds from. A source that does not
|
|
75
|
+
# answer +tai_utc_at+ is refused here, at configuration time, rather than
|
|
76
|
+
# when an instant is first read in UTC.
|
|
77
|
+
#
|
|
78
|
+
# @param source [#tai_utc_at] the source to read from
|
|
79
|
+
# @return [#tai_utc_at] the source that was set
|
|
80
|
+
# @raise [ConfigurationError] once the configuration is frozen, or when the
|
|
81
|
+
# source does not respond to +tai_utc_at+
|
|
82
|
+
def leap_second_source=(source)
|
|
83
|
+
if frozen?
|
|
84
|
+
raise ConfigurationError, "the configuration is already frozen"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
unless source.respond_to?(:tai_utc_at)
|
|
88
|
+
raise ConfigurationError,
|
|
89
|
+
"a leap second source must respond to tai_utc_at, " \
|
|
90
|
+
"got #{source.inspect}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
@leap_second_source = source
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Sets how UTC handles a date past the leap second data's horizon.
|
|
97
|
+
#
|
|
98
|
+
# @param horizon [Symbol] +:extrapolate+ or +:raise+
|
|
99
|
+
# @return [Symbol] the horizon that was set
|
|
100
|
+
# @raise [ConfigurationError] once the configuration is frozen, or when
|
|
101
|
+
# the horizon is not recognised
|
|
102
|
+
def leap_second_horizon=(horizon)
|
|
103
|
+
if frozen?
|
|
104
|
+
raise ConfigurationError, "the configuration is already frozen"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
unless LEAP_SECOND_HORIZONS.include?(horizon)
|
|
108
|
+
raise ConfigurationError,
|
|
109
|
+
"leap_second_horizon must be one of " \
|
|
110
|
+
"#{LEAP_SECOND_HORIZONS.join(", ")}, got #{horizon.inspect}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
@leap_second_horizon = horizon
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Registers a time scale under a name, so an instant can be read in it
|
|
117
|
+
# with {Instant#to}. The scale is a class implementing {Scales::Base}: it
|
|
118
|
+
# says how to read TAI in the scale, and how to read the scale back in
|
|
119
|
+
# TAI. Registering a name that is already taken replaces the scale under
|
|
120
|
+
# it, so a scale the library ships can be swapped for another model.
|
|
121
|
+
#
|
|
122
|
+
# A scale that does not implement both of them is refused here, at boot,
|
|
123
|
+
# rather than when an instant is first read in it.
|
|
124
|
+
#
|
|
125
|
+
# @param name [Symbol] the name to read the scale under
|
|
126
|
+
# @param scale [Class] a subclass of {Scales::Base}
|
|
127
|
+
# @return [Class] the scale that was registered
|
|
128
|
+
# @raise [ConfigurationError] once the configuration is frozen, when the
|
|
129
|
+
# name is not a Symbol, or when the scale does not implement
|
|
130
|
+
# {Scales::Base}
|
|
131
|
+
# @example
|
|
132
|
+
# class MyScale < Horologium::Scales::Base
|
|
133
|
+
# # .from_reference and .to_reference
|
|
134
|
+
# end
|
|
135
|
+
#
|
|
136
|
+
# Horologium.configure do |c|
|
|
137
|
+
# c.register_scale(:my_scale, MyScale)
|
|
138
|
+
# end
|
|
139
|
+
def register_scale(name, scale)
|
|
140
|
+
if @scales.frozen?
|
|
141
|
+
raise ConfigurationError, "the configuration is already frozen"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
unless name.is_a?(Symbol)
|
|
145
|
+
raise ConfigurationError,
|
|
146
|
+
"a scale is registered under a Symbol, got #{name.inspect}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
validate_scale!(scale)
|
|
150
|
+
|
|
151
|
+
@scales[name] = scale
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The scale registered under a name.
|
|
155
|
+
#
|
|
156
|
+
# @param name [Symbol] the name of the scale, such as +:tt+
|
|
157
|
+
# @return [Class] the scale
|
|
158
|
+
# @raise [UnknownScaleError] when no scale is registered under that name
|
|
159
|
+
def scale(name)
|
|
160
|
+
@scales.fetch(name) do
|
|
161
|
+
raise UnknownScaleError.new(name, scale_names)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Freezes the configuration and the scales with it, so neither changes
|
|
166
|
+
# once the library is configured.
|
|
167
|
+
#
|
|
168
|
+
# @return [self]
|
|
169
|
+
def freeze
|
|
170
|
+
@scales.freeze
|
|
171
|
+
super
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
# Checks that a scale can be read in: a class implementing both halves of
|
|
177
|
+
# {Scales::Base}. A subclass that inherits either one from {Scales::Base}
|
|
178
|
+
# would raise NotImplementedError on the first conversion, so it is
|
|
179
|
+
# refused here instead.
|
|
180
|
+
#
|
|
181
|
+
# @param scale [Object] the scale to check
|
|
182
|
+
# @return [void]
|
|
183
|
+
# @raise [ConfigurationError] when it is not a scale
|
|
184
|
+
def validate_scale!(scale)
|
|
185
|
+
unless scale.is_a?(Class) && scale < Scales::Base
|
|
186
|
+
raise ConfigurationError,
|
|
187
|
+
"a scale must be a subclass of Horologium::Scales::Base, " \
|
|
188
|
+
"got #{scale.inspect}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
missing = %i[from_reference to_reference].select do |method|
|
|
192
|
+
scale.method(method).owner == Scales::Base.singleton_class
|
|
193
|
+
end
|
|
194
|
+
return if missing.empty?
|
|
195
|
+
|
|
196
|
+
raise ConfigurationError,
|
|
197
|
+
"#{scale} does not implement #{missing.join(" or ")}"
|
|
198
|
+
end
|
|
29
199
|
end
|
|
30
200
|
|
|
201
|
+
# Guards the one-time build of the configuration, so two threads reaching
|
|
202
|
+
# it at once cannot each build one and lose the other's scales.
|
|
203
|
+
#
|
|
204
|
+
# @api private
|
|
205
|
+
CONFIGURATION_LOCK = Mutex.new
|
|
206
|
+
private_constant :CONFIGURATION_LOCK
|
|
207
|
+
|
|
31
208
|
class << self
|
|
32
209
|
# Configures the library. The yielded configuration is frozen when the
|
|
33
|
-
# block returns, so it can be set once at boot and not changed again.
|
|
210
|
+
# block returns, so it can be set once at boot and not changed again. It
|
|
211
|
+
# is frozen even when the block raises, so a configuration that failed
|
|
212
|
+
# half way through cannot be quietly finished off later.
|
|
213
|
+
#
|
|
214
|
+
# It is called once. A second call finds the configuration already frozen
|
|
215
|
+
# and raises {ConfigurationError}, so set everything in one block.
|
|
34
216
|
#
|
|
35
217
|
# @yieldparam config [Configuration] the configuration to set
|
|
36
218
|
# @return [Configuration] the frozen configuration
|
|
@@ -40,14 +222,23 @@ module Horologium
|
|
|
40
222
|
# end
|
|
41
223
|
def configure
|
|
42
224
|
config = configuration
|
|
43
|
-
|
|
44
|
-
|
|
225
|
+
begin
|
|
226
|
+
yield config if block_given?
|
|
227
|
+
ensure
|
|
228
|
+
config.freeze
|
|
229
|
+
end
|
|
230
|
+
config
|
|
45
231
|
end
|
|
46
232
|
|
|
233
|
+
# The configuration is built on the first read, under a lock, so that two
|
|
234
|
+
# threads racing to configure the library end up with the same one.
|
|
235
|
+
#
|
|
47
236
|
# @return [Configuration] the current configuration, built with defaults if
|
|
48
237
|
# the library has not been configured yet
|
|
49
238
|
def configuration
|
|
50
|
-
@configuration
|
|
239
|
+
@configuration || CONFIGURATION_LOCK.synchronize do
|
|
240
|
+
@configuration ||= Configuration.new
|
|
241
|
+
end
|
|
51
242
|
end
|
|
52
243
|
|
|
53
244
|
# @return [Symbol] the configured default precision
|