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::provenance::test_support::fixture as fixture_provenance;
use super::*;
use crate::evaluator::Target;
use crate::units::radiometry::{
    PhotonsPerSquareCentimeterNanosecondSteradian as BandPhotonRadiance, S10s,
};
use crate::DEG;
use siderust::coordinates::cartesian::Direction as CartesianDirection;
use siderust::coordinates::frames::Galactic;
use siderust::coordinates::spherical;
use siderust::healpix::{HealpixGrid, HealpixIndex, HealpixOrdering, Nside};
use siderust::qtty::Degrees;

const FIXTURE: &str = include_str!("../../../tests/data/starlight_fixture_map.csv");

const HEALPIX_FIXTURE: &str = r#"# map_type=healpix
# nside=1
# ordering=ring
# coordinate_frame=galactic
# s10_diagnostics=not_provided
# dataset_name=NSB synthetic test-only HEALPix starlight fixture
# version=fixture
# generation_date_utc=2026-06-21T00:00:00Z
# source_catalogue=synthetic unit-test fixture
# source_catalogue_release=test
# source_catalogue_license=test-only
# source_catalogue_checksum=sha256:fixture
# magnitude_limit=test-only
# map_resolution=HEALPix nside=1 ring 12 pixels
# photometry_model=fixture
# band_definition=integrated 300-650 nm photon radiance
# smoothing_fwhm_deg=none
# generated_by=unit test
healpix_index,integrated_ph_cm2_ns_sr,statistical_uncertainty_ph_cm2_ns_sr,systematic_uncertainty_ph_cm2_ns_sr,total_uncertainty_ph_cm2_ns_sr
0,1.0,0.1,0.2,0.25
1,2.0,0.2,0.4,0.5
2,3.0,0.3,0.6,0.75
3,4.0,0.4,0.8,1.0
4,5.0,0.5,1.0,1.25
5,6.0,0.6,1.2,1.5
6,7.0,0.7,1.4,1.75
7,8.0,0.8,1.6,2.0
8,9.0,0.9,1.8,2.25
9,10.0,1.0,2.0,2.5
10,11.0,1.1,2.2,2.75
11,12.0,1.2,2.4,3.0
"#;

fn fixture_map() -> StarlightMap {
    StarlightMap::from_csv_str(FIXTURE, fixture_provenance()).unwrap()
}

fn galactic_direction(lon_deg: f64, lat_deg: f64) -> CartesianDirection<Galactic> {
    spherical::Direction::<Galactic>::new(Degrees::new(lon_deg), Degrees::new(lat_deg))
        .to_cartesian()
}

fn packed_uncertainty_fixture(
    first_statistical: f64,
    first_systematic: f64,
    first_total: f64,
) -> String {
    let mut raw = String::from(concat!(
        "# map_type=healpix\n",
        "# nside=1\n",
        "# ordering=ring\n",
        "# coordinate_frame=galactic\n",
        "# s10_diagnostics=not_provided\n",
        "healpix_index,integrated_ph_cm2_ns_sr,",
        "statistical_uncertainty_ph_cm2_ns_sr,",
        "systematic_uncertainty_ph_cm2_ns_sr,",
        "total_uncertainty_ph_cm2_ns_sr\n",
    ));
    for index in 0..12 {
        let integrated = index as f64 + 1.0;
        let (statistical, systematic, total) = if index == 0 {
            (first_statistical, first_systematic, first_total)
        } else {
            (integrated * 0.1, integrated * 0.2, integrated * 0.25)
        };
        raw.push_str(&format!(
            "{index},{integrated},{statistical},{systematic},{total}\n",
        ));
    }
    raw
}

fn target(ra: f64, dec: f64) -> Target {
    Target::new(ra * DEG, dec * DEG)
}

#[test]
fn healpix_experimental_map_is_explicitly_labelled() {
    let map = StarlightMap::from_csv_str(HEALPIX_FIXTURE, fixture_provenance()).unwrap();
    let model = Starlight::with_shared_map(std::sync::Arc::new(map));
    let provenance = model.map().provenance();

    assert_eq!(
        provenance.dataset_name,
        "NSB synthetic test-only HEALPix starlight fixture"
    );
    assert_eq!(provenance.version, "fixture");
    assert_eq!(provenance.photometry_model.as_deref(), Some("fixture"));
    assert_eq!(model.map().pixels().len(), 12);

    let output = model.compute(target(266.4051, -28.936175)).unwrap();
    assert!(output.integrated.value().is_finite());
    assert!(output.integrated.value() > 0.0);
}

#[test]
fn bundled_production_model_is_available_only_with_registered_release_assets() {
    if StarlightProduct::bundled_production_available() {
        let model = Starlight::bundled_production_model().unwrap();
        let provenance = model.map().provenance();
        assert_eq!(provenance.calibration_status.as_deref(), Some("production"));
        assert_eq!(
            provenance.photometry_model.as_deref(),
            Some("gaia_dr3_xp_photon_radiance_300_650nm_packed_v1")
        );
        assert!(model.map().pixels().len() > 12);

        let map_meta =
            crate::data::bundled::bundled_asset("starlight_nside128.compact.release.bin")
                .expect("production map metadata");
        let sidecar_meta =
            crate::data::bundled::bundled_asset("starlight_nside128.compact.manifest.toml")
                .expect("production sidecar metadata");
        assert_eq!(map_meta.schema, "nsb-healpix-starlight-binary-v1");
        assert_eq!(sidecar_meta.schema, "nsb-starlight-runtime-manifest-v1");
        assert_eq!(map_meta.calibration_status, "production");
        assert_eq!(sidecar_meta.calibration_status, "production");
        assert!(!map_meta.sha256.is_empty());
        assert!(!sidecar_meta.sha256.is_empty());
    } else {
        let error = Starlight::bundled_production_model().unwrap_err();
        assert!(error
            .to_string()
            .contains("bundled production starlight asset is not registered"));
    }
}

#[cfg(nsb_bundled_production_starlight)]
#[test]
fn bundled_production_starlight_pins_issue_207_candidate_lineage() {
    const ISSUE_207_CANDIDATE_SHA256: &str =
        "e8d31d0a04a18314e3273ffc9e2ad449014adca8710b11bda2f3b6cf2c811526";
    let sidecar = crate::data::bundled::BUNDLED_PRODUCTION_STARLIGHT_MANIFEST;
    assert!(
        sidecar.contains(ISSUE_207_CANDIDATE_SHA256),
        "bundled production sidecar must pin the issue #207 Combined300To650 candidate"
    );
    let map = crate::data::bundled::bundled_asset("starlight_nside128.compact.release.bin")
        .expect("production map metadata");
    let sidecar_meta =
        crate::data::bundled::bundled_asset("starlight_nside128.compact.manifest.toml")
            .expect("production sidecar metadata");
    assert_eq!(map.sha256.len(), 64);
    assert_eq!(sidecar_meta.sha256.len(), 64);
    assert_ne!(map.sha256, ISSUE_207_CANDIDATE_SHA256);

    let model = Starlight::bundled_production_model().unwrap();
    let supplement = model
        .map()
        .provenance()
        .bright_star_supplement
        .as_ref()
        .expect("runtime provenance must expose the bright-star supplement");
    assert_eq!(
        supplement.uv_completion_model_id,
        "ck04-hp-scaled-uv-300-336-v1"
    );
    assert!(supplement.spectral_route.contains("Hp-scaled CK04"));
    assert!(!supplement.input_catalogues.is_empty());
    assert!(!supplement.license_provenance.is_empty());
}

#[cfg(nsb_bundled_production_starlight)]
#[test]
fn bundled_production_model_is_the_canonical_admitted_compact_map() {
    let canonical = ValidatedStarlightMap::from_bytes_and_manifest(
        crate::data::bundled::BUNDLED_PRODUCTION_STARLIGHT_MAP,
        crate::data::bundled::BUNDLED_PRODUCTION_STARLIGHT_MANIFEST,
    )
    .unwrap();
    let bundled = Starlight::bundled_production_model().unwrap();

    assert_eq!(bundled.map().grid(), canonical.map().grid());
    assert_eq!(bundled.map().pixels(), canonical.map().pixels());
    assert_eq!(bundled.map().provenance(), canonical.map().provenance());
    assert_eq!(
        canonical.diagnostics().pixel_count,
        canonical.map().pixels().len()
    );
    assert!(canonical.diagnostics().flux_conservation_recomputed);
}

#[test]
fn healpix_csv_fixture_loads_from_test_data_only() {
    let map = StarlightMap::from_csv_str(HEALPIX_FIXTURE, fixture_provenance()).unwrap();

    assert_eq!(map.pixels().len(), 12);
    assert_eq!(
        map.provenance().dataset_name,
        "NSB synthetic test-only HEALPix starlight fixture"
    );
    assert_eq!(
        map.provenance().photometry_model.as_deref(),
        Some("fixture")
    );

    let output = map.lookup(galactic_direction(0.0, 0.0));
    assert!(output.integrated.value().is_finite());
    assert!(output.integrated.value() > 0.0);
    assert!(output.statistical_uncertainty.is_some());
    assert!(!output.s10_diagnostics_provided);
}

#[test]
fn packed_uncertainties_parse_and_survive_lookup() {
    let raw = packed_uncertainty_fixture(0.1, 0.2, 0.25);
    let map = StarlightMap::from_csv_str(&raw, fixture_provenance()).unwrap();
    let output = map.lookup(
        map.pixel_direction(HealpixIndex::new(7))
            .unwrap()
            .to_cartesian(),
    );

    assert_eq!(output.integrated.value(), 8.0);
    assert_eq!(output.statistical_uncertainty.unwrap().value(), 0.8);
    assert_eq!(output.systematic_uncertainty.unwrap().value(), 1.6);
    assert_eq!(output.total_uncertainty.unwrap().value(), 2.0);
    assert_eq!(output.relative_uncertainty(), Some(0.25));
}

#[test]
fn packed_candidate_header_loads_without_invented_s10() {
    let mut raw = String::from(concat!(
        "# map_type=healpix\n",
        "# nside=1\n",
        "# ordering=ring\n",
        "# coordinate_frame=galactic\n",
        "# s10_diagnostics=not_provided\n",
        "healpix_index,integrated_ph_cm2_ns_sr,",
        "statistical_uncertainty_ph_cm2_ns_sr,",
        "systematic_uncertainty_ph_cm2_ns_sr,",
        "total_uncertainty_ph_cm2_ns_sr\n",
    ));
    for index in 0..12 {
        let integrated = if index == 11 { 0.0 } else { index as f64 + 1.0 };
        raw.push_str(&format!(
            "{index},{integrated},{stat},{sys},{tot}\n",
            stat = integrated * 0.1,
            sys = integrated * 0.2,
            tot = integrated * 0.25
        ));
    }
    let map = StarlightMap::from_csv_str(&raw, fixture_provenance()).unwrap();
    let occupied = map.lookup(
        map.pixel_direction(HealpixIndex::new(0))
            .unwrap()
            .to_cartesian(),
    );
    assert_eq!(occupied.integrated.value(), 1.0);
    assert!(!occupied.s10_diagnostics_provided);
    assert_eq!(occupied.b_flux_s10.value(), 0.0);
    let omitted = &map.pixels()[11];
    assert_eq!(omitted.integrated.value(), 0.0);
    assert_eq!(omitted.total_uncertainty.unwrap().value(), 0.0);
}

#[test]
fn packed_rejects_negative_or_inconsistent_uncertainties() {
    let negative = packed_uncertainty_fixture(-0.1, 0.2, 0.25);
    let error = StarlightMap::from_csv_str(&negative, fixture_provenance()).unwrap_err();
    assert!(error.to_string().contains("uncertainty triplet"));

    let total_below_component = packed_uncertainty_fixture(0.1, 0.3, 0.2);
    let error =
        StarlightMap::from_csv_str(&total_below_component, fixture_provenance()).unwrap_err();
    assert!(error.to_string().contains("total >= statistical"));
}

#[test]
fn map_lookup_is_directional_nearest_neighbor() {
    let map = fixture_map();
    let plane = map.lookup(galactic_direction(0.0, 0.0));
    let pole = map.lookup(galactic_direction(0.0, 90.0));
    assert!(plane.integrated > pole.integrated);
    assert!(!plane.s10_diagnostics_provided);

    let equatorial = map.lookup(galactic_direction(45.0, 0.0));
    assert!((equatorial.integrated.value() - 4.0).abs() < 1.0e-12);
}

#[test]
fn model_compute_depends_on_target() {
    let model = Starlight::with_shared_map(std::sync::Arc::new(fixture_map()));
    let galactic_center = model.compute(target(266.4051, -28.936175)).unwrap();
    let north_pole = model.compute(target(192.85948, 27.12825)).unwrap();

    assert!(galactic_center.integrated > north_pole.integrated);
}

#[test]
fn custom_scale_changes_outputs() {
    let base = Starlight::with_shared_map(std::sync::Arc::new(fixture_map()))
        .compute(target(266.4051, -28.936175))
        .unwrap();
    let scaled = super::model::test_support::with_scale(
        Starlight::with_shared_map(std::sync::Arc::new(fixture_map())),
        crate::units::ScaleFactors::new(2.0),
    )
    .compute(target(266.4051, -28.936175))
    .unwrap();

    assert!((scaled.integrated.value() / base.integrated.value() - 2.0).abs() < 1.0e-12);
}

#[test]
fn custom_scale_changes_absolute_but_not_relative_uncertainty() {
    let raw = packed_uncertainty_fixture(0.1, 0.2, 0.25);
    let map = StarlightMap::from_csv_str(&raw, fixture_provenance()).unwrap();
    let base = Starlight::with_shared_map(std::sync::Arc::new(map.clone()))
        .compute(target(266.4051, -28.936175))
        .unwrap();
    let scaled = super::model::test_support::with_scale(
        Starlight::with_shared_map(std::sync::Arc::new(map)),
        crate::units::ScaleFactors::new(2.0),
    )
    .compute(target(266.4051, -28.936175))
    .unwrap();

    assert_eq!(
        scaled.statistical_uncertainty.unwrap().value(),
        base.statistical_uncertainty.unwrap().value() * 2.0
    );
    assert_eq!(
        scaled.systematic_uncertainty.unwrap().value(),
        base.systematic_uncertainty.unwrap().value() * 2.0
    );
    assert_eq!(
        scaled.total_uncertainty.unwrap().value(),
        base.total_uncertainty.unwrap().value() * 2.0
    );
    assert_eq!(scaled.relative_uncertainty(), base.relative_uncertainty());
}

#[test]
fn incomplete_healpix_maps_are_rejected() {
    let grid = HealpixGrid::new(Nside::new(1).unwrap(), HealpixOrdering::Ring).unwrap();
    let pixel = StarlightPixel::new(BandPhotonRadiance::new(1.0), S10s::new(0.0), S10s::new(0.0))
        .without_s10_diagnostics()
        .with_uncertainties(
            BandPhotonRadiance::new(0.1),
            BandPhotonRadiance::new(0.2),
            BandPhotonRadiance::new(0.25),
        );
    let err = StarlightMap::from_healpix(grid, vec![pixel], fixture_provenance()).unwrap_err();
    assert!(matches!(err, crate::NsbError::InvalidMap { .. }));
}

#[test]
fn unsupported_csv_header_is_rejected() {
    let raw = "pixel,value\n0,1.0\n";
    let error = StarlightMap::from_csv_str(raw, fixture_provenance()).unwrap_err();
    assert!(error
        .to_string()
        .contains("unsupported starlight map header"));
}

#[test]
fn mixed_uncertainty_schema_is_rejected() {
    let grid = HealpixGrid::new(Nside::new(1).unwrap(), HealpixOrdering::Ring).unwrap();
    let with_uncertainty =
        StarlightPixel::new(BandPhotonRadiance::new(1.0), S10s::new(0.0), S10s::new(0.0))
            .without_s10_diagnostics()
            .with_uncertainties(
                BandPhotonRadiance::new(0.1),
                BandPhotonRadiance::new(0.2),
                BandPhotonRadiance::new(0.25),
            );
    let mut pixels = vec![with_uncertainty; 12];
    pixels[5] = StarlightPixel::new(BandPhotonRadiance::new(1.0), S10s::new(0.0), S10s::new(0.0))
        .without_s10_diagnostics();
    let err = StarlightMap::from_healpix(grid, pixels, fixture_provenance()).unwrap_err();
    assert!(err.to_string().contains("same uncertainty schema"));
}