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
mod common;

use chrono::{DateTime, Utc};
use common::{ctao_north_planning, ctao_south_planning};
use nsb::{
    ComponentCalibrationStatus, ComponentMask, GenericClearSky, MoonlightModel, NsbComponent,
    NsbEvaluator, NsbModelConfig, PointQuery, SiteProfile, Target, DEG,
};
use siderust::catalogs::observatories;
use siderust::coordinates::centers::Geodetic;
use siderust::coordinates::frames::ECEF;
use siderust::qtty::{Degrees, Meters};
use tempoch::{Time, UTC};

fn parse_time(value: &str) -> Time<UTC> {
    Time::<UTC>::from_chrono(
        DateTime::parse_from_rfc3339(value)
            .unwrap()
            .with_timezone(&Utc),
    )
}

fn time() -> Time<UTC> {
    parse_time("2023-09-04T01:48:00Z")
}

fn profile_time() -> Time<UTC> {
    parse_time("2023-09-29T03:00:00Z")
}

fn target() -> Target {
    Target::new(266.41683 * DEG, -29.00781 * DEG)
}

fn profile_target() -> Target {
    Target::new(270.0 * DEG, -30.0 * DEG)
}

fn paranal() -> Geodetic<ECEF> {
    observatories::EL_PARANAL.geodetic()
}

fn arbitrary_observer() -> Geodetic<ECEF> {
    Geodetic::new_raw(Degrees::new(18.4), Degrees::new(-33.9), Meters::new(120.0))
}

fn evaluate_moonlight_at(
    config: NsbModelConfig,
    observer: Geodetic<ECEF>,
    time: Time<UTC>,
    target: Target,
) -> NsbComponent {
    NsbEvaluator::with_config(config)
        .unwrap()
        .evaluate(&PointQuery::new(observer, time, target).with_components(ComponentMask::MOON))
        .unwrap()
        .components
        .into_iter()
        .next()
        .unwrap()
}

fn evaluate_moonlight(config: NsbModelConfig, observer: Geodetic<ECEF>) -> NsbComponent {
    evaluate_moonlight_at(config, observer, time(), target())
}

#[test]
fn moonlight_model_identity_and_default_are_stable() {
    let default = NsbModelConfig::default();

    assert_eq!(default.moonlight_model(), MoonlightModel::Jones2013Spectral);
    assert_eq!(
        MoonlightModel::Jones2013Spectral.as_str(),
        "jones-2013-spectral"
    );
    assert_eq!(
        MoonlightModel::KrisciunasSchaefer1991.as_str(),
        "krisciunas-schaefer-1991"
    );
}

#[test]
fn moonlight_model_builder_selects_both_supported_models() {
    for model in [
        MoonlightModel::Jones2013Spectral,
        MoonlightModel::KrisciunasSchaefer1991,
    ] {
        let config = NsbModelConfig::default().with_moonlight_model(model);
        assert_eq!(config.moonlight_model(), model);
    }
}

#[test]
fn explicit_default_selection_is_bitwise_identical_and_dispatches_to_jones() {
    let default_config = NsbModelConfig::default();
    let explicit_config =
        NsbModelConfig::default().with_moonlight_model(MoonlightModel::Jones2013Spectral);

    let default = evaluate_moonlight(default_config, paranal());
    let explicit = evaluate_moonlight(explicit_config, paranal());

    assert_eq!(
        default.integrated.value().to_bits(),
        explicit.integrated.value().to_bits()
    );
    assert_eq!(
        default.b_flux_s10.value().to_bits(),
        explicit.b_flux_s10.value().to_bits()
    );
    assert_eq!(
        default.v_flux_s10.value().to_bits(),
        explicit.v_flux_s10.value().to_bits()
    );
    assert!(explicit.metadata.provenance.contains("Jones+2013"));
}

#[test]
fn ks_selection_dispatches_to_published_reference_implementation() {
    let component = evaluate_moonlight(
        NsbModelConfig::default().with_moonlight_model(MoonlightModel::KrisciunasSchaefer1991),
        paranal(),
    );

    assert!(component.integrated.value().is_finite());
    assert_eq!(
        component.metadata.status,
        ComponentCalibrationStatus::PublishedReference
    );
    assert!(component
        .metadata
        .provenance
        .contains("Krisciunas & Schaefer 1991"));
}

#[test]
fn site_profile_selection_does_not_change_scientific_model() {
    for model in [
        MoonlightModel::Jones2013Spectral,
        MoonlightModel::KrisciunasSchaefer1991,
    ] {
        let configs = [
            NsbModelConfig::default()
                .with_moonlight_model(model)
                .with_site_profile(SiteProfile::<GenericClearSky>::generic_clear_sky()),
            NsbModelConfig::default()
                .with_moonlight_model(model)
                .with_site_profile(ctao_north_planning()),
            NsbModelConfig::default()
                .with_moonlight_model(model)
                .with_site_profile(ctao_south_planning()),
        ];
        for config in configs {
            assert_eq!(config.moonlight_model(), model);
        }
    }
}

#[test]
fn observer_coordinates_do_not_change_selected_scientific_model() {
    for (model, expected_provenance) in [
        (MoonlightModel::Jones2013Spectral, "Jones+2013"),
        (
            MoonlightModel::KrisciunasSchaefer1991,
            "Krisciunas & Schaefer 1991",
        ),
    ] {
        let config = NsbModelConfig::default()
            .with_site_profile(ctao_south_planning())
            .with_moonlight_model(model);

        for observer in [paranal(), arbitrary_observer()] {
            let evaluator = NsbEvaluator::with_config(config.clone()).unwrap();
            assert_eq!(evaluator.config().moonlight_model(), model);
            let component = evaluator
                .evaluate(
                    &PointQuery::new(observer, time(), target())
                        .with_components(ComponentMask::MOON),
                )
                .unwrap()
                .components
                .into_iter()
                .next()
                .unwrap();
            assert!(component.metadata.provenance.contains(expected_provenance));
        }
    }
}

#[test]
fn jones_uses_selected_site_profile_atmosphere() {
    let observer = paranal();
    let evaluate = |config: NsbModelConfig| {
        evaluate_moonlight_at(
            config.with_moonlight_model(MoonlightModel::Jones2013Spectral),
            observer,
            profile_time(),
            profile_target(),
        )
    };
    let generic = evaluate(
        NsbModelConfig::default()
            .with_site_profile(SiteProfile::<GenericClearSky>::generic_clear_sky()),
    );
    let north = evaluate(NsbModelConfig::default().with_site_profile(ctao_north_planning()));
    let south = evaluate(NsbModelConfig::default().with_site_profile(ctao_south_planning()));

    for output in [&generic, &north, &south] {
        assert!(output.integrated.value().is_finite());
        assert!(output.integrated.value() > 0.0);
    }
    assert_ne!(
        generic.integrated.value().to_bits(),
        north.integrated.value().to_bits()
    );
    assert_ne!(
        generic.integrated.value().to_bits(),
        south.integrated.value().to_bits()
    );
    assert_ne!(
        north.integrated.value().to_bits(),
        south.integrated.value().to_bits()
    );
}

#[test]
fn ks_published_reference_is_independent_of_site_profile() {
    let observer = paranal();

    let evaluate = |config: NsbModelConfig| {
        evaluate_moonlight_at(
            config.with_moonlight_model(MoonlightModel::KrisciunasSchaefer1991),
            observer,
            profile_time(),
            profile_target(),
        )
    };
    let generic = evaluate(
        NsbModelConfig::default()
            .with_site_profile(SiteProfile::<GenericClearSky>::generic_clear_sky()),
    );
    let north = evaluate(NsbModelConfig::default().with_site_profile(ctao_north_planning()));
    let south = evaluate(NsbModelConfig::default().with_site_profile(ctao_south_planning()));

    for output in [&generic, &north, &south] {
        assert!(output.integrated.value().is_finite());
        assert!(output.integrated.value() > 0.0);
    }

    for candidate in [&north, &south] {
        assert_eq!(
            generic.integrated.value().to_bits(),
            candidate.integrated.value().to_bits()
        );
        assert_eq!(
            generic.b_flux_s10.value().to_bits(),
            candidate.b_flux_s10.value().to_bits()
        );
        assert_eq!(
            generic.v_flux_s10.value().to_bits(),
            candidate.v_flux_s10.value().to_bits()
        );
    }
}

#[test]
fn metadata_preserves_model_identity_and_records_site_assumptions_separately() {
    let jones = evaluate_moonlight_at(
        NsbModelConfig::default()
            .with_site_profile(ctao_south_planning())
            .with_moonlight_model(MoonlightModel::Jones2013Spectral),
        paranal(),
        profile_time(),
        profile_target(),
    );
    assert!(jones.metadata.provenance.contains("Jones+2013"));
    assert!(jones.metadata.provenance.contains("ctao-south-planning"));
    assert!(!jones
        .metadata
        .provenance
        .contains("Krisciunas & Schaefer 1991"));

    let ks = evaluate_moonlight_at(
        NsbModelConfig::default()
            .with_site_profile(ctao_north_planning())
            .with_moonlight_model(MoonlightModel::KrisciunasSchaefer1991),
        paranal(),
        profile_time(),
        profile_target(),
    );
    assert_eq!(
        ks.metadata.status,
        ComponentCalibrationStatus::PublishedReference
    );
    assert!(ks
        .metadata
        .provenance
        .contains("Krisciunas & Schaefer 1991"));
    assert!(ks.metadata.provenance.contains("ctao-north-planning"));
    assert!(ks
        .metadata
        .provenance
        .contains("fixed validated V-band extinction k=0.172"));
    assert!(!ks.metadata.provenance.contains("Jones+2013"));
}