affn 0.5.0

Affine geometry primitives: strongly-typed coordinate systems, reference frames, and centers for scientific computing.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
// Astronomical reference frames for spherical coordinate systems.
//
// These are feature-gated behind `#[cfg(feature = "astro")]` to keep the
// core `affn` crate domain-agnostic. Enable the `astro` feature to use them.
//
// Because these types are defined in the same crate as `Direction`/`Position`,
// the derive macro can generate inherent named constructors and getters
// (e.g., `.ra()`, `.dec()`, `Direction::<ICRS>::new(ra, dec)`) without
// violating Rust's orphan rules for inherent impls.

use crate::DeriveReferenceFrame;

#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

// =============================================================================
// Equatorial frames (ra/dec)
// =============================================================================

/// International Celestial Reference System.
///
/// The fundamental celestial reference frame used in modern astronomy.
/// It is quasi-inertial and centered at the solar system barycenter.
/// The axes are defined by the positions of distant quasars.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "dec", azimuth = "ra", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ICRS;

/// International Celestial Reference Frame (ICRF).
///
/// The physical realisation of the ICRS via VLBI observations of extragalactic
/// sources. DE440 ephemeris data is natively expressed in ICRF.
///
/// Practically coincident with [`ICRS`] to sub-milliarcsecond accuracy, but
/// kept as a distinct type so that DE440 internal vectors carry the correct
/// frame provenance and cannot be accidentally mixed with other equatorial
/// frames without an explicit conversion step.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "dec", azimuth = "ra")]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ICRF;

/// Mean equator and equinox of J2000.0 (FK5/J2000 mean).
///
/// Earth-based mean equator/equinox at epoch J2000.0, with nutation removed.
/// This is the classic "J2000 equatorial" frame used by many catalogs.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "dec", azimuth = "ra", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct EquatorialMeanJ2000;

/// Earth Mean Equator and Equinox of J2000.0 (EME2000).
///
/// CCSDS Orbit Data Messages and many flight-dynamics tools use `EME2000`
/// for the same mean-equator/mean-equinox J2000 axes that are commonly
/// labelled FK5/J2000. siderust keeps it as an explicit marker so exchanged
/// data can preserve the original frame name in public APIs.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "dec", azimuth = "ra", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct EME2000;

/// Mean equator and equinox of date.
///
/// Earth-based mean equator/equinox at a given epoch (precession applied,
/// nutation removed). Requires a TT epoch for transformations.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "dec", azimuth = "ra", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct EquatorialMeanOfDate;

/// True equator and equinox of date.
///
/// Earth-based true equator/equinox at a given epoch (precession + nutation).
/// Requires a TT epoch for transformations.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "dec", azimuth = "ra", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct EquatorialTrueOfDate;

/// Geocentric Celestial Reference System (GCRS).
///
/// The geocentric counterpart of the BCRS (barycentric CRS). Its axes are
/// kinematically non-rotating with respect to the ICRS, but the origin is
/// at the Earth's center of mass. In the IAU 2000/2006 framework, directions
/// expressed in the GCRS are transformed to the terrestrial frame via the
/// CIO-based procedure: GCRS → (CIP X,Y + CIO s) → CIRS → (ERA) → TIRS →
/// (polar motion W) → ITRS.
///
/// For most astronomical purposes (< 1 mas), GCRS ≈ ICRS for directions.
///
/// ## References
/// * IAU 2000 Resolution B1.3
/// * IERS Conventions (2010), §5.1
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "dec", azimuth = "ra", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct GCRS;

/// Celestial Intermediate Reference System (CIRS).
///
/// An intermediate geocentric equatorial frame whose pole is the **Celestial
/// Intermediate Pole** (CIP) and whose origin on the CIP equator is the
/// **Celestial Intermediate Origin** (CIO). CIRS is obtained from GCRS by
/// applying the CIP (X, Y) coordinates and the CIO locator *s*.
///
/// The CIRS is the "bridge" between the celestial (GCRS) and terrestrial
/// (TIRS) frames in the CIO-based procedure.
///
/// ## References
/// * IAU 2000 Resolution B1.8
/// * IERS Conventions (2010), §5.4.4
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "dec", azimuth = "ra", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct CIRS;

/// Terrestrial Intermediate Reference System (TIRS).
///
/// An intermediate geocentric frame obtained from CIRS by applying the
/// **Earth Rotation Angle** (ERA). Its pole is the CIP (same as CIRS),
/// but the prime direction on the CIP equator is the **Terrestrial
/// Intermediate Origin** (TIO), which tracks the Earth's rotation.
///
/// The TIRS is connected to the ITRS by the polar motion matrix **W**.
///
/// ## References
/// * IAU 2000 Resolution B1.8
/// * IERS Conventions (2010), §5.4.4
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "dec", azimuth = "ra", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TIRS;

// =============================================================================
// Horizontal frame (alt/az)
// =============================================================================

/// Local horizon coordinate system.
///
/// A topocentric frame based on the observer's local horizon.
/// Uses altitude (elevation above horizon) and azimuth (bearing from north).
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "alt", azimuth = "az", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Horizontal;

// =============================================================================
// Longitude/latitude frames
// =============================================================================

/// Mean ecliptic of J2000.0.
///
/// Based on the mean plane of Earth's orbit around the Sun at epoch J2000.0.
/// Uses ecliptic longitude and latitude.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "lat", azimuth = "lon", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct EclipticMeanJ2000;

/// Mean ecliptic of date.
///
/// Uses the mean ecliptic plane (obliquity of date) without nutation.
/// Transformations to/from this frame are time-dependent and require a TT epoch.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "lat", azimuth = "lon", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct EclipticOfDate;

/// Mean ecliptic of date.
///
/// Alias for [`EclipticOfDate`], provided for naming parity with
/// [`EquatorialMeanOfDate`].
pub type EclipticMeanOfDate = EclipticOfDate;

/// True ecliptic of date.
///
/// Uses the true ecliptic plane of date with nutation effects applied.
/// Ecliptic longitude is measured from the **true equinox** (precession +
/// nutation in longitude), and the obliquity used for the equator-to-ecliptic
/// tilt is the **true obliquity** (ε_A + Δε).
/// Transformations to/from this frame are time-dependent and require a TT epoch.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "lat", azimuth = "lon", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct EclipticTrueOfDate;

/// International Terrestrial Reference Frame — **EOP-realised** Earth-fixed frame.
///
/// `ITRF` is the *physical* geocentric frame that co-rotates with the solid
/// Earth.  Its axes are realised through a network of VLBI / SLR / GNSS stations
/// and are linked to the celestial frame via the full IERS Earth Orientation
/// Parameters (polar motion **W**, Earth rotation angle **ERA**, and the
/// precession-nutation matrix **Q**).
///
/// # When to use `ITRF`
/// Use `ITRF` whenever the physical location of a point on the Earth's surface
/// matters and you intend to apply (or have already applied) the complete IERS
/// EOP reduction:
/// - Observatory geocentric coordinates derived from WGS-84 / ITRF2020.
/// - Polar-motion-corrected topocentric baselines.
///
/// # Distinction from `ECEF`
/// `ECEF` is a *mathematical* placeholder that deliberately ignores the ~10 m
/// polar-motion correction.  Coordinates labelled `ECEF` may differ from true `ITRF`
/// by up to tens of metres.  See [`ECEF`] for details.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(
    polar = "lat",
    azimuth = "lon",
    distance = "altitude",
    inherent,
    ellipsoid = "Grs80"
)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ITRF;

/// Earth-Centred Earth-Fixed — **mathematical** geocentric frame (no EOP).
///
/// `ECEF` is a *generic* Earth-fixed reference that rotates with the Earth
/// (using ERA / GMST) but does **not** apply polar motion or the full IERS
/// EOP chain.  It is suitable for:
/// - First-order geodetic → topocentric conversions where sub-kilometre
///   accuracy is sufficient.
/// - Internal bookkeeping when a labelled "Earth-fixed" frame is needed
///   but a full EOP-realised solution is not yet available.
///
/// # Accuracy note
/// Omitting polar motion introduces an error of roughly **±10 m** in
/// geocentric Cartesian coordinates (up to ~30 m at solar maximum).
/// For observatory positioning at the metre level or better, use [`ITRF`]
/// coordinates with a full EOP correction.
///
/// # Distinction from `ITRF`
/// [`ITRF`] carries the full EOP realisation; `ECEF` does not.
/// A coordinate in `ECEF` is *implicitly* in a frame that coincides with
/// ITRF to first order but lacks the polar-motion rotation **W**.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(
    polar = "lat",
    azimuth = "lon",
    distance = "altitude",
    inherent,
    ellipsoid = "Wgs84"
)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ECEF;

// =============================================================================
// Galactic frame
// =============================================================================

/// Galactic coordinate system.
///
/// Based on the plane of the Milky Way galaxy.
/// Uses galactic longitude and latitude, with the center
/// of the galaxy defining the origin of galactic longitude.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "b", azimuth = "l", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Galactic;

// =============================================================================
// Historical and operational frames (ra/dec)
// =============================================================================

/// Mean equator and mean equinox of B1950.0 (FK4 catalog reference).
///
/// The Fourth Fundamental Catalogue (FK4) used the mean equator and equinox of
/// the Besselian epoch B1950.0 as its reference frame. This frame was the
/// standard before the IAU adopted FK5/J2000 in 1976.
///
/// FK4 coordinates include the effects of elliptic terms of aberration (E-terms)
/// that are embedded in the catalog positions. When converting FK4 → FK5/ICRS,
/// these E-terms must be removed.
///
/// # References
/// * Standish, E.M. (1982). "Conversion of positions and proper motions from
///   B1950.0 to the IAU system at J2000.0", A&A, 115, 20-22.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "dec", azimuth = "ra", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct FK4B1950;

/// True Equator, Mean Equinox (TEME) frame.
///
/// An Earth-centered inertial frame used operationally for SGP4/SDP4
/// two-line element (TLE) propagation. The pole is the true celestial pole
/// (CIP, including nutation), but the origin of right ascension is the
/// **mean** equinox of date (no nutation in longitude applied to the equinox).
///
/// TEME differs from TOD (True of Date) by the equation of the equinoxes:
/// ```text
/// TEME → TOD: Rz(equation_of_equinoxes)
/// ```
///
/// # References
/// * Vallado, D.A. et al. (2006). "Revisiting Spacetrack Report No. 3",
///   AIAA/AAS Astrodynamics Specialist Conference, AIAA 2006-6753.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "dec", azimuth = "ra", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TEME;

// =============================================================================
// Planetary body-fixed frames (lat/lon/radius)
// =============================================================================

/// Mercury IAU body-fixed frame.
///
/// Planetocentric frame rotating with Mercury's solid body.
/// Uses latitude/longitude/radius spherical naming.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "lat", azimuth = "lon", distance = "radius", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MercuryFixed;

/// Venus IAU body-fixed frame.
///
/// Planetocentric frame rotating with Venus (retrograde rotation).
/// Uses latitude/longitude/radius spherical naming.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "lat", azimuth = "lon", distance = "radius", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct VenusFixed;

/// Mars IAU body-fixed frame.
///
/// Planetocentric frame rotating with Mars, the standard cartographic
/// reference used by NASA/ESA missions.
/// Uses latitude/longitude/radius spherical naming.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "lat", azimuth = "lon", distance = "radius", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MarsFixed;

/// Moon principal axes (selenocentric) frame.
///
/// A body-fixed frame aligned with the Moon's principal moments of inertia.
/// Uses latitude/longitude/radius spherical naming.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "lat", azimuth = "lon", distance = "radius", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MoonPrincipalAxes;

/// Jupiter System III body-fixed frame.
///
/// Defined by Jupiter's magnetic field rotation period (9h 55m 29.711s).
/// Uses latitude/longitude/radius spherical naming.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "lat", azimuth = "lon", distance = "radius", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct JupiterSystemIII;

/// Saturn IAU body-fixed frame.
///
/// Planetocentric frame rotating with Saturn's magnetic field (System III).
/// Uses latitude/longitude/radius spherical naming.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "lat", azimuth = "lon", distance = "radius", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SaturnFixed;

/// Uranus IAU body-fixed frame.
///
/// Planetocentric frame rotating with Uranus (extreme ~97.8° axial tilt,
/// retrograde rotation in IAU convention).
/// Uses latitude/longitude/radius spherical naming.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "lat", azimuth = "lon", distance = "radius", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct UranusFixed;

/// Neptune IAU body-fixed frame.
///
/// Planetocentric frame rotating with Neptune's magnetic field.
/// Uses latitude/longitude/radius spherical naming.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "lat", azimuth = "lon", distance = "radius", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct NeptuneFixed;

/// Pluto IAU body-fixed frame.
///
/// Planetocentric frame rotating with Pluto (retrograde rotation).
/// Uses latitude/longitude/radius spherical naming.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, DeriveReferenceFrame)]
#[frame(polar = "lat", azimuth = "lon", distance = "radius", inherent)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PlutoFixed;

// =============================================================================
// Tests
// =============================================================================

#[cfg(test)]
mod tests {
    use super::*;
    use crate::centers::{AffineCenter, ReferenceCenter};
    use crate::frames::{ReferenceFrame, SphericalNaming};
    use crate::spherical::{Direction, Position};
    use qtty::*;

    #[test]
    fn test_frame_names() {
        assert_eq!(ICRS::frame_name(), "ICRS");
        assert_eq!(ICRF::frame_name(), "ICRF");
        assert_eq!(EME2000::frame_name(), "EME2000");
        assert_eq!(Horizontal::frame_name(), "Horizontal");
        assert_eq!(EclipticMeanJ2000::frame_name(), "EclipticMeanJ2000");
        assert_eq!(EclipticOfDate::frame_name(), "EclipticOfDate");
        assert_eq!(EclipticTrueOfDate::frame_name(), "EclipticTrueOfDate");
        assert_eq!(EclipticMeanOfDate::frame_name(), "EclipticOfDate");
        assert_eq!(Galactic::frame_name(), "Galactic");
        assert_eq!(ITRF::frame_name(), "ITRF");
        assert_eq!(ECEF::frame_name(), "ECEF");
        assert_eq!(FK4B1950::frame_name(), "FK4B1950");
        assert_eq!(TEME::frame_name(), "TEME");

        // Planetary body-fixed
        assert_eq!(MercuryFixed::frame_name(), "MercuryFixed");
        assert_eq!(VenusFixed::frame_name(), "VenusFixed");
        assert_eq!(MarsFixed::frame_name(), "MarsFixed");
        assert_eq!(MoonPrincipalAxes::frame_name(), "MoonPrincipalAxes");
        assert_eq!(JupiterSystemIII::frame_name(), "JupiterSystemIII");
        assert_eq!(SaturnFixed::frame_name(), "SaturnFixed");
        assert_eq!(UranusFixed::frame_name(), "UranusFixed");
        assert_eq!(NeptuneFixed::frame_name(), "NeptuneFixed");
        assert_eq!(PlutoFixed::frame_name(), "PlutoFixed");
    }

    #[test]
    fn test_spherical_naming() {
        assert_eq!(ICRS::polar_name(), "dec");
        assert_eq!(ICRS::azimuth_name(), "ra");
        assert_eq!(EME2000::polar_name(), "dec");
        assert_eq!(EME2000::azimuth_name(), "ra");

        assert_eq!(Horizontal::polar_name(), "alt");
        assert_eq!(Horizontal::azimuth_name(), "az");

        assert_eq!(EclipticMeanJ2000::polar_name(), "lat");
        assert_eq!(EclipticMeanJ2000::azimuth_name(), "lon");
        assert_eq!(EclipticOfDate::polar_name(), "lat");
        assert_eq!(EclipticOfDate::azimuth_name(), "lon");
        assert_eq!(EclipticTrueOfDate::polar_name(), "lat");
        assert_eq!(EclipticTrueOfDate::azimuth_name(), "lon");

        assert_eq!(Galactic::polar_name(), "b");
        assert_eq!(Galactic::azimuth_name(), "l");

        assert_eq!(ITRF::distance_name(), "altitude");

        // Planetary body-fixed: all use lat/lon/radius
        assert_eq!(MercuryFixed::polar_name(), "lat");
        assert_eq!(MercuryFixed::azimuth_name(), "lon");
        assert_eq!(MercuryFixed::distance_name(), "radius");

        assert_eq!(MarsFixed::polar_name(), "lat");
        assert_eq!(MarsFixed::azimuth_name(), "lon");
        assert_eq!(MarsFixed::distance_name(), "radius");
    }

    // ── Direction inherent constructors ──

    #[test]
    fn test_icrs_direction_new() {
        let d = Direction::<ICRS>::new(120.0 * DEG, 45.0 * DEG);
        assert_eq!(d.ra(), 120.0 * DEG);
        assert_eq!(d.dec(), 45.0 * DEG);
    }

    #[test]
    fn test_horizontal_direction_new() {
        // IAU convention: polar first → new(alt, az)
        let d = Direction::<Horizontal>::new(30.0 * DEG, 180.0 * DEG);
        assert_eq!(d.alt(), 30.0 * DEG);
        assert_eq!(d.az(), 180.0 * DEG);
    }

    #[test]
    fn test_ecliptic_direction_new() {
        let d = Direction::<EclipticMeanJ2000>::new(270.0 * DEG, -10.0 * DEG);
        assert_eq!(d.lon(), 270.0 * DEG);
        assert_eq!(d.lat(), -10.0 * DEG);
    }

    #[test]
    fn test_galactic_direction_new() {
        let d = Direction::<Galactic>::new(45.0 * DEG, 20.0 * DEG);
        assert_eq!(d.l(), 45.0 * DEG);
        assert_eq!(d.b(), 20.0 * DEG);
    }

    #[test]
    fn test_direction_canonicalization() {
        // RA wraps: 370° → 10°
        let d = Direction::<ICRS>::new(370.0 * DEG, 45.0 * DEG);
        assert!((d.ra().value() - 10.0).abs() < 1e-10);
        assert_eq!(d.dec(), 45.0 * DEG);

        // Dec folds: 100° → 80° (and RA shifts by 180°)
        let d = Direction::<ICRS>::new(0.0 * DEG, 100.0 * DEG);
        assert!((d.dec().value() - 80.0).abs() < 1e-10);
    }

    // ── Position inherent constructors ──

    #[derive(Debug, Copy, Clone)]
    struct TestCenter;
    impl ReferenceCenter for TestCenter {
        type Params = ();
        fn center_name() -> &'static str {
            "TestCenter"
        }
    }
    impl AffineCenter for TestCenter {}

    #[test]
    fn test_icrs_position_new() {
        let p = Position::<TestCenter, ICRS, Parsec>::new(120.0 * DEG, 45.0 * DEG, 10.0);
        assert_eq!(p.ra(), 120.0 * DEG);
        assert_eq!(p.dec(), 45.0 * DEG);
    }

    #[test]
    fn test_horizontal_position_new() {
        let p = Position::<TestCenter, Horizontal, Kilometer>::new(30.0 * DEG, 180.0 * DEG, 100.0);
        assert_eq!(p.alt(), 30.0 * DEG);
        assert_eq!(p.az(), 180.0 * DEG);
    }

    #[test]
    fn test_position_accessors_any_center() {
        // Position accessors should work for centers with non-() Params too
        #[derive(Debug, Copy, Clone)]
        struct ParamCenter;
        impl ReferenceCenter for ParamCenter {
            type Params = f64;
            fn center_name() -> &'static str {
                "ParamCenter"
            }
        }

        let p = Position::<ParamCenter, ICRS, Parsec>::new_raw_with_params(
            42.0,
            45.0 * DEG,
            120.0 * DEG,
            Quantity::<Parsec>::new(10.0),
        );
        assert_eq!(p.ra(), 120.0 * DEG);
        assert_eq!(p.dec(), 45.0 * DEG);
    }
}