ephem 0.4.1 → 0.5.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.
@@ -1,21 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "numo/narray"
4
-
5
3
  module Ephem
6
4
  module Segments
7
- # Manages data segments within SPICE kernel (SPK) files, providing methods
8
- # to compute positions and velocities of celestial bodies using Chebyshev
9
- # polynomial approximations.
10
- #
11
- # Each segment contains data for a specific celestial body (target) relative
12
- # to another body (center) within a specific time range. The data is stored
13
- # as Chebyshev polynomial coefficients that can be evaluated to obtain
14
- # position and velocity vectors.
15
- #
16
- # The class provides thread-safe data loading and caching mechanisms to
17
- # optimize performance while ensuring data consistency in multithreaded
18
- # environments.
5
+ # SPK trajectory segment: position (type 2) and position/velocity (type 3)
6
+ # of a target body relative to a center body, stored as Chebyshev
7
+ # coefficients.
19
8
  #
20
9
  # @example Computing position at a specific time
21
10
  # segment = Ephem::Segments::Segment.new(
@@ -27,29 +16,18 @@ module Ephem
27
16
  #
28
17
  # @example Computing position and velocity
29
18
  # state = segment.compute_and_differentiate(time) # returns State
30
- # position = state.position # Vector
31
- # velocity = state.velocity # Vector
32
19
  #
33
20
  # @see Ephem::Core::Vector
34
21
  # @see Ephem::Core::State
35
- # @see Ephem::Computation::ChebyshevPolynomial
22
+ # @see Ephem::Segments::ChebyshevType2
36
23
  class Segment < BaseSegment
24
+ include ChebyshevType2
25
+
37
26
  COMPONENT_COUNTS = {
38
27
  2 => 3, # Type 2: position (x, y, z)
39
28
  3 => 6 # Type 3: position (x, y, z) and velocity (vx, vy, vz)
40
29
  }.freeze
41
30
 
42
- # @param daf [Ephem::IO::DAF] DAF file object containing the segment data
43
- # @param source [String] Name of the source SPK file
44
- # @param descriptor [Array] Array containing segment metadata:
45
- # - start_second [Float] Start time in seconds from J2000
46
- # - end_second [Float] End time in seconds from J2000
47
- # - target [Integer] NAIF ID of target body
48
- # - center [Integer] NAIF ID of center body
49
- # - frame [Integer] Reference frame ID
50
- # - data_type [Integer] Type of data (2 for position, 3 for pos/vel)
51
- # - start_i [Integer] Start index in DAF array
52
- # - end_i [Integer] End index in DAF array
53
31
  def initialize(daf:, source:, descriptor:)
54
32
  super
55
33
  @data_loaded = false
@@ -59,39 +37,35 @@ module Ephem
59
37
  # Computes the position of the target body relative to the center body at
60
38
  # the specified time.
61
39
  #
62
- # Uses Chebyshev polynomial approximation to interpolate the position from
63
- # stored coefficients. The computation is thread-safe and uses cached data
64
- # when available.
65
- #
66
40
  # @param tdb [Numeric, Array<Numeric>] Time(s) in TDB Julian Date
67
41
  # @param tdb2 [Numeric] Optional fractional part of TDB date
68
42
  # @return [Ephem::Core::Vector] Position vector in kilometers
69
43
  # @raise [Ephem::OutOfRangeError] if time is outside segment coverage
70
- #
71
- # @example Computing Earth's position relative to Solar System Barycenter
72
- # position = segment.compute(2451545.0) # J2000 epoch
73
44
  def compute(tdb, tdb2 = 0.0)
74
- Core::Vector.new(*generate(tdb, tdb2).first)
45
+ load_data
46
+ tdb_seconds = convert_to_seconds(tdb, tdb2)
47
+
48
+ case tdb_seconds
49
+ when Numeric
50
+ position = generate_position(tdb_seconds)
51
+ Core::Vector.new(position[0], position[1], position[2])
52
+ else
53
+ tdb_seconds.map do |t|
54
+ position = generate_position(t)
55
+ Core::Vector.new(position[0], position[1], position[2])
56
+ end
57
+ end
75
58
  end
76
59
  alias_method :position_at, :compute
77
60
 
78
61
  # Computes both position and velocity vectors at the specified time.
79
62
  #
80
- # Uses Chebyshev polynomial approximation and its derivative to compute
81
- # both position and velocity. The computation is thread-safe and uses
82
- # cached data when available.
83
- #
84
63
  # @param tdb [Numeric, Array<Numeric>] Time(s) in TDB Julian Date
85
64
  # @param tdb2 [Numeric] Optional fractional part of TDB date
86
65
  # @return [Ephem::Core::State, Array<Ephem::Core::State>] State object(s)
87
- # containing position and velocity vectors. Returns array if input is
88
- # array.
66
+ # containing position (km) and velocity (km/day) vectors. Returns an
67
+ # array if the input is an array.
89
68
  # @raise [Ephem::OutOfRangeError] if time is outside segment coverage
90
- #
91
- # @example Computing Earth's state vector
92
- # state = segment.compute_and_differentiate(2451545.0)
93
- # position = state.position # in kilometers
94
- # velocity = state.velocity # in kilometers/second
95
69
  def compute_and_differentiate(tdb, tdb2 = 0.0)
96
70
  load_data
97
71
  tdb_seconds = convert_to_seconds(tdb, tdb2)
@@ -114,190 +88,16 @@ module Ephem
114
88
  end
115
89
  alias_method :state_at, :compute_and_differentiate
116
90
 
117
- # Clears cached coefficient data, forcing reload on next computation.
118
- #
119
- # This method is thread-safe and can be used to free memory or force
120
- # fresh data loading if needed.
121
- #
122
- # @return [void]
123
- def clear_data
124
- @data_lock.synchronize do
125
- @data_loaded = false
126
- @midpoints = nil
127
- @radii = nil
128
- @coefficients = nil
129
- end
130
- end
131
-
132
91
  private
133
92
 
134
- def load_data
135
- # Synchronize access to data loading using a mutex lock
136
- # to prevent race conditions in multithreaded environments
137
- @data_lock.synchronize do
138
- return if @data_loaded
139
-
140
- component_count = determine_component_count
141
- coefficients_data = load_coefficient_data
142
- process_coefficient_data(coefficients_data, component_count)
143
-
144
- @data_loaded = true
145
- end
146
- end
147
-
148
- def determine_component_count
93
+ def component_count
149
94
  COMPONENT_COUNTS.fetch(@data_type) do
150
95
  raise "Unsupported data type: #{@data_type}"
151
96
  end
152
97
  end
153
98
 
154
- def load_coefficient_data
155
- # Read metadata from the end of the segment
156
- # start_index: index of first coefficient in segment
157
- # end_index: index of last coefficient in segment
158
- # record_size: total size of each record (coefficients + 2)
159
- # segment_count: number of records in the segment
160
- metadata = @daf.read_array(@end_i - 3, @end_i)
161
- _start_index, _end_index, record_size, segment_count = metadata
162
-
163
- coefficient_count = ((record_size - 2) / determine_component_count).to_i
164
- coefficients_raw = @daf.map_array(@start_i, @end_i - 4)
165
-
166
- [
167
- coefficients_raw,
168
- record_size.to_i,
169
- segment_count.to_i,
170
- coefficient_count
171
- ]
172
- end
173
-
174
- def process_coefficient_data(data, component_count)
175
- coefficients_raw, record_size, segment_count, coefficient_count = data
176
-
177
- # Convert raw coefficient data to Numo::DFloat and reshape to 2D array.
178
- # Numo::NArray allows efficient numerical computations on arrays.
179
- # It provides ndarray data structures and supports various arithmetic
180
- # operations. Using Numo::DFloat ensures the array elements are 64-bit
181
- # floating point numbers.
182
- coefficients = Numo::DFloat.cast(coefficients_raw)
183
- coefficients = coefficients.reshape(segment_count, record_size)
184
-
185
- @midpoints = coefficients[0...segment_count, 0].to_a
186
- @radii = coefficients[0...segment_count, 1].to_a
187
- n_terms = coefficient_count
188
- n_components = component_count
189
-
190
- @coefficients = Array.new(segment_count) do |i|
191
- row = coefficients[i, 2..-1].to_a
192
- Array.new(n_terms) do |k|
193
- Array.new(n_components) do |j|
194
- row[k + j * n_terms]
195
- end
196
- end
197
- end
198
- end
199
-
200
- def convert_to_seconds(tdb, tdb2)
201
- case tdb
202
- when Array
203
- tdb.map { |t| time_to_seconds(t, tdb2) }
204
- when Numo::NArray
205
- tdb.to_a.map { |t| time_to_seconds(t, tdb2) }
206
- else
207
- time_to_seconds(tdb, tdb2)
208
- end
209
- end
210
-
211
- def time_to_seconds(time, offset)
212
- (time - Time::J2000_EPOCH) *
213
- Time::SECONDS_PER_DAY +
214
- offset *
215
- Time::SECONDS_PER_DAY
216
- end
217
-
218
- def generate(tdb, tdb2)
219
- load_data
220
- tdb_seconds = convert_to_seconds(tdb, tdb2)
221
-
222
- case tdb_seconds
223
- when Numeric
224
- generate_single(tdb_seconds)
225
- else
226
- generate_multiple(tdb_seconds)
227
- end
228
- end
229
-
230
- def generate_single(tdb_seconds)
231
- interval = find_interval(tdb_seconds)
232
- normalized_time = compute_normalized_time(tdb_seconds, interval)
233
-
234
- coeffs = @coefficients[interval] # already [n_terms][3]
235
- position = Computation::ChebyshevPolynomial.evaluate(
236
- coeffs,
237
- normalized_time
238
- )
239
- velocity = Computation::ChebyshevPolynomial.evaluate_derivative(
240
- coeffs,
241
- normalized_time,
242
- @radii[interval]
243
- )
244
- [position, velocity]
245
- end
246
-
247
- def generate_multiple(tdb_seconds)
248
- positions = []
249
- velocities = []
250
-
251
- tdb_seconds.each do |time|
252
- pos, vel = generate_single(time)
253
- positions << pos
254
- velocities << vel
255
- end
256
-
257
- [positions, velocities]
258
- end
259
-
260
- def find_interval(tdb_seconds)
261
- left = 0
262
- right = @midpoints.size - 1
263
-
264
- if @last_interval && time_in_interval?(tdb_seconds, @last_interval)
265
- return @last_interval
266
- end
267
-
268
- while left <= right
269
- mid = (left + right) / 2
270
- min_time = @midpoints[mid] - @radii[mid]
271
- max_time = @midpoints[mid] + @radii[mid]
272
-
273
- if tdb_seconds < min_time
274
- right = mid - 1
275
- elsif tdb_seconds > max_time
276
- left = mid + 1
277
- else
278
- @last_interval = mid
279
- return mid
280
- end
281
- end
282
-
283
- raise OutOfRangeError.new(
284
- "Time #{tdb_seconds} is outside the coverage of this segment",
285
- tdb_seconds
286
- )
287
- end
288
-
289
- def time_in_interval?(time, interval)
290
- min_time = @midpoints[interval] - @radii[interval]
291
- max_time = @midpoints[interval] + @radii[interval]
292
- time.between?(min_time, max_time)
293
- end
294
-
295
- def compute_normalized_time(time_seconds, interval)
296
- (time_seconds - @midpoints[interval]) / @radii[interval]
297
- end
298
-
299
- Registry.register(2, self)
300
- Registry.register(3, self)
99
+ Registry.register(:spk, 2, self)
100
+ Registry.register(:spk, 3, self)
301
101
  end
302
102
  end
303
103
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephem
4
+ module Segments
5
+ # Several segments that share the same key (an SPK center/target pair, or a
6
+ # PCK body) but cover different, contiguous time intervals. Each query is
7
+ # routed to the segment covering the requested time, so a body that a kernel
8
+ # splits across several intervals behaves as a single, continuous source.
9
+ #
10
+ # SPK and PCK only build a group when a key actually has more than one
11
+ # segment; the common single-segment case returns the bare segment, so this
12
+ # routing never sits in the hot path for it.
13
+ #
14
+ # Subclasses ({PositionGroup}, {OrientationGroup}) add the query methods
15
+ # appropriate to the segments they hold.
16
+ class SegmentGroup
17
+ # Wraps segments that share a key. A single segment is returned as-is, so
18
+ # the common case carries no routing overhead; only a key spanning several
19
+ # time intervals becomes a group.
20
+ #
21
+ # @param segments [Array<BaseSegment>] segments sharing the same key
22
+ # @return [BaseSegment, SegmentGroup]
23
+ def self.wrap(segments)
24
+ segments.one? ? segments.first : new(segments)
25
+ end
26
+
27
+ # @return [Array<BaseSegment>] the underlying segments
28
+ attr_reader :segments
29
+
30
+ # @param segments [Array<BaseSegment>] segments sharing the same key
31
+ def initialize(segments)
32
+ @segments = segments
33
+ end
34
+
35
+ # Clears cached data for every segment in the group.
36
+ #
37
+ # @return [void]
38
+ def clear_data
39
+ @segments.each(&:clear_data)
40
+ end
41
+
42
+ def to_s
43
+ @segments.join("\n")
44
+ end
45
+
46
+ private
47
+
48
+ # Routes a query to the covering segment(s) and assembles the result. For
49
+ # a scalar time the block is called once with that segment and time; for
50
+ # an array, times are grouped by covering segment so each is queried in a
51
+ # single batched call, then results are reassembled in input order.
52
+ def query(tdb, tdb2)
53
+ if tdb.is_a?(Array)
54
+ query_many(tdb, tdb2) { |segment, times| yield segment, times, tdb2 }
55
+ else
56
+ yield segment_for(tdb, tdb2), tdb, tdb2
57
+ end
58
+ end
59
+
60
+ def query_many(times, tdb2)
61
+ results = Array.new(times.size)
62
+ indices_by_segment = times.each_index.group_by do |index|
63
+ segment_for(times[index], tdb2)
64
+ end
65
+
66
+ indices_by_segment.each do |segment, indices|
67
+ segment_results = yield(segment, indices.map { |index| times[index] })
68
+ indices.each_with_index do |original_index, position|
69
+ results[original_index] = segment_results[position]
70
+ end
71
+ end
72
+
73
+ results
74
+ end
75
+
76
+ def segment_for(tdb, tdb2)
77
+ @segments.reverse_each.find { |segment| segment.covers?(tdb, tdb2) } ||
78
+ raise(OutOfRangeError.new(
79
+ "Time #{tdb} is outside the coverage of this group", tdb
80
+ ))
81
+ end
82
+ end
83
+ end
84
+ end
data/lib/ephem/spk.rb CHANGED
@@ -25,9 +25,8 @@ module Ephem
25
25
  DE_FILENAME = "NIO2SPK"
26
26
 
27
27
  DATA_TYPE_IDENTIFIER = 5
28
- SEGMENT_CLASSES = {}
29
28
 
30
- attr_reader :segments, :pairs
29
+ attr_reader :daf, :segments, :pairs
31
30
 
32
31
  # Creates a new SPK instance with the given DAF.
33
32
  #
@@ -49,9 +48,16 @@ module Ephem
49
48
  # @raise [ArgumentError] If the file cannot be accessed due to permissions
50
49
  def self.open(path)
51
50
  daf = IO::DAF.new(File.open(path, "rb"))
51
+ if daf.file_type == :pck
52
+ raise ArgumentError, "#{path} is a binary PCK file, use Ephem::PCK.open"
53
+ end
54
+
52
55
  new(daf: daf)
53
56
  rescue Errno::EACCES => e
54
57
  raise ArgumentError, "File permission denied: #{path} (#{e.message})"
58
+ rescue
59
+ daf&.close
60
+ raise
55
61
  end
56
62
 
57
63
  # Closes the SPK file and cleans up resources.
@@ -69,7 +75,7 @@ module Ephem
69
75
  def to_s
70
76
  <<~DESCRIPTION
71
77
  SPK file with #{@segments.size} segments:
72
- #{@segments.map(&:to_s).join("\n")}
78
+ #{@segments.join("\n")}
73
79
  DESCRIPTION
74
80
  end
75
81
 
@@ -77,8 +83,9 @@ module Ephem
77
83
  #
78
84
  # @param center [Integer] NAIF ID of the center body
79
85
  # @param target [Integer] NAIF ID of the target body
80
- # @return [Segments::BaseSegment] The segment containing data for the
81
- # specified bodies
86
+ # @return [Segments::Segment, Segments::PositionGroup] the position source
87
+ # for the pair: a single segment, or a group routing each query to the
88
+ # covering segment when the pair spans several time intervals
82
89
  # @raise [KeyError] If no segment is found for the given center-target pair
83
90
  def [](center, target)
84
91
  @pairs.fetch([center, target]) do
@@ -139,14 +146,18 @@ module Ephem
139
146
  end
140
147
 
141
148
  def build_pairs
142
- @segments.to_h do |segment|
143
- [[segment.center, segment.target], segment]
144
- end
149
+ @segments
150
+ .group_by { |segment| [segment.center, segment.target] }
151
+ .transform_values { |segments| Segments::PositionGroup.wrap(segments) }
145
152
  end
146
153
 
147
154
  def build_segment(source:, descriptor:)
148
155
  data_type = descriptor[DATA_TYPE_IDENTIFIER]
149
- segment_class = SEGMENT_CLASSES.fetch(data_type, Segments::BaseSegment)
156
+ segment_class = Segments::Registry.lookup(
157
+ :spk,
158
+ data_type,
159
+ Segments::BaseSegment
160
+ )
150
161
  segment_class.new(daf: @daf, source: source, descriptor: descriptor)
151
162
  end
152
163
  end
data/lib/ephem/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ephem
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/ephem.rb CHANGED
@@ -6,6 +6,8 @@ require_relative "ephem/computation/chebyshev_polynomial"
6
6
  require_relative "ephem/core/calendar_calculations"
7
7
  require_relative "ephem/core/state"
8
8
  require_relative "ephem/core/vector"
9
+ require_relative "ephem/core/orientation"
10
+ require_relative "ephem/core/rotation"
9
11
  require_relative "ephem/error"
10
12
  require_relative "ephem/io/binary_reader"
11
13
  require_relative "ephem/io/daf"
@@ -15,9 +17,16 @@ require_relative "ephem/io/record_data"
15
17
  require_relative "ephem/io/record_parser"
16
18
  require_relative "ephem/io/summary_manager"
17
19
  require_relative "ephem/spk"
20
+ require_relative "ephem/pck"
18
21
  require_relative "ephem/segments/base_segment"
19
22
  require_relative "ephem/segments/registry"
23
+ require_relative "ephem/segments/chebyshev_type2"
24
+ require_relative "ephem/segments/orientation_source"
20
25
  require_relative "ephem/segments/segment"
26
+ require_relative "ephem/segments/orientation_segment"
27
+ require_relative "ephem/segments/segment_group"
28
+ require_relative "ephem/segments/position_group"
29
+ require_relative "ephem/segments/orientation_group"
21
30
  require_relative "ephem/excerpt"
22
31
  require_relative "ephem/cli"
23
32
  require_relative "ephem/version"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ephem
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rémy Hannequin
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-08-03 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: minitar
@@ -23,20 +23,6 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0.12'
26
- - !ruby/object:Gem::Dependency
27
- name: numo-narray
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: 0.9.2.1
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: 0.9.2.1
40
26
  - !ruby/object:Gem::Dependency
41
27
  name: zlib
42
28
  requirement: !ruby/object:Gem::Requirement
@@ -121,6 +107,20 @@ dependencies:
121
107
  - - "~>"
122
108
  - !ruby/object:Gem::Version
123
109
  version: '3.13'
110
+ - !ruby/object:Gem::Dependency
111
+ name: benchmark-ips
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '2.14'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '2.14'
124
124
  - !ruby/object:Gem::Dependency
125
125
  name: standard
126
126
  requirement: !ruby/object:Gem::Requirement
@@ -152,6 +152,7 @@ files:
152
152
  - LICENSE.txt
153
153
  - README.md
154
154
  - Rakefile
155
+ - benchmarks/run.rb
155
156
  - bin/console
156
157
  - bin/ruby-ephem
157
158
  - bin/setup
@@ -161,6 +162,8 @@ files:
161
162
  - lib/ephem/core/calendar_calculations.rb
162
163
  - lib/ephem/core/constants/bodies.rb
163
164
  - lib/ephem/core/constants/time.rb
165
+ - lib/ephem/core/orientation.rb
166
+ - lib/ephem/core/rotation.rb
164
167
  - lib/ephem/core/state.rb
165
168
  - lib/ephem/core/vector.rb
166
169
  - lib/ephem/download.rb
@@ -172,9 +175,16 @@ files:
172
175
  - lib/ephem/io/record_data.rb
173
176
  - lib/ephem/io/record_parser.rb
174
177
  - lib/ephem/io/summary_manager.rb
178
+ - lib/ephem/pck.rb
175
179
  - lib/ephem/segments/base_segment.rb
180
+ - lib/ephem/segments/chebyshev_type2.rb
181
+ - lib/ephem/segments/orientation_group.rb
182
+ - lib/ephem/segments/orientation_segment.rb
183
+ - lib/ephem/segments/orientation_source.rb
184
+ - lib/ephem/segments/position_group.rb
176
185
  - lib/ephem/segments/registry.rb
177
186
  - lib/ephem/segments/segment.rb
187
+ - lib/ephem/segments/segment_group.rb
178
188
  - lib/ephem/spk.rb
179
189
  - lib/ephem/version.rb
180
190
  - lib/tasks/validate_accuracy.rake
@@ -199,7 +209,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
199
209
  - !ruby/object:Gem::Version
200
210
  version: '0'
201
211
  requirements: []
202
- rubygems_version: 3.6.2
212
+ rubygems_version: 4.0.10
203
213
  specification_version: 4
204
214
  summary: Compute astronomical ephemerides from NASA/JPL DE and IMCCE INPOP
205
215
  test_files: []