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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/CHANGELOG.md +235 -0
  4. data/README.md +19 -1
  5. data/UPGRADING.md +270 -0
  6. data/docs/README.md +105 -16
  7. data/docs/altitude_crossings.md +92 -0
  8. data/docs/angles.md +1 -0
  9. data/docs/configuration.md +28 -17
  10. data/docs/coordinates.md +72 -12
  11. data/docs/deep_sky_bodies.md +46 -1
  12. data/docs/ephem.md +5 -2
  13. data/docs/equinoxes_solstices_times.md +4 -3
  14. data/docs/glossary.md +99 -1
  15. data/docs/iers.md +40 -0
  16. data/docs/instant.md +33 -21
  17. data/docs/lunar_eclipses.md +208 -0
  18. data/docs/lunar_observation.md +87 -0
  19. data/docs/moon_phases.md +4 -1
  20. data/docs/observer.md +20 -6
  21. data/docs/planetary_phenomena.md +78 -0
  22. data/docs/reference_frames.md +192 -34
  23. data/docs/rise_transit_set_times.md +6 -4
  24. data/docs/solar_system_bodies.md +26 -4
  25. data/docs/twilight_times.md +43 -24
  26. data/lib/astronoby/angle.rb +63 -2
  27. data/lib/astronoby/angles/dms.rb +18 -1
  28. data/lib/astronoby/angles/hms.rb +14 -1
  29. data/lib/astronoby/angular_velocity.rb +21 -0
  30. data/lib/astronoby/bodies/deep_sky_object.rb +6 -1
  31. data/lib/astronoby/bodies/deep_sky_object_position.rb +35 -38
  32. data/lib/astronoby/bodies/earth.rb +7 -44
  33. data/lib/astronoby/bodies/jupiter.rb +10 -0
  34. data/lib/astronoby/bodies/mars.rb +10 -0
  35. data/lib/astronoby/bodies/mercury.rb +10 -0
  36. data/lib/astronoby/bodies/moon.rb +158 -32
  37. data/lib/astronoby/bodies/neptune.rb +10 -0
  38. data/lib/astronoby/bodies/saturn.rb +10 -0
  39. data/lib/astronoby/bodies/solar_system_body.rb +240 -61
  40. data/lib/astronoby/bodies/sun.rb +79 -4
  41. data/lib/astronoby/bodies/uranus.rb +10 -0
  42. data/lib/astronoby/bodies/venus.rb +10 -0
  43. data/lib/astronoby/body.rb +6 -0
  44. data/lib/astronoby/cache.rb +13 -0
  45. data/lib/astronoby/center.rb +84 -0
  46. data/lib/astronoby/constants.rb +1 -2
  47. data/lib/astronoby/constellation.rb +9 -1
  48. data/lib/astronoby/constellations/data.rb +1 -1
  49. data/lib/astronoby/coordinates/ecliptic.rb +10 -1
  50. data/lib/astronoby/coordinates/equatorial.rb +79 -13
  51. data/lib/astronoby/coordinates/geodetic.rb +102 -0
  52. data/lib/astronoby/coordinates/horizontal.rb +13 -1
  53. data/lib/astronoby/delta_t.rb +44 -0
  54. data/lib/astronoby/distance.rb +35 -0
  55. data/lib/astronoby/duration.rb +116 -0
  56. data/lib/astronoby/earth_rotation.rb +70 -0
  57. data/lib/astronoby/equinox_solstice.rb +31 -8
  58. data/lib/astronoby/errors.rb +11 -0
  59. data/lib/astronoby/events/altitude_crossing.rb +29 -0
  60. data/lib/astronoby/events/altitude_crossing_calculator.rb +511 -0
  61. data/lib/astronoby/events/altitude_extreme.rb +31 -0
  62. data/lib/astronoby/events/conjunction.rb +51 -0
  63. data/lib/astronoby/events/conjunction_opposition_calculator.rb +84 -0
  64. data/lib/astronoby/events/extremum_calculator.rb +23 -176
  65. data/lib/astronoby/events/greatest_elongation.rb +58 -0
  66. data/lib/astronoby/events/greatest_elongation_calculator.rb +56 -0
  67. data/lib/astronoby/events/lunar_eclipse.rb +111 -0
  68. data/lib/astronoby/events/lunar_eclipse_calculator.rb +397 -0
  69. data/lib/astronoby/events/lunar_eclipse_geometry.rb +115 -0
  70. data/lib/astronoby/events/lunar_eclipse_phase.rb +45 -0
  71. data/lib/astronoby/events/lunar_eclipse_visibility.rb +296 -0
  72. data/lib/astronoby/events/opposition.rb +19 -0
  73. data/lib/astronoby/events/rise_transit_set_calculator.rb +9 -13
  74. data/lib/astronoby/events/rise_transit_set_event.rb +12 -1
  75. data/lib/astronoby/events/rise_transit_set_events.rb +12 -1
  76. data/lib/astronoby/events/twilight_calculator.rb +37 -147
  77. data/lib/astronoby/events/twilight_event.rb +24 -6
  78. data/lib/astronoby/events/twilight_events.rb +26 -6
  79. data/lib/astronoby/extremum_finder.rb +148 -0
  80. data/lib/astronoby/horizon.rb +40 -0
  81. data/lib/astronoby/horizontal_transformation.rb +112 -0
  82. data/lib/astronoby/instant.rb +56 -16
  83. data/lib/astronoby/libration.rb +25 -0
  84. data/lib/astronoby/mean_obliquity.rb +8 -0
  85. data/lib/astronoby/moon_orientation_ephemeris.rb +69 -0
  86. data/lib/astronoby/moon_physical_ephemeris.rb +263 -0
  87. data/lib/astronoby/nutation.rb +10 -20
  88. data/lib/astronoby/observer.rb +67 -49
  89. data/lib/astronoby/orientation.rb +107 -0
  90. data/lib/astronoby/position.rb +16 -0
  91. data/lib/astronoby/precession.rb +62 -61
  92. data/lib/astronoby/reference_frame.rb +80 -9
  93. data/lib/astronoby/reference_frames/apparent.rb +43 -12
  94. data/lib/astronoby/reference_frames/astrometric.rb +14 -1
  95. data/lib/astronoby/reference_frames/geometric.rb +7 -1
  96. data/lib/astronoby/reference_frames/mean_of_date.rb +19 -1
  97. data/lib/astronoby/reference_frames/teme.rb +159 -0
  98. data/lib/astronoby/reference_frames/topocentric.rb +36 -4
  99. data/lib/astronoby/refraction.rb +26 -5
  100. data/lib/astronoby/root_finder.rb +90 -0
  101. data/lib/astronoby/rotation.rb +49 -0
  102. data/lib/astronoby/time/greenwich_apparent_sidereal_time.rb +19 -8
  103. data/lib/astronoby/time/greenwich_mean_sidereal_time.rb +44 -5
  104. data/lib/astronoby/time/greenwich_sidereal_time.rb +21 -0
  105. data/lib/astronoby/time/local_apparent_sidereal_time.rb +21 -0
  106. data/lib/astronoby/time/local_mean_sidereal_time.rb +21 -0
  107. data/lib/astronoby/time/local_sidereal_time.rb +24 -0
  108. data/lib/astronoby/time/sidereal_time.rb +23 -1
  109. data/lib/astronoby/true_obliquity.rb +4 -0
  110. data/lib/astronoby/util/maths.rb +8 -0
  111. data/lib/astronoby/util/time.rb +0 -497
  112. data/lib/astronoby/vector.rb +10 -0
  113. data/lib/astronoby/velocity.rb +39 -0
  114. data/lib/astronoby/version.rb +1 -1
  115. data/lib/astronoby.rb +30 -0
  116. 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