astronoby 0.9.0 → 0.10.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/CHANGELOG.md +101 -0
  4. data/README.md +6 -1
  5. data/UPGRADING.md +84 -0
  6. data/docs/README.md +80 -15
  7. data/docs/angles.md +1 -0
  8. data/docs/configuration.md +20 -17
  9. data/docs/coordinates.md +72 -12
  10. data/docs/deep_sky_bodies.md +1 -1
  11. data/docs/ephem.md +5 -2
  12. data/docs/equinoxes_solstices_times.md +4 -3
  13. data/docs/glossary.md +97 -1
  14. data/docs/iers.md +40 -0
  15. data/docs/instant.md +20 -15
  16. data/docs/lunar_eclipses.md +93 -0
  17. data/docs/lunar_observation.md +87 -0
  18. data/docs/moon_phases.md +4 -1
  19. data/docs/observer.md +20 -6
  20. data/docs/planetary_phenomena.md +78 -0
  21. data/docs/reference_frames.md +192 -34
  22. data/docs/rise_transit_set_times.md +6 -4
  23. data/docs/solar_system_bodies.md +26 -4
  24. data/docs/twilight_times.md +25 -21
  25. data/lib/astronoby/angle.rb +63 -2
  26. data/lib/astronoby/angles/dms.rb +18 -1
  27. data/lib/astronoby/angles/hms.rb +14 -1
  28. data/lib/astronoby/angular_velocity.rb +21 -0
  29. data/lib/astronoby/bodies/deep_sky_object.rb +6 -1
  30. data/lib/astronoby/bodies/deep_sky_object_position.rb +32 -17
  31. data/lib/astronoby/bodies/earth.rb +7 -44
  32. data/lib/astronoby/bodies/jupiter.rb +10 -0
  33. data/lib/astronoby/bodies/mars.rb +10 -0
  34. data/lib/astronoby/bodies/mercury.rb +10 -0
  35. data/lib/astronoby/bodies/moon.rb +158 -32
  36. data/lib/astronoby/bodies/neptune.rb +10 -0
  37. data/lib/astronoby/bodies/saturn.rb +10 -0
  38. data/lib/astronoby/bodies/solar_system_body.rb +240 -61
  39. data/lib/astronoby/bodies/sun.rb +79 -4
  40. data/lib/astronoby/bodies/uranus.rb +10 -0
  41. data/lib/astronoby/bodies/venus.rb +10 -0
  42. data/lib/astronoby/body.rb +6 -0
  43. data/lib/astronoby/center.rb +84 -0
  44. data/lib/astronoby/constellation.rb +9 -1
  45. data/lib/astronoby/coordinates/ecliptic.rb +10 -1
  46. data/lib/astronoby/coordinates/equatorial.rb +64 -8
  47. data/lib/astronoby/coordinates/geodetic.rb +102 -0
  48. data/lib/astronoby/coordinates/horizontal.rb +13 -1
  49. data/lib/astronoby/distance.rb +35 -0
  50. data/lib/astronoby/duration.rb +116 -0
  51. data/lib/astronoby/earth_rotation.rb +70 -0
  52. data/lib/astronoby/equinox_solstice.rb +31 -8
  53. data/lib/astronoby/errors.rb +11 -0
  54. data/lib/astronoby/events/conjunction.rb +51 -0
  55. data/lib/astronoby/events/conjunction_opposition_calculator.rb +84 -0
  56. data/lib/astronoby/events/eclipse_phase.rb +27 -0
  57. data/lib/astronoby/events/extremum_calculator.rb +23 -176
  58. data/lib/astronoby/events/greatest_elongation.rb +58 -0
  59. data/lib/astronoby/events/greatest_elongation_calculator.rb +56 -0
  60. data/lib/astronoby/events/lunar_eclipse.rb +99 -0
  61. data/lib/astronoby/events/lunar_eclipse_calculator.rb +285 -0
  62. data/lib/astronoby/events/opposition.rb +19 -0
  63. data/lib/astronoby/events/rise_transit_set_event.rb +12 -1
  64. data/lib/astronoby/events/rise_transit_set_events.rb +12 -1
  65. data/lib/astronoby/events/twilight_event.rb +24 -6
  66. data/lib/astronoby/events/twilight_events.rb +26 -6
  67. data/lib/astronoby/extremum_finder.rb +148 -0
  68. data/lib/astronoby/instant.rb +10 -7
  69. data/lib/astronoby/libration.rb +25 -0
  70. data/lib/astronoby/mean_obliquity.rb +8 -0
  71. data/lib/astronoby/moon_orientation_ephemeris.rb +69 -0
  72. data/lib/astronoby/moon_physical_ephemeris.rb +263 -0
  73. data/lib/astronoby/nutation.rb +10 -20
  74. data/lib/astronoby/observer.rb +67 -49
  75. data/lib/astronoby/orientation.rb +107 -0
  76. data/lib/astronoby/position.rb +16 -0
  77. data/lib/astronoby/precession.rb +61 -60
  78. data/lib/astronoby/reference_frame.rb +73 -7
  79. data/lib/astronoby/reference_frames/apparent.rb +26 -7
  80. data/lib/astronoby/reference_frames/astrometric.rb +14 -1
  81. data/lib/astronoby/reference_frames/geometric.rb +7 -1
  82. data/lib/astronoby/reference_frames/mean_of_date.rb +13 -1
  83. data/lib/astronoby/reference_frames/teme.rb +153 -0
  84. data/lib/astronoby/reference_frames/topocentric.rb +30 -4
  85. data/lib/astronoby/refraction.rb +26 -5
  86. data/lib/astronoby/root_finder.rb +83 -0
  87. data/lib/astronoby/rotation.rb +49 -0
  88. data/lib/astronoby/time/greenwich_apparent_sidereal_time.rb +9 -0
  89. data/lib/astronoby/time/greenwich_mean_sidereal_time.rb +42 -5
  90. data/lib/astronoby/time/greenwich_sidereal_time.rb +21 -0
  91. data/lib/astronoby/time/local_apparent_sidereal_time.rb +21 -0
  92. data/lib/astronoby/time/local_mean_sidereal_time.rb +21 -0
  93. data/lib/astronoby/time/local_sidereal_time.rb +24 -0
  94. data/lib/astronoby/time/sidereal_time.rb +23 -1
  95. data/lib/astronoby/true_obliquity.rb +4 -0
  96. data/lib/astronoby/util/maths.rb +8 -0
  97. data/lib/astronoby/util/time.rb +10 -485
  98. data/lib/astronoby/vector.rb +10 -0
  99. data/lib/astronoby/velocity.rb +39 -0
  100. data/lib/astronoby/version.rb +1 -1
  101. data/lib/astronoby.rb +22 -0
  102. metadata +45 -5
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astronoby
4
+ class GreatestElongation
5
+ EASTERN = :eastern
6
+ WESTERN = :western
7
+
8
+ # @param instant [Astronoby::Instant] when the greatest elongation occurs
9
+ # @param body [Astronoby::Body] the body reaching greatest elongation
10
+ # @param angle [Astronoby::Angle] the Sun-Earth-body angle at that instant
11
+ # @return [Astronoby::GreatestElongation] a greatest eastern elongation
12
+ def self.eastern(instant:, body:, angle:)
13
+ new(instant: instant, body: body, angle: angle, direction: EASTERN)
14
+ end
15
+
16
+ # @param instant [Astronoby::Instant] when the greatest elongation occurs
17
+ # @param body [Astronoby::Body] the body reaching greatest elongation
18
+ # @param angle [Astronoby::Angle] the Sun-Earth-body angle at that instant
19
+ # @return [Astronoby::GreatestElongation] a greatest western elongation
20
+ def self.western(instant:, body:, angle:)
21
+ new(instant: instant, body: body, angle: angle, direction: WESTERN)
22
+ end
23
+
24
+ # @return [Astronoby::Instant] when the greatest elongation occurs
25
+ attr_reader :instant
26
+
27
+ # @return [Astronoby::Body] the body reaching greatest elongation
28
+ attr_reader :body
29
+
30
+ # @return [Astronoby::Angle] the Sun-Earth-body angle at that instant
31
+ attr_reader :angle
32
+
33
+ # @return [Symbol] +EASTERN+ or +WESTERN+
34
+ attr_reader :direction
35
+
36
+ # @param instant [Astronoby::Instant] when the greatest elongation occurs
37
+ # @param body [Astronoby::Body] the body reaching greatest elongation
38
+ # @param angle [Astronoby::Angle] the Sun-Earth-body angle at that instant
39
+ # @param direction [Symbol] +EASTERN+ or +WESTERN+
40
+ def initialize(instant:, body:, angle:, direction:)
41
+ @instant = instant
42
+ @body = body
43
+ @angle = angle
44
+ @direction = direction
45
+ freeze
46
+ end
47
+
48
+ # @return [Boolean] true for a greatest eastern elongation
49
+ def eastern?
50
+ @direction == EASTERN
51
+ end
52
+
53
+ # @return [Boolean] true for a greatest western elongation
54
+ def western?
55
+ @direction == WESTERN
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astronoby
4
+ class GreatestElongationCalculator
5
+ # @param body [Astronoby::SolarSystemBody] the planet to track
6
+ # @param ephem [::Ephem::SPK] ephemeris data source
7
+ # @param samples_per_period [Integer] number of samples per synodic period
8
+ def initialize(body:, ephem:, samples_per_period: 60)
9
+ @body = body
10
+ @ephem = ephem
11
+ @samples_per_period = samples_per_period
12
+ end
13
+
14
+ # @param start_time [Time] start time
15
+ # @param end_time [Time] end time
16
+ # @return [Array<Astronoby::GreatestElongation>] greatest elongations in
17
+ # the range
18
+ def greatest_elongation_events_between(start_time, end_time)
19
+ finder.extrema(
20
+ Instant.from_time(start_time).tt,
21
+ Instant.from_time(end_time).tt,
22
+ type: :maximum
23
+ ).map { |extremum| build_event(extremum) }
24
+ end
25
+
26
+ private
27
+
28
+ def finder
29
+ @finder ||= ExtremumFinder.new(
30
+ value_at: ->(jd) { planet_at(jd).elongation },
31
+ period: synodic_period,
32
+ samples_per_period: @samples_per_period
33
+ )
34
+ end
35
+
36
+ def build_event(extremum)
37
+ instant = Instant.from_terrestrial_time(extremum[:jd])
38
+ angle = extremum[:value]
39
+
40
+ if planet_at(instant.tt).eastern?
41
+ GreatestElongation.eastern(instant: instant, body: @body, angle: angle)
42
+ else
43
+ GreatestElongation.western(instant: instant, body: @body, angle: angle)
44
+ end
45
+ end
46
+
47
+ def planet_at(jd)
48
+ @body.new(instant: Instant.from_terrestrial_time(jd), ephem: @ephem)
49
+ end
50
+
51
+ def synodic_period
52
+ @synodic_period ||=
53
+ 1.0 / ((1.0 / @body::ORBITAL_PERIOD) - (1.0 / Earth::ORBITAL_PERIOD)).abs
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astronoby
4
+ # A lunar eclipse: a geocentric passage of the Moon through Earth's shadow.
5
+ # Immutable; built by LunarEclipseCalculator.
6
+ #
7
+ # The penumbral phase is always present. The partial phase is present for
8
+ # partial and total eclipses, and the total phase only for total eclipses.
9
+ class LunarEclipse
10
+ PENUMBRAL = :penumbral
11
+ PARTIAL = :partial
12
+ TOTAL = :total
13
+
14
+ # @return [Astronoby::Instant] greatest eclipse, when the Moon's centre is
15
+ # least distant from the axis of Earth's shadow
16
+ attr_reader :instant
17
+ alias_method :greatest_eclipse, :instant
18
+
19
+ # @return [Symbol] +PENUMBRAL+, +PARTIAL+ or +TOTAL+
20
+ attr_reader :kind
21
+
22
+ # @return [Float] fraction of the Moon's diameter immersed in the umbra at
23
+ # greatest eclipse (negative when the Moon misses the umbra)
24
+ attr_reader :umbral_magnitude
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
34
+
35
+ # @return [Astronoby::Distance] least distance of the Moon's centre from the
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)
41
+ attr_reader :penumbral
42
+
43
+ # @return [Astronoby::EclipsePhase, nil] the partial phase, present for
44
+ # partial and total eclipses
45
+ attr_reader :partial
46
+
47
+ # @return [Astronoby::EclipsePhase, nil] the total phase (totality),
48
+ # present only for total eclipses
49
+ attr_reader :total
50
+
51
+ # @param instant [Astronoby::Instant] greatest eclipse
52
+ # @param kind [Symbol] +PENUMBRAL+, +PARTIAL+ or +TOTAL+
53
+ # @param umbral_magnitude [Float] umbral magnitude at greatest eclipse
54
+ # @param penumbral_magnitude [Float] penumbral magnitude at greatest eclipse
55
+ # @param gamma [Float] least distance from the shadow axis, in Earth radii
56
+ # @param shadow_axis_distance [Astronoby::Distance] least distance from the
57
+ # shadow axis
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
61
+ def initialize(
62
+ instant:,
63
+ kind:,
64
+ umbral_magnitude:,
65
+ penumbral_magnitude:,
66
+ gamma:,
67
+ shadow_axis_distance:,
68
+ penumbral:,
69
+ partial: nil,
70
+ total: nil
71
+ )
72
+ @instant = instant
73
+ @kind = kind
74
+ @umbral_magnitude = umbral_magnitude
75
+ @penumbral_magnitude = penumbral_magnitude
76
+ @gamma = gamma
77
+ @shadow_axis_distance = shadow_axis_distance
78
+ @penumbral = penumbral
79
+ @partial = partial
80
+ @total = total
81
+ freeze
82
+ end
83
+
84
+ # @return [Boolean] true for a penumbral eclipse (the Moon misses the umbra)
85
+ def penumbral?
86
+ @kind == PENUMBRAL
87
+ end
88
+
89
+ # @return [Boolean] true for a partial eclipse
90
+ def partial?
91
+ @kind == PARTIAL
92
+ end
93
+
94
+ # @return [Boolean] true for a total eclipse
95
+ def total?
96
+ @kind == TOTAL
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,285 @@
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): 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
30
+
31
+ SUN_RADIUS_KM = Sun::EQUATORIAL_RADIUS.km
32
+ MOON_RADIUS_KM = Moon::EQUATORIAL_RADIUS.km
33
+ EARTH_RADIUS_KM =
34
+ Constants::WGS84_EARTH_EQUATORIAL_RADIUS_IN_METERS / 1000.0
35
+
36
+ # Largest distance of the Moon's centre from the shadow axis, in Earth radii,
37
+ # at which any (penumbral) eclipse is still possible is about 1.57. Full moons
38
+ # whose seed already exceeds this margin cannot be eclipses and skip the
39
+ # minimum search entirely.
40
+ MAX_ECLIPSE_GAMMA = 1.8
41
+
42
+ # Half-window, in days, for the local greatest-eclipse search around a full
43
+ # moon. A lunar eclipse occurs within minutes of full moon.
44
+ GREATEST_HALF_WINDOW = 0.25
45
+
46
+ # Half-window, in days, for the contact search around greatest eclipse. Wide
47
+ # enough to bracket the longest penumbral phase (about 3 hours each side).
48
+ CONTACT_HALF_WINDOW = 0.21
49
+
50
+ SEARCH_SAMPLES = 48
51
+
52
+ # Bisection tolerance, in days, for a contact time. ~1e-7 day is ~8.6 ms,
53
+ # well below the one-second resolution the contacts are reported at.
54
+ CONTACT_TOLERANCE = 1e-7
55
+
56
+ # Geometry of the Sun, Moon and Earth's shadow at one instant, in kilometres
57
+ # in the plane perpendicular to the shadow axis at the Moon's distance.
58
+ class Geometry
59
+ # @return [Float] distance of the Moon's centre from the shadow axis (km)
60
+ attr_reader :axis_distance
61
+
62
+ # @return [Float] radius of the umbra at the Moon's distance (km)
63
+ attr_reader :umbra_radius
64
+
65
+ # @return [Float] radius of the penumbra at the Moon's distance (km)
66
+ attr_reader :penumbra_radius
67
+
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:)
72
+ @axis_distance = axis_distance
73
+ @umbra_radius = umbra_radius
74
+ @penumbra_radius = penumbra_radius
75
+ @gamma = gamma
76
+ freeze
77
+ end
78
+
79
+ def umbral_magnitude
80
+ (umbra_radius + MOON_RADIUS_KM - axis_distance) / (2 * MOON_RADIUS_KM)
81
+ end
82
+
83
+ def penumbral_magnitude
84
+ (
85
+ penumbra_radius + MOON_RADIUS_KM - axis_distance
86
+ ) / (2 * MOON_RADIUS_KM)
87
+ end
88
+
89
+ def penumbral_contact_value
90
+ axis_distance - (penumbra_radius + MOON_RADIUS_KM)
91
+ end
92
+
93
+ def partial_contact_value
94
+ axis_distance - (umbra_radius + MOON_RADIUS_KM)
95
+ end
96
+
97
+ def total_contact_value
98
+ axis_distance - (umbra_radius - MOON_RADIUS_KM)
99
+ end
100
+ end
101
+
102
+ # @param ephem [::Ephem::SPK] ephemeris data source
103
+ def initialize(ephem:)
104
+ @ephem = ephem
105
+ @geometry_cache = {}
106
+ end
107
+
108
+ # @param start_time [Time] start time
109
+ # @param end_time [Time] end time
110
+ # @return [Array<Astronoby::LunarEclipse>] eclipses whose greatest instant
111
+ # lies in the range, sorted by time
112
+ def events_between(start_time, end_time)
113
+ full_moon_seeds(start_time, end_time)
114
+ .filter_map { |seed_jd| eclipse_near(seed_jd) }
115
+ .select do |eclipse|
116
+ eclipse.instant.to_time.between?(start_time, end_time)
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ # Full moons in the range, padded by a day so an eclipse near a boundary is
123
+ # not missed. Seeded analytically (Meeus, chapter 49) at no ephemeris cost.
124
+ def full_moon_seeds(start_time, end_time)
125
+ padded_start = start_time - Constants::SECONDS_PER_DAY
126
+ padded_end = end_time + Constants::SECONDS_PER_DAY
127
+ year_months(padded_start, padded_end)
128
+ .flat_map do |year, month|
129
+ Events::MoonPhases.phases_for(year: year, month: month)
130
+ end
131
+ .select { |phase| phase.phase == :full_moon }
132
+ .map(&:time)
133
+ .select { |time| time.between?(padded_start, padded_end) }
134
+ .map { |time| Instant.from_time(time).tt }
135
+ end
136
+
137
+ def year_months(from, to)
138
+ cursor = Date.new(from.to_date.year, from.to_date.month, 1)
139
+ last = Date.new(to.to_date.year, to.to_date.month, 1)
140
+ months = []
141
+ while cursor <= last
142
+ months << [cursor.year, cursor.month]
143
+ cursor = cursor.next_month
144
+ end
145
+ months
146
+ end
147
+
148
+ # Resolves the eclipse for one full-moon seed, if any.
149
+ def eclipse_near(seed_jd)
150
+ if geometry_at(seed_jd).axis_distance > MAX_ECLIPSE_GAMMA * EARTH_RADIUS_KM
151
+ return nil
152
+ end
153
+
154
+ greatest_jd = greatest_eclipse_jd(seed_jd)
155
+ greatest_jd && eclipse_at(greatest_jd)
156
+ ensure
157
+ @geometry_cache.clear
158
+ end
159
+
160
+ # Greatest eclipse: the least distance of the Moon's centre from the shadow
161
+ # axis, found by minimising it on a narrow window around the full moon.
162
+ def greatest_eclipse_jd(seed_jd)
163
+ ExtremumFinder
164
+ .new(
165
+ value_at: ->(jd) { geometry_at(jd).axis_distance },
166
+ period: 2 * GREATEST_HALF_WINDOW,
167
+ samples_per_period: SEARCH_SAMPLES
168
+ )
169
+ .extrema(
170
+ seed_jd - GREATEST_HALF_WINDOW,
171
+ seed_jd + GREATEST_HALF_WINDOW,
172
+ type: :minimum
173
+ )
174
+ .min_by { |extremum| extremum[:value] }
175
+ &.fetch(:jd)
176
+ end
177
+
178
+ def eclipse_at(greatest_jd)
179
+ geometry = geometry_at(greatest_jd)
180
+ penumbral = phase_for(greatest_jd, &:penumbral_contact_value)
181
+ return nil if penumbral.nil?
182
+
183
+ partial = phase_for(greatest_jd, &:partial_contact_value)
184
+ total = phase_for(greatest_jd, &:total_contact_value)
185
+
186
+ LunarEclipse.new(
187
+ instant: Instant.from_terrestrial_time(greatest_jd),
188
+ 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),
193
+ penumbral: penumbral,
194
+ partial: partial,
195
+ total: total
196
+ )
197
+ end
198
+
199
+ def kind_for(partial, total)
200
+ if total
201
+ LunarEclipse::TOTAL
202
+ elsif partial
203
+ LunarEclipse::PARTIAL
204
+ else
205
+ LunarEclipse::PENUMBRAL
206
+ end
207
+ end
208
+
209
+ # Builds a phase from a contact function. The phase occurs only when the
210
+ # Moon is inside the boundary at greatest eclipse (contact value negative);
211
+ # each contact is then the single crossing between greatest eclipse and the
212
+ # corresponding edge of the window, found by bisection. This is robust to
213
+ # arbitrarily short phases (a barely-total or grazing eclipse), unlike a
214
+ # fixed-resolution scan that can step over a brief crossing.
215
+ def phase_for(greatest_jd, &contact_value)
216
+ value_at = ->(jd) { contact_value.call(geometry_at(jd)) }
217
+ return nil unless value_at.call(greatest_jd).negative?
218
+
219
+ starting = bisect_contact(
220
+ value_at,
221
+ greatest_jd - CONTACT_HALF_WINDOW,
222
+ greatest_jd
223
+ )
224
+ ending = bisect_contact(
225
+ value_at,
226
+ greatest_jd + CONTACT_HALF_WINDOW,
227
+ greatest_jd
228
+ )
229
+ return nil unless starting && ending
230
+
231
+ EclipsePhase.new(
232
+ starting_instant: Instant.from_terrestrial_time(starting),
233
+ ending_instant: Instant.from_terrestrial_time(ending)
234
+ )
235
+ end
236
+
237
+ # Bisects for the single contact between +outside_jd+ (value positive, the
238
+ # Moon outside the boundary) and +inside_jd+ (value negative, at greatest
239
+ # eclipse). Returns nil if the boundary is not crossed within the window.
240
+ def bisect_contact(value_at, outside_jd, inside_jd)
241
+ return nil unless value_at.call(outside_jd).positive?
242
+
243
+ while (inside_jd - outside_jd).abs > CONTACT_TOLERANCE
244
+ midpoint = (outside_jd + inside_jd) / 2.0
245
+ if value_at.call(midpoint).negative?
246
+ inside_jd = midpoint
247
+ else
248
+ outside_jd = midpoint
249
+ end
250
+ end
251
+ (outside_jd + inside_jd) / 2.0
252
+ end
253
+
254
+ # Builds the geometry at a Julian Date (TT) from the apparent geocentric
255
+ # positions of the Sun and Moon. Memoised so repeated evaluations during the
256
+ # searches reuse the same computation.
257
+ def geometry_at(jd)
258
+ @geometry_cache[jd] ||= begin
259
+ instant = Instant.from_terrestrial_time(jd)
260
+ moon = Moon.new(ephem: @ephem, instant: instant).apparent
261
+ sun = Sun.new(ephem: @ephem, instant: instant).apparent
262
+
263
+ moon_distance = moon.distance.km
264
+ 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)
268
+
269
+ # Danjon enlargement: enlarge Earth's radius before building the cones.
270
+ earth_radius = EARTH_RADIUS_KM * SHADOW_ENLARGEMENT
271
+ umbra_half_angle_tangent = (SUN_RADIUS_KM - earth_radius) / sun_distance
272
+ penumbra_half_angle_tangent =
273
+ (SUN_RADIUS_KM + earth_radius) / sun_distance
274
+ latitude_sign = moon.ecliptic.latitude.degrees.negative? ? -1 : 1
275
+
276
+ Geometry.new(
277
+ axis_distance: perpendicular_distance,
278
+ umbra_radius: earth_radius - axial_distance * umbra_half_angle_tangent,
279
+ penumbra_radius: earth_radius + axial_distance * penumbra_half_angle_tangent,
280
+ gamma: latitude_sign * perpendicular_distance / EARTH_RADIUS_KM
281
+ )
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astronoby
4
+ class Opposition
5
+ # @return [Astronoby::Instant] when the opposition occurs
6
+ attr_reader :instant
7
+
8
+ # @return [Astronoby::Body] the body in opposition with the Sun
9
+ attr_reader :body
10
+
11
+ # @param instant [Astronoby::Instant] when the opposition occurs
12
+ # @param body [Astronoby::Body] the body in opposition with the Sun
13
+ def initialize(instant:, body:)
14
+ @instant = instant
15
+ @body = body
16
+ freeze
17
+ end
18
+ end
19
+ end
@@ -1,9 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Astronoby
4
+ # Holds the rising, transit, and setting times for a single day.
4
5
  class RiseTransitSetEvent
5
- attr_reader :rising_time, :transit_time, :setting_time
6
+ # @return [Time, nil] the rising time
7
+ attr_reader :rising_time
6
8
 
9
+ # @return [Time, nil] the transit (culmination) time
10
+ attr_reader :transit_time
11
+
12
+ # @return [Time, nil] the setting time
13
+ attr_reader :setting_time
14
+
15
+ # @param rising [Time, nil] the rising time
16
+ # @param transit [Time, nil] the transit time
17
+ # @param setting [Time, nil] the setting time
7
18
  def initialize(rising, transit, setting)
8
19
  @rising_time = rising
9
20
  @transit_time = transit
@@ -1,9 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Astronoby
4
+ # Holds arrays of rising, transit, and setting times over a time range.
4
5
  class RiseTransitSetEvents
5
- attr_reader :rising_times, :transit_times, :setting_times
6
+ # @return [Array<Time>] rising times
7
+ attr_reader :rising_times
6
8
 
9
+ # @return [Array<Time>] transit (culmination) times
10
+ attr_reader :transit_times
11
+
12
+ # @return [Array<Time>] setting times
13
+ attr_reader :setting_times
14
+
15
+ # @param risings [Array<Time>] rising times
16
+ # @param transits [Array<Time>] transit times
17
+ # @param settings [Array<Time>] setting times
7
18
  def initialize(risings, transits, settings)
8
19
  @rising_times = risings
9
20
  @transit_times = transits
@@ -1,14 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Astronoby
4
+ # Holds twilight times (civil, nautical, astronomical) for a single day.
4
5
  class TwilightEvent
5
- attr_reader :morning_civil_twilight_time,
6
- :evening_civil_twilight_time,
7
- :morning_nautical_twilight_time,
8
- :evening_nautical_twilight_time,
9
- :morning_astronomical_twilight_time,
10
- :evening_astronomical_twilight_time
6
+ # @return [Time, nil] morning civil twilight time
7
+ attr_reader :morning_civil_twilight_time
11
8
 
9
+ # @return [Time, nil] evening civil twilight time
10
+ attr_reader :evening_civil_twilight_time
11
+
12
+ # @return [Time, nil] morning nautical twilight time
13
+ attr_reader :morning_nautical_twilight_time
14
+
15
+ # @return [Time, nil] evening nautical twilight time
16
+ attr_reader :evening_nautical_twilight_time
17
+
18
+ # @return [Time, nil] morning astronomical twilight time
19
+ attr_reader :morning_astronomical_twilight_time
20
+
21
+ # @return [Time, nil] evening astronomical twilight time
22
+ attr_reader :evening_astronomical_twilight_time
23
+
24
+ # @param morning_civil_twilight_time [Time, nil]
25
+ # @param evening_civil_twilight_time [Time, nil]
26
+ # @param morning_nautical_twilight_time [Time, nil]
27
+ # @param evening_nautical_twilight_time [Time, nil]
28
+ # @param morning_astronomical_twilight_time [Time, nil]
29
+ # @param evening_astronomical_twilight_time [Time, nil]
12
30
  def initialize(
13
31
  morning_civil_twilight_time: nil,
14
32
  evening_civil_twilight_time: nil,
@@ -1,14 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Astronoby
4
+ # Holds arrays of twilight times over a time range.
4
5
  class TwilightEvents
5
- attr_reader :morning_civil_twilight_times,
6
- :evening_civil_twilight_times,
7
- :morning_nautical_twilight_times,
8
- :evening_nautical_twilight_times,
9
- :morning_astronomical_twilight_times,
10
- :evening_astronomical_twilight_times
6
+ # @return [Array<Time>] morning civil twilight times
7
+ attr_reader :morning_civil_twilight_times
11
8
 
9
+ # @return [Array<Time>] evening civil twilight times
10
+ attr_reader :evening_civil_twilight_times
11
+
12
+ # @return [Array<Time>] morning nautical twilight times
13
+ attr_reader :morning_nautical_twilight_times
14
+
15
+ # @return [Array<Time>] evening nautical twilight times
16
+ attr_reader :evening_nautical_twilight_times
17
+
18
+ # @return [Array<Time>] morning astronomical twilight times
19
+ attr_reader :morning_astronomical_twilight_times
20
+
21
+ # @return [Array<Time>] evening astronomical twilight times
22
+ attr_reader :evening_astronomical_twilight_times
23
+
24
+ # @param morning_civil [Array<Time>] morning civil twilight times
25
+ # @param evening_civil [Array<Time>] evening civil twilight times
26
+ # @param morning_nautical [Array<Time>] morning nautical twilight times
27
+ # @param evening_nautical [Array<Time>] evening nautical twilight times
28
+ # @param morning_astronomical [Array<Time>] morning astronomical twilight
29
+ # times
30
+ # @param evening_astronomical [Array<Time>] evening astronomical twilight
31
+ # times
12
32
  def initialize(
13
33
  morning_civil,
14
34
  evening_civil,