use astrodyn_quantities::frame_descriptor::FrameUid;
use serde::{Deserialize, Serialize};
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Conventions {
pub translation: String,
pub rotation: String,
pub angular_velocity: String,
pub time_scale: String,
}
impl Conventions {
pub fn current() -> Self {
Self {
translation: "position/velocity in parent-frame coordinates, SI (m, m/s)".into(),
rotation: "scalar-first left-transformation quaternion parent->this; \
matrix is the same transformation; the non-canonical \
representation is re-derived on load"
.into(),
angular_velocity: "this-frame coordinates, rad/s".into(),
time_scale: "TDB seconds since J2000 epoch".into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DocHeader {
pub schema_version: u32,
pub conventions: Conventions,
pub simtime: f64,
pub tai_tjt_at_epoch: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CanonicalRotation {
Quat([f64; 4]),
Matrix([[f64; 3]; 3]),
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TransRecord {
pub position: [f64; 3],
pub velocity: [f64; 3],
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Origin {
Integrated {
attitude_quat: Option<[f64; 4]>,
ang_vel_body: Option<[f64; 3]>,
},
Derived {
model: String,
},
Injected,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FrameRecord {
pub name: String,
pub uid_index: u32,
pub parent: Option<u32>,
pub epoch: Option<f64>,
pub trans: TransRecord,
pub rotation: CanonicalRotation,
pub ang_vel_this: [f64; 3],
pub origin: Origin,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FrameDocument {
pub header: DocHeader,
pub uids: Vec<FrameUid>,
pub records: Vec<FrameRecord>,
}
#[derive(Debug, thiserror::Error)]
pub enum DocError {
#[error(
"unsupported frame-document schema version {found}; this build \
supports version {SCHEMA_VERSION}"
)]
UnsupportedVersion {
found: u32,
},
#[error(
"frame-document convention mismatch in `{field}`: document says \
{found:?}, this build expects {expected:?} — refusing to interpret \
state under a different convention"
)]
ConventionMismatch {
field: &'static str,
found: String,
expected: String,
},
#[error("record {record} names uid index {index}, but the table has {len} entries")]
UidIndexOutOfRange {
record: usize,
index: u32,
len: usize,
},
#[error("record {record} names parent uid index {index}, but the table has {len} entries")]
ParentIndexOutOfRange {
record: usize,
index: u32,
len: usize,
},
#[error("record {record} (uid index {index}) names itself as its parent")]
SelfParent {
record: usize,
index: u32,
},
#[error("non-finite value in {0} — fix the producing physics before serializing")]
NonFinite(String),
#[error("uid index {index} appears on more than one record")]
DuplicateUid {
index: u32,
},
#[error("uid table entries {first} and {second} hold the same identity")]
DuplicateUidEntry {
first: usize,
second: usize,
},
#[error(
"segment {segment} epoch at simtime {simtime} carries {found} records \
for a {expected}-frame population — every epoch row must cover the \
full uid table (replay v1 is fixed-population)"
)]
IncompleteRow {
segment: usize,
simtime: f64,
found: usize,
expected: usize,
},
#[error("segment {segment} has no epochs")]
EmptySegment {
segment: usize,
},
#[error(
"segment {segment} declares start_simtime {start} but its first epoch \
is at {first_epoch} — the boundary is the seek keyframe and must match"
)]
SegmentStartMismatch {
segment: usize,
start: f64,
first_epoch: f64,
},
#[error(
"segment {segment} epoch at simtime {simtime} declares a parent for uid \
index {uid_index} that differs from the segment's topology — a \
topology change must open a new segment"
)]
TopologyMismatch {
segment: usize,
simtime: f64,
uid_index: u32,
},
#[error("frame-document JSON error: {0}")]
Json(#[from] serde_json::Error),
}
impl FrameDocument {
pub fn validate(&self) -> Result<(), DocError> {
validate_header(&self.header)?;
validate_uid_table(&self.uids)?;
let mut seen = vec![false; self.uids.len()];
for (i, rec) in self.records.iter().enumerate() {
validate_record(rec, i, self.uids.len())?;
let idx = rec.uid_index as usize;
if seen[idx] {
return Err(DocError::DuplicateUid {
index: rec.uid_index,
});
}
seen[idx] = true;
}
Ok(())
}
pub fn to_json_string(&self) -> String {
self.validate().unwrap_or_else(|err| {
panic!(
"FrameDocument::to_json_string: refusing to serialize an invalid document: {err}"
)
});
serde_json::to_string(self)
.expect("FrameDocument serialization is infallible after validate()")
}
pub fn from_json_str(json: &str) -> Result<Self, DocError> {
let doc: Self = serde_json::from_str(json)?;
doc.validate()?;
Ok(doc)
}
}
pub fn validate_uid_table(uids: &[FrameUid]) -> Result<(), DocError> {
let mut seen: std::collections::HashMap<&FrameUid, usize> =
std::collections::HashMap::with_capacity(uids.len());
for (i, uid) in uids.iter().enumerate() {
if let Some(&first) = seen.get(uid) {
return Err(DocError::DuplicateUidEntry { first, second: i });
}
seen.insert(uid, i);
}
Ok(())
}
pub fn validate_header(header: &DocHeader) -> Result<(), DocError> {
if header.schema_version != SCHEMA_VERSION {
return Err(DocError::UnsupportedVersion {
found: header.schema_version,
});
}
let expected = Conventions::current();
let pairs = [
(
"translation",
&header.conventions.translation,
&expected.translation,
),
("rotation", &header.conventions.rotation, &expected.rotation),
(
"angular_velocity",
&header.conventions.angular_velocity,
&expected.angular_velocity,
),
(
"time_scale",
&header.conventions.time_scale,
&expected.time_scale,
),
];
for (field, found, want) in pairs {
if found != want {
return Err(DocError::ConventionMismatch {
field,
found: found.clone(),
expected: want.clone(),
});
}
}
finite(header.simtime, || "header.simtime".into())?;
finite(header.tai_tjt_at_epoch, || "header.tai_tjt_at_epoch".into())?;
Ok(())
}
pub fn validate_record(
rec: &FrameRecord,
record_pos: usize,
uid_table_len: usize,
) -> Result<(), DocError> {
if rec.uid_index as usize >= uid_table_len {
return Err(DocError::UidIndexOutOfRange {
record: record_pos,
index: rec.uid_index,
len: uid_table_len,
});
}
if let Some(p) = rec.parent {
if p as usize >= uid_table_len {
return Err(DocError::ParentIndexOutOfRange {
record: record_pos,
index: p,
len: uid_table_len,
});
}
if p == rec.uid_index {
return Err(DocError::SelfParent {
record: record_pos,
index: rec.uid_index,
});
}
}
let ctx = |field: &str| format!("record {record_pos} ({}) {field}", rec.name);
if let Some(e) = rec.epoch {
finite(e, || ctx("epoch"))?;
}
finite3(&rec.trans.position, || ctx("trans.position"))?;
finite3(&rec.trans.velocity, || ctx("trans.velocity"))?;
match &rec.rotation {
CanonicalRotation::Quat(q) => {
for v in q {
finite(*v, || ctx("rotation.quat"))?;
}
}
CanonicalRotation::Matrix(m) => {
for row in m {
finite3(row, || ctx("rotation.matrix"))?;
}
}
}
finite3(&rec.ang_vel_this, || ctx("ang_vel_this"))?;
if let Origin::Integrated {
attitude_quat,
ang_vel_body,
} = &rec.origin
{
if let Some(q) = attitude_quat {
for v in q {
finite(*v, || ctx("origin.attitude_quat"))?;
}
}
if let Some(w) = ang_vel_body {
finite3(w, || ctx("origin.ang_vel_body"))?;
}
}
Ok(())
}
fn finite(v: f64, ctx: impl Fn() -> String) -> Result<(), DocError> {
if v.is_finite() {
Ok(())
} else {
Err(DocError::NonFinite(format!("{} = {v}", ctx())))
}
}
fn finite3(v: &[f64; 3], ctx: impl Fn() -> String) -> Result<(), DocError> {
for x in v {
finite(*x, &ctx)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_fixtures::{body_record, pfix_record, record_bits, snapshot};
#[test]
fn snapshot_round_trips_bit_exact_both_canonicity_regimes() {
let doc = snapshot();
let json = doc.to_json_string();
let back = FrameDocument::from_json_str(&json).expect("round trip");
assert_eq!(back.records.len(), doc.records.len());
for (a, b) in doc.records.iter().zip(&back.records) {
assert_eq!(record_bits(a), record_bits(b), "record {} drifted", a.name);
}
assert_eq!(doc.header.simtime.to_bits(), back.header.simtime.to_bits());
assert_eq!(
doc.header.tai_tjt_at_epoch.to_bits(),
back.header.tai_tjt_at_epoch.to_bits()
);
assert_eq!(doc.uids, back.uids);
}
#[test]
fn uid_table_interning_round_trips() {
let doc = snapshot();
let json = doc.to_json_string();
let back = FrameDocument::from_json_str(&json).expect("round trip");
assert_eq!(back.uids, doc.uids);
assert_eq!(back.records[0].parent, None, "root names no parent");
assert_eq!(back.records[1].parent, Some(0));
assert_eq!(back.records[2].parent, Some(0));
assert!(matches!(
back.records[1].origin,
Origin::Derived { ref model } if model == "EarthRNP"
));
assert!(matches!(back.records[0].origin, Origin::Injected));
assert!(matches!(
back.records[2].origin,
Origin::Integrated {
attitude_quat: Some(_),
ang_vel_body: Some(_)
}
));
}
#[test]
#[should_panic(expected = "non-finite value")]
fn non_finite_serialize_panics() {
let mut doc = snapshot();
doc.records[2].trans.velocity[1] = f64::NAN;
let _ = doc.to_json_string();
}
#[test]
fn validate_rejects_out_of_range_uid_index() {
let mut doc = snapshot();
doc.records[1].uid_index = 99;
assert!(matches!(
doc.validate(),
Err(DocError::UidIndexOutOfRange {
record: 1,
index: 99,
..
})
));
}
#[test]
fn validate_rejects_out_of_range_parent() {
let mut doc = snapshot();
doc.records[2].parent = Some(99);
assert!(matches!(
doc.validate(),
Err(DocError::ParentIndexOutOfRange {
record: 2,
index: 99,
..
})
));
}
#[test]
fn validate_rejects_self_parent() {
let mut doc = snapshot();
doc.records[1].parent = Some(doc.records[1].uid_index);
assert!(matches!(doc.validate(), Err(DocError::SelfParent { .. })));
}
#[test]
fn validate_rejects_duplicate_uid() {
let mut doc = snapshot();
doc.records[2].uid_index = doc.records[1].uid_index;
assert!(matches!(doc.validate(), Err(DocError::DuplicateUid { .. })));
}
#[test]
fn validate_rejects_duplicate_uid_table_entry() {
let mut doc = snapshot();
doc.uids[2] = doc.uids[1].clone();
assert!(matches!(
doc.validate(),
Err(DocError::DuplicateUidEntry {
first: 1,
second: 2
})
));
}
#[test]
fn unsupported_version_rejected() {
let mut doc = snapshot();
doc.header.schema_version = SCHEMA_VERSION + 1;
let json = serde_json::to_string(&doc).expect("raw serialize");
assert!(matches!(
FrameDocument::from_json_str(&json),
Err(DocError::UnsupportedVersion { found }) if found == SCHEMA_VERSION + 1
));
}
#[test]
fn convention_mismatch_rejected_before_state_is_interpreted() {
let mut doc = snapshot();
doc.header.conventions.rotation = "scalar-LAST right-transformation".into();
let json = serde_json::to_string(&doc).expect("raw serialize");
assert!(matches!(
FrameDocument::from_json_str(&json),
Err(DocError::ConventionMismatch {
field: "rotation",
..
})
));
}
mod proptests {
use super::*;
use proptest::prelude::*;
fn finite_f64() -> impl Strategy<Value = f64> {
any::<u64>()
.prop_map(f64::from_bits)
.prop_filter("finite", |x| x.is_finite())
}
fn arr3() -> impl Strategy<Value = [f64; 3]> {
[finite_f64(), finite_f64(), finite_f64()]
}
fn arr4() -> impl Strategy<Value = [f64; 4]> {
[finite_f64(), finite_f64(), finite_f64(), finite_f64()]
}
proptest! {
#[test]
fn any_finite_f64_round_trips_bit_exact(
pos in arr3(), vel in arr3(), quat in arr4(),
m0 in arr3(), m1 in arr3(), m2 in arr3(),
w in arr3(), wb in arr3(), epoch in finite_f64(),
simtime in finite_f64(), tjt in finite_f64(),
) {
let mut doc = snapshot();
doc.header.simtime = simtime;
doc.header.tai_tjt_at_epoch = tjt;
doc.records[1] = FrameRecord {
rotation: CanonicalRotation::Matrix([m0, m1, m2]),
epoch: Some(epoch),
..pfix_record()
};
doc.records[2] = FrameRecord {
trans: TransRecord { position: pos, velocity: vel },
rotation: CanonicalRotation::Quat(quat),
ang_vel_this: w,
origin: Origin::Integrated {
attitude_quat: Some(quat),
ang_vel_body: Some(wb),
},
..body_record()
};
let json = doc.to_json_string();
let back = FrameDocument::from_json_str(&json).expect("round trip");
for (a, b) in doc.records.iter().zip(&back.records) {
prop_assert_eq!(record_bits(a), record_bits(b));
}
prop_assert_eq!(doc.header.simtime.to_bits(), back.header.simtime.to_bits());
prop_assert_eq!(
doc.header.tai_tjt_at_epoch.to_bits(),
back.header.tai_tjt_at_epoch.to_bits()
);
}
}
}
}