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,397 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astronoby
|
|
4
|
+
# Computes lunar eclipses over a time range.
|
|
5
|
+
#
|
|
6
|
+
# A lunar eclipse is a geocentric event, identical for every observer who can
|
|
7
|
+
# see the Moon, so no observer is involved. The geometry is built from the
|
|
8
|
+
# apparent geocentric positions of the Sun and Moon: this matches the standard
|
|
9
|
+
# reduction used by IMCCE, validated against IMCCE (Opale, INPOP19A) where the
|
|
10
|
+
# eclipse kind, greatest eclipse, magnitudes, and contact times all agree to
|
|
11
|
+
# within a second or two.
|
|
12
|
+
#
|
|
13
|
+
# Candidate full moons are seeded analytically from Events::MoonPhases, then
|
|
14
|
+
# refined against the ephemeris: full moons far from a node are skipped, the
|
|
15
|
+
# greatest eclipse is the least distance of the Moon's centre from the shadow
|
|
16
|
+
# axis, and each contact is found by bisecting between greatest eclipse (inside
|
|
17
|
+
# the shadow) and the edge of the search window (outside it).
|
|
18
|
+
#
|
|
19
|
+
# Source:
|
|
20
|
+
# Title: Explanatory Supplement to the Astronomical Almanac
|
|
21
|
+
# Authors: Sean E. Urban and P. Kenneth Seidelmann
|
|
22
|
+
# Chapter: 11 - Eclipses of the Sun and Moon
|
|
23
|
+
class LunarEclipseCalculator
|
|
24
|
+
# Atmospheric enlargement of Earth's shadow (Danjon-style): the shell of
|
|
25
|
+
# atmosphere added to Earth's radius before the shadow cones are built, which
|
|
26
|
+
# propagates into both the umbra and the penumbra. Calibrated against IMCCE,
|
|
27
|
+
# which uses the same INPOP19A ephemeris, over the eclipses from 2026 to
|
|
28
|
+
# 2048. It is well below the textbook values, which put the shell at 75 km
|
|
29
|
+
# (Danjon) or 128 km (Chauvenet).
|
|
30
|
+
SHADOW_ATMOSPHERE_KM = 64.0
|
|
31
|
+
|
|
32
|
+
SUN_RADIUS_KM = Sun::EQUATORIAL_RADIUS.km
|
|
33
|
+
EARTH_RADIUS_KM =
|
|
34
|
+
Constants::WGS84_EARTH_EQUATORIAL_RADIUS_IN_METERS / 1000.0
|
|
35
|
+
MOON_RADIUS_KM = Constants::IAU_MOON_RADIUS_IN_METERS / 1000.0
|
|
36
|
+
|
|
37
|
+
# Largest distance of the Moon's centre from the shadow axis, in Earth radii,
|
|
38
|
+
# at which any (penumbral) eclipse is still possible is about 1.57. Full moons
|
|
39
|
+
# whose seed already exceeds this margin cannot be eclipses and skip the
|
|
40
|
+
# minimum search entirely.
|
|
41
|
+
MAX_ECLIPSE_GAMMA = 1.8
|
|
42
|
+
|
|
43
|
+
# Half-window, in days, for the local greatest-eclipse search around a full
|
|
44
|
+
# moon. A lunar eclipse occurs within minutes of full moon.
|
|
45
|
+
GREATEST_HALF_WINDOW = 0.25
|
|
46
|
+
|
|
47
|
+
# Half-window, in days, for the contact search around greatest eclipse. Wide
|
|
48
|
+
# enough to bracket the longest penumbral phase (about 3 hours each side).
|
|
49
|
+
CONTACT_HALF_WINDOW = 0.21
|
|
50
|
+
|
|
51
|
+
SEARCH_SAMPLES = 48
|
|
52
|
+
|
|
53
|
+
# Bisection tolerance, in days, for a contact time. ~1e-7 day is ~8.6 ms,
|
|
54
|
+
# well below the one-second resolution the contacts are reported at.
|
|
55
|
+
CONTACT_TOLERANCE = 1e-7
|
|
56
|
+
|
|
57
|
+
# Which limb of the Moon a phase boundary touches the shadow cone with. The
|
|
58
|
+
# penumbral and partial phases are bounded by external tangencies, where the
|
|
59
|
+
# contact point is on the limb facing the shadow axis; the total phase by
|
|
60
|
+
# internal ones, where it is on the far limb. Greatest eclipse has no
|
|
61
|
+
# contact point, and IMCCE reports the axis-to-Moon direction there.
|
|
62
|
+
EXTERNAL_TANGENCY = :external
|
|
63
|
+
INTERNAL_TANGENCY = :internal
|
|
64
|
+
NO_TANGENCY = :none
|
|
65
|
+
|
|
66
|
+
# Geometry of the Sun, Moon and Earth's shadow at one instant, in kilometres
|
|
67
|
+
# in the plane perpendicular to the shadow axis at the Moon's distance.
|
|
68
|
+
class Geometry
|
|
69
|
+
# @return [Float] distance of the Moon's centre from the shadow axis (km)
|
|
70
|
+
attr_reader :axis_distance
|
|
71
|
+
|
|
72
|
+
# @return [Float] radius of the umbra at the Moon's distance (km)
|
|
73
|
+
attr_reader :umbra_radius
|
|
74
|
+
|
|
75
|
+
# @return [Float] radius of the penumbra at the Moon's distance (km)
|
|
76
|
+
attr_reader :penumbra_radius
|
|
77
|
+
|
|
78
|
+
# @return [Boolean] whether the Moon is north of the shadow axis
|
|
79
|
+
attr_reader :north_of_axis
|
|
80
|
+
|
|
81
|
+
# @return [Float] position angle of the Moon's centre from the shadow
|
|
82
|
+
# axis, in radians from celestial north through east
|
|
83
|
+
attr_reader :position_angle
|
|
84
|
+
|
|
85
|
+
# @return [Float] geocentric distance of the Moon (km)
|
|
86
|
+
attr_reader :moon_distance
|
|
87
|
+
|
|
88
|
+
# @return [Float] x component of the Moon's apparent geocentric unit
|
|
89
|
+
# vector, in the true equator and equinox of date
|
|
90
|
+
attr_reader :moon_unit_x
|
|
91
|
+
|
|
92
|
+
# @return [Float] y component of the Moon's apparent geocentric unit
|
|
93
|
+
# vector, in the true equator and equinox of date
|
|
94
|
+
attr_reader :moon_unit_y
|
|
95
|
+
|
|
96
|
+
# @return [Float] z component of the Moon's apparent geocentric unit
|
|
97
|
+
# vector, in the true equator and equinox of date
|
|
98
|
+
attr_reader :moon_unit_z
|
|
99
|
+
|
|
100
|
+
# @return [Numeric] Julian Date (TT) the geometry was built at, which is
|
|
101
|
+
# the epoch its equator and equinox are referred to
|
|
102
|
+
attr_reader :epoch
|
|
103
|
+
|
|
104
|
+
def initialize(
|
|
105
|
+
axis_distance:,
|
|
106
|
+
umbra_radius:,
|
|
107
|
+
penumbra_radius:,
|
|
108
|
+
north_of_axis:,
|
|
109
|
+
position_angle:,
|
|
110
|
+
moon_distance:,
|
|
111
|
+
moon_unit_x:,
|
|
112
|
+
moon_unit_y:,
|
|
113
|
+
moon_unit_z:,
|
|
114
|
+
epoch:
|
|
115
|
+
)
|
|
116
|
+
@axis_distance = axis_distance
|
|
117
|
+
@umbra_radius = umbra_radius
|
|
118
|
+
@penumbra_radius = penumbra_radius
|
|
119
|
+
@north_of_axis = north_of_axis
|
|
120
|
+
@position_angle = position_angle
|
|
121
|
+
@moon_distance = moon_distance
|
|
122
|
+
@moon_unit_x = moon_unit_x
|
|
123
|
+
@moon_unit_y = moon_unit_y
|
|
124
|
+
@moon_unit_z = moon_unit_z
|
|
125
|
+
@epoch = epoch
|
|
126
|
+
freeze
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def to_lunar_eclipse_geometry(tangency)
|
|
130
|
+
angle = if tangency == EXTERNAL_TANGENCY
|
|
131
|
+
(position_angle + Math::PI) % Constants::RADIANS_PER_CIRCLE
|
|
132
|
+
else
|
|
133
|
+
position_angle
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
LunarEclipseGeometry.new(
|
|
137
|
+
axis_distance: Distance.from_kilometers(axis_distance),
|
|
138
|
+
position_angle: Angle.from_radians(angle),
|
|
139
|
+
umbra_radius: Distance.from_kilometers(umbra_radius),
|
|
140
|
+
penumbra_radius: Distance.from_kilometers(penumbra_radius),
|
|
141
|
+
moon_distance: Distance.from_kilometers(moon_distance),
|
|
142
|
+
moon_coordinates: moon_coordinates,
|
|
143
|
+
north_of_axis: north_of_axis
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def moon_coordinates
|
|
148
|
+
Coordinates::Equatorial.new(
|
|
149
|
+
right_ascension: Angle.from_radians(
|
|
150
|
+
Math.atan2(moon_unit_y, moon_unit_x) %
|
|
151
|
+
Constants::RADIANS_PER_CIRCLE
|
|
152
|
+
),
|
|
153
|
+
declination: Angle.asin(moon_unit_z.clamp(-1.0, 1.0)),
|
|
154
|
+
epoch: epoch
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def umbral_magnitude
|
|
159
|
+
(umbra_radius + MOON_RADIUS_KM - axis_distance) / (2 * MOON_RADIUS_KM)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def penumbral_magnitude
|
|
163
|
+
(
|
|
164
|
+
penumbra_radius + MOON_RADIUS_KM - axis_distance
|
|
165
|
+
) / (2 * MOON_RADIUS_KM)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def penumbral_contact_value
|
|
169
|
+
axis_distance - (penumbra_radius + MOON_RADIUS_KM)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def partial_contact_value
|
|
173
|
+
axis_distance - (umbra_radius + MOON_RADIUS_KM)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def total_contact_value
|
|
177
|
+
axis_distance - (umbra_radius - MOON_RADIUS_KM)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private_constant :Geometry
|
|
182
|
+
|
|
183
|
+
# @param ephem [::Ephem::SPK] ephemeris data source
|
|
184
|
+
def initialize(ephem:)
|
|
185
|
+
@ephem = ephem
|
|
186
|
+
@geometry_cache = {}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @param start_time [Time] start time
|
|
190
|
+
# @param end_time [Time] end time
|
|
191
|
+
# @return [Array<Astronoby::LunarEclipse>] eclipses whose greatest instant
|
|
192
|
+
# lies in the range, sorted by time
|
|
193
|
+
def events_between(start_time, end_time)
|
|
194
|
+
full_moon_seeds(start_time, end_time)
|
|
195
|
+
.filter_map { |seed_jd| eclipse_near(seed_jd) }
|
|
196
|
+
.select do |eclipse|
|
|
197
|
+
eclipse.instant.to_time.between?(start_time, end_time)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private
|
|
202
|
+
|
|
203
|
+
# Full moons in the range, padded by a day so an eclipse near a boundary is
|
|
204
|
+
# not missed. Seeded analytically (Meeus, chapter 49) at no ephemeris cost.
|
|
205
|
+
def full_moon_seeds(start_time, end_time)
|
|
206
|
+
padded_start = start_time - Constants::SECONDS_PER_DAY
|
|
207
|
+
padded_end = end_time + Constants::SECONDS_PER_DAY
|
|
208
|
+
year_months(padded_start, padded_end)
|
|
209
|
+
.flat_map do |year, month|
|
|
210
|
+
Events::MoonPhases.phases_for(year: year, month: month)
|
|
211
|
+
end
|
|
212
|
+
.select { |phase| phase.phase == :full_moon }
|
|
213
|
+
.map(&:time)
|
|
214
|
+
.select { |time| time.between?(padded_start, padded_end) }
|
|
215
|
+
.map { |time| Instant.from_time(time).tt }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def year_months(from, to)
|
|
219
|
+
cursor = Date.new(from.to_date.year, from.to_date.month, 1)
|
|
220
|
+
last = Date.new(to.to_date.year, to.to_date.month, 1)
|
|
221
|
+
months = []
|
|
222
|
+
while cursor <= last
|
|
223
|
+
months << [cursor.year, cursor.month]
|
|
224
|
+
cursor = cursor.next_month
|
|
225
|
+
end
|
|
226
|
+
months
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Resolves the eclipse for one full-moon seed, if any.
|
|
230
|
+
def eclipse_near(seed_jd)
|
|
231
|
+
if geometry_at(seed_jd).axis_distance > MAX_ECLIPSE_GAMMA * EARTH_RADIUS_KM
|
|
232
|
+
return nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
greatest_jd = greatest_eclipse_jd(seed_jd)
|
|
236
|
+
greatest_jd && eclipse_at(greatest_jd)
|
|
237
|
+
ensure
|
|
238
|
+
@geometry_cache.clear
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Greatest eclipse: the least distance of the Moon's centre from the shadow
|
|
242
|
+
# axis, found by minimising it on a narrow window around the full moon.
|
|
243
|
+
def greatest_eclipse_jd(seed_jd)
|
|
244
|
+
ExtremumFinder
|
|
245
|
+
.new(
|
|
246
|
+
value_at: ->(jd) { geometry_at(jd).axis_distance },
|
|
247
|
+
period: 2 * GREATEST_HALF_WINDOW,
|
|
248
|
+
samples_per_period: SEARCH_SAMPLES
|
|
249
|
+
)
|
|
250
|
+
.extrema(
|
|
251
|
+
seed_jd - GREATEST_HALF_WINDOW,
|
|
252
|
+
seed_jd + GREATEST_HALF_WINDOW,
|
|
253
|
+
type: :minimum
|
|
254
|
+
)
|
|
255
|
+
.min_by { |extremum| extremum[:value] }
|
|
256
|
+
&.fetch(:jd)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def eclipse_at(greatest_jd)
|
|
260
|
+
geometry = geometry_at(greatest_jd)
|
|
261
|
+
penumbral = phase_for(
|
|
262
|
+
greatest_jd,
|
|
263
|
+
EXTERNAL_TANGENCY,
|
|
264
|
+
&:penumbral_contact_value
|
|
265
|
+
)
|
|
266
|
+
return nil if penumbral.nil?
|
|
267
|
+
|
|
268
|
+
partial = phase_for(greatest_jd, EXTERNAL_TANGENCY, &:partial_contact_value)
|
|
269
|
+
total = phase_for(greatest_jd, INTERNAL_TANGENCY, &:total_contact_value)
|
|
270
|
+
|
|
271
|
+
LunarEclipse.new(
|
|
272
|
+
instant: Instant.from_terrestrial_time(greatest_jd),
|
|
273
|
+
kind: kind_for(partial, total),
|
|
274
|
+
geometry: geometry.to_lunar_eclipse_geometry(NO_TANGENCY),
|
|
275
|
+
penumbral: penumbral,
|
|
276
|
+
partial: partial,
|
|
277
|
+
total: total
|
|
278
|
+
)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def kind_for(partial, total)
|
|
282
|
+
if total
|
|
283
|
+
LunarEclipse::TOTAL
|
|
284
|
+
elsif partial
|
|
285
|
+
LunarEclipse::PARTIAL
|
|
286
|
+
else
|
|
287
|
+
LunarEclipse::PENUMBRAL
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Builds a phase from a contact function. The phase occurs only when the
|
|
292
|
+
# Moon is inside the boundary at greatest eclipse (contact value negative);
|
|
293
|
+
# each contact is then the single crossing between greatest eclipse and the
|
|
294
|
+
# corresponding edge of the window, found by bisection. This is robust to
|
|
295
|
+
# arbitrarily short phases (a barely-total or grazing eclipse), unlike a
|
|
296
|
+
# fixed-resolution scan that can step over a brief crossing.
|
|
297
|
+
def phase_for(greatest_jd, tangency, &contact_value)
|
|
298
|
+
value_at = ->(jd) { contact_value.call(geometry_at(jd)) }
|
|
299
|
+
return nil unless value_at.call(greatest_jd).negative?
|
|
300
|
+
|
|
301
|
+
starting = bisect_contact(
|
|
302
|
+
value_at,
|
|
303
|
+
greatest_jd - CONTACT_HALF_WINDOW,
|
|
304
|
+
greatest_jd
|
|
305
|
+
)
|
|
306
|
+
ending = bisect_contact(
|
|
307
|
+
value_at,
|
|
308
|
+
greatest_jd + CONTACT_HALF_WINDOW,
|
|
309
|
+
greatest_jd
|
|
310
|
+
)
|
|
311
|
+
return nil unless starting && ending
|
|
312
|
+
|
|
313
|
+
starting_jd, starting_geometry = starting
|
|
314
|
+
ending_jd, ending_geometry = ending
|
|
315
|
+
|
|
316
|
+
LunarEclipsePhase.new(
|
|
317
|
+
starting_instant: Instant.from_terrestrial_time(starting_jd),
|
|
318
|
+
ending_instant: Instant.from_terrestrial_time(ending_jd),
|
|
319
|
+
starting_geometry:
|
|
320
|
+
starting_geometry.to_lunar_eclipse_geometry(tangency),
|
|
321
|
+
ending_geometry: ending_geometry.to_lunar_eclipse_geometry(tangency)
|
|
322
|
+
)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Bisects for the single contact between +outside_jd+ (value positive, the
|
|
326
|
+
# Moon outside the boundary) and +inside_jd+ (value negative, at greatest
|
|
327
|
+
# eclipse). Returns the contact date, taken as the midpoint of the final
|
|
328
|
+
# bracket, and the geometry already computed at that bracket's inner end, at
|
|
329
|
+
# most 2 ms away. Returns nil if the boundary is not crossed in the window.
|
|
330
|
+
def bisect_contact(value_at, outside_jd, inside_jd)
|
|
331
|
+
return nil unless value_at.call(outside_jd).positive?
|
|
332
|
+
|
|
333
|
+
while (inside_jd - outside_jd).abs > CONTACT_TOLERANCE
|
|
334
|
+
midpoint = (outside_jd + inside_jd) / 2.0
|
|
335
|
+
if value_at.call(midpoint).negative?
|
|
336
|
+
inside_jd = midpoint
|
|
337
|
+
else
|
|
338
|
+
outside_jd = midpoint
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
[(outside_jd + inside_jd) / 2.0, geometry_at(inside_jd)]
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Builds the geometry at a Julian Date (TT) from the apparent geocentric
|
|
345
|
+
# positions of the Sun and Moon. Memoised so repeated evaluations during the
|
|
346
|
+
# searches reuse the same computation.
|
|
347
|
+
def geometry_at(jd)
|
|
348
|
+
@geometry_cache[jd] ||= begin
|
|
349
|
+
instant = Instant.from_terrestrial_time(jd)
|
|
350
|
+
moon = Moon.new(ephem: @ephem, instant: instant).apparent
|
|
351
|
+
sun = Sun.new(ephem: @ephem, instant: instant).apparent
|
|
352
|
+
|
|
353
|
+
moon_distance = moon.distance.km
|
|
354
|
+
sun_distance = sun.distance.km
|
|
355
|
+
|
|
356
|
+
moon_x, moon_y, moon_z =
|
|
357
|
+
moon.position.to_a.map { |component| component.km / moon_distance }
|
|
358
|
+
axis_x, axis_y, axis_z =
|
|
359
|
+
sun.position.to_a.map { |component| -component.km / sun_distance }
|
|
360
|
+
|
|
361
|
+
hypotenuse = Math.sqrt(axis_x * axis_x + axis_y * axis_y)
|
|
362
|
+
east_x = -axis_y / hypotenuse
|
|
363
|
+
east_y = axis_x / hypotenuse
|
|
364
|
+
north_x = -axis_z * east_y
|
|
365
|
+
north_y = axis_z * east_x
|
|
366
|
+
north_z = axis_x * east_y - axis_y * east_x
|
|
367
|
+
|
|
368
|
+
east = moon_x * east_x + moon_y * east_y
|
|
369
|
+
north = moon_x * north_x + moon_y * north_y + moon_z * north_z
|
|
370
|
+
cosine = moon_x * axis_x + moon_y * axis_y + moon_z * axis_z
|
|
371
|
+
sine = Math.sqrt(east * east + north * north)
|
|
372
|
+
|
|
373
|
+
axial_distance = moon_distance * cosine
|
|
374
|
+
perpendicular_distance = moon_distance * sine
|
|
375
|
+
|
|
376
|
+
# Danjon enlargement: enlarge Earth's radius before building the cones.
|
|
377
|
+
earth_radius = EARTH_RADIUS_KM + SHADOW_ATMOSPHERE_KM
|
|
378
|
+
umbra_half_angle_tangent = (SUN_RADIUS_KM - earth_radius) / sun_distance
|
|
379
|
+
penumbra_half_angle_tangent =
|
|
380
|
+
(SUN_RADIUS_KM + earth_radius) / sun_distance
|
|
381
|
+
|
|
382
|
+
Geometry.new(
|
|
383
|
+
axis_distance: perpendicular_distance,
|
|
384
|
+
umbra_radius: earth_radius - axial_distance * umbra_half_angle_tangent,
|
|
385
|
+
penumbra_radius: earth_radius + axial_distance * penumbra_half_angle_tangent,
|
|
386
|
+
north_of_axis: !north.negative?,
|
|
387
|
+
position_angle: Math.atan2(east, north) % Constants::RADIANS_PER_CIRCLE,
|
|
388
|
+
moon_distance: moon_distance,
|
|
389
|
+
moon_unit_x: moon_x,
|
|
390
|
+
moon_unit_y: moon_y,
|
|
391
|
+
moon_unit_z: moon_z,
|
|
392
|
+
epoch: jd
|
|
393
|
+
)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astronoby
|
|
4
|
+
# The geometry of Earth's shadow and the Moon's place in it at one instant of
|
|
5
|
+
# a lunar eclipse.
|
|
6
|
+
#
|
|
7
|
+
# The shadow cones are cut by the plane perpendicular to the shadow axis at
|
|
8
|
+
# the Moon's distance, which is where their radii are published. Earth's
|
|
9
|
+
# radius is enlarged by its atmosphere before the cones are built, so these
|
|
10
|
+
# are the enlarged, observable radii.
|
|
11
|
+
class LunarEclipseGeometry
|
|
12
|
+
# @return [Astronoby::Distance] distance of the Moon's centre from the axis
|
|
13
|
+
# of Earth's shadow
|
|
14
|
+
attr_reader :axis_distance
|
|
15
|
+
|
|
16
|
+
# @return [Astronoby::Angle] position angle of the contact point on the
|
|
17
|
+
# Moon's limb, seen from the Moon's centre, from celestial north through
|
|
18
|
+
# east. This is the quantity IMCCE and NASA publish. At greatest eclipse,
|
|
19
|
+
# where there is no contact point, it is the direction from the shadow
|
|
20
|
+
# axis to the Moon, which is what they report there.
|
|
21
|
+
attr_reader :position_angle
|
|
22
|
+
|
|
23
|
+
# @return [Astronoby::Distance] radius of the umbra, the inner and total
|
|
24
|
+
# shadow, at the Moon's distance
|
|
25
|
+
attr_reader :umbra_radius
|
|
26
|
+
|
|
27
|
+
# @return [Astronoby::Distance] radius of the penumbra, the outer and
|
|
28
|
+
# partial shadow, at the Moon's distance
|
|
29
|
+
attr_reader :penumbra_radius
|
|
30
|
+
|
|
31
|
+
# @return [Astronoby::Distance] geocentric distance of the Moon, the
|
|
32
|
+
# distance of the plane the radii are measured in
|
|
33
|
+
attr_reader :moon_distance
|
|
34
|
+
|
|
35
|
+
# @return [Astronoby::Coordinates::Equatorial] apparent geocentric place
|
|
36
|
+
# of the Moon, referred to the true equator and equinox of date
|
|
37
|
+
attr_reader :moon_coordinates
|
|
38
|
+
|
|
39
|
+
# @return [Boolean] true when the Moon is north of the shadow axis
|
|
40
|
+
def north_of_axis?
|
|
41
|
+
@north_of_axis
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param axis_distance [Astronoby::Distance] distance from the shadow axis
|
|
45
|
+
# @param position_angle [Astronoby::Angle] position angle of the contact
|
|
46
|
+
# point on the Moon's limb, from celestial north through east
|
|
47
|
+
# @param umbra_radius [Astronoby::Distance] umbra radius
|
|
48
|
+
# @param penumbra_radius [Astronoby::Distance] penumbra radius
|
|
49
|
+
# @param moon_distance [Astronoby::Distance] geocentric distance of the Moon
|
|
50
|
+
# @param moon_coordinates [Astronoby::Coordinates::Equatorial] apparent
|
|
51
|
+
# geocentric place of the Moon, referred to the true equator and equinox
|
|
52
|
+
# of date
|
|
53
|
+
# @param north_of_axis [Boolean] whether the Moon is north of the shadow
|
|
54
|
+
# axis
|
|
55
|
+
def initialize(
|
|
56
|
+
axis_distance:,
|
|
57
|
+
position_angle:,
|
|
58
|
+
umbra_radius:,
|
|
59
|
+
penumbra_radius:,
|
|
60
|
+
moon_distance:,
|
|
61
|
+
moon_coordinates:,
|
|
62
|
+
north_of_axis:
|
|
63
|
+
)
|
|
64
|
+
@axis_distance = axis_distance
|
|
65
|
+
@position_angle = position_angle
|
|
66
|
+
@umbra_radius = umbra_radius
|
|
67
|
+
@penumbra_radius = penumbra_radius
|
|
68
|
+
@moon_distance = moon_distance
|
|
69
|
+
@moon_coordinates = moon_coordinates
|
|
70
|
+
@north_of_axis = north_of_axis
|
|
71
|
+
freeze
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Astronoby::Angle] angular distance of the Moon's centre from the
|
|
75
|
+
# axis of Earth's shadow
|
|
76
|
+
def angular_axis_distance
|
|
77
|
+
angular_radius_of(@axis_distance)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Astronoby::Angle] angular radius of the umbra
|
|
81
|
+
def umbra_angular_radius
|
|
82
|
+
angular_radius_of(@umbra_radius)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [Astronoby::Angle] angular radius of the penumbra
|
|
86
|
+
def penumbra_angular_radius
|
|
87
|
+
angular_radius_of(@penumbra_radius)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [Float] fraction of the Moon's diameter immersed in the umbra,
|
|
91
|
+
# negative when the Moon is clear of the umbra
|
|
92
|
+
def umbral_magnitude
|
|
93
|
+
magnitude_for(@umbra_radius)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [Float] fraction of the Moon's diameter immersed in the penumbra,
|
|
97
|
+
# negative when the Moon is clear of the penumbra
|
|
98
|
+
def penumbral_magnitude
|
|
99
|
+
magnitude_for(@penumbra_radius)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def angular_radius_of(distance)
|
|
105
|
+
Angle.from_radians(Math.asin(distance.km / @moon_distance.km))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def magnitude_for(shadow_radius)
|
|
109
|
+
moon_radius = Constants::IAU_MOON_RADIUS_IN_METERS / 1000.0
|
|
110
|
+
(
|
|
111
|
+
shadow_radius.km + moon_radius - @axis_distance.km
|
|
112
|
+
) / (2 * moon_radius)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astronoby
|
|
4
|
+
class LunarEclipsePhase
|
|
5
|
+
# @return [Astronoby::Instant] when the phase begins
|
|
6
|
+
attr_reader :starting_instant
|
|
7
|
+
|
|
8
|
+
# @return [Astronoby::Instant] when the phase ends
|
|
9
|
+
attr_reader :ending_instant
|
|
10
|
+
|
|
11
|
+
# @return [Astronoby::LunarEclipseGeometry] the shadow geometry when the
|
|
12
|
+
# phase begins
|
|
13
|
+
attr_reader :starting_geometry
|
|
14
|
+
|
|
15
|
+
# @return [Astronoby::LunarEclipseGeometry] the shadow geometry when the
|
|
16
|
+
# phase ends
|
|
17
|
+
attr_reader :ending_geometry
|
|
18
|
+
|
|
19
|
+
# @param starting_instant [Astronoby::Instant] when the phase begins
|
|
20
|
+
# @param ending_instant [Astronoby::Instant] when the phase ends
|
|
21
|
+
# @param starting_geometry [Astronoby::LunarEclipseGeometry] the geometry
|
|
22
|
+
# when the phase begins
|
|
23
|
+
# @param ending_geometry [Astronoby::LunarEclipseGeometry] the geometry when
|
|
24
|
+
# the phase ends
|
|
25
|
+
def initialize(
|
|
26
|
+
starting_instant:,
|
|
27
|
+
ending_instant:,
|
|
28
|
+
starting_geometry:,
|
|
29
|
+
ending_geometry:
|
|
30
|
+
)
|
|
31
|
+
@starting_instant = starting_instant
|
|
32
|
+
@ending_instant = ending_instant
|
|
33
|
+
@starting_geometry = starting_geometry
|
|
34
|
+
@ending_geometry = ending_geometry
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Astronoby::Duration] phase duration
|
|
39
|
+
def duration
|
|
40
|
+
Duration.from_seconds(
|
|
41
|
+
(@ending_instant.tt - @starting_instant.tt) * Constants::SECONDS_PER_DAY
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|