#![forbid(unsafe_code)]
use core::fmt;
use std::collections::HashMap;
use pleiades_types::{
Angle, CelestialBody, CustomBodyId, EclipticCoordinates, EquatorialCoordinates, Instant,
JulianDay, Latitude, Longitude, TimeScale,
};
pub const ARTIFACT_VERSION: u16 = 4;
const ARTIFACT_MAGIC: [u8; 8] = *b"PLDEPHEM";
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
#[non_exhaustive]
pub enum EndianPolicy {
LittleEndian,
}
impl EndianPolicy {
pub const fn label(self) -> &'static str {
match self {
Self::LittleEndian => "little-endian",
}
}
}
impl fmt::Display for EndianPolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtifactHeader {
pub version: u16,
pub generation_label: String,
pub source: String,
pub endian_policy: EndianPolicy,
pub profile: ArtifactProfile,
}
impl ArtifactHeader {
pub fn new(generation_label: impl Into<String>, source: impl Into<String>) -> Self {
Self::with_profile(
generation_label,
source,
ArtifactProfile::packaged_ecliptic_longitude_latitude_distance_with_derived_equatorial(
),
)
}
pub fn with_profile(
generation_label: impl Into<String>,
source: impl Into<String>,
profile: ArtifactProfile,
) -> Self {
Self::with_profile_and_endian(
generation_label,
source,
EndianPolicy::LittleEndian,
profile,
)
}
pub fn with_profile_and_endian(
generation_label: impl Into<String>,
source: impl Into<String>,
endian_policy: EndianPolicy,
profile: ArtifactProfile,
) -> Self {
Self {
version: ARTIFACT_VERSION,
generation_label: generation_label.into(),
source: source.into(),
endian_policy,
profile,
}
}
pub fn summary(&self) -> String {
self.summary_line()
}
pub fn summary_line(&self) -> String {
format!("byte order: {}; {}", self.endian_policy, self.profile)
}
pub fn validate(&self) -> Result<(), CompressionError> {
if self.version != ARTIFACT_VERSION {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!(
"artifact header version {} does not match the current format version {}",
self.version, ARTIFACT_VERSION
),
));
}
validate_canonical_header_text("artifact header generation label", &self.generation_label)?;
validate_canonical_header_text("artifact header source", &self.source)?;
self.profile.validate()
}
pub fn summary_for_body_count(&self, body_count: usize) -> String {
format!(
"{}; applies to {} bundled bodies",
self.summary_line(),
body_count
)
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
#[non_exhaustive]
pub enum ArtifactOutput {
EclipticCoordinates,
EquatorialCoordinates,
ApparentCorrections,
TopocentricCoordinates,
SiderealCoordinates,
Motion,
}
impl ArtifactOutput {
pub const fn all() -> [Self; 6] {
[
Self::EclipticCoordinates,
Self::EquatorialCoordinates,
Self::ApparentCorrections,
Self::TopocentricCoordinates,
Self::SiderealCoordinates,
Self::Motion,
]
}
const fn ordinal(self) -> u8 {
match self {
Self::EclipticCoordinates => 0,
Self::EquatorialCoordinates => 1,
Self::ApparentCorrections => 2,
Self::TopocentricCoordinates => 3,
Self::SiderealCoordinates => 4,
Self::Motion => 5,
}
}
pub const fn label(self) -> &'static str {
match self {
Self::EclipticCoordinates => "EclipticCoordinates",
Self::EquatorialCoordinates => "EquatorialCoordinates",
Self::ApparentCorrections => "ApparentCorrections",
Self::TopocentricCoordinates => "TopocentricCoordinates",
Self::SiderealCoordinates => "SiderealCoordinates",
Self::Motion => "Motion",
}
}
}
impl fmt::Display for ArtifactOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
#[non_exhaustive]
pub enum ArtifactOutputSupport {
Stored,
Derived,
Approximated,
Unsupported,
Unlisted,
}
impl ArtifactOutputSupport {
pub const fn label(self) -> &'static str {
match self {
Self::Stored => "stored",
Self::Derived => "derived",
Self::Approximated => "approximated",
Self::Unsupported => "unsupported",
Self::Unlisted => "unlisted",
}
}
}
impl fmt::Display for ArtifactOutputSupport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
impl fmt::Display for ArtifactProfile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.summary_line())
}
}
impl fmt::Display for ArtifactHeader {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.summary_line())
}
}
fn validate_canonical_header_text(field: &str, value: &str) -> Result<(), CompressionError> {
if value.trim().is_empty() {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!("{field} must not be blank"),
));
}
if value != value.trim() {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!("{field} must not include surrounding whitespace"),
));
}
Ok(())
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
#[non_exhaustive]
pub enum SpeedPolicy {
Unsupported,
Stored,
FittedDerivative,
NumericalDifference,
}
impl SpeedPolicy {
pub const fn label(self) -> &'static str {
match self {
Self::Unsupported => "Unsupported",
Self::Stored => "Stored",
Self::FittedDerivative => "FittedDerivative",
Self::NumericalDifference => "NumericalDifference",
}
}
pub const fn motion_output_support(self) -> ArtifactOutputSupport {
match self {
Self::Unsupported => ArtifactOutputSupport::Unsupported,
Self::Stored => ArtifactOutputSupport::Stored,
Self::FittedDerivative => ArtifactOutputSupport::Derived,
Self::NumericalDifference => ArtifactOutputSupport::Approximated,
}
}
}
impl fmt::Display for SpeedPolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtifactProfile {
pub stored_channels: Vec<ChannelKind>,
pub derived_outputs: Vec<ArtifactOutput>,
pub unsupported_outputs: Vec<ArtifactOutput>,
pub speed_policy: SpeedPolicy,
}
pub fn join_display<T: fmt::Display>(values: &[T]) -> String {
values
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
}
fn format_bracketed_labels<T: fmt::Display>(values: &[T]) -> String {
format!("[{}]", join_display(values))
}
impl ArtifactProfile {
pub fn new(
stored_channels: Vec<ChannelKind>,
derived_outputs: Vec<ArtifactOutput>,
unsupported_outputs: Vec<ArtifactOutput>,
speed_policy: SpeedPolicy,
) -> Self {
Self {
stored_channels,
derived_outputs,
unsupported_outputs,
speed_policy,
}
}
pub fn validate(&self) -> Result<(), CompressionError> {
validate_artifact_profile(self)
}
pub fn summary(&self) -> String {
self.summary_line()
}
pub fn motion_output_support(&self) -> ArtifactOutputSupport {
self.speed_policy.motion_output_support()
}
pub fn summary_line(&self) -> String {
format!(
"stored channels: {}; derived outputs: {}; unsupported outputs: {}; speed policy: {}",
format_bracketed_labels(&self.stored_channels),
format_bracketed_labels(&self.derived_outputs),
format_bracketed_labels(&self.unsupported_outputs),
self.speed_policy,
)
}
pub fn validated_summary_line(&self) -> Result<String, CompressionError> {
self.validate()?;
Ok(self.summary_line())
}
pub fn validated_output_support_entries_summary_line(
&self,
) -> Result<String, CompressionError> {
self.validate()?;
Ok(self.output_support_entries_summary_line())
}
pub fn validated_output_support_summary_line(&self) -> Result<String, CompressionError> {
self.validate()?;
Ok(self.output_support_summary_line())
}
pub fn summary_for_body_count(&self, body_count: usize) -> String {
format!(
"{}; applies to {} bundled bodies",
self.summary_line(),
body_count
)
}
pub fn summary_line_with_body_count(&self, body_count: usize) -> String {
self.summary_for_body_count(body_count)
}
pub fn output_support_entries_summary_line(&self) -> String {
ArtifactOutput::all()
.into_iter()
.map(|output| format!("{output}={}", self.output_support(output)))
.collect::<Vec<_>>()
.join(", ")
}
pub fn output_support_summary_line(&self) -> String {
let mut stored_count = 0usize;
let mut derived_count = 0usize;
let mut approximated_count = 0usize;
let mut unsupported_count = 0usize;
let mut unlisted_count = 0usize;
let mut unlisted_outputs = Vec::new();
for output in ArtifactOutput::all() {
match self.output_support(output) {
ArtifactOutputSupport::Stored => stored_count += 1,
ArtifactOutputSupport::Derived => derived_count += 1,
ArtifactOutputSupport::Approximated => approximated_count += 1,
ArtifactOutputSupport::Unsupported => unsupported_count += 1,
ArtifactOutputSupport::Unlisted => {
unlisted_count += 1;
unlisted_outputs.push(output);
}
}
}
format!(
"{}; unlisted outputs: {}; support counts: stored={}, derived={}, approximated={}, unsupported={}, unlisted={}",
self.output_support_entries_summary_line(),
format_bracketed_labels(&unlisted_outputs),
stored_count,
derived_count,
approximated_count,
unsupported_count,
unlisted_count,
)
}
pub fn output_support(&self, output: ArtifactOutput) -> ArtifactOutputSupport {
if output == ArtifactOutput::Motion {
self.motion_output_support()
} else if self.derived_outputs.contains(&output) {
ArtifactOutputSupport::Derived
} else if self.unsupported_outputs.contains(&output) {
ArtifactOutputSupport::Unsupported
} else {
ArtifactOutputSupport::Unlisted
}
}
pub fn supports_output(&self, output: ArtifactOutput) -> bool {
matches!(
self.output_support(output),
ArtifactOutputSupport::Stored
| ArtifactOutputSupport::Derived
| ArtifactOutputSupport::Approximated
)
}
pub fn is_unsupported_output(&self, output: ArtifactOutput) -> bool {
matches!(
self.output_support(output),
ArtifactOutputSupport::Unsupported
)
}
pub fn ecliptic_longitude_latitude_distance() -> Self {
Self::ecliptic_longitude_latitude_distance_with_derived_equatorial()
}
pub fn packaged_ecliptic_longitude_latitude_distance_with_derived_equatorial() -> Self {
Self::new(
vec![
ChannelKind::Longitude,
ChannelKind::Latitude,
ChannelKind::DistanceAu,
],
vec![
ArtifactOutput::EclipticCoordinates,
ArtifactOutput::EquatorialCoordinates,
],
vec![
ArtifactOutput::ApparentCorrections,
ArtifactOutput::TopocentricCoordinates,
ArtifactOutput::SiderealCoordinates,
ArtifactOutput::Motion,
],
SpeedPolicy::Unsupported,
)
}
pub fn ecliptic_longitude_latitude_distance_with_derived_equatorial() -> Self {
Self::packaged_ecliptic_longitude_latitude_distance_with_derived_equatorial()
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtifactProfileCoverageSummary {
pub body_count: usize,
pub bodies: Vec<CelestialBody>,
pub profile: ArtifactProfile,
}
impl ArtifactProfileCoverageSummary {
pub fn new(profile: ArtifactProfile, bodies: Vec<CelestialBody>) -> Self {
let body_count = bodies.len();
Self {
body_count,
bodies,
profile,
}
}
pub fn validate(&self) -> Result<(), CompressionError> {
self.profile.validate()?;
if self.bodies.is_empty() {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
"artifact profile coverage bundled body list must not be empty",
));
}
validate_unique_values("artifact profile coverage bundled bodies", &self.bodies)?;
validate_canonical_body_order("artifact profile coverage bundled bodies", &self.bodies)?;
if self.body_count != self.bodies.len() {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
"artifact profile coverage body count does not match bundled body list",
));
}
Ok(())
}
pub fn summary_line(&self) -> String {
self.profile.summary_for_body_count(self.bodies.len())
}
pub fn validated_summary_line(&self) -> Result<String, CompressionError> {
self.validate()?;
Ok(self.summary_line())
}
pub fn summary_line_with_bodies(&self) -> String {
format!(
"{}; bundled bodies: {}",
self.summary_line(),
join_display(&self.bodies)
)
}
pub fn validated_summary_line_with_bodies(&self) -> Result<String, CompressionError> {
self.validate()?;
Ok(self.summary_line_with_bodies())
}
}
impl fmt::Display for ArtifactProfileCoverageSummary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.summary_line())
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtifactResidualBodyCoverageSummary {
pub body_count: usize,
pub bodies: Vec<CelestialBody>,
}
impl ArtifactResidualBodyCoverageSummary {
pub fn new(bodies: Vec<CelestialBody>) -> Self {
let body_count = bodies.len();
Self { body_count, bodies }
}
pub fn validate(&self, artifact: &CompressedArtifact) -> Result<(), CompressionError> {
let expected_bodies = artifact.residual_bodies();
if self.body_count != self.bodies.len() {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
"artifact residual-body coverage body count does not match the body list",
));
}
validate_unique_values("artifact residual-body coverage bodies", &self.bodies)?;
if self.body_count != expected_bodies.len() {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
"artifact residual-body coverage body count does not match residual body list",
));
}
if self.bodies != expected_bodies {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
"artifact residual-body coverage body list does not match the current artifact",
));
}
Ok(())
}
pub fn summary_line(&self) -> String {
match self.bodies.as_slice() {
[] => "residual bodies: none".to_string(),
bodies => format!("residual bodies: {}", join_display(bodies)),
}
}
pub fn validated_summary_line(
&self,
artifact: &CompressedArtifact,
) -> Result<String, CompressionError> {
self.validate(artifact)?;
Ok(self.summary_line())
}
pub fn summary_line_with_body_count(&self) -> String {
format!(
"{}; applies to {}",
self.summary_line(),
self.body_count_suffix()
)
}
pub fn validated_summary_line_with_body_count(
&self,
artifact: &CompressedArtifact,
) -> Result<String, CompressionError> {
let summary = self.validated_summary_line(artifact)?;
Ok(format!(
"{}; applies to {}",
summary,
self.body_count_suffix()
))
}
fn body_count_suffix(&self) -> String {
match self.body_count {
1 => "1 bundled body".to_string(),
count => format!("{count} bundled bodies"),
}
}
}
impl fmt::Display for ArtifactResidualBodyCoverageSummary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.summary_line())
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
#[repr(u8)]
#[non_exhaustive]
pub enum ChannelKind {
Longitude,
Latitude,
DistanceAu,
}
impl ChannelKind {
pub const fn label(self) -> &'static str {
match self {
Self::Longitude => "Longitude",
Self::Latitude => "Latitude",
Self::DistanceAu => "DistanceAu",
}
}
}
impl fmt::Display for ChannelKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq)]
pub struct PolynomialChannel {
pub kind: ChannelKind,
pub scale_exponent: u8,
pub coefficients: Vec<f64>,
}
impl PolynomialChannel {
pub fn new(kind: ChannelKind, scale_exponent: u8, coefficients: Vec<f64>) -> Self {
Self {
kind,
scale_exponent,
coefficients,
}
}
pub fn linear(kind: ChannelKind, scale_exponent: u8, start: f64, end: f64) -> Self {
Self::new(kind, scale_exponent, vec![start, end - start])
}
pub fn quadratic(
kind: ChannelKind,
scale_exponent: u8,
start: f64,
midpoint: f64,
end: f64,
midpoint_x: f64,
) -> Self {
let linear_delta = end - start;
let midpoint_residual = midpoint - (start + linear_delta * midpoint_x);
let curvature_scale = midpoint_x * (1.0 - midpoint_x);
if curvature_scale == 0.0 {
return Self::linear(kind, scale_exponent, start, end);
}
let curvature = midpoint_residual / curvature_scale;
Self::new(
kind,
scale_exponent,
vec![start, linear_delta + curvature, -curvature],
)
}
pub fn validate(&self) -> Result<(), CompressionError> {
for (index, coefficient) in self.coefficients.iter().enumerate() {
if !coefficient.is_finite() {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!(
"polynomial channel {:?} contains a non-finite coefficient at index {index}",
self.kind
),
));
}
}
Ok(())
}
fn evaluate(&self, x: f64) -> f64 {
let mut result = 0.0;
let mut power = 1.0;
for coefficient in &self.coefficients {
result += coefficient * power;
power *= x;
}
result
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq)]
pub struct Segment {
pub start: Instant,
pub end: Instant,
pub channels: Vec<PolynomialChannel>,
pub residual_channels: Vec<PolynomialChannel>,
}
impl Segment {
pub fn new(start: Instant, end: Instant, channels: Vec<PolynomialChannel>) -> Self {
Self {
start,
end,
channels,
residual_channels: Vec::new(),
}
}
pub fn with_residual_channels(
start: Instant,
end: Instant,
channels: Vec<PolynomialChannel>,
residual_channels: Vec<PolynomialChannel>,
) -> Self {
Self {
start,
end,
channels,
residual_channels,
}
}
pub fn validate(&self) -> Result<(), CompressionError> {
validate_segment(self)
}
pub fn summary_line(&self) -> String {
let stored_channels = self
.channels
.iter()
.map(|channel| channel.kind)
.collect::<Vec<_>>();
let residual_channels = self
.residual_channels
.iter()
.map(|channel| channel.kind)
.collect::<Vec<_>>();
format!(
"start: {}; end: {}; stored channels: {}; residual channels: {}",
self.start,
self.end,
format_bracketed_labels(&stored_channels),
format_bracketed_labels(&residual_channels),
)
}
fn contains(&self, instant: Instant) -> bool {
self.start.scale == instant.scale
&& self.end.scale == instant.scale
&& self.start.julian_day.days() <= instant.julian_day.days()
&& instant.julian_day.days() <= self.end.julian_day.days()
}
fn span_days(&self) -> f64 {
self.end.julian_day.days() - self.start.julian_day.days()
}
fn channel(&self, kind: ChannelKind) -> Option<&PolynomialChannel> {
self.channels.iter().find(|channel| channel.kind == kind)
}
fn residual_channel(&self, kind: ChannelKind) -> Option<&PolynomialChannel> {
self.residual_channels
.iter()
.find(|channel| channel.kind == kind)
}
fn evaluate_channel(&self, kind: ChannelKind, x: f64) -> Result<f64, CompressionError> {
let base = self
.channel(kind)
.map(|channel| channel.evaluate(x))
.ok_or_else(|| {
CompressionError::new(
CompressionErrorKind::MissingChannel,
format!("missing {kind:?} channel"),
)
})?;
let residual = self
.residual_channel(kind)
.map(|channel| channel.evaluate(x))
.unwrap_or(0.0);
Ok(base + residual)
}
}
impl fmt::Display for Segment {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.summary_line())
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq)]
pub struct BodyArtifact {
pub body: CelestialBody,
pub segments: Vec<Segment>,
}
impl BodyArtifact {
pub fn new(body: CelestialBody, segments: Vec<Segment>) -> Self {
Self { body, segments }
}
pub fn validate(&self) -> Result<(), CompressionError> {
for segment in &self.segments {
segment.validate()?;
}
validate_body_segments(&self.segments)
}
pub fn summary_line(&self) -> String {
let residual_segment_count = self
.segments
.iter()
.filter(|segment| !segment.residual_channels.is_empty())
.count();
format!(
"body: {}; segments: {}; residual-bearing segments: {}",
self.body,
self.segments.len(),
residual_segment_count,
)
}
pub fn segment_at(&self, instant: Instant) -> Option<&Segment> {
self.segments
.iter()
.rev()
.find(|segment| segment.contains(instant))
}
}
impl fmt::Display for BodyArtifact {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.summary_line())
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq)]
pub struct CompressedArtifact {
pub header: ArtifactHeader,
pub checksum: u64,
pub bodies: Vec<BodyArtifact>,
}
impl CompressedArtifact {
pub fn new(header: ArtifactHeader, bodies: Vec<BodyArtifact>) -> Self {
Self {
header,
checksum: 0,
bodies,
}
}
pub fn validate(&self) -> Result<(), CompressionError> {
self.header.validate()?;
validate_body_artifacts(&self.bodies)?;
self.profile_coverage_summary().validate()?;
for body in &self.bodies {
body.validate()?;
}
Ok(())
}
pub fn profile_coverage_summary(&self) -> ArtifactProfileCoverageSummary {
ArtifactProfileCoverageSummary::new(
self.header.profile.clone(),
self.bodies.iter().map(|body| body.body.clone()).collect(),
)
}
pub fn residual_body_coverage_summary(&self) -> ArtifactResidualBodyCoverageSummary {
ArtifactResidualBodyCoverageSummary::new(self.residual_bodies())
}
pub fn body_artifact(&self, body: &CelestialBody) -> Option<&BodyArtifact> {
self.bodies.iter().find(|series| &series.body == body)
}
pub fn segment_count(&self) -> usize {
self.bodies.iter().map(|body| body.segments.len()).sum()
}
pub fn residual_segment_count(&self) -> usize {
self.bodies
.iter()
.flat_map(|body| body.segments.iter())
.filter(|segment| !segment.residual_channels.is_empty())
.count()
}
pub fn residual_bodies(&self) -> Vec<CelestialBody> {
self.bodies
.iter()
.filter(|body| {
body.segments
.iter()
.any(|segment| !segment.residual_channels.is_empty())
})
.map(|body| body.body.clone())
.collect()
}
pub fn summary_line(&self) -> String {
let residual_bodies = self.residual_bodies();
let residual_bodies = if residual_bodies.is_empty() {
"none".to_string()
} else {
join_display(&residual_bodies)
};
format!(
"bodies: {}; segments: {}; residual-bearing segments: {}; residual-bearing bodies: {}",
self.bodies.len(),
self.segment_count(),
self.residual_segment_count(),
residual_bodies,
)
}
pub fn segment_for(
&self,
body: &CelestialBody,
instant: Instant,
) -> Result<&Segment, CompressionError> {
let series = self.body_artifact(body).ok_or_else(|| {
CompressionError::new(
CompressionErrorKind::MissingBody,
format!("no packed data exists for {body:?}"),
)
})?;
series.segment_at(instant).ok_or_else(|| {
CompressionError::new(
CompressionErrorKind::OutOfRangeInstant,
format!("no packed segment covers {body:?} at {instant:?}"),
)
})
}
pub fn checksum(&self) -> Result<u64, CompressionError> {
Ok(fnv1a64(&self.encode_payload()?))
}
pub fn encode(&self) -> Result<Vec<u8>, CompressionError> {
let payload = self.encode_payload()?;
let checksum = fnv1a64(&payload);
let mut bytes = Vec::new();
bytes.extend_from_slice(&ARTIFACT_MAGIC);
write_u16(&mut bytes, self.header.version);
write_u64(&mut bytes, checksum);
bytes.extend_from_slice(&payload);
Ok(bytes)
}
pub fn decode(bytes: &[u8]) -> Result<Self, CompressionError> {
let mut cursor = Cursor::new(bytes);
let magic = cursor.read_array::<8>()?;
if magic != ARTIFACT_MAGIC {
return Err(CompressionError::new(
CompressionErrorKind::InvalidMagic,
"compressed artifact magic header did not match",
));
}
let version = cursor.read_u16()?;
if version != ARTIFACT_VERSION {
return Err(CompressionError::new(
CompressionErrorKind::UnsupportedVersion,
format!("artifact version {version} is not supported"),
));
}
let checksum = cursor.read_u64()?;
let payload = cursor.remaining();
if fnv1a64(payload) != checksum {
return Err(CompressionError::new(
CompressionErrorKind::ChecksumMismatch,
"compressed artifact checksum did not match",
));
}
let mut payload_cursor = Cursor::new(payload);
let header = ArtifactHeader {
version,
generation_label: payload_cursor.read_string()?,
source: payload_cursor.read_string()?,
endian_policy: decode_endian_policy(payload_cursor.read_u8()?)?,
profile: decode_artifact_profile(&mut payload_cursor)?,
};
let body_count = payload_cursor.read_u16()? as usize;
let mut bodies = Vec::with_capacity(body_count);
for _ in 0..body_count {
let body = decode_body(&mut payload_cursor)?;
bodies.push(body);
}
if !payload_cursor.is_finished() {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
"compressed artifact contained trailing bytes",
));
}
let artifact = Self {
header,
checksum,
bodies,
};
artifact.validate()?;
Ok(artifact)
}
pub fn lookup_ecliptic(
&self,
body: &CelestialBody,
instant: Instant,
) -> Result<EclipticCoordinates, CompressionError> {
self.require_output_support(ArtifactOutput::EclipticCoordinates)?;
if !matches!(instant.scale, TimeScale::Tt | TimeScale::Tdb) {
return Err(CompressionError::new(
CompressionErrorKind::UnsupportedTimeScale,
"packaged lookup only accepts TT or TDB instants",
));
}
let segment = self.segment_for(body, instant)?;
let span = segment.span_days();
let x = if span == 0.0 {
0.0
} else {
(instant.julian_day.days() - segment.start.julian_day.days()) / span
};
let longitude = segment.evaluate_channel(ChannelKind::Longitude, x)?;
let latitude = segment.evaluate_channel(ChannelKind::Latitude, x)?;
let distance_au = segment.evaluate_channel(ChannelKind::DistanceAu, x)?;
Ok(EclipticCoordinates::new(
Longitude::from_degrees(longitude),
Latitude::from_degrees(latitude),
Some(distance_au),
))
}
pub fn lookup_equatorial(
&self,
body: &CelestialBody,
instant: Instant,
obliquity: Angle,
) -> Result<EquatorialCoordinates, CompressionError> {
self.require_output_support(ArtifactOutput::EquatorialCoordinates)?;
Ok(self
.lookup_ecliptic(body, instant)?
.to_equatorial(obliquity))
}
fn require_output_support(&self, output: ArtifactOutput) -> Result<(), CompressionError> {
if self.header.profile.supports_output(output) {
Ok(())
} else {
Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!("artifact profile does not support {output}"),
))
}
}
fn encode_payload(&self) -> Result<Vec<u8>, CompressionError> {
self.validate()?;
let mut bytes = Vec::new();
write_string(&mut bytes, &self.header.generation_label);
write_string(&mut bytes, &self.header.source);
write_u8(&mut bytes, encode_endian_policy(self.header.endian_policy));
encode_artifact_profile(&mut bytes, &self.header.profile)?;
write_u16(&mut bytes, self.bodies.len() as u16);
for body in &self.bodies {
encode_body(&mut bytes, body)?;
}
Ok(bytes)
}
}
impl fmt::Display for CompressedArtifact {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.summary_line())
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
#[non_exhaustive]
pub enum CompressionErrorKind {
InvalidMagic,
UnsupportedVersion,
ChecksumMismatch,
Truncated,
InvalidFormat,
UnsupportedEndianPolicy,
MissingBody,
MissingChannel,
OutOfRangeInstant,
UnsupportedTimeScale,
QuantizationOverflow,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompressionError {
pub kind: CompressionErrorKind,
pub message: String,
}
impl CompressionError {
pub fn new(kind: CompressionErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
}
impl fmt::Display for CompressionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}: {}", self.kind, self.message)
}
}
impl std::error::Error for CompressionError {}
fn encode_endian_policy(policy: EndianPolicy) -> u8 {
match policy {
EndianPolicy::LittleEndian => 0,
}
}
fn decode_endian_policy(value: u8) -> Result<EndianPolicy, CompressionError> {
match value {
0 => Ok(EndianPolicy::LittleEndian),
other => Err(CompressionError::new(
CompressionErrorKind::UnsupportedEndianPolicy,
format!("artifact byte-order policy {other} is not supported"),
)),
}
}
fn encode_artifact_profile(
bytes: &mut Vec<u8>,
profile: &ArtifactProfile,
) -> Result<(), CompressionError> {
profile.validate()?;
write_u8(bytes, profile.stored_channels.len() as u8);
for channel in &profile.stored_channels {
write_u8(bytes, encode_channel_kind(*channel));
}
write_u8(bytes, profile.derived_outputs.len() as u8);
for output in &profile.derived_outputs {
write_u8(bytes, encode_artifact_output(*output));
}
write_u8(bytes, profile.unsupported_outputs.len() as u8);
for output in &profile.unsupported_outputs {
write_u8(bytes, encode_artifact_output(*output));
}
write_u8(bytes, encode_speed_policy(profile.speed_policy));
Ok(())
}
fn decode_artifact_profile(cursor: &mut Cursor<'_>) -> Result<ArtifactProfile, CompressionError> {
let stored_channel_count = cursor.read_u8()? as usize;
let mut stored_channels = Vec::with_capacity(stored_channel_count);
for _ in 0..stored_channel_count {
stored_channels.push(decode_channel_kind(cursor.read_u8()?)?);
}
let derived_output_count = cursor.read_u8()? as usize;
let mut derived_outputs = Vec::with_capacity(derived_output_count);
for _ in 0..derived_output_count {
derived_outputs.push(decode_artifact_output(cursor.read_u8()?)?);
}
let unsupported_output_count = cursor.read_u8()? as usize;
let mut unsupported_outputs = Vec::with_capacity(unsupported_output_count);
for _ in 0..unsupported_output_count {
unsupported_outputs.push(decode_artifact_output(cursor.read_u8()?)?);
}
let speed_policy = decode_speed_policy(cursor.read_u8()?)?;
let profile = ArtifactProfile::new(
stored_channels,
derived_outputs,
unsupported_outputs,
speed_policy,
);
profile.validate()?;
Ok(profile)
}
fn validate_artifact_profile(profile: &ArtifactProfile) -> Result<(), CompressionError> {
validate_unique_values("artifact profile stored channels", &profile.stored_channels)?;
validate_unique_values("artifact profile derived outputs", &profile.derived_outputs)?;
validate_unique_values(
"artifact profile unsupported outputs",
&profile.unsupported_outputs,
)?;
validate_channel_kind_order("artifact profile stored channels", &profile.stored_channels)?;
validate_artifact_output_order("artifact profile derived outputs", &profile.derived_outputs)?;
validate_artifact_output_order(
"artifact profile unsupported outputs",
&profile.unsupported_outputs,
)?;
validate_disjoint_values(
"artifact profile derived outputs",
&profile.derived_outputs,
"artifact profile unsupported outputs",
&profile.unsupported_outputs,
)?;
validate_explicit_output_classification(profile)?;
validate_coordinate_output_policy(profile)?;
validate_motion_policy(profile)?;
Ok(())
}
fn validate_artifact_output_order(
field: &str,
outputs: &[ArtifactOutput],
) -> Result<(), CompressionError> {
for pair in outputs.windows(2) {
if pair[0].ordinal() > pair[1].ordinal() {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!(
"{field} must be ordered by artifact output kind; found {:?} before {:?}",
pair[0], pair[1]
),
));
}
}
Ok(())
}
fn validate_explicit_output_classification(
profile: &ArtifactProfile,
) -> Result<(), CompressionError> {
for output in ArtifactOutput::all() {
if profile.output_support(output) == ArtifactOutputSupport::Unlisted {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!(
"artifact profile output {output} must be explicitly listed as stored, derived, approximated, or unsupported"
),
));
}
}
Ok(())
}
fn validate_coordinate_output_policy(profile: &ArtifactProfile) -> Result<(), CompressionError> {
let needs_coordinate_channels = profile.derived_outputs.iter().any(|output| {
matches!(
*output,
ArtifactOutput::EclipticCoordinates
| ArtifactOutput::EquatorialCoordinates
| ArtifactOutput::ApparentCorrections
| ArtifactOutput::TopocentricCoordinates
| ArtifactOutput::SiderealCoordinates
)
});
if needs_coordinate_channels
&& (!profile.stored_channels.contains(&ChannelKind::Longitude)
|| !profile.stored_channels.contains(&ChannelKind::Latitude)
|| !profile.stored_channels.contains(&ChannelKind::DistanceAu))
{
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
"artifact profile derived coordinate outputs require Longitude, Latitude, and DistanceAu in stored channels",
));
}
Ok(())
}
fn validate_motion_policy(profile: &ArtifactProfile) -> Result<(), CompressionError> {
match profile.motion_output_support() {
ArtifactOutputSupport::Stored => {
if profile.derived_outputs.contains(&ArtifactOutput::Motion) {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
"artifact profile speed policy Stored must not list Motion in derived outputs",
));
}
if profile
.unsupported_outputs
.contains(&ArtifactOutput::Motion)
{
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
"artifact profile speed policy Stored must not list Motion in unsupported outputs",
));
}
}
ArtifactOutputSupport::Derived => {
if !profile.derived_outputs.contains(&ArtifactOutput::Motion) {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!(
"artifact profile speed policy {} requires Motion to be listed in derived outputs",
profile.speed_policy
),
));
}
if profile
.unsupported_outputs
.contains(&ArtifactOutput::Motion)
{
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
"artifact profile speed policy FittedDerivative must not list Motion in unsupported outputs",
));
}
}
ArtifactOutputSupport::Approximated => {
if profile.derived_outputs.contains(&ArtifactOutput::Motion) {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
"artifact profile speed policy NumericalDifference must not list Motion in derived outputs",
));
}
if profile
.unsupported_outputs
.contains(&ArtifactOutput::Motion)
{
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
"artifact profile speed policy NumericalDifference must not list Motion in unsupported outputs",
));
}
}
ArtifactOutputSupport::Unsupported => {
if !profile
.unsupported_outputs
.contains(&ArtifactOutput::Motion)
{
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!(
"artifact profile speed policy {} requires Motion to be listed in unsupported outputs",
profile.speed_policy
),
));
}
}
ArtifactOutputSupport::Unlisted => {
unreachable!("motion support is always stored, derived, approximated, or unsupported")
}
}
Ok(())
}
fn canonical_body_order_key(body: &CelestialBody) -> (u8, &str, &str) {
match body {
CelestialBody::Sun => (0, "", ""),
CelestialBody::Moon => (1, "", ""),
CelestialBody::Mercury => (2, "", ""),
CelestialBody::Venus => (3, "", ""),
CelestialBody::Mars => (4, "", ""),
CelestialBody::Jupiter => (5, "", ""),
CelestialBody::Saturn => (6, "", ""),
CelestialBody::Uranus => (7, "", ""),
CelestialBody::Neptune => (8, "", ""),
CelestialBody::Pluto => (9, "", ""),
CelestialBody::MeanNode => (10, "", ""),
CelestialBody::TrueNode => (11, "", ""),
CelestialBody::MeanApogee => (12, "", ""),
CelestialBody::TrueApogee => (13, "", ""),
CelestialBody::MeanPerigee => (14, "", ""),
CelestialBody::TruePerigee => (15, "", ""),
CelestialBody::Ceres => (16, "", ""),
CelestialBody::Pallas => (17, "", ""),
CelestialBody::Juno => (18, "", ""),
CelestialBody::Vesta => (19, "", ""),
CelestialBody::Custom(custom) => (20, custom.catalog.as_str(), custom.designation.as_str()),
_ => (u8::MAX, "", ""),
}
}
fn validate_body_artifacts(bodies: &[BodyArtifact]) -> Result<(), CompressionError> {
for (index, body) in bodies.iter().enumerate() {
if bodies[..index].iter().any(|other| other.body == body.body) {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!("compressed artifact contains duplicate body entry {body:?}"),
));
}
}
let body_order = bodies
.iter()
.map(|body| body.body.clone())
.collect::<Vec<_>>();
validate_canonical_body_order("compressed artifact body entries", &body_order)?;
Ok(())
}
fn validate_canonical_body_order(
field: &str,
bodies: &[CelestialBody],
) -> Result<(), CompressionError> {
for pair in bodies.windows(2) {
if canonical_body_order_key(&pair[0]) > canonical_body_order_key(&pair[1]) {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!(
"{field} must be ordered canonically; found {:?} before {:?}",
pair[0], pair[1]
),
));
}
}
Ok(())
}
fn validate_unique_values<T: fmt::Display + Eq>(
label: &str,
values: &[T],
) -> Result<(), CompressionError> {
for (index, value) in values.iter().enumerate() {
if values[..index].contains(value) {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!("{label} contains duplicate {value} entry"),
));
}
}
Ok(())
}
fn validate_disjoint_values<T: fmt::Display + Eq>(
left_label: &str,
left: &[T],
right_label: &str,
right: &[T],
) -> Result<(), CompressionError> {
for value in left {
if right.contains(value) {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!("{left_label} and {right_label} both include {value}"),
));
}
}
Ok(())
}
fn encode_body(bytes: &mut Vec<u8>, body: &BodyArtifact) -> Result<(), CompressionError> {
encode_celestial_body(bytes, &body.body)?;
write_u16(bytes, body.segments.len() as u16);
for segment in &body.segments {
encode_segment(bytes, segment)?;
}
Ok(())
}
fn decode_body(cursor: &mut Cursor<'_>) -> Result<BodyArtifact, CompressionError> {
let body = decode_celestial_body(cursor)?;
let segment_count = cursor.read_u16()? as usize;
let mut segments = Vec::with_capacity(segment_count);
for _ in 0..segment_count {
segments.push(decode_segment(cursor)?);
}
Ok(BodyArtifact { body, segments })
}
fn encode_segment(bytes: &mut Vec<u8>, segment: &Segment) -> Result<(), CompressionError> {
validate_segment(segment)?;
encode_instant(bytes, segment.start);
encode_instant(bytes, segment.end);
write_u8(bytes, segment.channels.len() as u8);
for channel in &segment.channels {
encode_polynomial_channel(bytes, channel)?;
}
write_u8(bytes, segment.residual_channels.len() as u8);
for channel in &segment.residual_channels {
encode_polynomial_channel(bytes, channel)?;
}
Ok(())
}
fn decode_segment(cursor: &mut Cursor<'_>) -> Result<Segment, CompressionError> {
let start = decode_instant(cursor)?;
let end = decode_instant(cursor)?;
let channel_count = cursor.read_u8()? as usize;
let mut channels = Vec::with_capacity(channel_count);
for _ in 0..channel_count {
channels.push(decode_polynomial_channel(cursor)?);
}
let residual_channel_count = cursor.read_u8()? as usize;
let mut residual_channels = Vec::with_capacity(residual_channel_count);
for _ in 0..residual_channel_count {
residual_channels.push(decode_polynomial_channel(cursor)?);
}
let segment = Segment::with_residual_channels(start, end, channels, residual_channels);
validate_segment(&segment)?;
Ok(segment)
}
fn validate_segment(segment: &Segment) -> Result<(), CompressionError> {
if !segment.start.julian_day.days().is_finite() {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
"segment start must be finite",
));
}
if !segment.end.julian_day.days().is_finite() {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
"segment end must be finite",
));
}
if segment.end.julian_day.days() < segment.start.julian_day.days() {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
"segment end precedes segment start",
));
}
if segment.start.scale != segment.end.scale {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
"segment start and end use different time scales",
));
}
for channel in segment
.channels
.iter()
.chain(segment.residual_channels.iter())
{
channel.validate()?;
}
let stored_channels = segment
.channels
.iter()
.map(|channel| channel.kind)
.collect::<Vec<_>>();
let residual_channels = segment
.residual_channels
.iter()
.map(|channel| channel.kind)
.collect::<Vec<_>>();
validate_unique_values("segment stored channels", &stored_channels)?;
validate_unique_values("segment residual channels", &residual_channels)?;
validate_channel_kind_order("segment stored channels", &stored_channels)?;
validate_channel_kind_order("segment residual channels", &residual_channels)?;
for residual_channel in &segment.residual_channels {
if segment.channel(residual_channel.kind).is_none() {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!(
"segment residual channels require a matching stored channel for {:?}",
residual_channel.kind
),
));
}
}
Ok(())
}
fn validate_channel_kind_order(field: &str, kinds: &[ChannelKind]) -> Result<(), CompressionError> {
for pair in kinds.windows(2) {
if pair[0] as u8 > pair[1] as u8 {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!(
"{field} must be ordered by channel kind; found {:?} before {:?}",
pair[0], pair[1]
),
));
}
}
Ok(())
}
fn validate_body_segments(segments: &[Segment]) -> Result<(), CompressionError> {
let mut last_segment_end_by_scale: HashMap<TimeScale, f64> = HashMap::new();
for segment in segments {
let scale = segment.start.scale;
let start = segment.start.julian_day.days();
let end = segment.end.julian_day.days();
if let Some(previous_end) = last_segment_end_by_scale.get(&scale) {
if start < *previous_end {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!(
"body segments for {scale} must be ordered and non-overlapping; segment starting at {start} precedes the previous segment end {previous_end}"
),
));
}
}
last_segment_end_by_scale.insert(scale, end);
}
Ok(())
}
fn encode_instant(bytes: &mut Vec<u8>, instant: Instant) {
write_f64(bytes, instant.julian_day.days());
write_u8(bytes, encode_time_scale(instant.scale));
}
fn decode_instant(cursor: &mut Cursor<'_>) -> Result<Instant, CompressionError> {
let julian_day = cursor.read_f64()?;
let scale = decode_time_scale(cursor.read_u8()?)?;
Ok(Instant::new(JulianDay::from_days(julian_day), scale))
}
fn encode_polynomial_channel(
bytes: &mut Vec<u8>,
channel: &PolynomialChannel,
) -> Result<(), CompressionError> {
channel.validate()?;
write_u8(bytes, encode_channel_kind(channel.kind));
write_u8(bytes, channel.scale_exponent);
write_u8(bytes, channel.coefficients.len() as u8);
let scale = 10f64.powi(channel.scale_exponent as i32);
for coefficient in &channel.coefficients {
let scaled = (*coefficient * scale).round();
if !scaled.is_finite() || scaled < i64::MIN as f64 || scaled > i64::MAX as f64 {
return Err(CompressionError::new(
CompressionErrorKind::QuantizationOverflow,
"a polynomial coefficient exceeded the supported quantization range",
));
}
write_i64(bytes, scaled as i64);
}
Ok(())
}
fn decode_polynomial_channel(
cursor: &mut Cursor<'_>,
) -> Result<PolynomialChannel, CompressionError> {
let kind = decode_channel_kind(cursor.read_u8()?)?;
let scale_exponent = cursor.read_u8()?;
let coefficient_count = cursor.read_u8()? as usize;
let scale = 10f64.powi(scale_exponent as i32);
let mut coefficients = Vec::with_capacity(coefficient_count);
for _ in 0..coefficient_count {
coefficients.push(cursor.read_i64()? as f64 / scale);
}
Ok(PolynomialChannel::new(kind, scale_exponent, coefficients))
}
fn encode_celestial_body(
bytes: &mut Vec<u8>,
body: &CelestialBody,
) -> Result<(), CompressionError> {
match body {
CelestialBody::Sun => write_u8(bytes, 0),
CelestialBody::Moon => write_u8(bytes, 1),
CelestialBody::Mercury => write_u8(bytes, 2),
CelestialBody::Venus => write_u8(bytes, 3),
CelestialBody::Mars => write_u8(bytes, 4),
CelestialBody::Jupiter => write_u8(bytes, 5),
CelestialBody::Saturn => write_u8(bytes, 6),
CelestialBody::Uranus => write_u8(bytes, 7),
CelestialBody::Neptune => write_u8(bytes, 8),
CelestialBody::Pluto => write_u8(bytes, 9),
CelestialBody::MeanNode => write_u8(bytes, 10),
CelestialBody::TrueNode => write_u8(bytes, 11),
CelestialBody::MeanApogee => write_u8(bytes, 12),
CelestialBody::TrueApogee => write_u8(bytes, 13),
CelestialBody::MeanPerigee => write_u8(bytes, 20),
CelestialBody::TruePerigee => write_u8(bytes, 21),
CelestialBody::Ceres => write_u8(bytes, 14),
CelestialBody::Pallas => write_u8(bytes, 15),
CelestialBody::Juno => write_u8(bytes, 16),
CelestialBody::Vesta => write_u8(bytes, 17),
CelestialBody::Custom(custom) => {
write_u8(bytes, 255);
write_string(bytes, &custom.catalog);
write_string(bytes, &custom.designation);
}
_ => {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
"unsupported celestial body variant in compressed artifact",
))
}
}
Ok(())
}
fn decode_celestial_body(cursor: &mut Cursor<'_>) -> Result<CelestialBody, CompressionError> {
Ok(match cursor.read_u8()? {
0 => CelestialBody::Sun,
1 => CelestialBody::Moon,
2 => CelestialBody::Mercury,
3 => CelestialBody::Venus,
4 => CelestialBody::Mars,
5 => CelestialBody::Jupiter,
6 => CelestialBody::Saturn,
7 => CelestialBody::Uranus,
8 => CelestialBody::Neptune,
9 => CelestialBody::Pluto,
10 => CelestialBody::MeanNode,
11 => CelestialBody::TrueNode,
12 => CelestialBody::MeanApogee,
13 => CelestialBody::TrueApogee,
14 => CelestialBody::Ceres,
15 => CelestialBody::Pallas,
16 => CelestialBody::Juno,
17 => CelestialBody::Vesta,
20 => CelestialBody::MeanPerigee,
21 => CelestialBody::TruePerigee,
255 => {
let catalog = cursor.read_string()?;
let designation = cursor.read_string()?;
CelestialBody::Custom(CustomBodyId::new(catalog, designation))
}
other => {
return Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!("unknown body tag {other}"),
))
}
})
}
fn encode_channel_kind(kind: ChannelKind) -> u8 {
match kind {
ChannelKind::Longitude => 0,
ChannelKind::Latitude => 1,
ChannelKind::DistanceAu => 2,
}
}
fn decode_channel_kind(tag: u8) -> Result<ChannelKind, CompressionError> {
match tag {
0 => Ok(ChannelKind::Longitude),
1 => Ok(ChannelKind::Latitude),
2 => Ok(ChannelKind::DistanceAu),
other => Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!("unknown channel kind tag {other}"),
)),
}
}
fn encode_artifact_output(output: ArtifactOutput) -> u8 {
match output {
ArtifactOutput::EclipticCoordinates => 0,
ArtifactOutput::EquatorialCoordinates => 1,
ArtifactOutput::ApparentCorrections => 2,
ArtifactOutput::TopocentricCoordinates => 3,
ArtifactOutput::SiderealCoordinates => 4,
ArtifactOutput::Motion => 5,
}
}
fn decode_artifact_output(tag: u8) -> Result<ArtifactOutput, CompressionError> {
match tag {
0 => Ok(ArtifactOutput::EclipticCoordinates),
1 => Ok(ArtifactOutput::EquatorialCoordinates),
2 => Ok(ArtifactOutput::ApparentCorrections),
3 => Ok(ArtifactOutput::TopocentricCoordinates),
4 => Ok(ArtifactOutput::SiderealCoordinates),
5 => Ok(ArtifactOutput::Motion),
other => Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!("unknown artifact output tag {other}"),
)),
}
}
fn encode_speed_policy(policy: SpeedPolicy) -> u8 {
match policy {
SpeedPolicy::Unsupported => 0,
SpeedPolicy::Stored => 1,
SpeedPolicy::FittedDerivative => 2,
SpeedPolicy::NumericalDifference => 3,
}
}
fn decode_speed_policy(tag: u8) -> Result<SpeedPolicy, CompressionError> {
match tag {
0 => Ok(SpeedPolicy::Unsupported),
1 => Ok(SpeedPolicy::Stored),
2 => Ok(SpeedPolicy::FittedDerivative),
3 => Ok(SpeedPolicy::NumericalDifference),
other => Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!("unknown speed policy tag {other}"),
)),
}
}
fn encode_time_scale(scale: TimeScale) -> u8 {
match scale {
TimeScale::Utc => 0,
TimeScale::Ut1 => 1,
TimeScale::Tt => 2,
TimeScale::Tdb => 3,
_ => 255,
}
}
fn decode_time_scale(tag: u8) -> Result<TimeScale, CompressionError> {
match tag {
0 => Ok(TimeScale::Utc),
1 => Ok(TimeScale::Ut1),
2 => Ok(TimeScale::Tt),
3 => Ok(TimeScale::Tdb),
other => Err(CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!("unknown time-scale tag {other}"),
)),
}
}
fn write_string(bytes: &mut Vec<u8>, value: &str) {
write_u32(bytes, value.len() as u32);
bytes.extend_from_slice(value.as_bytes());
}
fn write_u8(bytes: &mut Vec<u8>, value: u8) {
bytes.push(value);
}
fn write_u16(bytes: &mut Vec<u8>, value: u16) {
bytes.extend_from_slice(&value.to_le_bytes());
}
fn write_u32(bytes: &mut Vec<u8>, value: u32) {
bytes.extend_from_slice(&value.to_le_bytes());
}
fn write_u64(bytes: &mut Vec<u8>, value: u64) {
bytes.extend_from_slice(&value.to_le_bytes());
}
fn write_i64(bytes: &mut Vec<u8>, value: i64) {
bytes.extend_from_slice(&value.to_le_bytes());
}
fn write_f64(bytes: &mut Vec<u8>, value: f64) {
bytes.extend_from_slice(&value.to_le_bytes());
}
fn fnv1a64(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf29ce484222325;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
struct Cursor<'a> {
bytes: &'a [u8],
offset: usize,
}
impl<'a> Cursor<'a> {
fn new(bytes: &'a [u8]) -> Self {
Self { bytes, offset: 0 }
}
fn remaining(&self) -> &'a [u8] {
&self.bytes[self.offset..]
}
fn is_finished(&self) -> bool {
self.offset >= self.bytes.len()
}
fn read_array<const N: usize>(&mut self) -> Result<[u8; N], CompressionError> {
let bytes = self.read_exact(N)?;
let mut array = [0u8; N];
array.copy_from_slice(bytes);
Ok(array)
}
fn read_u8(&mut self) -> Result<u8, CompressionError> {
Ok(self.read_exact(1)?[0])
}
fn read_u16(&mut self) -> Result<u16, CompressionError> {
Ok(u16::from_le_bytes(self.read_array()?))
}
fn read_u32(&mut self) -> Result<u32, CompressionError> {
Ok(u32::from_le_bytes(self.read_array()?))
}
fn read_u64(&mut self) -> Result<u64, CompressionError> {
Ok(u64::from_le_bytes(self.read_array()?))
}
fn read_i64(&mut self) -> Result<i64, CompressionError> {
Ok(i64::from_le_bytes(self.read_array()?))
}
fn read_f64(&mut self) -> Result<f64, CompressionError> {
Ok(f64::from_le_bytes(self.read_array()?))
}
fn read_string(&mut self) -> Result<String, CompressionError> {
let len = self.read_u32()? as usize;
let bytes = self.read_exact(len)?;
String::from_utf8(bytes.to_vec()).map_err(|error| {
CompressionError::new(
CompressionErrorKind::InvalidFormat,
format!("compressed artifact string was not valid UTF-8: {error}"),
)
})
}
fn read_exact(&mut self, len: usize) -> Result<&'a [u8], CompressionError> {
let end = self.offset.checked_add(len).ok_or_else(|| {
CompressionError::new(
CompressionErrorKind::InvalidFormat,
"compressed artifact length overflowed",
)
})?;
if end > self.bytes.len() {
return Err(CompressionError::new(
CompressionErrorKind::Truncated,
"compressed artifact ended unexpectedly",
));
}
let slice = &self.bytes[self.offset..end];
self.offset = end;
Ok(slice)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_decode_roundtrip_preserves_structure() {
let artifact = CompressedArtifact::new(
ArtifactHeader::new("demo", "unit test fixture"),
vec![BodyArtifact::new(
CelestialBody::Sun,
vec![Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(10.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 10.0, 20.0),
PolynomialChannel::linear(ChannelKind::Latitude, 9, -1.0, 1.0),
PolynomialChannel::linear(ChannelKind::DistanceAu, 12, 1.0, 2.0),
],
)],
)],
);
let encoded = artifact.encode().expect("artifact should encode");
let decoded = CompressedArtifact::decode(&encoded).expect("artifact should decode");
assert_eq!(decoded.header.version, ARTIFACT_VERSION);
assert_eq!(decoded.header.generation_label, "demo");
assert_eq!(decoded.header.source, "unit test fixture");
assert_eq!(decoded.header.endian_policy, EndianPolicy::LittleEndian);
assert_eq!(
decoded.header.profile,
ArtifactProfile::packaged_ecliptic_longitude_latitude_distance_with_derived_equatorial(
)
);
assert_eq!(
decoded.header.profile.stored_channels,
vec![
ChannelKind::Longitude,
ChannelKind::Latitude,
ChannelKind::DistanceAu
]
);
assert_eq!(
decoded.header.profile.speed_policy,
SpeedPolicy::Unsupported
);
assert!(decoded
.header
.profile
.derived_outputs
.contains(&ArtifactOutput::EclipticCoordinates));
assert!(decoded
.header
.profile
.unsupported_outputs
.contains(&ArtifactOutput::Motion));
assert_eq!(decoded.bodies.len(), 1);
assert_eq!(decoded.bodies[0].body, CelestialBody::Sun);
assert_eq!(
decoded.checksum,
artifact.checksum().expect("checksum should compute")
);
}
#[test]
fn decode_rejects_checksum_corruption() {
let artifact = CompressedArtifact::new(
ArtifactHeader::new("demo", "tamper check fixture"),
vec![BodyArtifact::new(
CelestialBody::Sun,
vec![Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
vec![PolynomialChannel::linear(
ChannelKind::Longitude,
9,
10.0,
11.0,
)],
)],
)],
);
let mut encoded = artifact.encode().expect("artifact should encode");
let last_index = encoded.len() - 1;
encoded[last_index] ^= 0x01;
let error =
CompressedArtifact::decode(&encoded).expect_err("tampered artifact should fail");
assert_eq!(error.kind, CompressionErrorKind::ChecksumMismatch);
}
#[test]
fn polynomial_channel_quadratic_interpolates_start_midpoint_and_end() {
let channel =
PolynomialChannel::quadratic(ChannelKind::Longitude, 9, 10.0, 16.0, 20.0, 0.5);
assert!((channel.evaluate(0.0) - 10.0).abs() < 1e-12);
assert!((channel.evaluate(0.5) - 16.0).abs() < 1e-12);
assert!((channel.evaluate(1.0) - 20.0).abs() < 1e-12);
}
#[test]
fn explicit_profile_roundtrip_preserves_stored_derived_and_unsupported_outputs() {
let profile = ArtifactProfile::new(
vec![
ChannelKind::Longitude,
ChannelKind::Latitude,
ChannelKind::DistanceAu,
],
vec![ArtifactOutput::EclipticCoordinates, ArtifactOutput::Motion],
vec![
ArtifactOutput::EquatorialCoordinates,
ArtifactOutput::ApparentCorrections,
ArtifactOutput::TopocentricCoordinates,
ArtifactOutput::SiderealCoordinates,
],
SpeedPolicy::FittedDerivative,
);
let artifact = CompressedArtifact::new(
ArtifactHeader::with_profile("profile demo", "unit test profile", profile.clone()),
vec![BodyArtifact::new(
CelestialBody::Sun,
vec![Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 10.0, 11.0),
PolynomialChannel::linear(ChannelKind::Latitude, 9, 1.0, 2.0),
PolynomialChannel::linear(ChannelKind::DistanceAu, 12, 0.1, 0.2),
],
)],
)],
);
let decoded = CompressedArtifact::decode(
&artifact
.encode()
.expect("artifact should encode with profile"),
)
.expect("artifact should decode with profile");
assert_eq!(decoded.header.profile, profile);
assert_eq!(decoded.header.endian_policy, EndianPolicy::LittleEndian);
}
#[test]
fn artifact_profile_reports_output_support_statuses() {
let profile = ArtifactProfile::ecliptic_longitude_latitude_distance();
let explicit_profile =
ArtifactProfile::packaged_ecliptic_longitude_latitude_distance_with_derived_equatorial(
);
assert_eq!(profile, explicit_profile);
assert_eq!(profile.summary_line(), explicit_profile.summary_line());
assert_eq!(
ArtifactProfile::packaged_ecliptic_longitude_latitude_distance_with_derived_equatorial(
),
explicit_profile
);
assert_eq!(
profile.output_support(ArtifactOutput::EclipticCoordinates),
ArtifactOutputSupport::Derived
);
assert_eq!(
profile.output_support(ArtifactOutput::EquatorialCoordinates),
ArtifactOutputSupport::Derived
);
assert_eq!(
profile.output_support(ArtifactOutput::Motion),
ArtifactOutputSupport::Unsupported
);
assert_eq!(
profile.output_support(ArtifactOutput::SiderealCoordinates),
ArtifactOutputSupport::Unsupported
);
assert_eq!(
profile.speed_policy.motion_output_support(),
ArtifactOutputSupport::Unsupported
);
assert_eq!(
profile.motion_output_support(),
ArtifactOutputSupport::Unsupported
);
assert!(profile.supports_output(ArtifactOutput::EclipticCoordinates));
assert!(profile.supports_output(ArtifactOutput::EquatorialCoordinates));
assert!(!profile.is_unsupported_output(ArtifactOutput::EquatorialCoordinates));
assert!(profile.is_unsupported_output(ArtifactOutput::SiderealCoordinates));
let unlisted_summary_profile = ArtifactProfile::new(
vec![
ChannelKind::Longitude,
ChannelKind::Latitude,
ChannelKind::DistanceAu,
],
vec![ArtifactOutput::EclipticCoordinates],
vec![
ArtifactOutput::ApparentCorrections,
ArtifactOutput::TopocentricCoordinates,
ArtifactOutput::SiderealCoordinates,
ArtifactOutput::Motion,
],
SpeedPolicy::Unsupported,
);
let error = unlisted_summary_profile
.validate()
.expect_err("unlisted profile output should be rejected");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
assert_eq!(
error.message,
"artifact profile output EquatorialCoordinates must be explicitly listed as stored, derived, approximated, or unsupported"
);
assert_eq!(
SpeedPolicy::Stored.motion_output_support(),
ArtifactOutputSupport::Stored
);
assert_eq!(
SpeedPolicy::FittedDerivative.motion_output_support(),
ArtifactOutputSupport::Derived
);
assert_eq!(
SpeedPolicy::NumericalDifference.motion_output_support(),
ArtifactOutputSupport::Approximated
);
let numerical_difference_profile = ArtifactProfile::new(
vec![ChannelKind::Longitude],
Vec::new(),
Vec::new(),
SpeedPolicy::NumericalDifference,
);
assert_eq!(
numerical_difference_profile.output_support(ArtifactOutput::Motion),
ArtifactOutputSupport::Approximated
);
assert_eq!(
numerical_difference_profile.motion_output_support(),
ArtifactOutputSupport::Approximated
);
assert!(numerical_difference_profile.supports_output(ArtifactOutput::Motion));
assert!(!numerical_difference_profile.is_unsupported_output(ArtifactOutput::Motion));
let unlisted_profile = ArtifactProfile::new(
vec![ChannelKind::Longitude],
Vec::new(),
Vec::new(),
SpeedPolicy::Unsupported,
);
assert_eq!(
unlisted_profile.output_support(ArtifactOutput::Motion),
ArtifactOutputSupport::Unsupported
);
assert_eq!(ArtifactOutputSupport::Approximated.label(), "approximated");
assert_eq!(
ArtifactOutputSupport::Approximated.to_string(),
"approximated"
);
assert!(!unlisted_profile.supports_output(ArtifactOutput::Motion));
assert!(unlisted_profile.is_unsupported_output(ArtifactOutput::Motion));
}
#[test]
fn artifact_residual_body_coverage_summary_tracks_artifact_residual_bodies() {
let artifact = CompressedArtifact::new(
ArtifactHeader::new("residual coverage demo", "unit test residual coverage"),
vec![
BodyArtifact::new(
CelestialBody::Sun,
vec![Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
vec![PolynomialChannel::linear(
ChannelKind::Longitude,
9,
10.0,
11.0,
)],
)],
),
BodyArtifact::new(
CelestialBody::Moon,
vec![Segment::with_residual_channels(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
vec![PolynomialChannel::linear(
ChannelKind::Longitude,
9,
20.0,
21.0,
)],
vec![PolynomialChannel::linear(
ChannelKind::Longitude,
9,
0.1,
0.2,
)],
)],
),
],
);
let summary = artifact.residual_body_coverage_summary();
assert_eq!(summary.body_count, 1);
assert_eq!(summary.bodies, vec![CelestialBody::Moon]);
assert_eq!(summary.summary_line(), "residual bodies: Moon");
assert_eq!(
summary
.validated_summary_line(&artifact)
.expect("residual body coverage summary should validate"),
"residual bodies: Moon"
);
assert_eq!(
summary.summary_line_with_body_count(),
"residual bodies: Moon; applies to 1 bundled body"
);
assert_eq!(
summary
.validated_summary_line_with_body_count(&artifact)
.expect("residual body coverage summary should validate"),
"residual bodies: Moon; applies to 1 bundled body"
);
assert_eq!(summary.to_string(), summary.summary_line());
summary
.validate(&artifact)
.expect("residual body coverage should match the artifact");
let mut drifted = summary.clone();
drifted.body_count += 1;
let count_error = drifted
.validate(&artifact)
.expect_err("drifted residual body coverage count should be rejected");
assert_eq!(count_error.kind, CompressionErrorKind::InvalidFormat);
assert!(format!("{count_error}").contains("body count does not match the body list"));
let validated_error = drifted
.validated_summary_line(&artifact)
.expect_err("drifted residual body coverage validated line should be rejected");
assert_eq!(validated_error.kind, CompressionErrorKind::InvalidFormat);
let mut drifted = summary.clone();
drifted.bodies = vec![CelestialBody::Sun];
let error = drifted
.validate(&artifact)
.expect_err("drifted residual body coverage should be rejected");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
assert!(format!("{error}").contains("residual-body coverage body list"));
let duplicate_residual_segment = Segment::with_residual_channels(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
vec![PolynomialChannel::linear(
ChannelKind::Longitude,
9,
20.0,
21.0,
)],
vec![PolynomialChannel::linear(
ChannelKind::Longitude,
9,
0.1,
0.2,
)],
);
let duplicate_residual_artifact = CompressedArtifact::new(
ArtifactHeader::new(
"duplicate residual coverage demo",
"unit test duplicate residual coverage",
),
vec![
BodyArtifact::new(
CelestialBody::Moon,
vec![duplicate_residual_segment.clone()],
),
BodyArtifact::new(CelestialBody::Moon, vec![duplicate_residual_segment]),
],
);
let duplicate_summary = duplicate_residual_artifact.residual_body_coverage_summary();
let duplicate_error = duplicate_summary
.validate(&duplicate_residual_artifact)
.expect_err("duplicate residual body coverage should be rejected");
assert_eq!(duplicate_error.kind, CompressionErrorKind::InvalidFormat);
assert!(format!("{duplicate_error}")
.contains("artifact residual-body coverage bodies contains duplicate Moon entry"));
}
#[test]
fn artifact_validation_helpers_reject_invalid_profiles_and_segments() {
let invalid_profile = ArtifactProfile::new(
vec![ChannelKind::Longitude, ChannelKind::Longitude],
vec![ArtifactOutput::EclipticCoordinates],
vec![ArtifactOutput::EquatorialCoordinates],
SpeedPolicy::Unsupported,
);
let profile_error = invalid_profile
.validate()
.expect_err("duplicate profile entries should be rejected");
assert_eq!(profile_error.kind, CompressionErrorKind::InvalidFormat);
let out_of_order_stored_profile = ArtifactProfile::new(
vec![
ChannelKind::Longitude,
ChannelKind::DistanceAu,
ChannelKind::Latitude,
],
vec![ArtifactOutput::EclipticCoordinates],
vec![ArtifactOutput::Motion],
SpeedPolicy::Unsupported,
);
let out_of_order_stored_error = out_of_order_stored_profile
.validate()
.expect_err("stored channels should be ordered canonically");
assert_eq!(
out_of_order_stored_error.kind,
CompressionErrorKind::InvalidFormat
);
assert!(format!("{out_of_order_stored_error}")
.contains("artifact profile stored channels must be ordered by channel kind"));
let out_of_order_output_profile = ArtifactProfile::new(
vec![
ChannelKind::Longitude,
ChannelKind::Latitude,
ChannelKind::DistanceAu,
],
vec![
ArtifactOutput::EquatorialCoordinates,
ArtifactOutput::EclipticCoordinates,
],
vec![ArtifactOutput::Motion],
SpeedPolicy::Unsupported,
);
let out_of_order_output_error = out_of_order_output_profile
.validate()
.expect_err("derived outputs should be ordered canonically");
assert_eq!(
out_of_order_output_error.kind,
CompressionErrorKind::InvalidFormat
);
assert!(format!("{out_of_order_output_error}")
.contains("artifact profile derived outputs must be ordered by artifact output kind"));
let stored_motion_profile = ArtifactProfile::new(
vec![
ChannelKind::Longitude,
ChannelKind::Latitude,
ChannelKind::DistanceAu,
],
vec![ArtifactOutput::EclipticCoordinates],
vec![
ArtifactOutput::EquatorialCoordinates,
ArtifactOutput::ApparentCorrections,
ArtifactOutput::TopocentricCoordinates,
ArtifactOutput::SiderealCoordinates,
],
SpeedPolicy::Stored,
);
assert_eq!(
stored_motion_profile.output_support(ArtifactOutput::Motion),
ArtifactOutputSupport::Stored
);
assert!(stored_motion_profile.supports_output(ArtifactOutput::Motion));
assert_eq!(stored_motion_profile.validate(), Ok(()));
let motion_policy_mismatch = ArtifactProfile::new(
vec![
ChannelKind::Longitude,
ChannelKind::Latitude,
ChannelKind::DistanceAu,
],
vec![ArtifactOutput::EclipticCoordinates, ArtifactOutput::Motion],
Vec::new(),
SpeedPolicy::Stored,
);
let motion_policy_error = motion_policy_mismatch
.validate()
.expect_err("stored motion support should keep Motion out of derived outputs");
assert_eq!(
motion_policy_error.kind,
CompressionErrorKind::InvalidFormat
);
let unsupported_motion_mismatch = ArtifactProfile::new(
vec![ChannelKind::Longitude],
vec![ArtifactOutput::EclipticCoordinates],
vec![
ArtifactOutput::EquatorialCoordinates,
ArtifactOutput::ApparentCorrections,
ArtifactOutput::TopocentricCoordinates,
ArtifactOutput::SiderealCoordinates,
],
SpeedPolicy::Unsupported,
);
let unsupported_motion_error = unsupported_motion_mismatch
.validate()
.expect_err("unsupported motion policy should require Motion in unsupported outputs");
assert_eq!(
unsupported_motion_error.kind,
CompressionErrorKind::InvalidFormat
);
let out_of_order_unsupported_profile = ArtifactProfile::new(
vec![
ChannelKind::Longitude,
ChannelKind::Latitude,
ChannelKind::DistanceAu,
],
vec![ArtifactOutput::EclipticCoordinates],
vec![ArtifactOutput::Motion, ArtifactOutput::SiderealCoordinates],
SpeedPolicy::Unsupported,
);
let out_of_order_unsupported_error = out_of_order_unsupported_profile
.validate()
.expect_err("unsupported outputs should be ordered canonically");
assert_eq!(
out_of_order_unsupported_error.kind,
CompressionErrorKind::InvalidFormat
);
assert!(format!("{out_of_order_unsupported_error}").contains(
"artifact profile unsupported outputs must be ordered by artifact output kind"
));
let derived_coordinate_channel_mismatch = ArtifactProfile::new(
vec![ChannelKind::Longitude, ChannelKind::Latitude],
vec![
ArtifactOutput::EclipticCoordinates,
ArtifactOutput::EquatorialCoordinates,
],
vec![
ArtifactOutput::ApparentCorrections,
ArtifactOutput::TopocentricCoordinates,
ArtifactOutput::SiderealCoordinates,
ArtifactOutput::Motion,
],
SpeedPolicy::Unsupported,
);
let derived_coordinate_channel_error = derived_coordinate_channel_mismatch
.validate()
.expect_err(
"derived coordinate outputs should require longitude, latitude, and distance channels",
);
assert_eq!(
derived_coordinate_channel_error.kind,
CompressionErrorKind::InvalidFormat
);
assert!(format!("{derived_coordinate_channel_error}").contains(
"derived coordinate outputs require Longitude, Latitude, and DistanceAu in stored channels"
));
let derived_coordinate_channel_missing_latitude = ArtifactProfile::new(
vec![ChannelKind::Longitude, ChannelKind::DistanceAu],
vec![
ArtifactOutput::EclipticCoordinates,
ArtifactOutput::EquatorialCoordinates,
],
vec![
ArtifactOutput::ApparentCorrections,
ArtifactOutput::TopocentricCoordinates,
ArtifactOutput::SiderealCoordinates,
ArtifactOutput::Motion,
],
SpeedPolicy::Unsupported,
);
let derived_coordinate_channel_missing_latitude_error =
derived_coordinate_channel_missing_latitude
.validate()
.expect_err("derived coordinate outputs should require latitude as well");
assert_eq!(
derived_coordinate_channel_missing_latitude_error.kind,
CompressionErrorKind::InvalidFormat
);
assert!(format!("{derived_coordinate_channel_missing_latitude_error}").contains(
"derived coordinate outputs require Longitude, Latitude, and DistanceAu in stored channels"
));
let invalid_segment_body = BodyArtifact::new(
CelestialBody::Moon,
vec![Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 0.0, 1.0),
PolynomialChannel::linear(ChannelKind::Longitude, 9, 2.0, 3.0),
PolynomialChannel::linear(ChannelKind::Latitude, 9, 4.0, 5.0),
],
)],
);
let segment_error = invalid_segment_body
.validate()
.expect_err("duplicate segment channels should be rejected");
assert_eq!(segment_error.kind, CompressionErrorKind::InvalidFormat);
let invalid_artifact = CompressedArtifact::new(
ArtifactHeader::new("duplicate body demo", "unit test duplicate body validation"),
vec![
BodyArtifact::new(
CelestialBody::Sun,
vec![Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
vec![PolynomialChannel::linear(
ChannelKind::Longitude,
9,
10.0,
11.0,
)],
)],
),
BodyArtifact::new(
CelestialBody::Sun,
vec![Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(2.0), TimeScale::Tt),
vec![PolynomialChannel::linear(
ChannelKind::Longitude,
9,
12.0,
13.0,
)],
)],
),
],
);
let artifact_error = invalid_artifact
.validate()
.expect_err("duplicate bodies should be rejected");
assert_eq!(artifact_error.kind, CompressionErrorKind::InvalidFormat);
let blank_header = ArtifactHeader::with_profile(
" ",
"unit test blank header",
ArtifactProfile::ecliptic_longitude_latitude_distance(),
);
let blank_header_error = blank_header
.validate()
.expect_err("blank generation labels should be rejected");
assert_eq!(blank_header_error.kind, CompressionErrorKind::InvalidFormat);
let blank_source_header = ArtifactHeader::with_profile(
"blank source demo",
"",
ArtifactProfile::ecliptic_longitude_latitude_distance(),
);
let blank_source_error = blank_source_header
.validate()
.expect_err("blank sources should be rejected");
assert_eq!(blank_source_error.kind, CompressionErrorKind::InvalidFormat);
let padded_header = ArtifactHeader::with_profile(
" padded demo ",
"unit test padded header",
ArtifactProfile::ecliptic_longitude_latitude_distance(),
);
let padded_header_error = padded_header
.validate()
.expect_err("padded generation labels should be rejected");
assert_eq!(
padded_header_error.kind,
CompressionErrorKind::InvalidFormat
);
let padded_source_header = ArtifactHeader::with_profile(
"padded source demo",
" unit test padded source ",
ArtifactProfile::ecliptic_longitude_latitude_distance(),
);
let padded_source_error = padded_source_header
.validate()
.expect_err("padded sources should be rejected");
assert_eq!(
padded_source_error.kind,
CompressionErrorKind::InvalidFormat
);
let blank_header_artifact = CompressedArtifact::new(
ArtifactHeader::with_profile(
" ",
"unit test blank header artifact",
ArtifactProfile::ecliptic_longitude_latitude_distance(),
),
Vec::new(),
);
let blank_header_artifact_error = blank_header_artifact
.validate()
.expect_err("artifact validation should reject blank header metadata");
assert_eq!(
blank_header_artifact_error.kind,
CompressionErrorKind::InvalidFormat
);
let blank_header_encode_error = CompressedArtifact::new(
ArtifactHeader::with_profile(
" ",
"unit test blank header encode",
ArtifactProfile::ecliptic_longitude_latitude_distance(),
),
Vec::new(),
)
.encode()
.expect_err("artifact encoding should reject blank header metadata");
assert_eq!(
blank_header_encode_error.kind,
CompressionErrorKind::InvalidFormat
);
}
#[test]
fn artifact_encoding_rejects_duplicate_profile_entries() {
let artifact = CompressedArtifact::new(
ArtifactHeader::with_profile(
"duplicate profile demo",
"unit test duplicate profile entries",
ArtifactProfile::new(
vec![ChannelKind::Longitude, ChannelKind::Longitude],
vec![ArtifactOutput::EclipticCoordinates],
vec![ArtifactOutput::EquatorialCoordinates],
SpeedPolicy::Unsupported,
),
),
Vec::new(),
);
let error = artifact
.encode()
.expect_err("duplicate profile entries should be rejected");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
}
#[test]
fn artifact_encoding_rejects_duplicate_segment_channels() {
let artifact = CompressedArtifact::new(
ArtifactHeader::new(
"duplicate segment demo",
"unit test duplicate segment channels",
),
vec![BodyArtifact::new(
CelestialBody::Moon,
vec![Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 0.0, 1.0),
PolynomialChannel::linear(ChannelKind::Longitude, 9, 2.0, 3.0),
PolynomialChannel::linear(ChannelKind::Latitude, 9, 4.0, 5.0),
PolynomialChannel::linear(ChannelKind::DistanceAu, 12, 0.1, 0.2),
],
)],
)],
);
let error = artifact
.encode()
.expect_err("duplicate segment channels should be rejected");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
}
#[test]
fn artifact_encoding_rejects_mismatched_segment_scales() {
let artifact = CompressedArtifact::new(
ArtifactHeader::new(
"mismatched segment scales demo",
"unit test mismatched segment scales",
),
vec![BodyArtifact::new(
CelestialBody::Moon,
vec![Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tdb),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 0.0, 1.0),
PolynomialChannel::linear(ChannelKind::Latitude, 9, 2.0, 3.0),
PolynomialChannel::linear(ChannelKind::DistanceAu, 12, 0.1, 0.2),
],
)],
)],
);
let error = artifact
.encode()
.expect_err("mismatched segment scales should be rejected");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
}
#[test]
fn artifact_decoding_rejects_duplicate_body_entries() {
let profile = ArtifactProfile::ecliptic_longitude_latitude_distance();
let mut payload = Vec::new();
write_string(&mut payload, "duplicate body decode demo");
write_string(&mut payload, "unit test duplicate body decode");
write_u8(
&mut payload,
encode_endian_policy(EndianPolicy::LittleEndian),
);
encode_artifact_profile(&mut payload, &profile).expect("profile should encode");
write_u16(&mut payload, 2);
encode_celestial_body(&mut payload, &CelestialBody::Sun).expect("Sun should encode");
write_u16(&mut payload, 0);
encode_celestial_body(&mut payload, &CelestialBody::Sun).expect("Sun should encode");
write_u16(&mut payload, 0);
let checksum = fnv1a64(&payload);
let mut bytes = Vec::new();
bytes.extend_from_slice(&ARTIFACT_MAGIC);
write_u16(&mut bytes, ARTIFACT_VERSION);
write_u64(&mut bytes, checksum);
bytes.extend_from_slice(&payload);
let error = CompressedArtifact::decode(&bytes)
.expect_err("duplicate body entries should be rejected");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
}
#[test]
fn residual_channels_are_applied_during_lookup() {
let artifact = CompressedArtifact::new(
ArtifactHeader::new("residual demo", "unit test residual channels"),
vec![BodyArtifact::new(
CelestialBody::Sun,
vec![Segment::with_residual_channels(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 10.0, 11.0),
PolynomialChannel::new(ChannelKind::Latitude, 9, vec![1.0]),
PolynomialChannel::new(ChannelKind::DistanceAu, 12, vec![2.0]),
],
vec![
PolynomialChannel::new(ChannelKind::Longitude, 9, vec![0.25]),
PolynomialChannel::new(ChannelKind::Latitude, 9, vec![0.0]),
PolynomialChannel::new(ChannelKind::DistanceAu, 12, vec![-0.1]),
],
)],
)],
);
let lookup = artifact
.lookup_ecliptic(
&CelestialBody::Sun,
Instant::new(pleiades_types::JulianDay::from_days(0.5), TimeScale::Tt),
)
.expect("residual-corrected lookup should succeed");
assert!((lookup.longitude.degrees() - 10.75).abs() < 1e-12);
assert!((lookup.latitude.degrees() - 1.0).abs() < 1e-12);
assert!((lookup.distance_au.unwrap() - 1.9).abs() < 1e-12);
}
#[test]
fn residual_channels_roundtrip_through_the_codec() {
let segment = Segment::with_residual_channels(
Instant::new(pleiades_types::JulianDay::from_days(10.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(11.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 20.0, 22.0),
PolynomialChannel::new(ChannelKind::Latitude, 9, vec![3.0]),
PolynomialChannel::new(ChannelKind::DistanceAu, 12, vec![4.0]),
],
vec![
PolynomialChannel::new(ChannelKind::Longitude, 9, vec![0.5]),
PolynomialChannel::new(ChannelKind::Latitude, 9, vec![-0.25]),
PolynomialChannel::new(ChannelKind::DistanceAu, 12, vec![0.125]),
],
);
segment
.validate()
.expect("segment metadata should validate");
let artifact = CompressedArtifact::new(
ArtifactHeader::new("residual roundtrip demo", "unit test residual roundtrip"),
vec![BodyArtifact::new(CelestialBody::Sun, vec![segment.clone()])],
);
let decoded = CompressedArtifact::decode(
&artifact
.encode()
.expect("artifact with residual channels should encode"),
)
.expect("artifact with residual channels should decode");
assert_eq!(decoded.bodies[0].segments[0], segment);
}
#[test]
fn compressed_artifact_summary_line_reports_residual_segments() {
let residual_segment = Segment::with_residual_channels(
Instant::new(pleiades_types::JulianDay::from_days(10.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(11.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 20.0, 22.0),
PolynomialChannel::new(ChannelKind::Latitude, 9, vec![3.0]),
PolynomialChannel::new(ChannelKind::DistanceAu, 12, vec![4.0]),
],
vec![
PolynomialChannel::new(ChannelKind::Longitude, 9, vec![0.5]),
PolynomialChannel::new(ChannelKind::Latitude, 9, vec![-0.25]),
PolynomialChannel::new(ChannelKind::DistanceAu, 12, vec![0.125]),
],
);
let artifact = CompressedArtifact::new(
ArtifactHeader::new("residual summary demo", "unit test residual summary"),
vec![
BodyArtifact::new(
CelestialBody::Sun,
vec![Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
vec![PolynomialChannel::linear(
ChannelKind::Longitude,
9,
1.0,
2.0,
)],
)],
),
BodyArtifact::new(CelestialBody::Moon, vec![residual_segment]),
],
);
assert_eq!(artifact.segment_count(), 2);
assert_eq!(artifact.residual_segment_count(), 1);
assert_eq!(artifact.residual_bodies(), vec![CelestialBody::Moon]);
assert_eq!(
artifact.summary_line(),
"bodies: 2; segments: 2; residual-bearing segments: 1; residual-bearing bodies: Moon"
);
assert_eq!(artifact.to_string(), artifact.summary_line());
}
#[test]
fn segment_summary_line_reports_stored_and_residual_channels() {
let segment = Segment::with_residual_channels(
Instant::new(pleiades_types::JulianDay::from_days(10.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(11.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 20.0, 22.0),
PolynomialChannel::new(ChannelKind::Latitude, 9, vec![3.0]),
PolynomialChannel::new(ChannelKind::DistanceAu, 12, vec![4.0]),
],
vec![
PolynomialChannel::new(ChannelKind::Longitude, 9, vec![0.5]),
PolynomialChannel::new(ChannelKind::Latitude, 9, vec![-0.25]),
PolynomialChannel::new(ChannelKind::DistanceAu, 12, vec![0.125]),
],
);
let expected = format!(
"start: {}; end: {}; stored channels: [Longitude, Latitude, DistanceAu]; residual channels: [Longitude, Latitude, DistanceAu]",
segment.start, segment.end
);
assert_eq!(segment.summary_line(), expected);
assert_eq!(segment.to_string(), expected);
}
#[test]
fn body_artifact_summary_line_reports_body_and_residual_segments() {
let body_artifact = BodyArtifact::new(
CelestialBody::Sun,
vec![
Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 10.0, 11.0),
PolynomialChannel::new(ChannelKind::Latitude, 9, vec![1.0]),
PolynomialChannel::new(ChannelKind::DistanceAu, 12, vec![2.0]),
],
),
Segment::with_residual_channels(
Instant::new(pleiades_types::JulianDay::from_days(10.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(11.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 20.0, 22.0),
PolynomialChannel::new(ChannelKind::Latitude, 9, vec![3.0]),
PolynomialChannel::new(ChannelKind::DistanceAu, 12, vec![4.0]),
],
vec![PolynomialChannel::new(ChannelKind::Longitude, 9, vec![0.5])],
),
],
);
let expected = "body: Sun; segments: 2; residual-bearing segments: 1";
assert_eq!(body_artifact.summary_line(), expected);
assert_eq!(body_artifact.to_string(), expected);
}
#[test]
fn segment_validate_rejects_duplicate_residual_channels() {
let segment = Segment::with_residual_channels(
Instant::new(pleiades_types::JulianDay::from_days(10.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(11.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 20.0, 22.0),
PolynomialChannel::new(ChannelKind::Latitude, 9, vec![3.0]),
PolynomialChannel::new(ChannelKind::DistanceAu, 12, vec![4.0]),
],
vec![
PolynomialChannel::new(ChannelKind::Longitude, 9, vec![0.5]),
PolynomialChannel::new(ChannelKind::Longitude, 9, vec![-0.25]),
PolynomialChannel::new(ChannelKind::DistanceAu, 12, vec![0.125]),
],
);
let error = segment
.validate()
.expect_err("duplicate residual channels should be rejected");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
assert!(error.to_string().contains("segment residual channels"));
}
#[test]
fn segment_validate_rejects_unsorted_stored_channels() {
let segment = Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(10.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(11.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Latitude, 9, 3.0, 4.0),
PolynomialChannel::linear(ChannelKind::Longitude, 9, 20.0, 22.0),
PolynomialChannel::new(ChannelKind::DistanceAu, 12, vec![4.0]),
],
);
let error = segment
.validate()
.expect_err("stored channels should be ordered canonically by channel kind");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
assert!(error
.to_string()
.contains("segment stored channels must be ordered by channel kind"));
}
#[test]
fn segment_validate_rejects_residual_channels_without_matching_stored_channels() {
let segment = Segment::with_residual_channels(
Instant::new(pleiades_types::JulianDay::from_days(10.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(11.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 20.0, 22.0),
PolynomialChannel::new(ChannelKind::DistanceAu, 12, vec![4.0]),
],
vec![
PolynomialChannel::new(ChannelKind::Longitude, 9, vec![0.5]),
PolynomialChannel::new(ChannelKind::Latitude, 9, vec![-0.25]),
],
);
let error = segment
.validate()
.expect_err("residual channels should require matching stored channels");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
assert!(error
.to_string()
.contains("segment residual channels require a matching stored channel"));
}
#[test]
fn segment_validate_rejects_non_finite_bounds() {
let segment = Segment::new(
Instant::new(
pleiades_types::JulianDay::from_days(f64::INFINITY),
TimeScale::Tt,
),
Instant::new(pleiades_types::JulianDay::from_days(11.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 20.0, 22.0),
PolynomialChannel::new(ChannelKind::Latitude, 9, vec![3.0]),
PolynomialChannel::new(ChannelKind::DistanceAu, 12, vec![4.0]),
],
);
let error = segment
.validate()
.expect_err("non-finite segment bounds should be rejected");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
assert!(error.message.contains("segment start must be finite"));
}
#[test]
fn segment_validate_rejects_non_finite_channel_coefficients() {
let segment = Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(10.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(11.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 20.0, 22.0),
PolynomialChannel::new(ChannelKind::Latitude, 9, vec![f64::NAN]),
PolynomialChannel::new(ChannelKind::DistanceAu, 12, vec![4.0]),
],
);
let error = segment
.validate()
.expect_err("non-finite channel coefficients should be rejected");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
assert!(error
.message
.contains("polynomial channel Latitude contains a non-finite coefficient at index 0"));
}
#[test]
fn body_validate_rejects_overlapping_segments_for_the_same_scale() {
let body = BodyArtifact::new(
CelestialBody::Moon,
vec![
Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(2.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 0.0, 20.0),
PolynomialChannel::linear(ChannelKind::Latitude, 9, 1.0, 2.0),
PolynomialChannel::linear(ChannelKind::DistanceAu, 12, 0.1, 0.2),
],
),
Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(1.5), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(3.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 20.0, 30.0),
PolynomialChannel::linear(ChannelKind::Latitude, 9, 3.0, 4.0),
PolynomialChannel::linear(ChannelKind::DistanceAu, 12, 0.3, 0.4),
],
),
],
);
let error = body
.validate()
.expect_err("overlapping same-scale segments should be rejected");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
assert!(error.message.contains("ordered and non-overlapping"));
}
#[test]
fn body_validate_allows_shared_boundary_segments_for_the_same_scale() {
let body = BodyArtifact::new(
CelestialBody::Moon,
vec![
Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 0.0, 10.0),
PolynomialChannel::linear(ChannelKind::Latitude, 9, 1.0, 2.0),
PolynomialChannel::linear(ChannelKind::DistanceAu, 12, 0.1, 0.2),
],
),
Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(2.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 20.0, 30.0),
PolynomialChannel::linear(ChannelKind::Latitude, 9, 3.0, 4.0),
PolynomialChannel::linear(ChannelKind::DistanceAu, 12, 0.3, 0.4),
],
),
],
);
assert!(body.validate().is_ok());
}
#[test]
fn explicit_endian_policy_roundtrip_preserves_header_metadata() {
let artifact = CompressedArtifact::new(
ArtifactHeader::with_profile_and_endian(
"endian demo",
"unit test endian policy",
EndianPolicy::LittleEndian,
ArtifactProfile::ecliptic_longitude_latitude_distance(),
),
vec![BodyArtifact::new(
CelestialBody::Sun,
vec![Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 10.0, 11.0),
PolynomialChannel::linear(ChannelKind::Latitude, 9, 1.0, 2.0),
PolynomialChannel::linear(ChannelKind::DistanceAu, 12, 0.1, 0.2),
],
)],
)],
);
let decoded = CompressedArtifact::decode(
&artifact
.encode()
.expect("artifact should encode with explicit endian policy"),
)
.expect("artifact should decode with explicit endian policy");
assert_eq!(decoded.header.endian_policy, EndianPolicy::LittleEndian);
assert_eq!(decoded.header.generation_label, "endian demo");
}
#[test]
fn decode_rejects_unsupported_endian_policy() {
let artifact = CompressedArtifact::new(
ArtifactHeader::with_profile_and_endian(
"endian demo",
"unit test endian policy",
EndianPolicy::LittleEndian,
ArtifactProfile::ecliptic_longitude_latitude_distance(),
),
vec![BodyArtifact::new(
CelestialBody::Sun,
vec![Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 10.0, 11.0),
PolynomialChannel::linear(ChannelKind::Latitude, 9, 1.0, 2.0),
PolynomialChannel::linear(ChannelKind::DistanceAu, 12, 0.1, 0.2),
],
)],
)],
);
let mut bytes = artifact
.encode()
.expect("artifact should encode with explicit endian policy");
let mut cursor = Cursor::new(&bytes);
assert_eq!(
cursor.read_array::<8>().expect("magic should decode"),
ARTIFACT_MAGIC
);
assert_eq!(
cursor.read_u16().expect("version should decode"),
ARTIFACT_VERSION
);
let _ = cursor.read_u64().expect("checksum should decode");
let _ = cursor.read_string().expect("label should decode");
let _ = cursor.read_string().expect("source should decode");
let endian_index = cursor.offset;
bytes[endian_index] = 1;
let payload_offset =
ARTIFACT_MAGIC.len() + std::mem::size_of::<u16>() + std::mem::size_of::<u64>();
let checksum = fnv1a64(&bytes[payload_offset..]);
let checksum_offset = ARTIFACT_MAGIC.len() + std::mem::size_of::<u16>();
bytes[checksum_offset..checksum_offset + std::mem::size_of::<u64>()]
.copy_from_slice(&checksum.to_le_bytes());
let error = CompressedArtifact::decode(&bytes)
.expect_err("artifact should reject unsupported endian policy");
assert_eq!(error.kind, CompressionErrorKind::UnsupportedEndianPolicy);
assert_eq!(
error.message,
"artifact byte-order policy 1 is not supported"
);
}
#[test]
fn artifact_profile_summary_lists_capability_fields() {
let profile = ArtifactProfile::new(
vec![
ChannelKind::Longitude,
ChannelKind::Latitude,
ChannelKind::DistanceAu,
],
vec![
ArtifactOutput::EclipticCoordinates,
ArtifactOutput::EquatorialCoordinates,
],
vec![
ArtifactOutput::ApparentCorrections,
ArtifactOutput::TopocentricCoordinates,
ArtifactOutput::SiderealCoordinates,
ArtifactOutput::Motion,
],
SpeedPolicy::Unsupported,
);
let coverage = ArtifactProfileCoverageSummary::new(
profile.clone(),
vec![CelestialBody::Sun, CelestialBody::Moon],
);
assert_eq!(
profile.summary(),
"stored channels: [Longitude, Latitude, DistanceAu]; derived outputs: [EclipticCoordinates, EquatorialCoordinates]; unsupported outputs: [ApparentCorrections, TopocentricCoordinates, SiderealCoordinates, Motion]; speed policy: Unsupported"
);
assert_eq!(
profile.summary_line(),
"stored channels: [Longitude, Latitude, DistanceAu]; derived outputs: [EclipticCoordinates, EquatorialCoordinates]; unsupported outputs: [ApparentCorrections, TopocentricCoordinates, SiderealCoordinates, Motion]; speed policy: Unsupported"
);
assert_eq!(
profile.summary_for_body_count(11),
"stored channels: [Longitude, Latitude, DistanceAu]; derived outputs: [EclipticCoordinates, EquatorialCoordinates]; unsupported outputs: [ApparentCorrections, TopocentricCoordinates, SiderealCoordinates, Motion]; speed policy: Unsupported; applies to 11 bundled bodies"
);
assert_eq!(
profile.summary_line_with_body_count(11),
"stored channels: [Longitude, Latitude, DistanceAu]; derived outputs: [EclipticCoordinates, EquatorialCoordinates]; unsupported outputs: [ApparentCorrections, TopocentricCoordinates, SiderealCoordinates, Motion]; speed policy: Unsupported; applies to 11 bundled bodies"
);
assert_eq!(
profile.to_string(),
"stored channels: [Longitude, Latitude, DistanceAu]; derived outputs: [EclipticCoordinates, EquatorialCoordinates]; unsupported outputs: [ApparentCorrections, TopocentricCoordinates, SiderealCoordinates, Motion]; speed policy: Unsupported"
);
assert_eq!(
profile.output_support_entries_summary_line(),
"EclipticCoordinates=derived, EquatorialCoordinates=derived, ApparentCorrections=unsupported, TopocentricCoordinates=unsupported, SiderealCoordinates=unsupported, Motion=unsupported"
);
assert_eq!(
profile.output_support_summary_line(),
"EclipticCoordinates=derived, EquatorialCoordinates=derived, ApparentCorrections=unsupported, TopocentricCoordinates=unsupported, SiderealCoordinates=unsupported, Motion=unsupported; unlisted outputs: []; support counts: stored=0, derived=2, approximated=0, unsupported=4, unlisted=0"
);
assert_eq!(
profile
.validated_summary_line()
.expect("profile summary should validate"),
profile.summary_line()
);
assert_eq!(
profile
.validated_output_support_entries_summary_line()
.expect("output-support entries should validate"),
profile.output_support_entries_summary_line()
);
assert_eq!(
profile
.validated_output_support_summary_line()
.expect("output-support summary should validate"),
profile.output_support_summary_line()
);
assert_eq!(coverage.body_count, 2);
assert_eq!(
coverage.bodies,
vec![CelestialBody::Sun, CelestialBody::Moon]
);
assert_eq!(coverage.profile, profile);
assert_eq!(
coverage.summary_line(),
"stored channels: [Longitude, Latitude, DistanceAu]; derived outputs: [EclipticCoordinates, EquatorialCoordinates]; unsupported outputs: [ApparentCorrections, TopocentricCoordinates, SiderealCoordinates, Motion]; speed policy: Unsupported; applies to 2 bundled bodies"
);
assert_eq!(
coverage
.validated_summary_line()
.expect("coverage summary should validate"),
"stored channels: [Longitude, Latitude, DistanceAu]; derived outputs: [EclipticCoordinates, EquatorialCoordinates]; unsupported outputs: [ApparentCorrections, TopocentricCoordinates, SiderealCoordinates, Motion]; speed policy: Unsupported; applies to 2 bundled bodies"
);
assert_eq!(
coverage.summary_line_with_bodies(),
"stored channels: [Longitude, Latitude, DistanceAu]; derived outputs: [EclipticCoordinates, EquatorialCoordinates]; unsupported outputs: [ApparentCorrections, TopocentricCoordinates, SiderealCoordinates, Motion]; speed policy: Unsupported; applies to 2 bundled bodies; bundled bodies: Sun, Moon"
);
assert_eq!(
coverage
.validated_summary_line_with_bodies()
.expect("coverage summary should validate"),
"stored channels: [Longitude, Latitude, DistanceAu]; derived outputs: [EclipticCoordinates, EquatorialCoordinates]; unsupported outputs: [ApparentCorrections, TopocentricCoordinates, SiderealCoordinates, Motion]; speed policy: Unsupported; applies to 2 bundled bodies; bundled bodies: Sun, Moon"
);
assert_eq!(
coverage.to_string(),
"stored channels: [Longitude, Latitude, DistanceAu]; derived outputs: [EclipticCoordinates, EquatorialCoordinates]; unsupported outputs: [ApparentCorrections, TopocentricCoordinates, SiderealCoordinates, Motion]; speed policy: Unsupported; applies to 2 bundled bodies"
);
coverage
.validate()
.expect("coverage summary should validate");
let header = ArtifactHeader::with_profile_and_endian(
"demo",
"source",
EndianPolicy::LittleEndian,
profile,
);
assert_eq!(
header.summary(),
"byte order: little-endian; stored channels: [Longitude, Latitude, DistanceAu]; derived outputs: [EclipticCoordinates, EquatorialCoordinates]; unsupported outputs: [ApparentCorrections, TopocentricCoordinates, SiderealCoordinates, Motion]; speed policy: Unsupported"
);
assert_eq!(
header.summary_line(),
"byte order: little-endian; stored channels: [Longitude, Latitude, DistanceAu]; derived outputs: [EclipticCoordinates, EquatorialCoordinates]; unsupported outputs: [ApparentCorrections, TopocentricCoordinates, SiderealCoordinates, Motion]; speed policy: Unsupported"
);
assert_eq!(
header.summary_for_body_count(11),
"byte order: little-endian; stored channels: [Longitude, Latitude, DistanceAu]; derived outputs: [EclipticCoordinates, EquatorialCoordinates]; unsupported outputs: [ApparentCorrections, TopocentricCoordinates, SiderealCoordinates, Motion]; speed policy: Unsupported; applies to 11 bundled bodies"
);
assert_eq!(
header.to_string(),
"byte order: little-endian; stored channels: [Longitude, Latitude, DistanceAu]; derived outputs: [EclipticCoordinates, EquatorialCoordinates]; unsupported outputs: [ApparentCorrections, TopocentricCoordinates, SiderealCoordinates, Motion]; speed policy: Unsupported"
);
}
#[test]
fn artifact_profile_coverage_validation_rejects_body_count_drift() {
let mut coverage = ArtifactProfileCoverageSummary::new(
ArtifactProfile::ecliptic_longitude_latitude_distance(),
vec![CelestialBody::Sun, CelestialBody::Moon],
);
coverage.body_count += 1;
let error = coverage
.validate()
.expect_err("body-count drift should be rejected");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
assert!(error
.message
.contains("artifact profile coverage body count does not match bundled body list"));
}
#[test]
fn artifact_profile_coverage_summary_line_uses_bundled_body_list() {
let mut coverage = ArtifactProfileCoverageSummary::new(
ArtifactProfile::ecliptic_longitude_latitude_distance(),
vec![CelestialBody::Sun, CelestialBody::Moon],
);
coverage.body_count += 1;
assert_eq!(
coverage.summary_line(),
"stored channels: [Longitude, Latitude, DistanceAu]; derived outputs: [EclipticCoordinates, EquatorialCoordinates]; unsupported outputs: [ApparentCorrections, TopocentricCoordinates, SiderealCoordinates, Motion]; speed policy: Unsupported; applies to 2 bundled bodies"
);
assert_eq!(
coverage.summary_line_with_bodies(),
"stored channels: [Longitude, Latitude, DistanceAu]; derived outputs: [EclipticCoordinates, EquatorialCoordinates]; unsupported outputs: [ApparentCorrections, TopocentricCoordinates, SiderealCoordinates, Motion]; speed policy: Unsupported; applies to 2 bundled bodies; bundled bodies: Sun, Moon"
);
assert_eq!(
coverage.to_string(),
"stored channels: [Longitude, Latitude, DistanceAu]; derived outputs: [EclipticCoordinates, EquatorialCoordinates]; unsupported outputs: [ApparentCorrections, TopocentricCoordinates, SiderealCoordinates, Motion]; speed policy: Unsupported; applies to 2 bundled bodies"
);
}
#[test]
fn artifact_profile_coverage_validation_rejects_empty_bodies() {
let coverage = ArtifactProfileCoverageSummary::new(
ArtifactProfile::ecliptic_longitude_latitude_distance(),
Vec::new(),
);
let error = coverage
.validate()
.expect_err("empty bundled-body lists should be rejected");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
assert!(error
.message
.contains("artifact profile coverage bundled body list must not be empty"));
}
#[test]
fn artifact_profile_coverage_validated_summary_line_rejects_drift() {
let mut coverage = ArtifactProfileCoverageSummary::new(
ArtifactProfile::ecliptic_longitude_latitude_distance(),
vec![CelestialBody::Sun, CelestialBody::Moon],
);
coverage.body_count += 1;
let error = coverage
.validated_summary_line_with_bodies()
.expect_err("drifted coverage summaries should be rejected");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
assert!(error
.message
.contains("artifact profile coverage body count does not match bundled body list"));
}
#[test]
fn artifact_profile_coverage_validation_rejects_duplicate_bodies() {
let coverage = ArtifactProfileCoverageSummary::new(
ArtifactProfile::ecliptic_longitude_latitude_distance(),
vec![CelestialBody::Sun, CelestialBody::Moon, CelestialBody::Sun],
);
let error = coverage
.validate()
.expect_err("duplicate bundled bodies should be rejected");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
assert!(error
.message
.contains("artifact profile coverage bundled bodies contains duplicate Sun entry"));
}
#[test]
fn artifact_profile_coverage_validation_rejects_out_of_order_bodies() {
let coverage = ArtifactProfileCoverageSummary::new(
ArtifactProfile::ecliptic_longitude_latitude_distance(),
vec![CelestialBody::Moon, CelestialBody::Sun],
);
let error = coverage
.validate()
.expect_err("out-of-order bundled bodies should be rejected");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
assert!(error.message.contains(
"artifact profile coverage bundled bodies must be ordered canonically; found Moon before Sun"
));
}
#[test]
fn artifact_profile_labels_are_stable() {
assert_eq!(ChannelKind::Longitude.label(), "Longitude");
assert_eq!(ChannelKind::Latitude.label(), "Latitude");
assert_eq!(ChannelKind::DistanceAu.label(), "DistanceAu");
assert_eq!(
ArtifactOutput::EclipticCoordinates.label(),
"EclipticCoordinates"
);
assert_eq!(
ArtifactOutput::EquatorialCoordinates.label(),
"EquatorialCoordinates"
);
assert_eq!(
ArtifactOutput::ApparentCorrections.label(),
"ApparentCorrections"
);
assert_eq!(
ArtifactOutput::TopocentricCoordinates.label(),
"TopocentricCoordinates"
);
assert_eq!(
ArtifactOutput::SiderealCoordinates.label(),
"SiderealCoordinates"
);
assert_eq!(ArtifactOutput::Motion.label(), "Motion");
assert_eq!(SpeedPolicy::Unsupported.label(), "Unsupported");
assert_eq!(SpeedPolicy::Stored.label(), "Stored");
assert_eq!(SpeedPolicy::FittedDerivative.label(), "FittedDerivative");
assert_eq!(
SpeedPolicy::NumericalDifference.label(),
"NumericalDifference"
);
assert_eq!(ChannelKind::Longitude.to_string(), "Longitude");
assert_eq!(ArtifactOutput::Motion.to_string(), "Motion");
assert_eq!(SpeedPolicy::Stored.to_string(), "Stored");
assert_eq!(
join_display(&[CelestialBody::Sun, CelestialBody::Moon]),
"Sun, Moon"
);
}
#[cfg(feature = "serde")]
#[test]
fn serde_roundtrip_preserves_artifacts() {
let artifact = CompressedArtifact::new(
ArtifactHeader::new("serde demo", "json roundtrip fixture"),
vec![BodyArtifact::new(
CelestialBody::Moon,
vec![Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(2.0), TimeScale::Tt),
vec![PolynomialChannel::linear(
ChannelKind::Longitude,
9,
15.0,
30.0,
)],
)],
)],
);
let decoded: CompressedArtifact = serde_json::from_value(
serde_json::to_value(&artifact).expect("artifact should serialize"),
)
.expect("artifact should deserialize");
assert_eq!(decoded, artifact);
}
#[test]
fn lookup_interpolates_segment_channels() {
let artifact = CompressedArtifact::new(
ArtifactHeader::new("demo", "lookup fixture"),
vec![BodyArtifact::new(
CelestialBody::Moon,
vec![Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(2.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 0.0, 20.0),
PolynomialChannel::linear(ChannelKind::Latitude, 9, 1.0, 3.0),
PolynomialChannel::linear(ChannelKind::DistanceAu, 12, 0.1, 0.2),
],
)],
)],
);
let result = artifact
.lookup_ecliptic(
&CelestialBody::Moon,
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
)
.expect("lookup should work");
assert_eq!(result.longitude.degrees(), 10.0);
assert_eq!(result.latitude.degrees(), 2.0);
assert_eq!(result.distance_au, Some(0.15000000000000002));
}
#[test]
fn lookup_rejects_missing_body() {
let artifact = CompressedArtifact::new(ArtifactHeader::new("demo", "x"), Vec::new());
let error = artifact
.lookup_ecliptic(
&CelestialBody::Sun,
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
)
.expect_err("missing bodies should error");
assert_eq!(error.kind, CompressionErrorKind::MissingBody);
}
#[test]
fn compressed_artifact_profile_coverage_summary_tracks_bundled_bodies() {
let artifact = CompressedArtifact::new(
ArtifactHeader::new("demo", "coverage fixture"),
vec![
BodyArtifact::new(CelestialBody::Sun, Vec::new()),
BodyArtifact::new(CelestialBody::Moon, Vec::new()),
],
);
let coverage = artifact.profile_coverage_summary();
assert_eq!(coverage.body_count, 2);
assert_eq!(
coverage.bodies,
vec![CelestialBody::Sun, CelestialBody::Moon]
);
assert_eq!(coverage.profile, artifact.header.profile);
assert_eq!(coverage.validate(), Ok(()));
assert_eq!(
coverage.summary_line_with_bodies(),
format!(
"{}; bundled bodies: Sun, Moon",
artifact.header.profile.summary_for_body_count(2)
)
);
}
#[test]
fn compressed_artifact_validate_rejects_empty_body_set() {
let artifact = CompressedArtifact::new(ArtifactHeader::new("demo", "empty"), Vec::new());
let error = artifact
.validate()
.expect_err("empty body sets should be rejected");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
assert!(error
.to_string()
.contains("artifact profile coverage bundled body list must not be empty"));
}
#[test]
fn compressed_artifact_validate_rejects_noncanonical_body_order() {
let artifact = CompressedArtifact::new(
ArtifactHeader::new("demo", "unordered bodies"),
vec![
BodyArtifact::new(CelestialBody::Moon, Vec::new()),
BodyArtifact::new(CelestialBody::Sun, Vec::new()),
],
);
let error = artifact
.validate()
.expect_err("non-canonical body ordering should be rejected");
assert_eq!(error.kind, CompressionErrorKind::InvalidFormat);
assert!(error
.to_string()
.contains("compressed artifact body entries must be ordered canonically"));
}
#[test]
fn random_access_helpers_return_body_and_segment_matches() {
let artifact = CompressedArtifact::new(
ArtifactHeader::new("demo", "segment access fixture"),
vec![BodyArtifact::new(
CelestialBody::Moon,
vec![Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(2.0), TimeScale::Tt),
vec![PolynomialChannel::linear(
ChannelKind::Longitude,
9,
0.0,
20.0,
)],
)],
)],
);
let body = artifact
.body_artifact(&CelestialBody::Moon)
.expect("body lookup should work");
assert_eq!(body.body, CelestialBody::Moon);
assert_eq!(body.segments.len(), 1);
let segment = artifact
.segment_for(
&CelestialBody::Moon,
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
)
.expect("segment lookup should work");
assert_eq!(segment.start.julian_day.days(), 0.0);
assert_eq!(segment.end.julian_day.days(), 2.0);
assert!(body
.segment_at(Instant::new(
pleiades_types::JulianDay::from_days(0.0),
TimeScale::Tt
))
.is_some());
assert!(body
.segment_at(Instant::new(
pleiades_types::JulianDay::from_days(2.0),
TimeScale::Tt
))
.is_some());
assert!(body
.segment_at(Instant::new(
pleiades_types::JulianDay::from_days(2.1),
TimeScale::Tt
))
.is_none());
let error = artifact
.segment_for(
&CelestialBody::Moon,
Instant::new(pleiades_types::JulianDay::from_days(2.1), TimeScale::Tt),
)
.expect_err("out-of-range instant should error");
assert_eq!(error.kind, CompressionErrorKind::OutOfRangeInstant);
}
#[test]
fn random_access_helpers_prefer_the_later_segment_on_shared_boundaries() {
let artifact = CompressedArtifact::new(
ArtifactHeader::new("demo", "boundary fixture"),
vec![BodyArtifact::new(
CelestialBody::Moon,
vec![
Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 0.0, 10.0),
PolynomialChannel::linear(ChannelKind::Latitude, 9, 1.0, 2.0),
PolynomialChannel::linear(ChannelKind::DistanceAu, 12, 0.1, 0.2),
],
),
Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(2.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 20.0, 30.0),
PolynomialChannel::linear(ChannelKind::Latitude, 9, 3.0, 4.0),
PolynomialChannel::linear(ChannelKind::DistanceAu, 12, 0.3, 0.4),
],
),
],
)],
);
let shared_boundary =
Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt);
let segment = artifact
.segment_for(&CelestialBody::Moon, shared_boundary)
.expect("shared boundary should resolve to the later segment");
assert_eq!(segment.start.julian_day.days(), 1.0);
assert_eq!(segment.end.julian_day.days(), 2.0);
let ecliptic = artifact
.lookup_ecliptic(&CelestialBody::Moon, shared_boundary)
.expect("boundary lookup should succeed");
assert_eq!(ecliptic.longitude.degrees(), 20.0);
assert_eq!(ecliptic.latitude.degrees(), 3.0);
assert_eq!(ecliptic.distance_au, Some(0.3));
}
#[test]
fn encode_decode_roundtrip_preserves_lunar_apogee_and_perigee_bodies() {
let artifact = CompressedArtifact::new(
ArtifactHeader::new("demo", "lunar point fixture"),
vec![
BodyArtifact::new(CelestialBody::MeanApogee, Vec::new()),
BodyArtifact::new(CelestialBody::TrueApogee, Vec::new()),
BodyArtifact::new(CelestialBody::MeanPerigee, Vec::new()),
BodyArtifact::new(CelestialBody::TruePerigee, Vec::new()),
],
);
let encoded = artifact.encode().expect("artifact should encode");
let decoded = CompressedArtifact::decode(&encoded).expect("artifact should decode");
assert_eq!(decoded.bodies.len(), 4);
assert_eq!(decoded.bodies[0].body, CelestialBody::MeanApogee);
assert_eq!(decoded.bodies[1].body, CelestialBody::TrueApogee);
assert_eq!(decoded.bodies[2].body, CelestialBody::MeanPerigee);
assert_eq!(decoded.bodies[3].body, CelestialBody::TruePerigee);
}
#[test]
fn lookup_ecliptic_requires_the_profile_to_advertise_it() {
let artifact = CompressedArtifact::new(
ArtifactHeader::with_profile(
"demo",
"ecliptic lookup fixture",
ArtifactProfile::new(
vec![
ChannelKind::Longitude,
ChannelKind::Latitude,
ChannelKind::DistanceAu,
],
vec![ArtifactOutput::EquatorialCoordinates],
vec![
ArtifactOutput::ApparentCorrections,
ArtifactOutput::TopocentricCoordinates,
ArtifactOutput::SiderealCoordinates,
ArtifactOutput::Motion,
],
SpeedPolicy::Unsupported,
),
),
vec![BodyArtifact::new(
CelestialBody::Sun,
vec![Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(2.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 10.0, 20.0),
PolynomialChannel::linear(ChannelKind::Latitude, 9, 1.0, 3.0),
PolynomialChannel::linear(ChannelKind::DistanceAu, 12, 0.5, 0.75),
],
)],
)],
);
let instant = Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt);
let error = artifact
.lookup_ecliptic(&CelestialBody::Sun, instant)
.expect_err("ecliptic lookup should respect the advertised profile");
assert_eq!(
error.message,
"artifact profile does not support EclipticCoordinates"
);
}
#[test]
fn lookup_equatorial_requires_the_profile_to_advertise_it() {
let artifact = CompressedArtifact::new(
ArtifactHeader::with_profile(
"demo",
"equatorial lookup fixture",
ArtifactProfile::new(
vec![
ChannelKind::Longitude,
ChannelKind::Latitude,
ChannelKind::DistanceAu,
],
vec![ArtifactOutput::EclipticCoordinates],
vec![
ArtifactOutput::ApparentCorrections,
ArtifactOutput::TopocentricCoordinates,
ArtifactOutput::SiderealCoordinates,
ArtifactOutput::Motion,
],
SpeedPolicy::Unsupported,
),
),
vec![BodyArtifact::new(
CelestialBody::Sun,
vec![Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(2.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 10.0, 20.0),
PolynomialChannel::linear(ChannelKind::Latitude, 9, 1.0, 3.0),
PolynomialChannel::linear(ChannelKind::DistanceAu, 12, 0.5, 0.75),
],
)],
)],
);
let instant = Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt);
let obliquity = Angle::from_degrees(23.439_291_11);
let error = artifact
.lookup_equatorial(&CelestialBody::Sun, instant, obliquity)
.expect_err("equatorial lookup should respect the advertised profile");
assert_eq!(
error.message,
"artifact profile does not support EquatorialCoordinates"
);
}
#[test]
fn lookup_equatorial_reconstructs_derived_coordinates() {
let artifact = CompressedArtifact::new(
ArtifactHeader::new("demo", "equatorial lookup fixture"),
vec![BodyArtifact::new(
CelestialBody::Sun,
vec![Segment::new(
Instant::new(pleiades_types::JulianDay::from_days(0.0), TimeScale::Tt),
Instant::new(pleiades_types::JulianDay::from_days(2.0), TimeScale::Tt),
vec![
PolynomialChannel::linear(ChannelKind::Longitude, 9, 10.0, 20.0),
PolynomialChannel::linear(ChannelKind::Latitude, 9, 1.0, 3.0),
PolynomialChannel::linear(ChannelKind::DistanceAu, 12, 0.5, 0.75),
],
)],
)],
);
let instant = Instant::new(pleiades_types::JulianDay::from_days(1.0), TimeScale::Tt);
let obliquity = Angle::from_degrees(23.439_291_11);
let ecliptic = artifact
.lookup_ecliptic(&CelestialBody::Sun, instant)
.expect("ecliptic lookup should succeed");
let equatorial = artifact
.lookup_equatorial(&CelestialBody::Sun, instant, obliquity)
.expect("equatorial lookup should succeed");
assert_eq!(equatorial, ecliptic.to_equatorial(obliquity));
assert_eq!(equatorial.distance_au, Some(0.625));
}
}