nsb 0.1.1

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
//! Target-independent and target-specific preparation for window searches.

use super::types::{PreparedThresholdQuery, SiteWindowContext, ThresholdQuery};
use crate::components::airglow;
use crate::error::{NsbError, Result};
use crate::evaluator::{ComponentMask, NsbEvaluator};
use crate::units::radiometry::PhotonsPerSquareCentimeterNanosecondSteradian as BandPhotonRadiance;
use siderust::bodies::Moon as MoonBody;
use siderust::coordinates::{spherical::direction, transform::TransformFrame};
use siderust::event::altitude::{above_threshold as altitude_above_threshold, SearchOpts};
use siderust::qtty::Degrees;
use siderust::time::{intersect_periods, Interval as TimePeriod, ModifiedJulianDate};
use std::sync::Arc;

pub(crate) fn validate_query(query: &ThresholdQuery) -> Result<()> {
    crate::evaluator::validate_geometry(query.observer, query.target)?;
    if query
        .sun_altitude_ceiling
        .is_some_and(|angle| !angle.is_finite())
        || query
            .target_altitude_floor
            .is_some_and(|angle| !angle.is_finite())
    {
        return Err(NsbError::OutOfRange(
            "altitude filters must be finite".into(),
        ));
    }
    if !query.threshold.is_finite() {
        return Err(NsbError::OutOfRange("threshold must be finite".to_string()));
    }
    if query.window.start > query.window.end {
        return Err(NsbError::OutOfRange(
            "query window start must not be after end".to_string(),
        ));
    }
    Ok(())
}

pub(crate) fn prepare_site_context(
    evaluator: &NsbEvaluator,
    query: &ThresholdQuery,
    tt_window: TimePeriod<ModifiedJulianDate>,
) -> Result<SiteWindowContext> {
    let astronomy = Arc::new(super::astronomy::AstronomyCache::new(query.observer));
    let uses_airglow = query.components.contains(ComponentMask::AIRGLOW);
    let uses_moon = query.components.contains(ComponentMask::MOON);
    let prepare_nights = || {
        if uses_airglow {
            airglow::temporal::astronomical_nights_for_window(tt_window, query.observer)
        } else {
            Vec::new()
        }
    };
    let prepare_moon = || {
        uses_moon.then(|| {
            altitude_above_threshold(
                &MoonBody,
                &query.observer,
                tt_window,
                Degrees::new(0.0),
                SearchOpts::default(),
            )
        })
    };
    let (astronomical_night_periods, moon_visible_periods) =
        rayon::join(prepare_nights, prepare_moon);
    let sun_filter_periods = match query.sun_altitude_ceiling {
        Some(sun_max) if uses_airglow && airglow::temporal::is_astronomical_twilight(sun_max) => {
            airglow::temporal::clipped_night_periods(&astronomical_night_periods, tt_window)
        }
        Some(sun_max) => {
            airglow::temporal::sun_below_threshold_periods(tt_window, query.observer, sun_max)
        }
        None => vec![tt_window],
    };
    let airglow_phase_periods = if uses_airglow {
        airglow::temporal::airglow_phase_periods_for_window(&astronomical_night_periods, tt_window)
    } else {
        Vec::new()
    };
    let airglow_model = uses_airglow.then(|| {
        let profile = evaluator
            .model_config()
            .site_profile()
            .resolve(query.observer);
        airglow::Airglow::with_shared_continuum(
            query.observer,
            Arc::clone(evaluator.airglow_continuum()),
        )
        .with_atmosphere(profile.atmosphere)
        .with_geometry(evaluator.model_config().airglow_geometry().clone())
        .with_scale(profile.airglow.scale)
    });
    let solar_activity_cache = uses_airglow
        .then(|| {
            crate::solar_activity::SolarActivityValueCache::new(
                evaluator.model_config().solar_activity(),
            )
        })
        .transpose()?;
    astronomy.validate_time_data()?;
    Ok(SiteWindowContext {
        evaluator_identity: Arc::clone(evaluator.identity()),
        astronomy,
        observer: query.observer,
        window: query.window,
        components: query.components,
        sun_altitude_ceiling: query.sun_altitude_ceiling,
        tt_window,
        sun_filter_periods: Arc::from(sun_filter_periods),
        astronomical_night_periods: Arc::from(astronomical_night_periods),
        airglow_phase_periods: Arc::from(airglow_phase_periods),
        airglow_model,
        solar_activity_cache,
        moon_visible_periods: moon_visible_periods.map(Arc::from),
    })
}

pub(crate) fn validate_site_context(
    evaluator: &NsbEvaluator,
    context: &SiteWindowContext,
    query: &ThresholdQuery,
) -> Result<()> {
    if !Arc::ptr_eq(&context.evaluator_identity, evaluator.identity()) {
        return Err(NsbError::OutOfRange(
            "site window context belongs to a different evaluator".to_string(),
        ));
    }
    if context.observer != query.observer
        || context.window != query.window
        || context.components != query.components
        || context.sun_altitude_ceiling != query.sun_altitude_ceiling
    {
        return Err(NsbError::OutOfRange(
            "query site, window, components, or Sun filter is incompatible with the site window context"
                .to_string(),
        ));
    }
    context.astronomy.validate_time_data()
}

pub(crate) fn prepare_target_threshold(
    evaluator: &NsbEvaluator,
    context: &SiteWindowContext,
    query: &ThresholdQuery,
) -> Result<PreparedThresholdQuery> {
    let starlight_integrated = if query.components.contains(ComponentMask::STARLIGHT) {
        evaluator.evaluate_starlight(query.target)?.integrated
    } else {
        BandPhotonRadiance::new(0.0)
    };
    let target_visible_periods = if let Some(target_min) = query.target_altitude_floor {
        let target_dir: direction::ICRS = query.target.to_frame();
        altitude_above_threshold(
            &target_dir,
            &query.observer,
            context.tt_window,
            target_min,
            SearchOpts::default(),
        )
    } else {
        vec![context.tt_window]
    };
    let candidate_windows =
        intersect_periods(context.sun_filter_periods.as_ref(), &target_visible_periods);
    let prepared = PreparedThresholdQuery {
        astronomy: Arc::clone(&context.astronomy),
        observer: query.observer,
        target: query.target,
        components: query.components,
        starlight_integrated,
        tt_window: context.tt_window,
        astronomical_night_periods: Arc::clone(&context.astronomical_night_periods),
        candidate_windows,
        airglow_phase_periods: Arc::clone(&context.airglow_phase_periods),
        airglow_model: context.airglow_model.clone(),
        solar_activity_cache: context.solar_activity_cache.clone(),
        moon_visible_periods: context.moon_visible_periods.clone(),
    };
    Ok(prepared)
}