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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -0
- data/.standard.yml +1 -0
- data/CHANGELOG.md +116 -0
- data/Gemfile.lock +45 -23
- data/README.md +42 -285
- data/UPGRADING.md +238 -0
- data/lib/astronoby/aberration.rb +56 -31
- data/lib/astronoby/angle.rb +20 -16
- data/lib/astronoby/angles/dms.rb +2 -2
- data/lib/astronoby/angles/hms.rb +2 -2
- data/lib/astronoby/bodies/earth.rb +56 -0
- data/lib/astronoby/bodies/jupiter.rb +11 -0
- data/lib/astronoby/bodies/mars.rb +11 -0
- data/lib/astronoby/bodies/mercury.rb +11 -0
- data/lib/astronoby/bodies/moon.rb +50 -290
- data/lib/astronoby/bodies/neptune.rb +11 -0
- data/lib/astronoby/bodies/saturn.rb +11 -0
- data/lib/astronoby/bodies/solar_system_body.rb +122 -0
- data/lib/astronoby/bodies/sun.rb +16 -220
- data/lib/astronoby/bodies/uranus.rb +11 -0
- data/lib/astronoby/bodies/venus.rb +11 -0
- data/lib/astronoby/constants.rb +13 -1
- data/lib/astronoby/coordinates/ecliptic.rb +2 -37
- data/lib/astronoby/coordinates/equatorial.rb +25 -7
- data/lib/astronoby/coordinates/horizontal.rb +0 -46
- data/lib/astronoby/corrections/light_time_delay.rb +90 -0
- data/lib/astronoby/deflection.rb +187 -0
- data/lib/astronoby/distance.rb +9 -0
- data/lib/astronoby/ephem.rb +39 -0
- data/lib/astronoby/equinox_solstice.rb +21 -18
- data/lib/astronoby/errors.rb +4 -0
- data/lib/astronoby/events/moon_phases.rb +2 -1
- data/lib/astronoby/events/rise_transit_set_calculator.rb +352 -0
- data/lib/astronoby/events/rise_transit_set_event.rb +13 -0
- data/lib/astronoby/events/rise_transit_set_events.rb +13 -0
- data/lib/astronoby/events/twilight_calculator.rb +166 -0
- data/lib/astronoby/events/twilight_event.rb +28 -0
- data/lib/astronoby/instant.rb +171 -0
- data/lib/astronoby/mean_obliquity.rb +23 -10
- data/lib/astronoby/nutation.rb +227 -42
- data/lib/astronoby/observer.rb +55 -0
- data/lib/astronoby/precession.rb +91 -17
- data/lib/astronoby/reference_frame.rb +49 -0
- data/lib/astronoby/reference_frames/apparent.rb +60 -0
- data/lib/astronoby/reference_frames/astrometric.rb +21 -0
- data/lib/astronoby/reference_frames/geometric.rb +20 -0
- data/lib/astronoby/reference_frames/mean_of_date.rb +38 -0
- data/lib/astronoby/reference_frames/topocentric.rb +82 -0
- data/lib/astronoby/true_obliquity.rb +2 -1
- data/lib/astronoby/util/maths.rb +70 -73
- data/lib/astronoby/util/time.rb +454 -31
- data/lib/astronoby/vector.rb +36 -0
- data/lib/astronoby/velocity.rb +116 -0
- data/lib/astronoby/version.rb +1 -1
- data/lib/astronoby.rb +26 -5
- metadata +61 -16
- data/.tool-versions +0 -1
- data/lib/astronoby/astronomical_models/ephemeride_lunaire_parisienne.rb +0 -143
- data/lib/astronoby/events/observation_events.rb +0 -285
- data/lib/astronoby/events/rise_transit_set_iteration.rb +0 -218
- data/lib/astronoby/events/twilight_events.rb +0 -121
- 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
|
data/lib/astronoby/distance.rb
CHANGED
@@ -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).
|
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).
|
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).
|
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).
|
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)
|
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
|
133
|
-
|
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
|
data/lib/astronoby/errors.rb
CHANGED
@@ -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
|