mod common;
use chrono::{DateTime, NaiveDateTime, Utc};
use common::starlight_test_provenance;
use common::{ctao_north_planning, ctao_south_planning};
use nsb::components::starlight::StarlightMap;
use nsb::solar_activity::{bundled_f107_store, SolarActivitySource};
use nsb::units::radiometry::PhotonsPerSquareCentimeterNanosecondSteradian as BandPhotonRadiance;
use nsb::{
CalibrationStatus, ComponentCalibrationStatus, ComponentMask, MoonlightModel, NsbEvaluator,
NsbModelConfig, PointQuery, StarlightProduct, Target, ThresholdQuery, DEG,
};
use siderust::catalogs::observatories;
use siderust::coordinates::centers::Geodetic;
use siderust::coordinates::frames::ECEF;
use std::sync::Arc;
use tempoch::{Period, Time, UTC};
fn parse_obstime(s: &str) -> Time<UTC> {
let ndt = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
.expect("parse obstime");
let dt = DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc);
Time::<UTC>::from_chrono(dt)
}
fn paranal() -> Geodetic<ECEF> {
observatories::EL_PARANAL.geodetic()
}
fn sgr_a_star() -> Target {
Target::new(266.41683 * DEG, -29.00781 * DEG)
}
fn invalid_target() -> Target {
Target::new(f64::NAN * DEG, 0.0 * DEG)
}
fn default_components() -> ComponentMask {
ComponentMask::ZODIACAL | ComponentMask::AIRGLOW
}
fn fixture_starlight_map() -> StarlightMap {
StarlightMap::from_csv_str(
include_str!("data/starlight_fixture_map.csv"),
starlight_test_provenance(),
)
.expect("starlight fixture")
}
fn fixture_starlight_map_with_uncertainty() -> StarlightMap {
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 {
raw.push_str(&format!("{index},4.0,0.4,0.8,1.0\n"));
}
StarlightMap::from_csv_str(&raw, starlight_test_provenance()).expect("uncertainty fixture")
}
#[test]
fn default_evaluator_config_matches_generic_clear_sky() {
let default = NsbModelConfig::default();
let explicit = NsbModelConfig::generic_clear_sky();
assert_eq!(default.moonlight_model(), explicit.moonlight_model());
assert_eq!(default.moonlight_model(), MoonlightModel::Jones2013Spectral);
assert_eq!(default.site_profile_name(), "generic-clear-sky");
assert_eq!(explicit.site_profile_name(), "generic-clear-sky");
assert_eq!(
default.starlight_product().is_some(),
StarlightProduct::bundled_production_available()
);
let evaluator = NsbEvaluator::new().expect("evaluator");
let config = evaluator.config();
assert_eq!(config.moonlight_model(), default.moonlight_model());
assert_eq!(config.site_profile_name(), "generic-clear-sky");
assert_eq!(
config.starlight_product().is_some(),
StarlightProduct::bundled_production_available()
);
}
#[test]
fn caller_supplied_ctao_planning_profiles_resolve_with_planning_preset_maturity() {
let north = NsbModelConfig::generic_clear_sky().with_site_profile(ctao_north_planning());
let south = NsbModelConfig::generic_clear_sky().with_site_profile(ctao_south_planning());
assert_eq!(north.site_profile_name(), "ctao-north-planning");
assert_eq!(south.site_profile_name(), "ctao-south-planning");
assert_eq!(
ctao_north_planning().calibration_status(),
CalibrationStatus::PlanningPreset
);
assert_eq!(
ctao_south_planning().calibration_status(),
CalibrationStatus::PlanningPreset
);
}
#[test]
fn all_components_are_the_production_safe_default() {
assert_eq!(
ComponentMask::ALL.contains(ComponentMask::STARLIGHT),
StarlightProduct::bundled_production_available()
);
let evaluator = NsbEvaluator::new().expect("evaluator");
let result = evaluator
.evaluate(
&PointQuery::new(
paranal(),
parse_obstime("2023-09-04 01:48:00"),
sgr_a_star(),
)
.with_components(ComponentMask::ALL),
)
.expect("generic clear-sky ALL evaluates");
assert!(result.integrated.value() > 0.0);
assert!(result
.components
.iter()
.any(|component| component.name == "zodiacal"));
let starlight = result
.components
.iter()
.find(|component| component.name == "starlight");
assert_eq!(
starlight.is_some(),
StarlightProduct::bundled_production_available()
);
if let Some(component) = starlight {
assert!(component.integrated.value() > 0.0);
}
assert!(result
.components
.iter()
.any(|component| component.name == "airglow"));
assert!(result
.components
.iter()
.any(|component| component.name == "moon"));
}
#[test]
fn point_query_uses_direct_geodetic_observer() {
let evaluator = NsbEvaluator::new().expect("evaluator");
let result = evaluator
.evaluate(
&PointQuery::new(
paranal(),
parse_obstime("2023-09-04 01:48:00"),
sgr_a_star(),
)
.with_components(default_components()),
)
.expect("point query");
assert!(result.integrated.value() > 0.0);
assert!(!result.components.is_empty());
}
#[test]
fn point_query_propagates_selected_component_error() {
let evaluator = NsbEvaluator::new().expect("evaluator");
let result = evaluator.evaluate(
&PointQuery::new(
paranal(),
parse_obstime("2023-09-04 01:48:00"),
invalid_target(),
)
.with_components(ComponentMask::ZODIACAL),
);
assert!(
result.is_err(),
"invalid selected component input must fail point evaluation"
);
}
#[test]
fn starlight_product_identity_is_data_product_identity() {
assert_eq!(
StarlightProduct::bundled_production_gaia_dr3().as_str(),
"bundled-production-gaia-dr3"
);
assert_eq!(
StarlightProduct::with_experimental_map(fixture_starlight_map()).as_str(),
"experimental-map"
);
}
#[cfg(not(nsb_bundled_production_starlight))]
#[test]
fn starlight_request_without_product_fails_explicitly() {
let evaluator = NsbEvaluator::new().expect("evaluator");
let error = evaluator
.evaluate(
&PointQuery::new(
paranal(),
parse_obstime("2023-09-04 01:48:00"),
sgr_a_star(),
)
.with_components(ComponentMask::STARLIGHT),
)
.expect_err("unconfigured starlight must fail when requested");
assert!(error.to_string().contains("starlight component requested"));
}
#[test]
fn custom_starlight_map_evaluates_when_explicitly_configured() {
let config = NsbModelConfig::generic_clear_sky().with_starlight_product(
StarlightProduct::with_experimental_map(fixture_starlight_map()),
);
assert_eq!(
config
.starlight_product()
.expect("configured product")
.as_str(),
"experimental-map"
);
let evaluator = NsbEvaluator::with_config(config).expect("evaluator");
let result = evaluator
.evaluate(
&PointQuery::new(
paranal(),
parse_obstime("2023-09-04 01:48:00"),
sgr_a_star(),
)
.with_components(ComponentMask::STARLIGHT),
)
.expect("explicit starlight map");
assert_eq!(result.components.len(), 1);
assert_eq!(result.components[0].name, "starlight");
assert!(result.integrated.value() > 0.0);
}
#[test]
fn starlight_uncertainties_reach_nsb_component() {
let config = NsbModelConfig::generic_clear_sky().with_starlight_product(
StarlightProduct::with_experimental_map(fixture_starlight_map_with_uncertainty()),
);
let evaluator = NsbEvaluator::with_config(config).expect("evaluator");
let result = evaluator
.evaluate(
&PointQuery::new(
paranal(),
parse_obstime("2023-09-04 01:48:00"),
sgr_a_star(),
)
.with_components(ComponentMask::STARLIGHT),
)
.expect("starlight uncertainty evaluation");
let component = &result.components[0];
assert_eq!(component.statistical_uncertainty.unwrap().value(), 0.4);
assert_eq!(component.systematic_uncertainty.unwrap().value(), 0.8);
assert_eq!(component.total_uncertainty.unwrap().value(), 1.0);
assert_eq!(component.relative_uncertainty, Some(0.25));
}
#[test]
fn threshold_query_returns_full_window_for_large_threshold() {
let evaluator = NsbEvaluator::new().expect("evaluator");
let start = parse_obstime("2023-09-04 01:00:00");
let end = parse_obstime("2023-09-04 02:00:00");
let result = evaluator
.periods_below_threshold(
&ThresholdQuery::new(
paranal(),
sgr_a_star(),
Period::new(start, end),
BandPhotonRadiance::new(1.0e6),
)
.with_components(default_components())
.with_sun_altitude_ceiling(None)
.with_target_altitude_floor(None),
)
.expect("threshold query");
assert_eq!(result.periods.len(), 1);
assert_eq!(result.periods[0].start, start);
assert_eq!(result.periods[0].end, end);
}
#[test]
fn threshold_query_returns_no_periods_when_threshold_is_unreachable() {
let evaluator = NsbEvaluator::new().expect("evaluator");
let start = parse_obstime("2023-09-04 01:00:00");
let end = parse_obstime("2023-09-04 02:00:00");
let result = evaluator
.periods_below_threshold(
&ThresholdQuery::new(
paranal(),
sgr_a_star(),
Period::new(start, end),
BandPhotonRadiance::new(0.0),
)
.with_components(default_components())
.with_sun_altitude_ceiling(None)
.with_target_altitude_floor(None),
)
.expect("threshold query");
assert!(result.periods.is_empty());
}
#[test]
fn threshold_query_empty_window_returns_no_periods() {
let evaluator = NsbEvaluator::new().expect("evaluator");
let start = parse_obstime("2023-09-04 01:00:00");
let result = evaluator
.periods_below_threshold(
&ThresholdQuery::new(
paranal(),
sgr_a_star(),
Period::new(start, start),
BandPhotonRadiance::new(1.0e6),
)
.with_components(default_components())
.with_sun_altitude_ceiling(None)
.with_target_altitude_floor(None),
)
.expect("empty window");
assert!(result.periods.is_empty());
}
#[test]
fn threshold_query_rejects_non_finite_threshold_and_ignores_legacy_step() {
let evaluator = NsbEvaluator::new().expect("evaluator");
let start = parse_obstime("2023-09-04 01:00:00");
let end = parse_obstime("2023-09-04 02:00:00");
let nan = evaluator
.periods_below_threshold(
&ThresholdQuery::new(
paranal(),
sgr_a_star(),
Period::new(start, end),
BandPhotonRadiance::new(f64::NAN),
)
.with_components(default_components())
.with_sun_altitude_ceiling(None)
.with_target_altitude_floor(None),
)
.expect_err("NaN threshold");
assert!(nan.to_string().contains("threshold must be finite"));
let infinity = evaluator
.periods_below_threshold(
&ThresholdQuery::new(
paranal(),
sgr_a_star(),
Period::new(start, end),
BandPhotonRadiance::new(f64::INFINITY),
)
.with_components(default_components())
.with_sun_altitude_ceiling(None)
.with_target_altitude_floor(None),
)
.expect_err("infinite threshold");
assert!(infinity.to_string().contains("threshold must be finite"));
let query = ThresholdQuery::new(
paranal(),
sgr_a_star(),
Period::new(start, end),
BandPhotonRadiance::new(0.2),
)
.with_components(default_components())
.with_sun_altitude_ceiling(None)
.with_target_altitude_floor(None);
let expected = evaluator.periods_below_threshold_adaptive(&query).unwrap();
let actual = evaluator.periods_below_threshold(&query).unwrap();
assert_eq!(actual.periods, expected.periods);
}
#[test]
fn threshold_query_rejects_inverted_window() {
let evaluator = NsbEvaluator::new().expect("evaluator");
let start = parse_obstime("2023-09-04 02:00:00");
let end = parse_obstime("2023-09-04 01:00:00");
let err = evaluator
.periods_below_threshold(
&ThresholdQuery::new(
paranal(),
sgr_a_star(),
Period::new(start, end),
BandPhotonRadiance::new(1.0e6),
)
.with_components(default_components())
.with_sun_altitude_ceiling(None)
.with_target_altitude_floor(None),
)
.expect_err("inverted window");
assert!(err.to_string().contains("start must not be after end"));
}
#[test]
fn threshold_query_new_applies_production_safe_defaults() {
let start = parse_obstime("2023-09-04 01:00:00");
let end = parse_obstime("2023-09-04 02:00:00");
let query = ThresholdQuery::new(
paranal(),
sgr_a_star(),
Period::new(start, end),
BandPhotonRadiance::new(0.2),
);
assert_eq!(query.components, ComponentMask::ALL);
assert_eq!(
query.sun_altitude_ceiling,
Some(ThresholdQuery::DEFAULT_SUN_ALTITUDE_CEILING)
);
assert_eq!(
query.target_altitude_floor,
Some(ThresholdQuery::DEFAULT_TARGET_ALTITUDE_FLOOR)
);
}
#[test]
fn point_query_evaluates_individual_supported_components() {
let evaluator = NsbEvaluator::new().expect("evaluator");
let time = parse_obstime("2023-09-04 01:48:00");
for (mask, expected_name) in [
(ComponentMask::ZODIACAL, "zodiacal"),
(ComponentMask::AIRGLOW, "airglow"),
(ComponentMask::MOON, "moon"),
] {
let result = evaluator
.evaluate(&PointQuery::new(paranal(), time, sgr_a_star()).with_components(mask))
.expect("component evaluates");
assert_eq!(result.components.len(), 1);
assert_eq!(result.components[0].name, expected_name);
assert!(result.integrated.value().is_finite());
}
}
#[test]
fn explicit_f107_configuration_changes_airglow_relative_to_automatic() {
let time = parse_obstime("2023-09-04 01:48:00");
let query =
PointQuery::new(paranal(), time, sgr_a_star()).with_components(ComponentMask::AIRGLOW);
let automatic = NsbEvaluator::new()
.expect("evaluator")
.evaluate(&query)
.expect("automatic F10.7");
let explicit = NsbEvaluator::with_config(
NsbModelConfig::generic_clear_sky().with_solar_radio_flux(nsb::SolarFluxUnits::new(250.0)),
)
.expect("explicit evaluator")
.evaluate(&query)
.expect("explicit F10.7");
assert_ne!(automatic.integrated.value(), explicit.integrated.value());
assert!(automatic.integrated.value().is_finite());
assert!(explicit.integrated.value().is_finite());
}
#[test]
fn threshold_query_fails_closed_on_selected_component_error() {
let evaluator = NsbEvaluator::new().expect("evaluator");
let start = parse_obstime("2023-09-04 01:00:00");
let end = parse_obstime("2023-09-04 02:00:00");
let result = evaluator.periods_below_threshold(
&ThresholdQuery::new(
paranal(),
invalid_target(),
Period::new(start, end),
BandPhotonRadiance::new(1.0e6),
)
.with_components(ComponentMask::ZODIACAL)
.with_sun_altitude_ceiling(None)
.with_target_altitude_floor(None),
);
assert!(
result.is_err(),
"threshold search must not treat a failed component as zero"
);
}
#[test]
fn with_f107_store_resolves_airglow_through_evaluator() {
let time = parse_obstime("2023-09-04 01:48:00");
let store = Arc::new(bundled_f107_store().expect("bundled store").clone());
let config = NsbModelConfig::generic_clear_sky().with_f107_store(store.clone());
assert!(matches!(
config.solar_activity(),
SolarActivitySource::Dataset(_)
));
let result = NsbEvaluator::with_config(config)
.expect("evaluator")
.evaluate(
&PointQuery::new(paranal(), time, sgr_a_star()).with_components(ComponentMask::AIRGLOW),
)
.expect("dataset airglow");
let solar = result.components[0]
.metadata
.solar_activity
.as_ref()
.expect("solar metadata");
assert_eq!(solar.dataset_id.as_deref(), Some(store.dataset_id.as_str()));
assert!(solar.value.value().is_finite() && solar.value.value() > 0.0);
}
#[test]
fn ks91_moonlight_model_evaluates_and_labels_published_reference() {
let time = parse_obstime("2023-09-04 01:48:00");
let config = NsbModelConfig::generic_clear_sky()
.with_moonlight_model(MoonlightModel::KrisciunasSchaefer1991);
assert_eq!(
config.moonlight_model().as_str(),
"krisciunas-schaefer-1991"
);
let evaluator = NsbEvaluator::with_config(config).expect("evaluator");
let result = evaluator
.evaluate(
&PointQuery::new(paranal(), time, sgr_a_star()).with_components(ComponentMask::MOON),
)
.expect("KS91 moonlight");
assert_eq!(result.components.len(), 1);
assert_eq!(result.components[0].name, "moon");
assert!(result.integrated.value().is_finite());
assert_eq!(
result.components[0].metadata.status,
ComponentCalibrationStatus::PublishedReference
);
assert!(result.components[0]
.metadata
.provenance
.contains("Krisciunas & Schaefer 1991"));
let descriptions = evaluator
.describe_components(paranal(), ComponentMask::MOON | ComponentMask::ZODIACAL)
.expect("describe");
let names: Vec<_> = descriptions.iter().map(|d| d.name).collect();
assert_eq!(names, ["zodiacal", "moon"]);
assert!(descriptions
.iter()
.find(|d| d.name == "moon")
.unwrap()
.metadata
.provenance
.contains("Krisciunas & Schaefer 1991"));
}