use crate::coordinates::Equatorial;
pub mod features;
mod gaia;
pub mod hipparcos;
#[cfg(feature = "photometry")]
pub mod isophote_series;
pub mod minimal_catalog;
#[cfg(feature = "photometry")]
pub mod photometry;
#[cfg(feature = "photometry")]
pub mod radial_profile;
pub mod synthetic;
pub use features::{FeatureCatalog, FeatureType, SkyFeature};
pub use gaia::{GaiaCatalog, GaiaEntry};
pub use hipparcos::{HipparcosCatalog, HipparcosEntry};
#[cfg(feature = "photometry")]
pub use isophote_series::{IsophoteSample, IsophoteSeries};
pub use minimal_catalog::{MinimalCatalog, MinimalStar, ProgressUpdate};
#[cfg(feature = "photometry")]
pub use photometry::{Band, Photometry};
#[cfg(feature = "photometry")]
pub use radial_profile::RadialProfile;
pub use synthetic::{
create_fov_catalog, create_synthetic_catalog, MagnitudeDistribution, SpatialDistribution,
SyntheticCatalogConfig,
};
use rand::distr::{Distribution, Uniform};
use rand::rngs::StdRng;
use rand::SeedableRng;
use std::path::PathBuf;
pub trait StarPosition {
fn ra(&self) -> f64;
fn dec(&self) -> f64;
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SersicProfile {
pub theta_half_arcsec: f64,
pub n: f64,
pub axis_ratio: f64,
pub position_angle_deg: f64,
}
impl SersicProfile {
pub fn b_n(&self) -> f64 {
let n = self.n;
2.0 * n - 1.0 / 3.0
+ 4.0 / (405.0 * n)
+ 46.0 / (25_515.0 * n.powi(2))
+ 131.0 / (1_148_175.0 * n.powi(3))
- 2_194_697.0 / (30_690_717_750.0 * n.powi(4))
}
pub fn surface_brightness_at(&self, dx_arcsec: f64, dy_arcsec: f64) -> f64 {
let bn = self.b_n();
let a = self.theta_half_arcsec;
let b = a * self.axis_ratio;
let pa_rad = self.position_angle_deg.to_radians();
let (sin_pa, cos_pa) = pa_rad.sin_cos();
let x_maj = dx_arcsec * sin_pa + dy_arcsec * cos_pa;
let x_min = -dx_arcsec * cos_pa + dy_arcsec * sin_pa;
let z = ((x_maj / a).powi(2) + (x_min / b).powi(2)).sqrt();
(-bn * (z.powf(1.0 / self.n) - 1.0)).exp()
}
pub fn total_flux_per_ie(&self) -> f64 {
let n = self.n;
let bn = self.b_n();
2.0 * std::f64::consts::PI
* n
* bn.powf(-2.0 * n)
* gamma_lanczos(2.0 * n)
* self.axis_ratio
* self.theta_half_arcsec
* self.theta_half_arcsec
* bn.exp()
}
}
fn gamma_lanczos(x: f64) -> f64 {
const G: f64 = 7.0;
const COEF: [f64; 9] = [
0.999_999_999_999_810,
676.520_368_121_885_2,
-1_259.139_216_722_403,
771.323_428_777_653_1,
-176.615_029_162_140_6,
12.507_343_278_686_905,
-0.138_571_095_265_720_1,
9.984_369_578_019_572e-6,
1.505_632_735_149_312e-7,
];
if x < 0.5 {
std::f64::consts::PI / ((std::f64::consts::PI * x).sin() * gamma_lanczos(1.0 - x))
} else {
let x = x - 1.0;
let mut a = COEF[0];
for (i, c) in COEF.iter().enumerate().skip(1) {
a += c / (x + i as f64);
}
let t = x + G + 0.5;
(2.0 * std::f64::consts::PI).sqrt() * t.powf(x + 0.5) * (-t).exp() * a
}
}
pub trait ExtendedSource {
fn sersic_profile(&self) -> Option<SersicProfile> {
None
}
}
impl ExtendedSource for StarData {}
#[derive(Debug, Clone, Copy)]
pub struct StarData {
pub id: u64,
pub position: Equatorial,
pub magnitude: f64,
pub b_v: Option<f64>,
}
impl StarData {
pub fn new(id: u64, ra_deg: f64, dec_deg: f64, magnitude: f64, b_v: Option<f64>) -> Self {
Self {
id,
position: Equatorial::from_degrees(ra_deg, dec_deg),
magnitude,
b_v,
}
}
pub fn with_position(id: u64, position: Equatorial, magnitude: f64, b_v: Option<f64>) -> Self {
Self {
id,
position,
magnitude,
b_v,
}
}
pub fn ra_deg(&self) -> f64 {
self.position.ra_degrees()
}
pub fn dec_deg(&self) -> f64 {
self.position.dec_degrees()
}
}
impl StarPosition for StarData {
fn ra(&self) -> f64 {
self.ra_deg()
}
fn dec(&self) -> f64 {
self.dec_deg()
}
}
pub trait StarCatalog {
type Star;
fn get_star(&self, id: usize) -> Option<&Self::Star>;
fn stars(&self) -> impl Iterator<Item = &Self::Star>;
fn len(&self) -> usize;
fn is_empty(&self) -> bool {
self.len() == 0
}
fn filter<F>(&self, predicate: F) -> Vec<&Self::Star>
where
F: Fn(&Self::Star) -> bool;
fn star_data(&self) -> impl Iterator<Item = StarData> + '_;
fn filter_star_data<F>(&self, predicate: F) -> Vec<StarData>
where
F: Fn(&StarData) -> bool;
fn brighter_than(&self, magnitude: f64) -> Vec<StarData> {
self.filter_star_data(|star| star.magnitude <= magnitude)
}
fn stars_in_field(&self, ra_deg: f64, dec_deg: f64, fov_deg: f64) -> Vec<StarData> {
let center = Equatorial::from_degrees(ra_deg, dec_deg);
let radius_rad = (fov_deg / 2.0).to_radians();
let cos_radius = radius_rad.cos();
self.filter_star_data(|star| {
let cos_dist = star.position.dec.sin() * center.dec.sin()
+ star.position.dec.cos() * center.dec.cos() * (star.position.ra - center.ra).cos();
cos_dist > cos_radius
})
}
}
#[derive(Debug, Clone)]
pub enum CatalogSource {
Hipparcos,
Minimal(PathBuf),
Random { seed: u64, count: usize },
}
pub fn get_stars_in_window(
source: CatalogSource,
position: Equatorial,
fov_deg: f64,
) -> crate::Result<Vec<StarData>> {
let ra_deg = position.ra_degrees();
let dec_deg = position.dec_degrees();
match source {
CatalogSource::Random { seed, count } => {
println!("Using synthetic stars ({})", count);
println!("Seed: {}", seed);
Ok(generate_synthetic_stars(
count, ra_deg, dec_deg, fov_deg, seed,
))
}
CatalogSource::Minimal(path) => {
println!("Loading minimal catalog from: {}", path.display());
let catalog = MinimalCatalog::load(&path)?;
println!("Loaded catalog: {}", catalog.description());
println!("Total stars in catalog: {}", catalog.len());
let stars = catalog.stars_in_field(ra_deg, dec_deg, fov_deg);
println!("Found {} stars in field of view", stars.len());
Ok(stars)
}
CatalogSource::Hipparcos => {
let path = PathBuf::from("hip_main.dat");
if !path.exists() {
return Err(crate::StarfieldError::DataError(format!(
"Hipparcos catalog not found at: {}",
path.display()
)));
}
println!("Loading Hipparcos catalog from: {}", path.display());
let catalog = HipparcosCatalog::from_dat_file(&path, 8.0)?;
println!("Total stars in catalog: {}", catalog.len());
let stars = catalog.stars_in_field(ra_deg, dec_deg, fov_deg);
println!("Found {} stars in field of view", stars.len());
Ok(stars)
}
}
}
fn generate_synthetic_stars(
count: usize,
center_ra: f64,
center_dec: f64,
fov_deg: f64,
seed: u64,
) -> Vec<StarData> {
let mut rng = StdRng::seed_from_u64(seed);
let mut stars = Vec::with_capacity(count);
let half_fov = fov_deg / 2.0;
let ra_dist = Uniform::new(center_ra - half_fov, center_ra + half_fov).unwrap();
let dec_dist = Uniform::new(center_dec - half_fov, center_dec + half_fov).unwrap();
let min_mag = 3.0; let max_mag = 8.0;
let uniform = Uniform::new(0.0, 1.0).unwrap();
for id in 1..=count {
let ra = ra_dist.sample(&mut rng);
let dec = dec_dist.sample(&mut rng);
let u = uniform.sample(&mut rng);
let log_base: f64 = 2.5; let exp_range = log_base.powf(max_mag - min_mag) - 1.0;
let t: f64 = u * exp_range + 1.0;
let magnitude = min_mag + t.log(log_base).clamp(0.0, max_mag - min_mag);
let b_v = if uniform.sample(&mut rng) > 0.3 {
Some(uniform.sample(&mut rng) * 2.0 - 0.3) } else {
None
};
stars.push(StarData::new(id as u64, ra, dec, magnitude, b_v));
}
stars
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalogs::minimal_catalog::{MinimalCatalog, MinimalStar};
use approx::assert_abs_diff_eq;
#[test]
fn test_star_data() {
let stars = vec![
MinimalStar::new(1, 100.0, 10.0, -1.5), MinimalStar::new(2, 50.0, -20.0, 0.5), MinimalStar::new(3, 150.0, 30.0, 1.2), MinimalStar::new(4, 200.0, -45.0, 3.7), MinimalStar::new(5, 250.0, 60.0, 5.9), ];
let catalog = MinimalCatalog::from_stars(stars, "Test catalog");
let star_data: Vec<StarData> = catalog.star_data().collect();
assert_eq!(star_data.len(), 5);
let bright_stars = catalog.brighter_than(1.0);
assert_eq!(bright_stars.len(), 2);
let brightest = star_data
.iter()
.min_by(|a, b| a.magnitude.partial_cmp(&b.magnitude).unwrap())
.unwrap();
assert_eq!(brightest.magnitude, -1.5);
}
#[test]
fn test_star_data_is_a_point_source_via_extended_source() {
let star = StarData::new(1, 12.34, 56.78, 9.0, Some(0.6));
assert_eq!(star.sersic_profile(), None);
}
#[test]
fn test_extended_source_default_impl_returns_none() {
struct PointSource;
impl ExtendedSource for PointSource {}
assert_eq!(PointSource.sersic_profile(), None);
}
#[test]
fn test_sersic_profile_round_trips_through_struct_literal() {
let p = SersicProfile {
theta_half_arcsec: 3.5,
n: 4.0,
axis_ratio: 0.7,
position_angle_deg: 32.0,
};
assert_eq!(p.theta_half_arcsec, 3.5);
assert_eq!(p.n, 4.0);
assert_eq!(p.axis_ratio, 0.7);
assert_eq!(p.position_angle_deg, 32.0);
}
fn sersic_profile(n: f64, axis_ratio: f64, position_angle_deg: f64) -> SersicProfile {
SersicProfile {
theta_half_arcsec: 2.0,
n,
axis_ratio,
position_angle_deg,
}
}
#[test]
fn test_b_n_matches_ciotti_bertin_reference_values() {
let p_half = sersic_profile(0.5, 1.0, 0.0);
let p_one = sersic_profile(1.0, 1.0, 0.0);
let p_two = sersic_profile(2.0, 1.0, 0.0);
let p_four = sersic_profile(4.0, 1.0, 0.0);
let p_six = sersic_profile(6.0, 1.0, 0.0);
assert_abs_diff_eq!(p_half.b_n(), 0.6931471806, epsilon = 5e-4);
assert_abs_diff_eq!(p_one.b_n(), 1.6783469900, epsilon = 1e-4);
assert_abs_diff_eq!(p_two.b_n(), 3.6720607489, epsilon = 1e-5);
assert_abs_diff_eq!(p_four.b_n(), 7.6692494425, epsilon = 1e-6);
assert_abs_diff_eq!(p_six.b_n(), 11.6683631530, epsilon = 1e-6);
}
#[test]
fn test_surface_brightness_at_half_light_radius_equals_unity_along_major_axis() {
let p = sersic_profile(4.0, 0.6, 0.0);
assert_abs_diff_eq!(
p.surface_brightness_at(0.0, p.theta_half_arcsec),
1.0,
epsilon = 1e-12
);
}
#[test]
fn test_surface_brightness_at_circular_profile_is_axis_independent() {
let p = sersic_profile(2.5, 1.0, 37.0);
let r = 1.3;
let east = p.surface_brightness_at(r, 0.0);
let north = p.surface_brightness_at(0.0, r);
let diag = p.surface_brightness_at(r / 2.0_f64.sqrt(), r / 2.0_f64.sqrt());
assert_abs_diff_eq!(east, north, epsilon = 1e-12);
assert_abs_diff_eq!(east, diag, epsilon = 1e-12);
}
#[test]
fn test_surface_brightness_at_position_angle_rotates_major_axis_east_of_north() {
let p = sersic_profile(4.0, 0.5, 90.0);
assert_abs_diff_eq!(
p.surface_brightness_at(p.theta_half_arcsec, 0.0),
1.0,
epsilon = 1e-12
);
assert_abs_diff_eq!(
p.surface_brightness_at(0.0, p.theta_half_arcsec * p.axis_ratio),
1.0,
epsilon = 1e-12
);
}
#[test]
fn test_surface_brightness_at_centre_is_e_to_the_b_n() {
let p = sersic_profile(4.0, 0.6, 45.0);
assert_abs_diff_eq!(
p.surface_brightness_at(0.0, 0.0),
p.b_n().exp(),
epsilon = 1e-9
);
}
#[test]
fn test_surface_brightness_at_matches_astropy_sersic2d_reference() {
let p = sersic_profile(4.0, 0.6, 45.0);
assert_abs_diff_eq!(p.surface_brightness_at(1.0, 0.5), 2.461462, epsilon = 1e-5);
assert_abs_diff_eq!(p.surface_brightness_at(2.0, 2.0), 0.499511, epsilon = 1e-5);
}
#[test]
fn test_position_angle_zero_aligns_major_axis_with_north() {
let p_pa0 = sersic_profile(4.0, 0.5, 0.0);
let on_major = p_pa0.surface_brightness_at(0.0, 2.0);
let on_minor = p_pa0.surface_brightness_at(2.0, 0.0);
assert_abs_diff_eq!(on_major, 1.0, epsilon = 1e-12);
assert!(
on_minor < on_major,
"PA=0 should put major axis along +y; \
got on_major={on_major}, on_minor={on_minor}"
);
let p_pa90 = sersic_profile(4.0, 0.5, 90.0);
let on_major_90 = p_pa90.surface_brightness_at(2.0, 0.0);
let on_minor_90 = p_pa90.surface_brightness_at(0.0, 2.0);
assert_abs_diff_eq!(on_major_90, 1.0, epsilon = 1e-12);
assert!(on_minor_90 < on_major_90);
assert_abs_diff_eq!(on_minor, on_minor_90, epsilon = 1e-12);
}
#[test]
fn test_surface_brightness_at_decays_monotonically_along_major_axis() {
let p = sersic_profile(2.0, 0.7, 0.0);
let samples: Vec<f64> = (0..20)
.map(|i| p.surface_brightness_at(0.0, 0.25 * i as f64))
.collect();
for w in samples.windows(2) {
assert!(w[0] > w[1], "expected monotonic decay, got {:?}", w);
}
}
#[test]
fn test_gamma_lanczos_matches_factorial_for_integer_arguments() {
let factorials = [
(1.0, 1.0),
(2.0, 1.0),
(3.0, 2.0),
(4.0, 6.0),
(5.0, 24.0),
(6.0, 120.0),
(10.0, 362_880.0),
];
for (x, expected) in factorials {
let g = super::gamma_lanczos(x);
let rel = (g - expected).abs() / expected;
assert!(
rel < 1e-10,
"Γ({x}) = {g}, expected {expected} (rel err {rel:.2e})"
);
}
}
#[test]
fn test_gamma_lanczos_half_equals_sqrt_pi() {
let g = super::gamma_lanczos(0.5);
let expected = std::f64::consts::PI.sqrt();
let rel = (g - expected).abs() / expected;
assert!(rel < 1e-12, "Γ(0.5) = {g}, expected √π = {expected}");
}
#[test]
fn test_total_flux_per_ie_matches_hand_expansion_at_n_equals_4() {
let p = SersicProfile {
theta_half_arcsec: 3.0,
n: 4.0,
axis_ratio: 0.7,
position_angle_deg: 30.0,
};
let bn = p.b_n();
let gamma_2n = super::gamma_lanczos(8.0);
let expected =
2.0 * std::f64::consts::PI * 4.0 * bn.powf(-8.0) * gamma_2n * 0.7 * 9.0 * bn.exp();
let actual = p.total_flux_per_ie();
let rel = (actual - expected).abs() / expected;
assert!(rel < 1e-14, "K(actual)={actual} K(expected)={expected}");
}
#[test]
fn test_total_flux_per_ie_round_trips_ie_to_total_to_ie() {
for n in [1.0_f64, 2.0, 4.0, 6.0] {
for q in [1.0_f64, 0.7, 0.4] {
let p = SersicProfile {
theta_half_arcsec: 4.5,
n,
axis_ratio: q,
position_angle_deg: 17.0,
};
let i_e_in = 1234.5_f64;
let k = p.total_flux_per_ie();
let f_total = k * i_e_in;
let i_e_out = f_total / k;
assert!(
(i_e_in - i_e_out).abs() < 1e-9,
"round-trip I_e mismatch at n={n}, q={q}: in={i_e_in} out={i_e_out}"
);
}
}
}
#[test]
fn test_total_flux_per_ie_includes_exp_b_n_factor_at_n_equals_4() {
let p = SersicProfile {
theta_half_arcsec: 2.0,
n: 4.0,
axis_ratio: 1.0,
position_angle_deg: 0.0,
};
let bn = p.b_n();
let with_exp = p.total_flux_per_ie();
let without_exp =
2.0 * std::f64::consts::PI * 4.0 * bn.powf(-8.0) * super::gamma_lanczos(8.0) * 4.0; let ratio = with_exp / without_exp;
let rel = (ratio - bn.exp()).abs() / bn.exp();
assert!(
rel < 1e-12,
"with_exp / without_exp = {ratio}, expected exp(b_n) = {} (n=4)",
bn.exp()
);
assert!(
(2000.0..2300.0).contains(&bn.exp()),
"exp(b_n) at n=4 = {} expected to be ≈ 2140",
bn.exp()
);
}
#[cfg(feature = "python-tests")]
#[test]
fn test_surface_brightness_at_matches_astropy_sersic2d_via_bridge() {
use crate::pybridge::{bridge::PyRustBridge, helpers::PythonResult};
let bridge = PyRustBridge::new().expect("bridge");
let raw = bridge
.run_py_to_json(
r#"
import numpy as np
from astropy.modeling.functional_models import Sersic2D
# Documented convention: theta_AstroPy = 90° - position_angle_deg
# (AstroPy measures theta CCW from +x; this trait stores PA east of
# north — CW from +y toward +x — so the rotations are mirrored.)
PA_DEG = 45.0
model = Sersic2D(amplitude=1.0, r_eff=2.0, n=4.0,
x_0=0.0, y_0=0.0,
ellip=1.0 - 0.6,
theta=np.deg2rad(90.0 - PA_DEG))
xs = np.array([1.0, 2.0, 3.0, 0.5, 4.0])
ys = np.array([0.5, 2.0, 1.5, 1.5, 0.0])
out = model(xs, ys).astype(np.float64)
rust.collect_array(out)
"#,
)
.expect("bridge run failed");
let parsed = PythonResult::try_from(raw.as_str()).expect("bad json from bridge");
let astropy_values: Vec<f64> = match parsed {
PythonResult::Array { data, .. } => data
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
.collect(),
other => panic!("expected Array, got {other:?}"),
};
let p = sersic_profile(4.0, 0.6, 45.0);
let rust_values = [
p.surface_brightness_at(1.0, 0.5),
p.surface_brightness_at(2.0, 2.0),
p.surface_brightness_at(3.0, 1.5),
p.surface_brightness_at(0.5, 1.5),
p.surface_brightness_at(4.0, 0.0),
];
assert_eq!(astropy_values.len(), rust_values.len());
for (rust, astropy) in rust_values.iter().zip(astropy_values.iter()) {
assert_abs_diff_eq!(*rust, *astropy, epsilon = 1e-6);
}
}
}