astronoby 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/CHANGELOG.md +235 -0
  4. data/README.md +19 -1
  5. data/UPGRADING.md +270 -0
  6. data/docs/README.md +105 -16
  7. data/docs/altitude_crossings.md +92 -0
  8. data/docs/angles.md +1 -0
  9. data/docs/configuration.md +28 -17
  10. data/docs/coordinates.md +72 -12
  11. data/docs/deep_sky_bodies.md +46 -1
  12. data/docs/ephem.md +5 -2
  13. data/docs/equinoxes_solstices_times.md +4 -3
  14. data/docs/glossary.md +99 -1
  15. data/docs/iers.md +40 -0
  16. data/docs/instant.md +33 -21
  17. data/docs/lunar_eclipses.md +208 -0
  18. data/docs/lunar_observation.md +87 -0
  19. data/docs/moon_phases.md +4 -1
  20. data/docs/observer.md +20 -6
  21. data/docs/planetary_phenomena.md +78 -0
  22. data/docs/reference_frames.md +192 -34
  23. data/docs/rise_transit_set_times.md +6 -4
  24. data/docs/solar_system_bodies.md +26 -4
  25. data/docs/twilight_times.md +43 -24
  26. data/lib/astronoby/angle.rb +63 -2
  27. data/lib/astronoby/angles/dms.rb +18 -1
  28. data/lib/astronoby/angles/hms.rb +14 -1
  29. data/lib/astronoby/angular_velocity.rb +21 -0
  30. data/lib/astronoby/bodies/deep_sky_object.rb +6 -1
  31. data/lib/astronoby/bodies/deep_sky_object_position.rb +35 -38
  32. data/lib/astronoby/bodies/earth.rb +7 -44
  33. data/lib/astronoby/bodies/jupiter.rb +10 -0
  34. data/lib/astronoby/bodies/mars.rb +10 -0
  35. data/lib/astronoby/bodies/mercury.rb +10 -0
  36. data/lib/astronoby/bodies/moon.rb +158 -32
  37. data/lib/astronoby/bodies/neptune.rb +10 -0
  38. data/lib/astronoby/bodies/saturn.rb +10 -0
  39. data/lib/astronoby/bodies/solar_system_body.rb +240 -61
  40. data/lib/astronoby/bodies/sun.rb +79 -4
  41. data/lib/astronoby/bodies/uranus.rb +10 -0
  42. data/lib/astronoby/bodies/venus.rb +10 -0
  43. data/lib/astronoby/body.rb +6 -0
  44. data/lib/astronoby/cache.rb +13 -0
  45. data/lib/astronoby/center.rb +84 -0
  46. data/lib/astronoby/constants.rb +1 -2
  47. data/lib/astronoby/constellation.rb +9 -1
  48. data/lib/astronoby/constellations/data.rb +1 -1
  49. data/lib/astronoby/coordinates/ecliptic.rb +10 -1
  50. data/lib/astronoby/coordinates/equatorial.rb +79 -13
  51. data/lib/astronoby/coordinates/geodetic.rb +102 -0
  52. data/lib/astronoby/coordinates/horizontal.rb +13 -1
  53. data/lib/astronoby/delta_t.rb +44 -0
  54. data/lib/astronoby/distance.rb +35 -0
  55. data/lib/astronoby/duration.rb +116 -0
  56. data/lib/astronoby/earth_rotation.rb +70 -0
  57. data/lib/astronoby/equinox_solstice.rb +31 -8
  58. data/lib/astronoby/errors.rb +11 -0
  59. data/lib/astronoby/events/altitude_crossing.rb +29 -0
  60. data/lib/astronoby/events/altitude_crossing_calculator.rb +511 -0
  61. data/lib/astronoby/events/altitude_extreme.rb +31 -0
  62. data/lib/astronoby/events/conjunction.rb +51 -0
  63. data/lib/astronoby/events/conjunction_opposition_calculator.rb +84 -0
  64. data/lib/astronoby/events/extremum_calculator.rb +23 -176
  65. data/lib/astronoby/events/greatest_elongation.rb +58 -0
  66. data/lib/astronoby/events/greatest_elongation_calculator.rb +56 -0
  67. data/lib/astronoby/events/lunar_eclipse.rb +111 -0
  68. data/lib/astronoby/events/lunar_eclipse_calculator.rb +397 -0
  69. data/lib/astronoby/events/lunar_eclipse_geometry.rb +115 -0
  70. data/lib/astronoby/events/lunar_eclipse_phase.rb +45 -0
  71. data/lib/astronoby/events/lunar_eclipse_visibility.rb +296 -0
  72. data/lib/astronoby/events/opposition.rb +19 -0
  73. data/lib/astronoby/events/rise_transit_set_calculator.rb +9 -13
  74. data/lib/astronoby/events/rise_transit_set_event.rb +12 -1
  75. data/lib/astronoby/events/rise_transit_set_events.rb +12 -1
  76. data/lib/astronoby/events/twilight_calculator.rb +37 -147
  77. data/lib/astronoby/events/twilight_event.rb +24 -6
  78. data/lib/astronoby/events/twilight_events.rb +26 -6
  79. data/lib/astronoby/extremum_finder.rb +148 -0
  80. data/lib/astronoby/horizon.rb +40 -0
  81. data/lib/astronoby/horizontal_transformation.rb +112 -0
  82. data/lib/astronoby/instant.rb +56 -16
  83. data/lib/astronoby/libration.rb +25 -0
  84. data/lib/astronoby/mean_obliquity.rb +8 -0
  85. data/lib/astronoby/moon_orientation_ephemeris.rb +69 -0
  86. data/lib/astronoby/moon_physical_ephemeris.rb +263 -0
  87. data/lib/astronoby/nutation.rb +10 -20
  88. data/lib/astronoby/observer.rb +67 -49
  89. data/lib/astronoby/orientation.rb +107 -0
  90. data/lib/astronoby/position.rb +16 -0
  91. data/lib/astronoby/precession.rb +62 -61
  92. data/lib/astronoby/reference_frame.rb +80 -9
  93. data/lib/astronoby/reference_frames/apparent.rb +43 -12
  94. data/lib/astronoby/reference_frames/astrometric.rb +14 -1
  95. data/lib/astronoby/reference_frames/geometric.rb +7 -1
  96. data/lib/astronoby/reference_frames/mean_of_date.rb +19 -1
  97. data/lib/astronoby/reference_frames/teme.rb +159 -0
  98. data/lib/astronoby/reference_frames/topocentric.rb +36 -4
  99. data/lib/astronoby/refraction.rb +26 -5
  100. data/lib/astronoby/root_finder.rb +90 -0
  101. data/lib/astronoby/rotation.rb +49 -0
  102. data/lib/astronoby/time/greenwich_apparent_sidereal_time.rb +19 -8
  103. data/lib/astronoby/time/greenwich_mean_sidereal_time.rb +44 -5
  104. data/lib/astronoby/time/greenwich_sidereal_time.rb +21 -0
  105. data/lib/astronoby/time/local_apparent_sidereal_time.rb +21 -0
  106. data/lib/astronoby/time/local_mean_sidereal_time.rb +21 -0
  107. data/lib/astronoby/time/local_sidereal_time.rb +24 -0
  108. data/lib/astronoby/time/sidereal_time.rb +23 -1
  109. data/lib/astronoby/true_obliquity.rb +4 -0
  110. data/lib/astronoby/util/maths.rb +8 -0
  111. data/lib/astronoby/util/time.rb +0 -497
  112. data/lib/astronoby/vector.rb +10 -0
  113. data/lib/astronoby/velocity.rb +39 -0
  114. data/lib/astronoby/version.rb +1 -1
  115. data/lib/astronoby.rb +30 -0
  116. metadata +80 -5
@@ -0,0 +1,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
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astronoby
4
+ class Conjunction
5
+ INFERIOR = :inferior
6
+ SUPERIOR = :superior
7
+
8
+ # @param instant [Astronoby::Instant] when the conjunction occurs
9
+ # @param body [Astronoby::Body] the body in conjunction with the Sun
10
+ # @return [Astronoby::Conjunction] an inferior conjunction
11
+ def self.inferior(instant:, body:)
12
+ new(instant: instant, body: body, subtype: INFERIOR)
13
+ end
14
+
15
+ # @param instant [Astronoby::Instant] when the conjunction occurs
16
+ # @param body [Astronoby::Body] the body in conjunction with the Sun
17
+ # @return [Astronoby::Conjunction] a superior conjunction
18
+ def self.superior(instant:, body:)
19
+ new(instant: instant, body: body, subtype: SUPERIOR)
20
+ end
21
+
22
+ # @return [Astronoby::Instant] when the conjunction occurs
23
+ attr_reader :instant
24
+
25
+ # @return [Astronoby::Body] the body in conjunction with the Sun
26
+ attr_reader :body
27
+
28
+ # @return [Symbol] +INFERIOR+ or +SUPERIOR+
29
+ attr_reader :subtype
30
+
31
+ # @param instant [Astronoby::Instant] when the conjunction occurs
32
+ # @param body [Astronoby::Body] the body in conjunction with the Sun
33
+ # @param subtype [Symbol] +INFERIOR+ or +SUPERIOR+
34
+ def initialize(instant:, body:, subtype:)
35
+ @instant = instant
36
+ @body = body
37
+ @subtype = subtype
38
+ freeze
39
+ end
40
+
41
+ # @return [Boolean] true for an inferior conjunction
42
+ def inferior?
43
+ @subtype == INFERIOR
44
+ end
45
+
46
+ # @return [Boolean] true for a superior conjunction
47
+ def superior?
48
+ @subtype == SUPERIOR
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astronoby
4
+ class ConjunctionOppositionCalculator
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::Conjunction>] conjunctions in the range
17
+ def conjunction_events_between(start_time, end_time)
18
+ roots_between(start_time, end_time, accept: method(:conjunction?))
19
+ .map { |jd| conjunction_at(Instant.from_terrestrial_time(jd)) }
20
+ end
21
+
22
+ # @param start_time [Time] start time
23
+ # @param end_time [Time] end time
24
+ # @return [Array<Astronoby::Opposition>] oppositions in the range
25
+ def opposition_events_between(start_time, end_time)
26
+ roots_between(start_time, end_time, accept: method(:opposition?))
27
+ .map do |jd|
28
+ Opposition.new(
29
+ instant: Instant.from_terrestrial_time(jd),
30
+ body: @body
31
+ )
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def roots_between(start_time, end_time, accept:)
38
+ finder.roots(
39
+ Instant.from_time(start_time).tt,
40
+ Instant.from_time(end_time).tt,
41
+ accept: accept
42
+ )
43
+ end
44
+
45
+ def finder
46
+ @finder ||= RootFinder.new(
47
+ value_at: ->(jd) { delta_longitude_at(jd).sin },
48
+ period: synodic_period,
49
+ samples_per_period: @samples_per_period
50
+ )
51
+ end
52
+
53
+ def conjunction?(jd)
54
+ delta_longitude_at(jd).cos.positive?
55
+ end
56
+
57
+ def opposition?(jd)
58
+ delta_longitude_at(jd).cos.negative?
59
+ end
60
+
61
+ def delta_longitude_at(jd)
62
+ instant = Instant.from_terrestrial_time(jd)
63
+ planet = @body.new(instant: instant, ephem: @ephem)
64
+ sun = Sun.new(instant: instant, ephem: @ephem)
65
+ planet.apparent.ecliptic.longitude - sun.apparent.ecliptic.longitude
66
+ end
67
+
68
+ def conjunction_at(instant)
69
+ planet = @body.new(instant: instant, ephem: @ephem)
70
+ sun = Sun.new(instant: instant, ephem: @ephem)
71
+
72
+ if planet.apparent.distance < sun.apparent.distance
73
+ Conjunction.inferior(instant: instant, body: @body)
74
+ else
75
+ Conjunction.superior(instant: instant, body: @body)
76
+ end
77
+ end
78
+
79
+ def synodic_period
80
+ @synodic_period ||=
81
+ 1.0 / ((1.0 / @body::ORBITAL_PERIOD) - (1.0 / Earth::ORBITAL_PERIOD)).abs
82
+ end
83
+ end
84
+ end