siderust 0.13.0

High-precision astronomy and satellite mechanics in Rust.
Documentation
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Vallés Puig, Ramon

//! # Lunar Altitude Window Periods
//!
//! ## Scientific scope
//!
//! Moon‑specific routines for finding time intervals where the
//! topocentric Moon altitude is above, below, or within a given band —
//! the kinematic basis for moonrise/moonset, dark‑sky planning, and
//! lunar visibility windows. Topocentric parallax (~1° at horizon) is
//! handled by [`Moon::get_horizontal`] inside [`moon_altitude_rad`]; no
//! atmospheric refraction is applied. Position accuracy is bounded by the
//! underlying lunar engine (ELP truncation / DE chebyshev cache).
//!
//! ## Technical scope
//!
//! Crate-internal API: `moon_altitude_rad`, `lunar_above_threshold_impl`,
//! `lunar_below_threshold_impl`, `lunar_altitude_ranges_impl`.
//! period‑finding uses Chebyshev-first labelled crossing discovery with
//! precise validation and local scan+Brent fallback via
//! [`crate::event::search::crossings`]. `find_and_label_crossings` remains
//! available as a scan+Brent validation baseline.
//!
//! ## References
//! None.

use crate::bodies::solar_system::Moon;
use crate::coordinates::centers::Geodetic;
use crate::coordinates::frames::ECEF;
use crate::event::altitude::search::{InternalSearchConfig, DIURNAL_CHEBY_SCAN_STEP};
use crate::event::altitude::{CrossingDirection, CrossingEvent};
use crate::event::search::crossings;
use crate::event::search::intervals;
use crate::event::search::periods as threshold_periods;
use crate::qtty::*;
use crate::time::{Interval, JulianDate, ModifiedJulianDate};
use alloc::vec::Vec;

use super::moon_cache::MoonAltitudeContext;

// =============================================================================
// Core Altitude Function
// =============================================================================

/// Computes the Moon's **topocentric** altitude at a given Julian Date.
///
/// Accounts for topocentric parallax (~1° at horizon), precession, and the
/// full ecliptic → equatorial → horizontal transform chain.
///
/// # Arguments
/// * `jd` - Julian Date (TT) for the calculation
/// * `site` - Observer's geographic location
///
/// # Returns
/// Altitude as `Quantity<Radian>` (positive above horizon, negative below)
pub(crate) fn moon_altitude_rad(
    mjd: ModifiedJulianDate,
    site: &Geodetic<ECEF>,
) -> Quantity<Radian> {
    let jd: JulianDate = mjd.to::<crate::JD>();
    Moon::get_horizontal::<Kilometer>(jd, *site)
        .alt()
        .to::<Radian>()
}

// =============================================================================
// Main API
// =============================================================================

/// Finds periods when the Moon is above the given altitude threshold.
///
/// Uses Chebyshev-first crossing discovery with scan+Brent fallback.
///
/// # Arguments
/// * `site` - Observer's geographic location
/// * `period` - Time period to search
/// * `threshold` - Minimum altitude (e.g., 0° for horizon)
///
/// # Example
/// ```ignore
/// above_threshold(&Moon, &site, period, Degrees::new(0.0), SearchOpts::default());
/// ```
///
/// # Returns
///
/// Sorted, non‑overlapping `Vec<Interval<ModifiedJulianDate>>` where the
/// Moon altitude is at or above `threshold`.
pub(crate) fn lunar_above_threshold_impl(
    site: Geodetic<ECEF>,
    period: Interval<ModifiedJulianDate>,
    threshold: Degrees,
    opts: InternalSearchConfig,
) -> Vec<Interval<ModifiedJulianDate>> {
    let thr = threshold.to::<Radian>();

    // Build Chebyshev + nutation caches for the period
    let ctx = MoonAltitudeContext::new(period.start, period.end, site);

    let (labeled, start_above) = find_moon_labeled_crossings_with_context(&ctx, period, thr, opts);
    threshold_periods::assemble_above_threshold_periods(&labeled, period, start_above)
}

/// Finds periods when the Moon is below the given altitude threshold.
///
/// Complement of [`above_threshold`] within `period`.
///
/// # Arguments
///
/// * `site`, geodetic observer location
/// * `period`, MJD/TT search window
/// * `threshold`, altitude upper bound (e.g. `−0.5°` for "Moon down")
///
/// # Example
/// ```ignore
/// below_threshold(&Moon, &site, period, Degrees::new(-0.5), SearchOpts::default());
/// ```
///
/// # Returns
///
/// Sorted, non‑overlapping `Vec<Interval<ModifiedJulianDate>>` where the
/// Moon altitude is at or below `threshold`.
pub(crate) fn lunar_below_threshold_impl(
    site: Geodetic<ECEF>,
    period: Interval<ModifiedJulianDate>,
    threshold: Degrees,
    opts: InternalSearchConfig,
) -> Vec<Interval<ModifiedJulianDate>> {
    let above = lunar_above_threshold_impl(site, period, threshold, opts);
    threshold_periods::complement_threshold_periods(period, &above)
}

/// Finds periods when Moon altitude is within a range `[min, max]`.
///
/// Computed as `above(min) ∩ complement(above(max))` via
/// [`math_core::intervals::in_range_periods`].
///
/// # Arguments
///
/// * `site`, geodetic observer location
/// * `period`, MJD/TT search window
/// * `range`, `(min_altitude, max_altitude)` band
///
/// # Example
/// ```ignore
/// altitude_ranges(&Moon, &site, period, Degrees::new(0.0), Degrees::new(30.0), SearchOpts::default());
/// ```
///
/// # Returns
///
/// Sorted, non‑overlapping `Vec<Interval<ModifiedJulianDate>>` of intervals
/// where `min ≤ altitude(t) ≤ max`.
pub(crate) fn lunar_altitude_ranges_impl(
    site: Geodetic<ECEF>,
    period: Interval<ModifiedJulianDate>,
    range: (Degrees, Degrees),
    opts: InternalSearchConfig,
) -> Vec<Interval<ModifiedJulianDate>> {
    let h_min = range.0.to::<Radian>();
    let h_max = range.1.to::<Radian>();

    // Build Chebyshev + nutation caches for the period
    let ctx = MoonAltitudeContext::new(period.start, period.end, site);

    let (min_crossings, start_above_min) =
        find_moon_labeled_crossings_with_context(&ctx, period, h_min, opts);
    let (max_crossings, start_above_max) =
        find_moon_labeled_crossings_with_context(&ctx, period, h_max, opts);
    threshold_periods::assemble_in_range_periods(
        &min_crossings,
        start_above_min,
        &max_crossings,
        start_above_max,
        period,
    )
}

pub(crate) fn lunar_crossings_impl(
    site: Geodetic<ECEF>,
    period: Interval<ModifiedJulianDate>,
    threshold: Degrees,
    opts: InternalSearchConfig,
) -> Vec<CrossingEvent> {
    let thr = threshold.to::<Radian>();
    let ctx = MoonAltitudeContext::new(period.start, period.end, site);
    let (labeled, _) = find_moon_labeled_crossings_with_context(&ctx, period, thr, opts);
    labeled
        .iter()
        .map(|crossing| CrossingEvent {
            mjd: crossing.t,
            direction: if crossing.direction > 0 {
                CrossingDirection::Rising
            } else {
                CrossingDirection::Setting
            },
        })
        .collect()
}

fn find_moon_labeled_crossings_with_context(
    ctx: &MoonAltitudeContext,
    period: Interval<ModifiedJulianDate>,
    threshold: Radians,
    opts: InternalSearchConfig,
) -> (Vec<intervals::LabeledCrossing>, bool) {
    let signal = |t: ModifiedJulianDate| -> f64 { ctx.altitude_rad(t).sin() };
    let (labeled, start_above, _) = crossings::find_labelled_crossings(
        period,
        DIURNAL_CHEBY_SCAN_STEP,
        &signal,
        threshold.sin(),
        opts,
    );
    (labeled, start_above)
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use crate::catalogs::observatories::ROQUE_DE_LOS_MUCHACHOS;

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

    #[test]
    fn test_moon_altitude_basic() {
        let site = greenwich_site();
        let mjd: ModifiedJulianDate = crate::J2000.to::<crate::MJD>();
        let alt = moon_altitude_rad(mjd, &site);
        assert!(
            alt > -core::f64::consts::FRAC_PI_2 * RAD && alt < core::f64::consts::FRAC_PI_2 * RAD
        );
    }

    #[test]
    fn test_lunar_above_threshold() {
        let site = greenwich_site();
        let mjd_start = crate::time::ModifiedJulianDate::new(60000.0);
        let mjd_end = crate::time::ModifiedJulianDate::new(60007.0);
        let period = Interval::new(mjd_start, mjd_end);

        let periods = lunar_above_threshold_impl(
            site,
            period,
            Degrees::new(0.0),
            InternalSearchConfig::default(),
        );
        assert!(
            !periods.is_empty(),
            "Should find moon-up periods over 7 days"
        );

        for p in &periods {
            assert!(
                p.length() > Days::new(0.0),
                "Period duration should be positive"
            );
        }
    }

    #[test]
    fn test_lunar_below_threshold() {
        let site = ROQUE_DE_LOS_MUCHACHOS.geodetic();
        let mjd_start = crate::time::ModifiedJulianDate::new(60676.0);
        let mjd_end = crate::time::ModifiedJulianDate::new(60683.0);
        let period = Interval::new(mjd_start, mjd_end);

        let periods = lunar_below_threshold_impl(
            site,
            period,
            Degrees::new(-0.5),
            InternalSearchConfig::default(),
        );
        assert!(!periods.is_empty(), "Should find moon-down periods");
    }

    fn scan_baseline_above_horizon(
        ctx: &MoonAltitudeContext,
        period: Interval<ModifiedJulianDate>,
        threshold: Degrees,
    ) -> Vec<Interval<ModifiedJulianDate>> {
        let f = |t: ModifiedJulianDate| -> Radians { ctx.altitude_rad(t) };
        let (labeled, start_above) = crate::event::lunar::moon_cache::find_and_label_crossings(
            period,
            DIURNAL_CHEBY_SCAN_STEP,
            &f,
            threshold.to::<Radian>(),
        );
        intervals::build_above_periods_directed(&labeled, period, start_above)
    }

    #[test]
    fn chebyshev_lunar_above_threshold_matches_scan_baseline() {
        // Verify that math_core-based and scan-based algorithms produce similar results
        let site = greenwich_site();
        let mjd_start = crate::time::ModifiedJulianDate::new(60000.0);
        let mjd_end = crate::time::ModifiedJulianDate::new(60003.0);
        let period = Interval::new(mjd_start, mjd_end);

        let main_result = lunar_above_threshold_impl(
            site,
            period,
            Degrees::new(0.0),
            InternalSearchConfig::default(),
        );
        let ctx = MoonAltitudeContext::new(period.start, period.end, site);
        let scan_result = scan_baseline_above_horizon(&ctx, period, Degrees::new(0.0));

        assert!(!main_result.is_empty());
        assert!(!scan_result.is_empty());

        assert_eq!(
            main_result.len(),
            scan_result.len(),
            "Should find same number of periods"
        );

        // Boundaries should match within tolerance (~1 minute)
        let tolerance = Days::new(1.0 / 1440.0);
        for (m, s) in main_result.iter().zip(scan_result.iter()) {
            assert!(
                (m.start.raw() - s.start.raw()).abs() < tolerance,
                "Start times should match within 1 minute"
            );
            assert!(
                (m.end.raw() - s.end.raw()).abs() < tolerance,
                "End times should match within 1 minute"
            );
        }
    }
}