Skip to main content

sidereon_core/
frame_catalog.rs

1//! Epoch-aware terrestrial reference-frame transformations.
2//!
3//! The catalog contains published 14-parameter Helmert transforms between
4//! ITRF/ETRF realizations. Epochs are decimal years, positions are geocentric
5//! Cartesian metres, and station velocities are metres per year.
6
7use core::fmt;
8
9use crate::astro::math::linear::invert_3x3_adjugate;
10use crate::astro::math::mat3::{mul_vec3, Mat3};
11use crate::astro::math::vec3::{add3, scale3, sub3};
12use crate::frame::ItrfPositionM;
13
14const MM_TO_M: f64 = 1.0e-3;
15const PPB_TO_SCALE: f64 = 1.0e-9;
16const MAS_TO_RAD: f64 = core::f64::consts::PI / (180.0 * 3600.0 * 1000.0);
17
18/// A supported terrestrial reference-frame realization.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum TerrestrialFrame {
21    /// International Terrestrial Reference Frame 2020.
22    Itrf2020,
23    /// International Terrestrial Reference Frame 2014.
24    Itrf2014,
25    /// International Terrestrial Reference Frame 2008.
26    Itrf2008,
27    /// European Terrestrial Reference Frame 2020.
28    Etrf2020,
29}
30
31impl TerrestrialFrame {
32    fn index(self) -> usize {
33        match self {
34            Self::Itrf2020 => 0,
35            Self::Itrf2014 => 1,
36            Self::Itrf2008 => 2,
37            Self::Etrf2020 => 3,
38        }
39    }
40}
41
42impl fmt::Display for TerrestrialFrame {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Self::Itrf2020 => f.write_str("ITRF2020"),
46            Self::Itrf2014 => f.write_str("ITRF2014"),
47            Self::Itrf2008 => f.write_str("ITRF2008"),
48            Self::Etrf2020 => f.write_str("ETRF2020"),
49        }
50    }
51}
52
53/// Cartesian position in a named terrestrial frame, in metres.
54#[derive(Debug, Clone, Copy, PartialEq)]
55pub struct TerrestrialPositionM {
56    /// Geocentric X coordinate in metres.
57    pub x_m: f64,
58    /// Geocentric Y coordinate in metres.
59    pub y_m: f64,
60    /// Geocentric Z coordinate in metres.
61    pub z_m: f64,
62}
63
64impl TerrestrialPositionM {
65    /// Construct a finite Cartesian terrestrial position in metres.
66    pub const fn new(x_m: f64, y_m: f64, z_m: f64) -> Result<Self, FrameCatalogError> {
67        if !x_m.is_finite() {
68            return Err(invalid_input("x_m", "must be finite"));
69        }
70        if !y_m.is_finite() {
71            return Err(invalid_input("y_m", "must be finite"));
72        }
73        if !z_m.is_finite() {
74            return Err(invalid_input("z_m", "must be finite"));
75        }
76        Ok(Self { x_m, y_m, z_m })
77    }
78
79    /// Construct a finite Cartesian terrestrial position from `[x, y, z]`.
80    pub const fn from_array(position_m: [f64; 3]) -> Result<Self, FrameCatalogError> {
81        Self::new(position_m[0], position_m[1], position_m[2])
82    }
83
84    /// Return the Cartesian components as `[x, y, z]` metres.
85    pub const fn as_array(self) -> [f64; 3] {
86        [self.x_m, self.y_m, self.z_m]
87    }
88
89    /// Convert a frame-tagged ITRF position into the catalog position type.
90    pub const fn from_itrf(position: ItrfPositionM) -> Self {
91        Self {
92            x_m: position.x_m,
93            y_m: position.y_m,
94            z_m: position.z_m,
95        }
96    }
97}
98
99impl From<ItrfPositionM> for TerrestrialPositionM {
100    fn from(position: ItrfPositionM) -> Self {
101        Self::from_itrf(position)
102    }
103}
104
105/// Cartesian station velocity in a named terrestrial frame, in metres per year.
106#[derive(Debug, Clone, Copy, PartialEq)]
107pub struct TerrestrialVelocityMPerYear {
108    /// Geocentric X velocity in metres per year.
109    pub vx_m_per_year: f64,
110    /// Geocentric Y velocity in metres per year.
111    pub vy_m_per_year: f64,
112    /// Geocentric Z velocity in metres per year.
113    pub vz_m_per_year: f64,
114}
115
116impl TerrestrialVelocityMPerYear {
117    /// Construct a finite Cartesian station velocity in metres per year.
118    pub const fn new(
119        vx_m_per_year: f64,
120        vy_m_per_year: f64,
121        vz_m_per_year: f64,
122    ) -> Result<Self, FrameCatalogError> {
123        if !vx_m_per_year.is_finite() {
124            return Err(invalid_input("vx_m_per_year", "must be finite"));
125        }
126        if !vy_m_per_year.is_finite() {
127            return Err(invalid_input("vy_m_per_year", "must be finite"));
128        }
129        if !vz_m_per_year.is_finite() {
130            return Err(invalid_input("vz_m_per_year", "must be finite"));
131        }
132        Ok(Self {
133            vx_m_per_year,
134            vy_m_per_year,
135            vz_m_per_year,
136        })
137    }
138
139    /// Construct a finite Cartesian station velocity from `[vx, vy, vz]`.
140    pub const fn from_array(velocity_m_per_year: [f64; 3]) -> Result<Self, FrameCatalogError> {
141        Self::new(
142            velocity_m_per_year[0],
143            velocity_m_per_year[1],
144            velocity_m_per_year[2],
145        )
146    }
147
148    /// Return the Cartesian components as `[vx, vy, vz]` metres per year.
149    pub const fn as_array(self) -> [f64; 3] {
150        [self.vx_m_per_year, self.vy_m_per_year, self.vz_m_per_year]
151    }
152}
153
154/// A transformed terrestrial position and optional station velocity.
155#[derive(Debug, Clone, Copy, PartialEq)]
156pub struct TerrestrialState {
157    /// Transformed Cartesian position in the target frame.
158    pub position: TerrestrialPositionM,
159    /// Transformed station velocity in the target frame, when supplied.
160    pub velocity: Option<TerrestrialVelocityMPerYear>,
161}
162
163/// Helmert parameters in the units used by the published tables.
164#[derive(Debug, Clone, Copy, PartialEq)]
165pub struct HelmertParameters {
166    /// Translation components `[Tx, Ty, Tz]`, in millimetres.
167    pub translation_mm: [f64; 3],
168    /// Scale difference `D`, in parts per billion.
169    pub scale_ppb: f64,
170    /// Rotation components `[Rx, Ry, Rz]`, in milliarcseconds.
171    pub rotation_mas: [f64; 3],
172}
173
174/// Helmert parameter rates in the units used by the published tables.
175#[derive(Debug, Clone, Copy, PartialEq)]
176pub struct HelmertRates {
177    /// Translation rates `[Tx, Ty, Tz]`, in millimetres per year.
178    pub translation_mm_per_year: [f64; 3],
179    /// Scale rate `D`, in parts per billion per year.
180    pub scale_ppb_per_year: f64,
181    /// Rotation rates `[Rx, Ry, Rz]`, in milliarcseconds per year.
182    pub rotation_mas_per_year: [f64; 3],
183}
184
185/// One published 14-parameter Helmert catalog entry.
186#[derive(Debug, Clone, Copy, PartialEq)]
187pub struct HelmertTransform {
188    /// Source frame for the published forward transform.
189    pub from: TerrestrialFrame,
190    /// Target frame for the published forward transform.
191    pub to: TerrestrialFrame,
192    /// Parameter reference epoch, expressed as a decimal year.
193    pub reference_epoch_year: f64,
194    /// Parameters at [`reference_epoch_year`](Self::reference_epoch_year).
195    pub parameters: HelmertParameters,
196    /// Linear rates of the seven Helmert parameters.
197    pub rates: HelmertRates,
198    /// Published table or memo that supplied this entry.
199    pub provenance: &'static str,
200}
201
202impl HelmertTransform {
203    /// Evaluate the seven Helmert parameters at a decimal year.
204    pub fn parameters_at(&self, epoch_year: f64) -> Result<HelmertParameters, FrameCatalogError> {
205        validate_epoch(epoch_year)?;
206        validate_finite("reference_epoch_year", self.reference_epoch_year)?;
207        validate_helmert_parameters(&self.parameters)?;
208        validate_helmert_rates(&self.rates)?;
209        let dt_years = epoch_year - self.reference_epoch_year;
210        validate_finite("epoch_delta_years", dt_years)?;
211        let parameters = HelmertParameters {
212            translation_mm: [
213                self.parameters.translation_mm[0]
214                    + self.rates.translation_mm_per_year[0] * dt_years,
215                self.parameters.translation_mm[1]
216                    + self.rates.translation_mm_per_year[1] * dt_years,
217                self.parameters.translation_mm[2]
218                    + self.rates.translation_mm_per_year[2] * dt_years,
219            ],
220            scale_ppb: self.parameters.scale_ppb + self.rates.scale_ppb_per_year * dt_years,
221            rotation_mas: [
222                self.parameters.rotation_mas[0] + self.rates.rotation_mas_per_year[0] * dt_years,
223                self.parameters.rotation_mas[1] + self.rates.rotation_mas_per_year[1] * dt_years,
224                self.parameters.rotation_mas[2] + self.rates.rotation_mas_per_year[2] * dt_years,
225            ],
226        };
227        validate_helmert_parameters(&parameters)?;
228        Ok(parameters)
229    }
230
231    fn evaluated(&self, epoch_year: f64) -> Result<EvaluatedHelmert, FrameCatalogError> {
232        let parameters = self.parameters_at(epoch_year)?;
233        Ok(EvaluatedHelmert {
234            translation_m: scale3(parameters.translation_mm, MM_TO_M),
235            scale: parameters.scale_ppb * PPB_TO_SCALE,
236            rotation_rad: scale3(parameters.rotation_mas, MAS_TO_RAD),
237            translation_rate_m_per_year: scale3(self.rates.translation_mm_per_year, MM_TO_M),
238            scale_rate_per_year: self.rates.scale_ppb_per_year * PPB_TO_SCALE,
239            rotation_rate_rad_per_year: scale3(self.rates.rotation_mas_per_year, MAS_TO_RAD),
240        })
241    }
242}
243
244/// Errors returned by terrestrial frame catalog operations.
245#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
246pub enum FrameCatalogError {
247    /// An input value was non-finite or outside its documented domain.
248    #[error("invalid frame catalog {field}: {reason}")]
249    InvalidInput {
250        /// Name of the rejected input field.
251        field: &'static str,
252        /// Reason the field was rejected.
253        reason: &'static str,
254    },
255    /// No sequence of built-in catalog entries connects the requested frames.
256    #[error("no terrestrial frame catalog path from {from} to {to}")]
257    NoCatalogPath {
258        /// Requested source frame.
259        from: TerrestrialFrame,
260        /// Requested target frame.
261        to: TerrestrialFrame,
262    },
263    /// The affine Helmert matrix was singular and could not be inverted.
264    #[error("singular terrestrial frame transform from {from} to {to} at epoch {epoch_year}")]
265    SingularTransform {
266        /// Published source frame for the transform being inverted.
267        from: TerrestrialFrame,
268        /// Published target frame for the transform being inverted.
269        to: TerrestrialFrame,
270        /// Decimal year used to evaluate the transform.
271        epoch_year: f64,
272    },
273}
274
275/// Built-in published terrestrial Helmert catalog entries.
276pub const TERRESTRIAL_FRAME_CATALOG: &[HelmertTransform] = &[
277    HelmertTransform {
278        from: TerrestrialFrame::Itrf2020,
279        to: TerrestrialFrame::Itrf2014,
280        reference_epoch_year: 2015.0,
281        parameters: HelmertParameters {
282            translation_mm: [-1.4, -0.9, 1.4],
283            scale_ppb: -0.42,
284            rotation_mas: [0.0, 0.0, 0.0],
285        },
286        rates: HelmertRates {
287            translation_mm_per_year: [0.0, -0.1, 0.2],
288            scale_ppb_per_year: 0.0,
289            rotation_mas_per_year: [0.0, 0.0, 0.0],
290        },
291        provenance: "ITRF/IGN Transfo-ITRF2020_TRFs.txt, ITRF2020 to past ITRFs, epoch 2015.0",
292    },
293    HelmertTransform {
294        from: TerrestrialFrame::Itrf2020,
295        to: TerrestrialFrame::Itrf2008,
296        reference_epoch_year: 2015.0,
297        parameters: HelmertParameters {
298            translation_mm: [0.2, 1.0, 3.3],
299            scale_ppb: -0.29,
300            rotation_mas: [0.0, 0.0, 0.0],
301        },
302        rates: HelmertRates {
303            translation_mm_per_year: [0.0, -0.1, 0.1],
304            scale_ppb_per_year: 0.03,
305            rotation_mas_per_year: [0.0, 0.0, 0.0],
306        },
307        provenance: "ITRF/IGN Transfo-ITRF2020_TRFs.txt, ITRF2020 to past ITRFs, epoch 2015.0",
308    },
309    HelmertTransform {
310        from: TerrestrialFrame::Itrf2014,
311        to: TerrestrialFrame::Itrf2008,
312        reference_epoch_year: 2010.0,
313        parameters: HelmertParameters {
314            translation_mm: [1.6, 1.9, 2.4],
315            scale_ppb: -0.02,
316            rotation_mas: [0.0, 0.0, 0.0],
317        },
318        rates: HelmertRates {
319            translation_mm_per_year: [0.0, 0.0, -0.1],
320            scale_ppb_per_year: 0.03,
321            rotation_mas_per_year: [0.0, 0.0, 0.0],
322        },
323        provenance: "IERS Technical Note 38, Table 2, ITRF2014 to ITRF2008, epoch 2010.0",
324    },
325    HelmertTransform {
326        from: TerrestrialFrame::Itrf2020,
327        to: TerrestrialFrame::Etrf2020,
328        reference_epoch_year: 2015.0,
329        parameters: HelmertParameters {
330            translation_mm: [0.0, 0.0, 0.0],
331            scale_ppb: 0.0,
332            rotation_mas: [2.236, 13.494, -19.578],
333        },
334        rates: HelmertRates {
335            translation_mm_per_year: [0.0, 0.0, 0.0],
336            scale_ppb_per_year: 0.0,
337            rotation_mas_per_year: [0.086, 0.519, -0.753],
338        },
339        provenance: "EUREF Technical Note 1, Table 2, ITRF2020 to ETRF2020, epoch 2015.0",
340    },
341];
342
343/// Return the built-in terrestrial frame catalog.
344pub fn catalog() -> &'static [HelmertTransform] {
345    TERRESTRIAL_FRAME_CATALOG
346}
347
348/// Return the published catalog entry for the requested forward direction.
349pub fn catalog_entry(
350    from: TerrestrialFrame,
351    to: TerrestrialFrame,
352) -> Option<&'static HelmertTransform> {
353    TERRESTRIAL_FRAME_CATALOG
354        .iter()
355        .find(|entry| entry.from == from && entry.to == to)
356}
357
358/// Propagate a station position from one decimal year to another.
359pub fn propagate_position(
360    position: TerrestrialPositionM,
361    velocity: TerrestrialVelocityMPerYear,
362    from_epoch_year: f64,
363    to_epoch_year: f64,
364) -> Result<TerrestrialPositionM, FrameCatalogError> {
365    validate_epoch(from_epoch_year)?;
366    validate_epoch(to_epoch_year)?;
367    let position = TerrestrialPositionM::from_array(position.as_array())?;
368    let velocity = TerrestrialVelocityMPerYear::from_array(velocity.as_array())?;
369    let dt_years = to_epoch_year - from_epoch_year;
370    let propagated = add3(position.as_array(), scale3(velocity.as_array(), dt_years));
371    TerrestrialPositionM::from_array(propagated)
372}
373
374/// Propagate a station to a transform epoch, then transform it between frames.
375pub fn transform_from_epoch(
376    position: TerrestrialPositionM,
377    velocity: TerrestrialVelocityMPerYear,
378    position_epoch_year: f64,
379    from: TerrestrialFrame,
380    to: TerrestrialFrame,
381    transform_epoch_year: f64,
382) -> Result<TerrestrialState, FrameCatalogError> {
383    let position_at_transform_epoch = propagate_position(
384        position,
385        velocity,
386        position_epoch_year,
387        transform_epoch_year,
388    )?;
389    transform(
390        position_at_transform_epoch,
391        Some(velocity),
392        from,
393        to,
394        transform_epoch_year,
395    )
396}
397
398/// Transform a Cartesian station position and optional velocity between frames.
399pub fn transform(
400    position: TerrestrialPositionM,
401    velocity: Option<TerrestrialVelocityMPerYear>,
402    from: TerrestrialFrame,
403    to: TerrestrialFrame,
404    epoch_year: f64,
405) -> Result<TerrestrialState, FrameCatalogError> {
406    validate_epoch(epoch_year)?;
407    let position = TerrestrialPositionM::from_array(position.as_array())?;
408    let velocity = velocity
409        .map(|value| TerrestrialVelocityMPerYear::from_array(value.as_array()))
410        .transpose()?;
411    if from == to {
412        return Ok(TerrestrialState { position, velocity });
413    }
414
415    let path = resolve_path(from, to)?;
416    let mut state = WorkingState {
417        position_m: position.as_array(),
418        velocity_m_per_year: velocity.map(TerrestrialVelocityMPerYear::as_array),
419    };
420    for step in path.iter().take_while(|step| step.entry.is_some()) {
421        let step = step.entry.expect("filtered step");
422        state = apply_step(state, step, epoch_year)?;
423    }
424
425    Ok(TerrestrialState {
426        position: TerrestrialPositionM::from_array(state.position_m)?,
427        velocity: state
428            .velocity_m_per_year
429            .map(TerrestrialVelocityMPerYear::from_array)
430            .transpose()?,
431    })
432}
433
434const fn invalid_input(field: &'static str, reason: &'static str) -> FrameCatalogError {
435    FrameCatalogError::InvalidInput { field, reason }
436}
437
438fn validate_epoch(epoch_year: f64) -> Result<(), FrameCatalogError> {
439    validate_finite("epoch_year", epoch_year)
440}
441
442fn validate_finite(field: &'static str, value: f64) -> Result<(), FrameCatalogError> {
443    if value.is_finite() {
444        Ok(())
445    } else {
446        Err(invalid_input(field, "must be finite"))
447    }
448}
449
450fn validate_array3(fields: [&'static str; 3], values: [f64; 3]) -> Result<(), FrameCatalogError> {
451    validate_finite(fields[0], values[0])?;
452    validate_finite(fields[1], values[1])?;
453    validate_finite(fields[2], values[2])
454}
455
456fn validate_helmert_parameters(parameters: &HelmertParameters) -> Result<(), FrameCatalogError> {
457    validate_array3(
458        [
459            "translation_mm[0]",
460            "translation_mm[1]",
461            "translation_mm[2]",
462        ],
463        parameters.translation_mm,
464    )?;
465    validate_finite("scale_ppb", parameters.scale_ppb)?;
466    validate_array3(
467        ["rotation_mas[0]", "rotation_mas[1]", "rotation_mas[2]"],
468        parameters.rotation_mas,
469    )
470}
471
472fn validate_helmert_rates(rates: &HelmertRates) -> Result<(), FrameCatalogError> {
473    validate_array3(
474        [
475            "translation_mm_per_year[0]",
476            "translation_mm_per_year[1]",
477            "translation_mm_per_year[2]",
478        ],
479        rates.translation_mm_per_year,
480    )?;
481    validate_finite("scale_ppb_per_year", rates.scale_ppb_per_year)?;
482    validate_array3(
483        [
484            "rotation_mas_per_year[0]",
485            "rotation_mas_per_year[1]",
486            "rotation_mas_per_year[2]",
487        ],
488        rates.rotation_mas_per_year,
489    )
490}
491
492#[derive(Debug, Clone, Copy)]
493struct EvaluatedHelmert {
494    translation_m: [f64; 3],
495    scale: f64,
496    rotation_rad: [f64; 3],
497    translation_rate_m_per_year: [f64; 3],
498    scale_rate_per_year: f64,
499    rotation_rate_rad_per_year: [f64; 3],
500}
501
502impl EvaluatedHelmert {
503    fn matrix(self) -> Mat3 {
504        let [rx, ry, rz] = self.rotation_rad;
505        [
506            [1.0 + self.scale, -rz, ry],
507            [rz, 1.0 + self.scale, -rx],
508            [-ry, rx, 1.0 + self.scale],
509        ]
510    }
511
512    fn rate_term(self, position_m: [f64; 3]) -> [f64; 3] {
513        let [rx, ry, rz] = self.rotation_rate_rad_per_year;
514        let scale = self.scale_rate_per_year;
515        [
516            self.translation_rate_m_per_year[0] + scale * position_m[0] - rz * position_m[1]
517                + ry * position_m[2],
518            self.translation_rate_m_per_year[1] + rz * position_m[0] + scale * position_m[1]
519                - rx * position_m[2],
520            self.translation_rate_m_per_year[2] - ry * position_m[0]
521                + rx * position_m[1]
522                + scale * position_m[2],
523        ]
524    }
525}
526
527#[derive(Debug, Clone, Copy)]
528struct WorkingState {
529    position_m: [f64; 3],
530    velocity_m_per_year: Option<[f64; 3]>,
531}
532
533#[derive(Debug, Clone, Copy)]
534struct PathStep {
535    entry: Option<DirectedEntry>,
536}
537
538#[derive(Debug, Clone, Copy)]
539struct DirectedEntry {
540    transform: &'static HelmertTransform,
541    reverse: bool,
542}
543
544fn apply_step(
545    state: WorkingState,
546    step: DirectedEntry,
547    epoch_year: f64,
548) -> Result<WorkingState, FrameCatalogError> {
549    let evaluated = step.transform.evaluated(epoch_year)?;
550    if step.reverse {
551        apply_reverse(state, step.transform, evaluated, epoch_year)
552    } else {
553        Ok(apply_forward(state, evaluated))
554    }
555}
556
557fn apply_forward(state: WorkingState, evaluated: EvaluatedHelmert) -> WorkingState {
558    let matrix = evaluated.matrix();
559    let position_m = add3(evaluated.translation_m, mul_vec3(&matrix, state.position_m));
560    let velocity_m_per_year = state
561        .velocity_m_per_year
562        .map(|velocity| add3(velocity, evaluated.rate_term(state.position_m)));
563    WorkingState {
564        position_m,
565        velocity_m_per_year,
566    }
567}
568
569fn apply_reverse(
570    state: WorkingState,
571    transform: &HelmertTransform,
572    evaluated: EvaluatedHelmert,
573    epoch_year: f64,
574) -> Result<WorkingState, FrameCatalogError> {
575    let matrix = evaluated.matrix();
576    let inverse = invert_3x3_adjugate(&matrix).ok_or(FrameCatalogError::SingularTransform {
577        from: transform.from,
578        to: transform.to,
579        epoch_year,
580    })?;
581    let position_m = mul_vec3(&inverse, sub3(state.position_m, evaluated.translation_m));
582    let velocity_m_per_year = state
583        .velocity_m_per_year
584        .map(|velocity| sub3(velocity, evaluated.rate_term(position_m)));
585    Ok(WorkingState {
586        position_m,
587        velocity_m_per_year,
588    })
589}
590
591fn resolve_path(
592    from: TerrestrialFrame,
593    to: TerrestrialFrame,
594) -> Result<[PathStep; 3], FrameCatalogError> {
595    let frames = [
596        TerrestrialFrame::Itrf2020,
597        TerrestrialFrame::Itrf2014,
598        TerrestrialFrame::Itrf2008,
599        TerrestrialFrame::Etrf2020,
600    ];
601    let mut queue = [TerrestrialFrame::Itrf2020; 4];
602    let mut visited = [false; 4];
603    let mut predecessor: [Option<(TerrestrialFrame, DirectedEntry)>; 4] = [None; 4];
604    let mut head = 0_usize;
605    let mut tail = 0_usize;
606
607    visited[from.index()] = true;
608    queue[tail] = from;
609    tail += 1;
610
611    while head < tail {
612        let current = queue[head];
613        head += 1;
614        if current == to {
615            break;
616        }
617
618        for neighbor in frames {
619            if visited[neighbor.index()] {
620                continue;
621            }
622            if let Some(step) = directed_entry(current, neighbor) {
623                visited[neighbor.index()] = true;
624                predecessor[neighbor.index()] = Some((current, step));
625                queue[tail] = neighbor;
626                tail += 1;
627            }
628        }
629    }
630
631    if !visited[to.index()] {
632        return Err(FrameCatalogError::NoCatalogPath { from, to });
633    }
634
635    let mut reversed = [PathStep { entry: None }; 3];
636    let mut count = 0_usize;
637    let mut current = to;
638    while current != from {
639        let Some((previous, step)) = predecessor[current.index()] else {
640            return Err(FrameCatalogError::NoCatalogPath { from, to });
641        };
642        if count == reversed.len() {
643            return Err(FrameCatalogError::NoCatalogPath { from, to });
644        }
645        reversed[count] = PathStep { entry: Some(step) };
646        count += 1;
647        current = previous;
648    }
649
650    let mut path = [PathStep { entry: None }; 3];
651    for i in 0..count {
652        path[i] = reversed[count - 1 - i];
653    }
654    Ok(path)
655}
656
657fn directed_entry(from: TerrestrialFrame, to: TerrestrialFrame) -> Option<DirectedEntry> {
658    TERRESTRIAL_FRAME_CATALOG.iter().find_map(|entry| {
659        if entry.from == from && entry.to == to {
660            Some(DirectedEntry {
661                transform: entry,
662                reverse: false,
663            })
664        } else if entry.from == to && entry.to == from {
665            Some(DirectedEntry {
666                transform: entry,
667                reverse: true,
668            })
669        } else {
670            None
671        }
672    })
673}