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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +134 -0
  3. data/README.md +13 -0
  4. data/UPGRADING.md +186 -0
  5. data/docs/README.md +32 -8
  6. data/docs/altitude_crossings.md +92 -0
  7. data/docs/configuration.md +8 -0
  8. data/docs/deep_sky_bodies.md +45 -0
  9. data/docs/glossary.md +2 -0
  10. data/docs/instant.md +14 -7
  11. data/docs/lunar_eclipses.md +128 -13
  12. data/docs/twilight_times.md +26 -11
  13. data/lib/astronoby/bodies/deep_sky_object_position.rb +6 -24
  14. data/lib/astronoby/cache.rb +13 -0
  15. data/lib/astronoby/constants.rb +1 -2
  16. data/lib/astronoby/constellations/data.rb +1 -1
  17. data/lib/astronoby/coordinates/equatorial.rb +15 -5
  18. data/lib/astronoby/delta_t.rb +44 -0
  19. data/lib/astronoby/events/altitude_crossing.rb +29 -0
  20. data/lib/astronoby/events/altitude_crossing_calculator.rb +511 -0
  21. data/lib/astronoby/events/altitude_extreme.rb +31 -0
  22. data/lib/astronoby/events/lunar_eclipse.rb +49 -37
  23. data/lib/astronoby/events/lunar_eclipse_calculator.rb +143 -31
  24. data/lib/astronoby/events/lunar_eclipse_geometry.rb +115 -0
  25. data/lib/astronoby/events/lunar_eclipse_phase.rb +45 -0
  26. data/lib/astronoby/events/lunar_eclipse_visibility.rb +296 -0
  27. data/lib/astronoby/events/rise_transit_set_calculator.rb +9 -13
  28. data/lib/astronoby/events/twilight_calculator.rb +37 -147
  29. data/lib/astronoby/horizon.rb +40 -0
  30. data/lib/astronoby/horizontal_transformation.rb +112 -0
  31. data/lib/astronoby/instant.rb +50 -13
  32. data/lib/astronoby/precession.rb +1 -1
  33. data/lib/astronoby/reference_frame.rb +8 -3
  34. data/lib/astronoby/reference_frames/apparent.rb +20 -8
  35. data/lib/astronoby/reference_frames/mean_of_date.rb +6 -0
  36. data/lib/astronoby/reference_frames/teme.rb +6 -0
  37. data/lib/astronoby/reference_frames/topocentric.rb +6 -0
  38. data/lib/astronoby/root_finder.rb +16 -9
  39. data/lib/astronoby/time/greenwich_apparent_sidereal_time.rb +10 -8
  40. data/lib/astronoby/time/greenwich_mean_sidereal_time.rb +11 -9
  41. data/lib/astronoby/util/time.rb +0 -22
  42. data/lib/astronoby/version.rb +1 -1
  43. data/lib/astronoby.rb +9 -1
  44. metadata +40 -5
  45. data/lib/astronoby/events/eclipse_phase.rb +0 -27
@@ -21,17 +21,18 @@ module Astronoby
21
21
  # Authors: Sean E. Urban and P. Kenneth Seidelmann
22
22
  # Chapter: 11 - Eclipses of the Sun and Moon
23
23
  class LunarEclipseCalculator
24
- # Atmospheric enlargement of Earth's shadow (Danjon-style): Earth's radius is
25
- # enlarged before the shadow cones are built, which propagates into both the
26
- # umbra and the penumbra. The 1/99 factor is calibrated against IMCCE (which
27
- # uses the same INPOP19A ephemeris): it reproduces IMCCE's umbra and penumbra
28
- # angular radii to about 0.1 arcsecond across the 2023-2025 eclipses.
29
- SHADOW_ENLARGEMENT = 1.0 + 1.0 / 99
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
30
31
 
31
32
  SUN_RADIUS_KM = Sun::EQUATORIAL_RADIUS.km
32
- MOON_RADIUS_KM = Moon::EQUATORIAL_RADIUS.km
33
33
  EARTH_RADIUS_KM =
34
34
  Constants::WGS84_EARTH_EQUATORIAL_RADIUS_IN_METERS / 1000.0
35
+ MOON_RADIUS_KM = Constants::IAU_MOON_RADIUS_IN_METERS / 1000.0
35
36
 
36
37
  # Largest distance of the Moon's centre from the shadow axis, in Earth radii,
37
38
  # at which any (penumbral) eclipse is still possible is about 1.57. Full moons
@@ -53,6 +54,15 @@ module Astronoby
53
54
  # well below the one-second resolution the contacts are reported at.
54
55
  CONTACT_TOLERANCE = 1e-7
55
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
+
56
66
  # Geometry of the Sun, Moon and Earth's shadow at one instant, in kilometres
57
67
  # in the plane perpendicular to the shadow axis at the Moon's distance.
58
68
  class Geometry
@@ -65,17 +75,86 @@ module Astronoby
65
75
  # @return [Float] radius of the penumbra at the Moon's distance (km)
66
76
  attr_reader :penumbra_radius
67
77
 
68
- # @return [Float] signed distance from the shadow axis, in Earth radii
69
- attr_reader :gamma
70
-
71
- def initialize(axis_distance:, umbra_radius:, penumbra_radius:, gamma:)
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
+ )
72
116
  @axis_distance = axis_distance
73
117
  @umbra_radius = umbra_radius
74
118
  @penumbra_radius = penumbra_radius
75
- @gamma = gamma
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
76
126
  freeze
77
127
  end
78
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
+
79
158
  def umbral_magnitude
80
159
  (umbra_radius + MOON_RADIUS_KM - axis_distance) / (2 * MOON_RADIUS_KM)
81
160
  end
@@ -99,6 +178,8 @@ module Astronoby
99
178
  end
100
179
  end
101
180
 
181
+ private_constant :Geometry
182
+
102
183
  # @param ephem [::Ephem::SPK] ephemeris data source
103
184
  def initialize(ephem:)
104
185
  @ephem = ephem
@@ -177,19 +258,20 @@ module Astronoby
177
258
 
178
259
  def eclipse_at(greatest_jd)
179
260
  geometry = geometry_at(greatest_jd)
180
- penumbral = phase_for(greatest_jd, &:penumbral_contact_value)
261
+ penumbral = phase_for(
262
+ greatest_jd,
263
+ EXTERNAL_TANGENCY,
264
+ &:penumbral_contact_value
265
+ )
181
266
  return nil if penumbral.nil?
182
267
 
183
- partial = phase_for(greatest_jd, &:partial_contact_value)
184
- total = phase_for(greatest_jd, &:total_contact_value)
268
+ partial = phase_for(greatest_jd, EXTERNAL_TANGENCY, &:partial_contact_value)
269
+ total = phase_for(greatest_jd, INTERNAL_TANGENCY, &:total_contact_value)
185
270
 
186
271
  LunarEclipse.new(
187
272
  instant: Instant.from_terrestrial_time(greatest_jd),
188
273
  kind: kind_for(partial, total),
189
- umbral_magnitude: geometry.umbral_magnitude,
190
- penumbral_magnitude: geometry.penumbral_magnitude,
191
- gamma: geometry.gamma,
192
- shadow_axis_distance: Distance.from_kilometers(geometry.axis_distance),
274
+ geometry: geometry.to_lunar_eclipse_geometry(NO_TANGENCY),
193
275
  penumbral: penumbral,
194
276
  partial: partial,
195
277
  total: total
@@ -212,7 +294,7 @@ module Astronoby
212
294
  # corresponding edge of the window, found by bisection. This is robust to
213
295
  # arbitrarily short phases (a barely-total or grazing eclipse), unlike a
214
296
  # fixed-resolution scan that can step over a brief crossing.
215
- def phase_for(greatest_jd, &contact_value)
297
+ def phase_for(greatest_jd, tangency, &contact_value)
216
298
  value_at = ->(jd) { contact_value.call(geometry_at(jd)) }
217
299
  return nil unless value_at.call(greatest_jd).negative?
218
300
 
@@ -228,15 +310,23 @@ module Astronoby
228
310
  )
229
311
  return nil unless starting && ending
230
312
 
231
- EclipsePhase.new(
232
- starting_instant: Instant.from_terrestrial_time(starting),
233
- ending_instant: Instant.from_terrestrial_time(ending)
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)
234
322
  )
235
323
  end
236
324
 
237
325
  # Bisects for the single contact between +outside_jd+ (value positive, the
238
326
  # Moon outside the boundary) and +inside_jd+ (value negative, at greatest
239
- # eclipse). Returns nil if the boundary is not crossed within the window.
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.
240
330
  def bisect_contact(value_at, outside_jd, inside_jd)
241
331
  return nil unless value_at.call(outside_jd).positive?
242
332
 
@@ -248,7 +338,7 @@ module Astronoby
248
338
  outside_jd = midpoint
249
339
  end
250
340
  end
251
- (outside_jd + inside_jd) / 2.0
341
+ [(outside_jd + inside_jd) / 2.0, geometry_at(inside_jd)]
252
342
  end
253
343
 
254
344
  # Builds the geometry at a Julian Date (TT) from the apparent geocentric
@@ -262,22 +352,44 @@ module Astronoby
262
352
 
263
353
  moon_distance = moon.distance.km
264
354
  sun_distance = sun.distance.km
265
- axis_angle = Math::PI - sun.separation_from(moon).radians
266
- axial_distance = moon_distance * Math.cos(axis_angle)
267
- perpendicular_distance = moon_distance * Math.sin(axis_angle)
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
268
375
 
269
376
  # Danjon enlargement: enlarge Earth's radius before building the cones.
270
- earth_radius = EARTH_RADIUS_KM * SHADOW_ENLARGEMENT
377
+ earth_radius = EARTH_RADIUS_KM + SHADOW_ATMOSPHERE_KM
271
378
  umbra_half_angle_tangent = (SUN_RADIUS_KM - earth_radius) / sun_distance
272
379
  penumbra_half_angle_tangent =
273
380
  (SUN_RADIUS_KM + earth_radius) / sun_distance
274
- latitude_sign = moon.ecliptic.latitude.degrees.negative? ? -1 : 1
275
381
 
276
382
  Geometry.new(
277
383
  axis_distance: perpendicular_distance,
278
384
  umbra_radius: earth_radius - axial_distance * umbra_half_angle_tangent,
279
385
  penumbra_radius: earth_radius + axial_distance * penumbra_half_angle_tangent,
280
- gamma: latitude_sign * perpendicular_distance / EARTH_RADIUS_KM
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
281
393
  )
282
394
  end
283
395
  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