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}