use std::sync::OnceLock;
use crate::ext_qtty::length::Nanometer;
use crate::provenance::Provenance;
use crate::spectra::interp::{Interpolation, OutOfRange};
use crate::spectra::loaders::ascii;
use crate::spectra::sampled::SampledSpectrum;
use super::Throughput;
const RAW_U: &str = include_str!("../../archive/data/passbands/bessell1990/U.dat");
const RAW_B: &str = include_str!("../../archive/data/passbands/bessell1990/B.dat");
const RAW_V: &str = include_str!("../../archive/data/passbands/bessell1990/V.dat");
const RAW_R: &str = include_str!("../../archive/data/passbands/bessell1990/R.dat");
const RAW_I: &str = include_str!("../../archive/data/passbands/bessell1990/I.dat");
crate::assert_data_checksum!(
"siderust/data/passbands/bessell1990/U.dat",
RAW_U.as_bytes(),
"e00771381f3ecd293bcf8c20fdbe57ce5cb9c24404b6ad7f499f28912525ea58"
);
crate::assert_data_checksum!(
"siderust/data/passbands/bessell1990/B.dat",
RAW_B.as_bytes(),
"ba2b36196cc285482659df521fc5ffde60e8c6849963912cfcbe61f912f704b0"
);
crate::assert_data_checksum!(
"siderust/data/passbands/bessell1990/V.dat",
RAW_V.as_bytes(),
"fd5cc0ac9bba2e8121efe8b66d1aeb63b0744b0997d672053bdccf585248e24c"
);
crate::assert_data_checksum!(
"siderust/data/passbands/bessell1990/R.dat",
RAW_R.as_bytes(),
"25393e6cd0c2b3c2c7a2a0688ce675e6475d550150b44f580751e13731ac1bc0"
);
crate::assert_data_checksum!(
"siderust/data/passbands/bessell1990/I.dat",
RAW_I.as_bytes(),
"f3eba62d9898b1b69b6c56cbb7806875f528410145594e56d61cff4f8c5c44e0"
);
static U_TABLE: OnceLock<SampledSpectrum<Nanometer, Throughput>> = OnceLock::new();
static B_TABLE: OnceLock<SampledSpectrum<Nanometer, Throughput>> = OnceLock::new();
static V_TABLE: OnceLock<SampledSpectrum<Nanometer, Throughput>> = OnceLock::new();
static R_TABLE: OnceLock<SampledSpectrum<Nanometer, Throughput>> = OnceLock::new();
static I_TABLE: OnceLock<SampledSpectrum<Nanometer, Throughput>> = OnceLock::new();
fn provenance(filter_id: &str, dat_path: &str) -> Provenance {
Provenance::bundled_file(dat_path).with_notes(format!(
"Bessell 1990, PASP 102, 1181 — filter {filter_id}. \
Retrieved from SVO Filter Profile Service \
<http://svo2.cab.inta-csic.es/theory/fps/>. \
Wavelengths converted Å→nm (÷10)."
))
}
fn load(raw: &str, filter_id: &str, dat_path: &str) -> SampledSpectrum<Nanometer, Throughput> {
ascii::two_column::<Nanometer, Throughput>(
raw,
1.0, 1.0,
Interpolation::Linear,
OutOfRange::ClampToEndpoints,
Some(provenance(filter_id, dat_path)),
)
.unwrap_or_else(|e| panic!("Bessell 1990 {filter_id} passband failed to parse: {e}"))
}
pub fn u() -> &'static SampledSpectrum<Nanometer, Throughput> {
U_TABLE.get_or_init(|| {
load(
RAW_U,
"Generic/Bessell.U",
"siderust/data/passbands/bessell1990/U.dat",
)
})
}
pub fn b() -> &'static SampledSpectrum<Nanometer, Throughput> {
B_TABLE.get_or_init(|| {
load(
RAW_B,
"Generic/Bessell.B",
"siderust/data/passbands/bessell1990/B.dat",
)
})
}
pub fn v() -> &'static SampledSpectrum<Nanometer, Throughput> {
V_TABLE.get_or_init(|| {
load(
RAW_V,
"Generic/Bessell.V",
"siderust/data/passbands/bessell1990/V.dat",
)
})
}
pub fn r() -> &'static SampledSpectrum<Nanometer, Throughput> {
R_TABLE.get_or_init(|| {
load(
RAW_R,
"Generic/Bessell.R",
"siderust/data/passbands/bessell1990/R.dat",
)
})
}
pub fn i() -> &'static SampledSpectrum<Nanometer, Throughput> {
I_TABLE.get_or_init(|| {
load(
RAW_I,
"Generic/Bessell.I",
"siderust/data/passbands/bessell1990/I.dat",
)
})
}
#[cfg(test)]
fn effective_wavelength(s: &SampledSpectrum<Nanometer, Throughput>) -> f64 {
let xs = s.xs_raw();
let ys = s.ys_raw();
let num: f64 = xs
.windows(2)
.zip(ys.windows(2))
.map(|(xw, yw)| 0.5 * (xw[0] * yw[0] + xw[1] * yw[1]) * (xw[1] - xw[0]))
.sum();
let den: f64 = xs
.windows(2)
.zip(ys.windows(2))
.map(|(xw, yw)| 0.5 * (yw[0] + yw[1]) * (xw[1] - xw[0]))
.sum();
num / den
}
#[cfg(test)]
fn fwhm(s: &SampledSpectrum<Nanometer, Throughput>) -> f64 {
let xs = s.xs_raw();
let ys = s.ys_raw();
let half_max = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max) / 2.0;
let lo = xs
.windows(2)
.zip(ys.windows(2))
.find(|(_, yw)| yw[0] < half_max && yw[1] >= half_max)
.map(|(xw, yw)| {
let t = (half_max - yw[0]) / (yw[1] - yw[0]);
xw[0] + t * (xw[1] - xw[0])
})
.unwrap_or(xs[0]);
let hi = xs
.windows(2)
.zip(ys.windows(2))
.rev()
.find(|(_, yw)| yw[0] >= half_max && yw[1] < half_max)
.map(|(xw, yw)| {
let t = (yw[0] - half_max) / (yw[0] - yw[1]);
xw[0] + t * (xw[1] - xw[0])
})
.unwrap_or(*xs.last().unwrap());
hi - lo
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pinned_sha256_matches_runtime_hash() {
use crate::provenance::checksum::{sha256, to_hex};
let cases: &[(&str, &str)] = &[
(
RAW_U,
"e00771381f3ecd293bcf8c20fdbe57ce5cb9c24404b6ad7f499f28912525ea58",
),
(
RAW_B,
"ba2b36196cc285482659df521fc5ffde60e8c6849963912cfcbe61f912f704b0",
),
(
RAW_V,
"fd5cc0ac9bba2e8121efe8b66d1aeb63b0744b0997d672053bdccf585248e24c",
),
(
RAW_R,
"25393e6cd0c2b3c2c7a2a0688ce675e6475d550150b44f580751e13731ac1bc0",
),
(
RAW_I,
"f3eba62d9898b1b69b6c56cbb7806875f528410145594e56d61cff4f8c5c44e0",
),
];
for (raw, want) in cases {
assert_eq!(to_hex(&sha256(raw.as_bytes())), *want);
}
}
struct BandSpec {
name: &'static str,
band: fn() -> &'static SampledSpectrum<Nanometer, Throughput>,
lambda_eff_expected: f64,
lambda_eff_tol: f64,
fwhm_expected: Option<f64>,
fwhm_tol: f64,
}
fn bands() -> Vec<BandSpec> {
vec![
BandSpec {
name: "U",
band: u,
lambda_eff_expected: 360.5,
lambda_eff_tol: 2.0,
fwhm_expected: None,
fwhm_tol: 10.0,
},
BandSpec {
name: "B",
band: b,
lambda_eff_expected: 441.3,
lambda_eff_tol: 2.0,
fwhm_expected: Some(98.0),
fwhm_tol: 15.0,
},
BandSpec {
name: "V",
band: v,
lambda_eff_expected: 551.2,
lambda_eff_tol: 2.0,
fwhm_expected: Some(88.0),
fwhm_tol: 15.0,
},
BandSpec {
name: "R",
band: r,
lambda_eff_expected: 658.6,
lambda_eff_tol: 2.0,
fwhm_expected: None, fwhm_tol: 10.0,
},
BandSpec {
name: "I",
band: i,
lambda_eff_expected: 806.0,
lambda_eff_tol: 2.0,
fwhm_expected: None,
fwhm_tol: 10.0,
},
]
}
#[test]
fn all_bands_nonempty() {
for spec in bands() {
let s = (spec.band)();
assert!(!s.is_empty(), "{}: spectrum must not be empty", spec.name);
assert!(s.len() >= 2, "{}: must have at least 2 samples", spec.name);
}
}
#[test]
fn all_bands_monotonic_wavelengths() {
for spec in bands() {
let s = (spec.band)();
let xs = s.xs_raw();
for w in xs.windows(2) {
assert!(
w[1] > w[0],
"{}: wavelengths not strictly increasing: {} <= {}",
spec.name,
w[1],
w[0]
);
}
}
}
#[test]
fn all_bands_transmittances_in_unit_interval() {
for spec in bands() {
let s = (spec.band)();
for (i, y) in s.ys_raw().iter().enumerate() {
assert!(
*y >= 0.0 && *y <= 1.0,
"{}: transmittance[{i}] = {y} is outside [0, 1]",
spec.name
);
}
}
}
#[test]
fn flat_spectrum_centroid_matches_svo_wavelength_mean() {
for spec in bands() {
let s = (spec.band)();
let lam = effective_wavelength(s);
assert!(
(lam - spec.lambda_eff_expected).abs() <= spec.lambda_eff_tol,
"{}: λ_eff = {lam:.2} nm, expected {:.1} ± {:.1} nm",
spec.name,
spec.lambda_eff_expected,
spec.lambda_eff_tol
);
}
}
#[test]
fn fwhm_sanity_check_b_and_v() {
for spec in bands() {
if let Some(expected_fwhm) = spec.fwhm_expected {
let s = (spec.band)();
let w = fwhm(s);
assert!(
(w - expected_fwhm).abs() <= spec.fwhm_tol,
"{}: FWHM = {w:.2} nm, expected {expected_fwhm:.1} ± {:.1} nm",
spec.name,
spec.fwhm_tol
);
}
}
}
#[test]
fn repeated_calls_return_same_pointer() {
let a = v() as *const _;
let b = v() as *const _;
assert_eq!(
a, b,
"OnceLock must return the same instance on repeated calls"
);
}
#[test]
fn v_band_flat_spectrum_integral_is_positive() {
let vb = v();
let xs = vb.xs_raw();
let ys = vb.ys_raw();
let integral: f64 = xs
.windows(2)
.zip(ys.windows(2))
.map(|(xw, yw)| 0.5 * (yw[0] + yw[1]) * (xw[1] - xw[0]))
.sum();
assert!(
integral > 0.0,
"∫ T_V dλ should be positive, got {integral}"
);
assert!(integral > 10.0, "∫ T_V dλ = {integral} nm seems too small");
}
}