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<FileStorage>),
83}
84
85/// Reads of a file-backed kernel kept for reuse: a chart evaluates the same few dozen
86/// Chebyshev records hundreds of times (most of them in the sunrise search), and each
87/// read from the file costs a seek and a read.
88const CACHED_READS: usize = 64;
89
90struct FileStorage {
91    file: File,
92    /// (byte offset, bytes) of recent reads.
93    cache: Vec<(usize, Box<[u8]>)>,
94    /// The cache slot the next miss replaces, round robin.
95    next: usize,
96}
97
98impl Storage {
99    fn file(file: File) -> Self {
100        Storage::File(Mutex::new(FileStorage {
101            file,
102            cache: Vec::with_capacity(CACHED_READS),
103            next: 0,
104        }))
105    }
106
107    /// Size of the kernel in bytes.
108    fn len(&self) -> Result<u64, SpkError> {
109        match self {
110            Storage::Memory(data) => Ok(data.len() as u64),
111            Storage::File(file) => Ok(file
112                .lock()
113                .map_err(|_| SpkError::Io("poisoned lock".into()))?
114                .file
115                .metadata()?
116                .len()),
117        }
118    }
119
120    fn read_bytes(&self, offset: usize, buf: &mut [u8]) -> Result<(), SpkError> {
121        match self {
122            Storage::Memory(data) => {
123                let end = offset
124                    .checked_add(buf.len())
125                    .filter(|end| *end <= data.len())
126                    .ok_or_else(|| SpkError::Format("read past end of file".into()))?;
127                buf.copy_from_slice(&data[offset..end]);
128                Ok(())
129            }
130            Storage::File(storage) => {
131                let mut storage = storage
132                    .lock()
133                    .map_err(|_| SpkError::Io("poisoned lock".into()))?;
134                let FileStorage { file, cache, next } = &mut *storage;
135                if let Some((_, bytes)) = cache
136                    .iter()
137                    .find(|(at, bytes)| *at == offset && bytes.len() == buf.len())
138                {
139                    buf.copy_from_slice(bytes);
140                    return Ok(());
141                }
142                file.seek(SeekFrom::Start(offset as u64))?;
143                file.read_exact(buf)?;
144                let entry = (offset, Box::from(&*buf));
145                if cache.len() < CACHED_READS {
146                    cache.push(entry);
147                } else {
148                    cache[*next] = entry;
149                    *next = (*next + 1) % CACHED_READS;
150                }
151                Ok(())
152            }
153        }
154    }
155}
156
157/// One SPK segment: a center → target trajectory over a time span.
158#[derive(Debug, Clone, PartialEq)]
159pub struct Segment {
160    /// Segment name from the DAF name record (e.g. "DE-0440LE-0440").
161    pub name: String,
162    /// NAIF code of the center body.
163    pub center: i32,
164    /// NAIF code of the target body.
165    pub target: i32,
166    /// NAIF code of the reference frame (1 = J2000/ICRF).
167    pub frame: i32,
168    /// SPK data type (2: Chebyshev positions, 3: positions and velocities).
169    pub data_type: i32,
170    /// Start of coverage, TDB seconds past J2000.
171    pub start_et: f64,
172    /// End of coverage, TDB seconds past J2000.
173    pub end_et: f64,
174    /// 1-based DAF word addresses of the segment data.
175    start_word: usize,
176    // Chebyshev directory (types 2 and 3), read from the segment's last four words.
177    init: f64,
178    interval: f64,
179    record_size: usize,
180    record_count: usize,
181}
182
183impl Segment {
184    /// Whether the segment covers `et` (TDB seconds past J2000).
185    pub fn covers(&self, et: f64) -> bool {
186        et >= self.start_et && et <= self.end_et
187    }
188
189    fn components(&self) -> usize {
190        if self.data_type == 3 {
191            6
192        } else {
193            3
194        }
195    }
196}
197
198/// A JPL SPK (DAF) kernel, read from a file or from memory.
199pub struct Spk {
200    storage: Storage,
201    /// Size of the kernel in 8-byte words: every read is checked against it before its
202    /// buffer is allocated, so a corrupt header cannot request gigabytes.
203    words: usize,
204    little_endian: bool,
205    segments: Vec<Segment>,
206}
207
208impl fmt::Debug for Spk {
209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210        f.debug_struct("Spk")
211            .field("segments", &self.segments)
212            .finish()
213    }
214}
215
216/// Position (km) and velocity (km/s).
217pub type State = ([f64; 3], [f64; 3]);
218
219impl Spk {
220    /// Load a kernel held in memory (e.g. fetched by a browser or bundled in an app).
221    pub fn from_bytes(data: Vec<u8>) -> Result<Self, SpkError> {
222        Self::load(Storage::Memory(data))
223    }
224
225    /// Open a kernel on disk; segment data is read on demand, so size is not a concern.
226    pub fn open(path: impl AsRef<Path>) -> Result<Self, SpkError> {
227        Self::load(Storage::file(File::open(path)?))
228    }
229
230    /// The kernel's segments, in file order.
231    pub fn segments(&self) -> &[Segment] {
232        &self.segments
233    }
234
235    fn load(storage: Storage) -> Result<Self, SpkError> {
236        let mut header = [0u8; RECORD_BYTES];
237        storage.read_bytes(0, &mut header)?;
238        if &header[0..7] != b"DAF/SPK" {
239            return Err(SpkError::Format("not a DAF/SPK file".into()));
240        }
241        let little_endian = match &header[88..96] {
242            b"LTL-IEEE" => true,
243            b"BIG-IEEE" => false,
244            // Pre-1995 files lack LOCFMT: infer from ND, which is always 2 for SPK.
245            _ => i32::from_le_bytes(header[8..12].try_into().unwrap()) == 2,
246        };
247        let words = usize::try_from(storage.len()? / WORD_BYTES as u64).unwrap_or(usize::MAX);
248        let mut spk = Spk {
249            storage,
250            words,
251            little_endian,
252            segments: Vec::new(),
253        };
254        let nd = spk.int_at(&header, 8) as usize;
255        let ni = spk.int_at(&header, 12) as usize;
256        if nd != 2 || ni != 6 {
257            return Err(SpkError::Format(format!("unexpected ND={nd} NI={ni}")));
258        }
259        let summary_words = nd + ni.div_ceil(2);
260
261        let mut record_number = spk.address_at(&header, 76)?;
262        let mut visited = 0;
263        while record_number != 0 {
264            visited += 1;
265            if visited > 100_000 {
266                return Err(SpkError::Format("summary record chain does not end".into()));
267            }
268            let offset = (record_number - 1)
269                .checked_mul(RECORD_BYTES)
270                .ok_or_else(|| SpkError::Format("summary record out of range".into()))?;
271            let mut record = [0u8; RECORD_BYTES];
272            spk.storage.read_bytes(offset, &mut record)?;
273            let next = spk.double_at(&record, 0);
274            if !(next >= 0.0 && next.fract() == 0.0 && next <= i32::MAX as f64) {
275                return Err(SpkError::Format("bad next summary record".into()));
276            }
277            let count = spk.double_at(&record, 16) as usize;
278            // The name record follows its summary record.
279            let mut names = [0u8; RECORD_BYTES];
280            spk.storage.read_bytes(offset + RECORD_BYTES, &mut names)?;
281            let name_chars = summary_words * WORD_BYTES;
282            for i in 0..count {
283                let base = 24 + i * summary_words * WORD_BYTES;
284                if base + summary_words * WORD_BYTES > RECORD_BYTES {
285                    return Err(SpkError::Format("summary overflows its record".into()));
286                }
287                let mut segment = spk.read_segment(&record, base)?;
288                let raw = &names[i * name_chars..(i + 1) * name_chars];
289                segment.name = String::from_utf8_lossy(raw).trim_end().to_string();
290                spk.segments.push(segment);
291            }
292            record_number = next as usize;
293        }
294        Ok(spk)
295    }
296
297    fn read_segment(&self, record: &[u8], base: usize) -> Result<Segment, SpkError> {
298        let start_et = self.double_at(record, base);
299        let end_et = self.double_at(record, base + 8);
300        let ints = base + 16;
301        let target = self.int_at(record, ints);
302        let center = self.int_at(record, ints + 4);
303        let frame = self.int_at(record, ints + 8);
304        let data_type = self.int_at(record, ints + 12);
305        let start_word = self.address_at(record, ints + 16)?;
306        let end_word = self.address_at(record, ints + 20)?;
307
308        let mut segment = Segment {
309            name: String::new(),
310            center,
311            target,
312            frame,
313            data_type,
314            start_et,
315            end_et,
316            start_word,
317            init: 0.0,
318            interval: 0.0,
319            record_size: 0,
320            record_count: 0,
321        };
322        if data_type == 2 || data_type == 3 {
323            let bad =
324                || SpkError::Format(format!("bad Chebyshev directory for {center} -> {target}"));
325            if end_word < start_word.max(4) || end_word > self.words {
326                return Err(SpkError::Format(format!(
327                    "bad word range for {center} -> {target}"
328                )));
329            }
330            let dir = self.read_words(end_word - 3, 4)?;
331            let count = |x: f64| {
332                (x >= 1.0 && x.fract() == 0.0 && x <= self.words as f64)
333                    .then_some(x as usize)
334                    .ok_or_else(bad)
335            };
336            segment.init = dir[0];
337            segment.interval = dir[1];
338            segment.record_size = count(dir[2])?;
339            segment.record_count = count(dir[3])?;
340            // The records and the directory must fit in the segment's words.
341            let data_words = segment
342                .record_size
343                .checked_mul(segment.record_count)
344                .and_then(|w| w.checked_add(4))
345                .ok_or_else(bad)?;
346            let coefficients = segment.record_size.saturating_sub(2);
347            if !segment.init.is_finite()
348                || !(segment.interval > 0.0 && segment.interval.is_finite())
349                || data_words > end_word - start_word + 1
350                || coefficients == 0
351                || coefficients % segment.components() != 0
352            {
353                return Err(SpkError::Format(format!(
354                    "bad Chebyshev directory for {center} -> {target}"
355                )));
356            }
357        }
358        Ok(segment)
359    }
360
361    /// State of `target` relative to `center` from the single segment that covers `et`.
362    pub fn state(&self, target: i32, center: i32, et: f64) -> Result<State, SpkError> {
363        let mut found_pair = false;
364        for segment in &self.segments {
365            if segment.target != target || segment.center != center {
366                continue;
367            }
368            found_pair = true;
369            if segment.covers(et) {
370                return self.segment_state(segment, et);
371            }
372        }
373        if found_pair {
374            Err(SpkError::OutOfRange { target, center, et })
375        } else {
376            Err(SpkError::NoSegment { target, center })
377        }
378    }
379
380    /// Evaluate one segment at `et` (which must lie inside it).
381    pub fn segment_state(&self, segment: &Segment, et: f64) -> Result<State, SpkError> {
382        if !segment.covers(et) {
383            return Err(SpkError::OutOfRange {
384                target: segment.target,
385                center: segment.center,
386                et,
387            });
388        }
389        self.check_type(segment)?;
390        let index = (((et - segment.init) / segment.interval).floor().max(0.0) as usize)
391            .min(segment.record_count - 1);
392        self.with_record(segment, index, |words| {
393            let (mid, radius) = (words[0], words[1]);
394            self.evaluate(segment, words, (et - mid) / radius)
395        })
396    }
397
398    /// Evaluate one segment at a TDB Julian date given as `whole + fraction`, with the
399    /// same interval arithmetic as jplephem (which Skyfield uses), keeping the precision
400    /// of the split date.
401    pub fn segment_state_split(
402        &self,
403        segment: &Segment,
404        whole: f64,
405        fraction: f64,
406    ) -> Result<State, SpkError> {
407        self.check_type(segment)?;
408        let et = (whole - J2000_JD + fraction) * SECONDS_PER_DAY;
409        let out_of_range = SpkError::OutOfRange {
410            target: segment.target,
411            center: segment.center,
412            et,
413        };
414        let intlen = segment.interval;
415        let a = (whole - J2000_JD) * SECONDS_PER_DAY - segment.init;
416        let (index1, offset1) = (a.div_euclid(intlen), a.rem_euclid(intlen));
417        let b = fraction * SECONDS_PER_DAY;
418        let (index2, offset2) = (b.div_euclid(intlen), b.rem_euclid(intlen));
419        let c = offset1 + offset2;
420        let (index3, mut offset) = (c.div_euclid(intlen), c.rem_euclid(intlen));
421        let mut index = index1 + index2 + index3;
422        let count = segment.record_count as f64;
423        if index == count {
424            index -= 1.0;
425            offset += intlen;
426        }
427        if index < 0.0 || index >= count {
428            return Err(out_of_range);
429        }
430        self.with_record(segment, index as usize, |words| {
431            self.evaluate(segment, words, 2.0 * offset / intlen - 1.0)
432        })
433    }
434
435    fn check_type(&self, segment: &Segment) -> Result<(), SpkError> {
436        if segment.data_type == 2 || segment.data_type == 3 {
437            Ok(())
438        } else {
439            Err(SpkError::UnsupportedType(segment.data_type))
440        }
441    }
442
443    /// `f` applied to the words of record `index` of `segment`, decoded into a stack
444    /// buffer (records of the planetary ephemerides are a few dozen words).
445    fn with_record<R>(
446        &self,
447        segment: &Segment,
448        index: usize,
449        f: impl FnOnce(&[f64]) -> R,
450    ) -> Result<R, SpkError> {
451        const STACK_WORDS: usize = 128;
452        let (first, count) = (
453            segment.start_word + index * segment.record_size,
454            segment.record_size,
455        );
456        if count > STACK_WORDS {
457            return Ok(f(&self.read_words(first, count)?));
458        }
459        self.check_words(first, count)?;
460        let mut bytes = [0u8; STACK_WORDS * WORD_BYTES];
461        let bytes = &mut bytes[..count * WORD_BYTES];
462        self.storage.read_bytes((first - 1) * WORD_BYTES, bytes)?;
463        let mut words = [0.0; STACK_WORDS];
464        for (word, raw) in words.iter_mut().zip(bytes.chunks_exact(WORD_BYTES)) {
465            *word = self.to_f64(raw.try_into().unwrap());
466        }
467        Ok(f(&words[..count]))
468    }
469
470    /// Position and velocity from one Chebyshev record at normalized time `s`.
471    fn evaluate(&self, segment: &Segment, words: &[f64], s: f64) -> State {
472        let radius = words[1];
473        let n = (segment.record_size - 2) / segment.components();
474        let coeffs = &words[2..];
475        let mut position = [0.0; 3];
476        let mut velocity = [0.0; 3];
477        for axis in 0..3 {
478            let c = &coeffs[axis * n..(axis + 1) * n];
479            let (value, derivative) = chebyshev(c, s);
480            position[axis] = value;
481            velocity[axis] = derivative / radius;
482        }
483        if segment.data_type == 3 {
484            // Type 3 stores velocity as its own series.
485            for (axis, v) in velocity.iter_mut().enumerate() {
486                let c = &coeffs[(axis + 3) * n..(axis + 4) * n];
487                *v = chebyshev(c, s).0;
488            }
489        }
490        (position, velocity)
491    }
492
493    /// A new SPK file with only the data covering TDB Julian dates `start_jd` to
494    /// `end_jd` (like `python -m jplephem excerpt`): whole Chebyshev records are kept, so
495    /// positions inside the range are identical to this kernel's. Segments that do not
496    /// overlap the range are left out. Written little-endian; types 2 and 3 only.
497    pub fn excerpt(&self, start_jd: f64, end_jd: f64) -> Result<Vec<u8>, SpkError> {
498        const SUMMARIES_PER_RECORD: usize = 25; // (128 words - 3) / 5 words per summary
499        const WORDS_PER_RECORD: usize = RECORD_BYTES / WORD_BYTES;
500        if end_jd <= start_jd {
501            return Err(SpkError::Format("excerpt range is empty".into()));
502        }
503        let (start_et, end_et) = (jd_tdb_to_et(start_jd), jd_tdb_to_et(end_jd));
504
505        struct Kept<'a> {
506            segment: &'a Segment,
507            start_et: f64,
508            end_et: f64,
509            first_word: usize,
510            last_word: usize,
511        }
512        let overlapping: Vec<&Segment> = self
513            .segments
514            .iter()
515            .filter(|s| s.start_et <= end_et && s.end_et >= start_et)
516            .collect();
517        let summary_records = overlapping.len().div_ceil(SUMMARIES_PER_RECORD).max(1);
518        // Record 1 is the file record, then summary + name record pairs, then data.
519        let mut word = (1 + 2 * summary_records) * WORDS_PER_RECORD + 1;
520        let mut data: Vec<f64> = Vec::new();
521        let mut kept = Vec::new();
522        for segment in overlapping {
523            self.check_type(segment)?;
524            let last = segment.record_count as i64 - 1;
525            let first_index =
526                (((start_et - segment.init) / segment.interval).floor() as i64).clamp(0, last);
527            let last_index =
528                (((end_et - segment.init) / segment.interval).floor() as i64).clamp(0, last);
529            let (i0, i1) = (first_index as usize, last_index as usize);
530            let count = i1 - i0 + 1;
531            let records = self.read_words(
532                segment.start_word + i0 * segment.record_size,
533                count * segment.record_size,
534            )?;
535            let init = segment.init + i0 as f64 * segment.interval;
536            let first_word = word;
537            data.extend_from_slice(&records);
538            data.extend_from_slice(&[
539                init,
540                segment.interval,
541                segment.record_size as f64,
542                count as f64,
543            ]);
544            word += records.len() + 4;
545            kept.push(Kept {
546                segment,
547                start_et: segment.start_et.max(init),
548                end_et: segment.end_et.min(init + count as f64 * segment.interval),
549                first_word,
550                last_word: word - 1,
551            });
552        }
553
554        let data_records = data.len().div_ceil(WORDS_PER_RECORD);
555        let mut out = vec![0u8; (1 + 2 * summary_records + data_records) * RECORD_BYTES];
556        let put_i32 =
557            |out: &mut [u8], at: usize, v: i32| out[at..at + 4].copy_from_slice(&v.to_le_bytes());
558        let put_f64 =
559            |out: &mut [u8], at: usize, v: f64| out[at..at + 8].copy_from_slice(&v.to_le_bytes());
560
561        // File record.
562        out[0..8].copy_from_slice(b"DAF/SPK ");
563        put_i32(&mut out, 8, 2);
564        put_i32(&mut out, 12, 6);
565        let ifname = format!("{:<60}", "astroceleste-engine excerpt");
566        out[16..76].copy_from_slice(&ifname.as_bytes()[..60]);
567        put_i32(&mut out, 76, 2);
568        put_i32(&mut out, 80, (2 + 2 * (summary_records - 1)) as i32);
569        put_i32(&mut out, 84, word as i32);
570        out[88..96].copy_from_slice(b"LTL-IEEE");
571        out[699..727].copy_from_slice(b"FTPSTR:\r:\n:\r\n:\r\x00:\x81:\x10\xce:ENDFTP");
572
573        // Summary and name records.
574        let chunks: Vec<&[Kept]> = if kept.is_empty() {
575            vec![&[]]
576        } else {
577            kept.chunks(SUMMARIES_PER_RECORD).collect()
578        };
579        for (k, chunk) in chunks.iter().enumerate() {
580            let record = 2 + 2 * k;
581            let base = (record - 1) * RECORD_BYTES;
582            let next = if k + 1 < chunks.len() { record + 2 } else { 0 };
583            let prev = if k > 0 { record - 2 } else { 0 };
584            put_f64(&mut out, base, next as f64);
585            put_f64(&mut out, base + 8, prev as f64);
586            put_f64(&mut out, base + 16, chunk.len() as f64);
587            let names = base + RECORD_BYTES;
588            out[names..names + RECORD_BYTES].fill(b' ');
589            for (i, kept) in chunk.iter().enumerate() {
590                let at = base + 24 + i * 40;
591                put_f64(&mut out, at, kept.start_et);
592                put_f64(&mut out, at + 8, kept.end_et);
593                let s = kept.segment;
594                for (j, v) in [
595                    s.target,
596                    s.center,
597                    s.frame,
598                    s.data_type,
599                    kept.first_word as i32,
600                    kept.last_word as i32,
601                ]
602                .into_iter()
603                .enumerate()
604                {
605                    put_i32(&mut out, at + 16 + 4 * j, v);
606                }
607                let name = s.name.as_bytes();
608                let n = name.len().min(40);
609                out[names + i * 40..names + i * 40 + n].copy_from_slice(&name[..n]);
610            }
611        }
612
613        // Segment data.
614        let data_base = (1 + 2 * summary_records) * RECORD_BYTES;
615        for (i, v) in data.iter().enumerate() {
616            put_f64(&mut out, data_base + i * WORD_BYTES, *v);
617        }
618        Ok(out)
619    }
620
621    /// Words `first_word..first_word + count` (1-based) must lie inside the file.
622    fn check_words(&self, first_word: usize, count: usize) -> Result<(), SpkError> {
623        if first_word == 0 {
624            return Err(SpkError::Format("word address 0".into()));
625        }
626        if first_word
627            .checked_add(count)
628            .is_none_or(|end| end - 1 > self.words)
629        {
630            return Err(SpkError::Format("read past end of file".into()));
631        }
632        Ok(())
633    }
634
635    fn read_words(&self, first_word: usize, count: usize) -> Result<Vec<f64>, SpkError> {
636        self.check_words(first_word, count)?;
637        let mut buf = vec![0u8; count * WORD_BYTES];
638        self.storage
639            .read_bytes((first_word - 1) * WORD_BYTES, &mut buf)?;
640        Ok(buf
641            .chunks_exact(WORD_BYTES)
642            .map(|b| self.to_f64(b.try_into().unwrap()))
643            .collect())
644    }
645
646    /// A DAF record or word number (1-based; 0 ends a record chain).
647    fn address_at(&self, bytes: &[u8], offset: usize) -> Result<usize, SpkError> {
648        let n = self.int_at(bytes, offset);
649        usize::try_from(n).map_err(|_| SpkError::Format(format!("negative address {n}")))
650    }
651
652    fn double_at(&self, bytes: &[u8], offset: usize) -> f64 {
653        self.to_f64(bytes[offset..offset + 8].try_into().unwrap())
654    }
655
656    fn int_at(&self, bytes: &[u8], offset: usize) -> i32 {
657        let raw: [u8; 4] = bytes[offset..offset + 4].try_into().unwrap();
658        if self.little_endian {
659            i32::from_le_bytes(raw)
660        } else {
661            i32::from_be_bytes(raw)
662        }
663    }
664
665    fn to_f64(&self, raw: [u8; 8]) -> f64 {
666        if self.little_endian {
667            f64::from_le_bytes(raw)
668        } else {
669            f64::from_be_bytes(raw)
670        }
671    }
672}
673
674/// Chebyshev series value and derivative with respect to `s` at `s` in [-1, 1].
675fn chebyshev(coeffs: &[f64], s: f64) -> (f64, f64) {
676    let (mut t_prev, mut t) = (1.0, s);
677    let (mut d_prev, mut d) = (0.0, 1.0);
678    let mut value = coeffs[0];
679    let mut derivative = 0.0;
680    if coeffs.len() > 1 {
681        value += coeffs[1] * s;
682        derivative += coeffs[1];
683    }
684    for &c in &coeffs[2.min(coeffs.len())..] {
685        let t_next = 2.0 * s * t - t_prev;
686        let d_next = 2.0 * t + 2.0 * s * d - d_prev;
687        value += c * t_next;
688        derivative += c * d_next;
689        (t_prev, t) = (t, t_next);
690        (d_prev, d) = (d, d_next);
691    }
692    (value, derivative)
693}
694
695#[cfg(test)]
696mod tests {
697    use super::chebyshev;
698
699    #[test]
700    fn chebyshev_matches_closed_form() {
701        // 1 + 2 T1 + 3 T2 + 4 T3 with T2 = 2s²-1, T3 = 4s³-3s.
702        let s: f64 = 0.37;
703        let expected =
704            1.0 + 2.0 * s + 3.0 * (2.0 * s * s - 1.0) + 4.0 * (4.0 * s.powi(3) - 3.0 * s);
705        let expected_d = 2.0 + 12.0 * s + 4.0 * (12.0 * s * s - 3.0);
706        let (v, d) = chebyshev(&[1.0, 2.0, 3.0, 4.0], s);
707        assert!((v - expected).abs() < 1e-12);
708        assert!((d - expected_d).abs() < 1e-12);
709    }
710}