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,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IERS
4
+ # @api private
5
+ module EopLookup
6
+ module_function
7
+
8
+ # @param entries [Array] sorted finals entries
9
+ # @param mjd [Float]
10
+ # @param order [Integer] number of points in the window
11
+ # @return [Array]
12
+ # @raise [OutOfRangeError]
13
+ def window(entries, mjd, order: 4)
14
+ validate_range!(entries, mjd)
15
+
16
+ index = entries.bsearch_index { |e| e.mjd > mjd } || entries.size
17
+ center = index - 1
18
+
19
+ half = order / 2
20
+ start = center - half + 1
21
+ start = start.clamp(0, entries.size - order)
22
+
23
+ entries[start, order]
24
+ end
25
+
26
+ # @param entries [Array] sorted finals entries
27
+ # @param start_mjd [Float]
28
+ # @param end_mjd [Float]
29
+ # @return [Array] entries within the MJD range (inclusive)
30
+ def range(entries, start_mjd, end_mjd)
31
+ first = entries.bsearch_index { |e| e.mjd >= start_mjd } || entries.size
32
+ last = entries.bsearch_index { |e| e.mjd > end_mjd } || entries.size
33
+ entries[first...last]
34
+ end
35
+
36
+ # @param entries [Array] sorted finals entries
37
+ # @param mjd [Float]
38
+ # @return [Array] two-element array bracketing the query point
39
+ # @raise [OutOfRangeError]
40
+ def bracket(entries, mjd)
41
+ validate_range!(entries, mjd)
42
+
43
+ index = entries.bsearch_index { |e| e.mjd > mjd }
44
+
45
+ if index.nil? || index == 0
46
+ raise OutOfRangeError.new(
47
+ "No bracket available for MJD #{mjd}",
48
+ requested_mjd: mjd,
49
+ available_range: entries.first.mjd..entries.last.mjd
50
+ )
51
+ end
52
+
53
+ [entries[index - 1], entries[index]]
54
+ end
55
+
56
+ def validate_range!(entries, mjd)
57
+ first_mjd = entries.first.mjd
58
+ last_mjd = entries.last.mjd
59
+
60
+ return if mjd.between?(first_mjd, last_mjd)
61
+
62
+ raise OutOfRangeError.new(
63
+ "Requested MJD #{mjd} is outside available data " \
64
+ "(#{first_mjd}..#{last_mjd})",
65
+ requested_mjd: mjd,
66
+ available_range: first_mjd..last_mjd
67
+ )
68
+ end
69
+
70
+ private_class_method :validate_range!
71
+ end
72
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IERS
4
+ # @api private
5
+ module EopParameter
6
+ FLAG_TO_QUALITY = {"I" => :observed, "P" => :predicted}.freeze
7
+
8
+ def resolve(input, jd:, mjd:, interpolation:)
9
+ query_mjd = TimeScale.to_mjd(input, jd: jd, mjd: mjd)
10
+ entries = Data.finals_entries
11
+ method = interpolation || IERS.configuration.interpolation
12
+
13
+ window = case method
14
+ when :lagrange
15
+ order = IERS.configuration.lagrange_order
16
+ EopLookup.window(entries, query_mjd, order: order)
17
+ when :linear
18
+ EopLookup.bracket(entries, query_mjd)
19
+ end
20
+
21
+ [query_mjd, window, method]
22
+ end
23
+
24
+ def interpolate_field(window, query_mjd, method)
25
+ xs = window.map(&:mjd)
26
+ ys = window.map { |e| yield e }
27
+
28
+ case method
29
+ when :lagrange then Interpolation.lagrange(xs, ys, query_mjd)
30
+ when :linear then Interpolation.linear(xs, ys, query_mjd)
31
+ end
32
+ end
33
+
34
+ def derive_quality(window, flag)
35
+ if window.any? { |e| e.public_send(flag) == "P" }
36
+ :predicted
37
+ else
38
+ :observed
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IERS
4
+ class Error < StandardError; end
5
+
6
+ class DataError < Error; end
7
+
8
+ class ParseError < DataError
9
+ # @return [Pathname, nil]
10
+ attr_reader :path
11
+ # @return [Integer, nil]
12
+ attr_reader :line_number
13
+
14
+ def initialize(message = nil, path: nil, line_number: nil)
15
+ @path = path
16
+ @line_number = line_number
17
+ super(message)
18
+ end
19
+ end
20
+
21
+ class FileNotFoundError < DataError
22
+ # @return [Pathname, nil]
23
+ attr_reader :path
24
+
25
+ def initialize(message = nil, path: nil)
26
+ @path = path
27
+ super(message)
28
+ end
29
+ end
30
+
31
+ class DownloadError < Error; end
32
+
33
+ class NetworkError < DownloadError
34
+ # @return [String, nil]
35
+ attr_reader :url
36
+ # @return [Integer, nil]
37
+ attr_reader :status_code
38
+
39
+ def initialize(message = nil, url: nil, status_code: nil)
40
+ @url = url
41
+ @status_code = status_code
42
+ super(message)
43
+ end
44
+ end
45
+
46
+ class ValidationError < DownloadError
47
+ # @return [Pathname, nil]
48
+ attr_reader :path
49
+ # @return [String, nil]
50
+ attr_reader :reason
51
+
52
+ def initialize(message = nil, path: nil, reason: nil)
53
+ @path = path
54
+ @reason = reason
55
+ super(message)
56
+ end
57
+ end
58
+
59
+ class StaleDataError < DataError
60
+ # @return [Date, nil]
61
+ attr_reader :predicted_until
62
+ # @return [Date, nil]
63
+ attr_reader :required_until
64
+
65
+ def initialize(message = nil, predicted_until: nil, required_until: nil)
66
+ @predicted_until = predicted_until
67
+ @required_until = required_until
68
+ super(message)
69
+ end
70
+ end
71
+
72
+ class ConfigurationError < Error; end
73
+
74
+ class OutOfRangeError < Error
75
+ # @return [Float, nil]
76
+ attr_reader :requested_mjd
77
+ # @return [Range<Float>, nil]
78
+ attr_reader :available_range
79
+
80
+ def initialize(message = nil, requested_mjd: nil, available_range: nil)
81
+ @requested_mjd = requested_mjd
82
+ @available_range = available_range
83
+ super(message)
84
+ end
85
+ end
86
+ end
data/lib/iers/gmst.rb ADDED
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IERS
4
+ module GMST
5
+ TWO_PI = 2.0 * Math::PI
6
+
7
+ # IERS Conventions 2010, eq. 5.32 (arcseconds)
8
+ POLYNOMIAL = [
9
+ 0.014506,
10
+ 4612.156534,
11
+ 1.3915817,
12
+ -0.00000044,
13
+ -0.000029956,
14
+ -0.0000000368
15
+ ].freeze
16
+
17
+ private_constant :TWO_PI, :POLYNOMIAL
18
+
19
+ module_function
20
+
21
+ # @param input [Time, Date, DateTime, nil]
22
+ # @param jd [Float, nil] Julian Date
23
+ # @param mjd [Float, nil] Modified Julian Date
24
+ # @param interpolation [Symbol, nil] +:lagrange+ or +:linear+
25
+ # @return [Float] Greenwich Mean Sidereal Time in radians, norm. to [0, 2π)
26
+ # @raise [OutOfRangeError]
27
+ def at(input = nil, jd: nil, mjd: nil, interpolation: nil)
28
+ query_mjd = TimeScale.to_mjd(input, jd: jd, mjd: mjd)
29
+ era = EarthRotationAngle.at(mjd: query_mjd, interpolation: interpolation)
30
+
31
+ tai_utc = LeapSecond.at(mjd: query_mjd)
32
+ tt_mjd = query_mjd +
33
+ (tai_utc + TimeScale::TT_TAI) / TimeScale::SECONDS_PER_DAY
34
+ t = (tt_mjd - TimeScale::MJD_J2000) / TimeScale::DAYS_PER_JULIAN_CENTURY
35
+
36
+ poly = POLYNOMIAL.reverse.reduce { |acc, c| acc * t + c }
37
+ gmst = (era + poly * TimeScale::ARCSEC_TO_RAD) % TWO_PI
38
+ gmst += TWO_PI if gmst < 0.0
39
+ gmst
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IERS
4
+ # @api private
5
+ module HasDataQuality
6
+ # @return [Boolean]
7
+ def observed?
8
+ data_quality == :observed
9
+ end
10
+
11
+ # @return [Boolean]
12
+ def predicted?
13
+ data_quality == :predicted
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IERS
4
+ # @api private
5
+ module HasDate
6
+ # @return [Date]
7
+ def date
8
+ TimeScale.to_date(mjd)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IERS
4
+ # @api private
5
+ module Interpolation
6
+ module_function
7
+
8
+ # @param xs [Array<Float>] x-coordinates (exactly 2)
9
+ # @param ys [Array<Float>] y-coordinates (exactly 2)
10
+ # @param x [Float] interpolation point
11
+ # @return [Float]
12
+ def linear(xs, ys, x)
13
+ unless xs.size == 2 && ys.size == 2
14
+ raise ArgumentError, "linear interpolation requires exactly 2 points"
15
+ end
16
+
17
+ x0, x1 = xs
18
+ y0, y1 = ys
19
+ t = (x - x0) / (x1 - x0)
20
+ y0 + t * (y1 - y0)
21
+ end
22
+
23
+ # @param xs [Array<Float>] x-coordinates
24
+ # @param ys [Array<Float>] y-coordinates
25
+ # @param x [Float] interpolation point
26
+ # @return [Float]
27
+ def lagrange(xs, ys, x)
28
+ n = xs.size
29
+
30
+ unless n == ys.size
31
+ raise ArgumentError, "xs and ys must have the same size"
32
+ end
33
+
34
+ unless n >= 2
35
+ raise ArgumentError, "lagrange interpolation requires at least 2 points"
36
+ end
37
+
38
+ result = 0.0
39
+ n.times do |i|
40
+ basis = 1.0
41
+ n.times do |j|
42
+ next if i == j
43
+ basis *= (x - xs[j]) / (xs[i] - xs[j])
44
+ end
45
+ result += ys[i] * basis
46
+ end
47
+ result
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IERS
4
+ module LeapSecond
5
+ # @attr effective_date [Date]
6
+ # @attr tai_utc [Integer] cumulative TAI−UTC offset in seconds
7
+ Entry = ::Data.define(:effective_date, :tai_utc)
8
+
9
+ @mutex = Mutex.new
10
+ @all = nil
11
+
12
+ module_function
13
+
14
+ # @return [Array<Entry>]
15
+ def all
16
+ @mutex.synchronize do
17
+ @all ||= IERS::Data.leap_second_entries.map do |parser_entry|
18
+ Entry.new(
19
+ effective_date: parser_entry.date,
20
+ tai_utc: parser_entry.tai_utc
21
+ )
22
+ end.freeze
23
+ end
24
+ end
25
+
26
+ # @return [void]
27
+ def clear_cached!
28
+ @mutex.synchronize do
29
+ @all = nil
30
+ end
31
+ end
32
+
33
+ # @return [Entry, nil]
34
+ def next_scheduled
35
+ today = Date.today
36
+ all.find { |entry| entry.effective_date > today }
37
+ end
38
+
39
+ # @param input [Time, Date, DateTime, nil]
40
+ # @param jd [Float, nil] Julian Date
41
+ # @param mjd [Float, nil] Modified Julian Date
42
+ # @return [Integer] TAI−UTC in seconds
43
+ # @raise [OutOfRangeError]
44
+ def at(input = nil, jd: nil, mjd: nil)
45
+ query_mjd = TimeScale.to_mjd(input, jd: jd, mjd: mjd)
46
+ parser_entries = IERS::Data.leap_second_entries
47
+
48
+ first_mjd = parser_entries.first.mjd
49
+ last_mjd = parser_entries.last.mjd
50
+
51
+ if query_mjd < first_mjd
52
+ raise OutOfRangeError.new(
53
+ "Requested MJD #{query_mjd} is before the first leap second " \
54
+ "entry (MJD #{first_mjd})",
55
+ requested_mjd: query_mjd,
56
+ available_range: first_mjd..last_mjd
57
+ )
58
+ end
59
+
60
+ index = parser_entries.bsearch_index { |e| e.mjd > query_mjd }
61
+
62
+ if index.nil?
63
+ parser_entries.last.tai_utc
64
+ else
65
+ parser_entries[index - 1].tai_utc
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IERS
4
+ module LengthOfDay
5
+ # @attr length_of_day [Float] excess LOD in seconds
6
+ # @attr mjd [Float] Modified Julian Date of the query
7
+ # @attr data_quality [Symbol] +:observed+ or +:predicted+
8
+ Entry = ::Data.define(:length_of_day, :mjd, :data_quality) do
9
+ include HasDate
10
+ include HasDataQuality
11
+ end
12
+
13
+ extend EopParameter
14
+
15
+ module_function
16
+
17
+ # @param input [Time, Date, DateTime, nil]
18
+ # @param jd [Float, nil] Julian Date
19
+ # @param mjd [Float, nil] Modified Julian Date
20
+ # @param interpolation [Symbol, nil] +:lagrange+ or +:linear+
21
+ # @return [Entry]
22
+ # @raise [OutOfRangeError]
23
+ def at(input = nil, jd: nil, mjd: nil, interpolation: nil)
24
+ query_mjd, window, method = resolve(
25
+ input,
26
+ jd: jd,
27
+ mjd: mjd,
28
+ interpolation: interpolation
29
+ )
30
+
31
+ lod = interpolate_field(window, query_mjd, method) { |e| e.lod / 1000.0 }
32
+
33
+ Entry.new(
34
+ length_of_day: lod,
35
+ mjd: query_mjd,
36
+ data_quality: derive_quality(window, :ut1_flag)
37
+ )
38
+ end
39
+
40
+ # @param start_date [Date]
41
+ # @param end_date [Date]
42
+ # @return [Enumerator::Lazy<Entry>]
43
+ def between(start_date, end_date)
44
+ start_mjd = TimeScale.to_mjd(start_date)
45
+ end_mjd = TimeScale.to_mjd(end_date)
46
+ entries = Data.finals_entries
47
+
48
+ EopLookup
49
+ .range(entries, start_mjd, end_mjd)
50
+ .lazy
51
+ .map do |e|
52
+ Entry.new(
53
+ length_of_day: e.lod / 1000.0,
54
+ mjd: e.mjd,
55
+ data_quality: EopParameter::FLAG_TO_QUALITY.fetch(e.ut1_flag, :observed)
56
+ )
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module IERS
6
+ module Parsers
7
+ module Finals
8
+ Entry = Data.define(
9
+ :date, :mjd,
10
+ :pm_flag, :pm_x, :pm_x_error, :pm_y, :pm_y_error,
11
+ :ut1_flag, :ut1_utc, :ut1_utc_error,
12
+ :lod, :lod_error,
13
+ :nutation_flag, :dx, :dx_error, :dy, :dy_error,
14
+ :bulletin_b_pm_x, :bulletin_b_pm_y, :bulletin_b_ut1_utc,
15
+ :bulletin_b_dx, :bulletin_b_dy
16
+ )
17
+
18
+ MJD_Y2K_PIVOT = 51544.0
19
+
20
+ module_function
21
+
22
+ def parse(path)
23
+ path = Pathname(path)
24
+
25
+ unless path.exist?
26
+ raise FileNotFoundError.new(
27
+ "File not found: #{path}",
28
+ path: path.to_s
29
+ )
30
+ end
31
+
32
+ entries = []
33
+
34
+ path.each_line.with_index(1) do |line, line_number|
35
+ next if line.strip.empty?
36
+ next if no_eop_data?(line)
37
+
38
+ entries << parse_line(line, path, line_number)
39
+ end
40
+
41
+ entries.freeze
42
+ end
43
+
44
+ def parse_line(line, path, line_number)
45
+ mjd = parse_float(line, 7, 8)
46
+ yy = parse_int(line, 0, 2)
47
+ month = parse_int(line, 2, 2)
48
+ day = parse_int(line, 4, 2)
49
+ year = (mjd < MJD_Y2K_PIVOT) ? 1900 + yy : 2000 + yy
50
+
51
+ Entry.new(
52
+ date: Date.new(year, month, day),
53
+ mjd: mjd,
54
+ pm_flag: parse_flag(line, 16),
55
+ pm_x: parse_float(line, 18, 9),
56
+ pm_x_error: parse_float(line, 27, 9),
57
+ pm_y: parse_float(line, 37, 9),
58
+ pm_y_error: parse_float(line, 46, 9),
59
+ ut1_flag: parse_flag(line, 57),
60
+ ut1_utc: parse_float(line, 58, 10),
61
+ ut1_utc_error: parse_float(line, 68, 10),
62
+ lod: parse_optional_float(line, 79, 7),
63
+ lod_error: parse_optional_float(line, 86, 7),
64
+ nutation_flag: parse_optional_flag(line, 95),
65
+ dx: parse_optional_float(line, 97, 9),
66
+ dx_error: parse_optional_float(line, 106, 9),
67
+ dy: parse_optional_float(line, 116, 9),
68
+ dy_error: parse_optional_float(line, 125, 9),
69
+ bulletin_b_pm_x: parse_optional_float(line, 134, 10),
70
+ bulletin_b_pm_y: parse_optional_float(line, 144, 10),
71
+ bulletin_b_ut1_utc: parse_optional_float(line, 154, 11),
72
+ bulletin_b_dx: parse_optional_float(line, 165, 10),
73
+ bulletin_b_dy: parse_optional_float(line, 175, 10)
74
+ )
75
+ rescue ArgumentError, TypeError => e
76
+ raise ParseError.new(
77
+ "Failed to parse line #{line_number}: #{e.message}",
78
+ path: path.to_s,
79
+ line_number: line_number
80
+ )
81
+ end
82
+
83
+ def parse_float(line, offset, length)
84
+ Float(line[offset, length])
85
+ end
86
+
87
+ def parse_int(line, offset, length)
88
+ Integer(line[offset, length])
89
+ end
90
+
91
+ def parse_flag(line, offset)
92
+ line[offset, 1].strip
93
+ end
94
+
95
+ def parse_optional_float(line, offset, length)
96
+ raw = line[offset, length]
97
+ return nil if raw.nil? || raw.strip.empty?
98
+
99
+ Float(raw)
100
+ end
101
+
102
+ def parse_optional_flag(line, offset)
103
+ raw = line[offset, 1]
104
+ return nil if raw.nil? || raw.strip.empty?
105
+
106
+ raw.strip
107
+ end
108
+
109
+ def no_eop_data?(line)
110
+ flag = line[16, 1]
111
+ flag.nil? || flag.strip.empty?
112
+ end
113
+
114
+ private_class_method :parse_line,
115
+ :no_eop_data?,
116
+ :parse_float,
117
+ :parse_int,
118
+ :parse_flag,
119
+ :parse_optional_float,
120
+ :parse_optional_flag
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module IERS
6
+ module Parsers
7
+ module LeapSecond
8
+ Entry = Data.define(:mjd, :date, :tai_utc)
9
+
10
+ module_function
11
+
12
+ def parse(path)
13
+ path = Pathname(path)
14
+
15
+ unless path.exist?
16
+ raise FileNotFoundError.new(
17
+ "File not found: #{path}",
18
+ path: path.to_s
19
+ )
20
+ end
21
+
22
+ entries = []
23
+
24
+ path.each_line.with_index(1) do |line, line_number|
25
+ next if line.strip.empty? || line.strip.start_with?("#")
26
+
27
+ entries << parse_line(line, path, line_number)
28
+ end
29
+
30
+ entries.freeze
31
+ end
32
+
33
+ def parse_line(line, path, line_number)
34
+ parts = line.split
35
+
36
+ Entry.new(
37
+ mjd: Float(parts[0]),
38
+ date: Date.new(
39
+ Integer(parts[3]),
40
+ Integer(parts[2]),
41
+ Integer(parts[1])
42
+ ),
43
+ tai_utc: Integer(parts[4])
44
+ )
45
+ rescue ArgumentError, TypeError => e
46
+ raise ParseError.new(
47
+ "Failed to parse line #{line_number}: #{e.message}",
48
+ path: path.to_s,
49
+ line_number: line_number
50
+ )
51
+ end
52
+
53
+ private_class_method :parse_line
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parsers/finals"
4
+ require_relative "parsers/leap_second"