astronoby 0.6.0 → 0.7.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/.standard.yml +1 -0
  4. data/CHANGELOG.md +116 -0
  5. data/Gemfile.lock +45 -23
  6. data/README.md +42 -285
  7. data/UPGRADING.md +238 -0
  8. data/lib/astronoby/aberration.rb +56 -31
  9. data/lib/astronoby/angle.rb +20 -16
  10. data/lib/astronoby/angles/dms.rb +2 -2
  11. data/lib/astronoby/angles/hms.rb +2 -2
  12. data/lib/astronoby/bodies/earth.rb +56 -0
  13. data/lib/astronoby/bodies/jupiter.rb +11 -0
  14. data/lib/astronoby/bodies/mars.rb +11 -0
  15. data/lib/astronoby/bodies/mercury.rb +11 -0
  16. data/lib/astronoby/bodies/moon.rb +50 -290
  17. data/lib/astronoby/bodies/neptune.rb +11 -0
  18. data/lib/astronoby/bodies/saturn.rb +11 -0
  19. data/lib/astronoby/bodies/solar_system_body.rb +122 -0
  20. data/lib/astronoby/bodies/sun.rb +16 -220
  21. data/lib/astronoby/bodies/uranus.rb +11 -0
  22. data/lib/astronoby/bodies/venus.rb +11 -0
  23. data/lib/astronoby/constants.rb +13 -1
  24. data/lib/astronoby/coordinates/ecliptic.rb +2 -37
  25. data/lib/astronoby/coordinates/equatorial.rb +25 -7
  26. data/lib/astronoby/coordinates/horizontal.rb +0 -46
  27. data/lib/astronoby/corrections/light_time_delay.rb +90 -0
  28. data/lib/astronoby/deflection.rb +187 -0
  29. data/lib/astronoby/distance.rb +9 -0
  30. data/lib/astronoby/ephem.rb +39 -0
  31. data/lib/astronoby/equinox_solstice.rb +21 -18
  32. data/lib/astronoby/errors.rb +4 -0
  33. data/lib/astronoby/events/moon_phases.rb +2 -1
  34. data/lib/astronoby/events/rise_transit_set_calculator.rb +352 -0
  35. data/lib/astronoby/events/rise_transit_set_event.rb +13 -0
  36. data/lib/astronoby/events/rise_transit_set_events.rb +13 -0
  37. data/lib/astronoby/events/twilight_calculator.rb +166 -0
  38. data/lib/astronoby/events/twilight_event.rb +28 -0
  39. data/lib/astronoby/instant.rb +171 -0
  40. data/lib/astronoby/mean_obliquity.rb +23 -10
  41. data/lib/astronoby/nutation.rb +227 -42
  42. data/lib/astronoby/observer.rb +55 -0
  43. data/lib/astronoby/precession.rb +91 -17
  44. data/lib/astronoby/reference_frame.rb +49 -0
  45. data/lib/astronoby/reference_frames/apparent.rb +60 -0
  46. data/lib/astronoby/reference_frames/astrometric.rb +21 -0
  47. data/lib/astronoby/reference_frames/geometric.rb +20 -0
  48. data/lib/astronoby/reference_frames/mean_of_date.rb +38 -0
  49. data/lib/astronoby/reference_frames/topocentric.rb +82 -0
  50. data/lib/astronoby/true_obliquity.rb +2 -1
  51. data/lib/astronoby/util/maths.rb +70 -73
  52. data/lib/astronoby/util/time.rb +454 -31
  53. data/lib/astronoby/vector.rb +36 -0
  54. data/lib/astronoby/velocity.rb +116 -0
  55. data/lib/astronoby/version.rb +1 -1
  56. data/lib/astronoby.rb +26 -5
  57. metadata +61 -16
  58. data/.tool-versions +0 -1
  59. data/lib/astronoby/astronomical_models/ephemeride_lunaire_parisienne.rb +0 -143
  60. data/lib/astronoby/events/observation_events.rb +0 -285
  61. data/lib/astronoby/events/rise_transit_set_iteration.rb +0 -218
  62. data/lib/astronoby/events/twilight_events.rb +0 -121
  63. data/lib/astronoby/util/astrodynamics.rb +0 -60
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astronoby
4
+ class Deflection
5
+ # Solar gravitational constant (m^3/s^2)
6
+ SOLAR_GRAVITATION_CONSTANT = 1.32712440017987e+20
7
+
8
+ # @param instant [Astronoby::Instant] the instant of the observation
9
+ # @param target_astrometric_position [Astronoby::Vector<Distance>] the
10
+ # astrometric position of the target
11
+ # @param ephem [Astronoby::Ephemeris] the ephemeris to use for the
12
+ # computation
13
+ def initialize(instant:, target_astrometric_position:, ephem:)
14
+ @instant = instant
15
+ @target_astrometric_position = target_astrometric_position
16
+ @ephem = ephem
17
+ end
18
+
19
+ # @return [Astronoby::Vector<Distance>] corrected position of the target
20
+ def corrected_position
21
+ Astronoby::Vector[
22
+ *(target_position + deflection_vector)
23
+ .map { |au| Astronoby::Distance.from_au(au) }
24
+ ]
25
+ end
26
+
27
+ private
28
+
29
+ def tt
30
+ @tt ||= @instant.tt
31
+ end
32
+
33
+ def earth_geometric
34
+ Earth.compute_geometric(ephem: @ephem, instant: @instant)
35
+ end
36
+
37
+ def sun_geometric
38
+ Sun.compute_geometric(ephem: @ephem, instant: @instant)
39
+ end
40
+
41
+ def observer_position
42
+ @observer_position ||= earth_geometric.position.map(&:au)
43
+ end
44
+
45
+ def target_position
46
+ @target_position ||= @target_astrometric_position.map(&:au)
47
+ end
48
+
49
+ def sun_position
50
+ @sun_position ||= sun_geometric.position.map(&:au)
51
+ end
52
+
53
+ def light_travel_time
54
+ @light_travel_time ||=
55
+ target_position.magnitude / Astronoby::Velocity.light_speed.aupd
56
+ end
57
+
58
+ def deflector_to_observer_position
59
+ sun_position - observer_position
60
+ end
61
+
62
+ def closest_deflector_to_target_position
63
+ observer_position + target_position - deflector_position_at_closest
64
+ end
65
+
66
+ def closest_deflector_to_observer_position
67
+ @closest_deflector_to_observer_position ||=
68
+ observer_position - deflector_position_at_closest
69
+ end
70
+
71
+ # Compute light-time difference for the point where light ray is closest to
72
+ # deflector
73
+ def closest_approach_time_diff
74
+ @closest_approach_time_diff ||=
75
+ Util::Maths.dot_product(
76
+ target_position / target_position.magnitude,
77
+ deflector_to_observer_position
78
+ ) / Astronoby::Velocity.light_speed.aupd
79
+ end
80
+
81
+ # Determine time when incoming photons were closest to the deflecting body
82
+ def time_at_closest_approach
83
+ @time_at_closest_approach ||= if closest_approach_time_diff > 0.0
84
+ tt - closest_approach_time_diff
85
+ elsif light_travel_time < closest_approach_time_diff
86
+ tt - light_travel_time
87
+ else
88
+ tt
89
+ end
90
+ end
91
+
92
+ # Get position of deflecting body at the time of closest approach
93
+ def deflector_position_at_closest
94
+ @deflector_position_at_closest ||=
95
+ Sun.compute_geometric(
96
+ ephem: @ephem,
97
+ instant: Instant.from_terrestrial_time(time_at_closest_approach)
98
+ ).position.map(&:au)
99
+ end
100
+
101
+ def observer_to_target_distance
102
+ @observer_to_target_distance ||= target_position.magnitude
103
+ end
104
+
105
+ def closest_deflector_to_target_distance
106
+ @closest_deflector_to_target_distance ||=
107
+ closest_deflector_to_target_position.magnitude
108
+ end
109
+
110
+ def closest_deflector_to_observer_distance
111
+ @closest_deflector_to_observer_distance ||=
112
+ closest_deflector_to_observer_position.magnitude
113
+ end
114
+
115
+ def observer_to_target_unit
116
+ @observer_to_target_unit ||=
117
+ target_position / observer_to_target_distance
118
+ end
119
+
120
+ def closest_deflector_to_target_unit
121
+ @closest_deflector_to_target_unit ||=
122
+ closest_deflector_to_target_position / closest_deflector_to_target_distance
123
+ end
124
+
125
+ def closest_deflector_to_observer_unit
126
+ @closest_deflector_to_observer_unit ||=
127
+ closest_deflector_to_observer_position / closest_deflector_to_observer_distance
128
+ end
129
+
130
+ def cos_angle_target
131
+ Util::Maths.dot_product(
132
+ observer_to_target_unit,
133
+ closest_deflector_to_target_unit
134
+ )
135
+ end
136
+
137
+ def cos_angle_deflector
138
+ Util::Maths.dot_product(
139
+ closest_deflector_to_target_unit,
140
+ closest_deflector_to_observer_unit
141
+ )
142
+ end
143
+
144
+ def cos_angle_observer
145
+ @cos_angle_observer ||= Util::Maths.dot_product(
146
+ closest_deflector_to_observer_unit,
147
+ observer_to_target_unit
148
+ )
149
+ end
150
+
151
+ # Calculate the relativistic deflection coefficient
152
+ # This implements Einstein's light deflection formula: α = 4GM/(c²r)
153
+ # where:
154
+ # - G is the gravitational constant
155
+ # - M is the mass of the deflecting body
156
+ # - c is the speed of light
157
+ # - r is the impact parameter (closest approach distance)
158
+ def relativistic_factor
159
+ 2.0 * SOLAR_GRAVITATION_CONSTANT / (
160
+ Astronoby::Velocity.light_speed.mps *
161
+ Astronoby::Velocity.light_speed.mps *
162
+ closest_deflector_to_observer_distance *
163
+ Constants::ASTRONOMICAL_UNIT_IN_METERS
164
+ )
165
+ end
166
+
167
+ def geometry_factor
168
+ 1.0 + cos_angle_deflector
169
+ end
170
+
171
+ def deflection_vector
172
+ return 0 if colinear?
173
+
174
+ relativistic_factor *
175
+ (
176
+ cos_angle_target * closest_deflector_to_observer_unit -
177
+ cos_angle_observer * closest_deflector_to_target_unit
178
+ ) / geometry_factor * observer_to_target_distance
179
+ end
180
+
181
+ # If deflector is nearly in line with target, make no correction
182
+ # (avoids numerical instability in nearly-collinear cases)
183
+ def colinear?
184
+ cos_angle_observer.abs > 0.99999999999
185
+ end
186
+ end
187
+ end
@@ -25,6 +25,11 @@ module Astronoby
25
25
  from_meters(meters)
26
26
  end
27
27
  alias_method :from_au, :from_astronomical_units
28
+
29
+ def vector_from_meters(array)
30
+ Vector.elements(array.map { from_meters(_1) })
31
+ end
32
+ alias_method :vector_from_m, :vector_from_meters
28
33
  end
29
34
 
30
35
  attr_reader :meters
@@ -69,6 +74,10 @@ module Astronoby
69
74
  meters.zero?
70
75
  end
71
76
 
77
+ def abs2
78
+ meters**2
79
+ end
80
+
72
81
  def hash
73
82
  [meters, self.class].hash
74
83
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ephem"
4
+
5
+ module Astronoby
6
+ class Ephem
7
+ # Download an ephemeris file.
8
+ #
9
+ # @param name [String] Name of the ephemeris file, supported by the Ephem
10
+ # gem
11
+ # @param target [String] Location where to store the file
12
+ # @return [Boolean] true if the download was successful, false otherwise
13
+ #
14
+ # @example Downloading de440t SPK from NASA JPL
15
+ # Astronoby::Ephem.download(name: "de440t.bsp", target: "tmp/de440t.bsp")
16
+ def self.download(name:, target:)
17
+ ::Ephem::Download.call(name: name, target: target)
18
+ end
19
+
20
+ # Load an ephemeris file.
21
+ #
22
+ # @param target [String] Path of the ephemeris file
23
+ # @return [::Ephem::SPK] Ephemeris object from the Ephem gem
24
+ #
25
+ # @example Loading previously downloaded de440t SPK from NASA JPL
26
+ # Astronoby::Ephem.load("tmp/de440t.bsp")
27
+ def self.load(target)
28
+ spk = ::Ephem::SPK.open(target)
29
+ unless ::Ephem::SPK::TYPES.include?(spk&.type)
30
+ raise(
31
+ EphemerisError,
32
+ "#{target} is not a valid type. Accepted: #{::Ephem::SPK::TYPES.join(", ")}"
33
+ )
34
+ end
35
+
36
+ spk
37
+ end
38
+ end
39
+ end
@@ -73,23 +73,23 @@ module Astronoby
73
73
  [8, 15.45, 16859.074]
74
74
  ].freeze
75
75
 
76
- def self.march_equinox(year)
77
- new(year, MARCH_EQUINOX).compute
76
+ def self.march_equinox(year, ephem)
77
+ new(year, MARCH_EQUINOX, ephem).time
78
78
  end
79
79
 
80
- def self.june_solstice(year)
81
- new(year, JUNE_SOLSTICE).compute
80
+ def self.june_solstice(year, ephem)
81
+ new(year, JUNE_SOLSTICE, ephem).time
82
82
  end
83
83
 
84
- def self.september_equinox(year)
85
- new(year, SEPTEMBER_EQUINOX).compute
84
+ def self.september_equinox(year, ephem)
85
+ new(year, SEPTEMBER_EQUINOX, ephem).time
86
86
  end
87
87
 
88
- def self.december_solstice(year)
89
- new(year, DECEMBER_SOLSTICE).compute
88
+ def self.december_solstice(year, ephem)
89
+ new(year, DECEMBER_SOLSTICE, ephem).time
90
90
  end
91
91
 
92
- def initialize(year, event)
92
+ def initialize(year, event, ephem)
93
93
  unless EVENTS.include?(event)
94
94
  raise UnsupportedEventError.new(
95
95
  "Expected a format between #{EVENTS.join(", ")}, got #{event}"
@@ -98,8 +98,17 @@ module Astronoby
98
98
 
99
99
  @event = event
100
100
  @year = (year.to_i - 2000) / 1000.0
101
+ uncorrected_time = compute
102
+ @instant = Instant.from_time(uncorrected_time)
103
+ @sun = Sun.new(ephem: ephem, instant: @instant)
101
104
  end
102
105
 
106
+ def time
107
+ Instant.from_terrestrial_time(@instant.tt + corrected).to_time.round
108
+ end
109
+
110
+ private
111
+
103
112
  def compute
104
113
  t = (julian_day - Epoch::J2000) / Constants::DAYS_PER_JULIAN_CENTURY
105
114
  w = Angle.from_degrees(35999.373 * t) - Angle.from_degrees(2.47)
@@ -113,13 +122,10 @@ module Astronoby
113
122
 
114
123
  delta_days = 0.00001 * s / delta
115
124
  epoch = julian_day + delta_days
116
- epoch += correction(epoch)
117
125
 
118
- Epoch.to_utc(epoch).round
126
+ Epoch.to_utc(epoch)
119
127
  end
120
128
 
121
- private
122
-
123
129
  def julian_day
124
130
  component = JDE_COMPONENTS[@event]
125
131
  component[0] +
@@ -129,11 +135,8 @@ module Astronoby
129
135
  component[4] * @year**4
130
136
  end
131
137
 
132
- def correction(epoch)
133
- time = Epoch.to_utc(epoch)
134
- sun = Sun.new(time: time)
135
- longitude = sun.apparent_ecliptic_coordinates.longitude
136
-
138
+ def corrected
139
+ longitude = @sun.apparent.ecliptic.longitude
137
140
  58 * Angle.from_degrees(@event * 90 - longitude.degrees).sin
138
141
  end
139
142
  end
@@ -4,4 +4,8 @@ module Astronoby
4
4
  class UnsupportedFormatError < ArgumentError; end
5
5
 
6
6
  class UnsupportedEventError < ArgumentError; end
7
+
8
+ class CalculationError < StandardError; end
9
+
10
+ class EphemerisError < StandardError; end
7
11
  end
@@ -23,7 +23,8 @@ module Astronoby
23
23
  MoonPhase.first_quarter(new(year, month, :first_quarter, 0.25).time),
24
24
  MoonPhase.full_moon(new(year, month, :full_moon, 0.5).time),
25
25
  MoonPhase.last_quarter(new(year, month, :last_quarter, 0.75).time),
26
- MoonPhase.new_moon(new(year, month, :new_moon, 1).time)
26
+ MoonPhase.new_moon(new(year, month, :new_moon, 1).time),
27
+ MoonPhase.first_quarter(new(year, month, :first_quarter, 1.25).time)
27
28
  ].select { _1.time.month == month }
28
29
  end
29
30
 
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astronoby
4
+ class RiseTransitSetCalculator
5
+ class PotentialEvent
6
+ attr_reader :hour_angle, :can_occur
7
+
8
+ def initialize(hour_angle, can_occur)
9
+ @hour_angle = hour_angle
10
+ @can_occur = can_occur
11
+ end
12
+
13
+ def negated
14
+ self.class.new(-@hour_angle, @can_occur)
15
+ end
16
+ end
17
+
18
+ TAU = Math::PI * 2
19
+ SAMPLE_THRESHOLD = 0.8
20
+ REFINEMENT_ITERATIONS = 3
21
+ MIN_TIME_ADJUSTMENT = Constants::MICROSECOND_IN_DAYS
22
+ STANDARD_REFRACTION_ANGLE = -Angle.from_dms(0, 34, 0)
23
+ SUN_REFRACTION_ANGLE = -Angle.from_dms(0, 50, 0)
24
+ EVENT_TYPES = [:rising, :transit, :setting].freeze
25
+
26
+ def initialize(body:, observer:, ephem:)
27
+ @body = body
28
+ @observer = observer
29
+ @ephem = ephem
30
+ end
31
+
32
+ def event_on(date, utc_offset: 0)
33
+ events = events_on(date, utc_offset: utc_offset)
34
+ RiseTransitSetEvent.new(
35
+ events.rising_times.first,
36
+ events.transit_times.first,
37
+ events.setting_times.first
38
+ )
39
+ end
40
+
41
+ def events_on(date, utc_offset: 0)
42
+ start_time = Time.new(
43
+ date.year,
44
+ date.month,
45
+ date.day,
46
+ 0, 0, 0, utc_offset
47
+ )
48
+ end_time = Time.new(
49
+ date.year,
50
+ date.month,
51
+ date.day,
52
+ 23, 59, 59, utc_offset
53
+ )
54
+ events_between(start_time, end_time)
55
+ end
56
+
57
+ def events_between(start_time, end_time)
58
+ reset_state
59
+ @start_instant = Instant.from_time(start_time)
60
+ @end_instant = Instant.from_time(end_time)
61
+ events
62
+ end
63
+
64
+ private
65
+
66
+ def events
67
+ rising_events = calculate_initial_positions.map do |position|
68
+ calculate_rising_event(position)
69
+ end
70
+ setting_events = rising_events.map(&:negated)
71
+ transit_event = PotentialEvent.new(Angle.zero, true)
72
+
73
+ event_data = {
74
+ rising: rising_events,
75
+ transit: [transit_event],
76
+ setting: setting_events
77
+ }
78
+
79
+ results = EVENT_TYPES.each_with_object({}) do |event_type, results|
80
+ results[event_type] = calculate_event_times(
81
+ event_type,
82
+ event_data[event_type]
83
+ )
84
+ end
85
+
86
+ RiseTransitSetEvents.new(
87
+ results[:rising],
88
+ results[:transit],
89
+ results[:setting]
90
+ )
91
+ end
92
+
93
+ def calculate_rising_event(position)
94
+ declination = position.equatorial.declination
95
+ latitude = @observer.latitude
96
+ altitude = horizon_angle(position.distance)
97
+
98
+ # Calculate the unmodified ratio to check if the body rises/sets
99
+ numerator = altitude.sin - latitude.sin * declination.sin
100
+ denominator = latitude.cos * declination.cos
101
+ ratio = numerator / denominator
102
+
103
+ # Determine if the body can rise/set and calculate the hour angle
104
+ can_rise_set = ratio.abs <= 1.0
105
+ angle = can_rise_set ? -Angle.acos(ratio.clamp(-1.0, 1.0)) : Angle.zero
106
+
107
+ PotentialEvent.new(angle, can_rise_set)
108
+ end
109
+
110
+ def calculate_event_times(event_type, events)
111
+ desired_hour_angles = events.map(&:hour_angle)
112
+
113
+ # Calculate differences between current and desired hour angles
114
+ angle_differences = calculate_angle_differences(
115
+ calculate_initial_hour_angles,
116
+ desired_hour_angles
117
+ )
118
+
119
+ # Find intervals where the body crosses the desired hour angle
120
+ crossing_indexes = find_crossing_intervals(angle_differences)
121
+
122
+ # Skip if no relevant crossing points found
123
+ return [] if crossing_indexes.empty?
124
+
125
+ valid_crossings = if events.size == 1
126
+ # For transit (single event), all crossings are valid
127
+ crossing_indexes.map { true }
128
+ else
129
+ # For rise/set, check if each crossing corresponds to a valid event
130
+ crossing_indexes.map { |i| events[i].can_occur }
131
+ end
132
+
133
+ old_hour_angles = calculate_initial_hour_angles
134
+ .values_at(*crossing_indexes)
135
+ old_instants = sample_instants.values_at(*crossing_indexes)
136
+
137
+ # Initial estimate of event times using linear interpolation
138
+ new_instants = interpolate_initial_times(
139
+ crossing_indexes,
140
+ angle_differences
141
+ )
142
+
143
+ # Refine the estimates through iteration
144
+ refined_times = refine_time_estimates(
145
+ event_type,
146
+ new_instants,
147
+ old_hour_angles,
148
+ old_instants
149
+ )
150
+
151
+ # Filter out times for bodies that never rise/set
152
+ refined_times
153
+ .zip(valid_crossings)
154
+ .filter_map { |time, valid| time if valid }
155
+ end
156
+
157
+ def sample_count
158
+ @sample_count ||=
159
+ ((@end_instant.tt - @start_instant.tt) / SAMPLE_THRESHOLD).ceil + 1
160
+ end
161
+
162
+ def sample_instants
163
+ @sample_instants ||= Util::Maths.linspace(
164
+ @start_instant.tt,
165
+ @end_instant.tt,
166
+ sample_count
167
+ ).map { |tt| Instant.from_terrestrial_time(tt) }
168
+ end
169
+
170
+ def calculate_initial_positions
171
+ @initial_positions ||= calculate_positions_at_instants(sample_instants)
172
+ end
173
+
174
+ def calculate_initial_hour_angles
175
+ @initial_hour_angles ||=
176
+ calculate_hour_angles(calculate_initial_positions)
177
+ end
178
+
179
+ def calculate_angle_differences(hour_angles, angles)
180
+ hour_angles.each_with_index.map do |hour_angle, i|
181
+ angle = (angles.size == 1) ? angles[0] : angles[i]
182
+ Angle.from_radians((angle - hour_angle).radians % TAU)
183
+ end
184
+ end
185
+
186
+ def find_crossing_intervals(differences)
187
+ differences
188
+ .each_cons(2)
189
+ .map { |a, b| b - a }
190
+ .each_with_index
191
+ .filter_map { |diff, i| i if diff.radians > 0.0 }
192
+ end
193
+
194
+ def interpolate_initial_times(crossing_indexes, angle_differences)
195
+ crossing_indexes.map do |index|
196
+ a = angle_differences[index].radians
197
+ b = TAU - angle_differences[index + 1].radians
198
+ c = sample_instants[index].tt
199
+ d = sample_instants[index + 1].tt
200
+
201
+ # Linear interpolation formula
202
+ tt = (b * c + a * d) / (a + b)
203
+ Instant.from_terrestrial_time(tt)
204
+ end
205
+ end
206
+
207
+ def refine_time_estimates(
208
+ event_type,
209
+ new_instants,
210
+ old_hour_angles,
211
+ old_instants
212
+ )
213
+ REFINEMENT_ITERATIONS.times do |iteration|
214
+ # Calculate positions at current estimates
215
+ apparent_positions = calculate_positions_at_instants(new_instants)
216
+
217
+ # Calculate current hour angles
218
+ current_hour_angles = calculate_hour_angles(apparent_positions)
219
+
220
+ # Calculate desired hour angles based on current positions
221
+ desired_hour_angles = calculate_desired_hour_angles(
222
+ event_type,
223
+ apparent_positions
224
+ )
225
+
226
+ # Calculate hour angle adjustments
227
+ hour_angle_adjustments = calculate_hour_angle_adjustments(
228
+ current_hour_angles,
229
+ desired_hour_angles
230
+ )
231
+
232
+ # Calculate hour angle changes for rate determination
233
+ hour_angle_changes = calculate_hour_angle_changes(
234
+ current_hour_angles,
235
+ old_hour_angles,
236
+ iteration
237
+ )
238
+
239
+ # Calculate time differences
240
+ time_differences = new_instants.each_with_index.map do |instant, i|
241
+ Instant.from_terrestrial_time(instant.tt - old_instants[i].tt)
242
+ end
243
+
244
+ # Calculate hour angle rate (radians per day)
245
+ hour_angle_rates = hour_angle_changes.each_with_index.map do |angle, i|
246
+ angle.radians / time_differences[i].tt
247
+ end
248
+
249
+ # Store current values for next iteration
250
+ old_hour_angles = current_hour_angles
251
+ old_instants = new_instants
252
+
253
+ # Calculate time adjustments
254
+ time_adjustments = hour_angle_adjustments
255
+ .each_with_index
256
+ .map do |angle, i|
257
+ ratio = angle.radians / hour_angle_rates[i]
258
+ [ratio.nan? ? 0 : ratio, MIN_TIME_ADJUSTMENT].max
259
+ end
260
+
261
+ # Apply time adjustments
262
+ new_instants = new_instants.each_with_index.map do |instant, i|
263
+ Instant.from_terrestrial_time(instant.tt + time_adjustments[i])
264
+ end
265
+ end
266
+
267
+ new_instants.map { _1.to_time.round }
268
+ end
269
+
270
+ def calculate_positions_at_instants(instants)
271
+ instants.map do |instant|
272
+ @body
273
+ .new(instant: instant, ephem: @ephem)
274
+ .observed_by(@observer)
275
+ end
276
+ end
277
+
278
+ def calculate_hour_angles(positions)
279
+ positions.map do |position|
280
+ position.equatorial.compute_hour_angle(
281
+ time: position.instant.to_time,
282
+ longitude: @observer.longitude
283
+ )
284
+ end
285
+ end
286
+
287
+ def calculate_desired_hour_angles(event_type, positions)
288
+ positions.map do |position|
289
+ if event_type == :transit
290
+ Angle.zero
291
+ else
292
+ declination = position.equatorial.declination
293
+ ha = rising_hour_angle(
294
+ @observer.latitude,
295
+ declination,
296
+ horizon_angle(position.distance)
297
+ )
298
+ (event_type == :rising) ? ha : -ha
299
+ end
300
+ end
301
+ end
302
+
303
+ def calculate_hour_angle_adjustments(current_angles, angles)
304
+ current_angles.each_with_index.map do |angle, i|
305
+ radians = ((angles[i] - angle).radians + Math::PI) % TAU - Math::PI
306
+ Angle.from_radians(radians)
307
+ end
308
+ end
309
+
310
+ def calculate_hour_angle_changes(current_angles, old_angles, iteration)
311
+ current_angles.each_with_index.map do |angle, i|
312
+ radians = angle.radians - old_angles[i].radians
313
+
314
+ if iteration == 0
315
+ radians %= TAU
316
+ else
317
+ radians = (radians + Math::PI) % TAU - Math::PI
318
+ end
319
+
320
+ Angle.from_radians(radians)
321
+ end
322
+ end
323
+
324
+ def rising_hour_angle(latitude, declination, altitude)
325
+ numerator = altitude.sin - latitude.sin * declination.sin
326
+ denominator = latitude.cos * declination.cos
327
+ ratio = (numerator / denominator).clamp(-1.0, 1.0)
328
+ -Angle.acos(ratio)
329
+ end
330
+
331
+ def horizon_angle(distance)
332
+ case @body.name
333
+ when "Astronoby::Sun"
334
+ SUN_REFRACTION_ANGLE
335
+ when "Astronoby::Moon"
336
+ STANDARD_REFRACTION_ANGLE -
337
+ Angle.from_radians(Moon::EQUATORIAL_RADIUS.m / distance.m)
338
+ else
339
+ STANDARD_REFRACTION_ANGLE
340
+ end
341
+ end
342
+
343
+ def reset_state
344
+ @initial_hour_angles = nil
345
+ @initial_positions = nil
346
+ @sample_count = nil
347
+ @sample_instants = nil
348
+ @start_instant = nil
349
+ @end_instant = nil
350
+ end
351
+ end
352
+ end