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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -0
  3. data/README.md +166 -5
  4. data/Rakefile +13 -1
  5. data/lib/horologium/configuration.rb +73 -1
  6. data/lib/horologium/data/eop.rb +93 -0
  7. data/lib/horologium/duration.rb +242 -8
  8. data/lib/horologium/error.rb +16 -0
  9. data/lib/horologium/instant.rb +232 -4
  10. data/lib/horologium/interval.rb +267 -0
  11. data/lib/horologium/numeric/exact.rb +11 -5
  12. data/lib/horologium/numeric/precision.rb +105 -14
  13. data/lib/horologium/numeric/two_part_float.rb +32 -6
  14. data/lib/horologium/precise_value.rb +1 -1
  15. data/lib/horologium/representations/civil.rb +45 -24
  16. data/lib/horologium/representations/iso8601.rb +3 -3
  17. data/lib/horologium/representations/julian_date.rb +13 -9
  18. data/lib/horologium/representations/modified_julian_date.rb +4 -21
  19. data/lib/horologium/scale_reading.rb +1 -1
  20. data/lib/horologium/scales/base.rb +7 -0
  21. data/lib/horologium/scales/gps.rb +62 -0
  22. data/lib/horologium/scales/tcb.rb +118 -0
  23. data/lib/horologium/scales/tcg.rb +97 -0
  24. data/lib/horologium/scales/tt.rb +5 -20
  25. data/lib/horologium/scales/ut1.rb +260 -0
  26. data/lib/horologium/version.rb +1 -1
  27. data/lib/horologium.rb +6 -0
  28. data/sig/horologium/configuration.rbs +10 -0
  29. data/sig/horologium/data/eop.rbs +13 -0
  30. data/sig/horologium/duration.rbs +24 -0
  31. data/sig/horologium/instant.rbs +20 -0
  32. data/sig/horologium/interval.rbs +39 -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 +4 -0
  36. data/sig/horologium/representations/modified_julian_date.rbs +0 -2
  37. data/sig/horologium/scales/base.rbs +2 -0
  38. data/sig/horologium/scales/gps.rbs +15 -0
  39. data/sig/horologium/scales/tcb.rbs +25 -0
  40. data/sig/horologium/scales/tcg.rbs +19 -0
  41. data/sig/horologium/scales/tt.rbs +0 -2
  42. data/sig/horologium/scales/ut1.rbs +35 -0
  43. data/sig/horologium.rbs +6 -0
  44. metadata +15 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 65e0d322bb82639c79c818b3c8473f507ecfcb25215c522f20d41c5ef7f5be25
4
- data.tar.gz: 1d2d822d56768a6d9383cc29a700208e03ef3e6f639460d1a99b4eb18e01e1de
3
+ metadata.gz: 36835b6c549b41c5f598c2ed04052c7ef7ee21c6cc9265f21fadedce465ecfe5
4
+ data.tar.gz: 012c847982be87b762829bc0634a9c363847da2e57f7c6c12e3a5496202da517
5
5
  SHA512:
6
- metadata.gz: b4c87cfb9449bb41e362d2596c03ff44e2907ab593c332eb37155fb833b7d8a366911eece54a0759fff1e72410599c244c82d6757d49df66edf904783115f811
7
- data.tar.gz: 3fda2d236fb3c9afaa33e921a0a3e3738518df46cefc9e10146acf590f144cdd67f4c162448fef7c2a002ba62cd74efbd45f500b069c91e8a8020341ec082f22
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
  [![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
- (TAI, TT, TDB, and UTC so far), high-precision instants, Julian Dates,
7
- intervals, and rigorous conversions between scales that astronomy and physics
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, and read back out in SI
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
- utc: Scales::UTC
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