use astrodyn_frame_doc::{
CanonicalRotation, DocError, DocHeader, FrameDocument, FrameRecord, FrameUid, Origin,
TransRecord,
};
use astrodyn_frames::{
FrameId, FrameTree, FrameTreeError, RefFrameRot, RefFrameState, RefFrameTrans,
};
use astrodyn_quantities::quat::JeodQuat;
use astrodyn_quantities::time_scale::{SecondsSince, TDB};
use glam::{DMat3, DVec3};
#[derive(Debug, thiserror::Error)]
pub enum LoadError {
#[error(transparent)]
Doc(#[from] DocError),
#[error(
"record {record} (`{name}`) declares parent `{parent_uid}`, but the \
document has no record for that identity"
)]
UnresolvedParent {
record: usize,
name: String,
parent_uid: FrameUid,
},
#[error("records {names:?} form a parent cycle (no chain reaches a root)")]
Cycle {
names: Vec<String>,
},
#[error(
"root record {record} (`{name}`) has identity `{uid}` whose class \
cannot root a tree (only inertial-flavor classes may)"
)]
NonInertialRoot {
record: usize,
name: String,
uid: FrameUid,
},
#[error(transparent)]
Tree(#[from] FrameTreeError),
}
pub fn export_tree(
tree: &FrameTree,
header: DocHeader,
origin_of: impl Fn(FrameId) -> Origin,
) -> FrameDocument {
let mut uids: Vec<FrameUid> = Vec::with_capacity(tree.len());
let mut records = Vec::with_capacity(tree.len());
for id in 0..tree.len() {
let node = tree.get(id);
let uid = node.uid();
uids.push(uid.clone());
let parent = tree.parent(id).map(|pid| {
u32::try_from(pid)
.expect("frame tree node count exceeds u32 — unsupported document size")
});
let rotation = if uid.class == astrodyn_frame_doc::FrameClass::PlanetFixed {
CanonicalRotation::Matrix(node.state.rot.t_parent_this.to_cols_array_2d())
} else {
CanonicalRotation::Quat(node.state.rot.q_parent_this.data)
};
records.push(FrameRecord {
name: node.name.clone(),
uid_index: u32::try_from(id)
.expect("frame tree node count exceeds u32 — unsupported document size"),
parent,
epoch: node.epoch.map(SecondsSince::<TDB>::as_seconds),
trans: TransRecord {
position: node.state.trans.position.to_array(),
velocity: node.state.trans.velocity.to_array(),
},
rotation,
ang_vel_this: node.state.rot.ang_vel_this.to_array(),
origin: origin_of(id),
});
}
let doc = FrameDocument {
header,
uids,
records,
};
doc.validate()
.unwrap_or_else(|err| panic!("export_tree: produced an invalid document: {err}"));
doc
}
pub fn record_state(rec: &FrameRecord) -> RefFrameState {
let (q_parent_this, t_parent_this) = match &rec.rotation {
CanonicalRotation::Quat(q) => {
let q = JeodQuat::from_array(*q);
(q, q.left_quat_to_transformation())
}
CanonicalRotation::Matrix(cols) => {
let t = DMat3::from_cols_array_2d(cols);
(JeodQuat::left_quat_from_transformation(&t), t)
}
};
RefFrameState {
trans: RefFrameTrans {
position: DVec3::from_array(rec.trans.position),
velocity: DVec3::from_array(rec.trans.velocity),
},
rot: RefFrameRot {
q_parent_this,
t_parent_this,
ang_vel_this: DVec3::from_array(rec.ang_vel_this),
},
}
}
pub fn record_epoch(rec: &FrameRecord) -> Option<SecondsSince<TDB>> {
rec.epoch.map(SecondsSince::<TDB>::from_seconds)
}
pub fn load_document(doc: &FrameDocument) -> Result<FrameTree, LoadError> {
doc.validate()?;
let mut record_of_uid: Vec<Option<usize>> = vec![None; doc.uids.len()];
for (pos, rec) in doc.records.iter().enumerate() {
record_of_uid[rec.uid_index as usize] = Some(pos);
}
for (pos, rec) in doc.records.iter().enumerate() {
if let Some(p) = rec.parent {
if record_of_uid[p as usize].is_none() {
return Err(LoadError::UnresolvedParent {
record: pos,
name: rec.name.clone(),
parent_uid: doc.uids[p as usize].clone(),
});
}
}
}
let mut tree = FrameTree::new();
let mut placed: Vec<Option<FrameId>> = vec![None; doc.records.len()];
let mut placed_count = 0;
while placed_count < doc.records.len() {
let mut progressed = false;
for (pos, rec) in doc.records.iter().enumerate() {
if placed[pos].is_some() {
continue;
}
let fid = match rec.parent {
None => {
let uid = doc.uids[rec.uid_index as usize].clone();
if !uid.class.may_be_root_or_integ() {
return Err(LoadError::NonInertialRoot {
record: pos,
name: rec.name.clone(),
uid,
});
}
let fid = tree.add_root_uid(uid, rec.name.clone());
tree.get_mut(fid).state = record_state(rec);
tree.set_epoch(fid, record_epoch(rec));
fid
}
Some(p) => {
let parent_pos =
record_of_uid[p as usize].expect("checked unresolvable parents above");
let Some(parent_fid) = placed[parent_pos] else {
continue; };
tree.add_child_uid(
parent_fid,
doc.uids[rec.uid_index as usize].clone(),
rec.name.clone(),
record_state(rec),
record_epoch(rec),
)
}
};
placed[pos] = Some(fid);
placed_count += 1;
progressed = true;
}
if !progressed {
let names: Vec<String> = placed
.iter()
.zip(&doc.records)
.filter(|(p, _)| p.is_none())
.map(|(_, r)| r.name.clone())
.collect();
return Err(LoadError::Cycle { names });
}
}
tree.validate_forest()?;
Ok(tree)
}
#[cfg(test)]
mod tests {
use super::*;
use astrodyn_frame_doc::{Conventions, SCHEMA_VERSION};
use astrodyn_quantities::frame::{Earth, PlanetFixed, PlanetInertial, RootInertial};
fn header() -> DocHeader {
DocHeader {
schema_version: SCHEMA_VERSION,
conventions: Conventions::current(),
simtime: 100.0,
tai_tjt_at_epoch: 213.818,
}
}
fn stamped_tree() -> FrameTree {
let mut tree = FrameTree::new();
let root = tree.add_root_typed::<RootInertial>("root".into());
tree.set_epoch(root, Some(SecondsSince::from_seconds(100.0)));
let earth = tree.add_child_uid(
root,
FrameUid::of::<PlanetInertial<Earth>>(),
"Earth.inertial".into(),
RefFrameState {
trans: RefFrameTrans {
position: DVec3::new(1.0e9, -2.0e9, 3.0e9),
velocity: DVec3::new(10.0, -20.0, 30.0),
},
rot: RefFrameRot::default(),
},
Some(SecondsSince::from_seconds(100.0)),
);
let rotation = JeodQuat::left_quat_from_eigen_rotation(0.7, DVec3::new(0.1, 0.2, 0.97))
.left_quat_to_transformation();
tree.add_child_uid(
earth,
FrameUid::of::<PlanetFixed<Earth>>(),
"Earth.pfix".into(),
RefFrameState {
trans: RefFrameTrans::default(),
rot: RefFrameRot {
q_parent_this: JeodQuat::left_quat_from_transformation(&rotation),
t_parent_this: rotation,
ang_vel_this: DVec3::new(0.0, 0.0, 7.292_115_1e-5),
},
},
Some(SecondsSince::from_seconds(100.0)),
);
tree
}
fn export(tree: &FrameTree) -> FrameDocument {
export_tree(tree, header(), |_| Origin::Injected)
}
#[test]
fn export_load_round_trips_identity_topology_state() {
let tree = stamped_tree();
let doc = export(&tree);
assert!(matches!(
doc.records[2].rotation,
CanonicalRotation::Matrix(_)
));
assert!(matches!(
doc.records[0].rotation,
CanonicalRotation::Quat(_)
));
let loaded = load_document(&doc).expect("load");
assert_eq!(loaded.len(), tree.len());
for id in 0..tree.len() {
let (a, b) = (tree.get(id), loaded.get(id));
assert_eq!(a.uid(), b.uid(), "identity must round-trip");
assert_eq!(
tree.parent(id),
loaded.parent(id),
"topology must round-trip"
);
assert_eq!(a.name, b.name);
assert_eq!(
a.epoch.map(|e| e.as_seconds().to_bits()),
b.epoch.map(|e| e.as_seconds().to_bits()),
"epoch must round-trip bit-exactly"
);
assert_eq!(
a.state.trans.position.to_array().map(f64::to_bits),
b.state.trans.position.to_array().map(f64::to_bits)
);
assert_eq!(
a.state.trans.velocity.to_array().map(f64::to_bits),
b.state.trans.velocity.to_array().map(f64::to_bits)
);
assert_eq!(
a.state.rot.q_parent_this.data.map(f64::to_bits),
b.state.rot.q_parent_this.data.map(f64::to_bits),
"quaternion (node {id})"
);
assert_eq!(
a.state.rot.t_parent_this.to_cols_array().map(f64::to_bits),
b.state.rot.t_parent_this.to_cols_array().map(f64::to_bits),
"matrix (node {id})"
);
assert_eq!(
a.state.rot.ang_vel_this.to_array().map(f64::to_bits),
b.state.rot.ang_vel_this.to_array().map(f64::to_bits)
);
}
}
#[test]
fn export_load_round_trips_through_json() {
let tree = stamped_tree();
let json = export(&tree).to_json_string();
let doc = FrameDocument::from_json_str(&json).expect("parse");
let loaded = load_document(&doc).expect("load");
for id in 0..tree.len() {
assert_eq!(
tree.get(id)
.state
.rot
.t_parent_this
.to_cols_array()
.map(f64::to_bits),
loaded
.get(id)
.state
.rot
.t_parent_this
.to_cols_array()
.map(f64::to_bits),
"JSON round trip drifted (node {id})"
);
}
}
#[test]
fn load_rejects_dangling_parent() {
let mut doc = export(&stamped_tree());
doc.records.remove(1);
match load_document(&doc).err() {
Some(LoadError::UnresolvedParent { parent_uid, .. }) => {
assert_eq!(parent_uid, FrameUid::of::<PlanetInertial<Earth>>());
}
other => panic!("expected UnresolvedParent, got {other:?}"),
}
}
#[test]
fn load_rejects_parent_cycle() {
let mut doc = export(&stamped_tree());
doc.records[1].parent = Some(doc.records[2].uid_index);
match load_document(&doc).err() {
Some(LoadError::Cycle { names }) => {
assert_eq!(
names,
vec!["Earth.inertial".to_string(), "Earth.pfix".into()]
);
}
other => panic!("expected Cycle, got {other:?}"),
}
}
#[test]
fn load_rejects_non_inertial_root() {
let mut doc = export(&stamped_tree());
doc.records[2].parent = None;
assert!(matches!(
load_document(&doc).err(),
Some(LoadError::NonInertialRoot { record: 2, .. })
));
}
#[test]
fn load_assigns_frame_ids_in_document_order_for_interleaved_depth() {
let mut tree = stamped_tree(); let root = 0;
tree.add_child_uid(
root,
FrameUid::external(
astrodyn_quantities::frame_descriptor::Namespace(1),
astrodyn_frame_doc::FrameClass::Body,
astrodyn_frame_doc::FrameRole::CompositeBody,
astrodyn_frame_doc::Tag::Named("iss".into()),
),
"body_0.integ".into(),
RefFrameState::default(),
Some(SecondsSince::from_seconds(100.0)),
);
let loaded = load_document(&export(&tree)).expect("load");
assert_eq!(loaded.len(), 4);
for id in 0..tree.len() {
assert_eq!(
tree.get(id).uid(),
loaded.get(id).uid(),
"FrameId {id} must hold the same identity as the producer's arena"
);
assert_eq!(tree.parent(id), loaded.parent(id));
}
}
#[test]
fn load_accepts_multi_root_forest() {
let mut tree = FrameTree::new();
let r = tree.add_root_typed::<RootInertial>("root".into());
tree.set_epoch(r, Some(SecondsSince::from_seconds(1.0)));
let r2 = tree.add_root_uid(
FrameUid::of::<PlanetInertial<Earth>>(),
"imported-root".into(),
);
tree.set_epoch(r2, Some(SecondsSince::from_seconds(1.0)));
let doc = export(&tree);
let loaded = load_document(&doc).expect("forest loads");
assert_eq!(loaded.len(), 2);
assert_eq!(loaded.parent(0), None);
assert_eq!(loaded.parent(1), None);
}
}