iers 0.1.1 → 0.3.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.
@@ -39,6 +39,7 @@ module IERS
39
39
 
40
40
  def cache_dir=(value)
41
41
  @cache_dir = Pathname(value)
42
+ invalidate
42
43
  end
43
44
 
44
45
  def sources=(value)
@@ -59,10 +60,12 @@ module IERS
59
60
 
60
61
  def finals_path=(value)
61
62
  @finals_path = value && Pathname(value)
63
+ invalidate(:finals)
62
64
  end
63
65
 
64
66
  def leap_second_path=(value)
65
67
  @leap_second_path = value && Pathname(value)
68
+ invalidate(:leap_seconds)
66
69
  end
67
70
 
68
71
  def interpolation=(value)
@@ -82,5 +85,15 @@ module IERS
82
85
 
83
86
  @lagrange_order = value
84
87
  end
88
+
89
+ private
90
+
91
+ # Settings staged on a detached Configuration govern nothing, so they must
92
+ # not reach into the parse the gem is actually serving.
93
+ def invalidate(*sources)
94
+ return unless IERS.active_configuration?(self)
95
+
96
+ Data.clear_loaded!(*sources)
97
+ end
85
98
  end
86
99
  end
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)
@@ -37,6 +37,7 @@ module IERS
37
37
  begin
38
38
  Downloader.new(timeout: config.download_timeout).fetch(url, dest)
39
39
  updated << source
40
+ clear_loaded!(source)
40
41
  rescue DownloadError => e
41
42
  errors[source] = e
42
43
  end
@@ -67,6 +68,8 @@ module IERS
67
68
  path = config.cache_dir.join(filename)
68
69
  path.delete if path.exist?
69
70
  end
71
+
72
+ clear_loaded!
70
73
  end
71
74
 
72
75
  # @param coverage_days_ahead [Integer, nil]
@@ -96,16 +99,26 @@ module IERS
96
99
  end
97
100
  end
98
101
 
99
- # @return [Array<Parsers::LeapSecond::Entry>]
100
- def leap_second_entries
102
+ # @return [Parsers::LeapSecond::Table]
103
+ def leap_second_table
101
104
  @mutex.synchronize do
102
- @leap_seconds ||= begin
105
+ @leap_second_table ||= begin
103
106
  path = resolve_read_path(:leap_seconds)
104
- Parsers::LeapSecond.parse(path).freeze
107
+ Parsers::LeapSecond.parse(path)
105
108
  end
106
109
  end
107
110
  end
108
111
 
112
+ # @return [Array<Parsers::LeapSecond::Entry>]
113
+ def leap_second_entries
114
+ leap_second_table.entries
115
+ end
116
+
117
+ # @return [Parsers::LeapSecond::Metadata]
118
+ def leap_second_metadata
119
+ leap_second_table.metadata
120
+ end
121
+
109
122
  def resolve_path(source, config = IERS.configuration)
110
123
  case source
111
124
  when :finals
@@ -151,15 +164,33 @@ module IERS
151
164
  (Time.now - mtimes.min).to_i
152
165
  end
153
166
 
167
+ # @param sources [Array<Symbol>] data sources to drop (default: all)
154
168
  # @return [void]
155
- def clear_loaded!
169
+ def clear_loaded!(*sources)
170
+ sources = FILENAMES.keys if sources.empty?
171
+ validate_sources!(sources)
172
+
156
173
  @mutex.synchronize do
157
- @finals = nil
158
- @leap_seconds = nil
174
+ @finals = nil if sources.include?(:finals)
175
+ @leap_second_table = nil if sources.include?(:leap_seconds)
159
176
  end
177
+
178
+ # Outside the mutex above: LeapSecond takes its own lock before calling
179
+ # back into Data, so taking them in that order here would invert it.
180
+ LeapSecond.clear_cached! if sources.include?(:leap_seconds)
181
+ end
182
+
183
+ def validate_sources!(sources)
184
+ unknown = sources - FILENAMES.keys
185
+ return if unknown.empty?
186
+
187
+ raise ConfigurationError,
188
+ "Unknown data source: #{unknown.map(&:inspect).join(", ")}. " \
189
+ "Valid sources: #{FILENAMES.keys.inspect}"
160
190
  end
161
191
 
162
- private_class_method :resolve_path,
192
+ private_class_method :validate_sources!,
193
+ :resolve_path,
163
194
  :resolve_read_path,
164
195
  :validate_source!,
165
196
  :custom_paths_configured?,
@@ -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
data/lib/iers/delta_t.rb CHANGED
@@ -14,11 +14,11 @@ module IERS
14
14
  end
15
15
 
16
16
  EARLIEST_YEAR = 1800.0
17
- PRE_1972_MJD = 41317.0
17
+ LATEST_POLYNOMIAL_YEAR = 1986.0
18
18
  DAYS_PER_JULIAN_YEAR = 365.25
19
19
  YEAR_J2000 = 2000.0
20
20
 
21
- # Espenak & Meeus (2014) polynomial segments for 1800–1972.
21
+ # Espenak & Meeus (2014) polynomial segments for 1800–1986.
22
22
  # Each segment: [year_start, year_end, epoch, coefficients]
23
23
  # ΔT = c₀ + c₁·t + c₂·t² + ... where t = y − epoch
24
24
  # Reference: https://eclipsewise.com/help/deltatpoly2014.html
@@ -46,7 +46,7 @@ module IERS
46
46
  ].freeze
47
47
 
48
48
  private_constant :EARLIEST_YEAR,
49
- :PRE_1972_MJD,
49
+ :LATEST_POLYNOMIAL_YEAR,
50
50
  :DAYS_PER_JULIAN_YEAR,
51
51
  :YEAR_J2000,
52
52
  :POLYNOMIALS
@@ -69,24 +69,62 @@ module IERS
69
69
  )
70
70
  end
71
71
 
72
- if query_mjd < PRE_1972_MJD
73
- Entry.new(
74
- delta_t: polynomial_delta_t(y),
75
- mjd: query_mjd,
76
- source: :estimated
77
- )
72
+ if series_covers?(query_mjd)
73
+ measured_entry(query_mjd)
74
+ elsif y <= LATEST_POLYNOMIAL_YEAR
75
+ estimated_entry(query_mjd, y)
78
76
  else
79
- tai_utc = LeapSecond.at(mjd: query_mjd)
80
- ut1_utc = UT1.at(mjd: query_mjd).ut1_utc
81
-
82
- Entry.new(
83
- delta_t: tai_utc + TimeScale::TT_TAI - ut1_utc,
84
- mjd: query_mjd,
85
- source: :measured
86
- )
77
+ raise_uncovered!(query_mjd)
87
78
  end
88
79
  end
89
80
 
81
+ # The EOP series is the better source wherever it reaches, so it is asked
82
+ # first; the polynomial covers whatever is left within its own validity.
83
+ def series_covers?(mjd)
84
+ entries = Data.finals_entries
85
+ return false if entries.empty?
86
+
87
+ mjd.between?(entries.first.mjd, entries.last.mjd)
88
+ end
89
+
90
+ def estimated_entry(query_mjd, y)
91
+ Entry.new(
92
+ delta_t: polynomial_delta_t(y),
93
+ mjd: query_mjd,
94
+ source: :estimated
95
+ )
96
+ end
97
+
98
+ def raise_uncovered!(query_mjd)
99
+ entries = Data.finals_entries
100
+ series =
101
+ if entries.empty?
102
+ "is empty"
103
+ else
104
+ "covers #{entries.first.mjd}..#{entries.last.mjd}"
105
+ end
106
+
107
+ # No `available_range`: what DeltaT can answer is two disjoint spans, and
108
+ # naming either one alone is what made the old message misleading.
109
+ raise OutOfRangeError.new(
110
+ "No DeltaT available for MJD #{query_mjd}: the polynomial covers " \
111
+ "#{EARLIEST_YEAR.to_i}–#{LATEST_POLYNOMIAL_YEAR.to_i} and the EOP " \
112
+ "series #{series}",
113
+ requested_mjd: query_mjd
114
+ )
115
+ end
116
+
117
+ def measured_entry(query_mjd)
118
+ tai_utc = LeapSecond.at(mjd: query_mjd)
119
+ ut1_utc = UT1.at(mjd: query_mjd).ut1_utc
120
+
121
+ Entry.new(
122
+ delta_t: tai_utc + TimeScale::TT_TAI - ut1_utc,
123
+ mjd: query_mjd,
124
+ source: :measured
125
+ )
126
+ end
127
+
90
128
  def polynomial_delta_t(y)
91
129
  segment = POLYNOMIALS.find { |s| y < s[1] } || POLYNOMIALS.last
92
130
  t = y - segment[2]
@@ -97,6 +135,11 @@ module IERS
97
135
  YEAR_J2000 + (mjd - TimeScale::MJD_J2000) / DAYS_PER_JULIAN_YEAR
98
136
  end
99
137
 
100
- private_class_method :polynomial_delta_t, :mjd_to_decimal_year
138
+ private_class_method :series_covers?,
139
+ :estimated_entry,
140
+ :raise_uncovered!,
141
+ :measured_entry,
142
+ :polynomial_delta_t,
143
+ :mjd_to_decimal_year
101
144
  end
102
145
  end
@@ -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.1"
4
+ VERSION = "0.3.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"
@@ -39,11 +40,17 @@ module IERS
39
40
  yield configuration
40
41
  end
41
42
 
43
+ # @api private
44
+ # @param candidate [Configuration]
45
+ # @return [Boolean] whether the gem reads its data through this object
46
+ def active_configuration?(candidate)
47
+ @configuration.equal?(candidate)
48
+ end
49
+
42
50
  # @return [void]
43
51
  def reset_configuration!
44
52
  @configuration = nil
45
53
  Data.clear_loaded!
46
- LeapSecond.clear_cached!
47
54
  end
48
55
 
49
56
  # @return [void]
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.1
4
+ version: 0.3.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