Skip to main content

siderust/coordinates/transform/
ext.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! # Coordinate Extension Traits
5//!
6//! This module provides extension traits that add ergonomic transformation
7//! methods to `affn` coordinate types. These traits enable method-chaining
8//! style transformations with compile-time type safety.
9//!
10//! ## Design
11//!
12//! The default API uses IAU models with no context argument required:
13//!
14//! - `to_frame::<F2>(jd_tt)`, Rotate to a new reference frame.
15//! - `to::<C2, F2>(jd_tt)`, Combined center and frame transformation.
16//!
17//! Center-only transforms are exposed on the [`TransformCenter`] trait:
18//!
19//! - `pos.to_center(params, jd)`, Shift to a new reference center (all variants).
20//! - `pos.to_center_with(params, jd, &ctx)`, Same with a custom context.
21//!
22//! For expert overrides, a `_with` suffix variant accepts any
23//! [`crate::coordinates::transform::context::TransformContext`]:
24//!
25//! - `to_frame_with::<F2>(jd_tt, &ctx)`, Frame rotation with custom context.
26//! - `to_with::<C2, F2>(jd_tt, &ctx)`, Combined transform with custom context.
27//!
28//! A plain [`AstroContext`] uses the default IAU 2006A model. To bind another
29//! model at compile time, derive a [`ModelContext`](crate::coordinates::transform::context::ModelContext)
30//! with [`AstroContext::with_model`](crate::coordinates::transform::context::AstroContext::with_model):
31//!
32//! ```rust,ignore
33//! use siderust::astro::nutation::Iau2000B;
34//!
35//! let ctx = AstroContext::new();
36//! let low_cost = ctx.with_model::<Iau2000B>();
37//! let dir = dir.to_frame_with::<EclipticMeanJ2000>(&jd, &low_cost);
38//! ```
39//!
40//! Alternatively, wrap a coordinate with a custom context using
41//! [`WithEngine`] and use the same method names:
42//!
43//! ```rust,ignore
44//! coord.using(&engine).to_frame::<F2>(&jd);
45//! ```
46//!
47//! ## Transformation Order
48//!
49//! For combined transformations (`to`), the order is:
50//! 1. **Center first** (in source frame): Translate the position.
51//! 2. **Then frame**: Rotate to the target frame.
52//!
53//! This order is chosen because:
54//! - Center shifts depend on body positions which are frame-dependent.
55//! - Shifting in the source frame before rotating is more intuitive.
56//!
57//! ## Example
58//!
59//! ```rust
60//! use siderust::coordinates::transform::ext::PositionAstroExt;
61//! use siderust::coordinates::cartesian::Position;
62//! use siderust::coordinates::centers::{Barycentric, Geocentric};
63//! use siderust::coordinates::frames::{EclipticMeanJ2000, ICRS};
64//! use siderust::time::JulianDate;
65//! use siderust::qtty::AstronomicalUnit;
66//!
67//! let pos = Position::<Barycentric, EclipticMeanJ2000, AstronomicalUnit>::new(1.0, 0.5, 0.2);
68//! let jd = siderust::J2000;
69//!
70//! // Transform to Geocentric ICRS, no context needed
71//! let geo_icrs: Position<Geocentric, ICRS, AstronomicalUnit> = pos.to(&jd);
72//! ```
73
74use crate::coordinates::cartesian::{Direction, Position, Vector};
75use crate::coordinates::centers::{Geodetic, ReferenceCenter};
76use crate::coordinates::frames::{ReferenceFrame, ECEF};
77use crate::coordinates::spherical;
78use crate::coordinates::transform::centers::TransformCenter;
79use crate::coordinates::transform::context::{
80    AstroContext, DefaultEop, DefaultEphemeris, TransformContext,
81};
82use crate::coordinates::transform::providers::{
83    frame_rotation_with, CenterShiftProvider, FrameRotationProvider,
84};
85use crate::qtty::{LengthUnit, Unit};
86use crate::time::JulianDate;
87use affn::Rotation3;
88
89// =============================================================================
90// DirectionAstroExt - Extension trait for Direction<F>
91// =============================================================================
92
93/// Extension trait for `Direction<F>` providing frame transformations.
94///
95/// Directions are unit vectors (translation-invariant), so only frame
96/// rotations apply. Center transformations are not meaningful for directions.
97pub trait DirectionAstroExt<F: ReferenceFrame> {
98    /// Rotates this direction to a new reference frame using IAU defaults.
99    ///
100    /// # Type Parameters
101    ///
102    /// - `F2`: The target reference frame.
103    ///
104    /// # Arguments
105    ///
106    /// - `jd`: The Julian Date (TT) for time-dependent rotations.
107    fn to_frame<F2: ReferenceFrame>(&self, jd: &JulianDate) -> Direction<F2>
108    where
109        (): FrameRotationProvider<F, F2>;
110
111    /// Rotates this direction to a new reference frame with a custom context.
112    fn to_frame_with<F2: ReferenceFrame, Ctx>(&self, jd: &JulianDate, ctx: &Ctx) -> Direction<F2>
113    where
114        Ctx: TransformContext,
115        (): FrameRotationProvider<F, F2>;
116
117    /// Converts this direction to ecliptic-of-date coordinates (convenience).
118    ///
119    /// Available for ICRS and GCRS directions via the provider system.
120    fn to_ecliptic_of_date(
121        &self,
122        jd_tt: &JulianDate,
123    ) -> Direction<crate::coordinates::frames::EclipticTrueOfDate>
124    where
125        Self: crate::coordinates::transform::ecliptic_of_date::ToEclipticTrueOfDate,
126    {
127        crate::coordinates::transform::ecliptic_of_date::ToEclipticTrueOfDate::to_ecliptic_of_date(
128            self, jd_tt,
129        )
130    }
131
132    /// Converts this direction to horizontal coordinates using TT only.
133    ///
134    /// UT1 is derived from TT using the context's EOP provider
135    /// (`IersEop` by default), which applies the IERS `dUT1 = UT1 − UTC`
136    /// correction on top of tempoch's leap-second chain. For sub-second
137    /// precision, prefer [`to_horizontal_precise`] with an explicit UT1
138    /// value.
139    ///
140    /// [`to_horizontal_precise`]: Self::to_horizontal_precise
141    fn to_horizontal(
142        &self,
143        jd_tt: &JulianDate,
144        site: &Geodetic<ECEF>,
145    ) -> Direction<crate::coordinates::frames::Horizontal>
146    where
147        Self: crate::coordinates::transform::horizontal::ToHorizontal,
148    {
149        let ctx: AstroContext<DefaultEphemeris, DefaultEop> = AstroContext::default();
150        let eop = ctx.eop_at_tt(*jd_tt);
151        let jd_ut1 = crate::astro::earth_rotation::jd_ut1_from_tt_eop(*jd_tt, &eop);
152        crate::coordinates::transform::horizontal::ToHorizontal::to_horizontal(
153            self, &jd_ut1, jd_tt, site,
154        )
155    }
156
157    /// Converts this direction to horizontal coordinates with explicit UT1+TT.
158    ///
159    /// Use this when you have a precise UT1 value (e.g. from IERS EOP).
160    fn to_horizontal_precise(
161        &self,
162        jd_tt: &JulianDate,
163        jd_ut1: &JulianDate,
164        site: &Geodetic<ECEF>,
165    ) -> Direction<crate::coordinates::frames::Horizontal>
166    where
167        Self: crate::coordinates::transform::horizontal::ToHorizontal,
168    {
169        crate::coordinates::transform::horizontal::ToHorizontal::to_horizontal(
170            self, jd_ut1, jd_tt, site,
171        )
172    }
173}
174
175impl<F: ReferenceFrame> DirectionAstroExt<F> for Direction<F> {
176    fn to_frame<F2: ReferenceFrame>(&self, jd: &JulianDate) -> Direction<F2>
177    where
178        (): FrameRotationProvider<F, F2>,
179    {
180        let ctx: AstroContext = AstroContext::default();
181        self.to_frame_with(jd, &ctx)
182    }
183
184    fn to_frame_with<F2: ReferenceFrame, Ctx>(&self, jd: &JulianDate, ctx: &Ctx) -> Direction<F2>
185    where
186        Ctx: TransformContext,
187        (): FrameRotationProvider<F, F2>,
188    {
189        let rot: Rotation3 = frame_rotation_with::<F, F2, Ctx>(*jd, ctx);
190        let [x, y, z] = rot.apply_array([self.x(), self.y(), self.z()]);
191        // The result is still normalized (rotations preserve length)
192        Direction::new_unchecked(x, y, z)
193    }
194}
195
196// =============================================================================
197// SphericalDirectionAstroExt - Extension trait for spherical::Direction<F>
198// =============================================================================
199
200/// Extension trait for `spherical::Direction<F>` providing time-dependent
201/// frame transformations via the provider system.
202///
203/// This is the spherical counterpart of [`DirectionAstroExt`]. Internally,
204/// it converts to a cartesian [`Direction`], applies the rotation, and converts
205/// back.
206pub trait SphericalDirectionAstroExt<F: ReferenceFrame> {
207    /// Rotates this spherical direction to a new reference frame (IAU defaults).
208    fn to_frame<F2: ReferenceFrame>(&self, jd: &JulianDate) -> spherical::Direction<F2>
209    where
210        (): FrameRotationProvider<F, F2>;
211
212    /// Rotates this spherical direction to a new reference frame with custom context.
213    fn to_frame_with<F2: ReferenceFrame, Ctx>(
214        &self,
215        jd: &JulianDate,
216        ctx: &Ctx,
217    ) -> spherical::Direction<F2>
218    where
219        Ctx: TransformContext,
220        (): FrameRotationProvider<F, F2>;
221}
222
223impl<F: ReferenceFrame> SphericalDirectionAstroExt<F> for spherical::Direction<F> {
224    fn to_frame<F2: ReferenceFrame>(&self, jd: &JulianDate) -> spherical::Direction<F2>
225    where
226        (): FrameRotationProvider<F, F2>,
227    {
228        let ctx: AstroContext = AstroContext::default();
229        self.to_frame_with(jd, &ctx)
230    }
231
232    fn to_frame_with<F2: ReferenceFrame, Ctx>(
233        &self,
234        jd: &JulianDate,
235        ctx: &Ctx,
236    ) -> spherical::Direction<F2>
237    where
238        Ctx: TransformContext,
239        (): FrameRotationProvider<F, F2>,
240    {
241        let cart: Direction<F> = self.to_cartesian();
242        let cart_f2: Direction<F2> = cart.to_frame_with(jd, ctx);
243        spherical::Direction::from_cartesian(&cart_f2)
244    }
245}
246
247// =============================================================================
248// VectorAstroExt - Extension trait for Vector<F, U>
249// =============================================================================
250
251/// Extension trait for `Vector<F, U>` providing frame transformations.
252///
253/// Vectors (displacements, velocities) are free vectors that are
254/// translation-invariant. Only frame rotations apply.
255pub trait VectorAstroExt<F: ReferenceFrame, U: Unit> {
256    /// Rotates this vector to a new reference frame (IAU defaults).
257    fn to_frame<F2: ReferenceFrame>(&self, jd: &JulianDate) -> Vector<F2, U>
258    where
259        (): FrameRotationProvider<F, F2>;
260
261    /// Rotates this vector to a new reference frame with custom context.
262    fn to_frame_with<F2: ReferenceFrame, Ctx>(&self, jd: &JulianDate, ctx: &Ctx) -> Vector<F2, U>
263    where
264        Ctx: TransformContext,
265        (): FrameRotationProvider<F, F2>;
266}
267
268impl<F: ReferenceFrame, U: Unit> VectorAstroExt<F, U> for Vector<F, U> {
269    fn to_frame<F2: ReferenceFrame>(&self, jd: &JulianDate) -> Vector<F2, U>
270    where
271        (): FrameRotationProvider<F, F2>,
272    {
273        let ctx: AstroContext = AstroContext::default();
274        self.to_frame_with(jd, &ctx)
275    }
276
277    fn to_frame_with<F2: ReferenceFrame, Ctx>(&self, jd: &JulianDate, ctx: &Ctx) -> Vector<F2, U>
278    where
279        Ctx: TransformContext,
280        (): FrameRotationProvider<F, F2>,
281    {
282        let rot: Rotation3 = frame_rotation_with::<F, F2, Ctx>(*jd, ctx);
283        let [x, y, z] = rot * [self.x(), self.y(), self.z()];
284        Vector::new(x, y, z)
285    }
286}
287
288// =============================================================================
289// PositionAstroExt - Extension trait for Position<C, F, U>
290// =============================================================================
291
292/// Extension trait for `Position<C, F, U>` providing frame transformations
293/// and combined center+frame transformations.
294///
295/// Positions are affine points that can undergo both frame rotations and
296/// center translations.
297///
298/// **Center-only transforms** are provided by [`TransformCenter`] (use
299/// `pos.to_center(params, jd)`).
300///
301/// Default methods use IAU models with no context argument.
302/// `_with` variants accept an [`AstroContext`] for expert overrides.
303pub trait PositionAstroExt<C: ReferenceCenter, F: ReferenceFrame, U: LengthUnit> {
304    /// Rotates this position to a new reference frame (IAU defaults).
305    fn to_frame<F2: ReferenceFrame>(&self, jd: &JulianDate) -> Position<C, F2, U>
306    where
307        (): FrameRotationProvider<F, F2>;
308
309    /// Rotates this position to a new reference frame with custom context.
310    fn to_frame_with<F2: ReferenceFrame, Ctx>(
311        &self,
312        jd: &JulianDate,
313        ctx: &Ctx,
314    ) -> Position<C, F2, U>
315    where
316        Ctx: TransformContext,
317        (): FrameRotationProvider<F, F2>;
318
319    /// Transforms this position to a new center and frame (IAU defaults).
320    ///
321    /// # Transformation Order
322    ///
323    /// 1. Center shift (in source frame F): `C → C2`
324    /// 2. Frame rotation: `F → F2`
325    fn to<C2: ReferenceCenter<Params = ()>, F2: ReferenceFrame>(
326        &self,
327        jd: &JulianDate,
328    ) -> Position<C2, F2, U>
329    where
330        (): CenterShiftProvider<C, C2, F>,
331        (): FrameRotationProvider<F, F2>;
332
333    /// Transforms this position to a new center and frame with custom context.
334    fn to_with<C2: ReferenceCenter<Params = ()>, F2: ReferenceFrame, Ctx>(
335        &self,
336        jd: &JulianDate,
337        ctx: &Ctx,
338    ) -> Position<C2, F2, U>
339    where
340        Ctx: TransformContext,
341        Ctx::Eph: crate::ephemeris::Ephemeris,
342        (): CenterShiftProvider<C, C2, F>,
343        (): FrameRotationProvider<F, F2>;
344}
345
346impl<C, F, U> PositionAstroExt<C, F, U> for Position<C, F, U>
347where
348    C: ReferenceCenter<Params = ()>,
349    F: ReferenceFrame,
350    U: LengthUnit,
351{
352    fn to_frame<F2: ReferenceFrame>(&self, jd: &JulianDate) -> Position<C, F2, U>
353    where
354        (): FrameRotationProvider<F, F2>,
355    {
356        let ctx: AstroContext = AstroContext::default();
357        self.to_frame_with(jd, &ctx)
358    }
359
360    fn to_frame_with<F2: ReferenceFrame, Ctx>(
361        &self,
362        jd: &JulianDate,
363        ctx: &Ctx,
364    ) -> Position<C, F2, U>
365    where
366        Ctx: TransformContext,
367        (): FrameRotationProvider<F, F2>,
368    {
369        let rot: Rotation3 = frame_rotation_with::<F, F2, Ctx>(*jd, ctx);
370        let [x, y, z] = rot * [self.x(), self.y(), self.z()];
371        Position::new(x, y, z)
372    }
373
374    fn to<C2: ReferenceCenter<Params = ()>, F2: ReferenceFrame>(
375        &self,
376        jd: &JulianDate,
377    ) -> Position<C2, F2, U>
378    where
379        (): CenterShiftProvider<C, C2, F>,
380        (): FrameRotationProvider<F, F2>,
381    {
382        let ctx: AstroContext = AstroContext::default();
383        self.to_with(jd, &ctx)
384    }
385
386    fn to_with<C2: ReferenceCenter<Params = ()>, F2: ReferenceFrame, Ctx>(
387        &self,
388        jd: &JulianDate,
389        ctx: &Ctx,
390    ) -> Position<C2, F2, U>
391    where
392        Ctx: TransformContext,
393        Ctx::Eph: crate::ephemeris::Ephemeris,
394        (): CenterShiftProvider<C, C2, F>,
395        (): FrameRotationProvider<F, F2>,
396    {
397        // Order: center first (in source frame), then rotate
398        <Self as TransformCenter<C2, F, U>>::to_center_with(self, (), *jd, ctx)
399            .to_frame_with::<F2, Ctx>(jd, ctx)
400    }
401}
402
403// =============================================================================
404// WithEngine - Builder for custom context
405// =============================================================================
406
407/// A wrapper that pairs a coordinate reference with a custom transform
408/// context,
409/// enabling `.using(&engine).to_frame::<F2>(&jd)` style calls.
410///
411/// # Example
412///
413/// ```rust,ignore
414/// let engine = AstroContext::new();
415/// let result = direction.using(&engine).to_frame::<EclipticMeanJ2000>(&jd);
416/// ```
417pub struct WithEngine<'a, T, Ctx> {
418    inner: &'a T,
419    ctx: &'a Ctx,
420}
421
422/// Helper trait to create [`WithEngine`] wrappers.
423pub trait UsingEngine: Sized {
424    /// Wrap this coordinate with a custom transform context for the next
425    /// transformation call.
426    fn using<'a, Ctx>(&'a self, engine: &'a Ctx) -> WithEngine<'a, Self, Ctx>
427    where
428        Ctx: TransformContext,
429    {
430        WithEngine {
431            inner: self,
432            ctx: engine,
433        }
434    }
435}
436
437// Blanket impl: every type gets `.using()`
438impl<T> UsingEngine for T {}
439
440// --- WithEngine impls for Direction<F> ---
441
442impl<'a, F: ReferenceFrame, Ctx> WithEngine<'a, Direction<F>, Ctx>
443where
444    Ctx: TransformContext,
445{
446    /// Rotates this direction to a new reference frame using the wrapped context.
447    pub fn to_frame<F2: ReferenceFrame>(&self, jd: &JulianDate) -> Direction<F2>
448    where
449        (): FrameRotationProvider<F, F2>,
450    {
451        self.inner.to_frame_with(jd, self.ctx)
452    }
453}
454
455// --- WithEngine impls for Position<C, F, U> ---
456
457impl<'a, C, F, U, Ctx> WithEngine<'a, Position<C, F, U>, Ctx>
458where
459    C: ReferenceCenter<Params = ()>,
460    Ctx: TransformContext,
461    Ctx::Eph: crate::ephemeris::Ephemeris,
462    F: ReferenceFrame,
463    U: LengthUnit,
464{
465    /// Rotates this position to a new reference frame using the wrapped context.
466    pub fn to_frame<F2: ReferenceFrame>(&self, jd: &JulianDate) -> Position<C, F2, U>
467    where
468        (): FrameRotationProvider<F, F2>,
469    {
470        self.inner.to_frame_with(jd, self.ctx)
471    }
472
473    /// Translates this position to a new reference center using the wrapped context.
474    pub fn to_center<C2: ReferenceCenter<Params = ()>>(&self, jd: &JulianDate) -> Position<C2, F, U>
475    where
476        (): CenterShiftProvider<C, C2, F>,
477    {
478        <Position<C, F, U> as TransformCenter<C2, F, U>>::to_center_with(
479            self.inner,
480            (),
481            *jd,
482            self.ctx,
483        )
484    }
485
486    /// Combined center + frame transform using the wrapped context.
487    pub fn to<C2: ReferenceCenter<Params = ()>, F2: ReferenceFrame>(
488        &self,
489        jd: &JulianDate,
490    ) -> Position<C2, F2, U>
491    where
492        (): CenterShiftProvider<C, C2, F>,
493        (): FrameRotationProvider<F, F2>,
494    {
495        self.inner.to_with(jd, self.ctx)
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use crate::coordinates::centers::{Barycentric, Geocentric};
503    use crate::coordinates::frames::{EclipticMeanJ2000, ICRS};
504    use crate::qtty::{AstronomicalUnit, AstronomicalUnits};
505
506    const EPSILON: f64 = 1e-10;
507    const AU_EPS: AstronomicalUnits = AstronomicalUnits::new(EPSILON);
508    const AU_TIGHT: AstronomicalUnits = AstronomicalUnits::new(1e-15);
509
510    #[test]
511    fn test_direction_frame_transform() {
512        let dir = Direction::<ICRS>::new(1.0, 0.0, 0.0);
513        let jd = crate::J2000;
514
515        // ICRS to EclipticMeanJ2000 includes a small frame-bias; don't assume exact axis invariance.
516        let dir_ecl: Direction<EclipticMeanJ2000> = dir.to_frame(&jd);
517
518        // Must be finite and length-preserving.
519        assert!(dir_ecl.x().is_finite() && dir_ecl.y().is_finite() && dir_ecl.z().is_finite());
520        let n0 = (dir.x() * dir.x() + dir.y() * dir.y() + dir.z() * dir.z()).sqrt();
521        let n1 =
522            (dir_ecl.x() * dir_ecl.x() + dir_ecl.y() * dir_ecl.y() + dir_ecl.z() * dir_ecl.z())
523                .sqrt();
524        assert!((n0 - n1).abs() < 1e-12);
525    }
526
527    #[test]
528    fn test_direction_frame_transform_with_ctx() {
529        let dir = Direction::<ICRS>::new(1.0, 0.0, 0.0);
530        let ctx: AstroContext = AstroContext::default();
531        let jd = crate::J2000;
532
533        let dir_ecl: Direction<EclipticMeanJ2000> = dir.to_frame_with(&jd, &ctx);
534        let dir_ecl_default: Direction<EclipticMeanJ2000> = dir.to_frame(&jd);
535
536        assert!((dir_ecl.x() - dir_ecl_default.x()).abs() < 1e-15);
537        assert!((dir_ecl.y() - dir_ecl_default.y()).abs() < 1e-15);
538        assert!((dir_ecl.z() - dir_ecl_default.z()).abs() < 1e-15);
539    }
540
541    #[test]
542    fn test_direction_frame_roundtrip() {
543        let dir = Direction::<ICRS>::new(1.0, 2.0, 3.0);
544        let jd = crate::J2000;
545
546        let dir_ecl: Direction<EclipticMeanJ2000> = dir.to_frame(&jd);
547        let dir_back: Direction<ICRS> = dir_ecl.to_frame(&jd);
548
549        assert!((dir_back.x() - dir.x()).abs() < EPSILON);
550        assert!((dir_back.y() - dir.y()).abs() < EPSILON);
551        assert!((dir_back.z() - dir.z()).abs() < EPSILON);
552    }
553
554    #[test]
555    fn test_position_frame_transform() {
556        let pos = Position::<Barycentric, ICRS, AstronomicalUnit>::new(1.0, 0.0, 0.0);
557        let jd = crate::J2000;
558
559        let pos_ecl: Position<Barycentric, EclipticMeanJ2000, AstronomicalUnit> = pos.to_frame(&jd);
560
561        assert!(pos_ecl.x().is_finite() && pos_ecl.y().is_finite() && pos_ecl.z().is_finite());
562
563        // Length must be preserved under pure rotation.
564        let n0 =
565            (pos.x().value().powi(2) + pos.y().value().powi(2) + pos.z().value().powi(2)).sqrt();
566        let n1 = (pos_ecl.x().value().powi(2)
567            + pos_ecl.y().value().powi(2)
568            + pos_ecl.z().value().powi(2))
569        .sqrt();
570        assert!((n0 - n1).abs() < 1e-12);
571    }
572
573    #[test]
574    fn test_position_center_transform() {
575        let jd = crate::J2000;
576
577        // A point at the Geocentric origin should map to Earth's position in Barycentric
578        let geo_origin =
579            Position::<Geocentric, EclipticMeanJ2000, AstronomicalUnit>::new(0.0, 0.0, 0.0);
580        let bary: Position<Barycentric, EclipticMeanJ2000, AstronomicalUnit> =
581            geo_origin.to_center(jd);
582
583        // Should be non-zero (Earth is ~1 AU from barycenter)
584        let dist = bary.distance();
585        assert!(
586            dist.value() > 0.9 && dist.value() < 1.1,
587            "Earth should be ~1 AU from barycenter, got {}",
588            dist
589        );
590    }
591
592    #[test]
593    fn test_position_combined_transform() {
594        let jd = crate::J2000;
595
596        let pos = Position::<Barycentric, EclipticMeanJ2000, AstronomicalUnit>::new(1.0, 0.5, 0.2);
597
598        // Combined transform: Barycentric EclipticMeanJ2000 -> Geocentric ICRS
599        let result: Position<Geocentric, ICRS, AstronomicalUnit> = pos.to(&jd);
600
601        // Verify it's not the same as the input (transformation happened)
602        assert!(
603            (result.x() - pos.x()).abs() > AU_EPS
604                || (result.y() - pos.y()).abs() > AU_EPS
605                || (result.z() - pos.z()).abs() > AU_EPS
606        );
607    }
608
609    #[test]
610    fn test_position_identity_transforms() {
611        let jd = crate::J2000;
612
613        let pos = Position::<Barycentric, ICRS, AstronomicalUnit>::new(1.5, 2.5, 3.5);
614
615        // Identity frame transform
616        let same_frame: Position<Barycentric, ICRS, AstronomicalUnit> = pos.to_frame(&jd);
617        assert!((same_frame.x() - pos.x()).abs() < AU_EPS);
618        assert!((same_frame.y() - pos.y()).abs() < AU_EPS);
619        assert!((same_frame.z() - pos.z()).abs() < AU_EPS);
620
621        // Identity center transform (via ShiftCenter)
622        let same_center: Position<Barycentric, ICRS, AstronomicalUnit> = pos.to_center(jd);
623        assert!((same_center.x() - pos.x()).abs() < AU_EPS);
624        assert!((same_center.y() - pos.y()).abs() < AU_EPS);
625        assert!((same_center.z() - pos.z()).abs() < AU_EPS);
626    }
627
628    #[test]
629    fn test_using_engine() {
630        let dir = Direction::<ICRS>::new(1.0, 0.0, 0.0);
631        let engine: AstroContext = AstroContext::default();
632        let jd = crate::J2000;
633
634        let dir_ecl: Direction<EclipticMeanJ2000> = dir.using(&engine).to_frame(&jd);
635        let dir_ecl_direct: Direction<EclipticMeanJ2000> = dir.to_frame(&jd);
636
637        assert!((dir_ecl.x() - dir_ecl_direct.x()).abs() < 1e-15);
638        assert!((dir_ecl.y() - dir_ecl_direct.y()).abs() < 1e-15);
639        assert!((dir_ecl.z() - dir_ecl_direct.z()).abs() < 1e-15);
640    }
641
642    #[test]
643    fn test_phantom_model_selection_affects_true_of_date_rotation() {
644        use crate::astro::nutation::{Iau2006, Iau2006A};
645
646        let dir = Direction::<ICRS>::new(1.0, 0.0, 0.0);
647        let jd = crate::time::JulianDate::new(2_458_850.0);
648        let ctx: AstroContext<DefaultEphemeris, DefaultEop> = AstroContext::default();
649
650        let with_nutation = dir
651            .to_frame_with::<crate::coordinates::frames::EquatorialTrueOfDate, _>(
652                &jd,
653                &ctx.with_model::<Iau2006A>(),
654            );
655        let precession_only = dir
656            .to_frame_with::<crate::coordinates::frames::EquatorialTrueOfDate, _>(
657                &jd,
658                &ctx.with_model::<Iau2006>(),
659            );
660
661        let delta = ((with_nutation.x() - precession_only.x()).powi(2)
662            + (with_nutation.y() - precession_only.y()).powi(2)
663            + (with_nutation.z() - precession_only.z()).powi(2))
664        .sqrt();
665
666        assert!(
667            delta > 1e-9,
668            "model presets should produce distinct rotations"
669        );
670    }
671
672    // =====================================================================
673    // SphericalDirectionAstroExt tests
674    // =====================================================================
675
676    #[test]
677    fn test_spherical_direction_frame_transform() {
678        use super::SphericalDirectionAstroExt;
679        use crate::coordinates::spherical;
680        use crate::qtty::DEG;
681
682        let sph_dir = spherical::Direction::<ICRS>::new(45.0 * DEG, 30.0 * DEG);
683        let jd = crate::J2000;
684
685        let sph_ecl: spherical::Direction<EclipticMeanJ2000> = sph_dir.to_frame(&jd);
686
687        // Should be finite
688        assert!(sph_ecl.azimuth.is_finite());
689        assert!(sph_ecl.polar.is_finite());
690    }
691
692    #[test]
693    fn test_spherical_direction_roundtrip() {
694        use super::SphericalDirectionAstroExt;
695        use crate::coordinates::spherical;
696        use crate::qtty::DEG;
697
698        let sph_dir = spherical::Direction::<ICRS>::new(123.0 * DEG, -45.0 * DEG);
699        let jd = crate::J2000;
700
701        let sph_ecl: spherical::Direction<EclipticMeanJ2000> = sph_dir.to_frame(&jd);
702        let sph_back: spherical::Direction<ICRS> = sph_ecl.to_frame(&jd);
703
704        assert!((sph_back.azimuth - sph_dir.azimuth).abs().value() < 1e-8);
705        assert!((sph_back.polar - sph_dir.polar).abs().value() < 1e-8);
706    }
707
708    #[test]
709    fn test_spherical_direction_with_ctx() {
710        use super::SphericalDirectionAstroExt;
711        use crate::coordinates::spherical;
712        use crate::qtty::DEG;
713
714        let sph_dir = spherical::Direction::<ICRS>::new(90.0 * DEG, 0.0 * DEG);
715        let ctx: AstroContext = AstroContext::default();
716        let jd = crate::J2000;
717
718        let with_ctx: spherical::Direction<EclipticMeanJ2000> = sph_dir.to_frame_with(&jd, &ctx);
719        let without_ctx: spherical::Direction<EclipticMeanJ2000> = sph_dir.to_frame(&jd);
720
721        assert!((with_ctx.azimuth - without_ctx.azimuth).abs().value() < 1e-15);
722        assert!((with_ctx.polar - without_ctx.polar).abs().value() < 1e-15);
723    }
724
725    // =====================================================================
726    // VectorAstroExt tests
727    // =====================================================================
728
729    #[test]
730    fn test_vector_frame_transform() {
731        use super::VectorAstroExt;
732
733        let vec = Vector::<ICRS, AstronomicalUnit>::new(
734            AstronomicalUnits::new(1.0),
735            AstronomicalUnits::new(2.0),
736            AstronomicalUnits::new(3.0),
737        );
738        let jd = crate::J2000;
739
740        let vec_ecl: Vector<EclipticMeanJ2000, AstronomicalUnit> = vec.to_frame(&jd);
741        assert!(vec_ecl.x().is_finite() && vec_ecl.y().is_finite() && vec_ecl.z().is_finite());
742
743        // Length should be preserved
744        let n0 = (vec.x() * vec.x() + vec.y() * vec.y() + vec.z() * vec.z()).scalar_sqrt();
745        let n1 =
746            (vec_ecl.x() * vec_ecl.x() + vec_ecl.y() * vec_ecl.y() + vec_ecl.z() * vec_ecl.z())
747                .scalar_sqrt();
748        assert!((n0 - n1).abs() < 1e-12);
749    }
750
751    #[test]
752    fn test_vector_frame_roundtrip() {
753        use super::VectorAstroExt;
754
755        let vec = Vector::<ICRS, AstronomicalUnit>::new(
756            AstronomicalUnits::new(0.5),
757            AstronomicalUnits::new(-0.3),
758            AstronomicalUnits::new(0.8),
759        );
760        let jd = crate::J2000;
761
762        let vec_ecl: Vector<EclipticMeanJ2000, AstronomicalUnit> = vec.to_frame(&jd);
763        let vec_back: Vector<ICRS, AstronomicalUnit> = vec_ecl.to_frame(&jd);
764
765        assert!((vec_back.x() - vec.x()).abs() < AU_EPS);
766        assert!((vec_back.y() - vec.y()).abs() < AU_EPS);
767        assert!((vec_back.z() - vec.z()).abs() < AU_EPS);
768    }
769
770    #[test]
771    fn test_vector_frame_with_ctx() {
772        use super::VectorAstroExt;
773
774        let vec = Vector::<ICRS, AstronomicalUnit>::new(
775            AstronomicalUnits::new(1.0),
776            AstronomicalUnits::new(0.0),
777            AstronomicalUnits::new(0.0),
778        );
779        let ctx: AstroContext = AstroContext::default();
780        let jd = crate::J2000;
781
782        let with_ctx: Vector<EclipticMeanJ2000, AstronomicalUnit> = vec.to_frame_with(&jd, &ctx);
783        let without_ctx: Vector<EclipticMeanJ2000, AstronomicalUnit> = vec.to_frame(&jd);
784
785        assert!((with_ctx.x() - without_ctx.x()).abs() < AU_TIGHT);
786        assert!((with_ctx.y() - without_ctx.y()).abs() < AU_TIGHT);
787        assert!((with_ctx.z() - without_ctx.z()).abs() < AU_TIGHT);
788    }
789
790    // =====================================================================
791    // WithEngine for positions
792    // =====================================================================
793
794    #[test]
795    fn test_using_engine_position_frame() {
796        let pos = Position::<Barycentric, ICRS, AstronomicalUnit>::new(1.0, 0.5, 0.2);
797        let engine: AstroContext = AstroContext::default();
798        let jd = crate::J2000;
799
800        let via_engine: Position<Barycentric, EclipticMeanJ2000, AstronomicalUnit> =
801            pos.using(&engine).to_frame(&jd);
802        let direct: Position<Barycentric, EclipticMeanJ2000, AstronomicalUnit> = pos.to_frame(&jd);
803
804        assert!((via_engine.x() - direct.x()).abs() < AU_TIGHT);
805        assert!((via_engine.y() - direct.y()).abs() < AU_TIGHT);
806        assert!((via_engine.z() - direct.z()).abs() < AU_TIGHT);
807    }
808
809    #[test]
810    fn test_using_engine_position_center() {
811        let pos = Position::<Geocentric, EclipticMeanJ2000, AstronomicalUnit>::new(0.0, 0.0, 0.0);
812        let engine: AstroContext = AstroContext::default();
813        let jd = crate::J2000;
814
815        let via_engine: Position<Barycentric, EclipticMeanJ2000, AstronomicalUnit> =
816            pos.using(&engine).to_center(&jd);
817        let direct: Position<Barycentric, EclipticMeanJ2000, AstronomicalUnit> = pos.to_center(jd);
818
819        assert!((via_engine.x() - direct.x()).abs() < AU_TIGHT);
820        assert!((via_engine.y() - direct.y()).abs() < AU_TIGHT);
821        assert!((via_engine.z() - direct.z()).abs() < AU_TIGHT);
822    }
823
824    #[test]
825    fn test_using_engine_position_combined() {
826        let pos = Position::<Barycentric, EclipticMeanJ2000, AstronomicalUnit>::new(1.0, 0.5, 0.2);
827        let engine: AstroContext = AstroContext::default();
828        let jd = crate::J2000;
829
830        let via_engine: Position<Geocentric, ICRS, AstronomicalUnit> = pos.using(&engine).to(&jd);
831        let direct: Position<Geocentric, ICRS, AstronomicalUnit> = pos.to(&jd);
832
833        assert!((via_engine.x() - direct.x()).abs() < AU_TIGHT);
834        assert!((via_engine.y() - direct.y()).abs() < AU_TIGHT);
835        assert!((via_engine.z() - direct.z()).abs() < AU_TIGHT);
836    }
837
838    // =====================================================================
839    // Position with_ctx variants
840    // =====================================================================
841
842    #[test]
843    fn test_position_frame_with_ctx() {
844        let pos = Position::<Barycentric, ICRS, AstronomicalUnit>::new(1.0, 2.0, 3.0);
845        let ctx: AstroContext = AstroContext::default();
846        let jd = crate::J2000;
847
848        let with_ctx: Position<Barycentric, EclipticMeanJ2000, AstronomicalUnit> =
849            pos.to_frame_with(&jd, &ctx);
850        let default_ctx: Position<Barycentric, EclipticMeanJ2000, AstronomicalUnit> =
851            pos.to_frame(&jd);
852
853        assert!((with_ctx.x() - default_ctx.x()).abs() < AU_TIGHT);
854        assert!((with_ctx.y() - default_ctx.y()).abs() < AU_TIGHT);
855        assert!((with_ctx.z() - default_ctx.z()).abs() < AU_TIGHT);
856    }
857
858    #[test]
859    fn test_position_combined_with_ctx() {
860        let pos = Position::<Barycentric, EclipticMeanJ2000, AstronomicalUnit>::new(1.0, 0.5, 0.2);
861        let ctx: AstroContext = AstroContext::default();
862        let jd = crate::J2000;
863
864        let with_ctx: Position<Geocentric, ICRS, AstronomicalUnit> = pos.to_with(&jd, &ctx);
865        let default_ctx: Position<Geocentric, ICRS, AstronomicalUnit> = pos.to(&jd);
866
867        assert!((with_ctx.x() - default_ctx.x()).abs() < AU_TIGHT);
868        assert!((with_ctx.y() - default_ctx.y()).abs() < AU_TIGHT);
869        assert!((with_ctx.z() - default_ctx.z()).abs() < AU_TIGHT);
870    }
871}