Skip to main content

oxiproj_transformations/
frame_chain.rs

1//! ITRF/ETRF/NAD83/GDA frame-chain pathfinding and epoch-aware Helmert application.
2//!
3//! Provides a catalogue of 7-parameter (14-parameter with rates) Helmert
4//! transforms between reference-frame realisations, plus utilities to
5//!
6//! * find a *direct* transform ([`find_frame_transform`]),
7//! * find a *multi-hop* path through the transform graph
8//!   ([`find_frame_path`]) using breadth-first search, and
9//! * apply a transform (or a whole path) forward and inverse at an arbitrary
10//!   decimal-year epoch ([`apply_frame_transform`], [`apply_frame_path`]).
11//!
12//! All numeric parameters are taken verbatim from the IERS/EPSG published
13//! transformation tables that PROJ 9.8 ships in its `data/ITRF*` init files and
14//! `proj.db`, so every entry can be cross-checked against the `cct`/`cs2cs`
15//! binaries. Two rotation-sign conventions coexist in those sources
16//! ([`RotationConvention`]); each catalogue entry records the convention of its
17//! source so the values can be compared to PROJ 1:1.
18
19use oxiproj_core::ProjResult;
20
21/// Rotation-sign convention of a Helmert transform.
22///
23/// The two conventions differ only in the sign of the three rotation
24/// parameters (and their rates); the rotation matrices are transposes of one
25/// another. IERS ITRF↔ITRF and ITRF↔ETRF transforms are published in the
26/// *position-vector* convention, whereas the EPSG ITRF↔NAD83 and ITRF↔GDA2020
27/// operations use the *coordinate-frame* convention.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum RotationConvention {
30    /// Position-vector (a.k.a. "Helmert", IERS) convention.
31    ///
32    /// ```text
33    /// R = [  1  -rz   ry ]
34    ///     [ rz    1  -rx ]
35    ///     [-ry   rx    1  ]
36    /// ```
37    PositionVector,
38    /// Coordinate-frame (a.k.a. "Bursa-Wolf", EPSG geocentric) convention —
39    /// the transpose of [`RotationConvention::PositionVector`] (rotations of
40    /// opposite sign).
41    CoordinateFrame,
42}
43
44/// A 7-parameter Helmert transform (with temporal rates) between two frames.
45///
46/// All parameters follow the IERS/EPSG conventions:
47/// - translations in mm (rate in mm/yr)
48/// - scale in parts per billion (rate in ppb/yr)
49/// - rotations in milli-arc-seconds (rate in mas/yr), interpreted in
50///   [`FrameTransform::convention`].
51#[derive(Debug, Clone, Copy)]
52pub struct FrameTransform {
53    /// Source frame name (e.g. `"ITRF2020"`).
54    pub from: &'static str,
55    /// Target frame name (e.g. `"ITRF2014"`).
56    pub to: &'static str,
57    /// Reference epoch (decimal year) at which the published parameters apply.
58    pub ref_epoch: f64,
59    // Translations (mm)
60    pub tx_mm: f64,
61    pub ty_mm: f64,
62    pub tz_mm: f64,
63    // Translation rates (mm/yr)
64    pub dtx_mm: f64,
65    pub dty_mm: f64,
66    pub dtz_mm: f64,
67    // Scale (ppb) and rate (ppb/yr)
68    pub scale_ppb: f64,
69    pub dscale_ppb: f64,
70    // Rotations (mas) and rates (mas/yr)
71    pub rx_mas: f64,
72    pub ry_mas: f64,
73    pub rz_mas: f64,
74    pub drx_mas: f64,
75    pub dry_mas: f64,
76    pub drz_mas: f64,
77    /// Sign convention the rotation parameters are expressed in.
78    pub convention: RotationConvention,
79}
80
81use RotationConvention::{CoordinateFrame, PositionVector};
82
83/// Static catalogue of inter-frame Helmert transforms.
84///
85/// Each entry represents the transform FROM `from` TO `to` at `ref_epoch`,
86/// and is taken verbatim (converted to mm/ppb/mas units) from the PROJ 9.8
87/// `data/ITRF2020`, `data/ITRF2014` init files and `proj.db` EPSG operations:
88///
89/// | Entry | Source | PROJ cross-check |
90/// |-------|--------|------------------|
91/// | ITRF2020→ITRF2014/2008/2005/2000 | `data/ITRF2020` (Altamimi et al. 2023) | `cct +init=ITRF2020:ITRF20xx` |
92/// | ITRF2014→ITRF2008 | `data/ITRF2014` (Altamimi et al. 2016) | `cct +init=ITRF2014:ITRF2008` |
93/// | ITRF2000→ETRF2000 | EPSG "ITRF2000 to ETRF2000 (1)" | `projinfo -s ETRF2000 -t ITRF2000` |
94/// | ITRF2014→ETRF2014 | EPSG "ITRF2014 to ETRF2014 (1)" | `projinfo -s ETRF2014 -t ITRF2014` |
95/// | ITRF2014→NAD83(2011) | EPSG "ITRF2014 to NAD83(2011) (1)" | `projinfo -s NAD83(2011) -t ITRF2014` |
96/// | ITRF2014→GDA2020 | EPSG "ITRF2014 to GDA2020 (1)" | `projinfo -s GDA2020 -t ITRF2014` |
97///
98/// The graph is connected via the ITRF2020 hub (ITRF2020↔{2014,2008,2005,2000})
99/// plus ITRF2014→{2008, ETRF2014, NAD83(2011), GDA2020} and ITRF2000→ETRF2000,
100/// so [`find_frame_path`] can reach every frame (multi-hop where required).
101pub const FRAME_TRANSFORMS: &[FrameTransform] = &[
102    // ITRF2020 → ITRF2014  (ref_epoch = 2015.0)
103    // Source: PROJ data/ITRF2020, +x=-0.0014 +y=-0.0009 +z=0.0014 +s=-0.00042
104    //         +dy=-0.0001 +dz=0.0002 +t_epoch=2015 +convention=position_vector
105    FrameTransform {
106        from: "ITRF2020",
107        to: "ITRF2014",
108        ref_epoch: 2015.0,
109        tx_mm: -1.4,
110        ty_mm: -0.9,
111        tz_mm: 1.4,
112        dtx_mm: 0.0,
113        dty_mm: -0.1,
114        dtz_mm: 0.2,
115        scale_ppb: -0.42,
116        dscale_ppb: 0.0,
117        rx_mas: 0.0,
118        ry_mas: 0.0,
119        rz_mas: 0.0,
120        drx_mas: 0.0,
121        dry_mas: 0.0,
122        drz_mas: 0.0,
123        convention: PositionVector,
124    },
125    // ITRF2020 → ITRF2008  (ref_epoch = 2015.0)
126    // Source: PROJ data/ITRF2020, +x=0.0002 +y=0.001 +z=0.0033 +s=-0.00029
127    //         +dy=-0.0001 +dz=0.0001 +ds=3e-05 +t_epoch=2015 +convention=position_vector
128    FrameTransform {
129        from: "ITRF2020",
130        to: "ITRF2008",
131        ref_epoch: 2015.0,
132        tx_mm: 0.2,
133        ty_mm: 1.0,
134        tz_mm: 3.3,
135        dtx_mm: 0.0,
136        dty_mm: -0.1,
137        dtz_mm: 0.1,
138        scale_ppb: -0.29,
139        dscale_ppb: 0.03,
140        rx_mas: 0.0,
141        ry_mas: 0.0,
142        rz_mas: 0.0,
143        drx_mas: 0.0,
144        dry_mas: 0.0,
145        drz_mas: 0.0,
146        convention: PositionVector,
147    },
148    // ITRF2020 → ITRF2005  (ref_epoch = 2015.0)
149    // Source: PROJ data/ITRF2020, +x=0.0027 +y=0.0001 +z=-0.0014 +s=0.00065
150    //         +dx=0.0003 +dy=-0.0001 +dz=0.0001 +ds=3e-05 +t_epoch=2015 +convention=position_vector
151    FrameTransform {
152        from: "ITRF2020",
153        to: "ITRF2005",
154        ref_epoch: 2015.0,
155        tx_mm: 2.7,
156        ty_mm: 0.1,
157        tz_mm: -1.4,
158        dtx_mm: 0.3,
159        dty_mm: -0.1,
160        dtz_mm: 0.1,
161        scale_ppb: 0.65,
162        dscale_ppb: 0.03,
163        rx_mas: 0.0,
164        ry_mas: 0.0,
165        rz_mas: 0.0,
166        drx_mas: 0.0,
167        dry_mas: 0.0,
168        drz_mas: 0.0,
169        convention: PositionVector,
170    },
171    // ITRF2020 → ITRF2000  (ref_epoch = 2015.0)
172    // Source: PROJ data/ITRF2020, +x=-0.0002 +y=0.0008 +z=-0.0342 +s=0.00225
173    //         +dx=0.0001 +dz=-0.0017 +ds=0.00011 +t_epoch=2015 +convention=position_vector
174    FrameTransform {
175        from: "ITRF2020",
176        to: "ITRF2000",
177        ref_epoch: 2015.0,
178        tx_mm: -0.2,
179        ty_mm: 0.8,
180        tz_mm: -34.2,
181        dtx_mm: 0.1,
182        dty_mm: 0.0,
183        dtz_mm: -1.7,
184        scale_ppb: 2.25,
185        dscale_ppb: 0.11,
186        rx_mas: 0.0,
187        ry_mas: 0.0,
188        rz_mas: 0.0,
189        drx_mas: 0.0,
190        dry_mas: 0.0,
191        drz_mas: 0.0,
192        convention: PositionVector,
193    },
194    // ITRF2014 → ITRF2008  (ref_epoch = 2010.0)
195    // Source: PROJ data/ITRF2014 (Altamimi et al. 2016, Table 2),
196    //         +x=0.0016 +y=0.0019 +z=0.0024 +s=-0.00002 +dz=-0.0001 +ds=3e-05
197    //         +t_epoch=2010 +convention=position_vector
198    FrameTransform {
199        from: "ITRF2014",
200        to: "ITRF2008",
201        ref_epoch: 2010.0,
202        tx_mm: 1.6,
203        ty_mm: 1.9,
204        tz_mm: 2.4,
205        dtx_mm: 0.0,
206        dty_mm: 0.0,
207        dtz_mm: -0.1,
208        scale_ppb: -0.02,
209        dscale_ppb: 0.03,
210        rx_mas: 0.0,
211        ry_mas: 0.0,
212        rz_mas: 0.0,
213        drx_mas: 0.0,
214        dry_mas: 0.0,
215        drz_mas: 0.0,
216        convention: PositionVector,
217    },
218    // ITRF2000 → ETRF2000  (ref_epoch = 2000.0)
219    // Source: EPSG "ITRF2000 to ETRF2000 (1)", position-vector,
220    //         +x=0.054 +y=0.051 +z=-0.048 +rx=0.000891 +ry=0.00539 +rz=-0.008712
221    //         +drx=8.1e-05 +dry=0.00049 +drz=-0.000792 +t_epoch=2000
222    FrameTransform {
223        from: "ITRF2000",
224        to: "ETRF2000",
225        ref_epoch: 2000.0,
226        tx_mm: 54.0,
227        ty_mm: 51.0,
228        tz_mm: -48.0,
229        dtx_mm: 0.0,
230        dty_mm: 0.0,
231        dtz_mm: 0.0,
232        scale_ppb: 0.0,
233        dscale_ppb: 0.0,
234        rx_mas: 0.891,
235        ry_mas: 5.39,
236        rz_mas: -8.712,
237        drx_mas: 0.081,
238        dry_mas: 0.49,
239        drz_mas: -0.792,
240        convention: PositionVector,
241    },
242    // ITRF2014 → ETRF2014  (ref_epoch = 2010.0)
243    // Source: EPSG "ITRF2014 to ETRF2014 (1)", position-vector,
244    //         +rx=0.001785 +ry=0.011151 +rz=-0.01617 +drx=8.5e-05 +dry=0.000531
245    //         +drz=-0.00077 +t_epoch=2010
246    FrameTransform {
247        from: "ITRF2014",
248        to: "ETRF2014",
249        ref_epoch: 2010.0,
250        tx_mm: 0.0,
251        ty_mm: 0.0,
252        tz_mm: 0.0,
253        dtx_mm: 0.0,
254        dty_mm: 0.0,
255        dtz_mm: 0.0,
256        scale_ppb: 0.0,
257        dscale_ppb: 0.0,
258        rx_mas: 1.785,
259        ry_mas: 11.151,
260        rz_mas: -16.17,
261        drx_mas: 0.085,
262        dry_mas: 0.531,
263        drz_mas: -0.77,
264        convention: PositionVector,
265    },
266    // ITRF2014 → NAD83(2011)  (ref_epoch = 2010.0)
267    // Source: EPSG "ITRF2014 to NAD83(2011) (1)", coordinate-frame,
268    //         +x=1.0053 +y=-1.90921 +z=-0.54157 +rx=0.02678138 +ry=-0.00042027
269    //         +rz=0.01093206 +s=0.00036891 +dx=0.00079 +dy=-0.0006 +dz=-0.00144
270    //         +drx=6.667e-05 +dry=-0.00075744 +drz=-5.133e-05 +ds=-7.201e-05 +t_epoch=2010
271    FrameTransform {
272        from: "ITRF2014",
273        to: "NAD83(2011)",
274        ref_epoch: 2010.0,
275        tx_mm: 1005.3,
276        ty_mm: -1909.21,
277        tz_mm: -541.57,
278        dtx_mm: 0.79,
279        dty_mm: -0.6,
280        dtz_mm: -1.44,
281        scale_ppb: 0.36891,
282        dscale_ppb: -0.07201,
283        rx_mas: 26.78138,
284        ry_mas: -0.42027,
285        rz_mas: 10.93206,
286        drx_mas: 0.06667,
287        dry_mas: -0.75744,
288        drz_mas: -0.05133,
289        convention: CoordinateFrame,
290    },
291    // ITRF2014 → GDA2020  (ref_epoch = 2020.0)
292    // Source: EPSG "ITRF2014 to GDA2020 (1)", coordinate-frame,
293    //         +drx=0.00150379 +dry=0.00118346 +drz=0.00120716 +t_epoch=2020
294    //         (all static parameters zero — GDA2020 ≡ ITRF2014 @ 2020.0, plate rotation only)
295    FrameTransform {
296        from: "ITRF2014",
297        to: "GDA2020",
298        ref_epoch: 2020.0,
299        tx_mm: 0.0,
300        ty_mm: 0.0,
301        tz_mm: 0.0,
302        dtx_mm: 0.0,
303        dty_mm: 0.0,
304        dtz_mm: 0.0,
305        scale_ppb: 0.0,
306        dscale_ppb: 0.0,
307        rx_mas: 0.0,
308        ry_mas: 0.0,
309        rz_mas: 0.0,
310        drx_mas: 1.50379,
311        dry_mas: 1.18346,
312        drz_mas: 1.20716,
313        convention: CoordinateFrame,
314    },
315];
316
317/// Search [`FRAME_TRANSFORMS`] for a direct transform between two frames.
318///
319/// Comparison is case-insensitive. Returns `None` when no entry matches.
320pub fn find_frame_transform<'a>(from: &str, to: &str) -> Option<&'a FrameTransform> {
321    FRAME_TRANSFORMS
322        .iter()
323        .find(|ft| ft.from.eq_ignore_ascii_case(from) && ft.to.eq_ignore_ascii_case(to))
324}
325
326/// One hop in a multi-hop frame-transform path.
327#[derive(Debug, Clone, Copy)]
328pub struct FramePathStep {
329    /// The catalogue transform to apply.
330    pub transform: &'static FrameTransform,
331    /// When `true`, apply the transform in the inverse direction.
332    pub inverse: bool,
333}
334
335/// Find a path of Helmert transforms connecting `from` to `to`.
336///
337/// Performs a breadth-first search over the undirected transform graph induced
338/// by [`FRAME_TRANSFORMS`] (each catalogue entry contributes a forward edge
339/// `from→to` and an inverse edge `to→from`). Returns the shortest chain of
340/// [`FramePathStep`]s, or `None` when the two frames are not connected.
341///
342/// A request where `from` and `to` name the same frame (case-insensitively)
343/// returns `Some(empty path)`.
344///
345/// # Examples
346///
347/// ```
348/// use oxiproj_transformations::frame_chain::find_frame_path;
349///
350/// // Direct edge (1 hop).
351/// assert_eq!(find_frame_path("ITRF2020", "ITRF2014").map(|p| p.len()), Some(1));
352/// // Requires composing two hops through the ITRF2020 hub.
353/// assert_eq!(find_frame_path("ITRF2014", "ITRF2000").map(|p| p.len()), Some(2));
354/// ```
355#[must_use]
356pub fn find_frame_path(from: &str, to: &str) -> Option<Vec<FramePathStep>> {
357    if from.eq_ignore_ascii_case(to) {
358        return Some(Vec::new());
359    }
360
361    // BFS. The graph is tiny (a handful of nodes), so a linear-scan visited
362    // list and path-carrying queue are more than adequate and keep the code
363    // allocation-simple and `no_std`-friendly.
364    let mut visited: Vec<&str> = Vec::new();
365    let mut queue: Vec<(&str, Vec<FramePathStep>)> = Vec::new();
366
367    visited.push(from);
368    queue.push((from, Vec::new()));
369
370    let mut head = 0usize;
371    while head < queue.len() {
372        let (node, path) = queue[head].clone();
373        head += 1;
374
375        for ft in FRAME_TRANSFORMS {
376            // Forward edge: node == ft.from  → neighbour ft.to
377            // Inverse edge: node == ft.to    → neighbour ft.from
378            let (neighbour, inverse) = if ft.from.eq_ignore_ascii_case(node) {
379                (ft.to, false)
380            } else if ft.to.eq_ignore_ascii_case(node) {
381                (ft.from, true)
382            } else {
383                continue;
384            };
385
386            if visited.iter().any(|v| v.eq_ignore_ascii_case(neighbour)) {
387                continue;
388            }
389
390            let mut next_path = path.clone();
391            next_path.push(FramePathStep {
392                transform: ft,
393                inverse,
394            });
395
396            if neighbour.eq_ignore_ascii_case(to) {
397                return Some(next_path);
398            }
399
400            visited.push(neighbour);
401            queue.push((neighbour, next_path));
402        }
403    }
404
405    None
406}
407
408/// Apply a Helmert frame transform at a given decimal-year `epoch`.
409///
410/// Uses a linearised (small-angle) Helmert model in the *position-vector*
411/// convention:
412///
413/// ```text
414/// [xout]   [tx]         [  1  -rz  ry ] [x]
415/// [yout] = [ty] + scale·[ rz   1  -rx ]·[y]
416/// [zout]   [tz]         [-ry  rx   1  ] [z]
417/// ```
418///
419/// When [`FrameTransform::convention`] is
420/// [`RotationConvention::CoordinateFrame`], the three rotation angles are
421/// negated first (turning the coordinate-frame rotation matrix into its
422/// position-vector transpose) before this matrix is applied, so the same
423/// evaluation kernel serves both conventions.
424///
425/// Parameters are propagated from `transform.ref_epoch` to `epoch` using the
426/// published rates before the transform is applied.
427///
428/// # Errors
429///
430/// This function always succeeds; the return type is `ProjResult` for API
431/// consistency with the rest of the pipeline.
432pub fn apply_frame_transform(
433    transform: &FrameTransform,
434    x: f64,
435    y: f64,
436    z: f64,
437    epoch: f64,
438) -> ProjResult<(f64, f64, f64)> {
439    let dt = epoch - transform.ref_epoch;
440
441    let tx = (transform.tx_mm + transform.dtx_mm * dt) * 1e-3;
442    let ty = (transform.ty_mm + transform.dty_mm * dt) * 1e-3;
443    let tz = (transform.tz_mm + transform.dtz_mm * dt) * 1e-3;
444
445    let scale = 1.0 + (transform.scale_ppb + transform.dscale_ppb * dt) * 1e-9;
446
447    // 1 mas = π / (180 × 3_600_000) radians
448    const MAS_TO_RAD: f64 = core::f64::consts::PI / (180.0 * 3_600_000.0);
449
450    // Sign that converts the stored convention into position-vector rotations.
451    let conv_sign = match transform.convention {
452        RotationConvention::PositionVector => 1.0,
453        RotationConvention::CoordinateFrame => -1.0,
454    };
455
456    let rx = conv_sign * (transform.rx_mas + transform.drx_mas * dt) * MAS_TO_RAD;
457    let ry = conv_sign * (transform.ry_mas + transform.dry_mas * dt) * MAS_TO_RAD;
458    let rz = conv_sign * (transform.rz_mas + transform.drz_mas * dt) * MAS_TO_RAD;
459
460    let xout = tx + scale * (x - rz * y + ry * z);
461    let yout = ty + scale * (rz * x + y - rx * z);
462    let zout = tz + scale * (-ry * x + rx * y + z);
463
464    Ok((xout, yout, zout))
465}
466
467/// Apply the inverse of a Helmert frame transform at a given decimal-year `epoch`.
468///
469/// Constructs an inverted [`FrameTransform`] by negating all translation,
470/// scale, and rotation parameters (and their rates) while keeping the same
471/// [`RotationConvention`], then delegates to [`apply_frame_transform`]. This is
472/// the standard small-angle inverse (`X = -T + (1−s)·R(−θ)·X'`), accurate to
473/// well below a micrometre for all catalogue entries.
474pub fn apply_frame_transform_inverse(
475    transform: &FrameTransform,
476    x: f64,
477    y: f64,
478    z: f64,
479    epoch: f64,
480) -> ProjResult<(f64, f64, f64)> {
481    let inv = FrameTransform {
482        from: transform.from,
483        to: transform.to,
484        ref_epoch: transform.ref_epoch,
485        tx_mm: -transform.tx_mm,
486        ty_mm: -transform.ty_mm,
487        tz_mm: -transform.tz_mm,
488        dtx_mm: -transform.dtx_mm,
489        dty_mm: -transform.dty_mm,
490        dtz_mm: -transform.dtz_mm,
491        scale_ppb: -transform.scale_ppb,
492        dscale_ppb: -transform.dscale_ppb,
493        rx_mas: -transform.rx_mas,
494        ry_mas: -transform.ry_mas,
495        rz_mas: -transform.rz_mas,
496        drx_mas: -transform.drx_mas,
497        dry_mas: -transform.dry_mas,
498        drz_mas: -transform.drz_mas,
499        convention: transform.convention,
500    };
501    apply_frame_transform(&inv, x, y, z, epoch)
502}
503
504/// Apply a whole [`find_frame_path`] result to a coordinate at `epoch`.
505///
506/// Each hop is evaluated at the same coordinate `epoch` (the observation
507/// epoch); a frame change never alters the coordinate epoch. An empty path
508/// (same source and target frame) returns the input unchanged.
509pub fn apply_frame_path(
510    path: &[FramePathStep],
511    x: f64,
512    y: f64,
513    z: f64,
514    epoch: f64,
515) -> ProjResult<(f64, f64, f64)> {
516    let mut cur = (x, y, z);
517    for step in path {
518        cur = if step.inverse {
519            apply_frame_transform_inverse(step.transform, cur.0, cur.1, cur.2, epoch)?
520        } else {
521            apply_frame_transform(step.transform, cur.0, cur.1, cur.2, epoch)?
522        };
523    }
524    Ok(cur)
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    #[test]
532    fn frame_transform_itrf2020_to_itrf2014_found() {
533        let ft = find_frame_transform("ITRF2020", "ITRF2014");
534        assert!(ft.is_some(), "ITRF2020->ITRF2014 transform not found");
535    }
536
537    /// The catalogue entries must match the IERS/EPSG published values exactly
538    /// (as shipped in PROJ 9.8 `data/ITRF2020`, converted mm/ppb/mas).
539    #[test]
540    fn catalogue_itrf2020_to_itrf2014_matches_published_iers() {
541        let ft = find_frame_transform("ITRF2020", "ITRF2014").unwrap();
542        assert_eq!(ft.ref_epoch, 2015.0);
543        assert_eq!(ft.tx_mm, -1.4);
544        // Regression guard for the historical sign bug: IERS/PROJ give T2 = −0.9 mm.
545        assert_eq!(ft.ty_mm, -0.9, "ITRF2020->ITRF2014 T2 must be -0.9 mm");
546        assert_eq!(ft.tz_mm, 1.4);
547        assert_eq!(ft.dty_mm, -0.1);
548        assert_eq!(ft.dtz_mm, 0.2);
549        assert_eq!(ft.scale_ppb, -0.42);
550        assert_eq!(ft.convention, RotationConvention::PositionVector);
551    }
552
553    /// Empirical cross-check of the ITRF2020→ITRF2014 transform at the reference
554    /// epoch (2015.0) against `cct +init=ITRF2020:ITRF2014` from Homebrew PROJ:
555    ///   in : 4627798.0 119795.0 4369668.0 @2015
556    ///   out: 4627797.996656 119794.999050 4369667.999565
557    #[test]
558    fn frame_transform_itrf2020_to_itrf2014_matches_cct() {
559        let ft = find_frame_transform("ITRF2020", "ITRF2014").unwrap();
560        let (x, y, z) =
561            apply_frame_transform(ft, 4_627_798.0, 119_795.0, 4_369_668.0, 2015.0).expect("apply");
562        assert!((x - 4_627_797.996_656).abs() < 5e-6, "x={x}");
563        assert!((y - 119_794.999_050).abs() < 5e-6, "y={y}");
564        assert!((z - 4_369_667.999_565).abs() < 5e-6, "z={z}");
565    }
566
567    /// ITRF2020→ITRF2008/2005/2000 direct hops vs `cct +init=ITRF2020:ITRF20xx`.
568    #[test]
569    fn frame_transform_itrf2020_star_matches_cct() {
570        let cases: &[(&str, (f64, f64, f64))] = &[
571            (
572                "ITRF2008",
573                (4_627_797.998_858, 119_795.000_965, 4_369_668.002_033),
574            ),
575            (
576                "ITRF2005",
577                (4_627_798.005_708, 119_795.000_178, 4_369_668.001_440),
578            ),
579            (
580                "ITRF2000",
581                (4_627_798.010_213, 119_795.001_070, 4_369_667.975_632),
582            ),
583        ];
584        for (target, (ex, ey, ez)) in cases {
585            let ft = find_frame_transform("ITRF2020", target).unwrap();
586            let (x, y, z) =
587                apply_frame_transform(ft, 4_627_798.0, 119_795.0, 4_369_668.0, 2015.0).unwrap();
588            assert!((x - ex).abs() < 5e-6, "{target} x={x}");
589            assert!((y - ey).abs() < 5e-6, "{target} y={y}");
590            assert!((z - ez).abs() < 5e-6, "{target} z={z}");
591        }
592    }
593
594    #[test]
595    fn frame_transform_inverse_reverses_forward() {
596        let ft = find_frame_transform("ITRF2020", "ITRF2014").unwrap();
597        let x0 = 4_627_798.0_f64;
598        let y0 = 119_795.0_f64;
599        let z0 = 4_369_668.0_f64;
600        let (xf, yf, zf) = apply_frame_transform(ft, x0, y0, z0, 2015.0).unwrap();
601        let (xr, yr, zr) = apply_frame_transform_inverse(ft, xf, yf, zf, 2015.0).unwrap();
602        assert!(
603            (xr - x0).abs() < 0.001,
604            "x roundtrip error: {}",
605            (xr - x0).abs()
606        );
607        assert!(
608            (yr - y0).abs() < 0.001,
609            "y roundtrip error: {}",
610            (yr - y0).abs()
611        );
612        assert!(
613            (zr - z0).abs() < 0.001,
614            "z roundtrip error: {}",
615            (zr - z0).abs()
616        );
617    }
618
619    #[test]
620    fn frame_transform_unknown_returns_none() {
621        assert!(find_frame_transform("ITRF1900", "ITRF2014").is_none());
622    }
623
624    #[test]
625    fn find_frame_path_same_frame_is_empty() {
626        let p = find_frame_path("ITRF2020", "itrf2020").expect("same frame path");
627        assert!(p.is_empty());
628    }
629
630    #[test]
631    fn find_frame_path_direct_is_one_hop() {
632        let p = find_frame_path("ITRF2020", "ITRF2014").expect("direct");
633        assert_eq!(p.len(), 1);
634        assert!(!p[0].inverse);
635        assert_eq!(p[0].transform.to, "ITRF2014");
636    }
637
638    #[test]
639    fn find_frame_path_inverse_direct() {
640        let p = find_frame_path("ITRF2014", "ITRF2020").expect("inverse direct");
641        assert_eq!(p.len(), 1);
642        assert!(p[0].inverse);
643        assert_eq!(p[0].transform.from, "ITRF2020");
644    }
645
646    #[test]
647    fn find_frame_path_two_hop_itrf2014_to_itrf2000() {
648        // No direct ITRF2014→ITRF2000 edge: must go via the ITRF2020 hub.
649        let p = find_frame_path("ITRF2014", "ITRF2000").expect("2-hop");
650        assert_eq!(p.len(), 2);
651    }
652
653    #[test]
654    fn find_frame_path_reaches_gda2020_and_etrf_and_nad83() {
655        assert!(find_frame_path("ITRF2020", "GDA2020").is_some());
656        assert!(find_frame_path("ITRF2020", "NAD83(2011)").is_some());
657        assert!(find_frame_path("ITRF2020", "ETRF2000").is_some());
658        assert!(find_frame_path("ITRF2020", "ETRF2014").is_some());
659        // NAD83(2011)→GDA2020 spans NAD83→ITRF2014→GDA2020 (2 hops).
660        assert_eq!(
661            find_frame_path("NAD83(2011)", "GDA2020").map(|p| p.len()),
662            Some(2)
663        );
664    }
665
666    #[test]
667    fn find_frame_path_disconnected_returns_none() {
668        assert!(find_frame_path("ITRF2020", "MADE_UP_FRAME").is_none());
669    }
670
671    /// Multi-hop composition ITRF2014→ITRF2020→ITRF2000 at 2015.0 must match the
672    /// equivalent PROJ pipeline
673    ///   cct +proj=pipeline +step +inv +init=ITRF2020:ITRF2014
674    ///                      +step +init=ITRF2020:ITRF2000
675    ///   in : 4627798.0 119795.0 4369668.0 @2015
676    ///   out: 4627798.013556 119795.002020 4369667.976067
677    #[test]
678    fn apply_frame_path_two_hop_matches_cct_pipeline() {
679        let path = find_frame_path("ITRF2014", "ITRF2000").expect("2-hop");
680        let (x, y, z) =
681            apply_frame_path(&path, 4_627_798.0, 119_795.0, 4_369_668.0, 2015.0).unwrap();
682        assert!((x - 4_627_798.013_556).abs() < 1e-5, "x={x}");
683        assert!((y - 119_795.002_020).abs() < 1e-5, "y={y}");
684        assert!((z - 4_369_667.976_067).abs() < 1e-5, "z={z}");
685    }
686
687    /// ITRF2014→NAD83(2011) (coordinate-frame convention, with rates) vs
688    /// `cct +proj=helmert ... +convention=coordinate_frame`:
689    ///   in : -1300000 -4700000 4000000 @2010 → -1299999.236130 -4700001.322685 4000000.072801
690    ///   in : -1300000 -4700000 4000000 @2020 → -1299999.068711 -4700001.315606 4000000.118451
691    #[test]
692    fn coordinate_frame_nad83_matches_cct() {
693        let ft = find_frame_transform("ITRF2014", "NAD83(2011)").unwrap();
694        let (x, y, z) =
695            apply_frame_transform(ft, -1_300_000.0, -4_700_000.0, 4_000_000.0, 2010.0).unwrap();
696        assert!((x - (-1_299_999.236_130)).abs() < 5e-6, "x2010={x}");
697        assert!((y - (-4_700_001.322_685)).abs() < 5e-6, "y2010={y}");
698        assert!((z - 4_000_000.072_801).abs() < 5e-6, "z2010={z}");
699
700        let (x2, y2, z2) =
701            apply_frame_transform(ft, -1_300_000.0, -4_700_000.0, 4_000_000.0, 2020.0).unwrap();
702        assert!((x2 - (-1_299_999.068_711)).abs() < 5e-6, "x2020={x2}");
703        assert!((y2 - (-4_700_001.315_606)).abs() < 5e-6, "y2020={y2}");
704        assert!((z2 - 4_000_000.118_451).abs() < 5e-6, "z2020={z2}");
705    }
706
707    /// ITRF2014→GDA2020 (coordinate-frame, rotation-rate only) vs cct at 2015:
708    ///   in : -4000000 2500000 -3800000 @2015 → -4000000.182170 2500000.021471 -3799999.794116
709    #[test]
710    fn coordinate_frame_gda2020_matches_cct() {
711        let ft = find_frame_transform("ITRF2014", "GDA2020").unwrap();
712        let (x, y, z) =
713            apply_frame_transform(ft, -4_000_000.0, 2_500_000.0, -3_800_000.0, 2015.0).unwrap();
714        assert!((x - (-4_000_000.182_170)).abs() < 5e-6, "x={x}");
715        assert!((y - 2_500_000.021_471).abs() < 5e-6, "y={y}");
716        assert!((z - (-3_799_999.794_116)).abs() < 5e-6, "z={z}");
717    }
718
719    /// Full 3-hop NAD83(2011)→ITRF2014→GDA2020 vs the equivalent PROJ pipeline
720    /// at 2020.0:
721    ///   in : -4000000 2500000 -3800000 @2020 → -4000000.993599 2500002.219715 -3799999.267721
722    #[test]
723    fn apply_frame_path_nad83_to_gda2020_matches_cct_pipeline() {
724        let path = find_frame_path("NAD83(2011)", "GDA2020").expect("path");
725        let (x, y, z) =
726            apply_frame_path(&path, -4_000_000.0, 2_500_000.0, -3_800_000.0, 2020.0).unwrap();
727        // Small-angle inverse of the NAD83 leg differs from PROJ's exact inverse
728        // by sub-micrometre; 0.1 mm tolerance is comfortable.
729        assert!((x - (-4_000_000.993_599)).abs() < 1e-4, "x={x}");
730        assert!((y - 2_500_002.219_715).abs() < 1e-4, "y={y}");
731        assert!((z - (-3_799_999.267_721)).abs() < 1e-4, "z={z}");
732    }
733}