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
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astronoby
4
+ class LunarEclipseVisibility
5
+ NOT_VISIBLE = :not_visible
6
+ ENTIRELY_VISIBLE = :entirely_visible
7
+ BEGINS_BEFORE_MOONRISE = :begins_before_moonrise
8
+ ENDS_AFTER_MOONSET = :ends_after_moonset
9
+ PARTIALLY_VISIBLE = :partially_visible
10
+
11
+ PHASES = [
12
+ LunarEclipse::PENUMBRAL,
13
+ LunarEclipse::PARTIAL,
14
+ LunarEclipse::TOTAL
15
+ ].freeze
16
+
17
+ SAMPLING_INTERVAL_IN_DAYS =
18
+ 3.0 / Constants::HOURS_PER_DAY / Constants::MINUTES_PER_HOUR
19
+
20
+ RADIANS_PER_HOUR =
21
+ Constants::RADIANS_PER_CIRCLE / Constants::HOURS_PER_DAY
22
+
23
+ private_constant :SAMPLING_INTERVAL_IN_DAYS
24
+ private_constant :RADIANS_PER_HOUR
25
+
26
+ # @return [Astronoby::LunarEclipse] the eclipse
27
+ attr_reader :eclipse
28
+
29
+ # @return [Astronoby::Observer] the observer
30
+ attr_reader :observer
31
+
32
+ # @param eclipse [Astronoby::LunarEclipse] the eclipse
33
+ # @param observer [Astronoby::Observer] the observer
34
+ def initialize(eclipse:, observer:)
35
+ @eclipse = eclipse
36
+ @observer = observer
37
+
38
+ nodes = interpolation_nodes(eclipse)
39
+ @node_times = nodes.map { |node| node[:jd] }
40
+ @node_declinations = nodes.map { |node| node[:declination] }
41
+ @node_distances = nodes.map { |node| node[:distance] }
42
+ @node_right_ascensions = unwrapped(
43
+ nodes.map { |node| node[:right_ascension] }
44
+ )
45
+
46
+ @window_start = eclipse.penumbral.starting_instant.tt
47
+ @window_end = eclipse.penumbral.ending_instant.tt
48
+
49
+ @anchor = eclipse.instant.tt
50
+ @anchor_sidereal_hours = eclipse.instant.gast
51
+
52
+ @sin_latitude = observer.latitude.sin
53
+ @cos_latitude = observer.latitude.cos
54
+ @longitude = observer.longitude.radians
55
+
56
+ position = observer.geocentric_position
57
+ @observer_x = position.x.m
58
+ @observer_y = position.y.m
59
+ @observer_z = position.z.m
60
+
61
+ @memo = {}
62
+ freeze
63
+ end
64
+
65
+ # @return [Boolean] true if any part of the eclipse happens with the Moon
66
+ # above the horizon
67
+ def visible?
68
+ !observable_windows.empty?
69
+ end
70
+
71
+ # @return [Boolean] true if the Moon is above the horizon from the first to
72
+ # the last penumbral contact
73
+ def entirely_visible?
74
+ observable_windows.one? &&
75
+ above_horizon?(@window_start) &&
76
+ above_horizon?(@window_end)
77
+ end
78
+
79
+ # @return [Symbol] +NOT_VISIBLE+, +ENTIRELY_VISIBLE+,
80
+ # +BEGINS_BEFORE_MOONRISE+, +ENDS_AFTER_MOONSET+ or +PARTIALLY_VISIBLE+
81
+ def circumstance
82
+ return NOT_VISIBLE unless visible?
83
+ return ENTIRELY_VISIBLE if entirely_visible?
84
+ return PARTIALLY_VISIBLE unless observable_windows.one?
85
+
86
+ if above_horizon?(@window_end)
87
+ BEGINS_BEFORE_MOONRISE
88
+ elsif above_horizon?(@window_start)
89
+ ENDS_AFTER_MOONSET
90
+ else
91
+ PARTIALLY_VISIBLE
92
+ end
93
+ end
94
+
95
+ # @param phase [Symbol] +LunarEclipse::PENUMBRAL+,
96
+ # +LunarEclipse::PARTIAL+ or +LunarEclipse::TOTAL+
97
+ # @return [Symbol, nil] +ENTIRELY_VISIBLE+, +PARTIALLY_VISIBLE+ or
98
+ # +NOT_VISIBLE+, and nil when the eclipse has no such phase
99
+ # @raise [Astronoby::UnsupportedEventError] if the phase is not a phase of
100
+ # a lunar eclipse
101
+ def coverage(phase)
102
+ unless PHASES.include?(phase)
103
+ raise UnsupportedEventError,
104
+ "phase must be one of #{PHASES.join(", ")}, got #{phase.inspect}"
105
+ end
106
+
107
+ eclipse_phase = @eclipse.public_send(phase)
108
+ return nil if eclipse_phase.nil?
109
+
110
+ starting = eclipse_phase.starting_instant.tt
111
+ ending = eclipse_phase.ending_instant.tt
112
+ crossed_within = horizon_crossing_times.any? do |time|
113
+ time > starting && time < ending
114
+ end
115
+ starts_up = above_horizon?(starting)
116
+ ends_up = above_horizon?(ending)
117
+
118
+ if crossed_within
119
+ PARTIALLY_VISIBLE
120
+ elsif starts_up && ends_up
121
+ ENTIRELY_VISIBLE
122
+ elsif starts_up || ends_up
123
+ PARTIALLY_VISIBLE
124
+ else
125
+ NOT_VISIBLE
126
+ end
127
+ end
128
+
129
+ # @param instant [Astronoby::Instant] an instant of the eclipse
130
+ # @return [Boolean] true if the Moon is above the horizon then
131
+ # @raise [ArgumentError] if the instant is outside the eclipse
132
+ def above_horizon_at?(instant)
133
+ above_horizon?(within_eclipse(instant))
134
+ end
135
+
136
+ # @return [Array<Astronoby::Instant>] the instants of the eclipse at which
137
+ # the Moon rises or sets, in time order
138
+ def horizon_crossings
139
+ @memo[:horizon_crossings] ||= horizon_crossing_times
140
+ .map { |time| Instant.from_terrestrial_time(time) }
141
+ .freeze
142
+ end
143
+
144
+ # @return [Array<Range<Astronoby::Instant>>] the observable stretches, in
145
+ # time order
146
+ def observable_windows
147
+ @memo[:observable_windows] ||= begin
148
+ boundaries = [@window_start, *horizon_crossing_times, @window_end]
149
+ boundaries.each_cons(2).filter_map do |starting, ending|
150
+ next unless above_horizon?((starting + ending) / 2.0)
151
+
152
+ Range.new(
153
+ Instant.from_terrestrial_time(starting),
154
+ Instant.from_terrestrial_time(ending)
155
+ )
156
+ end.freeze
157
+ end
158
+ end
159
+
160
+ private
161
+
162
+ def interpolation_nodes(eclipse)
163
+ phases = [eclipse.partial, eclipse.total].compact
164
+ contacts = [
165
+ [eclipse.penumbral.starting_instant, eclipse.penumbral.starting_geometry],
166
+ [eclipse.penumbral.ending_instant, eclipse.penumbral.ending_geometry],
167
+ [eclipse.instant, eclipse.geometry],
168
+ *phases.flat_map do |phase|
169
+ [
170
+ [phase.starting_instant, phase.starting_geometry],
171
+ [phase.ending_instant, phase.ending_geometry]
172
+ ]
173
+ end
174
+ ]
175
+
176
+ contacts.sort_by { |instant, _| instant.tt }.map do |instant, geometry|
177
+ coordinates = geometry.moon_coordinates
178
+
179
+ {
180
+ jd: instant.tt,
181
+ right_ascension: coordinates.right_ascension.radians,
182
+ declination: coordinates.declination.radians,
183
+ distance: geometry.moon_distance.m
184
+ }
185
+ end
186
+ end
187
+
188
+ def unwrapped(right_ascensions)
189
+ right_ascensions.each_with_object([]) do |value, unwrapped|
190
+ previous = unwrapped.last
191
+ if previous.nil?
192
+ unwrapped << value
193
+ else
194
+ turns = ((value - previous) / Constants::RADIANS_PER_CIRCLE).round
195
+ unwrapped << value - turns * Constants::RADIANS_PER_CIRCLE
196
+ end
197
+ end
198
+ end
199
+
200
+ def above_horizon?(time)
201
+ clearance(time).positive?
202
+ end
203
+
204
+ def clearance(time)
205
+ right_ascension, declination, distance = place(time)
206
+ horizon = Horizon::REFRACTION_ANGLE.radians -
207
+ Horizon.moon_semidiameter_radians(distance)
208
+
209
+ altitude_of(right_ascension, declination, distance, time) - horizon
210
+ end
211
+
212
+ def altitude_of(right_ascension, declination, distance, time)
213
+ sidereal = sidereal_radians_at(time)
214
+ cos_declination = Math.cos(declination)
215
+ moon_x = distance * cos_declination * Math.cos(right_ascension)
216
+ moon_y = distance * cos_declination * Math.sin(right_ascension)
217
+ moon_z = distance * Math.sin(declination)
218
+
219
+ cos_sidereal = Math.cos(sidereal)
220
+ sin_sidereal = Math.sin(sidereal)
221
+ observer_x = @observer_x * cos_sidereal - @observer_y * sin_sidereal
222
+ observer_y = @observer_x * sin_sidereal + @observer_y * cos_sidereal
223
+
224
+ x = moon_x - observer_x
225
+ y = moon_y - observer_y
226
+ z = moon_z - @observer_z
227
+
228
+ local_sidereal = sidereal + @longitude
229
+ up_x = @cos_latitude * Math.cos(local_sidereal)
230
+ up_y = @cos_latitude * Math.sin(local_sidereal)
231
+ up_z = @sin_latitude
232
+
233
+ cosine = (x * up_x + y * up_y + z * up_z) /
234
+ Math.sqrt(x * x + y * y + z * z)
235
+
236
+ Math.asin(cosine.clamp(-1.0, 1.0))
237
+ end
238
+
239
+ def sidereal_radians_at(time)
240
+ sidereal_hours = @anchor_sidereal_hours +
241
+ (time - @anchor) *
242
+ Constants::HOURS_PER_DAY *
243
+ GreenwichMeanSiderealTime::UT_TO_SIDEREAL_RATIO
244
+
245
+ sidereal_hours * RADIANS_PER_HOUR
246
+ end
247
+
248
+ def place(time)
249
+ first, second, third = interpolation_window(time)
250
+ x0 = @node_times[first]
251
+ x1 = @node_times[second]
252
+ x2 = @node_times[third]
253
+ w0 = (time - x1) * (time - x2) / ((x0 - x1) * (x0 - x2))
254
+ w1 = (time - x0) * (time - x2) / ((x1 - x0) * (x1 - x2))
255
+ w2 = (time - x0) * (time - x1) / ((x2 - x0) * (x2 - x1))
256
+
257
+ [
258
+ @node_right_ascensions,
259
+ @node_declinations,
260
+ @node_distances
261
+ ].map do |values|
262
+ values[first] * w0 + values[second] * w1 + values[third] * w2
263
+ end
264
+ end
265
+
266
+ def interpolation_window(time)
267
+ nearest = @node_times
268
+ .each_index
269
+ .min_by { |index| (@node_times[index] - time).abs }
270
+ middle = nearest.clamp(1, @node_times.length - 2)
271
+
272
+ [middle - 1, middle, middle + 1]
273
+ end
274
+
275
+ def horizon_crossing_times
276
+ @memo[:horizon_crossing_times] ||= RootFinder
277
+ .new(
278
+ value_at: ->(time) { clearance(time) },
279
+ period: SAMPLING_INTERVAL_IN_DAYS,
280
+ samples_per_period: 1
281
+ )
282
+ .roots(@window_start, @window_end)
283
+ .freeze
284
+ end
285
+
286
+ def within_eclipse(instant)
287
+ time = instant.tt
288
+ unless time.between?(@window_start, @window_end)
289
+ raise ArgumentError,
290
+ "the instant must be between the first and last penumbral contact"
291
+ end
292
+
293
+ time
294
+ end
295
+ end
296
+ end
@@ -18,9 +18,9 @@ module Astronoby
18
18
  TAU = Math::PI * 2
19
19
  SAMPLE_THRESHOLD = 0.8
20
20
  REFINEMENT_ITERATIONS = 3
21
- MIN_TIME_ADJUSTMENT = Constants::MICROSECOND_IN_DAYS
22
- STANDARD_REFRACTION_ANGLE = -Angle.from_dms(0, 34, 0)
23
- SUN_REFRACTION_ANGLE = -Angle.from_dms(0, 50, 0)
21
+ CONVERGENCE_TOLERANCE_IN_DAYS = 0.5 / Constants::SECONDS_PER_DAY
22
+ STANDARD_REFRACTION_ANGLE = Horizon::REFRACTION_ANGLE
23
+ SUN_REFRACTION_ANGLE = Horizon::SUN_ANGLE
24
24
  EVENT_TYPES = [:rising, :transit, :setting].freeze
25
25
 
26
26
  # @param body [Astronoby::SolarSystemBody, Astronoby::DeepSkyObject]
@@ -260,14 +260,17 @@ module Astronoby
260
260
  .each_with_index
261
261
  .map do |angle, i|
262
262
  ratio = angle.radians / hour_angle_rates[i]
263
- time_adjustment = (ratio.nan? || ratio.infinite?) ? 0 : ratio
264
- [time_adjustment, MIN_TIME_ADJUSTMENT].max
263
+ (ratio.nan? || ratio.infinite?) ? 0.0 : ratio
265
264
  end
266
265
 
267
266
  # Apply time adjustments
268
267
  new_instants = new_instants.each_with_index.map do |instant, i|
269
268
  Instant.from_terrestrial_time(instant.tt + time_adjustments[i])
270
269
  end
270
+
271
+ break if time_adjustments.all? do |adjustment|
272
+ adjustment.abs < CONVERGENCE_TOLERANCE_IN_DAYS
273
+ end
271
274
  end
272
275
 
273
276
  new_instants.map { _1.to_time.round }
@@ -356,14 +359,7 @@ module Astronoby
356
359
  end
357
360
 
358
361
  def horizon_angle(distance)
359
- if @body == Astronoby::Sun
360
- SUN_REFRACTION_ANGLE
361
- elsif @body == Astronoby::Moon
362
- STANDARD_REFRACTION_ANGLE -
363
- Angle.from_radians(Moon::EQUATORIAL_RADIUS.m / distance.m)
364
- else
365
- STANDARD_REFRACTION_ANGLE
366
- end
362
+ Horizon.angle_for(body: @body, distance: distance)
367
363
  end
368
364
 
369
365
  def reset_state
@@ -22,13 +22,22 @@ module Astronoby
22
22
  def initialize(observer:, ephem:)
23
23
  @observer = observer
24
24
  @ephem = ephem
25
+ @crossing_calculator = AltitudeCrossingCalculator.new(
26
+ body: Sun,
27
+ observer: observer,
28
+ ephem: ephem
29
+ )
25
30
  end
26
31
 
27
32
  def event_on(date, utc_offset: 0)
28
33
  start_time = Time
29
34
  .new(date.year, date.month, date.day, 0, 0, 0, utc_offset)
30
- end_time = Time
31
- .new(date.year, date.month, date.day, 23, 59, 59, utc_offset)
35
+ following = date.next_day
36
+ end_time = Time.new(
37
+ following.year, following.month, following.day,
38
+ 0, 0, 0,
39
+ utc_offset
40
+ ) - 1e-6
32
41
  events = events_between(start_time, end_time)
33
42
 
34
43
  TwilightEvent.new(
@@ -48,73 +57,26 @@ module Astronoby
48
57
  end
49
58
 
50
59
  def events_between(start_time, end_time)
51
- rts_events = Astronoby::RiseTransitSetCalculator.new(
52
- body: Sun,
53
- observer: @observer,
54
- ephem: @ephem
55
- ).events_between(start_time, end_time)
56
-
57
- equatorial_by_time = {}
58
-
59
- (rts_events.rising_times + rts_events.setting_times)
60
- .compact
61
- .each do |event_time|
62
- rounded_time = event_time.round
63
- next if equatorial_by_time.key?(rounded_time)
64
-
65
- instant = Instant.from_time(rounded_time)
66
- sun_at_time = Sun.new(instant: instant, ephem: @ephem)
67
- equatorial_by_time[rounded_time] = sun_at_time.apparent.equatorial
68
- end
69
-
70
- morning_civil = []
71
- evening_civil = []
72
- morning_nautical = []
73
- evening_nautical = []
74
- morning_astronomical = []
75
- evening_astronomical = []
76
-
77
- arrays_by_period = {
78
- MORNING => {
79
- CIVIL => morning_civil,
80
- NAUTICAL => morning_nautical,
81
- ASTRONOMICAL => morning_astronomical
82
- },
83
- EVENING => {
84
- CIVIL => evening_civil,
85
- NAUTICAL => evening_nautical,
86
- ASTRONOMICAL => evening_astronomical
87
- }
88
- }
89
-
90
- [
91
- [rts_events.rising_times, MORNING],
92
- [rts_events.setting_times, EVENING]
93
- ].each do |times, period|
94
- times.each do |event_time|
95
- next unless event_time
96
-
97
- equatorial_coordinates = equatorial_by_time[event_time.round]
98
- TWILIGHT_ANGLES.each do |twilight, angle|
99
- arrays_by_period[period][twilight] << compute_twilight_time_from(
100
- period,
101
- angle,
102
- event_time,
103
- equatorial_coordinates
104
- )
105
- end
106
- end
60
+ crossings = @crossing_calculator.multiple_crossings_between(
61
+ start_time,
62
+ end_time,
63
+ altitudes: TWILIGHT_ANGLES.values.map { |angle| altitude_for(angle) }
64
+ )
65
+ times = TWILIGHT_ANGLES.keys.zip(crossings).to_h do |twilight, found|
66
+ [twilight, [:rising, :setting].map do |direction|
67
+ found
68
+ .select { |crossing| crossing.direction == direction }
69
+ .map { |crossing| crossing.instant.to_time.round }
70
+ end]
107
71
  end
108
72
 
109
- within_range = ->(time) { time && time >= start_time && time <= end_time }
110
-
111
73
  TwilightEvents.new(
112
- morning_civil.select(&within_range),
113
- evening_civil.select(&within_range),
114
- morning_nautical.select(&within_range),
115
- evening_nautical.select(&within_range),
116
- morning_astronomical.select(&within_range),
117
- evening_astronomical.select(&within_range)
74
+ times[CIVIL][0],
75
+ times[CIVIL][1],
76
+ times[NAUTICAL][0],
77
+ times[NAUTICAL][1],
78
+ times[ASTRONOMICAL][0],
79
+ times[ASTRONOMICAL][1]
118
80
  )
119
81
  end
120
82
 
@@ -130,92 +92,20 @@ module Astronoby
130
92
  "period_of_the_day, got #{period_of_the_day}"
131
93
  end
132
94
 
133
- observation_events = get_observation_events(date, utc_offset: utc_offset)
134
- midday_instant = create_midday_instant(date, utc_offset: utc_offset)
135
- sun_at_midday = Sun.new(instant: midday_instant, ephem: @ephem)
136
- equatorial_coordinates = sun_at_midday.apparent.equatorial
137
-
138
- compute_twilight_time(
139
- period_of_the_day,
140
- zenith_angle,
141
- observation_events,
142
- equatorial_coordinates
95
+ crossing = @crossing_calculator.crossing_on(
96
+ date,
97
+ altitude: altitude_for(zenith_angle),
98
+ direction: (period_of_the_day == MORNING) ? :rising : :setting,
99
+ utc_offset: utc_offset
143
100
  )
144
- end
145
-
146
- private
147
-
148
- def create_midday_instant(date, utc_offset: 0)
149
- time = Time.new(date.year, date.month, date.day, 12, 0, 0, utc_offset)
150
- Instant.from_time(time)
151
- end
152
101
 
153
- def get_observation_events(date, utc_offset: 0)
154
- Astronoby::RiseTransitSetCalculator.new(
155
- body: Sun,
156
- observer: @observer,
157
- ephem: @ephem
158
- ).event_on(date, utc_offset: utc_offset)
102
+ crossing&.instant&.to_time&.round
159
103
  end
160
104
 
161
- def compute_twilight_time(
162
- period_of_the_day,
163
- zenith_angle,
164
- observation_events,
165
- equatorial_coordinates
166
- )
167
- period_time = if period_of_the_day == MORNING
168
- observation_events.rising_time
169
- else
170
- observation_events.setting_time
171
- end
172
-
173
- compute_twilight_time_from(
174
- period_of_the_day,
175
- zenith_angle,
176
- period_time,
177
- equatorial_coordinates
178
- )
179
- end
180
-
181
- def compute_twilight_time_from(
182
- period_of_the_day,
183
- zenith_angle,
184
- period_time,
185
- equatorial_coordinates
186
- )
187
- # If the sun doesn't rise or set on this day, we can't calculate twilight
188
- return nil unless period_time
189
-
190
- hour_angle_at_period = equatorial_coordinates
191
- .compute_hour_angle(time: period_time, longitude: @observer.longitude)
192
-
193
- term1 = zenith_angle.cos -
194
- @observer.latitude.sin * equatorial_coordinates.declination.sin
195
- term2 = @observer.latitude.cos * equatorial_coordinates.declination.cos
196
- hour_angle_ratio_at_twilight = term1 / term2
197
-
198
- # Check if twilight occurs at this location and date
199
- return nil unless hour_angle_ratio_at_twilight.between?(-1, 1)
200
-
201
- hour_angle_at_twilight = Angle.acos(hour_angle_ratio_at_twilight)
202
- time_sign = -1
203
-
204
- if period_of_the_day == MORNING
205
- hour_angle_at_twilight = Angle.from_degrees(
206
- Constants::DEGREES_PER_CIRCLE - hour_angle_at_twilight.degrees
207
- )
208
- time_sign = 1
209
- end
210
-
211
- twilight_in_hours =
212
- time_sign * (hour_angle_at_twilight - hour_angle_at_period).hours *
213
- GreenwichMeanSiderealTime::SIDEREAL_MINUTE_IN_UT_MINUTE
214
- twilight_in_seconds = time_sign *
215
- twilight_in_hours *
216
- Constants::SECONDS_PER_HOUR
105
+ private
217
106
 
218
- (period_time + twilight_in_seconds).round
107
+ def altitude_for(zenith_angle)
108
+ Angle.from_degrees(90) - zenith_angle
219
109
  end
220
110
  end
221
111
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astronoby
4
+ module Horizon
5
+ # @return [Astronoby::Angle] altitude of the centre of a point-like body at
6
+ # rise and set
7
+ REFRACTION_ANGLE = -Angle.from_dms(0, 34, 0)
8
+
9
+ # @return [Astronoby::Angle] altitude of the Sun's centre at rise and set
10
+ SUN_ANGLE = -Angle.from_dms(0, 50, 0)
11
+
12
+ class << self
13
+ # @param body [Class] the body rising or setting
14
+ # @param distance [Astronoby::Distance] distance to the body, used for
15
+ # the Moon's semidiameter
16
+ # @return [Astronoby::Angle] altitude of the body's centre at rise and set
17
+ def angle_for(body:, distance:)
18
+ if body == Sun
19
+ SUN_ANGLE
20
+ elsif body == Moon
21
+ REFRACTION_ANGLE - moon_semidiameter(distance)
22
+ else
23
+ REFRACTION_ANGLE
24
+ end
25
+ end
26
+
27
+ # @param distance [Astronoby::Distance] distance to the Moon
28
+ # @return [Astronoby::Angle] the Moon's semidiameter
29
+ def moon_semidiameter(distance)
30
+ Angle.from_radians(moon_semidiameter_radians(distance.m))
31
+ end
32
+
33
+ # @param distance_in_meters [Numeric] distance to the Moon in meters
34
+ # @return [Float] the Moon's semidiameter in radians
35
+ def moon_semidiameter_radians(distance_in_meters)
36
+ Moon::EQUATORIAL_RADIUS.m.to_f / distance_in_meters
37
+ end
38
+ end
39
+ end
40
+ end