astronoby 0.8.0 → 0.9.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.md +6 -4
  4. data/UPGRADING.md +25 -0
  5. data/docs/README.md +30 -2
  6. data/docs/angles.md +1 -1
  7. data/docs/coordinates.md +1 -1
  8. data/docs/deep_sky_bodies.md +101 -0
  9. data/docs/ephem.md +1 -1
  10. data/docs/instant.md +1 -1
  11. data/docs/moon_phases.md +1 -1
  12. data/docs/observer.md +1 -1
  13. data/docs/reference_frames.md +1 -1
  14. data/docs/rise_transit_set_times.md +5 -5
  15. data/docs/{celestial_bodies.md → solar_system_bodies.md} +1 -1
  16. data/lib/astronoby/angle.rb +6 -2
  17. data/lib/astronoby/angular_velocity.rb +76 -0
  18. data/lib/astronoby/bodies/deep_sky_object.rb +44 -0
  19. data/lib/astronoby/bodies/deep_sky_object_position.rb +127 -0
  20. data/lib/astronoby/bodies/earth.rb +5 -1
  21. data/lib/astronoby/bodies/moon.rb +21 -0
  22. data/lib/astronoby/bodies/solar_system_body.rb +25 -0
  23. data/lib/astronoby/cache.rb +1 -0
  24. data/lib/astronoby/constants.rb +7 -2
  25. data/lib/astronoby/coordinates/equatorial.rb +2 -5
  26. data/lib/astronoby/distance.rb +6 -0
  27. data/lib/astronoby/events/extremum_calculator.rb +233 -0
  28. data/lib/astronoby/events/extremum_event.rb +15 -0
  29. data/lib/astronoby/events/rise_transit_set_calculator.rb +9 -6
  30. data/lib/astronoby/events/twilight_calculator.rb +1 -1
  31. data/lib/astronoby/instant.rb +27 -4
  32. data/lib/astronoby/reference_frames/apparent.rb +0 -10
  33. data/lib/astronoby/reference_frames/topocentric.rb +1 -1
  34. data/lib/astronoby/stellar_propagation.rb +162 -0
  35. data/lib/astronoby/time/greenwich_apparent_sidereal_time.rb +22 -0
  36. data/lib/astronoby/time/greenwich_mean_sidereal_time.rb +64 -0
  37. data/lib/astronoby/time/greenwich_sidereal_time.rb +20 -58
  38. data/lib/astronoby/time/local_apparent_sidereal_time.rb +42 -0
  39. data/lib/astronoby/time/local_mean_sidereal_time.rb +42 -0
  40. data/lib/astronoby/time/local_sidereal_time.rb +35 -26
  41. data/lib/astronoby/time/sidereal_time.rb +42 -0
  42. data/lib/astronoby/util/time.rb +61 -43
  43. data/lib/astronoby/velocity.rb +5 -0
  44. data/lib/astronoby/version.rb +1 -1
  45. data/lib/astronoby.rb +11 -0
  46. metadata +14 -2
@@ -15,12 +15,16 @@ module Astronoby
15
15
  end
16
16
  end
17
17
 
18
+ def phase_angle
19
+ nil
20
+ end
21
+
18
22
  private
19
23
 
20
24
  # Attributes that require Sun data like phase angle or magnitude are not
21
25
  # applicable for Earth.
22
26
  def requires_sun_data?
23
- false
27
+ true
24
28
  end
25
29
 
26
30
  def compute_astrometric(_ephem)
@@ -42,6 +42,27 @@ module Astronoby
42
42
  mean_elongation.degrees / Constants::DEGREES_PER_CIRCLE
43
43
  end
44
44
 
45
+ # @return [Boolean] True if the body is approaching the primary
46
+ # body (Earth), false otherwise.
47
+ def approaching_primary?
48
+ relative_position =
49
+ (geometric.position - @earth_geometric.position).map(&:m)
50
+ relative_velocity =
51
+ (geometric.velocity - @earth_geometric.velocity).map(&:mps)
52
+ radial_velocity_component = Astronoby::Util::Maths
53
+ .dot_product(relative_position, relative_velocity)
54
+ distance = Math.sqrt(
55
+ Astronoby::Util::Maths.dot_product(relative_position, relative_position)
56
+ )
57
+ radial_velocity_component / distance < 0
58
+ end
59
+
60
+ # @return [Boolean] True if the body is receding from the primary
61
+ # body (Earth), false otherwise.
62
+ def receding_from_primary?
63
+ !approaching_primary?
64
+ end
65
+
45
66
  private
46
67
 
47
68
  # Source:
@@ -19,6 +19,10 @@ module Astronoby
19
19
 
20
20
  attr_reader :geometric, :instant
21
21
 
22
+ def self.at(instant, ephem:)
23
+ new(ephem: ephem, instant: instant)
24
+ end
25
+
22
26
  def self.geometric(ephem:, instant:)
23
27
  compute_geometric(ephem: ephem, instant: instant)
24
28
  end
@@ -202,6 +206,27 @@ module Astronoby
202
206
  end
203
207
  end
204
208
 
209
+ # @return [Boolean] True if the body is approaching the primary
210
+ # body (Sun), false otherwise.
211
+ def approaching_primary?
212
+ relative_position =
213
+ (geometric.position - @sun.geometric.position).map(&:m)
214
+ relative_velocity =
215
+ (geometric.velocity - @sun.geometric.velocity).map(&:mps)
216
+ radial_velocity_component = Astronoby::Util::Maths
217
+ .dot_product(relative_position, relative_velocity)
218
+ distance = Math.sqrt(
219
+ Astronoby::Util::Maths.dot_product(relative_position, relative_position)
220
+ )
221
+ radial_velocity_component / distance < 0
222
+ end
223
+
224
+ # @return [Boolean] True if the body is receding from the primary
225
+ # body (Sun), false otherwise.
226
+ def receding_from_primary?
227
+ !approaching_primary?
228
+ end
229
+
205
230
  private
206
231
 
207
232
  # By default, Solar System bodies expose attributes that are dependent on
@@ -85,6 +85,7 @@ module Astronoby
85
85
  # @return [void]
86
86
  def max_size=(new_size)
87
87
  raise ArgumentError, "max_size must be positive" unless new_size > 0
88
+
88
89
  @mutex.synchronize do
89
90
  @max_size = new_size
90
91
  while @hash.size > @max_size
@@ -14,19 +14,24 @@ module Astronoby
14
14
 
15
15
  SECONDS_PER_MINUTE = 60.0
16
16
  MINUTES_PER_HOUR = 60.0
17
- MINUTES_PER_DEGREE = 60.0
18
-
19
17
  SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR
20
18
  SECONDS_PER_DAY = SECONDS_PER_HOUR * HOURS_PER_DAY
19
+ SECONDS_PER_JULIAN_YEAR = SECONDS_PER_DAY * DAYS_PER_JULIAN_YEAR
21
20
  RADIAN_PER_HOUR = Math::PI / 12.0
22
21
  MICROSECOND_IN_DAYS = 1.0 / SECONDS_PER_DAY / 1e6
23
22
 
23
+ ARCMINUTES_PER_DEGREE = 60.0
24
+ ARC_SECONDS_PER_ARCMINUTE = 60.0
25
+ ARCSECONDS_PER_DEGREE = ARC_SECONDS_PER_ARCMINUTE * ARCMINUTES_PER_DEGREE
26
+ MILLIARCSECONDS_PER_DEGREE = ARCSECONDS_PER_DEGREE * 1000
27
+
24
28
  PI_IN_DEGREES = 180.0
25
29
 
26
30
  EQUATION_OF_TIME_CONSTANT = 0.0057183
27
31
 
28
32
  KILOMETER_IN_METERS = 1_000
29
33
  ASTRONOMICAL_UNIT_IN_METERS = 149_597_870_700
34
+ PARSEC_IN_METERS = 3.0856775814913673e16
30
35
  EARTH_EQUATORIAL_RADIUS_IN_METERS = 6378140
31
36
 
32
37
  # WGS84 Earth Constants
@@ -42,11 +42,8 @@ module Astronoby
42
42
  end
43
43
 
44
44
  def compute_hour_angle(time:, longitude:)
45
- lst = GreenwichSiderealTime
46
- .from_utc(time.utc)
47
- .to_lst(longitude: longitude)
48
-
49
- ha = (lst.time - @right_ascension.hours)
45
+ last = LocalApparentSiderealTime.from_utc(time.utc, longitude: longitude)
46
+ ha = (last.time - @right_ascension.hours)
50
47
  ha += Constants::HOURS_PER_DAY if ha.negative?
51
48
 
52
49
  Angle.from_hours(ha)
@@ -26,6 +26,12 @@ module Astronoby
26
26
  end
27
27
  alias_method :from_au, :from_astronomical_units
28
28
 
29
+ def from_parsecs(parsecs)
30
+ meters = parsecs * Constants::PARSEC_IN_METERS
31
+ from_meters(meters)
32
+ end
33
+ alias_method :from_pc, :from_parsecs
34
+
29
35
  def vector_from_meters(array)
30
36
  Vector.elements(array.map { from_meters(_1) })
31
37
  end
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astronoby
4
+ # Calculates extrema (minima and maxima) in the distance between two
5
+ # celestial bodies over a given time range using adaptive sampling and golden
6
+ # section search refinement.
7
+ class ExtremumCalculator
8
+ # Mathematical constants
9
+ PHI = (1 + Math.sqrt(5)) / 2
10
+ INVPHI = 1 / PHI
11
+ GOLDEN_SECTION_TOLERANCE = 1e-5
12
+
13
+ # Algorithm parameters
14
+ MIN_SAMPLES_PER_PERIOD = 20
15
+ DUPLICATE_THRESHOLD_DAYS = 0.5
16
+ BOUNDARY_BUFFER_DAYS = 0.01
17
+
18
+ # Orbital periods
19
+ ORBITAL_PERIODS = {
20
+ "Astronoby::Moon" => 27.504339,
21
+ "Astronoby::Mercury" => 87.969,
22
+ "Astronoby::Venus" => 224.701,
23
+ "Astronoby::Earth" => 365.256,
24
+ "Astronoby::Mars" => 686.98,
25
+ "Astronoby::Jupiter" => 4332.59,
26
+ "Astronoby::Saturn" => 10759.22,
27
+ "Astronoby::Uranus" => 30688.5,
28
+ "Astronoby::Neptune" => 60182.0
29
+ }.freeze
30
+
31
+ # @param ephem [::Ephem::SPK] Ephemeris data source
32
+ # @param body [Astronoby::SolarSystemBody] The celestial body to track
33
+ # @param primary_body [Astronoby::SolarSystemBody] The reference body
34
+ # (e.g., Sun for planetary orbits)
35
+ # @param samples_per_period [Integer] Number of samples to take per orbital
36
+ # period
37
+ def initialize(
38
+ body:,
39
+ primary_body:,
40
+ ephem:,
41
+ samples_per_period: 60
42
+ )
43
+ @ephem = ephem
44
+ @body = body
45
+ @primary_body = primary_body
46
+ @orbital_period = ORBITAL_PERIODS.fetch(body.name)
47
+ @samples_per_period = samples_per_period
48
+ end
49
+
50
+ # Finds all apoapsis events between two times
51
+ # @param start_time [Time] Start time
52
+ # @param end_time [Time] End time
53
+ # @return [Array<Astronoby::ExtremumEvent>] Array of apoapsis events
54
+ def apoapsis_events_between(start_time, end_time)
55
+ find_extrema(
56
+ Astronoby::Instant.from_time(start_time).tt,
57
+ Astronoby::Instant.from_time(end_time).tt,
58
+ type: :maximum
59
+ )
60
+ end
61
+
62
+ # Finds all periapsis events between two times
63
+ # @param start_time [Time] Start time
64
+ # @param end_time [Time] End time
65
+ # @return [Array<Astronoby::ExtremumEvent>] Array of periapsis events
66
+ def periapsis_events_between(start_time, end_time)
67
+ find_extrema(
68
+ Astronoby::Instant.from_time(start_time).tt,
69
+ Astronoby::Instant.from_time(end_time).tt,
70
+ type: :minimum
71
+ )
72
+ end
73
+
74
+ # Finds extrema (minima or maxima) in the distance between bodies within
75
+ # a time range
76
+ # @param start_jd [Float] Start time in Julian Date (Terrestrial Time)
77
+ # @param end_jd [Float] End time in Julian Date (Terrestrial Time)
78
+ # @param type [Symbol] :maximum or :minimum
79
+ # @return [Array<Astronoby::ExtremumEvent>] Array of extrema events
80
+ def find_extrema(start_jd, end_jd, type: :maximum)
81
+ # 1: Find extrema candidates through adaptive sampling
82
+ candidates = find_extrema_candidates(start_jd, end_jd, type)
83
+
84
+ # 2: Refine each candidate using golden section search
85
+ refined_extrema = candidates
86
+ .map { |candidate| refine_extremum(candidate, type) }
87
+ .compact
88
+
89
+ # 3: Remove duplicates and boundary artifacts
90
+ refined_extrema = remove_duplicates(refined_extrema)
91
+ filter_boundary_artifacts(refined_extrema, start_jd, end_jd)
92
+ end
93
+
94
+ private
95
+
96
+ def distance_at(jd)
97
+ instant = Instant.from_terrestrial_time(jd)
98
+ body_geometric = @body.geometric(ephem: @ephem, instant: instant)
99
+ primary_geometric = @primary_body
100
+ .geometric(ephem: @ephem, instant: instant)
101
+
102
+ distance_vector = body_geometric.position - primary_geometric.position
103
+ distance_vector.magnitude
104
+ end
105
+
106
+ def find_extrema_candidates(start_jd, end_jd, type)
107
+ samples = collect_samples(start_jd, end_jd)
108
+ find_local_extrema_in_samples(samples, type)
109
+ end
110
+
111
+ def collect_samples(start_jd, end_jd)
112
+ duration = end_jd - start_jd
113
+ sample_count = calculate_sample_count(duration)
114
+ step = duration / sample_count
115
+
116
+ (0..sample_count).map do |i|
117
+ jd = start_jd + (i * step)
118
+ {jd: jd, value: distance_at(jd)}
119
+ end
120
+ end
121
+
122
+ def calculate_sample_count(duration)
123
+ # Adaptive sampling: scale with duration and orbital period
124
+ periods_in_range = duration / @orbital_period
125
+ base_samples = (periods_in_range * @samples_per_period).to_i
126
+
127
+ # Ensure minimum sample density for short ranges
128
+ [base_samples, MIN_SAMPLES_PER_PERIOD].max
129
+ end
130
+
131
+ def find_local_extrema_in_samples(samples, type)
132
+ candidates = []
133
+
134
+ # Check each interior point for local extrema
135
+ (1...samples.length - 1).each do |i|
136
+ if local_extremum?(samples, i, type)
137
+ candidates << {
138
+ start_jd: samples[i - 1][:jd],
139
+ end_jd: samples[i + 1][:jd],
140
+ center_jd: samples[i][:jd]
141
+ }
142
+ end
143
+ end
144
+
145
+ candidates
146
+ end
147
+
148
+ def local_extremum?(samples, index, type)
149
+ current_val = samples[index][:value].m
150
+ prev_val = samples[index - 1][:value].m
151
+ next_val = samples[index + 1][:value].m
152
+
153
+ if type == :maximum
154
+ current_val > prev_val && current_val > next_val
155
+ else
156
+ current_val < prev_val && current_val < next_val
157
+ end
158
+ end
159
+
160
+ def refine_extremum(candidate, type)
161
+ golden_section_search(candidate[:start_jd], candidate[:end_jd], type)
162
+ end
163
+
164
+ def golden_section_search(a, b, type)
165
+ return nil if b <= a
166
+
167
+ tol = GOLDEN_SECTION_TOLERANCE * (b - a).abs
168
+
169
+ # Initial points using golden ratio
170
+ x1 = a + (1 - INVPHI) * (b - a)
171
+ x2 = a + INVPHI * (b - a)
172
+
173
+ f1 = distance_at(x1).m
174
+ f2 = distance_at(x2).m
175
+
176
+ while (b - a).abs > tol
177
+ should_keep_left = (type == :maximum) ? (f1 > f2) : (f1 < f2)
178
+
179
+ if should_keep_left
180
+ b = x2
181
+ x2 = x1
182
+ f2 = f1
183
+ x1 = a + (1 - INVPHI) * (b - a)
184
+ f1 = distance_at(x1).m
185
+ else
186
+ a = x1
187
+ x1 = x2
188
+ f1 = f2
189
+ x2 = a + INVPHI * (b - a)
190
+ f2 = distance_at(x2).m
191
+ end
192
+ end
193
+
194
+ mid = (a + b) / 2
195
+ ExtremumEvent.new(
196
+ Instant.from_terrestrial_time(mid),
197
+ distance_at(mid)
198
+ )
199
+ end
200
+
201
+ def remove_duplicates(extrema)
202
+ return extrema if extrema.length <= 1
203
+
204
+ cleaned = [extrema.first]
205
+
206
+ extrema.each_with_index do |current, i|
207
+ next if i == 0
208
+
209
+ is_duplicate = cleaned.any? do |existing|
210
+ time_diff = (
211
+ current.instant.tt - existing.instant.tt
212
+ ).abs
213
+ time_diff < DUPLICATE_THRESHOLD_DAYS
214
+ end
215
+
216
+ cleaned << current unless is_duplicate
217
+ end
218
+
219
+ cleaned
220
+ end
221
+
222
+ def filter_boundary_artifacts(extrema, start_jd, end_jd)
223
+ extrema.reject do |extreme|
224
+ start_diff = (extreme.instant.tt - start_jd).abs
225
+ end_diff = (extreme.instant.tt - end_jd).abs
226
+
227
+ too_close_to_start = start_diff < BOUNDARY_BUFFER_DAYS
228
+ too_close_to_end = end_diff < BOUNDARY_BUFFER_DAYS
229
+ too_close_to_start || too_close_to_end
230
+ end
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astronoby
4
+ # Represents an extremum event with its timing and value
5
+ class ExtremumEvent
6
+ attr_reader :instant, :value
7
+
8
+ # @param instant [Astronoby::Instant] When the event occurs
9
+ # @param value [Object] The extreme value
10
+ def initialize(instant, value)
11
+ @instant = instant
12
+ @value = value
13
+ end
14
+ end
15
+ end
@@ -23,7 +23,11 @@ module Astronoby
23
23
  SUN_REFRACTION_ANGLE = -Angle.from_dms(0, 50, 0)
24
24
  EVENT_TYPES = [:rising, :transit, :setting].freeze
25
25
 
26
- def initialize(body:, observer:, ephem:)
26
+ # @param body [Astronoby::SolarSystemBody, Astronoby::DeepSkyObject]
27
+ # Celestial body for which to calculate events
28
+ # @param observer [Astronoby::Observer] Observer location
29
+ # @param ephem [::Ephem::SPK, nil] Ephemeris data source
30
+ def initialize(body:, observer:, ephem: nil)
27
31
  @body = body
28
32
  @observer = observer
29
33
  @ephem = ephem
@@ -280,7 +284,7 @@ module Astronoby
280
284
  )
281
285
  Astronoby.cache.fetch(cache_key) do
282
286
  @body
283
- .new(instant: instant, ephem: @ephem)
287
+ .at(instant, ephem: @ephem)
284
288
  .observed_by(@observer)
285
289
  end
286
290
  end
@@ -292,7 +296,7 @@ module Astronoby
292
296
  instant.tt.round(precision)
293
297
  )
294
298
  @positions_cache[rounded_instant] ||= @body
295
- .new(instant: rounded_instant, ephem: @ephem)
299
+ .at(rounded_instant, ephem: @ephem)
296
300
  .observed_by(@observer)
297
301
  end
298
302
  end
@@ -352,10 +356,9 @@ module Astronoby
352
356
  end
353
357
 
354
358
  def horizon_angle(distance)
355
- case @body.name
356
- when "Astronoby::Sun"
359
+ if @body == Astronoby::Sun
357
360
  SUN_REFRACTION_ANGLE
358
- when "Astronoby::Moon"
361
+ elsif @body == Astronoby::Moon
359
362
  STANDARD_REFRACTION_ANGLE -
360
363
  Angle.from_radians(Moon::EQUATORIAL_RADIUS.m / distance.m)
361
364
  else
@@ -210,7 +210,7 @@ module Astronoby
210
210
 
211
211
  twilight_in_hours =
212
212
  time_sign * (hour_angle_at_twilight - hour_angle_at_period).hours *
213
- GreenwichSiderealTime::SIDEREAL_MINUTE_IN_UT_MINUTE
213
+ GreenwichMeanSiderealTime::SIDEREAL_MINUTE_IN_UT_MINUTE
214
214
  twilight_in_seconds = time_sign *
215
215
  twilight_in_hours *
216
216
  Constants::SECONDS_PER_HOUR
@@ -94,7 +94,7 @@ module Astronoby
94
94
  def to_datetime
95
95
  DateTime.jd(
96
96
  @terrestrial_time -
97
- Rational(delta_t / Constants::SECONDS_PER_DAY) +
97
+ Rational(delta_t, Constants::SECONDS_PER_DAY) +
98
98
  DATETIME_JD_EPOCH_ADJUSTMENT
99
99
  )
100
100
  end
@@ -123,9 +123,32 @@ module Astronoby
123
123
 
124
124
  # Get the Greenwich Mean Sidereal Time
125
125
  #
126
- # @return [Numeric] the sidereal time in radians
126
+ # @return [Numeric] the sidereal time in hours
127
127
  def gmst
128
- GreenwichSiderealTime.from_utc(to_time).time
128
+ GreenwichMeanSiderealTime.from_utc(to_time).time
129
+ end
130
+
131
+ # Get the Greenwich Apparent Sidereal Time
132
+ #
133
+ # @return [Numeric] the sidereal time in hours
134
+ def gast
135
+ GreenwichApparentSiderealTime.from_utc(to_time).time
136
+ end
137
+
138
+ # Get the Local Mean Sidereal Time
139
+ #
140
+ # @param longitude [Astronoby::Angle] the observer's longitude
141
+ # @return [Numeric] the sidereal time in hours
142
+ def lmst(longitude:)
143
+ LocalMeanSiderealTime.from_utc(to_time, longitude: longitude).time
144
+ end
145
+
146
+ # Get the Local Apparent Sidereal Time
147
+ #
148
+ # @param longitude [Astronoby::Angle] the observer's longitude
149
+ # @return [Numeric] the sidereal time in hours
150
+ def last(longitude:)
151
+ LocalApparentSiderealTime.from_utc(to_time, longitude: longitude).time
129
152
  end
130
153
 
131
154
  # Get the International Atomic Time (TAI)
@@ -151,7 +174,7 @@ module Astronoby
151
174
  #
152
175
  # @return [Numeric] the offset in days
153
176
  def utc_offset
154
- @terrestrial_time - to_time.utc.to_datetime.ajd
177
+ Rational(delta_t / Constants::SECONDS_PER_DAY)
155
178
  end
156
179
 
157
180
  # Calculate hash value for the instant
@@ -46,15 +46,5 @@ module Astronoby
46
46
  equatorial.to_ecliptic(instant: @instant)
47
47
  end
48
48
  end
49
-
50
- def angular_diameter
51
- @angular_radius ||= begin
52
- return Angle.zero if @position.zero?
53
-
54
- Angle.from_radians(
55
- Math.atan(@target_body.class::EQUATORIAL_RADIUS.m / distance.m) * 2
56
- )
57
- end
58
- end
59
49
  end
60
50
  end
@@ -14,7 +14,7 @@ module Astronoby
14
14
  matrix * observer.geocentric_position.map(&:m)
15
15
  )
16
16
  observer_velocity = Velocity.vector_from_mps(
17
- matrix * observer.geocentric_velocity.map(&:kmps)
17
+ matrix * observer.geocentric_velocity.map(&:mps)
18
18
  )
19
19
 
20
20
  position = apparent.position - observer_position
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astronoby
4
+ class StellarPropagation
5
+ # @return [Astronoby::Vector] Propagated position vector of
6
+ # Astronoby::Distance components
7
+ def self.position_for(**kwargs)
8
+ new(**kwargs).position
9
+ end
10
+
11
+ # @return [Astronoby::Vector] Propagated position vector of
12
+ # Astronoby::Velocity components
13
+ def self.velocity_vector_for(**kwargs)
14
+ new(**kwargs).velocity_vector
15
+ end
16
+
17
+ # @return [Astronoby::Coordinates::Equatorial] Propagated equatorial
18
+ # coordinates
19
+ def self.equatorial_coordinates_for(**kwargs)
20
+ new(**kwargs).equatorial_coordinates
21
+ end
22
+
23
+ # @param instant [Astronoby::Instant] Instant of the observation
24
+ # @param equatorial_coordinates [Astronoby::Coordinates::Equatorial]
25
+ # Equatorial coordinates at epoch J2000.0
26
+ # @param proper_motion_ra [Astronoby::AngularVelocity] Proper motion in
27
+ # right ascension
28
+ # @param proper_motion_dec [Astronoby::AngularVelocity] Proper motion in
29
+ # declination
30
+ # @param parallax [Astronoby::Angle] Parallax angle
31
+ # @param radial_velocity [Astronoby::Velocity] Radial velocity
32
+ # @param earth_geometric [Astronoby::ReferenceFrame::Geometric, nil]
33
+ # Geometric reference frame of the Earth
34
+ def initialize(
35
+ instant:,
36
+ equatorial_coordinates:,
37
+ proper_motion_ra:,
38
+ proper_motion_dec:,
39
+ parallax:,
40
+ radial_velocity:,
41
+ earth_geometric: nil
42
+ )
43
+ @instant = instant
44
+ @right_ascension = equatorial_coordinates.right_ascension
45
+ @declination = equatorial_coordinates.declination
46
+ @initial_epoch = equatorial_coordinates.epoch
47
+ @proper_motion_ra = proper_motion_ra
48
+ @proper_motion_dec = proper_motion_dec
49
+ @parallax = parallax
50
+ @radial_velocity = radial_velocity
51
+ @earth_geometric = earth_geometric
52
+ end
53
+
54
+ # @return [Astronoby::Vector] Propagated position vector of
55
+ # Astronoby::Distance components
56
+ def position
57
+ @position ||= Distance.vector_from_meters(
58
+ initial_position_vector +
59
+ tangential_velocity.map(&:mps) * time_elapsed_seconds
60
+ )
61
+ end
62
+
63
+ # @return [Astronoby::Vector] Propagated position vector of
64
+ # Astronoby::Velocity components
65
+ def velocity_vector
66
+ @velocity_vector ||= if @earth_geometric
67
+ @earth_geometric.velocity - tangential_velocity
68
+ else
69
+ tangential_velocity
70
+ end
71
+ end
72
+
73
+ # @return [Astronoby::Coordinates::Equatorial] Propagated equatorial
74
+ # coordinates
75
+ def equatorial_coordinates
76
+ @equatorial_coordinates ||= begin
77
+ right_ascension = Util::Trigonometry.adjustement_for_arctangent(
78
+ position.y.m,
79
+ position.x.m,
80
+ Angle.atan(position.y.m / position.x.m)
81
+ )
82
+ declination = Angle.asin(position.z.m / position.magnitude.m)
83
+
84
+ Coordinates::Equatorial.new(
85
+ right_ascension: right_ascension,
86
+ declination: declination,
87
+ epoch: @instant.tt
88
+ )
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def distance
95
+ @distance ||= Distance.from_parsecs(
96
+ 1 / (@parallax.degrees * Constants::ARCSECONDS_PER_DEGREE)
97
+ )
98
+ end
99
+
100
+ def unit_position_vector
101
+ @unit_position_vector ||= Vector[
102
+ @right_ascension.cos * @declination.cos,
103
+ @right_ascension.sin * @declination.cos,
104
+ @declination.sin
105
+ ]
106
+ end
107
+
108
+ def right_ascension_unit_vector
109
+ @right_ascension_unit_vector ||= Vector[
110
+ -@right_ascension.sin,
111
+ @right_ascension.cos,
112
+ 0.0
113
+ ]
114
+ end
115
+
116
+ def declination_unit_vector
117
+ @declination_unit_vector ||= Vector[
118
+ -@right_ascension.cos * @declination.sin,
119
+ -@right_ascension.sin * @declination.sin,
120
+ @declination.cos
121
+ ]
122
+ end
123
+
124
+ def initial_position_vector
125
+ @initial_position_vector ||= unit_position_vector * distance.meters
126
+ end
127
+
128
+ def tangential_velocity
129
+ @tangential_velocity ||= begin
130
+ # Doppler factor for light travel time correction
131
+ k = 1.0 / (1.0 - @radial_velocity.kmps / Velocity.light_speed.kmps)
132
+
133
+ proper_motion_ra_component =
134
+ @proper_motion_ra.mas_per_year / (
135
+ @parallax.degree_milliarcseconds * Constants::DAYS_PER_JULIAN_YEAR
136
+ ) * k
137
+ proper_motion_dec_component =
138
+ @proper_motion_dec.mas_per_year / (
139
+ @parallax.degree_milliarcseconds * Constants::DAYS_PER_JULIAN_YEAR
140
+ ) * k
141
+ radial_velocity_component = Velocity
142
+ .from_kmps(@radial_velocity.kmps * k)
143
+
144
+ Velocity.vector_from_astronomical_units_per_day([
145
+ -proper_motion_ra_component * @right_ascension.sin -
146
+ proper_motion_dec_component * @declination.sin * @right_ascension.cos +
147
+ radial_velocity_component.aupd * @declination.cos * @right_ascension.cos,
148
+ proper_motion_ra_component * @right_ascension.cos -
149
+ proper_motion_dec_component * @declination.sin * @right_ascension.sin +
150
+ radial_velocity_component.aupd * @declination.cos * @right_ascension.sin,
151
+ proper_motion_dec_component * @declination.cos +
152
+ radial_velocity_component.aupd * @declination.sin
153
+ ])
154
+ end
155
+ end
156
+
157
+ def time_elapsed_seconds
158
+ @time_elapsed_seconds ||=
159
+ (@instant.tt - @initial_epoch) * Constants::SECONDS_PER_DAY
160
+ end
161
+ end
162
+ end