iers 0.1.0 → 0.2.0

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.
data/lib/iers/data.rb CHANGED
@@ -11,13 +11,13 @@ module IERS
11
11
 
12
12
  @mutex = Mutex.new
13
13
  @finals = nil
14
- @leap_seconds = nil
14
+ @leap_second_table = nil
15
15
 
16
16
  module_function
17
17
 
18
18
  # @return [Boolean]
19
19
  def loaded?
20
- !@finals.nil? || !@leap_seconds.nil?
20
+ !@finals.nil? || !@leap_second_table.nil?
21
21
  end
22
22
 
23
23
  # @param sources [Array<Symbol>] data sources to update (default: all)
@@ -96,16 +96,26 @@ module IERS
96
96
  end
97
97
  end
98
98
 
99
- # @return [Array<Parsers::LeapSecond::Entry>]
100
- def leap_second_entries
99
+ # @return [Parsers::LeapSecond::Table]
100
+ def leap_second_table
101
101
  @mutex.synchronize do
102
- @leap_seconds ||= begin
102
+ @leap_second_table ||= begin
103
103
  path = resolve_read_path(:leap_seconds)
104
- Parsers::LeapSecond.parse(path).freeze
104
+ Parsers::LeapSecond.parse(path)
105
105
  end
106
106
  end
107
107
  end
108
108
 
109
+ # @return [Array<Parsers::LeapSecond::Entry>]
110
+ def leap_second_entries
111
+ leap_second_table.entries
112
+ end
113
+
114
+ # @return [Parsers::LeapSecond::Metadata]
115
+ def leap_second_metadata
116
+ leap_second_table.metadata
117
+ end
118
+
109
119
  def resolve_path(source, config = IERS.configuration)
110
120
  case source
111
121
  when :finals
@@ -155,7 +165,7 @@ module IERS
155
165
  def clear_loaded!
156
166
  @mutex.synchronize do
157
167
  @finals = nil
158
- @leap_seconds = nil
168
+ @leap_second_table = nil
159
169
  end
160
170
  end
161
171
 
@@ -3,7 +3,7 @@
3
3
  module IERS
4
4
  # @attr source [Symbol] +:cached+, +:custom+, or +:bundled+
5
5
  # @attr cache_age [Integer, nil] age in seconds, or +nil+
6
- DataStatus = Data.define(:source, :cache_age) do
6
+ DataStatus = ::Data.define(:source, :cache_age) do
7
7
  # @return [Boolean]
8
8
  def cached?
9
9
  source == :cached
@@ -30,17 +30,41 @@ module IERS
30
30
  end
31
31
  end
32
32
 
33
+ # @return [Date, nil]
34
+ def expires_on
35
+ IERS::Data.leap_second_metadata.expires_on
36
+ end
37
+
38
+ # @param as_of [Date]
39
+ # @return [Boolean]
40
+ def expired?(as_of: Date.today)
41
+ expiry = expires_on
42
+ return false if expiry.nil?
43
+
44
+ as_of > expiry
45
+ end
46
+
47
+ # @return [String, nil]
48
+ def updated_through
49
+ IERS::Data.leap_second_metadata.updated_through
50
+ end
51
+
33
52
  # @return [Entry, nil]
34
53
  def next_scheduled
35
54
  today = Date.today
36
55
  all.find { |entry| entry.effective_date > today }
37
56
  end
38
57
 
58
+ # TAI−UTC covers 1961-01-01 onward. From 1972 the value is a whole number
59
+ # of seconds read from Leap_Second.dat and returned as an Integer. Between
60
+ # 1961 and 1972 UTC was steered by rate adjustments rather than whole leap
61
+ # seconds, so the value is a fraction of a second returned as a Rational.
62
+ #
39
63
  # @param input [Time, Date, DateTime, nil]
40
64
  # @param jd [Float, nil] Julian Date
41
65
  # @param mjd [Float, nil] Modified Julian Date
42
- # @return [Integer] TAI−UTC in seconds
43
- # @raise [OutOfRangeError]
66
+ # @return [Integer, Rational] TAI−UTC in seconds
67
+ # @raise [OutOfRangeError] before 1961-01-01
44
68
  def at(input = nil, jd: nil, mjd: nil)
45
69
  query_mjd = TimeScale.to_mjd(input, jd: jd, mjd: mjd)
46
70
  parser_entries = IERS::Data.leap_second_entries
@@ -49,11 +73,14 @@ module IERS
49
73
  last_mjd = parser_entries.last.mjd
50
74
 
51
75
  if query_mjd < first_mjd
76
+ return TaiUtcDrift.at(query_mjd) if TaiUtcDrift.covers?(query_mjd)
77
+
52
78
  raise OutOfRangeError.new(
53
- "Requested MJD #{query_mjd} is before the first leap second " \
54
- "entry (MJD #{first_mjd})",
79
+ "Requested MJD #{query_mjd} is before TAI−UTC was defined " \
80
+ "(MJD #{TaiUtcDrift::FIRST_MJD}, 1961-01-01; no published UTC " \
81
+ "before then)",
55
82
  requested_mjd: query_mjd,
56
- available_range: first_mjd..last_mjd
83
+ available_range: TaiUtcDrift::FIRST_MJD..last_mjd
57
84
  )
58
85
  end
59
86
 
@@ -5,7 +5,7 @@ require "date"
5
5
  module IERS
6
6
  module Parsers
7
7
  module Finals
8
- Entry = Data.define(
8
+ Entry = ::Data.define(
9
9
  :date, :mjd,
10
10
  :pm_flag, :pm_x, :pm_x_error, :pm_y, :pm_y_error,
11
11
  :ut1_flag, :ut1_utc, :ut1_utc_error,
@@ -5,10 +5,23 @@ require "date"
5
5
  module IERS
6
6
  module Parsers
7
7
  module LeapSecond
8
- Entry = Data.define(:mjd, :date, :tai_utc)
8
+ Entry = ::Data.define(:mjd, :date, :tai_utc)
9
+
10
+ # @attr expires_on [Date, nil]
11
+ # @attr updated_through [String, nil]
12
+ Metadata = ::Data.define(:expires_on, :updated_through)
13
+
14
+ # @attr entries [Array<Entry>]
15
+ # @attr metadata [Metadata]
16
+ Table = ::Data.define(:entries, :metadata)
17
+
18
+ EXPIRES_ON = /File expires on\s+(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})/i
19
+ UPDATED_THROUGH = /\AUpdated through\s+(.+?)\s*\z/i
20
+ COMMENT_PREFIX = /\A#+\s*/
9
21
 
10
22
  module_function
11
23
 
24
+ # @return [Table]
12
25
  def parse(path)
13
26
  path = Pathname(path)
14
27
 
@@ -20,14 +33,30 @@ module IERS
20
33
  end
21
34
 
22
35
  entries = []
36
+ expires_on = nil
37
+ updated_through = nil
23
38
 
24
39
  path.each_line.with_index(1) do |line, line_number|
25
- next if line.strip.empty? || line.strip.start_with?("#")
40
+ stripped = line.scrub.strip
41
+ next if stripped.empty?
42
+
43
+ if stripped.start_with?("#")
44
+ comment = stripped.sub(COMMENT_PREFIX, "")
45
+ expires_on ||= parse_expiry(comment)
46
+ updated_through ||= parse_updated_through(comment)
47
+ next
48
+ end
26
49
 
27
50
  entries << parse_line(line, path, line_number)
28
51
  end
29
52
 
30
- entries.freeze
53
+ Table.new(
54
+ entries: entries.freeze,
55
+ metadata: Metadata.new(
56
+ expires_on: expires_on,
57
+ updated_through: updated_through
58
+ )
59
+ )
31
60
  end
32
61
 
33
62
  def parse_line(line, path, line_number)
@@ -50,7 +79,28 @@ module IERS
50
79
  )
51
80
  end
52
81
 
53
- private_class_method :parse_line
82
+ # @return [Date, nil]
83
+ def parse_expiry(comment)
84
+ match = EXPIRES_ON.match(comment)
85
+ return nil if match.nil?
86
+
87
+ month = Date::MONTHNAMES.index do |name|
88
+ name&.casecmp?(match[2])
89
+ end
90
+ return nil if month.nil?
91
+
92
+ Date.new(Integer(match[3], 10), month, Integer(match[1], 10))
93
+ rescue ArgumentError
94
+ nil
95
+ end
96
+
97
+ # @return [String, nil]
98
+ def parse_updated_through(comment)
99
+ match = UPDATED_THROUGH.match(comment)
100
+ match && match[1]
101
+ end
102
+
103
+ private_class_method :parse_line, :parse_expiry, :parse_updated_through
54
104
  end
55
105
  end
56
106
  end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IERS
4
+ # TAI-UTC from 1961-01-01 to 1972-01-01, when UTC was steered by rate
5
+ # adjustments instead of whole leap seconds. In this era TAI-UTC is a linear
6
+ # function of MJD, restarting at each published step, so a value is
7
+ # +offset + (mjd - reference_mjd) * rate+.
8
+ #
9
+ # The table is closed: the last segment ends where Leap_Second.dat begins and
10
+ # no new segment can ever be added.
11
+ #
12
+ # Coefficients from the USNO tai-utc.dat file, the same values ERFA carries in
13
+ # dat.c. They are exact decimals, so they are stored as rationals rather than
14
+ # floats.
15
+ #
16
+ # @api private
17
+ module TaiUtcDrift
18
+ # @attr start_mjd [Integer] first MJD the segment applies to
19
+ # @attr offset [Rational] TAI-UTC in seconds at reference_mjd (not at
20
+ # start_mjd: several segments measure their rate from an earlier MJD)
21
+ # @attr reference_mjd [Integer] the MJD the rate is measured from
22
+ # @attr rate [Rational] seconds of TAI-UTC per day of MJD
23
+ Segment = ::Data.define(:start_mjd, :offset, :reference_mjd, :rate)
24
+
25
+ # 1961-01-01. Below this there is no published UTC, so TAI-UTC is undefined.
26
+ FIRST_MJD = 37_300
27
+
28
+ # 1972-01-01, where Leap_Second.dat takes over. The drift era stops here.
29
+ LAST_MJD = 41_317
30
+
31
+ module_function
32
+
33
+ # @param mjd [Numeric]
34
+ # @return [Rational] TAI-UTC in seconds
35
+ def at(mjd)
36
+ segment = SEGMENTS.reverse_each.find { |s| mjd >= s.start_mjd }
37
+
38
+ segment.offset +
39
+ (Rational(mjd) - segment.reference_mjd) * segment.rate
40
+ end
41
+
42
+ # Whether +at+ has a value for this MJD: 1961-01-01 up to but not including
43
+ # 1972-01-01, where Leap_Second.dat takes over. A caller with a custom leap
44
+ # second file starting after 1972 relies on the upper bound to fall through
45
+ # to its own error rather than reading a stale drift value.
46
+ #
47
+ # @param mjd [Numeric]
48
+ # @return [Boolean]
49
+ def covers?(mjd)
50
+ mjd >= FIRST_MJD && mjd < LAST_MJD
51
+ end
52
+
53
+ SEGMENTS = [
54
+ Segment.new(
55
+ start_mjd: 37_300,
56
+ offset: Rational(14_228_180, 10_000_000),
57
+ reference_mjd: 37_300,
58
+ rate: Rational(1_296, 1_000_000)
59
+ ),
60
+ Segment.new(
61
+ start_mjd: 37_512,
62
+ offset: Rational(13_728_180, 10_000_000),
63
+ reference_mjd: 37_300,
64
+ rate: Rational(1_296, 1_000_000)
65
+ ),
66
+ Segment.new(
67
+ start_mjd: 37_665,
68
+ offset: Rational(18_458_580, 10_000_000),
69
+ reference_mjd: 37_665,
70
+ rate: Rational(11_232, 10_000_000)
71
+ ),
72
+ Segment.new(
73
+ start_mjd: 38_334,
74
+ offset: Rational(19_458_580, 10_000_000),
75
+ reference_mjd: 37_665,
76
+ rate: Rational(11_232, 10_000_000)
77
+ ),
78
+ Segment.new(
79
+ start_mjd: 38_395,
80
+ offset: Rational(32_401_300, 10_000_000),
81
+ reference_mjd: 38_761,
82
+ rate: Rational(1_296, 1_000_000)
83
+ ),
84
+ Segment.new(
85
+ start_mjd: 38_486,
86
+ offset: Rational(33_401_300, 10_000_000),
87
+ reference_mjd: 38_761,
88
+ rate: Rational(1_296, 1_000_000)
89
+ ),
90
+ Segment.new(
91
+ start_mjd: 38_639,
92
+ offset: Rational(34_401_300, 10_000_000),
93
+ reference_mjd: 38_761,
94
+ rate: Rational(1_296, 1_000_000)
95
+ ),
96
+ Segment.new(
97
+ start_mjd: 38_761,
98
+ offset: Rational(35_401_300, 10_000_000),
99
+ reference_mjd: 38_761,
100
+ rate: Rational(1_296, 1_000_000)
101
+ ),
102
+ Segment.new(
103
+ start_mjd: 38_820,
104
+ offset: Rational(36_401_300, 10_000_000),
105
+ reference_mjd: 38_761,
106
+ rate: Rational(1_296, 1_000_000)
107
+ ),
108
+ Segment.new(
109
+ start_mjd: 38_942,
110
+ offset: Rational(37_401_300, 10_000_000),
111
+ reference_mjd: 38_761,
112
+ rate: Rational(1_296, 1_000_000)
113
+ ),
114
+ Segment.new(
115
+ start_mjd: 39_004,
116
+ offset: Rational(38_401_300, 10_000_000),
117
+ reference_mjd: 38_761,
118
+ rate: Rational(1_296, 1_000_000)
119
+ ),
120
+ Segment.new(
121
+ start_mjd: 39_126,
122
+ offset: Rational(43_131_700, 10_000_000),
123
+ reference_mjd: 39_126,
124
+ rate: Rational(2_592, 1_000_000)
125
+ ),
126
+ Segment.new(
127
+ start_mjd: 39_887,
128
+ offset: Rational(42_131_700, 10_000_000),
129
+ reference_mjd: 39_126,
130
+ rate: Rational(2_592, 1_000_000)
131
+ ),
132
+ # Reached only by a direct TaiUtcDrift.at(41_317): LeapSecond.at's drift
133
+ # branch requires query_mjd < 41_317. It pins the join to exactly 10,
134
+ # matching Leap_Second.dat's first row; without it the 1968 segment would
135
+ # give ~9.8922 here. Do not remove.
136
+ Segment.new(
137
+ start_mjd: 41_317,
138
+ offset: Rational(10),
139
+ reference_mjd: 41_317,
140
+ rate: Rational(0)
141
+ )
142
+ ].freeze
143
+ end
144
+ end
@@ -3,7 +3,7 @@
3
3
  module IERS
4
4
  # @attr updated_files [Array<Symbol>]
5
5
  # @attr errors [Hash{Symbol => Error}]
6
- UpdateResult = Data.define(:updated_files, :errors) do
6
+ UpdateResult = ::Data.define(:updated_files, :errors) do
7
7
  # @return [Boolean]
8
8
  def success?
9
9
  errors.empty?
data/lib/iers/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IERS
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/iers.rb CHANGED
@@ -14,6 +14,7 @@ require_relative "iers/has_data_quality"
14
14
  require_relative "iers/interpolation"
15
15
  require_relative "iers/eop_lookup"
16
16
  require_relative "iers/eop_parameter"
17
+ require_relative "iers/tai_utc_drift"
17
18
  require_relative "iers/leap_second"
18
19
  require_relative "iers/tai"
19
20
  require_relative "iers/ut1"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: iers
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rémy Hannequin
@@ -174,6 +174,7 @@ files:
174
174
  - lib/iers/parsers/leap_second.rb
175
175
  - lib/iers/polar_motion.rb
176
176
  - lib/iers/tai.rb
177
+ - lib/iers/tai_utc_drift.rb
177
178
  - lib/iers/terrestrial_rotation.rb
178
179
  - lib/iers/time_scale.rb
179
180
  - lib/iers/update_result.rb