horologium 0.0.3 → 0.0.5

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