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,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astronoby
4
+ class Duration
5
+ include Comparable
6
+
7
+ class << self
8
+ # @return [Astronoby::Duration] a zero duration
9
+ def zero
10
+ new(0)
11
+ end
12
+
13
+ # @param seconds [Numeric] the duration in seconds
14
+ # @return [Astronoby::Duration] a new Duration
15
+ def from_seconds(seconds)
16
+ new(seconds)
17
+ end
18
+
19
+ # @param minutes [Numeric] the duration in minutes
20
+ # @return [Astronoby::Duration] a new Duration
21
+ def from_minutes(minutes)
22
+ seconds = minutes * Constants::SECONDS_PER_MINUTE
23
+ from_seconds(seconds)
24
+ end
25
+
26
+ # @param hours [Numeric] the duration in hours
27
+ # @return [Astronoby::Duration] a new Duration
28
+ def from_hours(hours)
29
+ seconds = hours * Constants::SECONDS_PER_HOUR
30
+ from_seconds(seconds)
31
+ end
32
+
33
+ # @param days [Numeric] the duration in days
34
+ # @return [Astronoby::Duration] a new Duration
35
+ def from_days(days)
36
+ seconds = days * Constants::SECONDS_PER_DAY
37
+ from_seconds(seconds)
38
+ end
39
+ end
40
+
41
+ # @return [Numeric] the duration in seconds
42
+ attr_reader :seconds
43
+
44
+ # @param seconds [Numeric] the duration in seconds
45
+ def initialize(seconds)
46
+ @seconds = seconds
47
+ freeze
48
+ end
49
+
50
+ # @return [Float] the duration in minutes
51
+ def minutes
52
+ @seconds / Constants::SECONDS_PER_MINUTE
53
+ end
54
+
55
+ # @return [Float] the duration in hours
56
+ def hours
57
+ @seconds / Constants::SECONDS_PER_HOUR
58
+ end
59
+
60
+ # @return [Float] the duration in days
61
+ def days
62
+ @seconds / Constants::SECONDS_PER_DAY
63
+ end
64
+
65
+ # @param other [Astronoby::Duration] duration to add
66
+ # @return [Astronoby::Duration] the sum
67
+ def +(other)
68
+ self.class.from_seconds(@seconds + other.seconds)
69
+ end
70
+
71
+ # @param other [Astronoby::Duration] duration to subtract
72
+ # @return [Astronoby::Duration] the difference
73
+ def -(other)
74
+ self.class.from_seconds(@seconds - other.seconds)
75
+ end
76
+
77
+ # @return [Astronoby::Duration] the negated duration
78
+ def -@
79
+ self.class.from_seconds(-@seconds)
80
+ end
81
+
82
+ # @return [Astronoby::Duration] the absolute duration
83
+ def abs
84
+ self.class.from_seconds(@seconds.abs)
85
+ end
86
+
87
+ # @return [Boolean] true if the duration is positive
88
+ def positive?
89
+ @seconds > 0
90
+ end
91
+
92
+ # @return [Boolean] true if the duration is negative
93
+ def negative?
94
+ @seconds < 0
95
+ end
96
+
97
+ # @return [Boolean] true if the duration is zero
98
+ def zero?
99
+ @seconds.zero?
100
+ end
101
+
102
+ # @return [Integer] hash value
103
+ def hash
104
+ [@seconds, self.class].hash
105
+ end
106
+
107
+ # @param other [Astronoby::Duration] duration to compare with
108
+ # @return [Integer, nil] -1, 0, or 1; nil if not comparable
109
+ def <=>(other)
110
+ return unless other.is_a?(self.class)
111
+
112
+ seconds <=> other.seconds
113
+ end
114
+ alias_method :eql?, :==
115
+ end
116
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astronoby
4
+ # Computes Earth rotation matrices using Greenwich Sidereal Time.
5
+ # Provides both apparent (GAST-based) and mean (GMST-based) rotation
6
+ # matrices for transforming between Earth-fixed and celestial frames.
7
+ class EarthRotation
8
+ # Computes the apparent Earth rotation matrix R₃(GAST) for a given
9
+ # instant.
10
+ #
11
+ # @param instant [Astronoby::Instant] the time instant
12
+ # @return [Matrix] 3x3 Earth rotation matrix
13
+ def self.matrix_for(instant)
14
+ new(instant: instant).matrix
15
+ end
16
+
17
+ # Computes the mean Earth rotation matrix R₃(GMST) for a given
18
+ # instant.
19
+ #
20
+ # @param instant [Astronoby::Instant] the time instant
21
+ # @return [Matrix] 3x3 mean Earth rotation matrix
22
+ def self.mean_matrix_for(instant)
23
+ new(instant: instant).mean_matrix
24
+ end
25
+
26
+ # @param instant [Astronoby::Instant] the time instant
27
+ def initialize(instant:)
28
+ @instant = instant
29
+ end
30
+
31
+ # Computes R₃(GAST), the apparent Earth rotation matrix.
32
+ # GAST = GMST + equation of the equinoxes (Δψ cos ε₀).
33
+ #
34
+ # @return [Matrix] 3x3 rotation matrix
35
+ def matrix
36
+ rotation_matrix_for(gast)
37
+ end
38
+
39
+ # Computes R₃(GMST), the mean Earth rotation matrix.
40
+ #
41
+ # @return [Matrix] 3x3 rotation matrix
42
+ def mean_matrix
43
+ rotation_matrix_for(gmst)
44
+ end
45
+
46
+ private
47
+
48
+ def gmst
49
+ Angle.from_hours(@instant.gmst)
50
+ end
51
+
52
+ def gast
53
+ nutation = Nutation.new(instant: @instant)
54
+ dpsi = nutation.nutation_in_longitude
55
+ mean_obliquity = MeanObliquity.at(@instant)
56
+ Angle.from_radians(
57
+ gmst.radians + dpsi.radians * mean_obliquity.cos
58
+ )
59
+ end
60
+
61
+ def rotation_matrix_for(angle)
62
+ c, s = angle.cos, angle.sin
63
+ Matrix[
64
+ [c, -s, 0],
65
+ [s, c, 0],
66
+ [0, 0, 1]
67
+ ]
68
+ end
69
+ end
70
+ end
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Astronoby
4
+ # Computes the time of equinoxes and solstices for a given year.
5
+ #
6
+ # Source:
7
+ # Title: Astronomical Algorithms
8
+ # Author: Jean Meeus
9
+ # Edition: 2nd edition
10
+ # Chapter: 27 - Equinoxes and Soltices
4
11
  class EquinoxSolstice
5
- # Source:
6
- # Title: Astronomical Algorithms
7
- # Author: Jean Meeus
8
- # Edition: 2nd edition
9
- # Chapter: 27 - Equinoxes and Soltices
10
-
11
12
  EVENTS = [
12
13
  MARCH_EQUINOX = 0,
13
14
  JUNE_SOLSTICE = 1,
@@ -73,22 +74,39 @@ module Astronoby
73
74
  [8, 15.45, 16859.074]
74
75
  ].freeze
75
76
 
77
+ # @param year [Integer] the year
78
+ # @param ephem [::Ephem::SPK] ephemeris data source
79
+ # @return [Time] time of the March equinox
76
80
  def self.march_equinox(year, ephem)
77
81
  new(year, MARCH_EQUINOX, ephem).time
78
82
  end
79
83
 
84
+ # @param year [Integer] the year
85
+ # @param ephem [::Ephem::SPK] ephemeris data source
86
+ # @return [Time] time of the June solstice
80
87
  def self.june_solstice(year, ephem)
81
88
  new(year, JUNE_SOLSTICE, ephem).time
82
89
  end
83
90
 
91
+ # @param year [Integer] the year
92
+ # @param ephem [::Ephem::SPK] ephemeris data source
93
+ # @return [Time] time of the September equinox
84
94
  def self.september_equinox(year, ephem)
85
95
  new(year, SEPTEMBER_EQUINOX, ephem).time
86
96
  end
87
97
 
98
+ # @param year [Integer] the year
99
+ # @param ephem [::Ephem::SPK] ephemeris data source
100
+ # @return [Time] time of the December solstice
88
101
  def self.december_solstice(year, ephem)
89
102
  new(year, DECEMBER_SOLSTICE, ephem).time
90
103
  end
91
104
 
105
+ # @param year [Integer] the year
106
+ # @param event [Integer] one of MARCH_EQUINOX, JUNE_SOLSTICE,
107
+ # SEPTEMBER_EQUINOX, or DECEMBER_SOLSTICE
108
+ # @param ephem [::Ephem::SPK] ephemeris data source
109
+ # @raise [Astronoby::UnsupportedEventError] if event is invalid
92
110
  def initialize(year, event, ephem)
93
111
  unless EVENTS.include?(event)
94
112
  raise UnsupportedEventError.new(
@@ -97,12 +115,13 @@ module Astronoby
97
115
  end
98
116
 
99
117
  @event = event
118
+ @ephem = ephem
100
119
  @year = (year.to_i - 2000) / 1000.0
101
120
  uncorrected_time = compute
102
121
  @instant = Instant.from_time(uncorrected_time)
103
- @sun = Sun.new(ephem: ephem, instant: @instant)
104
122
  end
105
123
 
124
+ # @return [Time] the corrected UTC time of the event
106
125
  def time
107
126
  Instant.from_terrestrial_time(@instant.tt + corrected).to_time.round
108
127
  end
@@ -135,8 +154,12 @@ module Astronoby
135
154
  component[4] * @year**4
136
155
  end
137
156
 
157
+ def sun
158
+ @sun ||= Sun.new(ephem: @ephem, instant: @instant)
159
+ end
160
+
138
161
  def corrected
139
- longitude = @sun.apparent.ecliptic.longitude
162
+ longitude = sun.apparent.ecliptic.longitude
140
163
  58 * Angle.from_degrees(@event * 90 - longitude.degrees).sin
141
164
  end
142
165
  end
@@ -1,11 +1,22 @@
1
1
  module Astronoby
2
+ # Raised when arguments are mutually incompatible.
2
3
  class IncompatibleArgumentsError < ArgumentError; end
3
4
 
5
+ # Raised when an unsupported format is requested.
4
6
  class UnsupportedFormatError < ArgumentError; end
5
7
 
8
+ # Raised when an unsupported event type is requested.
6
9
  class UnsupportedEventError < ArgumentError; end
7
10
 
11
+ # Raised when an astronomical calculation fails.
8
12
  class CalculationError < StandardError; end
9
13
 
14
+ # Raised when there is an error with ephemeris data.
10
15
  class EphemerisError < StandardError; end
16
+
17
+ # Raised when an orientation kernel is not a supported orientation source.
18
+ class OrientationError < StandardError; end
19
+
20
+ # Raised when an orientation kernel does not cover the requested instant.
21
+ class OrientationOutOfRangeError < StandardError; end
11
22
  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
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astronoby
4
+ # A bounded phase of an eclipse, delimited by its two boundary instants.
5
+ class EclipsePhase
6
+ # @return [Astronoby::Instant] when the phase begins
7
+ attr_reader :starting_instant
8
+
9
+ # @return [Astronoby::Instant] when the phase ends
10
+ attr_reader :ending_instant
11
+
12
+ # @param starting_instant [Astronoby::Instant] when the phase begins
13
+ # @param ending_instant [Astronoby::Instant] when the phase ends
14
+ def initialize(starting_instant:, ending_instant:)
15
+ @starting_instant = starting_instant
16
+ @ending_instant = ending_instant
17
+ freeze
18
+ end
19
+
20
+ # @return [Astronoby::Duration] phase duration
21
+ def duration
22
+ Duration.from_seconds(
23
+ (@ending_instant.to_time - @starting_instant.to_time).round
24
+ )
25
+ end
26
+ end
27
+ end
@@ -1,49 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Astronoby
4
- # Calculates extrema (minima and maxima) in the distance between two
5
- # celestial bodies over a given time range using adaptive sampling and golden
6
- # section search refinement.
7
4
  class ExtremumCalculator
8
- # Mathematical constants
9
- PHI = (1 + Math.sqrt(5)) / 2
10
- INVPHI = 1 / PHI
11
- GOLDEN_SECTION_TOLERANCE = 1e-5
12
-
13
- # Algorithm parameters
14
- MIN_SAMPLES_PER_PERIOD = 20
15
- DUPLICATE_THRESHOLD_DAYS = 0.5
16
- BOUNDARY_BUFFER_DAYS = 0.01
17
-
18
- # Orbital periods
19
- ORBITAL_PERIODS = {
20
- "Astronoby::Moon" => 27.504339,
21
- "Astronoby::Mercury" => 87.969,
22
- "Astronoby::Venus" => 224.701,
23
- "Astronoby::Earth" => 365.256,
24
- "Astronoby::Mars" => 686.98,
25
- "Astronoby::Jupiter" => 4332.59,
26
- "Astronoby::Saturn" => 10759.22,
27
- "Astronoby::Uranus" => 30688.5,
28
- "Astronoby::Neptune" => 60182.0
29
- }.freeze
30
-
31
- # @param ephem [::Ephem::SPK] Ephemeris data source
32
- # @param body [Astronoby::SolarSystemBody] The celestial body to track
33
- # @param primary_body [Astronoby::SolarSystemBody] The reference body
34
- # (e.g., Sun for planetary orbits)
35
- # @param samples_per_period [Integer] Number of samples to take per orbital
36
- # period
5
+ # @param body [Astronoby::SolarSystemBody] the celestial body to track
6
+ # @param primary_body [Astronoby::SolarSystemBody] the reference body
7
+ # @param ephem [::Ephem::SPK] ephemeris data source
8
+ # @param samples_per_period [Integer] number of samples per orbital period
37
9
  def initialize(
38
10
  body:,
39
11
  primary_body:,
40
12
  ephem:,
41
13
  samples_per_period: 60
42
14
  )
43
- @ephem = ephem
44
15
  @body = body
45
16
  @primary_body = primary_body
46
- @orbital_period = ORBITAL_PERIODS.fetch(body.name)
17
+ @ephem = ephem
47
18
  @samples_per_period = samples_per_period
48
19
  end
49
20
 
@@ -53,8 +24,8 @@ module Astronoby
53
24
  # @return [Array<Astronoby::ExtremumEvent>] Array of apoapsis events
54
25
  def apoapsis_events_between(start_time, end_time)
55
26
  find_extrema(
56
- Astronoby::Instant.from_time(start_time).tt,
57
- Astronoby::Instant.from_time(end_time).tt,
27
+ Instant.from_time(start_time).tt,
28
+ Instant.from_time(end_time).tt,
58
29
  type: :maximum
59
30
  )
60
31
  end
@@ -65,8 +36,8 @@ module Astronoby
65
36
  # @return [Array<Astronoby::ExtremumEvent>] Array of periapsis events
66
37
  def periapsis_events_between(start_time, end_time)
67
38
  find_extrema(
68
- Astronoby::Instant.from_time(start_time).tt,
69
- Astronoby::Instant.from_time(end_time).tt,
39
+ Instant.from_time(start_time).tt,
40
+ Instant.from_time(end_time).tt,
70
41
  type: :minimum
71
42
  )
72
43
  end
@@ -78,21 +49,24 @@ module Astronoby
78
49
  # @param type [Symbol] :maximum or :minimum
79
50
  # @return [Array<Astronoby::ExtremumEvent>] Array of extrema events
80
51
  def find_extrema(start_jd, end_jd, type: :maximum)
81
- # 1: Find extrema candidates through adaptive sampling
82
- candidates = find_extrema_candidates(start_jd, end_jd, type)
83
-
84
- # 2: Refine each candidate using golden section search
85
- refined_extrema = candidates
86
- .map { |candidate| refine_extremum(candidate, type) }
87
- .compact
88
-
89
- # 3: Remove duplicates and boundary artifacts
90
- refined_extrema = remove_duplicates(refined_extrema)
91
- filter_boundary_artifacts(refined_extrema, start_jd, end_jd)
52
+ finder.extrema(start_jd, end_jd, type: type).map do |extremum|
53
+ ExtremumEvent.new(
54
+ Instant.from_terrestrial_time(extremum[:jd]),
55
+ extremum[:value]
56
+ )
57
+ end
92
58
  end
93
59
 
94
60
  private
95
61
 
62
+ def finder
63
+ @finder ||= ExtremumFinder.new(
64
+ value_at: method(:distance_at),
65
+ period: @body::ORBITAL_PERIOD,
66
+ samples_per_period: @samples_per_period
67
+ )
68
+ end
69
+
96
70
  def distance_at(jd)
97
71
  instant = Instant.from_terrestrial_time(jd)
98
72
  body_geometric = @body.geometric(ephem: @ephem, instant: instant)
@@ -102,132 +76,5 @@ module Astronoby
102
76
  distance_vector = body_geometric.position - primary_geometric.position
103
77
  distance_vector.magnitude
104
78
  end
105
-
106
- def find_extrema_candidates(start_jd, end_jd, type)
107
- samples = collect_samples(start_jd, end_jd)
108
- find_local_extrema_in_samples(samples, type)
109
- end
110
-
111
- def collect_samples(start_jd, end_jd)
112
- duration = end_jd - start_jd
113
- sample_count = calculate_sample_count(duration)
114
- step = duration / sample_count
115
-
116
- (0..sample_count).map do |i|
117
- jd = start_jd + (i * step)
118
- {jd: jd, value: distance_at(jd)}
119
- end
120
- end
121
-
122
- def calculate_sample_count(duration)
123
- # Adaptive sampling: scale with duration and orbital period
124
- periods_in_range = duration / @orbital_period
125
- base_samples = (periods_in_range * @samples_per_period).to_i
126
-
127
- # Ensure minimum sample density for short ranges
128
- [base_samples, MIN_SAMPLES_PER_PERIOD].max
129
- end
130
-
131
- def find_local_extrema_in_samples(samples, type)
132
- candidates = []
133
-
134
- # Check each interior point for local extrema
135
- (1...samples.length - 1).each do |i|
136
- if local_extremum?(samples, i, type)
137
- candidates << {
138
- start_jd: samples[i - 1][:jd],
139
- end_jd: samples[i + 1][:jd],
140
- center_jd: samples[i][:jd]
141
- }
142
- end
143
- end
144
-
145
- candidates
146
- end
147
-
148
- def local_extremum?(samples, index, type)
149
- current_val = samples[index][:value].m
150
- prev_val = samples[index - 1][:value].m
151
- next_val = samples[index + 1][:value].m
152
-
153
- if type == :maximum
154
- current_val > prev_val && current_val > next_val
155
- else
156
- current_val < prev_val && current_val < next_val
157
- end
158
- end
159
-
160
- def refine_extremum(candidate, type)
161
- golden_section_search(candidate[:start_jd], candidate[:end_jd], type)
162
- end
163
-
164
- def golden_section_search(a, b, type)
165
- return nil if b <= a
166
-
167
- tol = GOLDEN_SECTION_TOLERANCE * (b - a).abs
168
-
169
- # Initial points using golden ratio
170
- x1 = a + (1 - INVPHI) * (b - a)
171
- x2 = a + INVPHI * (b - a)
172
-
173
- f1 = distance_at(x1).m
174
- f2 = distance_at(x2).m
175
-
176
- while (b - a).abs > tol
177
- should_keep_left = (type == :maximum) ? (f1 > f2) : (f1 < f2)
178
-
179
- if should_keep_left
180
- b = x2
181
- x2 = x1
182
- f2 = f1
183
- x1 = a + (1 - INVPHI) * (b - a)
184
- f1 = distance_at(x1).m
185
- else
186
- a = x1
187
- x1 = x2
188
- f1 = f2
189
- x2 = a + INVPHI * (b - a)
190
- f2 = distance_at(x2).m
191
- end
192
- end
193
-
194
- mid = (a + b) / 2
195
- ExtremumEvent.new(
196
- Instant.from_terrestrial_time(mid),
197
- distance_at(mid)
198
- )
199
- end
200
-
201
- def remove_duplicates(extrema)
202
- return extrema if extrema.length <= 1
203
-
204
- cleaned = [extrema.first]
205
-
206
- extrema.each_with_index do |current, i|
207
- next if i == 0
208
-
209
- is_duplicate = cleaned.any? do |existing|
210
- time_diff = (
211
- current.instant.tt - existing.instant.tt
212
- ).abs
213
- time_diff < DUPLICATE_THRESHOLD_DAYS
214
- end
215
-
216
- cleaned << current unless is_duplicate
217
- end
218
-
219
- cleaned
220
- end
221
-
222
- def filter_boundary_artifacts(extrema, start_jd, end_jd)
223
- extrema.reject do |extreme|
224
- start_diff = (extreme.instant.tt - start_jd).abs
225
- end_diff = (extreme.instant.tt - end_jd).abs
226
-
227
- too_close_to_start = start_diff < BOUNDARY_BUFFER_DAYS
228
- too_close_to_end = end_diff < BOUNDARY_BUFFER_DAYS
229
- too_close_to_start || too_close_to_end
230
- end
231
- end
232
79
  end
233
80
  end