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.
- checksums.yaml +4 -4
- data/.yardopts +3 -0
- data/CHANGELOG.md +41 -2
- data/README.md +331 -1
- data/data/Leap_Second.dat +41 -0
- data/data/finals2000A.all +19830 -0
- data/lib/iers/celestial_pole_offset.rb +63 -0
- data/lib/iers/configuration.rb +86 -0
- data/lib/iers/data.rb +169 -0
- data/lib/iers/data_status.rb +22 -0
- data/lib/iers/delta_t.rb +102 -0
- data/lib/iers/downloader.rb +89 -0
- data/lib/iers/earth_rotation_angle.rb +39 -0
- data/lib/iers/eop.rb +104 -0
- data/lib/iers/eop_lookup.rb +72 -0
- data/lib/iers/eop_parameter.rb +42 -0
- data/lib/iers/errors.rb +86 -0
- data/lib/iers/gmst.rb +42 -0
- data/lib/iers/has_data_quality.rb +16 -0
- data/lib/iers/has_date.rb +11 -0
- data/lib/iers/interpolation.rb +50 -0
- data/lib/iers/leap_second.rb +69 -0
- data/lib/iers/length_of_day.rb +60 -0
- data/lib/iers/parsers/finals.rb +123 -0
- data/lib/iers/parsers/leap_second.rb +56 -0
- data/lib/iers/parsers.rb +4 -0
- data/lib/iers/polar_motion.rb +114 -0
- data/lib/iers/tai.rb +44 -0
- data/lib/iers/terrestrial_rotation.rb +48 -0
- data/lib/iers/time_scale.rb +45 -0
- data/lib/iers/update_result.rb +12 -0
- data/lib/iers/ut1.rb +84 -0
- data/lib/iers/version.rb +1 -1
- data/lib/iers.rb +48 -0
- metadata +58 -1
|
@@ -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
|
data/lib/iers/delta_t.rb
ADDED
|
@@ -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
|