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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/README.md +190 -12
  4. data/lib/horologium/configuration.rb +199 -8
  5. data/lib/horologium/data/barycentric_model.rb +901 -0
  6. data/lib/horologium/data/leap_seconds.rb +55 -0
  7. data/lib/horologium/duration.rb +87 -13
  8. data/lib/horologium/error.rb +86 -3
  9. data/lib/horologium/instant.rb +410 -62
  10. data/lib/horologium/numeric/exact.rb +46 -60
  11. data/lib/horologium/numeric/precision.rb +87 -4
  12. data/lib/horologium/numeric/two_part_float.rb +52 -68
  13. data/lib/horologium/precise_value.rb +4 -10
  14. data/lib/horologium/representations/civil.rb +518 -0
  15. data/lib/horologium/representations/civil_time.rb +162 -0
  16. data/lib/horologium/representations/iso8601.rb +286 -0
  17. data/lib/horologium/representations/julian_date.rb +227 -0
  18. data/lib/horologium/representations/modified_julian_date.rb +106 -0
  19. data/lib/horologium/scale_reading.rb +126 -0
  20. data/lib/horologium/scales/base.rb +118 -0
  21. data/lib/horologium/scales/tai.rb +41 -0
  22. data/lib/horologium/scales/tdb.rb +75 -0
  23. data/lib/horologium/scales/tt.rb +71 -0
  24. data/lib/horologium/scales/utc.rb +374 -0
  25. data/lib/horologium/version.rb +1 -1
  26. data/lib/horologium.rb +14 -1
  27. data/sig/horologium/configuration.rbs +32 -0
  28. data/sig/horologium/data/barycentric_model.rbs +19 -0
  29. data/sig/horologium/data/leap_seconds.rbs +11 -0
  30. data/sig/horologium/duration.rbs +18 -0
  31. data/sig/horologium/instant.rbs +34 -5
  32. data/sig/horologium/numeric/exact.rbs +6 -0
  33. data/sig/horologium/numeric/precision.rbs +10 -0
  34. data/sig/horologium/numeric/two_part_float.rbs +4 -0
  35. data/sig/horologium/representations/civil.rbs +63 -0
  36. data/sig/horologium/representations/civil_time.rbs +49 -0
  37. data/sig/horologium/representations/iso8601.rbs +27 -0
  38. data/sig/horologium/representations/julian_date.rbs +25 -0
  39. data/sig/horologium/representations/modified_julian_date.rbs +15 -0
  40. data/sig/horologium/scale_reading.rbs +33 -0
  41. data/sig/horologium/scales/base.rbs +17 -0
  42. data/sig/horologium/scales/tai.rbs +9 -0
  43. data/sig/horologium/scales/tdb.rbs +11 -0
  44. data/sig/horologium/scales/tt.rbs +17 -0
  45. data/sig/horologium/scales/utc.rbs +49 -0
  46. data/sig/horologium.rbs +32 -0
  47. metadata +49 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ea577f62fb959e825d069499184cafa4b1b15d4702700a1588d3ac4b00b7c2b
4
- data.tar.gz: 71b854eb59c20acaa9cd1adf77f17e97b2c0c03c9f62168dfba952f50f273dda
3
+ metadata.gz: 513d825c2d141d84274c76333af312631c4b9a077803d2e2921033782af518ce
4
+ data.tar.gz: '0108ee60e9bf9c1f149baefafe6e78f8d735dd824148ed91c69267b5ca10aad8'
5
5
  SHA512:
6
- metadata.gz: 8a5144d687290a581ef5ec2861f58383d35e7c416032bfbf351bc6033ab9f5889c57a3cb48bd812e52d4d19ee0b0688e929fe1e05e0d44231ac6b41f7d8e9251
7
- data.tar.gz: a5070ecf423cf7d49623d547a133e9fb0907935843397f8336e989bc4e7816cbcc662af2acfed63913c852bdf5620ef03fd7ba0e660c50c9463f3d7aa22d6624
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
  [![Tests](https://github.com/rhannequin/horologium/workflows/CI/badge.svg)](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
- (UTC, TAI, TT, TDB, TCG, TCB, UT1, GPS), high-precision instants, Julian Dates,
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
- instant = Horologium::Instant.from_tai_julian_date(2_460_000.5)
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
- later = instant + Horologium::Duration.days(1)
51
- later == Horologium::Instant.from_tai_julian_date(2_460_001.5) # => true
52
- instant < later # => true
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
- b = Horologium::Instant.from_tai_julian_date(2_460_001.5)
55
- b - instant == Horologium::Duration.days(1) # => true
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.from_tai_julian_date(2_460_000.5)
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.from_tai_julian_date(2_460_000.5, precision: :exact)
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 a 95% line minimum in CI. You can also run `bin/console`
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. Today that is a single value, the default
5
- # precision new instants and durations take when none is asked for. It is set
6
- # once, inside {Horologium.configure}, and frozen afterwards, so behaviour
7
- # does not depend on when in the process' life an object is read.
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
- yield config if block_given?
44
- config.freeze
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 ||= Configuration.new
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