siderust 0.6.0

High-precision astronomy and satellite mechanics in Rust.
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
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Vallés Puig, Ramon

//! # Azimuth Provider — Trait-Based Dispatch Layer
//!
//! Defines [`AzimuthProvider`] and implementations that normalise the azimuth
//! API across Sun, Moon, and fixed stars.
//!
//! ## Design
//!
//! The trait is the *dispatch* layer — all astronomical math lives in the
//! per-body engine modules (`calculus::solar`, `calculus::lunar`,
//! `calculus::stellar`).  Each `impl` simply delegates.
//!
//! | Body type | Engine |
//! |-----------|--------|
//! | [`solar_system::Sun`]  | [`calculus::solar::sun_azimuth_rad`]         |
//! | [`solar_system::Moon`] | [`calculus::lunar::moon_azimuth_rad`]        |
//! | [`Star`]               | delegates to [`direction::ICRS`] impl below  |
//! | [`direction::ICRS`]    | [`calculus::stellar::fixed_star_azimuth_rad`] |
//!
//! ## Quick Start
//!
//! ```rust
//! use siderust::bodies::Sun;
//! use siderust::calculus::azimuth::{AzimuthProvider, AzimuthQuery};
//! use siderust::coordinates::centers::Geodetic;
//! use siderust::coordinates::frames::ECEF;
//! use siderust::time::{ModifiedJulianDate, MJD, Period};
//! use qtty::*;
//!
//! let site = Geodetic::<ECEF>::new(Degrees::new(0.0), Degrees::new(51.48), Meters::new(0.0));
//! let window = Period::new(ModifiedJulianDate::new(60000.0), ModifiedJulianDate::new(60001.0));
//! let query = AzimuthQuery {
//!     observer: site,
//!     window,
//!     min_azimuth: Degrees::new(90.0),
//!     max_azimuth: Degrees::new(270.0),
//! };
//!
//! // Same call shape for any body:
//! let sun_periods = Sun.azimuth_periods(&query);
//! ```

use super::events;
use super::types::AzimuthQuery;
use crate::bodies::solar_system;
use crate::bodies::Star;
use crate::coordinates::centers::Geodetic;
use crate::coordinates::frames::ECEF;
use crate::coordinates::spherical::direction;
use crate::time::{complement_within, ModifiedJulianDate, Period, MJD};
use qtty::*;

// Imports for planet azimuth support
use crate::calculus::horizontal;
use crate::coordinates::transform::Transform;
use crate::coordinates::{cartesian, centers::Geocentric, frames};
use crate::time::JulianDate;

// ---------------------------------------------------------------------------
// Trait Definition
// ---------------------------------------------------------------------------

/// Unified interface for computing azimuth-related quantities and periods
/// for any celestial body.
///
/// ## Time Scale
///
/// All `ModifiedJulianDate` and `Period<MJD>` values are on the canonical
/// JD(TT) axis.  Convert UTC instants with `ModifiedJulianDate::from_utc(…)`
/// before using this API.
///
/// ## Azimuth convention
///
/// North-clockwise (North = 0°), values in `[0°, 360°)`.
pub trait AzimuthProvider {
    /// Compute the topocentric azimuth of this body at a single instant.
    ///
    /// Returns a value in radians, range `[0, 2π)` (North-clockwise).
    fn azimuth_at(&self, observer: &Geodetic<ECEF>, mjd: ModifiedJulianDate) -> Radians;

    /// Returns all contiguous intervals inside `query.window` where the body's
    /// azimuth is within `[query.min_azimuth, query.max_azimuth]`.
    ///
    /// If `min_azimuth > max_azimuth` the query is a **wrap-around** (North-
    /// crossing) range and the result covers the combined arc.
    ///
    /// The returned vector is sorted chronologically.  An empty vector means
    /// the body never enters the requested band during the window.
    fn azimuth_periods(&self, query: &AzimuthQuery) -> Vec<Period<MJD>>;

    /// Convenience: intervals where azimuth is within `[min_az, max_az]`.
    ///
    /// Equivalent to calling [`azimuth_periods`](Self::azimuth_periods)
    /// directly.
    fn in_azimuth_range(
        &self,
        observer: Geodetic<ECEF>,
        window: Period<MJD>,
        min_az: Degrees,
        max_az: Degrees,
    ) -> Vec<Period<MJD>> {
        self.azimuth_periods(&AzimuthQuery {
            observer,
            window,
            min_azimuth: min_az,
            max_azimuth: max_az,
        })
    }

    /// Convenience: intervals where azimuth is **outside** `[min_az, max_az]`.
    ///
    /// Returns the complement of [`in_azimuth_range`](Self::in_azimuth_range)
    /// within `window`.
    fn outside_azimuth_range(
        &self,
        observer: Geodetic<ECEF>,
        window: Period<MJD>,
        min_az: Degrees,
        max_az: Degrees,
    ) -> Vec<Period<MJD>> {
        let inside = self.in_azimuth_range(observer, window, min_az, max_az);
        complement_within(window, &inside)
    }

    /// Hint for the scan step to use when searching for azimuth events.
    ///
    /// Returns `None` to use the default (10 minutes). Bodies with slower
    /// apparent motion (like the Moon) can return a larger step.
    fn scan_step_hint(&self) -> Option<Days> {
        None
    }
}

// ---------------------------------------------------------------------------
// Free Function Entry Point
// ---------------------------------------------------------------------------

/// Generic entry point: compute azimuth periods for any body implementing
/// [`AzimuthProvider`].
///
/// This is a thin wrapper around the trait method for callers preferring a
/// function-style API.
///
/// # Example
/// ```rust
/// use siderust::calculus::azimuth::{azimuth_periods, AzimuthQuery};
/// use siderust::bodies::Sun;
/// use siderust::coordinates::centers::Geodetic;
/// use siderust::coordinates::frames::ECEF;
/// use siderust::time::{ModifiedJulianDate, MJD, Period};
/// use qtty::*;
///
/// let site = Geodetic::<ECEF>::new(Degrees::new(0.0), Degrees::new(51.48), Meters::new(0.0));
/// let window = Period::new(ModifiedJulianDate::new(60000.0), ModifiedJulianDate::new(60001.0));
/// let query = AzimuthQuery {
///     observer: site,
///     window,
///     min_azimuth: Degrees::new(90.0),
///     max_azimuth: Degrees::new(270.0),
/// };
/// let periods = azimuth_periods(&Sun, &query);
/// ```
#[inline]
pub fn azimuth_periods<B: AzimuthProvider>(body: &B, query: &AzimuthQuery) -> Vec<Period<MJD>> {
    body.azimuth_periods(query)
}

// ---------------------------------------------------------------------------
// Implementations
// ---------------------------------------------------------------------------

/// **Sun** — delegates to [`calculus::solar::sun_azimuth_rad`].
impl AzimuthProvider for solar_system::Sun {
    fn azimuth_at(&self, observer: &Geodetic<ECEF>, mjd: ModifiedJulianDate) -> Radians {
        crate::calculus::solar::sun_azimuth_rad(mjd, observer)
    }

    fn azimuth_periods(&self, query: &AzimuthQuery) -> Vec<Period<MJD>> {
        if query.window.duration() <= Days::zero() {
            return Vec::new();
        }
        events::azimuth_range_periods(self, query)
    }

    fn scan_step_hint(&self) -> Option<Days> {
        Some(Hours::new(2.0).to::<Day>())
    }
}

/// **Moon** — delegates to [`calculus::lunar::moon_azimuth_rad`].
impl AzimuthProvider for solar_system::Moon {
    fn azimuth_at(&self, observer: &Geodetic<ECEF>, mjd: ModifiedJulianDate) -> Radians {
        crate::calculus::lunar::moon_azimuth_rad(mjd, observer)
    }

    fn azimuth_periods(&self, query: &AzimuthQuery) -> Vec<Period<MJD>> {
        if query.window.duration() <= Days::zero() {
            return Vec::new();
        }
        events::azimuth_range_periods(self, query)
    }

    fn scan_step_hint(&self) -> Option<Days> {
        // Moon moves slower; 2-hour steps are sufficient.
        Some(Hours::new(2.0).to::<Day>())
    }
}

/// **Star** — extracts RA/Dec, delegates to [`direction::ICRS`].
impl AzimuthProvider for Star<'_> {
    fn azimuth_at(&self, observer: &Geodetic<ECEF>, mjd: ModifiedJulianDate) -> Radians {
        let dir = direction::ICRS::from(self);
        dir.azimuth_at(observer, mjd)
    }

    fn azimuth_periods(&self, query: &AzimuthQuery) -> Vec<Period<MJD>> {
        let dir = direction::ICRS::from(self);
        dir.azimuth_periods(query)
    }
}

/// **direction::ICRS** — raw RA/Dec → stellar engine.
impl AzimuthProvider for direction::ICRS {
    fn azimuth_at(&self, observer: &Geodetic<ECEF>, mjd: ModifiedJulianDate) -> Radians {
        crate::calculus::stellar::fixed_star_azimuth_rad(mjd, observer, self.ra(), self.dec())
    }

    fn azimuth_periods(&self, query: &AzimuthQuery) -> Vec<Period<MJD>> {
        if query.window.duration() <= Days::zero() {
            return Vec::new();
        }
        events::azimuth_range_periods(self, query)
    }
}

// ---------------------------------------------------------------------------
// Implementations: VSOP87 Planets (Mercury–Neptune)
// ---------------------------------------------------------------------------

/// Computes the topocentric azimuth (in radians, North-clockwise) of a
/// VSOP87 planet at a given instant using the full transform pipeline.
fn vsop87_planet_azimuth_rad<F>(
    vsop87e_fn: F,
    mjd: ModifiedJulianDate,
    site: &Geodetic<ECEF>,
) -> Radians
where
    F: Fn(
        JulianDate,
    ) -> cartesian::Position<
        crate::coordinates::centers::Barycentric,
        frames::EclipticMeanJ2000,
        AstronomicalUnit,
    >,
{
    let jd: JulianDate = mjd.into();
    let bary_ecl = vsop87e_fn(jd);
    let geo_equ: cartesian::Position<Geocentric, frames::EquatorialMeanJ2000, AstronomicalUnit> =
        bary_ecl.transform(jd);
    let topo = horizontal::geocentric_j2000_to_apparent_topocentric(&geo_equ, *site, jd);
    let horiz = horizontal::equatorial_to_horizontal(&topo, *site, jd);
    horiz.az().to::<Radian>()
}

/// Helper macro: implement [`AzimuthProvider`] for a VSOP87-backed planet.
macro_rules! impl_azimuth_provider_vsop87 {
    ($($Planet:ident),+ $(,)?) => {
        $(
            impl AzimuthProvider for solar_system::$Planet {
                fn azimuth_at(
                    &self,
                    observer: &Geodetic<ECEF>,
                    mjd: ModifiedJulianDate,
                ) -> Radians {
                    vsop87_planet_azimuth_rad(
                        solar_system::$Planet::vsop87e, mjd, observer,
                    )
                }

                fn azimuth_periods(&self, query: &AzimuthQuery) -> Vec<Period<MJD>> {
                    if query.window.duration() <= Days::zero() {
                        return Vec::new();
                    }
                    events::azimuth_range_periods(self, query)
                }

                fn scan_step_hint(&self) -> Option<Days> {
                    Some(Hours::new(2.0).to::<Day>())
                }
            }
        )+
    };
}

impl_azimuth_provider_vsop87!(Mercury, Venus, Mars, Jupiter, Saturn, Uranus, Neptune);

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::bodies::catalog;
    use crate::bodies::solar_system::Moon;

    fn greenwich() -> Geodetic<ECEF> {
        Geodetic::<ECEF>::new(Degrees::new(0.0), Degrees::new(51.4769), Meters::new(0.0))
    }

    fn one_day_window() -> Period<MJD> {
        Period::new(
            ModifiedJulianDate::new(60000.0),
            ModifiedJulianDate::new(60001.0),
        )
    }

    #[test]
    fn sun_azimuth_at_returns_valid_range() {
        let az = solar_system::Sun.azimuth_at(
            &greenwich(),
            ModifiedJulianDate::new(60000.5), // noon-ish
        );
        assert!(az.value() >= 0.0, "azimuth must be ≥ 0");
        assert!(az.value() < std::f64::consts::TAU, "azimuth must be < 2π");
    }

    #[test]
    fn moon_azimuth_at_returns_valid_range() {
        let az = Moon.azimuth_at(&greenwich(), ModifiedJulianDate::new(60000.5));
        assert!(az.value() >= 0.0);
        assert!(az.value() < std::f64::consts::TAU);
    }

    #[test]
    fn star_azimuth_at_returns_valid_range() {
        let sirius = &catalog::SIRIUS;
        let az = sirius.azimuth_at(&greenwich(), ModifiedJulianDate::new(60000.5));
        assert!(az.value() >= 0.0);
        assert!(az.value() < std::f64::consts::TAU);
    }

    #[test]
    fn star_and_icrs_agree() {
        let sirius = &catalog::SIRIUS;
        let dir = direction::ICRS::from(sirius);
        let mjd = ModifiedJulianDate::new(60000.5);
        let az_star = sirius.azimuth_at(&greenwich(), mjd);
        let az_dir = dir.azimuth_at(&greenwich(), mjd);
        assert!(
            (az_star.value() - az_dir.value()).abs() < 1e-10,
            "Star and direction::ICRS must agree"
        );
    }

    #[test]
    fn sun_azimuth_periods_eastern_half() {
        let query = AzimuthQuery {
            observer: greenwich(),
            window: one_day_window(),
            min_azimuth: Degrees::new(90.0),
            max_azimuth: Degrees::new(270.0),
        };
        let periods = solar_system::Sun.azimuth_periods(&query);
        // Sun is on the eastern half (az 90-270) for roughly half a day
        assert!(
            !periods.is_empty(),
            "Sun should cross the eastern half in 24h"
        );
    }

    #[test]
    fn outside_azimuth_range_is_complement() {
        let observer = greenwich();
        let window = one_day_window();
        let inside = solar_system::Sun.in_azimuth_range(
            observer,
            window,
            Degrees::new(90.0),
            Degrees::new(270.0),
        );
        let outside = solar_system::Sun.outside_azimuth_range(
            observer,
            window,
            Degrees::new(90.0),
            Degrees::new(270.0),
        );

        // Total covered should be the full window
        let total_inside: f64 = inside.iter().map(|p| p.duration_days().value()).sum();
        let total_outside: f64 = outside.iter().map(|p| p.duration_days().value()).sum();
        let window_len = window.duration_days().value();
        assert!(
            (total_inside + total_outside - window_len).abs() < 1e-6,
            "inside + outside should equal the full window"
        );
    }

    // --- Planet azimuth ---

    #[test]
    fn mars_azimuth_at_returns_valid_range() {
        let az = solar_system::Mars.azimuth_at(&greenwich(), ModifiedJulianDate::new(60000.5));
        assert!(az.value() >= 0.0, "azimuth must be ≥ 0, got {}", az);
        assert!(
            az.value() < std::f64::consts::TAU,
            "azimuth must be < 2π, got {}",
            az
        );
    }

    #[test]
    fn all_planets_azimuth_valid() {
        let observer = greenwich();
        let mjd = ModifiedJulianDate::new(60000.5);
        let mercury_az = solar_system::Mercury.azimuth_at(&observer, mjd);
        let venus_az = solar_system::Venus.azimuth_at(&observer, mjd);
        let mars_az = solar_system::Mars.azimuth_at(&observer, mjd);
        let jupiter_az = solar_system::Jupiter.azimuth_at(&observer, mjd);
        let saturn_az = solar_system::Saturn.azimuth_at(&observer, mjd);
        let uranus_az = solar_system::Uranus.azimuth_at(&observer, mjd);
        let neptune_az = solar_system::Neptune.azimuth_at(&observer, mjd);

        for (name, az) in [
            ("Mercury", mercury_az),
            ("Venus", venus_az),
            ("Mars", mars_az),
            ("Jupiter", jupiter_az),
            ("Saturn", saturn_az),
            ("Uranus", uranus_az),
            ("Neptune", neptune_az),
        ] {
            assert!(
                az.value() >= 0.0 && az.value() < std::f64::consts::TAU,
                "{name} azimuth out of range: {az}"
            );
        }
    }
}