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,511 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astronoby
4
+ # Finds when a body reaches a given altitude above the observer's horizon.
5
+ #
6
+ # The window is split at the body's culminations, since between a lower and
7
+ # the next upper culmination the altitude only goes one way. A segment whose
8
+ # ends lie on opposite sides of the target holds exactly one crossing, which
9
+ # is located on the real altitude. A segment where the target is within
10
+ # {GRAZING_MARGIN} of an end could hold two crossings or none, so it is
11
+ # sampled and bisected instead.
12
+ #
13
+ # The calculator holds no state between calls: every call searches with its
14
+ # own cache of evaluated positions, so one instance can be shared by threads.
15
+ class AltitudeCrossingCalculator
16
+ REFERENCES = [:center, :upper_limb, :lower_limb].freeze
17
+ DIRECTIONS = [:rising, :setting].freeze
18
+
19
+ TAU = Math::PI * 2
20
+ SIDEREAL_RATE = TAU * 1.00273790935
21
+
22
+ # A culmination is at most about 0.52 days after the previous one (the
23
+ # Moon's), so this much on each side of the window brackets its ends.
24
+ CULMINATION_PADDING_IN_DAYS = 0.6
25
+
26
+ ALTITUDE_TOLERANCE = Angle.from_degrees(1e-5).radians
27
+ # A Float Julian Date resolves about 47 µs, or 3.4e-9 rad of hour angle,
28
+ # so a tighter tolerance could never be met.
29
+ HOUR_ANGLE_TOLERANCE = 1e-8
30
+ # The ends of a segment only have to be close enough to the culminations
31
+ # for the altitude there to be within GRAZING_MARGIN of the extremum, which
32
+ # a hundred microradians of hour angle is, except for a body passing within
33
+ # a few thousandths of a degree of the zenith.
34
+ SEGMENT_HOUR_ANGLE_TOLERANCE = 1e-4
35
+ GRAZING_MARGIN = Angle.from_degrees(0.05).radians
36
+ # A Newton step from an altitude this close to the target lands within
37
+ # {ALTITUDE_TOLERANCE} of it, for a slope that is off by up to 2%, so the
38
+ # step is returned without evaluating the body there.
39
+ NEWTON_ACCEPT = Angle.from_degrees(5e-4).radians
40
+ MAX_ITERATIONS = 30
41
+
42
+ FALLBACK_SAMPLES_PER_DAY = 288
43
+ DIRECTION_PROBE_IN_DAYS = 60.0 / Constants::SECONDS_PER_DAY
44
+ DUPLICATE_THRESHOLD_IN_DAYS = 1e-6
45
+
46
+ Sample = Struct.new(:hour_angle, :declination, :altitude)
47
+ Culmination = Struct.new(:kind, :tt)
48
+ Crossing = Struct.new(:tt, :direction, :grazing)
49
+
50
+ # @param body [Class, Object] celestial body to track
51
+ # @param observer [Astronoby::Observer] observer location
52
+ # @param ephem [::Ephem::SPK, nil] ephemeris data source
53
+ # @param refraction [Boolean] whether to apply atmospheric refraction to
54
+ # the altitude, false by default as in the twilight convention
55
+ # @param reference [Symbol] the point of the body that reaches the target:
56
+ # +:center+, +:upper_limb+ or +:lower_limb+, the limbs using the body's
57
+ # true semi-diameter
58
+ def initialize(
59
+ body:,
60
+ observer:,
61
+ ephem: nil,
62
+ refraction: false,
63
+ reference: :center
64
+ )
65
+ unless REFERENCES.include?(reference)
66
+ raise IncompatibleArgumentsError,
67
+ "Only #{REFERENCES.join(", ")} are allowed as reference, " \
68
+ "got #{reference}"
69
+ end
70
+
71
+ unless body.respond_to?(:at)
72
+ raise IncompatibleArgumentsError,
73
+ "The body must respond to #at(instant, ephem:), got #{body}"
74
+ end
75
+
76
+ if reference != :center && !(body.is_a?(Class) && body <= SolarSystemBody)
77
+ raise IncompatibleArgumentsError,
78
+ "A limb reference needs a body with a known size, got #{body}"
79
+ end
80
+
81
+ @body = body
82
+ @observer = observer
83
+ @ephem = ephem
84
+ @refraction = refraction
85
+ @reference = reference
86
+ end
87
+
88
+ # @param start_time [Time] start of the window
89
+ # @param end_time [Time] end of the window
90
+ # @param altitude [Astronoby::Angle] the altitude to reach, positive or
91
+ # negative
92
+ # @return [Array<Astronoby::AltitudeCrossing>] the crossings in the window,
93
+ # sorted by time
94
+ def crossings_between(start_time, end_time, altitude:)
95
+ multiple_crossings_between(start_time, end_time, altitudes: [altitude])
96
+ .first
97
+ end
98
+
99
+ # Same as {#crossings_between} for several altitudes at once, sharing the
100
+ # culminations and the evaluated positions between them.
101
+ #
102
+ # @param start_time [Time] start of the window
103
+ # @param end_time [Time] end of the window
104
+ # @param altitudes [Array<Astronoby::Angle>] the altitudes to reach
105
+ # @return [Array<Array<Astronoby::AltitudeCrossing>>] the crossings of each
106
+ # altitude, in the order of +altitudes+
107
+ def multiple_crossings_between(start_time, end_time, altitudes:)
108
+ search_between(start_time, end_time).crossings(altitudes)
109
+ end
110
+
111
+ # @param date [Date] the day
112
+ # @param altitude [Astronoby::Angle] the altitude to reach
113
+ # @param direction [Symbol] +:rising+ or +:setting+
114
+ # @param utc_offset [String, Integer] UTC offset of the day, e.g.
115
+ # "+02:00"
116
+ # @return [Astronoby::AltitudeCrossing, nil] the first crossing of that
117
+ # direction from midnight to the next midnight, if any
118
+ def crossing_on(date, altitude:, direction:, utc_offset: 0)
119
+ unless DIRECTIONS.include?(direction)
120
+ raise IncompatibleArgumentsError,
121
+ "Only #{DIRECTIONS.join(" or ")} are allowed as direction, " \
122
+ "got #{direction}"
123
+ end
124
+
125
+ start_time = Time.new(date.year, date.month, date.day, 0, 0, 0, utc_offset)
126
+ following = date.next_day
127
+ end_time = Time.new(
128
+ following.year, following.month, following.day, 0, 0, 0, utc_offset
129
+ )
130
+
131
+ crossings_between(start_time, end_time, altitude: altitude)
132
+ .find do |crossing|
133
+ crossing.direction == direction &&
134
+ crossing.instant.to_time < end_time
135
+ end
136
+ end
137
+
138
+ # @param start_time [Time] start of the window
139
+ # @param end_time [Time] end of the window
140
+ # @return [Array<Astronoby::AltitudeExtreme>] the upper and lower
141
+ # culminations in the window, sorted by time, with the altitude of the
142
+ # reference point at each
143
+ def extremes_between(start_time, end_time)
144
+ search_between(start_time, end_time).extremes
145
+ end
146
+
147
+ private
148
+
149
+ def search_between(start_time, end_time)
150
+ if end_time < start_time
151
+ raise IncompatibleArgumentsError,
152
+ "The end of the window must not be before its start"
153
+ end
154
+
155
+ Search.new(
156
+ body: @body,
157
+ observer: @observer,
158
+ ephem: @ephem,
159
+ refraction: @refraction,
160
+ reference: @reference,
161
+ start_tt: Instant.from_time(start_time).tt.to_f,
162
+ end_tt: Instant.from_time(end_time).tt.to_f
163
+ )
164
+ end
165
+
166
+ class Search
167
+ def initialize(
168
+ body:,
169
+ observer:,
170
+ ephem:,
171
+ refraction:,
172
+ reference:,
173
+ start_tt:,
174
+ end_tt:
175
+ )
176
+ @body = body
177
+ @observer = observer
178
+ @ephem = ephem
179
+ @refraction = refraction
180
+ @reference = reference
181
+ @start_tt = start_tt
182
+ @end_tt = end_tt
183
+ @samples = {}
184
+ end
185
+
186
+ def crossings(altitudes)
187
+ overlapping = segments
188
+
189
+ altitudes.map do |altitude|
190
+ target = altitude.radians
191
+
192
+ overlapping
193
+ .flat_map { |first, last| segment_crossings(first, last, target) }
194
+ .sort_by(&:tt)
195
+ .then { |crossings| remove_duplicates(crossings) }
196
+ .map { |crossing| build_crossing(crossing) }
197
+ end
198
+ end
199
+
200
+ def extremes
201
+ culminations(@start_tt, @end_tt, HOUR_ANGLE_TOLERANCE).map do |culmination|
202
+ AltitudeExtreme.new(
203
+ kind: culmination.kind,
204
+ instant: Instant.from_terrestrial_time(culmination.tt),
205
+ altitude: Angle.from_radians(altitude_at(culmination.tt))
206
+ )
207
+ end
208
+ end
209
+
210
+ private
211
+
212
+ def sample_at(tt)
213
+ @samples[tt] ||= begin
214
+ instant = Instant.from_terrestrial_time(tt)
215
+ body = @body.at(instant, ephem: @ephem)
216
+ equatorial = body.observed_by(@observer).equatorial
217
+ hour_angle = equatorial.compute_hour_angle(
218
+ time: instant.to_time,
219
+ longitude: @observer.longitude
220
+ )
221
+ declination = equatorial.declination
222
+
223
+ Sample.new(
224
+ hour_angle.radians,
225
+ declination.radians,
226
+ reference_altitude(body, declination, hour_angle)
227
+ )
228
+ end
229
+ end
230
+
231
+ def reference_altitude(body, declination, hour_angle)
232
+ latitude = @observer.latitude
233
+ sin_altitude = latitude.sin * declination.sin +
234
+ latitude.cos * declination.cos * hour_angle.cos
235
+ altitude = Angle.asin(sin_altitude.clamp(-1.0, 1.0))
236
+
237
+ if @refraction
238
+ altitude += Refraction.angle(
239
+ coordinates: Coordinates::Horizontal.new(
240
+ azimuth: Angle.zero,
241
+ altitude: altitude,
242
+ observer: @observer
243
+ )
244
+ )
245
+ end
246
+
247
+ altitude.radians + limb_offset(body)
248
+ end
249
+
250
+ def limb_offset(body)
251
+ case @reference
252
+ when :upper_limb then body.angular_diameter.radians / 2
253
+ when :lower_limb then -body.angular_diameter.radians / 2
254
+ else 0.0
255
+ end
256
+ end
257
+
258
+ def altitude_at(tt)
259
+ sample_at(tt).altitude
260
+ end
261
+
262
+ def hour_angle_at(tt)
263
+ sample_at(tt).hour_angle
264
+ end
265
+
266
+ def wrap(radians)
267
+ (radians + Math::PI) % TAU - Math::PI
268
+ end
269
+
270
+ def segments
271
+ culminations(
272
+ @start_tt - CULMINATION_PADDING_IN_DAYS,
273
+ @end_tt + CULMINATION_PADDING_IN_DAYS,
274
+ SEGMENT_HOUR_ANGLE_TOLERANCE
275
+ ).each_cons(2).select do |first, last|
276
+ last.tt >= @start_tt && first.tt <= @end_tt
277
+ end
278
+ end
279
+
280
+ def culminations(start_tt, end_tt, tolerance)
281
+ start_hour_angle = hour_angle_at(start_tt)
282
+ spacing = TAU / SIDEREAL_RATE
283
+
284
+ {
285
+ AltitudeExtreme::UPPER_CULMINATION => 0.0,
286
+ AltitudeExtreme::LOWER_CULMINATION => Math::PI
287
+ }.flat_map do |kind, hour_angle|
288
+ tt = start_tt +
289
+ ((hour_angle - start_hour_angle) % TAU) / SIDEREAL_RATE
290
+ previous = nil
291
+ found = []
292
+
293
+ while tt <= end_tt
294
+ tt = refine_culmination(tt, hour_angle, tolerance)
295
+ found << Culmination.new(kind, tt) if tt.between?(start_tt, end_tt)
296
+ # Step from the refined event: a sidereal day drifts from the
297
+ # Moon's by about an hour per day, which would add up over a
298
+ # window. Once two are known their spacing, shared by both kinds,
299
+ # is a closer step than a sidereal day, as it changes slowly from
300
+ # one day to the next.
301
+ spacing = tt - previous if previous
302
+ previous = tt
303
+ tt += spacing
304
+ end
305
+
306
+ found
307
+ end.sort_by(&:tt)
308
+ end
309
+
310
+ def refine_culmination(tt, hour_angle, tolerance)
311
+ rate = SIDEREAL_RATE
312
+ previous = nil
313
+
314
+ MAX_ITERATIONS.times do
315
+ residual = wrap(hour_angle_at(tt) - hour_angle)
316
+ return tt if residual.abs < tolerance
317
+
318
+ if previous
319
+ measured_rate =
320
+ wrap(residual - previous[:residual]) / (tt - previous[:tt])
321
+ rate = measured_rate if measured_rate.positive?
322
+ end
323
+
324
+ previous = {tt: tt, residual: residual}
325
+ tt -= residual / rate
326
+ end
327
+
328
+ tt
329
+ end
330
+
331
+ def segment_crossings(first, last, target)
332
+ low = [first.tt, @start_tt].max
333
+ high = [last.tt, @end_tt].min
334
+ return [] unless low < high
335
+
336
+ f_first = altitude_at(first.tt) - target
337
+ f_last = altitude_at(last.tt) - target
338
+
339
+ if [f_first.abs, f_last.abs].min <= GRAZING_MARGIN
340
+ return scan(low, high, target, grazing: true)
341
+ end
342
+
343
+ f_low = (low == first.tt) ? f_first : altitude_at(low) - target
344
+ f_high = (high == last.tt) ? f_last : altitude_at(high) - target
345
+ return [] if f_low.negative? == f_high.negative?
346
+
347
+ tt = solve(Segment.new(
348
+ first, sample_at(first.tt), last, sample_at(last.tt)
349
+ ), low, f_low, high, target)
350
+ return scan(low, high, target, grazing: false) unless tt
351
+
352
+ [Crossing.new(tt, f_low.negative? ? :rising : :setting, false)]
353
+ end
354
+
355
+ # Between two consecutive culminations the hour angle goes through
356
+ # exactly half a turn, and the declination moves from its value at one
357
+ # to its value at the other. That gives the rates of both over the
358
+ # segment, which a sidereal day and a fixed declination only approximate:
359
+ # the Sun's right ascension drifts by a degree per day and the Moon's by
360
+ # thirteen.
361
+ class Segment
362
+ attr_reader :hour_angle_rate, :declination_rate
363
+
364
+ def initialize(first, first_sample, last, last_sample)
365
+ @first = first
366
+ @first_sample = first_sample
367
+ span = last.tt - first.tt
368
+ consecutive = first.kind != last.kind && span.positive?
369
+
370
+ @hour_angle_rate = consecutive ? Math::PI / span : SIDEREAL_RATE
371
+ @declination_rate = if consecutive
372
+ (last_sample.declination - first_sample.declination) / span
373
+ else
374
+ 0.0
375
+ end
376
+ @first_hour_angle =
377
+ (first.kind == AltitudeExtreme::UPPER_CULMINATION) ? 0.0 : Math::PI
378
+ @consecutive = consecutive
379
+ end
380
+
381
+ def consecutive? = @consecutive
382
+
383
+ def declination_at(tt)
384
+ @first_sample.declination + @declination_rate * (tt - @first.tt)
385
+ end
386
+
387
+ def time_at_hour_angle(hour_angle)
388
+ @first.tt + (hour_angle - @first_hour_angle) / @hour_angle_rate
389
+ end
390
+ end
391
+
392
+ def solve(segment, low, f_low, high, target)
393
+ x = initial_guess(segment, low, high, f_low, target)
394
+ previous = nil
395
+
396
+ MAX_ITERATIONS.times do
397
+ f_x = altitude_at(x) - target
398
+ return x if f_x.abs < ALTITUDE_TOLERANCE
399
+
400
+ if f_x.negative? == f_low.negative?
401
+ low = x
402
+ f_low = f_x
403
+ else
404
+ high = x
405
+ end
406
+
407
+ slope = if previous
408
+ (f_x - previous[:f]) / (x - previous[:tt])
409
+ else
410
+ analytic_slope(x, segment)
411
+ end
412
+ previous = {tt: x, f: f_x}
413
+
414
+ step = x - f_x / slope
415
+ within = step > low && step < high
416
+ return step if within && f_x.abs < NEWTON_ACCEPT && segment.consecutive?
417
+
418
+ x = within ? step : (low + high) / 2
419
+ end
420
+
421
+ nil
422
+ end
423
+
424
+ def initial_guess(segment, low, high, f_low, target)
425
+ guess = if segment.consecutive?
426
+ guess_from_segment(segment, f_low, target, low)
427
+ else
428
+ guess_from_sample(low, f_low, target)
429
+ end
430
+
431
+ (guess > low && guess < high) ? guess : (low + high) / 2
432
+ end
433
+
434
+ def guess_from_segment(segment, f_low, target, low)
435
+ # The declination at the crossing depends on when it happens, so the
436
+ # time is refined once from the first estimate.
437
+ tt = low
438
+ 2.times do
439
+ declination = segment.declination_at(tt)
440
+ half_arc = half_arc_for(target, declination)
441
+ needed = f_low.negative? ? TAU - half_arc : half_arc
442
+ tt = segment.time_at_hour_angle(needed)
443
+ end
444
+ tt
445
+ end
446
+
447
+ def guess_from_sample(low, f_low, target)
448
+ sample = sample_at(low)
449
+ half_arc = half_arc_for(target, sample.declination)
450
+ needed = f_low.negative? ? TAU - half_arc : half_arc
451
+
452
+ low + ((needed - sample.hour_angle) % TAU) / SIDEREAL_RATE
453
+ end
454
+
455
+ def half_arc_for(target, declination)
456
+ latitude = @observer.latitude
457
+ ratio = (Math.sin(target) - latitude.sin * Math.sin(declination)) /
458
+ (latitude.cos * Math.cos(declination))
459
+
460
+ Math.acos(ratio.clamp(-1.0, 1.0))
461
+ end
462
+
463
+ def analytic_slope(tt, segment)
464
+ sample = sample_at(tt)
465
+ latitude = @observer.latitude
466
+ declination = sample.declination
467
+ cos_hour_angle = Math.cos(sample.hour_angle)
468
+
469
+ rate_of_sine =
470
+ -latitude.cos * Math.cos(declination) * Math.sin(sample.hour_angle) *
471
+ segment.hour_angle_rate +
472
+ (latitude.sin * Math.cos(declination) -
473
+ latitude.cos * Math.sin(declination) * cos_hour_angle) *
474
+ segment.declination_rate
475
+
476
+ rate_of_sine / Math.cos(sample.altitude)
477
+ end
478
+
479
+ def scan(first_tt, last_tt, target, grazing:)
480
+ finder = RootFinder.new(
481
+ value_at: ->(tt) { altitude_at(tt) - target },
482
+ period: 1.0,
483
+ samples_per_period: FALLBACK_SAMPLES_PER_DAY
484
+ )
485
+
486
+ finder.roots(first_tt, last_tt).map do |tt|
487
+ rising = altitude_at(tt + DIRECTION_PROBE_IN_DAYS) >
488
+ altitude_at(tt - DIRECTION_PROBE_IN_DAYS)
489
+ Crossing.new(tt, rising ? :rising : :setting, grazing)
490
+ end
491
+ end
492
+
493
+ def remove_duplicates(crossings)
494
+ crossings.each_with_object([]) do |crossing, kept|
495
+ duplicate = kept.last &&
496
+ (crossing.tt - kept.last.tt) < DUPLICATE_THRESHOLD_IN_DAYS
497
+ kept << crossing unless duplicate
498
+ end
499
+ end
500
+
501
+ def build_crossing(crossing)
502
+ AltitudeCrossing.new(
503
+ instant: Instant.from_terrestrial_time(crossing.tt),
504
+ direction: crossing.direction,
505
+ grazing: crossing.grazing
506
+ )
507
+ end
508
+ end
509
+ private_constant :Search
510
+ end
511
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astronoby
4
+ # A culmination of a body, when it crosses the observer's meridian above or
5
+ # below the pole.
6
+ class AltitudeExtreme
7
+ KINDS = [
8
+ UPPER_CULMINATION = :upper_culmination,
9
+ LOWER_CULMINATION = :lower_culmination
10
+ ].freeze
11
+
12
+ # @return [Symbol] +:upper_culmination+ (highest altitude) or
13
+ # +:lower_culmination+ (lowest altitude)
14
+ attr_reader :kind
15
+
16
+ # @return [Astronoby::Instant] when the body crosses the meridian
17
+ attr_reader :instant
18
+
19
+ # @return [Astronoby::Angle] the altitude at that instant
20
+ attr_reader :altitude
21
+
22
+ # @param kind [Symbol] +:upper_culmination+ or +:lower_culmination+
23
+ # @param instant [Astronoby::Instant] when the body crosses the meridian
24
+ # @param altitude [Astronoby::Angle] the altitude at that instant
25
+ def initialize(kind:, instant:, altitude:)
26
+ @kind = kind
27
+ @instant = instant
28
+ @altitude = altitude
29
+ end
30
+ end
31
+ end
@@ -14,73 +14,79 @@ module Astronoby
14
14
  # @return [Astronoby::Instant] greatest eclipse, when the Moon's centre is
15
15
  # least distant from the axis of Earth's shadow
16
16
  attr_reader :instant
17
- alias_method :greatest_eclipse, :instant
17
+ alias_method :greatest_eclipse_instant, :instant
18
18
 
19
19
  # @return [Symbol] +PENUMBRAL+, +PARTIAL+ or +TOTAL+
20
20
  attr_reader :kind
21
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
22
+ # @return [Astronoby::LunarEclipseGeometry] the geometry of Earth's shadow
23
+ # and the Moon's place in it at greatest eclipse. The geometry at each
24
+ # contact is on the phases.
25
+ attr_reader :geometry
34
26
 
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)
27
+ # @return [Astronoby::LunarEclipsePhase] the penumbral phase (always present)
41
28
  attr_reader :penumbral
42
29
 
43
- # @return [Astronoby::EclipsePhase, nil] the partial phase, present for
30
+ # @return [Astronoby::LunarEclipsePhase, nil] the partial phase, present for
44
31
  # partial and total eclipses
45
32
  attr_reader :partial
46
33
 
47
- # @return [Astronoby::EclipsePhase, nil] the total phase (totality),
34
+ # @return [Astronoby::LunarEclipsePhase, nil] the total phase (totality),
48
35
  # present only for total eclipses
49
36
  attr_reader :total
50
37
 
51
38
  # @param instant [Astronoby::Instant] greatest eclipse
52
39
  # @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
40
+ # @param geometry [Astronoby::LunarEclipseGeometry] the shadow geometry at
41
+ # greatest eclipse
42
+ # @param penumbral [Astronoby::LunarEclipsePhase] the penumbral phase
43
+ # @param partial [Astronoby::LunarEclipsePhase, nil] the partial phase
44
+ # @param total [Astronoby::LunarEclipsePhase, nil] the total phase
61
45
  def initialize(
62
46
  instant:,
63
47
  kind:,
64
- umbral_magnitude:,
65
- penumbral_magnitude:,
66
- gamma:,
67
- shadow_axis_distance:,
48
+ geometry:,
68
49
  penumbral:,
69
50
  partial: nil,
70
51
  total: nil
71
52
  )
72
53
  @instant = instant
73
54
  @kind = kind
74
- @umbral_magnitude = umbral_magnitude
75
- @penumbral_magnitude = penumbral_magnitude
76
- @gamma = gamma
77
- @shadow_axis_distance = shadow_axis_distance
55
+ @geometry = geometry
78
56
  @penumbral = penumbral
79
57
  @partial = partial
80
58
  @total = total
81
59
  freeze
82
60
  end
83
61
 
62
+ # @return [Float] least distance of the Moon's centre from the axis of
63
+ # Earth's shadow at greatest eclipse, in Earth radii, positive when the
64
+ # Moon passes north of the axis
65
+ def gamma
66
+ sign = @geometry.north_of_axis? ? 1 : -1
67
+ sign * @geometry.axis_distance.m /
68
+ Constants::WGS84_EARTH_EQUATORIAL_RADIUS_IN_METERS
69
+ end
70
+
71
+ # @return [Float] fraction of the Moon's diameter immersed in the umbra at
72
+ # greatest eclipse (negative when the Moon misses the umbra)
73
+ def umbral_magnitude
74
+ @geometry.umbral_magnitude
75
+ end
76
+
77
+ # @return [Float] fraction of the Moon's diameter immersed in the penumbra
78
+ # at greatest eclipse
79
+ def penumbral_magnitude
80
+ @geometry.penumbral_magnitude
81
+ end
82
+
83
+ # @return [Astronoby::Distance] least distance of the Moon's centre from the
84
+ # axis of Earth's shadow at greatest eclipse. This is the unsigned length
85
+ # of which gamma is the value in Earth radii.
86
+ def shadow_axis_distance
87
+ @geometry.axis_distance
88
+ end
89
+
84
90
  # @return [Boolean] true for a penumbral eclipse (the Moon misses the umbra)
85
91
  def penumbral?
86
92
  @kind == PENUMBRAL
@@ -95,5 +101,11 @@ module Astronoby
95
101
  def total?
96
102
  @kind == TOTAL
97
103
  end
104
+
105
+ # @param observer [Astronoby::Observer] the observer
106
+ # @return [Astronoby::LunarEclipseVisibility] the local circumstances
107
+ def visibility_from(observer)
108
+ LunarEclipseVisibility.new(eclipse: self, observer: observer)
109
+ end
98
110
  end
99
111
  end