astronoby 0.9.0 → 0.11.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 -1
- data/CHANGELOG.md +235 -0
- data/README.md +19 -1
- data/UPGRADING.md +270 -0
- data/docs/README.md +105 -16
- data/docs/altitude_crossings.md +92 -0
- data/docs/angles.md +1 -0
- data/docs/configuration.md +28 -17
- data/docs/coordinates.md +72 -12
- data/docs/deep_sky_bodies.md +46 -1
- data/docs/ephem.md +5 -2
- data/docs/equinoxes_solstices_times.md +4 -3
- data/docs/glossary.md +99 -1
- data/docs/iers.md +40 -0
- data/docs/instant.md +33 -21
- data/docs/lunar_eclipses.md +208 -0
- data/docs/lunar_observation.md +87 -0
- data/docs/moon_phases.md +4 -1
- data/docs/observer.md +20 -6
- data/docs/planetary_phenomena.md +78 -0
- data/docs/reference_frames.md +192 -34
- data/docs/rise_transit_set_times.md +6 -4
- data/docs/solar_system_bodies.md +26 -4
- data/docs/twilight_times.md +43 -24
- data/lib/astronoby/angle.rb +63 -2
- data/lib/astronoby/angles/dms.rb +18 -1
- data/lib/astronoby/angles/hms.rb +14 -1
- data/lib/astronoby/angular_velocity.rb +21 -0
- data/lib/astronoby/bodies/deep_sky_object.rb +6 -1
- data/lib/astronoby/bodies/deep_sky_object_position.rb +35 -38
- data/lib/astronoby/bodies/earth.rb +7 -44
- data/lib/astronoby/bodies/jupiter.rb +10 -0
- data/lib/astronoby/bodies/mars.rb +10 -0
- data/lib/astronoby/bodies/mercury.rb +10 -0
- data/lib/astronoby/bodies/moon.rb +158 -32
- data/lib/astronoby/bodies/neptune.rb +10 -0
- data/lib/astronoby/bodies/saturn.rb +10 -0
- data/lib/astronoby/bodies/solar_system_body.rb +240 -61
- data/lib/astronoby/bodies/sun.rb +79 -4
- data/lib/astronoby/bodies/uranus.rb +10 -0
- data/lib/astronoby/bodies/venus.rb +10 -0
- data/lib/astronoby/body.rb +6 -0
- data/lib/astronoby/cache.rb +13 -0
- data/lib/astronoby/center.rb +84 -0
- data/lib/astronoby/constants.rb +1 -2
- data/lib/astronoby/constellation.rb +9 -1
- data/lib/astronoby/constellations/data.rb +1 -1
- data/lib/astronoby/coordinates/ecliptic.rb +10 -1
- data/lib/astronoby/coordinates/equatorial.rb +79 -13
- data/lib/astronoby/coordinates/geodetic.rb +102 -0
- data/lib/astronoby/coordinates/horizontal.rb +13 -1
- data/lib/astronoby/delta_t.rb +44 -0
- data/lib/astronoby/distance.rb +35 -0
- data/lib/astronoby/duration.rb +116 -0
- data/lib/astronoby/earth_rotation.rb +70 -0
- data/lib/astronoby/equinox_solstice.rb +31 -8
- data/lib/astronoby/errors.rb +11 -0
- data/lib/astronoby/events/altitude_crossing.rb +29 -0
- data/lib/astronoby/events/altitude_crossing_calculator.rb +511 -0
- data/lib/astronoby/events/altitude_extreme.rb +31 -0
- data/lib/astronoby/events/conjunction.rb +51 -0
- data/lib/astronoby/events/conjunction_opposition_calculator.rb +84 -0
- data/lib/astronoby/events/extremum_calculator.rb +23 -176
- data/lib/astronoby/events/greatest_elongation.rb +58 -0
- data/lib/astronoby/events/greatest_elongation_calculator.rb +56 -0
- data/lib/astronoby/events/lunar_eclipse.rb +111 -0
- data/lib/astronoby/events/lunar_eclipse_calculator.rb +397 -0
- data/lib/astronoby/events/lunar_eclipse_geometry.rb +115 -0
- data/lib/astronoby/events/lunar_eclipse_phase.rb +45 -0
- data/lib/astronoby/events/lunar_eclipse_visibility.rb +296 -0
- data/lib/astronoby/events/opposition.rb +19 -0
- data/lib/astronoby/events/rise_transit_set_calculator.rb +9 -13
- data/lib/astronoby/events/rise_transit_set_event.rb +12 -1
- data/lib/astronoby/events/rise_transit_set_events.rb +12 -1
- data/lib/astronoby/events/twilight_calculator.rb +37 -147
- data/lib/astronoby/events/twilight_event.rb +24 -6
- data/lib/astronoby/events/twilight_events.rb +26 -6
- data/lib/astronoby/extremum_finder.rb +148 -0
- data/lib/astronoby/horizon.rb +40 -0
- data/lib/astronoby/horizontal_transformation.rb +112 -0
- data/lib/astronoby/instant.rb +56 -16
- data/lib/astronoby/libration.rb +25 -0
- data/lib/astronoby/mean_obliquity.rb +8 -0
- data/lib/astronoby/moon_orientation_ephemeris.rb +69 -0
- data/lib/astronoby/moon_physical_ephemeris.rb +263 -0
- data/lib/astronoby/nutation.rb +10 -20
- data/lib/astronoby/observer.rb +67 -49
- data/lib/astronoby/orientation.rb +107 -0
- data/lib/astronoby/position.rb +16 -0
- data/lib/astronoby/precession.rb +62 -61
- data/lib/astronoby/reference_frame.rb +80 -9
- data/lib/astronoby/reference_frames/apparent.rb +43 -12
- data/lib/astronoby/reference_frames/astrometric.rb +14 -1
- data/lib/astronoby/reference_frames/geometric.rb +7 -1
- data/lib/astronoby/reference_frames/mean_of_date.rb +19 -1
- data/lib/astronoby/reference_frames/teme.rb +159 -0
- data/lib/astronoby/reference_frames/topocentric.rb +36 -4
- data/lib/astronoby/refraction.rb +26 -5
- data/lib/astronoby/root_finder.rb +90 -0
- data/lib/astronoby/rotation.rb +49 -0
- data/lib/astronoby/time/greenwich_apparent_sidereal_time.rb +19 -8
- data/lib/astronoby/time/greenwich_mean_sidereal_time.rb +44 -5
- data/lib/astronoby/time/greenwich_sidereal_time.rb +21 -0
- data/lib/astronoby/time/local_apparent_sidereal_time.rb +21 -0
- data/lib/astronoby/time/local_mean_sidereal_time.rb +21 -0
- data/lib/astronoby/time/local_sidereal_time.rb +24 -0
- data/lib/astronoby/time/sidereal_time.rb +23 -1
- data/lib/astronoby/true_obliquity.rb +4 -0
- data/lib/astronoby/util/maths.rb +8 -0
- data/lib/astronoby/util/time.rb +0 -497
- data/lib/astronoby/vector.rb +10 -0
- data/lib/astronoby/velocity.rb +39 -0
- data/lib/astronoby/version.rb +1 -1
- data/lib/astronoby.rb +30 -0
- metadata +80 -5
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astronoby
|
|
4
|
+
# Finds when a body reaches a given altitude above the observer's horizon.
|
|
5
|
+
#
|
|
6
|
+
# The window is split at the body's culminations, since between a lower and
|
|
7
|
+
# the next upper culmination the altitude only goes one way. A segment whose
|
|
8
|
+
# ends lie on opposite sides of the target holds exactly one crossing, which
|
|
9
|
+
# is located on the real altitude. A segment where the target is within
|
|
10
|
+
# {GRAZING_MARGIN} of an end could hold two crossings or none, so it is
|
|
11
|
+
# sampled and bisected instead.
|
|
12
|
+
#
|
|
13
|
+
# The calculator holds no state between calls: every call searches with its
|
|
14
|
+
# own cache of evaluated positions, so one instance can be shared by threads.
|
|
15
|
+
class AltitudeCrossingCalculator
|
|
16
|
+
REFERENCES = [:center, :upper_limb, :lower_limb].freeze
|
|
17
|
+
DIRECTIONS = [:rising, :setting].freeze
|
|
18
|
+
|
|
19
|
+
TAU = Math::PI * 2
|
|
20
|
+
SIDEREAL_RATE = TAU * 1.00273790935
|
|
21
|
+
|
|
22
|
+
# A culmination is at most about 0.52 days after the previous one (the
|
|
23
|
+
# Moon's), so this much on each side of the window brackets its ends.
|
|
24
|
+
CULMINATION_PADDING_IN_DAYS = 0.6
|
|
25
|
+
|
|
26
|
+
ALTITUDE_TOLERANCE = Angle.from_degrees(1e-5).radians
|
|
27
|
+
# A Float Julian Date resolves about 47 µs, or 3.4e-9 rad of hour angle,
|
|
28
|
+
# so a tighter tolerance could never be met.
|
|
29
|
+
HOUR_ANGLE_TOLERANCE = 1e-8
|
|
30
|
+
# The ends of a segment only have to be close enough to the culminations
|
|
31
|
+
# for the altitude there to be within GRAZING_MARGIN of the extremum, which
|
|
32
|
+
# a hundred microradians of hour angle is, except for a body passing within
|
|
33
|
+
# a few thousandths of a degree of the zenith.
|
|
34
|
+
SEGMENT_HOUR_ANGLE_TOLERANCE = 1e-4
|
|
35
|
+
GRAZING_MARGIN = Angle.from_degrees(0.05).radians
|
|
36
|
+
# A Newton step from an altitude this close to the target lands within
|
|
37
|
+
# {ALTITUDE_TOLERANCE} of it, for a slope that is off by up to 2%, so the
|
|
38
|
+
# step is returned without evaluating the body there.
|
|
39
|
+
NEWTON_ACCEPT = Angle.from_degrees(5e-4).radians
|
|
40
|
+
MAX_ITERATIONS = 30
|
|
41
|
+
|
|
42
|
+
FALLBACK_SAMPLES_PER_DAY = 288
|
|
43
|
+
DIRECTION_PROBE_IN_DAYS = 60.0 / Constants::SECONDS_PER_DAY
|
|
44
|
+
DUPLICATE_THRESHOLD_IN_DAYS = 1e-6
|
|
45
|
+
|
|
46
|
+
Sample = Struct.new(:hour_angle, :declination, :altitude)
|
|
47
|
+
Culmination = Struct.new(:kind, :tt)
|
|
48
|
+
Crossing = Struct.new(:tt, :direction, :grazing)
|
|
49
|
+
|
|
50
|
+
# @param body [Class, Object] celestial body to track
|
|
51
|
+
# @param observer [Astronoby::Observer] observer location
|
|
52
|
+
# @param ephem [::Ephem::SPK, nil] ephemeris data source
|
|
53
|
+
# @param refraction [Boolean] whether to apply atmospheric refraction to
|
|
54
|
+
# the altitude, false by default as in the twilight convention
|
|
55
|
+
# @param reference [Symbol] the point of the body that reaches the target:
|
|
56
|
+
# +:center+, +:upper_limb+ or +:lower_limb+, the limbs using the body's
|
|
57
|
+
# true semi-diameter
|
|
58
|
+
def initialize(
|
|
59
|
+
body:,
|
|
60
|
+
observer:,
|
|
61
|
+
ephem: nil,
|
|
62
|
+
refraction: false,
|
|
63
|
+
reference: :center
|
|
64
|
+
)
|
|
65
|
+
unless REFERENCES.include?(reference)
|
|
66
|
+
raise IncompatibleArgumentsError,
|
|
67
|
+
"Only #{REFERENCES.join(", ")} are allowed as reference, " \
|
|
68
|
+
"got #{reference}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
unless body.respond_to?(:at)
|
|
72
|
+
raise IncompatibleArgumentsError,
|
|
73
|
+
"The body must respond to #at(instant, ephem:), got #{body}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if reference != :center && !(body.is_a?(Class) && body <= SolarSystemBody)
|
|
77
|
+
raise IncompatibleArgumentsError,
|
|
78
|
+
"A limb reference needs a body with a known size, got #{body}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
@body = body
|
|
82
|
+
@observer = observer
|
|
83
|
+
@ephem = ephem
|
|
84
|
+
@refraction = refraction
|
|
85
|
+
@reference = reference
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @param start_time [Time] start of the window
|
|
89
|
+
# @param end_time [Time] end of the window
|
|
90
|
+
# @param altitude [Astronoby::Angle] the altitude to reach, positive or
|
|
91
|
+
# negative
|
|
92
|
+
# @return [Array<Astronoby::AltitudeCrossing>] the crossings in the window,
|
|
93
|
+
# sorted by time
|
|
94
|
+
def crossings_between(start_time, end_time, altitude:)
|
|
95
|
+
multiple_crossings_between(start_time, end_time, altitudes: [altitude])
|
|
96
|
+
.first
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Same as {#crossings_between} for several altitudes at once, sharing the
|
|
100
|
+
# culminations and the evaluated positions between them.
|
|
101
|
+
#
|
|
102
|
+
# @param start_time [Time] start of the window
|
|
103
|
+
# @param end_time [Time] end of the window
|
|
104
|
+
# @param altitudes [Array<Astronoby::Angle>] the altitudes to reach
|
|
105
|
+
# @return [Array<Array<Astronoby::AltitudeCrossing>>] the crossings of each
|
|
106
|
+
# altitude, in the order of +altitudes+
|
|
107
|
+
def multiple_crossings_between(start_time, end_time, altitudes:)
|
|
108
|
+
search_between(start_time, end_time).crossings(altitudes)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @param date [Date] the day
|
|
112
|
+
# @param altitude [Astronoby::Angle] the altitude to reach
|
|
113
|
+
# @param direction [Symbol] +:rising+ or +:setting+
|
|
114
|
+
# @param utc_offset [String, Integer] UTC offset of the day, e.g.
|
|
115
|
+
# "+02:00"
|
|
116
|
+
# @return [Astronoby::AltitudeCrossing, nil] the first crossing of that
|
|
117
|
+
# direction from midnight to the next midnight, if any
|
|
118
|
+
def crossing_on(date, altitude:, direction:, utc_offset: 0)
|
|
119
|
+
unless DIRECTIONS.include?(direction)
|
|
120
|
+
raise IncompatibleArgumentsError,
|
|
121
|
+
"Only #{DIRECTIONS.join(" or ")} are allowed as direction, " \
|
|
122
|
+
"got #{direction}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
start_time = Time.new(date.year, date.month, date.day, 0, 0, 0, utc_offset)
|
|
126
|
+
following = date.next_day
|
|
127
|
+
end_time = Time.new(
|
|
128
|
+
following.year, following.month, following.day, 0, 0, 0, utc_offset
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
crossings_between(start_time, end_time, altitude: altitude)
|
|
132
|
+
.find do |crossing|
|
|
133
|
+
crossing.direction == direction &&
|
|
134
|
+
crossing.instant.to_time < end_time
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @param start_time [Time] start of the window
|
|
139
|
+
# @param end_time [Time] end of the window
|
|
140
|
+
# @return [Array<Astronoby::AltitudeExtreme>] the upper and lower
|
|
141
|
+
# culminations in the window, sorted by time, with the altitude of the
|
|
142
|
+
# reference point at each
|
|
143
|
+
def extremes_between(start_time, end_time)
|
|
144
|
+
search_between(start_time, end_time).extremes
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def search_between(start_time, end_time)
|
|
150
|
+
if end_time < start_time
|
|
151
|
+
raise IncompatibleArgumentsError,
|
|
152
|
+
"The end of the window must not be before its start"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
Search.new(
|
|
156
|
+
body: @body,
|
|
157
|
+
observer: @observer,
|
|
158
|
+
ephem: @ephem,
|
|
159
|
+
refraction: @refraction,
|
|
160
|
+
reference: @reference,
|
|
161
|
+
start_tt: Instant.from_time(start_time).tt.to_f,
|
|
162
|
+
end_tt: Instant.from_time(end_time).tt.to_f
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
class Search
|
|
167
|
+
def initialize(
|
|
168
|
+
body:,
|
|
169
|
+
observer:,
|
|
170
|
+
ephem:,
|
|
171
|
+
refraction:,
|
|
172
|
+
reference:,
|
|
173
|
+
start_tt:,
|
|
174
|
+
end_tt:
|
|
175
|
+
)
|
|
176
|
+
@body = body
|
|
177
|
+
@observer = observer
|
|
178
|
+
@ephem = ephem
|
|
179
|
+
@refraction = refraction
|
|
180
|
+
@reference = reference
|
|
181
|
+
@start_tt = start_tt
|
|
182
|
+
@end_tt = end_tt
|
|
183
|
+
@samples = {}
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def crossings(altitudes)
|
|
187
|
+
overlapping = segments
|
|
188
|
+
|
|
189
|
+
altitudes.map do |altitude|
|
|
190
|
+
target = altitude.radians
|
|
191
|
+
|
|
192
|
+
overlapping
|
|
193
|
+
.flat_map { |first, last| segment_crossings(first, last, target) }
|
|
194
|
+
.sort_by(&:tt)
|
|
195
|
+
.then { |crossings| remove_duplicates(crossings) }
|
|
196
|
+
.map { |crossing| build_crossing(crossing) }
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def extremes
|
|
201
|
+
culminations(@start_tt, @end_tt, HOUR_ANGLE_TOLERANCE).map do |culmination|
|
|
202
|
+
AltitudeExtreme.new(
|
|
203
|
+
kind: culmination.kind,
|
|
204
|
+
instant: Instant.from_terrestrial_time(culmination.tt),
|
|
205
|
+
altitude: Angle.from_radians(altitude_at(culmination.tt))
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def sample_at(tt)
|
|
213
|
+
@samples[tt] ||= begin
|
|
214
|
+
instant = Instant.from_terrestrial_time(tt)
|
|
215
|
+
body = @body.at(instant, ephem: @ephem)
|
|
216
|
+
equatorial = body.observed_by(@observer).equatorial
|
|
217
|
+
hour_angle = equatorial.compute_hour_angle(
|
|
218
|
+
time: instant.to_time,
|
|
219
|
+
longitude: @observer.longitude
|
|
220
|
+
)
|
|
221
|
+
declination = equatorial.declination
|
|
222
|
+
|
|
223
|
+
Sample.new(
|
|
224
|
+
hour_angle.radians,
|
|
225
|
+
declination.radians,
|
|
226
|
+
reference_altitude(body, declination, hour_angle)
|
|
227
|
+
)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def reference_altitude(body, declination, hour_angle)
|
|
232
|
+
latitude = @observer.latitude
|
|
233
|
+
sin_altitude = latitude.sin * declination.sin +
|
|
234
|
+
latitude.cos * declination.cos * hour_angle.cos
|
|
235
|
+
altitude = Angle.asin(sin_altitude.clamp(-1.0, 1.0))
|
|
236
|
+
|
|
237
|
+
if @refraction
|
|
238
|
+
altitude += Refraction.angle(
|
|
239
|
+
coordinates: Coordinates::Horizontal.new(
|
|
240
|
+
azimuth: Angle.zero,
|
|
241
|
+
altitude: altitude,
|
|
242
|
+
observer: @observer
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
altitude.radians + limb_offset(body)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def limb_offset(body)
|
|
251
|
+
case @reference
|
|
252
|
+
when :upper_limb then body.angular_diameter.radians / 2
|
|
253
|
+
when :lower_limb then -body.angular_diameter.radians / 2
|
|
254
|
+
else 0.0
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def altitude_at(tt)
|
|
259
|
+
sample_at(tt).altitude
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def hour_angle_at(tt)
|
|
263
|
+
sample_at(tt).hour_angle
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def wrap(radians)
|
|
267
|
+
(radians + Math::PI) % TAU - Math::PI
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def segments
|
|
271
|
+
culminations(
|
|
272
|
+
@start_tt - CULMINATION_PADDING_IN_DAYS,
|
|
273
|
+
@end_tt + CULMINATION_PADDING_IN_DAYS,
|
|
274
|
+
SEGMENT_HOUR_ANGLE_TOLERANCE
|
|
275
|
+
).each_cons(2).select do |first, last|
|
|
276
|
+
last.tt >= @start_tt && first.tt <= @end_tt
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def culminations(start_tt, end_tt, tolerance)
|
|
281
|
+
start_hour_angle = hour_angle_at(start_tt)
|
|
282
|
+
spacing = TAU / SIDEREAL_RATE
|
|
283
|
+
|
|
284
|
+
{
|
|
285
|
+
AltitudeExtreme::UPPER_CULMINATION => 0.0,
|
|
286
|
+
AltitudeExtreme::LOWER_CULMINATION => Math::PI
|
|
287
|
+
}.flat_map do |kind, hour_angle|
|
|
288
|
+
tt = start_tt +
|
|
289
|
+
((hour_angle - start_hour_angle) % TAU) / SIDEREAL_RATE
|
|
290
|
+
previous = nil
|
|
291
|
+
found = []
|
|
292
|
+
|
|
293
|
+
while tt <= end_tt
|
|
294
|
+
tt = refine_culmination(tt, hour_angle, tolerance)
|
|
295
|
+
found << Culmination.new(kind, tt) if tt.between?(start_tt, end_tt)
|
|
296
|
+
# Step from the refined event: a sidereal day drifts from the
|
|
297
|
+
# Moon's by about an hour per day, which would add up over a
|
|
298
|
+
# window. Once two are known their spacing, shared by both kinds,
|
|
299
|
+
# is a closer step than a sidereal day, as it changes slowly from
|
|
300
|
+
# one day to the next.
|
|
301
|
+
spacing = tt - previous if previous
|
|
302
|
+
previous = tt
|
|
303
|
+
tt += spacing
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
found
|
|
307
|
+
end.sort_by(&:tt)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def refine_culmination(tt, hour_angle, tolerance)
|
|
311
|
+
rate = SIDEREAL_RATE
|
|
312
|
+
previous = nil
|
|
313
|
+
|
|
314
|
+
MAX_ITERATIONS.times do
|
|
315
|
+
residual = wrap(hour_angle_at(tt) - hour_angle)
|
|
316
|
+
return tt if residual.abs < tolerance
|
|
317
|
+
|
|
318
|
+
if previous
|
|
319
|
+
measured_rate =
|
|
320
|
+
wrap(residual - previous[:residual]) / (tt - previous[:tt])
|
|
321
|
+
rate = measured_rate if measured_rate.positive?
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
previous = {tt: tt, residual: residual}
|
|
325
|
+
tt -= residual / rate
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
tt
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def segment_crossings(first, last, target)
|
|
332
|
+
low = [first.tt, @start_tt].max
|
|
333
|
+
high = [last.tt, @end_tt].min
|
|
334
|
+
return [] unless low < high
|
|
335
|
+
|
|
336
|
+
f_first = altitude_at(first.tt) - target
|
|
337
|
+
f_last = altitude_at(last.tt) - target
|
|
338
|
+
|
|
339
|
+
if [f_first.abs, f_last.abs].min <= GRAZING_MARGIN
|
|
340
|
+
return scan(low, high, target, grazing: true)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
f_low = (low == first.tt) ? f_first : altitude_at(low) - target
|
|
344
|
+
f_high = (high == last.tt) ? f_last : altitude_at(high) - target
|
|
345
|
+
return [] if f_low.negative? == f_high.negative?
|
|
346
|
+
|
|
347
|
+
tt = solve(Segment.new(
|
|
348
|
+
first, sample_at(first.tt), last, sample_at(last.tt)
|
|
349
|
+
), low, f_low, high, target)
|
|
350
|
+
return scan(low, high, target, grazing: false) unless tt
|
|
351
|
+
|
|
352
|
+
[Crossing.new(tt, f_low.negative? ? :rising : :setting, false)]
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Between two consecutive culminations the hour angle goes through
|
|
356
|
+
# exactly half a turn, and the declination moves from its value at one
|
|
357
|
+
# to its value at the other. That gives the rates of both over the
|
|
358
|
+
# segment, which a sidereal day and a fixed declination only approximate:
|
|
359
|
+
# the Sun's right ascension drifts by a degree per day and the Moon's by
|
|
360
|
+
# thirteen.
|
|
361
|
+
class Segment
|
|
362
|
+
attr_reader :hour_angle_rate, :declination_rate
|
|
363
|
+
|
|
364
|
+
def initialize(first, first_sample, last, last_sample)
|
|
365
|
+
@first = first
|
|
366
|
+
@first_sample = first_sample
|
|
367
|
+
span = last.tt - first.tt
|
|
368
|
+
consecutive = first.kind != last.kind && span.positive?
|
|
369
|
+
|
|
370
|
+
@hour_angle_rate = consecutive ? Math::PI / span : SIDEREAL_RATE
|
|
371
|
+
@declination_rate = if consecutive
|
|
372
|
+
(last_sample.declination - first_sample.declination) / span
|
|
373
|
+
else
|
|
374
|
+
0.0
|
|
375
|
+
end
|
|
376
|
+
@first_hour_angle =
|
|
377
|
+
(first.kind == AltitudeExtreme::UPPER_CULMINATION) ? 0.0 : Math::PI
|
|
378
|
+
@consecutive = consecutive
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def consecutive? = @consecutive
|
|
382
|
+
|
|
383
|
+
def declination_at(tt)
|
|
384
|
+
@first_sample.declination + @declination_rate * (tt - @first.tt)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def time_at_hour_angle(hour_angle)
|
|
388
|
+
@first.tt + (hour_angle - @first_hour_angle) / @hour_angle_rate
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def solve(segment, low, f_low, high, target)
|
|
393
|
+
x = initial_guess(segment, low, high, f_low, target)
|
|
394
|
+
previous = nil
|
|
395
|
+
|
|
396
|
+
MAX_ITERATIONS.times do
|
|
397
|
+
f_x = altitude_at(x) - target
|
|
398
|
+
return x if f_x.abs < ALTITUDE_TOLERANCE
|
|
399
|
+
|
|
400
|
+
if f_x.negative? == f_low.negative?
|
|
401
|
+
low = x
|
|
402
|
+
f_low = f_x
|
|
403
|
+
else
|
|
404
|
+
high = x
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
slope = if previous
|
|
408
|
+
(f_x - previous[:f]) / (x - previous[:tt])
|
|
409
|
+
else
|
|
410
|
+
analytic_slope(x, segment)
|
|
411
|
+
end
|
|
412
|
+
previous = {tt: x, f: f_x}
|
|
413
|
+
|
|
414
|
+
step = x - f_x / slope
|
|
415
|
+
within = step > low && step < high
|
|
416
|
+
return step if within && f_x.abs < NEWTON_ACCEPT && segment.consecutive?
|
|
417
|
+
|
|
418
|
+
x = within ? step : (low + high) / 2
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
nil
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def initial_guess(segment, low, high, f_low, target)
|
|
425
|
+
guess = if segment.consecutive?
|
|
426
|
+
guess_from_segment(segment, f_low, target, low)
|
|
427
|
+
else
|
|
428
|
+
guess_from_sample(low, f_low, target)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
(guess > low && guess < high) ? guess : (low + high) / 2
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def guess_from_segment(segment, f_low, target, low)
|
|
435
|
+
# The declination at the crossing depends on when it happens, so the
|
|
436
|
+
# time is refined once from the first estimate.
|
|
437
|
+
tt = low
|
|
438
|
+
2.times do
|
|
439
|
+
declination = segment.declination_at(tt)
|
|
440
|
+
half_arc = half_arc_for(target, declination)
|
|
441
|
+
needed = f_low.negative? ? TAU - half_arc : half_arc
|
|
442
|
+
tt = segment.time_at_hour_angle(needed)
|
|
443
|
+
end
|
|
444
|
+
tt
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def guess_from_sample(low, f_low, target)
|
|
448
|
+
sample = sample_at(low)
|
|
449
|
+
half_arc = half_arc_for(target, sample.declination)
|
|
450
|
+
needed = f_low.negative? ? TAU - half_arc : half_arc
|
|
451
|
+
|
|
452
|
+
low + ((needed - sample.hour_angle) % TAU) / SIDEREAL_RATE
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def half_arc_for(target, declination)
|
|
456
|
+
latitude = @observer.latitude
|
|
457
|
+
ratio = (Math.sin(target) - latitude.sin * Math.sin(declination)) /
|
|
458
|
+
(latitude.cos * Math.cos(declination))
|
|
459
|
+
|
|
460
|
+
Math.acos(ratio.clamp(-1.0, 1.0))
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def analytic_slope(tt, segment)
|
|
464
|
+
sample = sample_at(tt)
|
|
465
|
+
latitude = @observer.latitude
|
|
466
|
+
declination = sample.declination
|
|
467
|
+
cos_hour_angle = Math.cos(sample.hour_angle)
|
|
468
|
+
|
|
469
|
+
rate_of_sine =
|
|
470
|
+
-latitude.cos * Math.cos(declination) * Math.sin(sample.hour_angle) *
|
|
471
|
+
segment.hour_angle_rate +
|
|
472
|
+
(latitude.sin * Math.cos(declination) -
|
|
473
|
+
latitude.cos * Math.sin(declination) * cos_hour_angle) *
|
|
474
|
+
segment.declination_rate
|
|
475
|
+
|
|
476
|
+
rate_of_sine / Math.cos(sample.altitude)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def scan(first_tt, last_tt, target, grazing:)
|
|
480
|
+
finder = RootFinder.new(
|
|
481
|
+
value_at: ->(tt) { altitude_at(tt) - target },
|
|
482
|
+
period: 1.0,
|
|
483
|
+
samples_per_period: FALLBACK_SAMPLES_PER_DAY
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
finder.roots(first_tt, last_tt).map do |tt|
|
|
487
|
+
rising = altitude_at(tt + DIRECTION_PROBE_IN_DAYS) >
|
|
488
|
+
altitude_at(tt - DIRECTION_PROBE_IN_DAYS)
|
|
489
|
+
Crossing.new(tt, rising ? :rising : :setting, grazing)
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def remove_duplicates(crossings)
|
|
494
|
+
crossings.each_with_object([]) do |crossing, kept|
|
|
495
|
+
duplicate = kept.last &&
|
|
496
|
+
(crossing.tt - kept.last.tt) < DUPLICATE_THRESHOLD_IN_DAYS
|
|
497
|
+
kept << crossing unless duplicate
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def build_crossing(crossing)
|
|
502
|
+
AltitudeCrossing.new(
|
|
503
|
+
instant: Instant.from_terrestrial_time(crossing.tt),
|
|
504
|
+
direction: crossing.direction,
|
|
505
|
+
grazing: crossing.grazing
|
|
506
|
+
)
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
private_constant :Search
|
|
510
|
+
end
|
|
511
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astronoby
|
|
4
|
+
# A culmination of a body, when it crosses the observer's meridian above or
|
|
5
|
+
# below the pole.
|
|
6
|
+
class AltitudeExtreme
|
|
7
|
+
KINDS = [
|
|
8
|
+
UPPER_CULMINATION = :upper_culmination,
|
|
9
|
+
LOWER_CULMINATION = :lower_culmination
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
# @return [Symbol] +:upper_culmination+ (highest altitude) or
|
|
13
|
+
# +:lower_culmination+ (lowest altitude)
|
|
14
|
+
attr_reader :kind
|
|
15
|
+
|
|
16
|
+
# @return [Astronoby::Instant] when the body crosses the meridian
|
|
17
|
+
attr_reader :instant
|
|
18
|
+
|
|
19
|
+
# @return [Astronoby::Angle] the altitude at that instant
|
|
20
|
+
attr_reader :altitude
|
|
21
|
+
|
|
22
|
+
# @param kind [Symbol] +:upper_culmination+ or +:lower_culmination+
|
|
23
|
+
# @param instant [Astronoby::Instant] when the body crosses the meridian
|
|
24
|
+
# @param altitude [Astronoby::Angle] the altitude at that instant
|
|
25
|
+
def initialize(kind:, instant:, altitude:)
|
|
26
|
+
@kind = kind
|
|
27
|
+
@instant = instant
|
|
28
|
+
@altitude = altitude
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astronoby
|
|
4
|
+
class Conjunction
|
|
5
|
+
INFERIOR = :inferior
|
|
6
|
+
SUPERIOR = :superior
|
|
7
|
+
|
|
8
|
+
# @param instant [Astronoby::Instant] when the conjunction occurs
|
|
9
|
+
# @param body [Astronoby::Body] the body in conjunction with the Sun
|
|
10
|
+
# @return [Astronoby::Conjunction] an inferior conjunction
|
|
11
|
+
def self.inferior(instant:, body:)
|
|
12
|
+
new(instant: instant, body: body, subtype: INFERIOR)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param instant [Astronoby::Instant] when the conjunction occurs
|
|
16
|
+
# @param body [Astronoby::Body] the body in conjunction with the Sun
|
|
17
|
+
# @return [Astronoby::Conjunction] a superior conjunction
|
|
18
|
+
def self.superior(instant:, body:)
|
|
19
|
+
new(instant: instant, body: body, subtype: SUPERIOR)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Astronoby::Instant] when the conjunction occurs
|
|
23
|
+
attr_reader :instant
|
|
24
|
+
|
|
25
|
+
# @return [Astronoby::Body] the body in conjunction with the Sun
|
|
26
|
+
attr_reader :body
|
|
27
|
+
|
|
28
|
+
# @return [Symbol] +INFERIOR+ or +SUPERIOR+
|
|
29
|
+
attr_reader :subtype
|
|
30
|
+
|
|
31
|
+
# @param instant [Astronoby::Instant] when the conjunction occurs
|
|
32
|
+
# @param body [Astronoby::Body] the body in conjunction with the Sun
|
|
33
|
+
# @param subtype [Symbol] +INFERIOR+ or +SUPERIOR+
|
|
34
|
+
def initialize(instant:, body:, subtype:)
|
|
35
|
+
@instant = instant
|
|
36
|
+
@body = body
|
|
37
|
+
@subtype = subtype
|
|
38
|
+
freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Boolean] true for an inferior conjunction
|
|
42
|
+
def inferior?
|
|
43
|
+
@subtype == INFERIOR
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Boolean] true for a superior conjunction
|
|
47
|
+
def superior?
|
|
48
|
+
@subtype == SUPERIOR
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astronoby
|
|
4
|
+
class ConjunctionOppositionCalculator
|
|
5
|
+
# @param body [Astronoby::SolarSystemBody] the planet to track
|
|
6
|
+
# @param ephem [::Ephem::SPK] ephemeris data source
|
|
7
|
+
# @param samples_per_period [Integer] number of samples per synodic period
|
|
8
|
+
def initialize(body:, ephem:, samples_per_period: 60)
|
|
9
|
+
@body = body
|
|
10
|
+
@ephem = ephem
|
|
11
|
+
@samples_per_period = samples_per_period
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @param start_time [Time] start time
|
|
15
|
+
# @param end_time [Time] end time
|
|
16
|
+
# @return [Array<Astronoby::Conjunction>] conjunctions in the range
|
|
17
|
+
def conjunction_events_between(start_time, end_time)
|
|
18
|
+
roots_between(start_time, end_time, accept: method(:conjunction?))
|
|
19
|
+
.map { |jd| conjunction_at(Instant.from_terrestrial_time(jd)) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param start_time [Time] start time
|
|
23
|
+
# @param end_time [Time] end time
|
|
24
|
+
# @return [Array<Astronoby::Opposition>] oppositions in the range
|
|
25
|
+
def opposition_events_between(start_time, end_time)
|
|
26
|
+
roots_between(start_time, end_time, accept: method(:opposition?))
|
|
27
|
+
.map do |jd|
|
|
28
|
+
Opposition.new(
|
|
29
|
+
instant: Instant.from_terrestrial_time(jd),
|
|
30
|
+
body: @body
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def roots_between(start_time, end_time, accept:)
|
|
38
|
+
finder.roots(
|
|
39
|
+
Instant.from_time(start_time).tt,
|
|
40
|
+
Instant.from_time(end_time).tt,
|
|
41
|
+
accept: accept
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def finder
|
|
46
|
+
@finder ||= RootFinder.new(
|
|
47
|
+
value_at: ->(jd) { delta_longitude_at(jd).sin },
|
|
48
|
+
period: synodic_period,
|
|
49
|
+
samples_per_period: @samples_per_period
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def conjunction?(jd)
|
|
54
|
+
delta_longitude_at(jd).cos.positive?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def opposition?(jd)
|
|
58
|
+
delta_longitude_at(jd).cos.negative?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def delta_longitude_at(jd)
|
|
62
|
+
instant = Instant.from_terrestrial_time(jd)
|
|
63
|
+
planet = @body.new(instant: instant, ephem: @ephem)
|
|
64
|
+
sun = Sun.new(instant: instant, ephem: @ephem)
|
|
65
|
+
planet.apparent.ecliptic.longitude - sun.apparent.ecliptic.longitude
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def conjunction_at(instant)
|
|
69
|
+
planet = @body.new(instant: instant, ephem: @ephem)
|
|
70
|
+
sun = Sun.new(instant: instant, ephem: @ephem)
|
|
71
|
+
|
|
72
|
+
if planet.apparent.distance < sun.apparent.distance
|
|
73
|
+
Conjunction.inferior(instant: instant, body: @body)
|
|
74
|
+
else
|
|
75
|
+
Conjunction.superior(instant: instant, body: @body)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def synodic_period
|
|
80
|
+
@synodic_period ||=
|
|
81
|
+
1.0 / ((1.0 / @body::ORBITAL_PERIOD) - (1.0 / Earth::ORBITAL_PERIOD)).abs
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|