astronoby 0.10.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/CHANGELOG.md +134 -0
- data/README.md +13 -0
- data/UPGRADING.md +186 -0
- data/docs/README.md +32 -8
- data/docs/altitude_crossings.md +92 -0
- data/docs/configuration.md +8 -0
- data/docs/deep_sky_bodies.md +45 -0
- data/docs/glossary.md +2 -0
- data/docs/instant.md +14 -7
- data/docs/lunar_eclipses.md +128 -13
- data/docs/twilight_times.md +26 -11
- data/lib/astronoby/bodies/deep_sky_object_position.rb +6 -24
- data/lib/astronoby/cache.rb +13 -0
- data/lib/astronoby/constants.rb +1 -2
- data/lib/astronoby/constellations/data.rb +1 -1
- data/lib/astronoby/coordinates/equatorial.rb +15 -5
- data/lib/astronoby/delta_t.rb +44 -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/lunar_eclipse.rb +49 -37
- data/lib/astronoby/events/lunar_eclipse_calculator.rb +143 -31
- 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/rise_transit_set_calculator.rb +9 -13
- data/lib/astronoby/events/twilight_calculator.rb +37 -147
- data/lib/astronoby/horizon.rb +40 -0
- data/lib/astronoby/horizontal_transformation.rb +112 -0
- data/lib/astronoby/instant.rb +50 -13
- data/lib/astronoby/precession.rb +1 -1
- data/lib/astronoby/reference_frame.rb +8 -3
- data/lib/astronoby/reference_frames/apparent.rb +20 -8
- data/lib/astronoby/reference_frames/mean_of_date.rb +6 -0
- data/lib/astronoby/reference_frames/teme.rb +6 -0
- data/lib/astronoby/reference_frames/topocentric.rb +6 -0
- data/lib/astronoby/root_finder.rb +16 -9
- data/lib/astronoby/time/greenwich_apparent_sidereal_time.rb +10 -8
- data/lib/astronoby/time/greenwich_mean_sidereal_time.rb +11 -9
- data/lib/astronoby/util/time.rb +0 -22
- data/lib/astronoby/version.rb +1 -1
- data/lib/astronoby.rb +9 -1
- metadata +40 -5
- data/lib/astronoby/events/eclipse_phase.rb +0 -27
|
@@ -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
|
|
@@ -14,73 +14,79 @@ module Astronoby
|
|
|
14
14
|
# @return [Astronoby::Instant] greatest eclipse, when the Moon's centre is
|
|
15
15
|
# least distant from the axis of Earth's shadow
|
|
16
16
|
attr_reader :instant
|
|
17
|
-
alias_method :
|
|
17
|
+
alias_method :greatest_eclipse_instant, :instant
|
|
18
18
|
|
|
19
19
|
# @return [Symbol] +PENUMBRAL+, +PARTIAL+ or +TOTAL+
|
|
20
20
|
attr_reader :kind
|
|
21
21
|
|
|
22
|
-
# @return [
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
# @return [Float] fraction of the Moon's diameter immersed in the penumbra
|
|
27
|
-
# at greatest eclipse
|
|
28
|
-
attr_reader :penumbral_magnitude
|
|
29
|
-
|
|
30
|
-
# @return [Float] least distance of the Moon's centre from the axis of
|
|
31
|
-
# Earth's shadow at greatest eclipse, in Earth radii, positive when the
|
|
32
|
-
# Moon passes north of the axis
|
|
33
|
-
attr_reader :gamma
|
|
22
|
+
# @return [Astronoby::LunarEclipseGeometry] the geometry of Earth's shadow
|
|
23
|
+
# and the Moon's place in it at greatest eclipse. The geometry at each
|
|
24
|
+
# contact is on the phases.
|
|
25
|
+
attr_reader :geometry
|
|
34
26
|
|
|
35
|
-
# @return [Astronoby::
|
|
36
|
-
# axis of Earth's shadow at greatest eclipse. This is the unsigned length
|
|
37
|
-
# of which gamma is the value in Earth radii.
|
|
38
|
-
attr_reader :shadow_axis_distance
|
|
39
|
-
|
|
40
|
-
# @return [Astronoby::EclipsePhase] the penumbral phase (always present)
|
|
27
|
+
# @return [Astronoby::LunarEclipsePhase] the penumbral phase (always present)
|
|
41
28
|
attr_reader :penumbral
|
|
42
29
|
|
|
43
|
-
# @return [Astronoby::
|
|
30
|
+
# @return [Astronoby::LunarEclipsePhase, nil] the partial phase, present for
|
|
44
31
|
# partial and total eclipses
|
|
45
32
|
attr_reader :partial
|
|
46
33
|
|
|
47
|
-
# @return [Astronoby::
|
|
34
|
+
# @return [Astronoby::LunarEclipsePhase, nil] the total phase (totality),
|
|
48
35
|
# present only for total eclipses
|
|
49
36
|
attr_reader :total
|
|
50
37
|
|
|
51
38
|
# @param instant [Astronoby::Instant] greatest eclipse
|
|
52
39
|
# @param kind [Symbol] +PENUMBRAL+, +PARTIAL+ or +TOTAL+
|
|
53
|
-
# @param
|
|
54
|
-
#
|
|
55
|
-
# @param
|
|
56
|
-
# @param
|
|
57
|
-
#
|
|
58
|
-
# @param penumbral [Astronoby::EclipsePhase] the penumbral phase
|
|
59
|
-
# @param partial [Astronoby::EclipsePhase, nil] the partial phase
|
|
60
|
-
# @param total [Astronoby::EclipsePhase, nil] the total phase
|
|
40
|
+
# @param geometry [Astronoby::LunarEclipseGeometry] the shadow geometry at
|
|
41
|
+
# greatest eclipse
|
|
42
|
+
# @param penumbral [Astronoby::LunarEclipsePhase] the penumbral phase
|
|
43
|
+
# @param partial [Astronoby::LunarEclipsePhase, nil] the partial phase
|
|
44
|
+
# @param total [Astronoby::LunarEclipsePhase, nil] the total phase
|
|
61
45
|
def initialize(
|
|
62
46
|
instant:,
|
|
63
47
|
kind:,
|
|
64
|
-
|
|
65
|
-
penumbral_magnitude:,
|
|
66
|
-
gamma:,
|
|
67
|
-
shadow_axis_distance:,
|
|
48
|
+
geometry:,
|
|
68
49
|
penumbral:,
|
|
69
50
|
partial: nil,
|
|
70
51
|
total: nil
|
|
71
52
|
)
|
|
72
53
|
@instant = instant
|
|
73
54
|
@kind = kind
|
|
74
|
-
@
|
|
75
|
-
@penumbral_magnitude = penumbral_magnitude
|
|
76
|
-
@gamma = gamma
|
|
77
|
-
@shadow_axis_distance = shadow_axis_distance
|
|
55
|
+
@geometry = geometry
|
|
78
56
|
@penumbral = penumbral
|
|
79
57
|
@partial = partial
|
|
80
58
|
@total = total
|
|
81
59
|
freeze
|
|
82
60
|
end
|
|
83
61
|
|
|
62
|
+
# @return [Float] least distance of the Moon's centre from the axis of
|
|
63
|
+
# Earth's shadow at greatest eclipse, in Earth radii, positive when the
|
|
64
|
+
# Moon passes north of the axis
|
|
65
|
+
def gamma
|
|
66
|
+
sign = @geometry.north_of_axis? ? 1 : -1
|
|
67
|
+
sign * @geometry.axis_distance.m /
|
|
68
|
+
Constants::WGS84_EARTH_EQUATORIAL_RADIUS_IN_METERS
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Float] fraction of the Moon's diameter immersed in the umbra at
|
|
72
|
+
# greatest eclipse (negative when the Moon misses the umbra)
|
|
73
|
+
def umbral_magnitude
|
|
74
|
+
@geometry.umbral_magnitude
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [Float] fraction of the Moon's diameter immersed in the penumbra
|
|
78
|
+
# at greatest eclipse
|
|
79
|
+
def penumbral_magnitude
|
|
80
|
+
@geometry.penumbral_magnitude
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Astronoby::Distance] least distance of the Moon's centre from the
|
|
84
|
+
# axis of Earth's shadow at greatest eclipse. This is the unsigned length
|
|
85
|
+
# of which gamma is the value in Earth radii.
|
|
86
|
+
def shadow_axis_distance
|
|
87
|
+
@geometry.axis_distance
|
|
88
|
+
end
|
|
89
|
+
|
|
84
90
|
# @return [Boolean] true for a penumbral eclipse (the Moon misses the umbra)
|
|
85
91
|
def penumbral?
|
|
86
92
|
@kind == PENUMBRAL
|
|
@@ -95,5 +101,11 @@ module Astronoby
|
|
|
95
101
|
def total?
|
|
96
102
|
@kind == TOTAL
|
|
97
103
|
end
|
|
104
|
+
|
|
105
|
+
# @param observer [Astronoby::Observer] the observer
|
|
106
|
+
# @return [Astronoby::LunarEclipseVisibility] the local circumstances
|
|
107
|
+
def visibility_from(observer)
|
|
108
|
+
LunarEclipseVisibility.new(eclipse: self, observer: observer)
|
|
109
|
+
end
|
|
98
110
|
end
|
|
99
111
|
end
|