iers 0.0.1 → 0.1.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.
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IERS
4
+ module CelestialPoleOffset
5
+ # @attr x [Float] dX correction in milliarcseconds
6
+ # @attr y [Float] dY correction in milliarcseconds
7
+ # @attr mjd [Float] Modified Julian Date of the query
8
+ # @attr data_quality [Symbol] +:observed+ or +:predicted+
9
+ Entry = ::Data.define(:x, :y, :mjd, :data_quality) do
10
+ include HasDate
11
+ include HasDataQuality
12
+ end
13
+
14
+ extend EopParameter
15
+
16
+ module_function
17
+
18
+ # @param input [Time, Date, DateTime, nil]
19
+ # @param jd [Float, nil] Julian Date
20
+ # @param mjd [Float, nil] Modified Julian Date
21
+ # @param interpolation [Symbol, nil] +:lagrange+ or +:linear+
22
+ # @return [Entry]
23
+ # @raise [OutOfRangeError]
24
+ def at(input = nil, jd: nil, mjd: nil, interpolation: nil)
25
+ query_mjd, window, method = resolve(
26
+ input,
27
+ jd: jd,
28
+ mjd: mjd,
29
+ interpolation: interpolation
30
+ )
31
+
32
+ x = interpolate_field(window, query_mjd, method) { |e| e.dx }
33
+ y = interpolate_field(window, query_mjd, method) { |e| e.dy }
34
+
35
+ Entry.new(
36
+ x: x, y: y,
37
+ mjd: query_mjd,
38
+ data_quality: derive_quality(window, :nutation_flag)
39
+ )
40
+ end
41
+
42
+ # @param start_date [Date]
43
+ # @param end_date [Date]
44
+ # @return [Enumerator::Lazy<Entry>]
45
+ def between(start_date, end_date)
46
+ start_mjd = TimeScale.to_mjd(start_date)
47
+ end_mjd = TimeScale.to_mjd(end_date)
48
+ entries = Data.finals_entries
49
+
50
+ EopLookup
51
+ .range(entries, start_mjd, end_mjd)
52
+ .lazy
53
+ .map do |e|
54
+ Entry.new(
55
+ x: e.dx,
56
+ y: e.dy,
57
+ mjd: e.mjd,
58
+ data_quality: EopParameter::FLAG_TO_QUALITY.fetch(e.nutation_flag, :observed)
59
+ )
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IERS
4
+ class Configuration
5
+ DEFAULT_CACHE_DIR = Pathname("~/.cache/iers").expand_path
6
+ DEFAULT_SOURCES = {
7
+ finals: "https://datacenter.iers.org/data/latestVersion/finals.all.iau2000.txt",
8
+ leap_seconds: "https://hpiers.obspm.fr/iers/bul/bulc/Leap_Second.dat"
9
+ }.freeze
10
+ DEFAULT_DOWNLOAD_TIMEOUT = 30
11
+ DEFAULT_INTERPOLATION = :lagrange
12
+ DEFAULT_LAGRANGE_ORDER = 4
13
+ INTERPOLATION_METHODS = %i[lagrange linear].freeze
14
+
15
+ # @return [Pathname]
16
+ attr_reader :cache_dir
17
+ # @return [Hash{Symbol => String}]
18
+ attr_reader :sources
19
+ # @return [Integer]
20
+ attr_reader :download_timeout
21
+ # @return [Pathname, nil]
22
+ attr_reader :finals_path
23
+ # @return [Pathname, nil]
24
+ attr_reader :leap_second_path
25
+ # @return [Symbol] +:lagrange+ or +:linear+
26
+ attr_reader :interpolation
27
+ # @return [Integer]
28
+ attr_reader :lagrange_order
29
+
30
+ def initialize
31
+ @cache_dir = DEFAULT_CACHE_DIR
32
+ @sources = DEFAULT_SOURCES.dup
33
+ @download_timeout = DEFAULT_DOWNLOAD_TIMEOUT
34
+ @finals_path = nil
35
+ @leap_second_path = nil
36
+ @interpolation = DEFAULT_INTERPOLATION
37
+ @lagrange_order = DEFAULT_LAGRANGE_ORDER
38
+ end
39
+
40
+ def cache_dir=(value)
41
+ @cache_dir = Pathname(value)
42
+ end
43
+
44
+ def sources=(value)
45
+ unless value.is_a?(Hash)
46
+ raise ConfigurationError, "sources must be a Hash"
47
+ end
48
+
49
+ @sources = value
50
+ end
51
+
52
+ def download_timeout=(value)
53
+ unless value.is_a?(Numeric) && value > 0
54
+ raise ConfigurationError, "download_timeout must be positive"
55
+ end
56
+
57
+ @download_timeout = value
58
+ end
59
+
60
+ def finals_path=(value)
61
+ @finals_path = value && Pathname(value)
62
+ end
63
+
64
+ def leap_second_path=(value)
65
+ @leap_second_path = value && Pathname(value)
66
+ end
67
+
68
+ def interpolation=(value)
69
+ unless INTERPOLATION_METHODS.include?(value)
70
+ raise ConfigurationError,
71
+ "interpolation must be one of: #{INTERPOLATION_METHODS.join(", ")}"
72
+ end
73
+
74
+ @interpolation = value
75
+ end
76
+
77
+ def lagrange_order=(value)
78
+ unless value.is_a?(Integer) && value > 0 && value.even?
79
+ raise ConfigurationError,
80
+ "lagrange_order must be a positive even integer"
81
+ end
82
+
83
+ @lagrange_order = value
84
+ end
85
+ end
86
+ end
data/lib/iers/data.rb ADDED
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IERS
4
+ module Data
5
+ FILENAMES = {
6
+ finals: "finals2000A.all",
7
+ leap_seconds: "Leap_Second.dat"
8
+ }.freeze
9
+
10
+ BUNDLED_DIR = Pathname(__dir__).join("..", "..", "data").expand_path
11
+
12
+ @mutex = Mutex.new
13
+ @finals = nil
14
+ @leap_seconds = nil
15
+
16
+ module_function
17
+
18
+ # @return [Boolean]
19
+ def loaded?
20
+ !@finals.nil? || !@leap_seconds.nil?
21
+ end
22
+
23
+ # @param sources [Array<Symbol>] data sources to update (default: all)
24
+ # @return [UpdateResult]
25
+ def update!(*sources)
26
+ config = IERS.configuration
27
+ sources = config.sources.keys if sources.empty?
28
+
29
+ updated = []
30
+ errors = {}
31
+
32
+ sources.each do |source|
33
+ validate_source!(source, config)
34
+ url = config.sources[source]
35
+ dest = resolve_path(source, config)
36
+
37
+ begin
38
+ Downloader.new(timeout: config.download_timeout).fetch(url, dest)
39
+ updated << source
40
+ rescue DownloadError => e
41
+ errors[source] = e
42
+ end
43
+ end
44
+
45
+ UpdateResult.new(updated_files: updated, errors: errors)
46
+ end
47
+
48
+ # @return [DataStatus]
49
+ def status
50
+ config = IERS.configuration
51
+
52
+ if custom_paths_configured?(config)
53
+ DataStatus.new(source: :custom, cache_age: nil)
54
+ elsif cache_exists?(config)
55
+ age = oldest_cache_age(config)
56
+ DataStatus.new(source: :cached, cache_age: age)
57
+ else
58
+ DataStatus.new(source: :bundled, cache_age: nil)
59
+ end
60
+ end
61
+
62
+ # @return [void]
63
+ def clear_cache!
64
+ config = IERS.configuration
65
+
66
+ FILENAMES.each_value do |filename|
67
+ path = config.cache_dir.join(filename)
68
+ path.delete if path.exist?
69
+ end
70
+ end
71
+
72
+ # @param coverage_days_ahead [Integer, nil]
73
+ # @return [void]
74
+ # @raise [StaleDataError]
75
+ def ensure_fresh!(coverage_days_ahead: nil)
76
+ predicted_until = finals_entries.last.date
77
+ required_until = Date.today + (coverage_days_ahead || 0)
78
+
79
+ return if predicted_until >= required_until
80
+
81
+ raise StaleDataError.new(
82
+ "Predictions only extend to #{predicted_until} " \
83
+ "but coverage through #{required_until} is required",
84
+ predicted_until: predicted_until,
85
+ required_until: required_until
86
+ )
87
+ end
88
+
89
+ # @return [Array<Parsers::Finals::Entry>]
90
+ def finals_entries
91
+ @mutex.synchronize do
92
+ @finals ||= begin
93
+ path = resolve_read_path(:finals)
94
+ Parsers::Finals.parse(path).freeze
95
+ end
96
+ end
97
+ end
98
+
99
+ # @return [Array<Parsers::LeapSecond::Entry>]
100
+ def leap_second_entries
101
+ @mutex.synchronize do
102
+ @leap_seconds ||= begin
103
+ path = resolve_read_path(:leap_seconds)
104
+ Parsers::LeapSecond.parse(path).freeze
105
+ end
106
+ end
107
+ end
108
+
109
+ def resolve_path(source, config = IERS.configuration)
110
+ case source
111
+ when :finals
112
+ config.finals_path || config.cache_dir.join(FILENAMES[:finals])
113
+ when :leap_seconds
114
+ config.leap_second_path ||
115
+ config.cache_dir.join(FILENAMES[:leap_seconds])
116
+ end
117
+ end
118
+
119
+ def resolve_read_path(source, config = IERS.configuration)
120
+ path = resolve_path(source, config)
121
+ return path if Pathname(path).exist?
122
+
123
+ BUNDLED_DIR.join(FILENAMES[source])
124
+ end
125
+
126
+ def validate_source!(source, config)
127
+ return if config.sources.key?(source)
128
+
129
+ raise ConfigurationError,
130
+ "Unknown data source: #{source.inspect}. Valid sources: #{config.sources.keys.inspect}"
131
+ end
132
+
133
+ def custom_paths_configured?(config)
134
+ config.finals_path || config.leap_second_path
135
+ end
136
+
137
+ def cache_exists?(config)
138
+ FILENAMES.any? do |_, filename|
139
+ config.cache_dir.join(filename).exist?
140
+ end
141
+ end
142
+
143
+ def oldest_cache_age(config)
144
+ mtimes = FILENAMES.filter_map do |_, filename|
145
+ path = config.cache_dir.join(filename)
146
+ path.mtime if path.exist?
147
+ end
148
+
149
+ return nil if mtimes.empty?
150
+
151
+ (Time.now - mtimes.min).to_i
152
+ end
153
+
154
+ # @return [void]
155
+ def clear_loaded!
156
+ @mutex.synchronize do
157
+ @finals = nil
158
+ @leap_seconds = nil
159
+ end
160
+ end
161
+
162
+ private_class_method :resolve_path,
163
+ :resolve_read_path,
164
+ :validate_source!,
165
+ :custom_paths_configured?,
166
+ :cache_exists?,
167
+ :oldest_cache_age
168
+ end
169
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IERS
4
+ # @attr source [Symbol] +:cached+, +:custom+, or +:bundled+
5
+ # @attr cache_age [Integer, nil] age in seconds, or +nil+
6
+ DataStatus = Data.define(:source, :cache_age) do
7
+ # @return [Boolean]
8
+ def cached?
9
+ source == :cached
10
+ end
11
+
12
+ # @return [Boolean]
13
+ def bundled?
14
+ source == :bundled
15
+ end
16
+
17
+ # @return [Boolean]
18
+ def custom?
19
+ source == :custom
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IERS
4
+ module DeltaT
5
+ # @attr delta_t [Float] TT − UT1 in seconds
6
+ # @attr mjd [Float] Modified Julian Date of the query
7
+ # @attr source [Symbol] +:measured+ or +:estimated+
8
+ Entry = ::Data.define(:delta_t, :mjd, :source) do
9
+ # @return [Boolean]
10
+ def measured? = source == :measured
11
+
12
+ # @return [Boolean]
13
+ def estimated? = source == :estimated
14
+ end
15
+
16
+ EARLIEST_YEAR = 1800.0
17
+ PRE_1972_MJD = 41317.0
18
+ DAYS_PER_JULIAN_YEAR = 365.25
19
+ YEAR_J2000 = 2000.0
20
+
21
+ # Espenak & Meeus (2014) polynomial segments for 1800–1972.
22
+ # Each segment: [year_start, year_end, epoch, coefficients]
23
+ # ΔT = c₀ + c₁·t + c₂·t² + ... where t = y − epoch
24
+ # Reference: https://eclipsewise.com/help/deltatpoly2014.html
25
+ POLYNOMIALS = [
26
+ [1800.0, 1860.0, 1800.0, [
27
+ 13.72, -0.332447, 0.0068612, 0.0041116,
28
+ -0.00037436, 0.0000121272, -0.0000001699, 0.000000000875
29
+ ].freeze],
30
+ [1860.0, 1900.0, 1860.0, [
31
+ 7.62, 0.5737, -0.251754, 0.01680668,
32
+ -0.0004473624, 1.0 / 233174
33
+ ].freeze],
34
+ [1900.0, 1920.0, 1900.0, [
35
+ -2.79, 1.494119, -0.0598939, 0.0061966, -0.000197
36
+ ].freeze],
37
+ [1920.0, 1941.0, 1920.0, [
38
+ 21.20, 0.84493, -0.076100, 0.0020936
39
+ ].freeze],
40
+ [1941.0, 1961.0, 1950.0, [
41
+ 29.07, 0.407, -1.0 / 233, 1.0 / 2547
42
+ ].freeze],
43
+ [1961.0, 1986.0, 1975.0, [
44
+ 45.45, 1.067, -1.0 / 260, -1.0 / 718
45
+ ].freeze]
46
+ ].freeze
47
+
48
+ private_constant :EARLIEST_YEAR,
49
+ :PRE_1972_MJD,
50
+ :DAYS_PER_JULIAN_YEAR,
51
+ :YEAR_J2000,
52
+ :POLYNOMIALS
53
+
54
+ module_function
55
+
56
+ # @param input [Time, Date, DateTime, nil]
57
+ # @param jd [Float, nil] Julian Date
58
+ # @param mjd [Float, nil] Modified Julian Date
59
+ # @return [Entry] DeltaT (TT − UT1) in seconds with source metadata
60
+ # @raise [OutOfRangeError]
61
+ def at(input = nil, jd: nil, mjd: nil)
62
+ query_mjd = TimeScale.to_mjd(input, jd: jd, mjd: mjd)
63
+ y = mjd_to_decimal_year(query_mjd)
64
+
65
+ if y < EARLIEST_YEAR
66
+ raise OutOfRangeError.new(
67
+ "DeltaT is only available from #{EARLIEST_YEAR.to_i} onward",
68
+ requested_mjd: query_mjd
69
+ )
70
+ end
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
+ )
78
+ 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
+ )
87
+ end
88
+ end
89
+
90
+ def polynomial_delta_t(y)
91
+ segment = POLYNOMIALS.find { |s| y < s[1] } || POLYNOMIALS.last
92
+ t = y - segment[2]
93
+ segment[3].reverse.reduce { |acc, c| acc * t + c }
94
+ end
95
+
96
+ def mjd_to_decimal_year(mjd)
97
+ YEAR_J2000 + (mjd - TimeScale::MJD_J2000) / DAYS_PER_JULIAN_YEAR
98
+ end
99
+
100
+ private_class_method :polynomial_delta_t, :mjd_to_decimal_year
101
+ end
102
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "tempfile"
6
+
7
+ module IERS
8
+ class Downloader
9
+ MAX_REDIRECTS = 5
10
+
11
+ def initialize(timeout:)
12
+ @timeout = timeout
13
+ end
14
+
15
+ def fetch(url, dest_path)
16
+ dest_path = Pathname(dest_path)
17
+ dest_path.dirname.mkpath
18
+
19
+ body = http_get(url)
20
+ validate!(body, dest_path)
21
+ atomic_write(dest_path, body)
22
+ end
23
+
24
+ private
25
+
26
+ def http_get(url, redirect_count = 0)
27
+ if redirect_count > MAX_REDIRECTS
28
+ raise NetworkError.new(
29
+ "Too many redirects",
30
+ url: url,
31
+ status_code: nil
32
+ )
33
+ end
34
+
35
+ uri = URI.parse(url)
36
+ response = perform_request(uri)
37
+
38
+ case response
39
+ when Net::HTTPRedirection
40
+ location = response["Location"]
41
+ resolved = uri + location
42
+ http_get(resolved.to_s, redirect_count + 1)
43
+ when Net::HTTPSuccess
44
+ response.body
45
+ else
46
+ raise NetworkError.new(
47
+ "HTTP #{response.code}: #{response.message}",
48
+ url: url,
49
+ status_code: response.code.to_i
50
+ )
51
+ end
52
+ rescue SocketError,
53
+ Errno::ECONNRESET,
54
+ Errno::ECONNREFUSED,
55
+ Net::OpenTimeout,
56
+ Net::ReadTimeout => e
57
+ raise NetworkError.new(e.message, url: url, status_code: nil)
58
+ end
59
+
60
+ def perform_request(uri)
61
+ http = Net::HTTP.new(uri.host, uri.port)
62
+ http.use_ssl = uri.scheme == "https"
63
+ http.open_timeout = @timeout
64
+ http.read_timeout = @timeout
65
+ http.get(uri.request_uri)
66
+ end
67
+
68
+ def validate!(body, dest_path)
69
+ return unless body.nil? || body.empty?
70
+
71
+ raise ValidationError.new(
72
+ "Downloaded file is empty",
73
+ path: dest_path.to_s,
74
+ reason: "empty response body"
75
+ )
76
+ end
77
+
78
+ def atomic_write(dest_path, body)
79
+ tempfile = Tempfile.new("iers", dest_path.dirname.to_s)
80
+ tempfile.binmode
81
+ tempfile.write(body)
82
+ tempfile.close
83
+ File.rename(tempfile.path, dest_path.to_s)
84
+ rescue
85
+ tempfile&.close!
86
+ raise
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IERS
4
+ module EarthRotationAngle
5
+ # ERA at J2000.0 in fractional turns
6
+ # (IERS Conventions 2010, eq. 5.15; IAU 2000 Resolution B1.8)
7
+ ERA_AT_J2000 = 0.7790572732640
8
+
9
+ # Ratio of universal to sidereal time
10
+ # (IERS Conventions 2010, eq. 5.15; IAU 2000 Resolution B1.8)
11
+ ERA_RATE = 1.00273781191135448
12
+
13
+ TWO_PI = 2.0 * Math::PI
14
+
15
+ private_constant :ERA_AT_J2000, :ERA_RATE, :TWO_PI
16
+
17
+ module_function
18
+
19
+ # @param input [Time, Date, DateTime, nil]
20
+ # @param jd [Float, nil] Julian Date
21
+ # @param mjd [Float, nil] Modified Julian Date
22
+ # @param interpolation [Symbol, nil] +:lagrange+ or +:linear+
23
+ # @return [Float] Earth Rotation Angle in radians, normalized to [0, 2π)
24
+ # @raise [OutOfRangeError]
25
+ def at(input = nil, jd: nil, mjd: nil, interpolation: nil)
26
+ query_mjd = TimeScale.to_mjd(input, jd: jd, mjd: mjd)
27
+ ut1_utc = UT1.at(mjd: query_mjd, interpolation: interpolation).ut1_utc
28
+
29
+ du = query_mjd - TimeScale::MJD_J2000 +
30
+ ut1_utc / TimeScale::SECONDS_PER_DAY
31
+
32
+ # IERS Conventions 2010, eq. 5.15
33
+ turns = ERA_AT_J2000 + ERA_RATE * du
34
+ era = (turns % 1.0) * TWO_PI
35
+ era += TWO_PI if era < 0.0
36
+ era
37
+ end
38
+ end
39
+ end
data/lib/iers/eop.rb ADDED
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IERS
4
+ module EOP
5
+ # @attr polar_motion_x [Float] pole x-coordinate in arcseconds
6
+ # @attr polar_motion_y [Float] pole y-coordinate in arcseconds
7
+ # @attr ut1_utc [Float] UT1−UTC in seconds
8
+ # @attr length_of_day [Float] excess LOD in seconds
9
+ # @attr celestial_pole_x [Float] dX correction in milliarcseconds
10
+ # @attr celestial_pole_y [Float] dY correction in milliarcseconds
11
+ # @attr mjd [Float] Modified Julian Date of the query
12
+ # @attr data_quality [Symbol] +:observed+ or +:predicted+
13
+ Entry = ::Data.define(
14
+ :polar_motion_x,
15
+ :polar_motion_y,
16
+ :ut1_utc,
17
+ :length_of_day,
18
+ :celestial_pole_x,
19
+ :celestial_pole_y,
20
+ :mjd,
21
+ :data_quality
22
+ ) do
23
+ include HasDate
24
+ include HasDataQuality
25
+ end
26
+
27
+ module_function
28
+
29
+ # @param input [Time, Date, DateTime, nil]
30
+ # @param jd [Float, nil] Julian Date
31
+ # @param mjd [Float, nil] Modified Julian Date
32
+ # @param interpolation [Symbol, nil] +:lagrange+ or +:linear+
33
+ # @return [Entry]
34
+ # @raise [OutOfRangeError]
35
+ def at(input = nil, jd: nil, mjd: nil, interpolation: nil)
36
+ query_mjd = TimeScale.to_mjd(input, jd: jd, mjd: mjd)
37
+ interp = interpolation ? {interpolation: interpolation} : {}
38
+
39
+ pm = PolarMotion.at(mjd: query_mjd, **interp)
40
+ ut1 = UT1.at(mjd: query_mjd, **interp)
41
+ cpo = CelestialPoleOffset.at(mjd: query_mjd, **interp)
42
+ lod = LengthOfDay.at(mjd: query_mjd, **interp)
43
+
44
+ quality = if [pm, ut1, cpo, lod].any?(&:predicted?)
45
+ :predicted
46
+ else
47
+ :observed
48
+ end
49
+
50
+ Entry.new(
51
+ polar_motion_x: pm.x,
52
+ polar_motion_y: pm.y,
53
+ ut1_utc: ut1.ut1_utc,
54
+ length_of_day: lod.length_of_day,
55
+ celestial_pole_x: cpo.x,
56
+ celestial_pole_y: cpo.y,
57
+ mjd: query_mjd,
58
+ data_quality: quality
59
+ )
60
+ end
61
+
62
+ # @param start_date [Date]
63
+ # @param end_date [Date]
64
+ # @return [Enumerator::Lazy<Entry>]
65
+ def between(start_date, end_date)
66
+ start_mjd = TimeScale.to_mjd(start_date)
67
+ end_mjd = TimeScale.to_mjd(end_date)
68
+ entries = Data.finals_entries
69
+
70
+ EopLookup
71
+ .range(entries, start_mjd, end_mjd)
72
+ .lazy
73
+ .map { |e| entry_from_parser(e) }
74
+ end
75
+
76
+ def entry_from_parser(e)
77
+ Entry.new(
78
+ polar_motion_x: e.bulletin_b_pm_x || e.pm_x,
79
+ polar_motion_y: e.bulletin_b_pm_y || e.pm_y,
80
+ ut1_utc: e.bulletin_b_ut1_utc || e.ut1_utc,
81
+ length_of_day: e.lod / 1000.0,
82
+ celestial_pole_x: e.dx,
83
+ celestial_pole_y: e.dy,
84
+ mjd: e.mjd,
85
+ data_quality: derive_quality(e)
86
+ )
87
+ end
88
+
89
+ def derive_quality(entry)
90
+ flags = [
91
+ entry.pm_flag,
92
+ entry.ut1_flag,
93
+ entry.nutation_flag
94
+ ]
95
+ if flags.include?("P")
96
+ :predicted
97
+ else
98
+ :observed
99
+ end
100
+ end
101
+
102
+ private_class_method :entry_from_parser, :derive_quality
103
+ end
104
+ end