// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Vallés Puig, Ramon
use super::Observer;
use crate::components::airglow::calibration::{
airglow_continuum_asset, AIRGLOW_CONTINUUM_ASSET_PATH,
};
use crate::components::airglow::{
AirglowEvaluationOutcome, AirglowFallbackReason, AirglowPhysicalOutcome,
AirglowPhysicalZeroReason, AirglowSelectionKind, AirglowSelectionMetadata,
ResolvedAirglowSelection, NOLL_AIRGLOW_SCATTERING_FIT_MAX_ZENITH_DEG,
};
use crate::components::moonlight::MoonlightModel;
use crate::components::starlight::{StarlightProduct, StarlightProvenance};
use crate::components::zodiacal::{ZodiacalExtinction, ZodiacalModel};
use crate::site::{CalibrationStatus, SiteProfileConfig};
use crate::units::photometry::SurfaceBrightness;
use crate::NSB_S10_ZP;
use siderust::qtty::Nanometers;
use std::borrow::Cow;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Scientific calibration/maturity classification.
///
/// Additional maturity labels may be added; match with a wildcard.
#[non_exhaustive]
pub enum ComponentCalibrationStatus {
/// Validated for its stated release domain.
Production,
/// Generic clear-sky assumptions, not site-calibrated.
GenericClearSky,
/// Named planning assumptions without dedicated calibration.
PlanningPreset,
/// Approximate conversion or engineering diagnostic.
Proxy,
/// Supported published comparison model.
PublishedReference,
/// Capability without a production validation contract.
Experimental,
}
impl ComponentCalibrationStatus {
/// Stable lowercase status identifier.
pub const fn as_str(self) -> &'static str {
match self {
Self::Production => "production",
Self::GenericClearSky => "generic-clear-sky",
Self::PlanningPreset => "planning-preset",
Self::Proxy => "proxy",
Self::PublishedReference => "published-reference",
Self::Experimental => "experimental",
}
}
}
impl From<CalibrationStatus> for ComponentCalibrationStatus {
fn from(status: CalibrationStatus) -> Self {
match status {
CalibrationStatus::GenericFallback => Self::GenericClearSky,
CalibrationStatus::PlanningPreset => Self::PlanningPreset,
CalibrationStatus::Calibrated => Self::Production,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
/// Meaning and zero point of reported B/V diagnostics.
#[non_exhaustive]
pub struct BandDiagnostic {
/// Stable diagnostic convention identifier.
pub convention: &'static str,
/// B diagnostic reference wavelength in nm.
pub b_reference: Nanometers,
/// V diagnostic reference wavelength in nm.
pub v_reference: Nanometers,
/// S10-to-surface-brightness zero point.
pub zero_point: SurfaceBrightness,
}
impl BandDiagnostic {
/// Central-wavelength S10 proxy used by current B/V fields.
pub const MONOCHROMATIC_S10_PROXY: Self = Self {
convention: "monochromatic-central-wavelength-s10-proxy",
b_reference: Nanometers::new(445.0),
v_reference: Nanometers::new(551.0),
zero_point: NSB_S10_ZP,
};
}
#[derive(Debug, Clone, PartialEq)]
/// Scientific interpretation attached to a component result.
#[non_exhaustive]
pub struct NsbComponentMetadata {
/// Calibration/maturity classification.
pub status: ComponentCalibrationStatus,
/// Source model and data provenance.
pub provenance: Cow<'static, str>,
/// Domain for which validation evidence exists.
pub validated_domain: Cow<'static, str>,
/// Meaning of B/V fields.
pub band_diagnostic: BandDiagnostic,
/// Airglow configuration/selection metadata when Airglow is selected.
///
/// Present for both descriptors and evaluated results. Does not invent a
/// physical evaluation outcome; see [`Self::airglow_evaluation`].
/// Resolved model identity lives here (`resolved_model`) as the canonical
/// source of truth — there is no separate `airglow_model` field.
pub airglow_selection: Option<AirglowSelectionMetadata>,
/// Airglow physical evaluation outcome when a time-dependent query ran.
///
/// Absent from [`crate::NsbEvaluator::describe_components`] descriptors.
pub airglow_evaluation: Option<AirglowEvaluationOutcome>,
/// Optional resolved F10.7 provenance for airglow evaluations.
pub solar_activity: Option<crate::solar_activity::ResolvedSolarActivity>,
/// Optional emitting-volume geometry provenance for Airglow evaluations.
pub airglow_geometry: Option<crate::components::airglow::AirglowGeometryMetadata>,
}
pub(super) fn component_status_for_site_profile(
site_profile: &SiteProfileConfig,
) -> ComponentCalibrationStatus {
site_profile.calibration_status().into()
}
pub(super) fn zodiacal_metadata(
model: ZodiacalModel,
extinction: ZodiacalExtinction,
) -> NsbComponentMetadata {
let (propagation, validated_domain) = match model {
ZodiacalModel::Leinert1998 => match extinction {
ZodiacalExtinction::None => (
"atmospheric propagation none",
"ground-observer Leinert table geometry with horizon gating; no atmospheric attenuation applied",
),
ZodiacalExtinction::Noll2012Approx => (
"atmospheric propagation noll-2012-approximation (Noll+2012 approximate extinction)",
"ground-observer Leinert table geometry with horizon gating plus generic Noll-style clear-sky attenuation",
),
},
};
NsbComponentMetadata {
status: ComponentCalibrationStatus::GenericClearSky,
provenance: Cow::Owned(format!(
"scientific model {}; Leinert+1998 zodiacal S10 table; bundled solar spectrum; {propagation}",
model.as_str()
)),
validated_domain: validated_domain.into(),
band_diagnostic: BandDiagnostic::MONOCHROMATIC_S10_PROXY,
airglow_selection: None,
airglow_evaluation: None,
solar_activity: None,
airglow_geometry: None,
}
}
/// Selection-only Airglow metadata for pre-evaluation descriptors.
pub(super) fn airglow_selection_metadata(
resolved: ResolvedAirglowSelection,
site_profile: &SiteProfileConfig,
observer: Observer,
geometry: &crate::components::airglow::AirglowGeometryModel,
) -> NsbComponentMetadata {
airglow_metadata_inner(resolved, site_profile, observer, None, geometry, None)
}
pub(super) fn airglow_metadata(
resolved: ResolvedAirglowSelection,
site_profile: &SiteProfileConfig,
observer: Observer,
solar: Option<&crate::solar_activity::ResolvedSolarActivity>,
geometry: &crate::components::airglow::AirglowGeometryModel,
physical_outcome: AirglowPhysicalOutcome,
physical_zero_reason: Option<AirglowPhysicalZeroReason>,
) -> NsbComponentMetadata {
airglow_metadata_inner(
resolved,
site_profile,
observer,
solar,
geometry,
Some(ResolvedAirglowSelection::evaluation_outcome(
physical_outcome,
physical_zero_reason,
)),
)
}
fn airglow_metadata_inner(
resolved: ResolvedAirglowSelection,
site_profile: &SiteProfileConfig,
observer: Observer,
solar: Option<&crate::solar_activity::ResolvedSolarActivity>,
geometry: &crate::components::airglow::AirglowGeometryModel,
evaluation: Option<AirglowEvaluationOutcome>,
) -> NsbComponentMetadata {
let model = resolved.model;
let selection = resolved.selection_metadata();
let profile = site_profile.resolve(observer);
let asset = airglow_continuum_asset();
let baseline_identity = format!(
"baseline asset {} schema {} sha256 {}; calibration_status {}; generator {}; validation_report {}; source {}; license {}; baseline_source PALACE v1.0 Paranal continuum; site_calibrated false",
AIRGLOW_CONTINUUM_ASSET_PATH,
asset.schema,
asset.sha256,
asset.calibration_status,
asset.generator,
asset.validation_report,
asset.source,
asset.license
);
let f107_fragment = match solar {
Some(resolved) => resolved.provenance_fragment(),
None => "F10.7 resolved per evaluation UTC date via SolarActivitySource (Automatic/Dataset/Explicit); solar-activity provenance only, not a site calibration".to_string(),
};
let selection_fragment = match selection.selection_kind {
AirglowSelectionKind::Automatic => format!(
"selection automatic; resolved model {}; used_automatic_fallback {}; fallback_reason {}",
selection
.resolved_model
.map(|model| model.as_str())
.unwrap_or("unresolved"),
selection.used_automatic_fallback,
selection
.fallback_reason
.map(AirglowFallbackReason::as_str)
.unwrap_or("none")
),
AirglowSelectionKind::Explicit => format!(
"selection explicit; requested model {}; resolved model {}; used_automatic_fallback false",
selection
.requested_model
.map(|model| model.as_str())
.unwrap_or("none"),
selection
.resolved_model
.map(|model| model.as_str())
.unwrap_or("unresolved")
),
};
let physical_fragment = match &evaluation {
None => "physical_outcome not-evaluated (descriptor)".to_string(),
Some(AirglowEvaluationOutcome {
physical_outcome: AirglowPhysicalOutcome::Evaluated,
..
}) => "physical_outcome evaluated".to_string(),
Some(AirglowEvaluationOutcome {
physical_outcome: AirglowPhysicalOutcome::PhysicalZero,
physical_zero_reason,
}) => format!(
"physical_outcome physical-zero; reason {}",
physical_zero_reason
.map(AirglowPhysicalZeroReason::as_str)
.unwrap_or("unspecified")
),
};
NsbComponentMetadata {
status: profile.calibration_status.into(),
provenance: Cow::Owned(format!(
"scientific model {}; {}; {}; {}; scientific site profile {}; template {}; {}; {}; observer coordinates provide geometry only and do not select or promote this profile",
model.as_str(),
selection_fragment,
physical_fragment,
profile.airglow.provenance,
profile.name,
AIRGLOW_CONTINUUM_ASSET_PATH,
baseline_identity,
f107_fragment
)),
validated_domain: Cow::Owned(format!(
"PALACE v1.0 Paranal-derived unresolved-continuum planning model (HO2, FeO-like, and unresolved O2 templates; PALACE emission lines excluded), used as the temporary automatic fallback and not intrinsically the generic global scientific contract; astronomical-night domain; integrated 300–650 nm; preserves 12 calendar months and 12 one-hour Paranal-fitted local-mean-solar-time bins, mapped at arbitrary observer locations by equivalent observer local mean solar month/hour as an explicit spatial-transfer extrapolation; preserves bin/component-specific linear F10.7 response about 100 sfu and PALACE residual variability; PALACE evidence covers centred 27-day F10.7 averages of 67–166 sfu, while the current NSB resolver supplies its documented monthly planning quantity; selected emitting-volume LOS geometry ({}) uses the 88 km representative layer although the asset records component heights 81/88/94 km; independent Noll-2012 effective Rayleigh/Mie airglow scattering (fitted primarily for zenith distances z≲{}°) uses site-profile atmospheric assumptions ({}); PALACE molecular atmospheric absorption and its full propagation are not reproduced; multiplied by site-profile airglow.scale, which is not calibration; observer location and operational inputs do not upgrade maturity; {}",
geometry.model_id(),
NOLL_AIRGLOW_SCATTERING_FIT_MAX_ZENITH_DEG as i32,
profile.atmosphere_provenance,
profile.airglow.assumptions
)),
band_diagnostic: BandDiagnostic::MONOCHROMATIC_S10_PROXY,
airglow_selection: Some(selection),
airglow_evaluation: evaluation,
solar_activity: solar.cloned(),
airglow_geometry: Some(geometry.metadata()),
}
}
pub(super) fn starlight_metadata(
product: Option<&StarlightProduct>,
provenance: Option<&StarlightProvenance>,
) -> NsbComponentMetadata {
match product {
Some(StarlightProduct::BundledProductionGaiaDr3) => starlight_map_metadata(
provenance.expect("bundled production map is loaded during evaluator construction"),
"bundled Gaia DR3 XP production map",
ComponentCalibrationStatus::Production,
"bundled validated Gaia DR3 XP-derived HEALPix map with checksum/header consistency, flux-conservation evidence, plane/pole contrast, longitude wrap, and independent comparison",
),
Some(StarlightProduct::ExperimentalMap(_)) => starlight_map_metadata(
provenance.expect("custom map is loaded during evaluator construction"),
"caller-provided map",
ComponentCalibrationStatus::Experimental,
"caller-provided map without the production manifest contract",
),
Some(StarlightProduct::ValidatedExternalMap(_)) => starlight_map_metadata(
provenance.expect("validated external map is loaded during evaluator construction"),
"validated external map",
ComponentCalibrationStatus::Production,
"complete HEALPix coverage, finite/nonnegative values, checksum/header consistency, flux-conservation evidence, plane/pole contrast, longitude wrap, and independent comparison",
),
None => NsbComponentMetadata {
status: ComponentCalibrationStatus::Experimental,
provenance: "no starlight product configured".into(),
validated_domain: "not evaluable".into(),
band_diagnostic: BandDiagnostic::MONOCHROMATIC_S10_PROXY,
airglow_selection: None,
airglow_evaluation: None,
solar_activity: None,
airglow_geometry: None,
},
}
}
fn starlight_map_metadata(
provenance: &crate::components::starlight::StarlightProvenance,
source: &'static str,
status: ComponentCalibrationStatus,
validated_domain: &'static str,
) -> NsbComponentMetadata {
let checksum = provenance.checksum.as_deref().unwrap_or("not recorded");
let map_checksum = provenance.map_checksum.as_deref().unwrap_or("not recorded");
NsbComponentMetadata {
status,
provenance: Cow::Owned(format!(
"{}; version {}; generated {}; source {}; release {}; license {}; source selection {}; magnitude limit {}; band {}; resolution {}; photometry {}; smoothing {}; source checksum {}; map checksum {}; generator {}; command {}; validation report {}; independent comparison {}; calibration status {}; {}",
provenance.dataset_name.as_str(),
provenance.version.as_str(),
provenance.generation_date.as_str(),
provenance.source_catalogue.as_str(),
provenance.source_catalogue_release.as_deref().unwrap_or("not recorded"),
provenance.license.as_str(),
provenance.source_selection.as_deref().unwrap_or("not recorded"),
provenance.magnitude_limit.as_str(),
provenance.band_definition.as_str(),
provenance.map_resolution.as_str(),
provenance.photometry_model.as_deref().unwrap_or("not recorded"),
provenance.smoothing.as_deref().unwrap_or("not recorded"),
checksum,
map_checksum,
provenance.generated_by.as_deref().unwrap_or("not recorded"),
provenance.generation_command.as_deref().unwrap_or("not recorded"),
provenance.validation_report.as_deref().unwrap_or("not recorded"),
provenance.independent_comparison.as_deref().unwrap_or("not recorded"),
provenance.calibration_status.as_deref().unwrap_or("not recorded"),
source,
)),
validated_domain: Cow::Owned(format!(
"{}; dataset {}; version {}; source checksum {}; map checksum {}",
validated_domain,
provenance.dataset_name.as_str(),
provenance.version.as_str(),
checksum,
map_checksum,
)),
band_diagnostic: BandDiagnostic::MONOCHROMATIC_S10_PROXY,
airglow_selection: None,
airglow_evaluation: None,
solar_activity: None,
airglow_geometry: None,
}
}
pub(super) fn moonlight_metadata(
model: MoonlightModel,
site_profile: &SiteProfileConfig,
observer: Observer,
) -> NsbComponentMetadata {
match model {
MoonlightModel::Jones2013Spectral => {
let profile = site_profile.resolve(observer);
NsbComponentMetadata {
status: component_status_for_site_profile(site_profile),
provenance: Cow::Owned(format!(
"Jones+2013 wavelength-resolved lunar radiance with Siderust lunar geometry; site profile {}; atmosphere: {}",
profile.name, profile.atmosphere_provenance
)),
validated_domain: Cow::Owned(format!(
"{} planning model; site aerosol calibration and external SkyCalc/observational validation remain explicit release gates",
profile.name
)),
band_diagnostic: BandDiagnostic::MONOCHROMATIC_S10_PROXY,
airglow_selection: None,
airglow_evaluation: None,
solar_activity: None,
airglow_geometry: None,
}
}
MoonlightModel::KrisciunasSchaefer1991 => {
let profile = site_profile.resolve(observer);
NsbComponentMetadata {
status: ComponentCalibrationStatus::PublishedReference,
provenance: Cow::Owned(format!(
"Krisciunas & Schaefer 1991 analytic V-band moonlight model; fixed validated V-band extinction k=0.172 mag/airmass; selected site profile {} does not alter this reference parameterization",
profile.name
)),
validated_domain:
"published analytic V-band reference model with fixed k=0.172 mag/airmass; not the wavelength-resolved default"
.into(),
band_diagnostic: BandDiagnostic::MONOCHROMATIC_S10_PROXY,
airglow_selection: None,
airglow_evaluation: None,
solar_activity: None,
airglow_geometry: None,
}
}
}
}