nsb 0.1.0

Night-sky background evaluation and observation planning for ground-based astronomy.
Documentation
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Vallés Puig, Ramon
use super::calibration::AirglowContinuum;
use super::continuum::{
    evaluate_continuum, evaluate_integrated_continuum_with_night_phase,
    validate_integrated_continuum_inputs, AirglowEvaluationContext,
};
use super::domain::AirglowNightPhase;
use super::geometry::{target_altitude, AirglowGeometryModel, VanRhijnConfig};
use super::output::AirglowOutputs;
use super::units::{SolarFluxUnits, DEFAULT_SOLAR_RADIO_FLUX};
use crate::error::Result;
use crate::site::AtmosphericConditions;
use crate::units::radiometry::PhotonsPerSquareCentimeterNanosecondSteradian as BandPhotonRadiance;
use crate::units::ScaleFactors;
use siderust::coordinates::centers::Geodetic;
use siderust::coordinates::frames::{EquatorialMeanJ2000, ECEF};
use siderust::coordinates::spherical::Direction as SphericalDirection;
use std::sync::Arc;
use tempoch::{Time, UTC};

/// Supported scientific Airglow models.
///
/// This selects the scientific parameterization used to estimate Airglow. It is
/// independent of emitting-volume geometry and site calibration/maturity.
/// Additional scientifically supported models may be added in future releases;
/// downstream matches should include a wildcard arm.
///
/// Model *selection* (automatic versus explicit) is owned by
/// [`super::AirglowSelection`], not by this enum alone.
#[cfg_attr(
    feature = "python",
    pyo3::pyclass(
        eq,
        frozen,
        rename_all = "SCREAMING_SNAKE_CASE",
        module = "nsb",
        from_py_object
    )
)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AirglowModel {
    /// PALACE v1.0 Paranal unresolved-continuum planning model.
    ///
    /// This preserves PALACE's continuum templates and climatology, but excludes
    /// its emission-line model. It is the temporary automatic fallback, not a
    /// globally calibrated climatology or a CTAO site calibration.
    ParanalPalaceV1,
}

impl AirglowModel {
    /// Stable machine-readable scientific model identifier.
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::ParanalPalaceV1 => "paranal-palace-v1",
        }
    }
}

#[derive(Debug, Clone)]
/// Empirical airglow continuum evaluator for an arbitrary Earth location.
///
/// Geographic support is independent of scientific calibration maturity. The
/// bundled continuum is Paranal-derived and is used as a generic/planning proxy
/// unless a future, explicit validated calibration path selects otherwise.
pub(crate) struct Airglow {
    location: Geodetic<ECEF>,
    continuum: Arc<AirglowContinuum>,
    atmosphere: AtmosphericConditions,
    geometry: AirglowGeometryModel,
    solar_radio_flux: SolarFluxUnits,
    scale: ScaleFactors,
}

impl Airglow {
    pub(crate) fn with_shared_continuum(
        location: Geodetic<ECEF>,
        continuum: Arc<AirglowContinuum>,
    ) -> Self {
        let geometry = AirglowGeometryModel::VanRhijn(VanRhijnConfig::from_continuum_height(
            continuum.representative_emission_height_km(),
        ));
        Self {
            location,
            continuum,
            atmosphere: AtmosphericConditions::generic_clear_sky(location),
            geometry,
            solar_radio_flux: DEFAULT_SOLAR_RADIO_FLUX,
            scale: ScaleFactors::new(1.0),
        }
    }

    /// Select atmospheric pressure/Rayleigh/Mie assumptions for Noll scattering.
    ///
    /// Atmospheric choices affect propagation only and preserve the scientific
    /// profile and calibration maturity.
    pub fn with_atmosphere(mut self, atmosphere: AtmosphericConditions) -> Self {
        self.atmosphere = atmosphere;
        self
    }

    /// Select the emitting-volume line-of-sight geometry model.
    ///
    /// This does not change atmospheric extinction/scattering or calibration
    /// maturity. Van Rhijn is the default; vertical profiles must be selected
    /// explicitly.
    pub fn with_geometry(mut self, geometry: AirglowGeometryModel) -> Self {
        self.geometry = geometry;
        self
    }

    /// Set the F10.7 solar-radio-flux input.
    ///
    /// Solar activity changes the empirical correction only; it cannot confer
    /// site calibration.
    pub fn with_solar_radio_flux(mut self, flux: SolarFluxUnits) -> Self {
        self.solar_radio_flux = flux;
        self
    }

    /// Apply an explicit multiplicative continuum scale.
    ///
    /// A caller-provided scale is operational input, not calibration evidence,
    /// and therefore preserves the model's scientific maturity.
    pub fn with_scale(mut self, scale: ScaleFactors) -> Self {
        self.scale = scale;
        self
    }

    /// Evaluate airglow toward a target at one UTC instant.
    pub fn compute(
        &self,
        time: Time<UTC>,
        target: SphericalDirection<EquatorialMeanJ2000>,
    ) -> Result<AirglowOutputs> {
        let altitude = target_altitude(time, self.location, target);
        evaluate_continuum(
            &self.continuum,
            time,
            altitude,
            AirglowEvaluationContext {
                location: self.location,
                atmosphere: self.atmosphere,
                geometry: self.geometry.clone(),
                solar_radio_flux: self.solar_radio_flux,
                user_scale: self.scale,
            },
        )
    }

    pub(crate) fn compute_integrated_with_night_phase(
        &self,
        time: Time<UTC>,
        target: SphericalDirection<EquatorialMeanJ2000>,
        phase: AirglowNightPhase,
        solar_radio_flux: SolarFluxUnits,
    ) -> Result<BandPhotonRadiance> {
        let altitude = target_altitude(time, self.location, target);
        evaluate_integrated_continuum_with_night_phase(
            &self.continuum,
            time,
            altitude,
            AirglowEvaluationContext {
                location: self.location,
                atmosphere: self.atmosphere,
                geometry: self.geometry.clone(),
                solar_radio_flux,
                user_scale: self.scale,
            },
            phase,
        )
    }

    /// Validate Airglow inputs for a sample that may be outside astronomical night.
    ///
    /// Planning paths that skip radiance when outside night must still reject
    /// invalid altitude / F10.7 / scale rather than treating them as physical zero.
    pub(crate) fn validate_inputs_for_query(
        &self,
        time: Time<UTC>,
        target: SphericalDirection<EquatorialMeanJ2000>,
        solar_radio_flux: SolarFluxUnits,
    ) -> Result<()> {
        let altitude = target_altitude(time, self.location, target);
        validate_integrated_continuum_inputs(
            altitude,
            &AirglowEvaluationContext {
                location: self.location,
                atmosphere: self.atmosphere,
                geometry: self.geometry.clone(),
                solar_radio_flux,
                user_scale: self.scale,
            },
        )
    }
}

#[cfg(test)]
pub(crate) mod test_support {
    use super::super::calibration::load_builtin_standard;
    use super::*;
    use crate::site::SiteProfileConfig;

    pub(crate) fn standard_clear_sky(location: Geodetic<ECEF>) -> Result<Airglow> {
        let continuum = Arc::new(load_builtin_standard()?);
        Ok(Airglow::with_shared_continuum(location, continuum)
            .with_atmosphere(AtmosphericConditions::generic_clear_sky(location)))
    }

    pub(crate) fn for_site_profile(
        location: Geodetic<ECEF>,
        site_profile: &SiteProfileConfig,
    ) -> Result<Airglow> {
        let profile = site_profile.resolve(location);
        let continuum = Arc::new(load_builtin_standard()?);
        Ok(Airglow::with_shared_continuum(location, continuum)
            .with_atmosphere(profile.atmosphere)
            .with_scale(profile.airglow.scale))
    }

    pub(crate) fn with_continuum(location: Geodetic<ECEF>, continuum: AirglowContinuum) -> Airglow {
        Airglow::with_shared_continuum(location, Arc::new(continuum))
    }

    pub(crate) fn geometry(model: &Airglow) -> &AirglowGeometryModel {
        &model.geometry
    }
}