Skip to main content

pleiades_compression/
channels.rs

1//! Channel, segment, and body artifact types.
2
3use core::fmt;
4
5use pleiades_types::{CelestialBody, Instant};
6
7use crate::codec::{validate_body_segments, validate_segment};
8use crate::error::{CompressionError, CompressionErrorKind};
9
10/// The kind of ecliptic channel carried by a segment.
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
13#[repr(u8)]
14#[non_exhaustive]
15pub enum ChannelKind {
16    /// Ecliptic longitude in degrees.
17    Longitude,
18    /// Ecliptic latitude in degrees.
19    Latitude,
20    /// Radius vector or distance in astronomical units.
21    DistanceAu,
22}
23
24impl ChannelKind {
25    /// Returns the compact label used in release-facing summaries.
26    pub const fn label(self) -> &'static str {
27        match self {
28            Self::Longitude => "Longitude",
29            Self::Latitude => "Latitude",
30            Self::DistanceAu => "DistanceAu",
31        }
32    }
33}
34
35impl fmt::Display for ChannelKind {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.write_str(self.label())
38    }
39}
40
41/// Quantized polynomial coefficients for one channel of a time segment.
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43#[derive(Clone, Debug, PartialEq)]
44pub struct PolynomialChannel {
45    /// Channel kind.
46    pub kind: ChannelKind,
47    /// Decimal scale exponent used when quantizing coefficients.
48    pub scale_exponent: u8,
49    /// Polynomial coefficients in ascending power order, expressed in native channel units.
50    pub coefficients: Vec<f64>,
51}
52
53impl PolynomialChannel {
54    /// Creates a channel from already-normalized polynomial coefficients.
55    pub fn new(kind: ChannelKind, scale_exponent: u8, coefficients: Vec<f64>) -> Self {
56        Self {
57            kind,
58            scale_exponent,
59            coefficients,
60        }
61    }
62
63    /// Creates a linear channel from endpoint values over the normalized interval `[0, 1]`.
64    pub fn linear(kind: ChannelKind, scale_exponent: u8, start: f64, end: f64) -> Self {
65        Self::new(kind, scale_exponent, vec![start, end - start])
66    }
67
68    /// Creates a quadratic channel that interpolates start, midpoint, and end values
69    /// over the normalized interval `[0, 1]`.
70    pub fn quadratic(
71        kind: ChannelKind,
72        scale_exponent: u8,
73        start: f64,
74        midpoint: f64,
75        end: f64,
76        midpoint_x: f64,
77    ) -> Self {
78        let linear_delta = end - start;
79        let midpoint_residual = midpoint - (start + linear_delta * midpoint_x);
80        let curvature_scale = midpoint_x * (1.0 - midpoint_x);
81
82        if curvature_scale == 0.0 {
83            return Self::linear(kind, scale_exponent, start, end);
84        }
85
86        let curvature = midpoint_residual / curvature_scale;
87        Self::new(
88            kind,
89            scale_exponent,
90            vec![start, linear_delta + curvature, -curvature],
91        )
92    }
93
94    /// Validates that the channel coefficients are finite before encoding or lookup.
95    pub fn validate(&self) -> Result<(), CompressionError> {
96        for (index, coefficient) in self.coefficients.iter().enumerate() {
97            if !coefficient.is_finite() {
98                return Err(CompressionError::new(
99                    CompressionErrorKind::InvalidFormat,
100                    format!(
101                        "polynomial channel {:?} contains a non-finite coefficient at index {index}",
102                        self.kind
103                    ),
104                ));
105            }
106        }
107
108        Ok(())
109    }
110
111    pub(crate) fn evaluate(&self, x: f64) -> f64 {
112        let mut result = 0.0;
113        let mut power = 1.0;
114        for coefficient in &self.coefficients {
115            result += coefficient * power;
116            power *= x;
117        }
118        result
119    }
120
121    /// Derivative dP/dx of the monomial polynomial at normalized time `x`.
122    /// Coefficients are ascending power order, so d/dx(Σ c_i x^i) = Σ i·c_i·x^(i-1).
123    pub(crate) fn evaluate_derivative(&self, x: f64) -> f64 {
124        let mut result = 0.0;
125        let mut power = 1.0; // x^(i-1), starting at i=1
126        for (i, coefficient) in self.coefficients.iter().enumerate().skip(1) {
127            result += (i as f64) * coefficient * power;
128            power *= x;
129        }
130        result
131    }
132}
133
134/// A single time segment for a specific body.
135#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
136#[derive(Clone, Debug, PartialEq)]
137pub struct Segment {
138    /// Inclusive segment start.
139    pub start: Instant,
140    /// Inclusive segment end.
141    pub end: Instant,
142    /// Quantized polynomial channels.
143    pub channels: Vec<PolynomialChannel>,
144    /// Optional residual-correction channels layered on top of the base fit.
145    pub residual_channels: Vec<PolynomialChannel>,
146}
147
148impl Segment {
149    /// Creates a new segment.
150    pub fn new(start: Instant, end: Instant, channels: Vec<PolynomialChannel>) -> Self {
151        Self {
152            start,
153            end,
154            channels,
155            residual_channels: Vec::new(),
156        }
157    }
158
159    /// Creates a new segment with optional residual-correction channels.
160    pub fn with_residual_channels(
161        start: Instant,
162        end: Instant,
163        channels: Vec<PolynomialChannel>,
164        residual_channels: Vec<PolynomialChannel>,
165    ) -> Self {
166        Self {
167            start,
168            end,
169            channels,
170            residual_channels,
171        }
172    }
173
174    /// Validates the segment metadata before the segment is stored or encoded.
175    ///
176    /// Stored and residual channels must be unique and ordered canonically by
177    /// channel kind so deterministic encoding stays stable across builders.
178    pub fn validate(&self) -> Result<(), CompressionError> {
179        validate_segment(self)
180    }
181
182    /// Returns a compact one-line summary of the segment span and channel mix.
183    pub fn summary_line(&self) -> String {
184        let stored_channels = self
185            .channels
186            .iter()
187            .map(|channel| channel.kind)
188            .collect::<Vec<_>>();
189        let residual_channels = self
190            .residual_channels
191            .iter()
192            .map(|channel| channel.kind)
193            .collect::<Vec<_>>();
194
195        format!(
196            "start: {}; end: {}; stored channels: {}; residual channels: {}",
197            self.start,
198            self.end,
199            crate::format::format_bracketed_labels(&stored_channels),
200            crate::format::format_bracketed_labels(&residual_channels),
201        )
202    }
203
204    pub(crate) fn contains(&self, instant: Instant) -> bool {
205        self.start.scale == instant.scale
206            && self.end.scale == instant.scale
207            && self.start.julian_day.days() <= instant.julian_day.days()
208            && instant.julian_day.days() <= self.end.julian_day.days()
209    }
210
211    pub(crate) fn span_days(&self) -> f64 {
212        self.end.julian_day.days() - self.start.julian_day.days()
213    }
214
215    pub(crate) fn channel(&self, kind: ChannelKind) -> Option<&PolynomialChannel> {
216        self.channels.iter().find(|channel| channel.kind == kind)
217    }
218
219    fn residual_channel(&self, kind: ChannelKind) -> Option<&PolynomialChannel> {
220        self.residual_channels
221            .iter()
222            .find(|channel| channel.kind == kind)
223    }
224
225    pub(crate) fn evaluate_channel(
226        &self,
227        kind: ChannelKind,
228        x: f64,
229    ) -> Result<f64, CompressionError> {
230        let base = self
231            .channel(kind)
232            .map(|channel| channel.evaluate(x))
233            .ok_or_else(|| {
234                CompressionError::new(
235                    CompressionErrorKind::MissingChannel,
236                    format!("missing {kind:?} channel"),
237                )
238            })?;
239
240        let residual = self
241            .residual_channel(kind)
242            .map(|channel| channel.evaluate(x))
243            .unwrap_or(0.0);
244
245        Ok(base + residual)
246    }
247
248    /// (base + residual) derivative dP/dx at normalized time `x` for `kind`.
249    pub(crate) fn evaluate_channel_derivative(
250        &self,
251        kind: ChannelKind,
252        x: f64,
253    ) -> Result<f64, CompressionError> {
254        let base = self
255            .channel(kind)
256            .map(|channel| channel.evaluate_derivative(x))
257            .ok_or_else(|| {
258                CompressionError::new(
259                    CompressionErrorKind::MissingChannel,
260                    format!("missing {kind:?} channel"),
261                )
262            })?;
263        let residual = self
264            .residual_channel(kind)
265            .map(|channel| channel.evaluate_derivative(x))
266            .unwrap_or(0.0);
267        Ok(base + residual)
268    }
269}
270
271impl fmt::Display for Segment {
272    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273        f.write_str(&self.summary_line())
274    }
275}
276
277/// The coordinate frame a body's stored channels are expressed in.
278///
279/// `Geocentric` channels are returned directly at lookup. `Heliocentric` channels
280/// are recombined with the geocentric Sun (`P_geo = P_helio + S_geo`) before being
281/// returned, so the public lookup output is always geocentric ecliptic.
282#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
283#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
284#[non_exhaustive]
285pub enum StoredFrame {
286    /// Stored channels are geocentric ecliptic; returned as-is.
287    Geocentric,
288    /// Stored channels are heliocentric ecliptic; recombined with the Sun at lookup.
289    Heliocentric,
290}
291
292/// All segments for a single body.
293#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
294#[derive(Clone, Debug, PartialEq)]
295pub struct BodyArtifact {
296    /// Body identifier.
297    pub body: CelestialBody,
298    /// Time segments for the body.
299    pub segments: Vec<Segment>,
300    /// Frame the stored channels are expressed in.
301    pub frame: StoredFrame,
302}
303
304impl BodyArtifact {
305    /// Creates a new geocentric body artifact (the default frame).
306    pub fn new(body: CelestialBody, segments: Vec<Segment>) -> Self {
307        Self {
308            body,
309            segments,
310            frame: StoredFrame::Geocentric,
311        }
312    }
313
314    /// Creates a body artifact with an explicit stored frame.
315    pub fn with_frame(body: CelestialBody, segments: Vec<Segment>, frame: StoredFrame) -> Self {
316        Self {
317            body,
318            segments,
319            frame,
320        }
321    }
322
323    /// Validates the body's segment metadata.
324    ///
325    /// This checks each segment's internal invariants, ensures that segments
326    /// using the same time scale are ordered and non-overlapping, and rejects
327    /// duplicate stored or residual channels before lookup or encoding.
328    pub fn validate(&self) -> Result<(), CompressionError> {
329        for segment in &self.segments {
330            segment.validate()?;
331        }
332
333        validate_body_segments(&self.segments)
334    }
335
336    /// Returns a compact one-line summary of the body's segment coverage.
337    pub fn summary_line(&self) -> String {
338        let residual_segment_count = self
339            .segments
340            .iter()
341            .filter(|segment| !segment.residual_channels.is_empty())
342            .count();
343
344        format!(
345            "body: {}; segments: {}; residual-bearing segments: {}",
346            self.body,
347            self.segments.len(),
348            residual_segment_count,
349        )
350    }
351
352    /// Returns the segment covering the requested instant, if any.
353    ///
354    /// When two adjacent segments both include the same boundary instant, the
355    /// later segment wins. This keeps shared segment edges deterministic for
356    /// piecewise artifacts that use inclusive endpoints.
357    pub fn segment_at(&self, instant: Instant) -> Option<&Segment> {
358        self.segments
359            .iter()
360            .rev()
361            .find(|segment| segment.contains(instant))
362    }
363}
364
365impl fmt::Display for BodyArtifact {
366    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367        f.write_str(&self.summary_line())
368    }
369}
370
371#[cfg(test)]
372mod frame_field_tests {
373    use super::*;
374    use pleiades_types::CelestialBody;
375
376    #[test]
377    fn new_defaults_to_geocentric() {
378        let b = BodyArtifact::new(CelestialBody::Sun, vec![]);
379        assert_eq!(b.frame, StoredFrame::Geocentric);
380    }
381
382    #[test]
383    fn with_frame_sets_heliocentric() {
384        let b = BodyArtifact::with_frame(CelestialBody::Jupiter, vec![], StoredFrame::Heliocentric);
385        assert_eq!(b.frame, StoredFrame::Heliocentric);
386    }
387}
388
389#[cfg(test)]
390mod derivative_tests {
391    use super::*;
392    use pleiades_types::{JulianDay, TimeScale};
393
394    #[test]
395    fn polynomial_derivative_matches_power_rule() {
396        // P(x) = 2 + 3x + 4x^2  ->  P'(x) = 3 + 8x
397        let ch = PolynomialChannel::new(ChannelKind::Longitude, 9, vec![2.0, 3.0, 4.0]);
398        assert!((ch.evaluate_derivative(0.0) - 3.0).abs() < 1e-12);
399        assert!((ch.evaluate_derivative(1.0) - 11.0).abs() < 1e-12);
400        assert!((ch.evaluate_derivative(0.5) - 7.0).abs() < 1e-12);
401    }
402
403    #[test]
404    fn segment_channel_derivative_includes_residual() {
405        let start = Instant::new(JulianDay::from_days(0.0), TimeScale::Tt);
406        let end = Instant::new(JulianDay::from_days(1.0), TimeScale::Tt);
407        let seg = Segment::with_residual_channels(
408            start,
409            end,
410            vec![PolynomialChannel::new(
411                ChannelKind::Longitude,
412                9,
413                vec![0.0, 2.0],
414            )], // base' = 2
415            vec![PolynomialChannel::new(
416                ChannelKind::Longitude,
417                9,
418                vec![0.0, 0.0, 5.0], // residual' = 10x
419            )],
420        );
421        // total derivative at x=1: 2 + 10 = 12
422        let d = seg
423            .evaluate_channel_derivative(ChannelKind::Longitude, 1.0)
424            .unwrap();
425        assert!((d - 12.0).abs() < 1e-12);
426    }
427}