ephem 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 86c850bd01f5699f6605f878ef9a6ce0a35fcce89e3b0ac0fdfeaf1b39872c2c
4
- data.tar.gz: 3e9342db922833b977bf78acdde7616c985b5d12e28c84d1189db2747136eeb3
3
+ metadata.gz: 24301ad81a75b670a87dd1b33e7d8c84e9e2bd9bd007e5d6f3564dbdc5d387ae
4
+ data.tar.gz: b6c16db80b49173c1aea2853774c2c1fb81bc0513d0273bb1f5564fd2043109c
5
5
  SHA512:
6
- metadata.gz: 3cebca5c4f487ef0cdda0a68c564db90674c14fccbef232e0b16c6d87b7ce0a603a6d9fede22180d1de2dd8da82f4040ea3f3e5ce22e40c0718012289357413c
7
- data.tar.gz: 9801e2dbc8a41bb9f38dc39cfcf827e69fee9ed4490d1aa426c8a951980cc28d31ca20eb3f462dabefa944667cf6906e3df093826645d3fe050229b6544f3f10
6
+ metadata.gz: 28e9df83433cb9a338197c1911cf136147c05b22909932ed591ce6512be2d58304c182947cc8232437f65342c2ac2d600329fdde648b9378d63ba0770ef724a6
7
+ data.tar.gz: 4d1a96e724299ecd9c220283807eb47312ea7134698d9cffd31edb1d3be716add8da1af15b831246066ad2cd894c6249d185c3bf8bec87c2b525c931ff3b4a70
data/CHANGELOG.md CHANGED
@@ -1,4 +1,47 @@
1
- ## [Unreleased]
1
+ # Changelog
2
+
3
+ ## [0.2.0] - 2025-03-28
4
+
5
+ ### Features
6
+
7
+ * Simplify download ([#12])
8
+ * SPK excerpt generator ([#13])
9
+ * Improve documentation on excerpts ([#16])
10
+ * IMCCE INPOP support ([#20])
11
+
12
+ ### Improvements
13
+
14
+ * Add Dependabot ([#6])
15
+ * Replace testing kernel ([#17])
16
+ * Add `irb` to dev dependencies ([#14])
17
+ * Add support for Rubies `3.2.7` and `3.4.2` ([#15])
18
+ * Bump csv from 3.3.0 to 3.3.2 by @dependabot ([#7])
19
+ * Bump standard from 1.43.0 to 1.44.0 by @dependabot ([#8])
20
+ * Bump standard from 1.44.0 to 1.45.0 by @dependabot ([#9])
21
+ * Bump csv from 3.3.2 to 3.3.3 by @dependabot ([#11])
22
+ * Bump standard from 1.45.0 to 1.47.0 by @dependabot ([#10])
23
+ * Bump json from 2.10.1 to 2.10.2 by @dependabot ([#18])
24
+
25
+ ### New Contributors
26
+
27
+ * @dependabot made their first contribution in [#7]
28
+
29
+ **Full Changelog**: https://github.com/rhannequin/ruby-ephem/compare/v0.1.0...v0.2.0
30
+
31
+ [#6]: https://github.com/rhannequin/ruby-ephem/pull/6
32
+ [#7]: https://github.com/rhannequin/ruby-ephem/pull/7
33
+ [#8]: https://github.com/rhannequin/ruby-ephem/pull/8
34
+ [#9]: https://github.com/rhannequin/ruby-ephem/pull/9
35
+ [#10]: https://github.com/rhannequin/ruby-ephem/pull/10
36
+ [#11]: https://github.com/rhannequin/ruby-ephem/pull/11
37
+ [#12]: https://github.com/rhannequin/ruby-ephem/pull/12
38
+ [#13]: https://github.com/rhannequin/ruby-ephem/pull/13
39
+ [#14]: https://github.com/rhannequin/ruby-ephem/pull/14
40
+ [#15]: https://github.com/rhannequin/ruby-ephem/pull/15
41
+ [#16]: https://github.com/rhannequin/ruby-ephem/pull/16
42
+ [#17]: https://github.com/rhannequin/ruby-ephem/pull/17
43
+ [#18]: https://github.com/rhannequin/ruby-ephem/pull/18
44
+ [#20]: https://github.com/rhannequin/ruby-ephem/pull/20
2
45
 
3
46
  ## [0.1.0] - 2025-01-01
4
47
 
data/README.md CHANGED
@@ -1,15 +1,21 @@
1
1
  # Ephem
2
2
 
3
- Ephem is a Ruby gem that provides a simple interface to the JPL Development
4
- Ephemeris (DE) series as SPICE binary kernel files. The DE series is a
5
- collection of numerical integrations of the equations of motion of the solar
6
- system, used to calculate the positions of the planets, the Moon, and other
7
- celestial bodies with high precision.
3
+ Ephem is a Ruby gem that provides a simple interface to the SPICE binary kernel
4
+ files such as:
5
+ * _JPL [Development Ephemeris]_ (DE)
6
+ * _IMCCE [Intégrateur numérique planétaire de l'Observatoire de Paris]_ (INPOP)
8
7
 
9
- Ephem currently only support planetary ephemerides like DE405, DE421, de430,
10
- etc.
8
+ [Development Ephemeris]: https://ssd.jpl.nasa.gov/planets/eph_export.html
9
+ [Intégrateur numérique planétaire de l'Observatoire de Paris]: https://www.imcce.fr/inpop
11
10
 
12
- The library in high development mode and does not have a stable version yet.
11
+ These files are a collection of numerical integrations of the equations of
12
+ motion of the Solar System, used to calculate the positions of the planets,
13
+ the Moon, and other celestial bodies with high precision.
14
+
15
+ Ephem currently only support planetary ephemerides like DE421, DE430,
16
+ INPOP19A, etc.
17
+
18
+ The library is in high development mode and does not have a stable version yet.
13
19
  The API is subject to major changes at the moment, please keep that in mind if
14
20
  you consider adding this gem as a dependency.
15
21
 
@@ -28,14 +34,21 @@ executing:
28
34
  gem install ephem
29
35
  ```
30
36
 
37
+ ## How to select the right kernel file
38
+
39
+ JPL and IMCCE produces many different kernels over the years, with different
40
+ accuracy and ranges of supported years. Here are some that we would recommend to
41
+ begin with:
42
+
43
+ * `de421.bsp`: from 1900 to 2050, 17 MB
44
+ * `de440s.bsp`: from 1849 to 2150, 32 MB
45
+ * `inpop19a.bsp`: from 1900 to 2100, 22 MB
46
+
31
47
  ## Usage
32
48
 
33
49
  ```rb
34
50
  # Download and store the SPICE binary kernel file
35
- Ephem::IO::Download.call(
36
- name: "de421.bsp",
37
- target: "tmp/de421.bsp"
38
- )
51
+ Ephem::Download.call(name: "de421.bsp", target: "tmp/de421.bsp")
39
52
 
40
53
  # Load the kernel
41
54
  spk = Ephem::SPK.open("tmp/de421.bsp")
@@ -86,6 +99,28 @@ puts "Velocity: #{state.velocity}"
86
99
  # The velocity is expressed in km/day
87
100
  ```
88
101
 
102
+ ## CLI
103
+
104
+ The gem also provides a CLI to generate an excerpt from an original kernel file.
105
+
106
+ ```bash
107
+ ruby-ephem excerpt [options] START_DATE END_DATE INPUT_FILE OUTPUT_FILE
108
+ ```
109
+
110
+ For example, generate an excerpt from DE440s for the year 2025 only supporting
111
+ the Sun, the Earth, the Moon and the Earth-Moon Barycenter:
112
+
113
+ ```bash
114
+ ruby-ephem excerpt --targets 3,10,301,399 2025-01-01 2026-01-01 /path/to/de440s.bsp 2025_excerpt.bsp
115
+ ```
116
+
117
+ While DE440s originally supports 14 segments from 1849 to 2150 with a size of
118
+ 32 MB, the excerpt will only support 4 segments from 2025 to 2026 with a size of
119
+ 140 KB.
120
+
121
+ Not only the excerpt is smaller, but most importantly it is way more
122
+ efficient to parse and to use in your application.
123
+
89
124
  ## Accuracy
90
125
 
91
126
  Data from this library has been tested against the Python library [jplephem]
data/bin/console ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "ephem"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ require "irb"
11
+ IRB.start(__FILE__)
data/bin/ruby-ephem ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Add the lib directory to the load path
5
+ lib = File.expand_path("../lib", __dir__)
6
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
7
+
8
+ require "ephem"
9
+ require "ephem/cli"
10
+
11
+ # Run the CLI
12
+ Ephem::CLI.start(ARGV)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/lib/ephem/cli.rb ADDED
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "date"
5
+
6
+ module Ephem
7
+ class CLI
8
+ def self.gregorian_to_julian(date_str)
9
+ date = Date.parse(date_str)
10
+
11
+ a = (14 - date.month) / 12
12
+ y = date.year + 4800 - a
13
+ m = date.month + 12 * a - 3
14
+
15
+ date.day +
16
+ ((153 * m + 2) / 5) +
17
+ 365 * y +
18
+ (y / 4) -
19
+ (y / 100) +
20
+ (y / 400) -
21
+ 32045
22
+ end
23
+
24
+ def self.start(args)
25
+ if args.empty?
26
+ puts "Usage: ruby-ephem excerpt [options] START_DATE END_DATE INPUT_FILE OUTPUT_FILE"
27
+ puts "For help: ruby-ephem help"
28
+ return
29
+ end
30
+
31
+ if args[0] == "help"
32
+ show_help
33
+ return
34
+ end
35
+
36
+ if args[0] == "excerpt"
37
+ handle_excerpt(args[1..])
38
+ return
39
+ end
40
+
41
+ puts "Unknown command: #{args[0]}"
42
+ puts "Available commands: excerpt, help"
43
+ end
44
+
45
+ def self.show_help
46
+ puts <<~HELP
47
+ Ruby Ephem - A tool for working with JPL Ephemerides
48
+
49
+ Commands:
50
+ excerpt - Create an excerpt of an SPK file
51
+ help - Show this help message
52
+
53
+ Excerpt command:
54
+ ruby-ephem excerpt [options] START_DATE END_DATE INPUT_FILE OUTPUT_FILE
55
+
56
+ Options:
57
+ --targets TARGET_IDS - Comma-separated list of target IDs to include
58
+ (default: all targets)
59
+
60
+ Example:
61
+ ruby-ephem excerpt --targets 3,10,399 2000-01-01 2030-01-01 de440s.bsp excerpt.bsp
62
+
63
+ This will create an excerpt of de440s.bsp containing only the specified
64
+ targets (Earth-Moon barycenter, Sun, Earth) for the period from
65
+ 2000-01-01 to 2030-01-01.
66
+ HELP
67
+ end
68
+
69
+ # Handle the excerpt command
70
+ def self.handle_excerpt(args)
71
+ # Parse options
72
+ options = {target_ids: nil, debug: false}
73
+
74
+ option_parser = OptionParser.new do |opts|
75
+ opts.banner = "Usage: ruby-ephem excerpt [options] START_DATE END_DATE INPUT_FILE OUTPUT_FILE"
76
+
77
+ opts.on("--targets TARGET_IDS", "Comma-separated list of target IDs to include") do |targets|
78
+ options[:target_ids] = targets.split(",").map(&:strip).map(&:to_i)
79
+ end
80
+
81
+ opts.on("--debug", "Enable debug output") do
82
+ options[:debug] = true
83
+ end
84
+ end
85
+
86
+ begin
87
+ option_parser.parse!(args)
88
+ rescue OptionParser::InvalidOption => e
89
+ puts e.message
90
+ puts option_parser
91
+ return
92
+ end
93
+
94
+ if args.size < 4
95
+ puts "Not enough arguments."
96
+ puts option_parser
97
+ return
98
+ end
99
+
100
+ start_date_str = args[0]
101
+ end_date_str = args[1]
102
+ input_file = args[2]
103
+ output_file = args[3]
104
+
105
+ unless File.exist?(input_file)
106
+ puts "Error: Input file '#{input_file}' does not exist."
107
+ return
108
+ end
109
+
110
+ begin
111
+ start_jd = gregorian_to_julian(start_date_str)
112
+ end_jd = gregorian_to_julian(end_date_str)
113
+ rescue Date::Error => e
114
+ puts "Error parsing dates: #{e.message}"
115
+ puts "Dates should be in YYYY-MM-DD format."
116
+ return
117
+ end
118
+
119
+ begin
120
+ puts "Creating excerpt from #{input_file} to #{output_file}..."
121
+ puts "Date range: #{start_date_str} to #{end_date_str} (JD #{start_jd} to #{end_jd})"
122
+ if options[:target_ids]
123
+ puts "Including targets: #{options[:target_ids].join(", ")}"
124
+ else
125
+ puts "Including all targets"
126
+ end
127
+
128
+ spk = Ephem::SPK.open(input_file)
129
+
130
+ excerpt_spk = spk.excerpt(
131
+ output_path: output_file,
132
+ start_jd: start_jd,
133
+ end_jd: end_jd,
134
+ target_ids: options[:target_ids],
135
+ debug: options[:debug]
136
+ )
137
+
138
+ puts "Excerpt created successfully!"
139
+ puts "Original segments: #{spk.segments.size}"
140
+ puts "Excerpt segments: #{excerpt_spk.segments.size}"
141
+
142
+ original_size = File.size(input_file)
143
+ excerpt_size = File.size(output_file)
144
+ reduction_percentage =
145
+ ((original_size - excerpt_size) / original_size.to_f * 100).round(2)
146
+
147
+ puts "File size reduced by #{reduction_percentage}%"
148
+ puts "Original: #{original_size} bytes"
149
+ puts "Excerpt: #{excerpt_size} bytes"
150
+
151
+ spk.close
152
+ excerpt_spk.close
153
+ rescue => e
154
+ puts "Error creating excerpt: #{e.message}"
155
+ puts e.backtrace if options[:debug]
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitar"
4
+ require "net/http"
5
+ require "tempfile"
6
+ require "zlib"
7
+
8
+ module Ephem
9
+ class Download
10
+ JPL_BASE_URL = "https://ssd.jpl.nasa.gov/ftp/eph/planets/bsp/"
11
+ IMCCE_BASE_URL = "https://ftp.imcce.fr/pub/ephem/planets/"
12
+
13
+ JPL_KERNELS = %w[
14
+ de102.bsp
15
+ de200.bsp
16
+ de202.bsp
17
+ de403.bsp
18
+ de405.bsp
19
+ de405_1960_2020.bsp
20
+ de406.bsp
21
+ de410.bsp
22
+ de413.bsp
23
+ de414.bsp
24
+ de418.bsp
25
+ de421.bsp
26
+ de422.bsp
27
+ de422_1850_2050.bsp
28
+ de423.bsp
29
+ de424.bsp
30
+ de424s.bsp
31
+ de425.bsp
32
+ de430_1850-2150.bsp
33
+ de430_plus_MarsPC.bsp
34
+ de430t.bsp
35
+ de431.bsp
36
+ de432t.bsp
37
+ de433.bsp
38
+ de433_plus_MarsPC.bsp
39
+ de433t.bsp
40
+ de434.bsp
41
+ de434s.bsp
42
+ de434t.bsp
43
+ de435.bsp
44
+ de435s.bsp
45
+ de435t.bsp
46
+ de436.bsp
47
+ de436s.bsp
48
+ de436t.bsp
49
+ de438.bsp
50
+ de438_plus_MarsPC.bsp
51
+ de438s.bsp
52
+ de438t.bsp
53
+ de440.bsp
54
+ de440s.bsp
55
+ de440s_plus_MarsPC.bsp
56
+ de440t.bsp
57
+ de441.bsp
58
+ ].freeze
59
+
60
+ IMCCE_KERNELS = {
61
+ "inpop10b.bsp" => "inpop10b_TDB_m100_p100_spice.bsp",
62
+ "inpop10b_large.bsp" => "inpop10b_TDB_m1000_p1000_spice.bsp",
63
+ "inpop10e.bsp" => "inpop10e_TDB_m100_p100_spice.bsp",
64
+ "inpop10e_large.bsp" => "inpop10e_TDB_m1000_p1000_spice.bsp",
65
+ "inpop13c.bsp" => "inpop13c_TDB_m100_p100_spice.bsp",
66
+ "inpop13c_large.bsp" => "inpop13c_TDB_m1000_p1000_spice.bsp",
67
+ "inpop17a.bsp" => "inpop17a_TDB_m100_p100_spice.bsp",
68
+ "inpop17a_large.bsp" => "inpop17a_TDB_m1000_p1000_spice.bsp",
69
+ "inpop19a.bsp" => "inpop19a_TDB_m100_p100_spice.bsp",
70
+ "inpop19a_large.bsp" => "inpop19a_TDB_m1000_p1000_spice.bsp",
71
+ "inpop21a.bsp" => "inpop21a_TDB_m100_p100_spice.bsp",
72
+ "inpop21a_large.bsp" => "inpop21a_TDB_m1000_p1000_spice.bsp"
73
+ }.freeze
74
+
75
+ IMCCE_KERNELS_MATCHING = {
76
+ "inpop10b.bsp" => "inpop10b/inpop10b_TDB_m100_p100_spice.tar.gz",
77
+ "inpop10b_large.bsp" => "inpop10b/inpop10b_TDB_m1000_p1000_spice.tar.gz",
78
+ "inpop10e.bsp" => "inpop10e/inpop10e_TDB_m100_p100_spice_release2.tar.gz",
79
+ "inpop10e_large.bsp" =>
80
+ "inpop10e/inpop10e_TDB_m1000_p1000_spice_release2.tar.gz",
81
+ "inpop13c.bsp" => "inpop13c/inpop13c_TDB_m100_p100_spice.tar.gz",
82
+ "inpop13c_large.bsp" => "inpop13c/inpop13c_TDB_m1000_p1000_spice.tar.gz",
83
+ "inpop17a.bsp" => "inpop17a/inpop17a_TDB_m100_p100_spice.tar.gz",
84
+ "inpop17a_large.bsp" => "inpop17a/inpop17a_TDB_m1000_p1000_spice.tar.gz",
85
+ "inpop19a.bsp" => "inpop19a/inpop19a_TDB_m100_p100_spice.tar.gz",
86
+ "inpop19a_large.bsp" => "inpop19a/inpop19a_TDB_m1000_p1000_spice.tar.gz",
87
+ "inpop21a.bsp" => "inpop21a/inpop21a_TDB_m100_p100_spice.tar.gz",
88
+ "inpop21a_large.bsp" => "inpop21a/inpop21a_TDB_m1000_p1000_spice.tar.gz"
89
+ }.freeze
90
+
91
+ SUPPORTED_KERNELS = (JPL_KERNELS + IMCCE_KERNELS.keys).freeze
92
+
93
+ def self.call(name:, target:)
94
+ new(name, target).call
95
+ end
96
+
97
+ def initialize(name, local_path)
98
+ @name = name
99
+ @local_path = local_path
100
+ validate_requested_kernel!
101
+ end
102
+
103
+ def call
104
+ content = jpl_kernel? ? download_from_jpl : download_from_imcce
105
+ File.write(@local_path, content)
106
+
107
+ true
108
+ end
109
+
110
+ private
111
+
112
+ def validate_requested_kernel!
113
+ unless SUPPORTED_KERNELS.include?(@name)
114
+ raise UnsupportedError,
115
+ "Kernel #{@name} is not supported by the library at the moment."
116
+ end
117
+ end
118
+
119
+ def jpl_kernel?
120
+ JPL_KERNELS.include?(@name)
121
+ end
122
+
123
+ def download_from_jpl
124
+ uri = URI("#{JPL_BASE_URL}#{@name}")
125
+ Net::HTTP.get(uri)
126
+ end
127
+
128
+ def download_from_imcce
129
+ temp_file = Tempfile.new(%w[archive .tar.gz])
130
+ uri = URI("#{IMCCE_BASE_URL}#{IMCCE_KERNELS_MATCHING[@name]}")
131
+ content = Net::HTTP.get(uri)
132
+ temp_file.write(content)
133
+ temp_file.rewind
134
+
135
+ Zlib::GzipReader.open(temp_file.path) do |gz|
136
+ Minitar::Reader.open(gz) do |tar|
137
+ tar.each_entry do |entry|
138
+ return entry.read if entry.full_name == IMCCE_KERNELS[@name]
139
+ end
140
+ end
141
+ end
142
+ ensure
143
+ temp_file.close
144
+ temp_file.unlink
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ephem
4
+ # The Excerpt class creates SPK file excerpts with reduced time spans and
5
+ # target bodies. This is useful for creating smaller files that focus only on
6
+ # the data needed for specific applications.
7
+ #
8
+ # @example Create an excerpt with specific time range and bodies
9
+ # spk = Ephem::SPK.open("de421.bsp")
10
+ # excerpt = Ephem::Excerpt.new(spk).extract(
11
+ # output_path: "excerpt.bsp",
12
+ # start_jd: 2458849.5, # January 1, 2020
13
+ # end_jd: 2459580.5, # December 31, 2021
14
+ # target_ids: [3, 10, 301, 399] # Earth-Moon, Sun, Moon, Earth
15
+ # )
16
+ class Excerpt
17
+ # Constants for time calculations
18
+ S_PER_DAY = Core::Constants::Time::SECONDS_PER_DAY
19
+ J2000_EPOCH = Core::Constants::Time::J2000_EPOCH
20
+ RECORD_SIZE = 1024
21
+
22
+ # @param spk [Ephem::SPK] The SPK object to create an excerpt from
23
+ def initialize(spk)
24
+ @spk = spk
25
+ @daf = spk.instance_variable_get(:@daf)
26
+ @binary_reader = @daf.instance_variable_get(:@binary_reader)
27
+ end
28
+
29
+ # Creates an excerpt of the SPK file
30
+ #
31
+ # @param output_path [String] Path where the excerpt will be written
32
+ # @param start_jd [Float] Start time as Julian Date
33
+ # @param end_jd [Float] End time as Julian Date
34
+ # @param target_ids [Array<Integer>, nil] Optional list of target IDs to
35
+ # include
36
+ # @param debug [Boolean] Whether to print debug information
37
+ #
38
+ # @return [Ephem::SPK] A new SPK instance for the excerpt file
39
+ def extract(output_path:, start_jd:, end_jd:, target_ids: nil, debug: false)
40
+ start_seconds = seconds_since_j2000(start_jd)
41
+ end_seconds = seconds_since_j2000(end_jd)
42
+ output_file = File.open(output_path, "wb+")
43
+ copy_file_header(output_file)
44
+ initialize_summary_section(output_file)
45
+ writer = create_daf_writer(output_file, debug)
46
+ process_segments(writer, start_seconds, end_seconds, target_ids, debug)
47
+ output_file.close
48
+
49
+ SPK.open(output_path)
50
+ end
51
+
52
+ private
53
+
54
+ def seconds_since_j2000(jd)
55
+ (jd - J2000_EPOCH) * S_PER_DAY
56
+ end
57
+
58
+ def copy_file_header(output_file)
59
+ # Get the first record number of summaries from original DAF
60
+ fward = @daf.record_data.forward_record
61
+
62
+ # Copy file record and comments
63
+ (1...fward).each do |n|
64
+ data = @binary_reader.read_record(n)
65
+ output_file.write(data)
66
+ end
67
+ end
68
+
69
+ def initialize_summary_section(output_file)
70
+ summary_data = "\0".ljust(RECORD_SIZE, "\0")
71
+ name_data = " ".ljust(RECORD_SIZE, "\0")
72
+ output_file.write(summary_data)
73
+ output_file.write(name_data)
74
+ end
75
+
76
+ def create_daf_writer(output_file, debug)
77
+ writer = DAFWriter.new(output_file, debug)
78
+ fward = @daf.record_data.forward_record
79
+ writer.fward = writer.bward = fward
80
+ writer.free = (fward + 1) * (RECORD_SIZE / 8) + 1
81
+ writer.endianness = @daf.endianness
82
+ writer.setup_formats(
83
+ @daf.record_data.double_count,
84
+ @daf.record_data.integer_count
85
+ )
86
+ writer.write_file_record
87
+
88
+ writer
89
+ end
90
+
91
+ def process_segments(writer, start_seconds, end_seconds, target_ids, debug)
92
+ segments_processed = 0
93
+ segments_included = 0
94
+
95
+ # Get all summaries from the original DAF
96
+ @daf.summaries.each do |name, values|
97
+ segments_processed += 1
98
+
99
+ # Filter by target ID if specified
100
+ if target_ids && !target_ids.empty?
101
+ # The target ID is at index 2 in the summary values
102
+ target_id = values[2].to_i
103
+
104
+ unless target_ids.include?(target_id)
105
+ if debug
106
+ puts "Segment #{segments_processed} (#{name}):"
107
+ puts "Target ID #{target_id} not in requested list, skipping"
108
+ end
109
+
110
+ next
111
+ end
112
+ end
113
+
114
+ # Extract segment data
115
+ if extract_segment(
116
+ writer,
117
+ name,
118
+ values,
119
+ start_seconds,
120
+ end_seconds,
121
+ segments_processed,
122
+ debug
123
+ )
124
+ segments_included += 1
125
+ end
126
+ end
127
+
128
+ if debug
129
+ puts "Summary:"
130
+ puts "Processed #{segments_processed} segments,"
131
+ puts "included #{segments_included} in the excerpt"
132
+ end
133
+ end
134
+
135
+ def extract_segment(
136
+ writer,
137
+ name,
138
+ values,
139
+ start_seconds,
140
+ end_seconds,
141
+ segment_index,
142
+ debug
143
+ )
144
+ # Get start and end positions of the segment in the file
145
+ start_pos, end_pos = values[-2], values[-1]
146
+
147
+ if debug
148
+ puts "Processing segment #{segment_index} (#{name}):"
149
+ puts "start=#{start_pos}, end=#{end_pos}"
150
+ end
151
+
152
+ # Read the metadata from the end of the array
153
+ init, intlen, rsize, n = @daf.read_array(end_pos - 3, end_pos)
154
+ rsize = rsize.to_i
155
+
156
+ if debug
157
+ puts " Metadata: init=#{init}, intlen=#{intlen}, rsize=#{rsize}, n=#{n}"
158
+ end
159
+
160
+ # Calculate which portion of the data to extract based on the date range
161
+ i = clip(0, n, ((start_seconds - init) / intlen)).to_i
162
+ j = clip(0, n, ((end_seconds - init) / intlen + 1)).to_i
163
+
164
+ puts " Date range: i=#{i}, j=#{j} out of n=#{n}" if debug
165
+
166
+ # Skip if no overlap with requested date range
167
+ if i == j
168
+ if debug
169
+ puts "Segment #{segment_index} (#{name}):"
170
+ puts "No overlap with requested date range"
171
+ end
172
+
173
+ return false
174
+ end
175
+
176
+ # Update initial time and number of records
177
+ init += i * intlen
178
+ n = j - i
179
+
180
+ puts " New metadata: init=#{init}, n=#{n}" if debug
181
+
182
+ # Extract the relevant portion of the data
183
+ extra = 4 # Enough space for the metadata: [init intlen rsize n]
184
+ excerpt_start = start_pos + rsize * i
185
+ excerpt_end = start_pos + rsize * j + extra - 1
186
+
187
+ puts " Reading array from #{excerpt_start} to #{excerpt_end}" if debug
188
+
189
+ excerpt = @daf.read_array(excerpt_start, excerpt_end)
190
+
191
+ puts " Read #{excerpt.length} values" if debug
192
+
193
+ # Update the metadata in the excerpt
194
+ excerpt[-4..-1] = [init, intlen, rsize, n]
195
+
196
+ new_values = if values.length >= 2
197
+ [init, init + n * intlen] + values[2...-2]
198
+ else
199
+ [init, init + n * intlen]
200
+ end
201
+
202
+ puts " New values: #{new_values.inspect}" if debug
203
+
204
+ # Add the extracted array to the output file
205
+ # Modify the name to indicate it's an excerpt (X prefix)
206
+ writer.add_array(
207
+ "X#{name[1..]}".force_encoding("ASCII-8BIT"),
208
+ new_values,
209
+ excerpt
210
+ )
211
+
212
+ if debug
213
+ puts "Segment #{segment_index} (#{name}):"
214
+ puts "Included in excerpt (#{i} to #{j} of #{n})"
215
+ end
216
+
217
+ true
218
+ rescue => e
219
+ puts "Error processing segment #{segment_index} (#{name}): #{e.message}"
220
+ puts e.backtrace.join("\n") if debug
221
+ false
222
+ end
223
+
224
+ # Clips a value between lower and upper bounds
225
+ def clip(lower, upper, n)
226
+ n.clamp(lower, upper)
227
+ end
228
+
229
+ # Helper class for writing DAF files
230
+ # This class handles the low-level details of DAF file format
231
+ class DAFWriter
232
+ attr_reader :file
233
+ attr_accessor :fward,
234
+ :bward,
235
+ :free,
236
+ :endianness,
237
+ :double_format,
238
+ :int_format,
239
+ :nd,
240
+ :ni,
241
+ :summary_format,
242
+ :summary_control_format,
243
+ :summary_length,
244
+ :summary_step,
245
+ :summaries_per_record
246
+
247
+ def initialize(file, debug = false)
248
+ @file = file
249
+ @debug = debug
250
+ @mutex = Mutex.new
251
+ end
252
+
253
+ def setup_formats(nd, ni)
254
+ @nd = nd
255
+ @ni = ni
256
+
257
+ # Double is always 8 bytes, int is always 4 bytes
258
+ double_size = 8
259
+ int_size = 4
260
+
261
+ # Set formats based on endianness
262
+ if @endianness == :little
263
+ @double_format = "E" # Little-endian double
264
+ @int_format = "l" # Little-endian signed long (32-bit)
265
+ else
266
+ @double_format = "G" # Big-endian double
267
+ @int_format = "l>" # Big-endian signed long (32-bit)
268
+ end
269
+
270
+ # Create formats for summary structures
271
+ @summary_control_format =
272
+ "#{@double_format}#{@double_format}#{@double_format}"
273
+ @summary_format = @double_format.to_s * @nd + @int_format.to_s * @ni
274
+
275
+ # Calculate segment summary sizes
276
+ @summary_length = double_size * @nd + int_size * @ni
277
+
278
+ # Pad to 8 bytes
279
+ @summary_step = @summary_length + (-@summary_length % 8)
280
+
281
+ @summaries_per_record = (RECORD_SIZE - 8 * 3) / @summary_step
282
+ end
283
+
284
+ def write_file_record
285
+ @file.seek(0)
286
+ data = @file.read(RECORD_SIZE)
287
+
288
+ # Update pointers directly in the data buffer
289
+ fward_pos = 76
290
+ bward_pos = 80
291
+ free_pos = 84
292
+
293
+ if @endianness == :little
294
+ data[fward_pos, 4] = [@fward].pack("l")
295
+ data[bward_pos, 4] = [@bward].pack("l")
296
+ data[free_pos, 4] = [@free].pack("l")
297
+ else
298
+ data[fward_pos, 4] = [@fward].pack("N")
299
+ data[bward_pos, 4] = [@bward].pack("N")
300
+ data[free_pos, 4] = [@free].pack("N")
301
+ end
302
+
303
+ # Write the updated record back to the file
304
+ @file.seek(0)
305
+ @file.write(data)
306
+ end
307
+
308
+ def read_record(n)
309
+ @mutex.synchronize do
310
+ @file.seek(n * RECORD_SIZE - RECORD_SIZE)
311
+ @file.read(RECORD_SIZE)
312
+ end
313
+ end
314
+
315
+ def write_record(n, data)
316
+ @mutex.synchronize do
317
+ @file.seek(n * RECORD_SIZE - RECORD_SIZE)
318
+ @file.write(data)
319
+ end
320
+ end
321
+
322
+ def add_array(name, values, array)
323
+ record_number = @bward
324
+ data = read_record(record_number).dup
325
+
326
+ control_data = data[0, 24].unpack(@summary_control_format)
327
+ next_record = control_data[0].to_i
328
+ previous_record = control_data[1].to_i
329
+ n_summaries = control_data[2].to_i
330
+
331
+ if n_summaries < @summaries_per_record
332
+ # Add to the existing record
333
+ summary_record = record_number
334
+ data[0, 24] = [next_record, previous_record, n_summaries + 1]
335
+ .pack(@summary_control_format)
336
+ write_record(summary_record, data)
337
+ else
338
+ # Create a new record
339
+ summary_record = ((@free - 1) * 8 + 1023) / 1024 + 1
340
+ name_record = summary_record + 1
341
+ free_record = summary_record + 2
342
+
343
+ data[0, 24] = [summary_record, previous_record, n_summaries]
344
+ .pack(@summary_control_format)
345
+ write_record(record_number, data)
346
+
347
+ n_summaries = 0
348
+ summaries = [0, record_number, 1]
349
+ .pack(@summary_control_format)
350
+ .ljust(RECORD_SIZE, "\0")
351
+ names = " ".ljust(RECORD_SIZE, "\0")
352
+ write_record(summary_record, summaries)
353
+ write_record(name_record, names)
354
+
355
+ @bward = summary_record
356
+ @free = (free_record - 1) * RECORD_SIZE / 8 + 1
357
+ end
358
+
359
+ # Convert array to binary data
360
+ array_data = array.pack("#{@double_format}*")
361
+
362
+ start_word = @free
363
+ @file.seek((start_word - 1) * 8)
364
+ @file.write(array_data)
365
+ end_word = @file.tell / 8
366
+
367
+ @free = end_word + 1
368
+ write_file_record
369
+
370
+ # Using values up to nd+ni-2, then adding start_word and end_word
371
+ new_values = values[0, @nd + @ni - 2] + [start_word, end_word]
372
+
373
+ base = RECORD_SIZE * (summary_record - 1)
374
+ offset = n_summaries * @summary_step
375
+ @file.seek(base + 24 + offset) # 24 is summary_control_struct size
376
+ @file.write(new_values.pack(@summary_format))
377
+ @file.seek(base + RECORD_SIZE + offset)
378
+ @file.write(name[0, @summary_length].ljust(@summary_step, " "))
379
+ end
380
+ end
381
+ end
382
+ end
@@ -36,6 +36,8 @@ module Ephem
36
36
  attr_reader :target
37
37
  # @return [Integer] the center body ID
38
38
  attr_reader :center
39
+ # @return [String] the source of the segment
40
+ attr_reader :source
39
41
 
40
42
  # Initialize a new segment
41
43
  #
data/lib/ephem/spk.rb CHANGED
@@ -15,7 +15,17 @@ module Ephem
15
15
  # spk.close
16
16
  #
17
17
  class SPK
18
+ TYPES = [
19
+ INPOP = "IMCCE INPOP",
20
+ JPL_DE = "JPL DE"
21
+ ].freeze
22
+
23
+ INPOP_REGEXP = /^\s+\d{4}\.\d{5}0+$/
24
+ DE_REGEXP = /^[A-Z]E-(\d{4})LE-\1$/
25
+ DE_FILENAME = "NIO2SPK"
26
+
18
27
  DATA_TYPE_IDENTIFIER = 5
28
+ SEGMENT_CLASSES = {}
19
29
 
20
30
  attr_reader :segments, :pairs
21
31
 
@@ -77,6 +87,18 @@ module Ephem
77
87
  end
78
88
  end
79
89
 
90
+ # Type of SPK file to make the difference between JPL DE and IMCCE INPOP
91
+ #
92
+ # @return [String, nil] The type of the SPK file
93
+ def type
94
+ @type ||= if @daf.record_data.internal_filename.match?(INPOP_REGEXP)
95
+ INPOP
96
+ elsif @daf.record_data.internal_filename == DE_FILENAME ||
97
+ segments.first&.source&.match?(DE_REGEXP)
98
+ JPL_DE
99
+ end
100
+ end
101
+
80
102
  # Returns the comments stored in the SPK file.
81
103
  #
82
104
  # @return [String] The comments from the DAF file
@@ -96,6 +118,18 @@ module Ephem
96
118
  @segments.each(&block)
97
119
  end
98
120
 
121
+ def excerpt(output_path:, start_jd:, end_jd:, target_ids: nil, debug: false)
122
+ Excerpt
123
+ .new(self)
124
+ .extract(
125
+ output_path: output_path,
126
+ start_jd: start_jd,
127
+ end_jd: end_jd,
128
+ target_ids: target_ids,
129
+ debug: debug
130
+ )
131
+ end
132
+
99
133
  private
100
134
 
101
135
  def load_segments
@@ -115,7 +149,5 @@ module Ephem
115
149
  segment_class = SEGMENT_CLASSES.fetch(data_type, Segments::BaseSegment)
116
150
  segment_class.new(daf: @daf, source: source, descriptor: descriptor)
117
151
  end
118
-
119
- SEGMENT_CLASSES = {}
120
152
  end
121
153
  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.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/ephem.rb CHANGED
@@ -9,7 +9,7 @@ require_relative "ephem/core/vector"
9
9
  require_relative "ephem/error"
10
10
  require_relative "ephem/io/binary_reader"
11
11
  require_relative "ephem/io/daf"
12
- require_relative "ephem/io/download"
12
+ require_relative "ephem/download"
13
13
  require_relative "ephem/io/endianness_manager"
14
14
  require_relative "ephem/io/record_data"
15
15
  require_relative "ephem/io/record_parser"
@@ -18,6 +18,8 @@ require_relative "ephem/spk"
18
18
  require_relative "ephem/segments/base_segment"
19
19
  require_relative "ephem/segments/registry"
20
20
  require_relative "ephem/segments/segment"
21
+ require_relative "ephem/excerpt"
22
+ require_relative "ephem/cli"
21
23
  require_relative "ephem/version"
22
24
 
23
25
  module Ephem
metadata CHANGED
@@ -1,14 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ephem
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rémy Hannequin
8
- bindir: exe
8
+ bindir: bin
9
9
  cert_chain: []
10
- date: 2025-01-13 00:00:00.000000000 Z
10
+ date: 2025-04-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitar
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.12'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.12'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: numo-narray
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -23,6 +37,20 @@ dependencies:
23
37
  - - "~>"
24
38
  - !ruby/object:Gem::Version
25
39
  version: 0.9.2.1
40
+ - !ruby/object:Gem::Dependency
41
+ name: zlib
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.2'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.2'
26
54
  - !ruby/object:Gem::Dependency
27
55
  name: csv
28
56
  requirement: !ruby/object:Gem::Requirement
@@ -37,6 +65,20 @@ dependencies:
37
65
  - - "~>"
38
66
  - !ruby/object:Gem::Version
39
67
  version: '3.3'
68
+ - !ruby/object:Gem::Dependency
69
+ name: irb
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.15'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.15'
40
82
  - !ruby/object:Gem::Dependency
41
83
  name: parallel
42
84
  requirement: !ruby/object:Gem::Requirement
@@ -97,7 +139,8 @@ description: Ruby implementation of the parsing and computation of ephemerides f
97
139
  NASA JPL Development Ephemerides DE4xx
98
140
  email:
99
141
  - remy.hannequin@gmail.com
100
- executables: []
142
+ executables:
143
+ - ruby-ephem
101
144
  extensions: []
102
145
  extra_rdoc_files: []
103
146
  files:
@@ -109,17 +152,22 @@ files:
109
152
  - LICENSE.txt
110
153
  - README.md
111
154
  - Rakefile
155
+ - bin/console
156
+ - bin/ruby-ephem
157
+ - bin/setup
112
158
  - lib/ephem.rb
159
+ - lib/ephem/cli.rb
113
160
  - lib/ephem/computation/chebyshev_polynomial.rb
114
161
  - lib/ephem/core/calendar_calculations.rb
115
162
  - lib/ephem/core/constants/bodies.rb
116
163
  - lib/ephem/core/constants/time.rb
117
164
  - lib/ephem/core/state.rb
118
165
  - lib/ephem/core/vector.rb
166
+ - lib/ephem/download.rb
119
167
  - lib/ephem/error.rb
168
+ - lib/ephem/excerpt.rb
120
169
  - lib/ephem/io/binary_reader.rb
121
170
  - lib/ephem/io/daf.rb
122
- - lib/ephem/io/download.rb
123
171
  - lib/ephem/io/endianness_manager.rb
124
172
  - lib/ephem/io/record_data.rb
125
173
  - lib/ephem/io/record_parser.rb
@@ -1,91 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "net/http"
4
-
5
- module Ephem
6
- module IO
7
- class Download
8
- BASE_URL = "https://ssd.jpl.nasa.gov/ftp/eph/planets/bsp/"
9
-
10
- SUPPORTED_KERNELS = %w[
11
- de102.bsp
12
- de200.bsp
13
- de202.bsp
14
- de403.bsp
15
- de405.bsp
16
- de405_1960_2020.bsp
17
- de406.bsp
18
- de410.bsp
19
- de413.bsp
20
- de414.bsp
21
- de418.bsp
22
- de421.bsp
23
- de422.bsp
24
- de422_1850_2050.bsp
25
- de423.bsp
26
- de424.bsp
27
- de424s.bsp
28
- de425.bsp
29
- de430_1850-2150.bsp
30
- de430_plus_MarsPC.bsp
31
- de430t.bsp
32
- de431.bsp
33
- de432t.bsp
34
- de433.bsp
35
- de433_plus_MarsPC.bsp
36
- de433t.bsp
37
- de434.bsp
38
- de434s.bsp
39
- de434t.bsp
40
- de435.bsp
41
- de435s.bsp
42
- de435t.bsp
43
- de436.bsp
44
- de436s.bsp
45
- de436t.bsp
46
- de438.bsp
47
- de438_plus_MarsPC.bsp
48
- de438s.bsp
49
- de438t.bsp
50
- de440.bsp
51
- de440s.bsp
52
- de440s_plus_MarsPC.bsp
53
- de440t.bsp
54
- de441.bsp
55
- ].freeze
56
-
57
- def self.call(name:, target:)
58
- new(name, target).call
59
- end
60
-
61
- def initialize(name, local_path)
62
- @name = name
63
- @local_path = local_path
64
- validate_requested_kernel!
65
- end
66
-
67
- def call
68
- uri = URI("#{BASE_URL}#{@name}")
69
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
70
- request = Net::HTTP::Get.new(uri)
71
- http.request(request) do |response|
72
- File.open(@local_path, "wb") do |file|
73
- response.read_body do |chunk|
74
- file.write(chunk)
75
- end
76
- end
77
- end
78
- end
79
- end
80
-
81
- private
82
-
83
- def validate_requested_kernel!
84
- unless SUPPORTED_KERNELS.include?(@name)
85
- raise UnsupportedError,
86
- "Kernel #{@name} is not supported by the library at the moment."
87
- end
88
- end
89
- end
90
- end
91
- end