Skip to main content

astroceleste_engine/ephemeris/
spk.rs

1//! Minimal reader for JPL SPK kernels (NAIF DAF container, segment types 2 and 3).
2//!
3//! Positions are returned in km and velocities in km/s, in the kernel's frame (ICRF/J2000
4//! for the JPL planetary ephemerides), relative to the segment's center body.
5//! Time is TDB seconds past J2000 ("ET"), see [`jd_tdb_to_et`].
6//!
7//! Format reference: NAIF "DAF Required Reading" and "SPK Required Reading".
8
9use std::fmt;
10use std::fs::File;
11use std::io::{Read, Seek, SeekFrom};
12use std::path::Path;
13use std::sync::Mutex;
14
15const RECORD_BYTES: usize = 1024;
16const WORD_BYTES: usize = 8;
17const J2000_JD: f64 = 2_451_545.0;
18const SECONDS_PER_DAY: f64 = 86_400.0;
19
20/// TDB Julian day → TDB seconds past J2000.
21pub fn jd_tdb_to_et(jd_tdb: f64) -> f64 {
22    (jd_tdb - J2000_JD) * SECONDS_PER_DAY
23}
24
25/// Failure reading or evaluating an SPK kernel.
26#[derive(Debug, Clone, PartialEq)]
27#[non_exhaustive]
28pub enum SpkError {
29    /// The file could not be read.
30    Io(String),
31    /// The file is not a valid SPK/DAF kernel.
32    Format(String),
33    /// A segment uses an SPK data type other than 2 or 3.
34    UnsupportedType(i32),
35    /// No segment for this center → target pair covers the requested time.
36    OutOfRange {
37        /// NAIF code of the target body.
38        target: i32,
39        /// NAIF code of the center body.
40        center: i32,
41        /// Requested time, TDB seconds past J2000.
42        et: f64,
43    },
44    /// The kernel has no segment at all for this center → target pair.
45    NoSegment {
46        /// NAIF code of the target body.
47        target: i32,
48        /// NAIF code of the center body.
49        center: i32,
50    },
51}
52
53impl fmt::Display for SpkError {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        match self {
56            SpkError::Io(msg) => write!(f, "SPK I/O error: {msg}"),
57            SpkError::Format(msg) => write!(f, "malformed SPK file: {msg}"),
58            SpkError::UnsupportedType(t) => write!(f, "unsupported SPK segment type {t}"),
59            SpkError::OutOfRange { target, center, et } => write!(
60                f,
61                "no SPK segment for {center} -> {target} covers ET {et:.1} s"
62            ),
63            SpkError::NoSegment { target, center } => {
64                write!(f, "kernel has no segment for {center} -> {target}")
65            }
66        }
67    }
68}
69
70impl std::error::Error for SpkError {}
71
72impl From<std::io::Error> for SpkError {
73    fn from(err: std::io::Error) -> Self {
74        SpkError::Io(err.to_string())
75    }
76}
77
78/// Where the kernel bytes live: fully in memory (WASM, mobile, small kernels) or read on
79/// demand from disk (multi-GB kernels such as de441 on the server).
80enum Storage {
81    Memory(Vec<u8>),
82    File(Mutex<File>),
83}
84
85impl Storage {
86    fn read_bytes(&self, offset: usize, buf: &mut [u8]) -> Result<(), SpkError> {
87        match self {
88            Storage::Memory(data) => {
89                let end = offset
90                    .checked_add(buf.len())
91                    .filter(|end| *end <= data.len())
92                    .ok_or_else(|| SpkError::Format("read past end of file".into()))?;
93                buf.copy_from_slice(&data[offset..end]);
94                Ok(())
95            }
96            Storage::File(file) => {
97                let mut file = file
98                    .lock()
99                    .map_err(|_| SpkError::Io("poisoned lock".into()))?;
100                file.seek(SeekFrom::Start(offset as u64))?;
101                file.read_exact(buf)?;
102                Ok(())
103            }
104        }
105    }
106}
107
108/// One SPK segment: a center → target trajectory over a time span.
109#[derive(Debug, Clone, PartialEq)]
110pub struct Segment {
111    /// Segment name from the DAF name record (e.g. "DE-0440LE-0440").
112    pub name: String,
113    /// NAIF code of the center body.
114    pub center: i32,
115    /// NAIF code of the target body.
116    pub target: i32,
117    /// NAIF code of the reference frame (1 = J2000/ICRF).
118    pub frame: i32,
119    /// SPK data type (2: Chebyshev positions, 3: positions and velocities).
120    pub data_type: i32,
121    /// Start of coverage, TDB seconds past J2000.
122    pub start_et: f64,
123    /// End of coverage, TDB seconds past J2000.
124    pub end_et: f64,
125    /// 1-based DAF word addresses of the segment data.
126    start_word: usize,
127    // Chebyshev directory (types 2 and 3), read from the segment's last four words.
128    init: f64,
129    interval: f64,
130    record_size: usize,
131    record_count: usize,
132}
133
134impl Segment {
135    /// Whether the segment covers `et` (TDB seconds past J2000).
136    pub fn covers(&self, et: f64) -> bool {
137        et >= self.start_et && et <= self.end_et
138    }
139
140    fn components(&self) -> usize {
141        if self.data_type == 3 {
142            6
143        } else {
144            3
145        }
146    }
147}
148
149/// A JPL SPK (DAF) kernel, read from a file or from memory.
150pub struct Spk {
151    storage: Storage,
152    little_endian: bool,
153    segments: Vec<Segment>,
154}
155
156impl fmt::Debug for Spk {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        f.debug_struct("Spk")
159            .field("segments", &self.segments)
160            .finish()
161    }
162}
163
164/// Position (km) and velocity (km/s).
165pub type State = ([f64; 3], [f64; 3]);
166
167impl Spk {
168    /// Load a kernel held in memory (e.g. fetched by a browser or bundled in an app).
169    pub fn from_bytes(data: Vec<u8>) -> Result<Self, SpkError> {
170        Self::load(Storage::Memory(data))
171    }
172
173    /// Open a kernel on disk; segment data is read on demand, so size is not a concern.
174    pub fn open(path: impl AsRef<Path>) -> Result<Self, SpkError> {
175        Self::load(Storage::File(Mutex::new(File::open(path)?)))
176    }
177
178    /// The kernel's segments, in file order.
179    pub fn segments(&self) -> &[Segment] {
180        &self.segments
181    }
182
183    fn load(storage: Storage) -> Result<Self, SpkError> {
184        let mut header = [0u8; RECORD_BYTES];
185        storage.read_bytes(0, &mut header)?;
186        if &header[0..7] != b"DAF/SPK" {
187            return Err(SpkError::Format("not a DAF/SPK file".into()));
188        }
189        let little_endian = match &header[88..96] {
190            b"LTL-IEEE" => true,
191            b"BIG-IEEE" => false,
192            // Pre-1995 files lack LOCFMT: infer from ND, which is always 2 for SPK.
193            _ => i32::from_le_bytes(header[8..12].try_into().unwrap()) == 2,
194        };
195        let mut spk = Spk {
196            storage,
197            little_endian,
198            segments: Vec::new(),
199        };
200        let nd = spk.int_at(&header, 8) as usize;
201        let ni = spk.int_at(&header, 12) as usize;
202        if nd != 2 || ni != 6 {
203            return Err(SpkError::Format(format!("unexpected ND={nd} NI={ni}")));
204        }
205        let summary_words = nd + ni.div_ceil(2);
206
207        let mut record_number = spk.int_at(&header, 76) as usize;
208        let mut visited = 0;
209        while record_number != 0 {
210            visited += 1;
211            if visited > 100_000 {
212                return Err(SpkError::Format("summary record chain does not end".into()));
213            }
214            let mut record = [0u8; RECORD_BYTES];
215            spk.storage
216                .read_bytes((record_number - 1) * RECORD_BYTES, &mut record)?;
217            let next = spk.double_at(&record, 0) as usize;
218            let count = spk.double_at(&record, 16) as usize;
219            // The name record follows its summary record.
220            let mut names = [0u8; RECORD_BYTES];
221            spk.storage
222                .read_bytes(record_number * RECORD_BYTES, &mut names)?;
223            let name_chars = summary_words * WORD_BYTES;
224            for i in 0..count {
225                let base = 24 + i * summary_words * WORD_BYTES;
226                if base + summary_words * WORD_BYTES > RECORD_BYTES {
227                    return Err(SpkError::Format("summary overflows its record".into()));
228                }
229                let mut segment = spk.read_segment(&record, base)?;
230                let raw = &names[i * name_chars..(i + 1) * name_chars];
231                segment.name = String::from_utf8_lossy(raw).trim_end().to_string();
232                spk.segments.push(segment);
233            }
234            record_number = next;
235        }
236        Ok(spk)
237    }
238
239    fn read_segment(&self, record: &[u8], base: usize) -> Result<Segment, SpkError> {
240        let start_et = self.double_at(record, base);
241        let end_et = self.double_at(record, base + 8);
242        let ints = base + 16;
243        let target = self.int_at(record, ints);
244        let center = self.int_at(record, ints + 4);
245        let frame = self.int_at(record, ints + 8);
246        let data_type = self.int_at(record, ints + 12);
247        let start_word = self.int_at(record, ints + 16) as usize;
248        let end_word = self.int_at(record, ints + 20) as usize;
249
250        let mut segment = Segment {
251            name: String::new(),
252            center,
253            target,
254            frame,
255            data_type,
256            start_et,
257            end_et,
258            start_word,
259            init: 0.0,
260            interval: 0.0,
261            record_size: 0,
262            record_count: 0,
263        };
264        if data_type == 2 || data_type == 3 {
265            if end_word < start_word.max(4) {
266                return Err(SpkError::Format(format!(
267                    "bad word range for {center} -> {target}"
268                )));
269            }
270            let dir = self.read_words(end_word - 3, 4)?;
271            segment.init = dir[0];
272            segment.interval = dir[1];
273            segment.record_size = dir[2] as usize;
274            segment.record_count = dir[3] as usize;
275            let coefficients = segment.record_size.saturating_sub(2);
276            if segment.interval <= 0.0
277                || segment.record_count == 0
278                || coefficients == 0
279                || coefficients % segment.components() != 0
280            {
281                return Err(SpkError::Format(format!(
282                    "bad Chebyshev directory for {center} -> {target}"
283                )));
284            }
285        }
286        Ok(segment)
287    }
288
289    /// State of `target` relative to `center` from the single segment that covers `et`.
290    pub fn state(&self, target: i32, center: i32, et: f64) -> Result<State, SpkError> {
291        let mut found_pair = false;
292        for segment in &self.segments {
293            if segment.target != target || segment.center != center {
294                continue;
295            }
296            found_pair = true;
297            if segment.covers(et) {
298                return self.segment_state(segment, et);
299            }
300        }
301        if found_pair {
302            Err(SpkError::OutOfRange { target, center, et })
303        } else {
304            Err(SpkError::NoSegment { target, center })
305        }
306    }
307
308    /// Evaluate one segment at `et` (which must lie inside it).
309    pub fn segment_state(&self, segment: &Segment, et: f64) -> Result<State, SpkError> {
310        if !segment.covers(et) {
311            return Err(SpkError::OutOfRange {
312                target: segment.target,
313                center: segment.center,
314                et,
315            });
316        }
317        self.check_type(segment)?;
318        let index = (((et - segment.init) / segment.interval).floor().max(0.0) as usize)
319            .min(segment.record_count - 1);
320        let words = self.record(segment, index)?;
321        let (mid, radius) = (words[0], words[1]);
322        Ok(self.evaluate(segment, &words, (et - mid) / radius))
323    }
324
325    /// Evaluate one segment at a TDB Julian date given as `whole + fraction`, with the
326    /// same interval arithmetic as jplephem (which Skyfield uses), keeping the precision
327    /// of the split date.
328    pub fn segment_state_split(
329        &self,
330        segment: &Segment,
331        whole: f64,
332        fraction: f64,
333    ) -> Result<State, SpkError> {
334        self.check_type(segment)?;
335        let et = (whole - J2000_JD + fraction) * SECONDS_PER_DAY;
336        let out_of_range = SpkError::OutOfRange {
337            target: segment.target,
338            center: segment.center,
339            et,
340        };
341        let intlen = segment.interval;
342        let a = (whole - J2000_JD) * SECONDS_PER_DAY - segment.init;
343        let (index1, offset1) = (a.div_euclid(intlen), a.rem_euclid(intlen));
344        let b = fraction * SECONDS_PER_DAY;
345        let (index2, offset2) = (b.div_euclid(intlen), b.rem_euclid(intlen));
346        let c = offset1 + offset2;
347        let (index3, mut offset) = (c.div_euclid(intlen), c.rem_euclid(intlen));
348        let mut index = index1 + index2 + index3;
349        let count = segment.record_count as f64;
350        if index == count {
351            index -= 1.0;
352            offset += intlen;
353        }
354        if index < 0.0 || index >= count {
355            return Err(out_of_range);
356        }
357        let words = self.record(segment, index as usize)?;
358        Ok(self.evaluate(segment, &words, 2.0 * offset / intlen - 1.0))
359    }
360
361    fn check_type(&self, segment: &Segment) -> Result<(), SpkError> {
362        if segment.data_type == 2 || segment.data_type == 3 {
363            Ok(())
364        } else {
365            Err(SpkError::UnsupportedType(segment.data_type))
366        }
367    }
368
369    fn record(&self, segment: &Segment, index: usize) -> Result<Vec<f64>, SpkError> {
370        self.read_words(
371            segment.start_word + index * segment.record_size,
372            segment.record_size,
373        )
374    }
375
376    /// Position and velocity from one Chebyshev record at normalized time `s`.
377    fn evaluate(&self, segment: &Segment, words: &[f64], s: f64) -> State {
378        let radius = words[1];
379        let n = (segment.record_size - 2) / segment.components();
380        let coeffs = &words[2..];
381        let mut position = [0.0; 3];
382        let mut velocity = [0.0; 3];
383        for axis in 0..3 {
384            let c = &coeffs[axis * n..(axis + 1) * n];
385            let (value, derivative) = chebyshev(c, s);
386            position[axis] = value;
387            velocity[axis] = derivative / radius;
388        }
389        if segment.data_type == 3 {
390            // Type 3 stores velocity as its own series.
391            for (axis, v) in velocity.iter_mut().enumerate() {
392                let c = &coeffs[(axis + 3) * n..(axis + 4) * n];
393                *v = chebyshev(c, s).0;
394            }
395        }
396        (position, velocity)
397    }
398
399    /// A new SPK file with only the data covering TDB Julian dates `start_jd` to
400    /// `end_jd` (like `python -m jplephem excerpt`): whole Chebyshev records are kept, so
401    /// positions inside the range are identical to this kernel's. Segments that do not
402    /// overlap the range are left out. Written little-endian; types 2 and 3 only.
403    pub fn excerpt(&self, start_jd: f64, end_jd: f64) -> Result<Vec<u8>, SpkError> {
404        const SUMMARIES_PER_RECORD: usize = 25; // (128 words - 3) / 5 words per summary
405        const WORDS_PER_RECORD: usize = RECORD_BYTES / WORD_BYTES;
406        if end_jd <= start_jd {
407            return Err(SpkError::Format("excerpt range is empty".into()));
408        }
409        let (start_et, end_et) = (jd_tdb_to_et(start_jd), jd_tdb_to_et(end_jd));
410
411        struct Kept<'a> {
412            segment: &'a Segment,
413            start_et: f64,
414            end_et: f64,
415            first_word: usize,
416            last_word: usize,
417        }
418        let overlapping: Vec<&Segment> = self
419            .segments
420            .iter()
421            .filter(|s| s.start_et <= end_et && s.end_et >= start_et)
422            .collect();
423        let summary_records = overlapping.len().div_ceil(SUMMARIES_PER_RECORD).max(1);
424        // Record 1 is the file record, then summary + name record pairs, then data.
425        let mut word = (1 + 2 * summary_records) * WORDS_PER_RECORD + 1;
426        let mut data: Vec<f64> = Vec::new();
427        let mut kept = Vec::new();
428        for segment in overlapping {
429            self.check_type(segment)?;
430            let last = segment.record_count as i64 - 1;
431            let first_index =
432                (((start_et - segment.init) / segment.interval).floor() as i64).clamp(0, last);
433            let last_index =
434                (((end_et - segment.init) / segment.interval).floor() as i64).clamp(0, last);
435            let (i0, i1) = (first_index as usize, last_index as usize);
436            let count = i1 - i0 + 1;
437            let records = self.read_words(
438                segment.start_word + i0 * segment.record_size,
439                count * segment.record_size,
440            )?;
441            let init = segment.init + i0 as f64 * segment.interval;
442            let first_word = word;
443            data.extend_from_slice(&records);
444            data.extend_from_slice(&[
445                init,
446                segment.interval,
447                segment.record_size as f64,
448                count as f64,
449            ]);
450            word += records.len() + 4;
451            kept.push(Kept {
452                segment,
453                start_et: segment.start_et.max(init),
454                end_et: segment.end_et.min(init + count as f64 * segment.interval),
455                first_word,
456                last_word: word - 1,
457            });
458        }
459
460        let data_records = data.len().div_ceil(WORDS_PER_RECORD);
461        let mut out = vec![0u8; (1 + 2 * summary_records + data_records) * RECORD_BYTES];
462        let put_i32 =
463            |out: &mut [u8], at: usize, v: i32| out[at..at + 4].copy_from_slice(&v.to_le_bytes());
464        let put_f64 =
465            |out: &mut [u8], at: usize, v: f64| out[at..at + 8].copy_from_slice(&v.to_le_bytes());
466
467        // File record.
468        out[0..8].copy_from_slice(b"DAF/SPK ");
469        put_i32(&mut out, 8, 2);
470        put_i32(&mut out, 12, 6);
471        let ifname = format!("{:<60}", "astroceleste-engine excerpt");
472        out[16..76].copy_from_slice(&ifname.as_bytes()[..60]);
473        put_i32(&mut out, 76, 2);
474        put_i32(&mut out, 80, (2 + 2 * (summary_records - 1)) as i32);
475        put_i32(&mut out, 84, word as i32);
476        out[88..96].copy_from_slice(b"LTL-IEEE");
477        out[699..727].copy_from_slice(b"FTPSTR:\r:\n:\r\n:\r\x00:\x81:\x10\xce:ENDFTP");
478
479        // Summary and name records.
480        let chunks: Vec<&[Kept]> = if kept.is_empty() {
481            vec![&[]]
482        } else {
483            kept.chunks(SUMMARIES_PER_RECORD).collect()
484        };
485        for (k, chunk) in chunks.iter().enumerate() {
486            let record = 2 + 2 * k;
487            let base = (record - 1) * RECORD_BYTES;
488            let next = if k + 1 < chunks.len() { record + 2 } else { 0 };
489            let prev = if k > 0 { record - 2 } else { 0 };
490            put_f64(&mut out, base, next as f64);
491            put_f64(&mut out, base + 8, prev as f64);
492            put_f64(&mut out, base + 16, chunk.len() as f64);
493            let names = base + RECORD_BYTES;
494            out[names..names + RECORD_BYTES].fill(b' ');
495            for (i, kept) in chunk.iter().enumerate() {
496                let at = base + 24 + i * 40;
497                put_f64(&mut out, at, kept.start_et);
498                put_f64(&mut out, at + 8, kept.end_et);
499                let s = kept.segment;
500                for (j, v) in [
501                    s.target,
502                    s.center,
503                    s.frame,
504                    s.data_type,
505                    kept.first_word as i32,
506                    kept.last_word as i32,
507                ]
508                .into_iter()
509                .enumerate()
510                {
511                    put_i32(&mut out, at + 16 + 4 * j, v);
512                }
513                let name = s.name.as_bytes();
514                let n = name.len().min(40);
515                out[names + i * 40..names + i * 40 + n].copy_from_slice(&name[..n]);
516            }
517        }
518
519        // Segment data.
520        let data_base = (1 + 2 * summary_records) * RECORD_BYTES;
521        for (i, v) in data.iter().enumerate() {
522            put_f64(&mut out, data_base + i * WORD_BYTES, *v);
523        }
524        Ok(out)
525    }
526
527    fn read_words(&self, first_word: usize, count: usize) -> Result<Vec<f64>, SpkError> {
528        if first_word == 0 {
529            return Err(SpkError::Format("word address 0".into()));
530        }
531        let mut buf = vec![0u8; count * WORD_BYTES];
532        self.storage
533            .read_bytes((first_word - 1) * WORD_BYTES, &mut buf)?;
534        Ok(buf
535            .chunks_exact(WORD_BYTES)
536            .map(|b| self.to_f64(b.try_into().unwrap()))
537            .collect())
538    }
539
540    fn double_at(&self, bytes: &[u8], offset: usize) -> f64 {
541        self.to_f64(bytes[offset..offset + 8].try_into().unwrap())
542    }
543
544    fn int_at(&self, bytes: &[u8], offset: usize) -> i32 {
545        let raw: [u8; 4] = bytes[offset..offset + 4].try_into().unwrap();
546        if self.little_endian {
547            i32::from_le_bytes(raw)
548        } else {
549            i32::from_be_bytes(raw)
550        }
551    }
552
553    fn to_f64(&self, raw: [u8; 8]) -> f64 {
554        if self.little_endian {
555            f64::from_le_bytes(raw)
556        } else {
557            f64::from_be_bytes(raw)
558        }
559    }
560}
561
562/// Chebyshev series value and derivative with respect to `s` at `s` in [-1, 1].
563fn chebyshev(coeffs: &[f64], s: f64) -> (f64, f64) {
564    let (mut t_prev, mut t) = (1.0, s);
565    let (mut d_prev, mut d) = (0.0, 1.0);
566    let mut value = coeffs[0];
567    let mut derivative = 0.0;
568    if coeffs.len() > 1 {
569        value += coeffs[1] * s;
570        derivative += coeffs[1];
571    }
572    for &c in &coeffs[2.min(coeffs.len())..] {
573        let t_next = 2.0 * s * t - t_prev;
574        let d_next = 2.0 * t + 2.0 * s * d - d_prev;
575        value += c * t_next;
576        derivative += c * d_next;
577        (t_prev, t) = (t, t_next);
578        (d_prev, d) = (d, d_next);
579    }
580    (value, derivative)
581}
582
583#[cfg(test)]
584mod tests {
585    use super::chebyshev;
586
587    #[test]
588    fn chebyshev_matches_closed_form() {
589        // 1 + 2 T1 + 3 T2 + 4 T3 with T2 = 2s²-1, T3 = 4s³-3s.
590        let s: f64 = 0.37;
591        let expected =
592            1.0 + 2.0 * s + 3.0 * (2.0 * s * s - 1.0) + 4.0 * (4.0 * s.powi(3) - 3.0 * s);
593        let expected_d = 2.0 + 12.0 * s + 4.0 * (12.0 * s * s - 3.0);
594        let (v, d) = chebyshev(&[1.0, 2.0, 3.0, 4.0], s);
595        assert!((v - expected).abs() < 1e-12);
596        assert!((d - expected_d).abs() < 1e-12);
597    }
598}