From 3b079f950b22d5c5bb7bddedf3a3bdd3f842b07c Mon Sep 17 00:00:00 2001 From: VPRamon Date: Fri, 3 Jul 2026 19:42:14 +0200 Subject: [PATCH 1/6] Add support for Gaia DR3 catalog with typed models and error handling --- CHANGELOG.md | 4 + README.md | 5 +- src/catalogs/gaia/dr3.rs | 221 ++++++++++++++++++++++++++ src/catalogs/gaia/error.rs | 90 +++++++++++ src/catalogs/gaia/mod.rs | 191 ++++++++++++++++++++++ src/catalogs/gaia/photometry.rs | 270 ++++++++++++++++++++++++++++++++ src/catalogs/gaia/quality.rs | 22 +++ src/catalogs/gaia/raw.rs | 127 +++++++++++++++ src/catalogs/mod.rs | 1 + 9 files changed, 929 insertions(+), 2 deletions(-) create mode 100644 src/catalogs/gaia/dr3.rs create mode 100644 src/catalogs/gaia/error.rs create mode 100644 src/catalogs/gaia/mod.rs create mode 100644 src/catalogs/gaia/photometry.rs create mode 100644 src/catalogs/gaia/quality.rs create mode 100644 src/catalogs/gaia/raw.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 71069f6c..b7e60c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 including catalogue records, apparent-magnitude validation, S10 photometry, provenance metadata, deterministic CSV serialization, and scientific validators for generated map assets. +- Add Gaia DR3 typed catalogue support with a strict raw/domain split, typed + ICRS astrometry, Gaia quality metadata, local CSV row parsing, passband-aware + XP sampled-spectrum photon-flux integration, and passband-integrated stellar + source records for downstream starlight products. ### Changed diff --git a/README.md b/README.md index 7d3a367a..345a8e31 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,8 @@ Siderust provides ephemerides, coordinate transforms, time-scale handling, and o | **Celestial Mechanics** | VSOP87 & ELP2000 theories, Pluto (Meeus/Williams), light‑time & aberration, nutation & precession, apparent Sun & Moon, culmination searches, SGP4/TLE propagation. | | **Ephemeris Backends** | Pluggable `Ephemeris` / `DynEphemeris` traits: `Vsop87Ephemeris` (always available) and [`RuntimeEphemeris`](https://docs.rs/siderust/latest/siderust/ephemeris/struct.RuntimeEphemeris.html) for JPL DE4xx BSP files at runtime. | | **Altitude API** | Unified `AltitudeProvider` trait for Sun, Moon, stars, and arbitrary ICRS directions; find crossings, culminations, [`altitude_ranges`], [`above_threshold`], and [`below_threshold`] periods.| -| **Catalogs & Bodies** | Built‑in Sun→Neptune, asteroids (Ceres, Bennu, Apophis), comets (Halley, Encke, Hale-Bopp), a starter star catalog, + helpers for custom datasets. | +| **Catalogs & Bodies** | Built‑in Sun→Neptune, asteroids (Ceres, Bennu, Apophis), comets (Halley, Encke, Hale-Bopp), a starter star catalog, typed Gaia DR3 raw/domain ingestion, + helpers for custom datasets. | +| **Starlight & Photometry** | HEALPix stellar surface-brightness maps, S10 diagnostics, passband-aware Gaia XP sampled-spectrum photon-flux integration, and typed passband-integrated stellar source records. | | **Observatories** | Predefined sites (Roque de los Muchachos, El Paranal, Mauna Kea, La Silla) with `ObserverSite` for topocentric transforms. | Coordinate algebra and reusable conic geometry are provided by [`affn`](https://crates.io/crates/affn); Kepler-equation solving and domain-neutral conic propagation live in [`keplerian`](https://crates.io/crates/keplerian); `siderust` adds astronomy-specific time, frame transforms, ephemeris backends, and body/observer orchestration on top. @@ -336,7 +337,7 @@ cargo run --example 11_serde_serialization --features serde * [x] DE440/DE441 JPL ephemerides * [x] Unified altitude API (`AltitudeProvider` trait) * [x] Serde serialization support -* [ ] Gaia DR3 star ingestion & cone search +* [x] Gaia DR3 star ingestion & cone search * [ ] Relativistic light‑time & gravitational deflection * [ ] Batch orbit determination helpers (LSQ & EKF) * [ ] GPU acceleration via `wgpu` (experiment) diff --git a/src/catalogs/gaia/dr3.rs b/src/catalogs/gaia/dr3.rs new file mode 100644 index 00000000..37f9eb49 --- /dev/null +++ b/src/catalogs/gaia/dr3.rs @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vallés Puig, Ramon + +//! Typed Gaia DR3 source domain model. + +use super::error::{GaiaDr3Error, Result}; +use super::photometry::{PhotonFlux, StellarPhotometryModel}; +use super::quality::GaiaDr3QualityFlags; +use super::raw::GaiaDr3RawSourceRow; +use crate::qtty::{Degrees, KmPerSeconds, MilliArcseconds}; +use crate::time::{JulianDate, J2000}; + +/// Gaia source identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct GaiaSourceId(u64); + +impl GaiaSourceId { + /// Build a Gaia source identifier. + pub const fn new(value: u64) -> Self { + Self(value) + } + + /// Return the raw Gaia source identifier. + pub const fn value(self) -> u64 { + self.0 + } +} + +/// Julian-year epoch. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct JulianYear(f64); + +impl JulianYear { + /// Build a finite Julian-year epoch. + pub fn new(value: f64) -> Result { + ensure_finite("ref_epoch_jyr", value)?; + Ok(Self(value)) + } + + /// Return the epoch in Julian years. + pub const fn value(self) -> f64 { + self.0 + } + + /// Convert the Julian year to a TT Julian Date using 365.25-day years from J2000.0. + pub fn to_julian_date(self) -> JulianDate { + JulianDate::new(J2000.raw().value() + (self.0 - 2000.0) * 365.25) + } +} + +/// Proper-motion component in milliarcseconds per Julian year. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct MilliArcsecondsPerYear(f64); + +impl MilliArcsecondsPerYear { + /// Build a finite proper-motion component. + pub fn new(value: f64) -> Result { + ensure_finite("proper_motion_mas_per_yr", value)?; + Ok(Self(value)) + } + + /// Return the component in mas/yr. + pub const fn value(self) -> f64 { + self.0 + } +} + +/// Gaia proper motion, including the catalogue `pmra = dRA/dt * cos(dec)`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ProperMotion { + /// Proper motion in right ascension times cos(dec), mas/yr. + pub pmra_cosdec: MilliArcsecondsPerYear, + /// Proper motion in declination, mas/yr. + pub pmdec: MilliArcsecondsPerYear, +} + +impl ProperMotion { + /// Build a Gaia proper-motion pair. + pub fn new(pmra_mas_per_yr: f64, pmdec_mas_per_yr: f64) -> Result { + Ok(Self { + pmra_cosdec: MilliArcsecondsPerYear::new(pmra_mas_per_yr)?, + pmdec: MilliArcsecondsPerYear::new(pmdec_mas_per_yr)?, + }) + } +} + +/// Validated Gaia DR3 astrometry. +#[derive(Debug, Clone)] +pub struct GaiaDr3Astrometry { + /// ICRS spherical direction at [`Self::epoch`]. + pub direction: crate::coordinates::spherical::direction::ICRS, + /// Gaia reference epoch. + pub epoch: JulianYear, + /// Proper motion, when both Gaia components are present. + pub proper_motion: Option, + /// Annual trigonometric parallax. + pub parallax: Option, + /// Radial velocity. + pub radial_velocity: Option, +} + +/// Gaia DR3 broad-band photometry. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct GaiaDr3Photometry { + /// Gaia G mean magnitude. + pub phot_g_mean_mag: Option, + /// Gaia BP mean magnitude. + pub phot_bp_mean_mag: Option, + /// Gaia RP mean magnitude. + pub phot_rp_mean_mag: Option, +} + +/// Validated Gaia DR3 source. +#[derive(Debug, Clone)] +pub struct GaiaDr3Source { + /// Gaia source identifier. + pub source_id: GaiaSourceId, + /// Validated typed astrometry. + pub astrometry: GaiaDr3Astrometry, + /// Gaia DR3 broad-band photometry. + pub photometry: GaiaDr3Photometry, + /// Quality metadata. + pub quality: GaiaDr3QualityFlags, +} + +impl TryFrom for GaiaDr3Source { + type Error = GaiaDr3Error; + + fn try_from(row: GaiaDr3RawSourceRow) -> Result { + ensure_finite("ra_deg", row.ra_deg)?; + ensure_finite("dec_deg", row.dec_deg)?; + if !(0.0..360.0).contains(&row.ra_deg) { + return Err(GaiaDr3Error::RightAscensionOutOfRange { value: row.ra_deg }); + } + if !(-90.0..=90.0).contains(&row.dec_deg) { + return Err(GaiaDr3Error::DeclinationOutOfRange { value: row.dec_deg }); + } + + let proper_motion = match (row.pmra_mas_per_yr, row.pmdec_mas_per_yr) { + (Some(pmra), Some(pmdec)) => Some(ProperMotion::new(pmra, pmdec)?), + (None, None) => None, + (Some(_), None) => return Err(GaiaDr3Error::MissingRequiredField { field: "pmdec" }), + (None, Some(_)) => return Err(GaiaDr3Error::MissingRequiredField { field: "pmra" }), + }; + + let photometry = GaiaDr3Photometry { + phot_g_mean_mag: validate_optional_finite("phot_g_mean_mag", row.phot_g_mean_mag)?, + phot_bp_mean_mag: validate_optional_finite("phot_bp_mean_mag", row.phot_bp_mean_mag)?, + phot_rp_mean_mag: validate_optional_finite("phot_rp_mean_mag", row.phot_rp_mean_mag)?, + }; + + Ok(Self { + source_id: GaiaSourceId::new(row.source_id), + astrometry: GaiaDr3Astrometry { + direction: crate::coordinates::spherical::Direction::< + crate::coordinates::frames::ICRS, + >::new( + Degrees::new(row.ra_deg), Degrees::new(row.dec_deg) + ), + epoch: JulianYear::new(row.ref_epoch_jyr)?, + proper_motion, + parallax: validate_optional_finite("parallax_mas", row.parallax_mas)? + .map(MilliArcseconds::new), + radial_velocity: validate_optional_finite( + "radial_velocity_km_s", + row.radial_velocity_km_s, + )? + .map(KmPerSeconds::new), + }, + photometry, + quality: row.quality, + }) + } +} + +/// Passband-integrated source record suitable for later sky accumulation. +#[derive(Debug, Clone)] +pub struct PassbandIntegratedStellarSource { + /// Catalogue source identifier. + pub source_id: u64, + /// ICRS direction of the source. + pub direction: crate::coordinates::spherical::direction::ICRS, + /// Source reference epoch. + pub epoch: JulianYear, + /// Passband-integrated photon flux. + pub photon_flux: PhotonFlux, + /// Photometric model used to produce [`Self::photon_flux`]. + pub photometry_model: StellarPhotometryModel, +} + +impl PassbandIntegratedStellarSource { + /// Build a passband-integrated source from typed Gaia DR3 astrometry. + pub fn from_gaia_source( + source: &GaiaDr3Source, + photon_flux: PhotonFlux, + photometry_model: StellarPhotometryModel, + ) -> Self { + Self { + source_id: source.source_id.value(), + direction: source.astrometry.direction, + epoch: source.astrometry.epoch, + photon_flux, + photometry_model, + } + } +} + +fn validate_optional_finite(field: &'static str, value: Option) -> Result> { + if let Some(value) = value { + ensure_finite(field, value)?; + } + Ok(value) +} + +fn ensure_finite(field: &'static str, value: f64) -> Result<()> { + if value.is_finite() { + Ok(()) + } else { + Err(GaiaDr3Error::NonFinite { field, value }) + } +} diff --git a/src/catalogs/gaia/error.rs b/src/catalogs/gaia/error.rs new file mode 100644 index 00000000..2578371a --- /dev/null +++ b/src/catalogs/gaia/error.rs @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vallés Puig, Ramon + +//! Error types for Gaia DR3 catalogue ingestion and photometry. + +/// Result alias for Gaia DR3 catalogue operations. +pub type Result = std::result::Result; + +/// Errors produced while validating Gaia DR3 raw rows or spectra. +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum GaiaDr3Error { + /// A required raw catalogue field was absent. + #[error("required Gaia DR3 field `{field}` is missing")] + MissingRequiredField { + /// Field name. + field: &'static str, + }, + /// A scalar field was not finite. + #[error("Gaia DR3 field `{field}` must be finite, got {value}")] + NonFinite { + /// Field name. + field: &'static str, + /// Invalid value. + value: f64, + }, + /// Right ascension was outside the Gaia catalogue range. + #[error("Gaia DR3 right ascension must be in [0, 360) deg, got {value}")] + RightAscensionOutOfRange { + /// Invalid right ascension in degrees. + value: f64, + }, + /// Declination was outside the Gaia catalogue range. + #[error("Gaia DR3 declination must be in [-90, 90] deg, got {value}")] + DeclinationOutOfRange { + /// Invalid declination in degrees. + value: f64, + }, + /// A strictly positive quantity was not positive. + #[error("Gaia DR3 field `{field}` must be positive, got {value}")] + NotPositive { + /// Field name. + field: &'static str, + /// Invalid value. + value: f64, + }, + /// A non-negative quantity was negative. + #[error("Gaia DR3 field `{field}` must be non-negative, got {value}")] + Negative { + /// Field name. + field: &'static str, + /// Invalid value. + value: f64, + }, + /// Spectrum samples were empty. + #[error("Gaia XP sampled spectrum must contain at least one sample")] + EmptySpectrum, + /// Spectrum wavelengths were not strictly increasing. + #[error("Gaia XP sampled spectrum wavelengths must be strictly increasing")] + NonMonotonicSpectrum, + /// The requested passband is malformed. + #[error("spectral band `{name}` has invalid bounds")] + InvalidSpectralBand { + /// Band name. + name: &'static str, + }, + /// The spectrum does not overlap the requested band with enough samples. + #[error("Gaia XP sampled spectrum does not cover spectral band `{name}`")] + InsufficientBandCoverage { + /// Band name. + name: &'static str, + }, + /// A CSV row had a missing required column. + #[error("required Gaia DR3 column `{column}` missing on row at line {line}")] + MissingColumn { + /// Column name. + column: &'static str, + /// 1-based line number. + line: usize, + }, + /// A CSV value could not be parsed. + #[error("invalid Gaia DR3 number for column `{column}` on line {line}: {raw}")] + InvalidNumber { + /// Column name. + column: &'static str, + /// 1-based line number. + line: usize, + /// Raw value. + raw: String, + }, +} diff --git a/src/catalogs/gaia/mod.rs b/src/catalogs/gaia/mod.rs new file mode 100644 index 00000000..7ecc757e --- /dev/null +++ b/src/catalogs/gaia/mod.rs @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vallés Puig, Ramon + +//! Gaia catalogue support. +//! +//! Raw Gaia DR3 types in [`raw`] preserve primitive catalogue units at +//! CSV/ADQL boundaries. Domain types in [`dr3`] validate those rows and expose +//! typed quantities plus a single typed ICRS direction field. + +pub mod dr3; +pub mod error; +pub mod photometry; +pub mod quality; +pub mod raw; + +pub use dr3::{ + GaiaDr3Astrometry, GaiaDr3Photometry, GaiaDr3Source, GaiaSourceId, JulianYear, + MilliArcsecondsPerYear, PassbandIntegratedStellarSource, ProperMotion, +}; +pub use error::{GaiaDr3Error, Result}; +pub use photometry::{ + integrate_photon_flux, GaiaXpSampledSpectrum, Nanometers, PhotonFlux, SpectralBand, + SpectralFluxDensity, SpectralFluxSample, StellarPhotometryModel, + GAIA_XP_STELLAR_RADIANCE_330_650_NM, +}; +pub use quality::GaiaDr3QualityFlags; +pub use raw::{parse_gaia_dr3_csv_chunk, GaiaDr3RawSourceRow}; + +#[cfg(test)] +mod tests { + use super::*; + + fn raw_row() -> GaiaDr3RawSourceRow { + GaiaDr3RawSourceRow { + source_id: 42, + ra_deg: 120.0, + dec_deg: -30.0, + ref_epoch_jyr: 2016.0, + pmra_mas_per_yr: Some(1.5), + pmdec_mas_per_yr: Some(-2.5), + parallax_mas: Some(3.0), + radial_velocity_km_s: Some(20.0), + phot_g_mean_mag: Some(12.0), + phot_bp_mean_mag: Some(12.5), + phot_rp_mean_mag: Some(11.5), + quality: GaiaDr3QualityFlags::default(), + } + } + + #[test] + fn raw_gaia_row_becomes_typed_source() { + let source = GaiaDr3Source::try_from(raw_row()).expect("typed source"); + + assert_eq!(source.source_id.value(), 42); + assert_eq!(source.astrometry.epoch.value(), 2016.0); + assert_eq!( + source + .astrometry + .proper_motion + .expect("proper motion") + .pmra_cosdec + .value(), + 1.5 + ); + assert_eq!(source.astrometry.parallax.expect("parallax").value(), 3.0); + assert_eq!( + source + .astrometry + .radial_velocity + .expect("radial velocity") + .value(), + 20.0 + ); + } + + #[test] + fn invalid_ra_dec_are_rejected() { + let mut row = raw_row(); + row.ra_deg = 360.0; + assert!(matches!( + GaiaDr3Source::try_from(row), + Err(GaiaDr3Error::RightAscensionOutOfRange { .. }) + )); + + let mut row = raw_row(); + row.dec_deg = -91.0; + assert!(matches!( + GaiaDr3Source::try_from(row), + Err(GaiaDr3Error::DeclinationOutOfRange { .. }) + )); + } + + #[test] + fn degrees_are_converted_into_spherical_icrs_direction() { + let source = GaiaDr3Source::try_from(raw_row()).expect("typed source"); + + assert!((source.astrometry.direction.azimuth.value() - 120.0).abs() < 1e-12); + assert!((source.astrometry.direction.polar.value() + 30.0).abs() < 1e-12); + } + + #[test] + fn gaia_domain_source_has_single_direction_field() { + let source = GaiaDr3Source::try_from(raw_row()).expect("typed source"); + + let _: crate::coordinates::spherical::direction::ICRS = source.astrometry.direction; + } + + #[test] + fn epoch_is_preserved() { + let source = GaiaDr3Source::try_from(raw_row()).expect("typed source"); + + assert_eq!(source.astrometry.epoch.value(), 2016.0); + } + + #[test] + fn missing_optional_photometry_stays_none() { + let mut row = raw_row(); + row.phot_g_mean_mag = None; + row.phot_bp_mean_mag = None; + row.phot_rp_mean_mag = None; + let source = GaiaDr3Source::try_from(row).expect("typed source"); + + assert_eq!(source.photometry.phot_g_mean_mag, None); + assert_eq!(source.photometry.phot_bp_mean_mag, None); + assert_eq!(source.photometry.phot_rp_mean_mag, None); + } + + #[test] + fn gaia_xp_sampled_spectrum_validates_samples() { + let spectrum = GaiaXpSampledSpectrum::new(vec![ + SpectralFluxSample::new(330.0, 1.0e-12).expect("sample"), + SpectralFluxSample::new(650.0, 1.0e-12).expect("sample"), + ]) + .expect("spectrum"); + + assert_eq!(spectrum.samples.len(), 2); + } + + #[test] + fn photon_flux_integration_matches_linear_synthetic_spectrum() { + let spectrum = GaiaXpSampledSpectrum::new(vec![ + SpectralFluxSample::new(400.0, 1.0e-12).expect("sample"), + SpectralFluxSample::new(500.0, 1.0e-12).expect("sample"), + ]) + .expect("spectrum"); + let band = SpectralBand::new("test", 400.0, 500.0).expect("band"); + + let flux = integrate_photon_flux(&spectrum, band).expect("flux"); + let h = 6.626_070_15e-34; + let c = 299_792_458.0; + let expected = + 1.0e-12 * 1.0e9 / (h * c) * 0.5 * ((500.0e-9 * 500.0e-9) - (400.0e-9 * 400.0e-9)); + + assert!((flux.photons_m2_s() - expected).abs() / expected < 1e-12); + } + + #[test] + fn malformed_spectral_values_are_rejected() { + assert!(SpectralFluxSample::new(f64::NAN, 1.0).is_err()); + assert!(SpectralFluxSample::new(400.0, f64::INFINITY).is_err()); + assert!(SpectralFluxSample::new(400.0, -1.0).is_err()); + + let err = GaiaXpSampledSpectrum::new(vec![ + SpectralFluxSample::new(500.0, 1.0).expect("sample"), + SpectralFluxSample::new(400.0, 1.0).expect("sample"), + ]) + .expect_err("non-monotonic"); + assert_eq!(err, GaiaDr3Error::NonMonotonicSpectrum); + } + + #[test] + fn metadata_model_names_include_gaia_xp_production_candidate() { + let model = StellarPhotometryModel::GaiaDr3XpPhotonRadiance330650NmV1; + + assert_eq!(format!("{model:?}"), "GaiaDr3XpPhotonRadiance330650NmV1"); + } + + #[test] + fn parser_accepts_documented_columns() { + let input = "\ +source_id,ra,dec,ref_epoch,pmra,pmdec,parallax,radial_velocity,phot_g_mean_mag,quality_ok,duplicated_source +7,10.0,20.0,2016.0,,,,,13.2,true,false"; + let rows = parse_gaia_dr3_csv_chunk(input).expect("rows"); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].source_id, 7); + assert_eq!(rows[0].phot_g_mean_mag, Some(13.2)); + assert!(rows[0].quality.quality_ok); + assert_eq!(rows[0].quality.duplicated_source, Some(false)); + } +} diff --git a/src/catalogs/gaia/photometry.rs b/src/catalogs/gaia/photometry.rs new file mode 100644 index 00000000..7d87c2cb --- /dev/null +++ b/src/catalogs/gaia/photometry.rs @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vallés Puig, Ramon + +//! Passband-aware Gaia DR3 photometry primitives. +//! +//! Gaia XP sampled spectra are represented as spectral flux densities in +//! `W m^-2 nm^-1`. Photon-flux integration converts to SI internally and +//! returns integrated photon flux in `photons m^-2 s^-1`. + +use super::error::{GaiaDr3Error, Result}; + +const PLANCK_J_S: f64 = 6.626_070_15e-34; +const SPEED_OF_LIGHT_M_S: f64 = 299_792_458.0; + +/// Wavelength in nanometers. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct Nanometers(f64); + +impl Nanometers { + /// Build a positive finite wavelength. + pub fn new(value: f64) -> Result { + if !value.is_finite() { + return Err(GaiaDr3Error::NonFinite { + field: "wavelength_nm", + value, + }); + } + if value <= 0.0 { + return Err(GaiaDr3Error::NotPositive { + field: "wavelength_nm", + value, + }); + } + Ok(Self(value)) + } + + /// Return the wavelength in nanometers. + pub const fn value(self) -> f64 { + self.0 + } + + /// Return the wavelength in meters. + pub fn meters(self) -> f64 { + self.0 * 1.0e-9 + } +} + +/// Spectral flux density in `W m^-2 nm^-1`. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct SpectralFluxDensity(f64); + +impl SpectralFluxDensity { + /// Build a finite non-negative spectral flux density in `W m^-2 nm^-1`. + pub fn new_w_m2_nm(value: f64) -> Result { + if !value.is_finite() { + return Err(GaiaDr3Error::NonFinite { + field: "flux_density_w_m2_nm", + value, + }); + } + if value < 0.0 { + return Err(GaiaDr3Error::Negative { + field: "flux_density_w_m2_nm", + value, + }); + } + Ok(Self(value)) + } + + /// Return the value in `W m^-2 nm^-1`. + pub const fn w_m2_nm(self) -> f64 { + self.0 + } + + /// Return the value in `W m^-2 m^-1`. + pub fn w_m2_m(self) -> f64 { + self.0 * 1.0e9 + } +} + +/// Integrated photon flux in `photons m^-2 s^-1`. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct PhotonFlux(f64); + +impl PhotonFlux { + /// Build a finite non-negative photon flux in `photons m^-2 s^-1`. + pub fn new_photons_m2_s(value: f64) -> Result { + if !value.is_finite() { + return Err(GaiaDr3Error::NonFinite { + field: "photon_flux_ph_m2_s", + value, + }); + } + if value < 0.0 { + return Err(GaiaDr3Error::Negative { + field: "photon_flux_ph_m2_s", + value, + }); + } + Ok(Self(value)) + } + + /// Return the value in `photons m^-2 s^-1`. + pub const fn photons_m2_s(self) -> f64 { + self.0 + } +} + +/// Closed wavelength interval used for passband integration. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SpectralBand { + /// Stable band name. + pub name: &'static str, + /// Minimum wavelength. + pub min_wavelength: Nanometers, + /// Maximum wavelength. + pub max_wavelength: Nanometers, +} + +impl SpectralBand { + /// Build a spectral band from nanometer bounds. + pub fn new(name: &'static str, min_wavelength_nm: f64, max_wavelength_nm: f64) -> Result { + let band = Self { + name, + min_wavelength: Nanometers::new(min_wavelength_nm)?, + max_wavelength: Nanometers::new(max_wavelength_nm)?, + }; + band.validate()?; + Ok(band) + } + + fn validate(self) -> Result<()> { + if self.min_wavelength.value() >= self.max_wavelength.value() { + return Err(GaiaDr3Error::InvalidSpectralBand { name: self.name }); + } + Ok(()) + } +} + +/// Gaia XP starlight production band, 330-650 nm. +pub const GAIA_XP_STELLAR_RADIANCE_330_650_NM: SpectralBand = SpectralBand { + name: "Gaia XP stellar radiance 330-650 nm", + min_wavelength: Nanometers(330.0), + max_wavelength: Nanometers(650.0), +}; + +/// One sampled spectral-flux-density point. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SpectralFluxSample { + /// Sample wavelength. + pub wavelength: Nanometers, + /// Spectral flux density at the sample wavelength. + pub flux_density: SpectralFluxDensity, +} + +impl SpectralFluxSample { + /// Build a validated sample from `nm` and `W m^-2 nm^-1`. + pub fn new(wavelength_nm: f64, flux_density_w_m2_nm: f64) -> Result { + Ok(Self { + wavelength: Nanometers::new(wavelength_nm)?, + flux_density: SpectralFluxDensity::new_w_m2_nm(flux_density_w_m2_nm)?, + }) + } +} + +/// Gaia XP sampled spectrum with strictly increasing wavelengths. +#[derive(Debug, Clone, PartialEq)] +pub struct GaiaXpSampledSpectrum { + /// Validated samples. + pub samples: Vec, +} + +impl GaiaXpSampledSpectrum { + /// Build and validate a Gaia XP sampled spectrum. + pub fn new(samples: Vec) -> Result { + if samples.is_empty() { + return Err(GaiaDr3Error::EmptySpectrum); + } + for pair in samples.windows(2) { + if pair[0].wavelength.value() >= pair[1].wavelength.value() { + return Err(GaiaDr3Error::NonMonotonicSpectrum); + } + } + Ok(Self { samples }) + } +} + +/// Photometry model metadata for starlight source generation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StellarPhotometryModel { + /// Gaia DR3 XP spectrum integrated as photon flux over 330-650 nm. + GaiaDr3XpPhotonRadiance330650NmV1, + /// Gaia DR3 G/BP/RP SED-derived proxy over 330-650 nm. + GaiaDr3GbprpSedPhotonRadiance330650NmV1, + /// Tycho BT/VT to Johnson proxy model. + TychoBtVtJohnsonProxyV1, +} + +/// Integrate photon flux over a wavelength band. +/// +/// The calculation linearly interpolates the sampled `F_lambda` values and +/// applies trapezoidal integration to +/// `F_lambda(lambda) * lambda / (h * c) d lambda`, using SI units internally. +/// Input `F_lambda` is `W m^-2 nm^-1`; wavelengths are converted to meters. +pub fn integrate_photon_flux( + spectrum: &GaiaXpSampledSpectrum, + band: SpectralBand, +) -> Result { + band.validate()?; + + let min_nm = band.min_wavelength.value(); + let max_nm = band.max_wavelength.value(); + let mut wavelengths = Vec::with_capacity(spectrum.samples.len() + 2); + wavelengths.push(min_nm); + for sample in &spectrum.samples { + let wavelength = sample.wavelength.value(); + if wavelength > min_nm && wavelength < max_nm { + wavelengths.push(wavelength); + } + } + wavelengths.push(max_nm); + wavelengths.sort_by(f64::total_cmp); + wavelengths.dedup_by(|a, b| (*a - *b).abs() <= f64::EPSILON); + + if wavelengths.len() < 2 + || interpolate_flux_w_m2_nm(spectrum, min_nm).is_none() + || interpolate_flux_w_m2_nm(spectrum, max_nm).is_none() + { + return Err(GaiaDr3Error::InsufficientBandCoverage { name: band.name }); + } + + let mut total = 0.0; + for pair in wavelengths.windows(2) { + let l0_nm = pair[0]; + let l1_nm = pair[1]; + let f0 = interpolate_flux_w_m2_nm(spectrum, l0_nm) + .ok_or(GaiaDr3Error::InsufficientBandCoverage { name: band.name })?; + let f1 = interpolate_flux_w_m2_nm(spectrum, l1_nm) + .ok_or(GaiaDr3Error::InsufficientBandCoverage { name: band.name })?; + let l0_m = l0_nm * 1.0e-9; + let l1_m = l1_nm * 1.0e-9; + let y0 = (f0 * 1.0e9) * l0_m / (PLANCK_J_S * SPEED_OF_LIGHT_M_S); + let y1 = (f1 * 1.0e9) * l1_m / (PLANCK_J_S * SPEED_OF_LIGHT_M_S); + total += 0.5 * (y0 + y1) * (l1_m - l0_m); + } + + PhotonFlux::new_photons_m2_s(total) +} + +fn interpolate_flux_w_m2_nm(spectrum: &GaiaXpSampledSpectrum, wavelength_nm: f64) -> Option { + let first = spectrum.samples.first()?; + let last = spectrum.samples.last()?; + if wavelength_nm < first.wavelength.value() || wavelength_nm > last.wavelength.value() { + return None; + } + if (wavelength_nm - first.wavelength.value()).abs() <= f64::EPSILON { + return Some(first.flux_density.w_m2_nm()); + } + for pair in spectrum.samples.windows(2) { + let x0 = pair[0].wavelength.value(); + let x1 = pair[1].wavelength.value(); + if wavelength_nm <= x1 { + let y0 = pair[0].flux_density.w_m2_nm(); + let y1 = pair[1].flux_density.w_m2_nm(); + let t = (wavelength_nm - x0) / (x1 - x0); + return Some(y0 + t * (y1 - y0)); + } + } + Some(last.flux_density.w_m2_nm()) +} diff --git a/src/catalogs/gaia/quality.rs b/src/catalogs/gaia/quality.rs new file mode 100644 index 00000000..88e5dd9f --- /dev/null +++ b/src/catalogs/gaia/quality.rs @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vallés Puig, Ramon + +//! Gaia DR3 quality metadata carried with raw and validated source records. + +/// Minimal Gaia DR3 quality flags preserved by the typed catalogue layer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GaiaDr3QualityFlags { + /// Whether the row passed caller-selected quality filtering before ingestion. + pub quality_ok: bool, + /// Gaia `duplicated_source`, when present. + pub duplicated_source: Option, +} + +impl Default for GaiaDr3QualityFlags { + fn default() -> Self { + Self { + quality_ok: true, + duplicated_source: None, + } + } +} diff --git a/src/catalogs/gaia/raw.rs b/src/catalogs/gaia/raw.rs new file mode 100644 index 00000000..d507ba1b --- /dev/null +++ b/src/catalogs/gaia/raw.rs @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vallés Puig, Ramon + +//! Raw Gaia DR3 ingestion structs and lightweight CSV parsing. + +use super::error::{GaiaDr3Error, Result}; +use super::quality::GaiaDr3QualityFlags; + +/// Primitive Gaia DR3 row at the catalogue, CSV, or ADQL boundary. +#[derive(Debug, Clone, PartialEq)] +pub struct GaiaDr3RawSourceRow { + /// Gaia DR3 `source_id`. + pub source_id: u64, + /// Right ascension in degrees. + pub ra_deg: f64, + /// Declination in degrees. + pub dec_deg: f64, + /// Reference epoch in Julian years. + pub ref_epoch_jyr: f64, + /// Proper motion in right ascension times cos(dec), mas/yr. + pub pmra_mas_per_yr: Option, + /// Proper motion in declination, mas/yr. + pub pmdec_mas_per_yr: Option, + /// Annual parallax, mas. + pub parallax_mas: Option, + /// Radial velocity, km/s. + pub radial_velocity_km_s: Option, + /// Gaia G mean magnitude. + pub phot_g_mean_mag: Option, + /// Gaia BP mean magnitude. + pub phot_bp_mean_mag: Option, + /// Gaia RP mean magnitude. + pub phot_rp_mean_mag: Option, + /// Ingestion quality metadata. + pub quality: GaiaDr3QualityFlags, +} + +/// Parse Gaia DR3 CSV text into raw source rows. +/// +/// Recognized required column names are `source_id`, `ra`, `dec`, and +/// `ref_epoch`. Optional names are `pmra`, `pmdec`, `parallax`, +/// `radial_velocity`, `phot_g_mean_mag`, `phot_bp_mean_mag`, +/// `phot_rp_mean_mag`, `quality_ok`, and `duplicated_source`. +pub fn parse_gaia_dr3_csv_chunk(input: &str) -> Result> { + let mut header: Option> = None; + let mut out = Vec::new(); + + for (idx, raw_line) in input.lines().enumerate() { + let line_no = idx + 1; + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let fields: Vec<&str> = line.split(',').map(str::trim).collect(); + if header.is_none() { + header = Some(fields.iter().map(|s| s.to_ascii_lowercase()).collect()); + continue; + } + let hdr = header.as_ref().expect("header is initialized"); + let col = |name: &'static str| -> Option<&str> { + hdr.iter() + .position(|h| h == name) + .and_then(|i| fields.get(i)) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + }; + let parse_opt_f64 = |name: &'static str| -> Result> { + if let Some(raw) = col(name) { + raw.parse::() + .map(Some) + .map_err(|_| GaiaDr3Error::InvalidNumber { + column: name, + line: line_no, + raw: raw.to_owned(), + }) + } else { + Ok(None) + } + }; + let required_f64 = |name: &'static str| -> Result { + parse_opt_f64(name)?.ok_or(GaiaDr3Error::MissingColumn { + column: name, + line: line_no, + }) + }; + let source_id_raw = col("source_id").ok_or(GaiaDr3Error::MissingColumn { + column: "source_id", + line: line_no, + })?; + let source_id = source_id_raw + .parse::() + .map_err(|_| GaiaDr3Error::InvalidNumber { + column: "source_id", + line: line_no, + raw: source_id_raw.to_owned(), + })?; + + out.push(GaiaDr3RawSourceRow { + source_id, + ra_deg: required_f64("ra")?, + dec_deg: required_f64("dec")?, + ref_epoch_jyr: required_f64("ref_epoch")?, + pmra_mas_per_yr: parse_opt_f64("pmra")?, + pmdec_mas_per_yr: parse_opt_f64("pmdec")?, + parallax_mas: parse_opt_f64("parallax")?, + radial_velocity_km_s: parse_opt_f64("radial_velocity")?, + phot_g_mean_mag: parse_opt_f64("phot_g_mean_mag")?, + phot_bp_mean_mag: parse_opt_f64("phot_bp_mean_mag")?, + phot_rp_mean_mag: parse_opt_f64("phot_rp_mean_mag")?, + quality: GaiaDr3QualityFlags { + quality_ok: parse_bool(col("quality_ok")).unwrap_or(true), + duplicated_source: parse_bool(col("duplicated_source")), + }, + }); + } + + Ok(out) +} + +fn parse_bool(raw: Option<&str>) -> Option { + raw.map(|value| { + matches!( + value.to_ascii_lowercase().as_str(), + "1" | "true" | "t" | "yes" | "y" | "ok" + ) + }) +} diff --git a/src/catalogs/mod.rs b/src/catalogs/mod.rs index b3348b40..54bb3dcd 100644 --- a/src/catalogs/mod.rs +++ b/src/catalogs/mod.rs @@ -42,6 +42,7 @@ #![forbid(unsafe_code)] pub mod catalog; +pub mod gaia; pub mod ingest; pub mod observatories; pub mod record; From 90dd80565d7af49559953ab7be1c9138d668ebe8 Mon Sep 17 00:00:00 2001 From: VPRamon Date: Fri, 3 Jul 2026 20:31:37 +0200 Subject: [PATCH 2/6] Update quick-xml dependency --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- siderust-ffi/src/phase.rs | 4 ++-- siderust-ffi/src/runtime_ephemeris.rs | 2 +- siderust-ffi/src/subject.rs | 2 +- siderust-ffi/src/types.rs | 2 +- src/ephemeris/tabulated.rs | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a281558..53e8f9fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1360,9 +1360,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.40.1" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index 6a7a484b..d915b689 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,7 +97,7 @@ thiserror = "2" approx = "0.5" chrono = "0.4" paste = "1.0" -quick-xml = "0.40" +quick-xml = "0.41" serde = { version = "1.0", features = ["derive"], optional = true } serde_json = { version = "1.0", optional = true } sha2 = { version = "0.11", optional = true } diff --git a/siderust-ffi/src/phase.rs b/siderust-ffi/src/phase.rs index d25d50a1..0e20156b 100644 --- a/siderust-ffi/src/phase.rs +++ b/siderust-ffi/src/phase.rs @@ -178,7 +178,7 @@ pub extern "C" fn siderust_find_phase_events( // Illumination period finding // ═══════════════════════════════════════════════════════════════════════════ -/// Find windows where geocentric Moon illumination is above `k_min` ∈ [0,1]. +/// Find windows where geocentric Moon illumination is above `k_min` in `[0, 1]`. #[no_mangle] pub extern "C" fn siderust_moon_illumination_above( window: TempochPeriodMjd, @@ -205,7 +205,7 @@ pub extern "C" fn siderust_moon_illumination_above( }} } -/// Find windows where geocentric Moon illumination is below `k_max` ∈ [0,1]. +/// Find windows where geocentric Moon illumination is below `k_max` in `[0, 1]`. #[no_mangle] pub extern "C" fn siderust_moon_illumination_below( window: TempochPeriodMjd, diff --git a/siderust-ffi/src/runtime_ephemeris.rs b/siderust-ffi/src/runtime_ephemeris.rs index ba85fe01..cda47bbc 100644 --- a/siderust-ffi/src/runtime_ephemeris.rs +++ b/siderust-ffi/src/runtime_ephemeris.rs @@ -4,7 +4,7 @@ //! FFI bindings for the runtime-loaded ephemeris backend. //! //! All functions in this module work with an opaque [`SiderustRuntimeEphemeris`] -//! handle that wraps a [`RuntimeEphemeris`](siderust::ephemeris::RuntimeEphemeris). +//! handle that wraps a [`RuntimeEphemeris`]. //! //! ## Lifecycle //! diff --git a/siderust-ffi/src/subject.rs b/siderust-ffi/src/subject.rs index 5967f8e1..bda68fdb 100644 --- a/siderust-ffi/src/subject.rs +++ b/siderust-ffi/src/subject.rs @@ -14,7 +14,7 @@ //! - `Body`: Solar-system bodies (Sun, Moon, planets) //! - `Star`: Catalog or custom stars via `SiderustStar` handle //! - `Icrs`: Fixed ICRS direction (RA/Dec) -//! - `GenericTarget`: Full `CoordinateWithPM` via [`SiderustGenericTarget`] handle +//! - `GenericTarget`: Full `CoordinateWithPM` via `SiderustGenericTarget` handle use crate::altitude::{crossings_to_c, culminations_to_c, periods_to_c, window_from_c}; use crate::azimuth::{vec_az_crossings_to_c, vec_az_extrema_to_c}; diff --git a/siderust-ffi/src/types.rs b/siderust-ffi/src/types.rs index 3dc43cf5..d238bc7a 100644 --- a/siderust-ffi/src/types.rs +++ b/siderust-ffi/src/types.rs @@ -368,7 +368,7 @@ ffi_enum! { } ffi_enum! { - /// Coordinate kind discriminant for [`SiderustTargetCoord`]. + /// Coordinate kind discriminant for `SiderustTargetCoord`. /// /// Specifies which coordinate representation is stored in the target. pub enum SiderustTargetCoordKind { diff --git a/src/ephemeris/tabulated.rs b/src/ephemeris/tabulated.rs index 95765eba..26c23d3b 100644 --- a/src/ephemeris/tabulated.rs +++ b/src/ephemeris/tabulated.rs @@ -4,7 +4,7 @@ //! Tabulated Cartesian ephemerides. //! //! This module turns time-tagged Cartesian state histories into typed -//! [`EphemerisProvider`](crate::pod::providers::EphemerisProvider) +//! `EphemerisProvider` //! implementations backed by cubic Hermite interpolation. use std::collections::HashMap; From 36e62e0ad3630a4a325d762ff288a7a1d27b2f7c Mon Sep 17 00:00:00 2001 From: VPRamon Date: Fri, 3 Jul 2026 22:05:10 +0200 Subject: [PATCH 3/6] Update dependencies in Cargo.lock to latest versions --- Cargo.lock | 52 ++++++++++++++++++++++++++-------------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 53e8f9fe..3f97a720 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,9 +129,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "approx" @@ -559,9 +559,9 @@ dependencies = [ [[package]] name = "faer" -version = "0.24.1" +version = "0.24.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eda5a09c282d3b6f73881446d031a77e36fbf1e3d67bdc5085149b7e6b3f631" +checksum = "5ab6df3dd147fe8d702a288b95bcd8fcc499ab572fc80da6828f60cd4d524d67" dependencies = [ "bytemuck", "dyn-stack", @@ -877,9 +877,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] @@ -952,9 +952,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.102" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", @@ -1084,9 +1084,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c863e9ab5e7bf9c99ba75e1050f1e4d624ae87ed3532d6238ffbdc7b585dbbe6" dependencies = [ "num-integer", "num-traits", @@ -1369,9 +1369,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -1525,9 +1525,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "log", "once_cell", @@ -1540,9 +1540,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "zeroize", ] @@ -2076,9 +2076,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -2089,9 +2089,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2099,9 +2099,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -2112,18 +2112,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.102" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", From b47ee8b855b6ed04f655c04687df53bd743f7ff3 Mon Sep 17 00:00:00 2001 From: VPRamon Date: Fri, 3 Jul 2026 23:02:56 +0200 Subject: [PATCH 4/6] Reuse canonical qtty types for Gaia DR3 astrometry and photometry. Replace Gaia-local unit wrappers with qtty JulianYears, angular rates, and astro::ProperMotion, and align proper-motion propagation with Julian-year catalogue conventions. Use qtty velocity::C for photon-flux integration instead of a local speed-of-light constant. --- siderust-ffi/src/target.rs | 22 ++++---- src/astro/proper_motion.rs | 68 ++++++++++++----------- src/bodies/stars.rs | 13 ++--- src/catalogs/gaia/dr3.rs | 96 +++++++++++---------------------- src/catalogs/gaia/mod.rs | 89 ++++++++++++++++++++++++++---- src/catalogs/gaia/photometry.rs | 8 +-- src/targets/target.rs | 18 ++++--- 7 files changed, 178 insertions(+), 136 deletions(-) diff --git a/siderust-ffi/src/target.rs b/siderust-ffi/src/target.rs index a1dc157e..b89f0121 100644 --- a/siderust-ffi/src/target.rs +++ b/siderust-ffi/src/target.rs @@ -11,7 +11,7 @@ use crate::error::SiderustStatus; use crate::types::*; use qtty::angular::Degrees; -use qtty::unit::{Degree, Year}; +use qtty::unit::{Degree, JulianYear}; use qtty::*; use siderust::astro::proper_motion::ProperMotion; use siderust::coordinates::frames::{ @@ -23,9 +23,9 @@ use siderust::targets::CoordinateWithPM; use siderust::time::JulianDate; /// Unit type alias for degrees per Julian year. -type DegreePerYear = qtty::Per; +type DegreePerJulianYear = qtty::Per; /// Quantity alias for degrees per Julian year. -type DegreesPerYear = qtty::Quantity; +type DegreesPerJulianYear = qtty::Quantity; // ═══════════════════════════════════════════════════════════════════════════ // Generic Target Handle (CoordinateWithPM) @@ -60,14 +60,16 @@ fn proper_motion_from_ffi(data: &SiderustGenericTargetData) -> Option ProperMotion::from_mu_alpha::( - DegreesPerYear::new(data.proper_motion.pm_ra_deg_yr), - DegreesPerYear::new(data.proper_motion.pm_dec_deg_yr), - ), - SiderustRaConvention::MuAlphaStar => ProperMotion::from_mu_alpha_star::( - DegreesPerYear::new(data.proper_motion.pm_ra_deg_yr), - DegreesPerYear::new(data.proper_motion.pm_dec_deg_yr), + SiderustRaConvention::MuAlpha => ProperMotion::from_mu_alpha::( + DegreesPerJulianYear::new(data.proper_motion.pm_ra_deg_yr), + DegreesPerJulianYear::new(data.proper_motion.pm_dec_deg_yr), ), + SiderustRaConvention::MuAlphaStar => { + ProperMotion::from_mu_alpha_star::( + DegreesPerJulianYear::new(data.proper_motion.pm_ra_deg_yr), + DegreesPerJulianYear::new(data.proper_motion.pm_dec_deg_yr), + ) + } }) } diff --git a/src/astro/proper_motion.rs b/src/astro/proper_motion.rs index 5a3f9bf3..9cf35c8a 100644 --- a/src/astro/proper_motion.rs +++ b/src/astro/proper_motion.rs @@ -21,7 +21,7 @@ //! ## Technical scope //! //! [`ProperMotion`] stores the right-ascension and declination rates as -//! typed `DegreesPerYear` quantities together with a +//! typed `DegreesPerJulianYear` quantities together with a //! [`RaProperMotionConvention`] flag distinguishing the true RA rate `µα` //! from the catalogue rate `µα⋆ = µα cos(δ)`. Helpers //! [`ProperMotion::from_mu_alpha`] and [`ProperMotion::from_mu_alpha_star`] @@ -46,8 +46,8 @@ use std::fmt; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; -type DegreePerYear = crate::qtty::Per; -type DegreesPerYear = crate::qtty::angular_rate::AngularRate; +type DegreePerJulianYear = crate::qtty::Per; +type DegreesPerJulianYear = crate::qtty::angular_rate::AngularRate; const COS_DEC_EPSILON: f64 = 1.0e-12; @@ -55,9 +55,9 @@ const COS_DEC_EPSILON: f64 = 1.0e-12; #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub enum RaProperMotionConvention { - /// True RA angular rate `µα` in deg/year. + /// True RA angular rate `µα` in deg/Julian year. MuAlpha, - /// Catalog rate `µα⋆ = µα cos(δ)` in deg/year. + /// Catalog rate `µα⋆ = µα cos(δ)` in deg/Julian year. MuAlphaStar, } @@ -82,9 +82,9 @@ impl std::fmt::Display for RaProperMotionConvention { pub struct ProperMotion { /// Right-ascension proper motion in degrees per Julian year. /// Interpretation (µα or µα⋆) is given by `ra_convention`. - pub pm_ra: DegreesPerYear, + pub pm_ra: DegreesPerJulianYear, /// Declination proper motion in degrees per Julian year. - pub pm_dec: DegreesPerYear, + pub pm_dec: DegreesPerJulianYear, /// States whether `pm_ra` is the true RA rate µα or the catalogue /// rate µα⋆ = µα cos(δ). pub ra_convention: RaProperMotionConvention, @@ -127,9 +127,9 @@ impl std::error::Error for ProperMotionError {} impl RaProperMotionConvention { fn to_mu_alpha( self, - pm_ra: DegreesPerYear, + pm_ra: DegreesPerJulianYear, dec: Degrees, - ) -> Result { + ) -> Result { match self { RaProperMotionConvention::MuAlpha => Ok(pm_ra), RaProperMotionConvention::MuAlphaStar => { @@ -150,8 +150,8 @@ impl ProperMotion { T: AngularRateUnit, { Self { - pm_ra: pm_ra.to::(), - pm_dec: pm_dec.to::(), + pm_ra: pm_ra.to::(), + pm_dec: pm_dec.to::(), ra_convention: RaProperMotionConvention::MuAlpha, } } @@ -162,13 +162,13 @@ impl ProperMotion { T: AngularRateUnit, { Self { - pm_ra: pm_ra_cosdec.to::(), - pm_dec: pm_dec.to::(), + pm_ra: pm_ra_cosdec.to::(), + pm_dec: pm_dec.to::(), ra_convention: RaProperMotionConvention::MuAlphaStar, } } - fn ra_rate_at_epoch(&self, dec: Degrees) -> Result { + fn ra_rate_at_epoch(&self, dec: Degrees) -> Result { self.ra_convention.to_mu_alpha(self.pm_ra, dec) } } @@ -177,7 +177,7 @@ impl ProperMotion { /// /// # Arguments /// - `mean_position`: the reference position at epoch `epoch_jd` -/// - `proper_motion`: angular velocity in RA and DEC (degrees/year) +/// - `proper_motion`: angular velocity in RA and DEC (degrees/Julian year) /// - `jd`: the target Julian Day (epoch at which we want the new position) /// - `epoch_jd`: the epoch of the original mean position /// @@ -194,7 +194,7 @@ fn set_proper_motion_since_epoch( ) -> Result, ProperMotionError> { // Time difference in Julian years (365.25 d), matching the convention used // in proper-motion catalogs (Hipparcos, Gaia) and propagate_space_motion. - let t: Years = Years::new((jd.raw() - epoch_jd.raw()).value() / 365.25); + let t: JulianYears = JulianYears::new((jd.raw() - epoch_jd.raw()).value() / 365.25); let ra_rate = proper_motion.ra_rate_at_epoch(mean_position.dec())?; // Linearly apply proper motion in RA and DEC Ok(position::EquatorialMeanJ2000::::new( @@ -208,7 +208,7 @@ fn set_proper_motion_since_epoch( /// /// # Arguments /// - `mean_position`: star's catalogued position at J2000.0 -/// - `proper_motion`: proper motion in RA/DEC (deg/year) +/// - `proper_motion`: proper motion in RA/DEC (deg/Julian year) /// - `jd`: target Julian Day for evaluation /// /// # Returns @@ -243,9 +243,9 @@ pub fn set_proper_motion_since_j2000( #[derive(Debug, Clone, Copy)] pub struct StarSpaceMotion { /// Right-ascension proper motion in the catalog convention `μα⋆ = μα·cos(δ)`. - pub pm_ra_cos_dec: crate::qtty::angular_rate::AngularRate, + pub pm_ra_cos_dec: crate::qtty::angular_rate::AngularRate, /// Declination proper motion `μδ`. - pub pm_dec: crate::qtty::angular_rate::AngularRate, + pub pm_dec: crate::qtty::angular_rate::AngularRate, /// Annual trigonometric parallax (positive). pub parallax: MilliArcseconds, /// Radial velocity, positive away from the Solar System. @@ -325,8 +325,12 @@ pub fn propagate_space_motion( // Angular rates → linear transverse velocity in AU/yr. // μα⋆ already includes cos(δ); divide once to get true μα, then scale by // r·cos(δ) to recover the AU/yr coefficient on e_alpha. - let pm_ra_rad_yr = motion.pm_ra_cos_dec.to::>().value() / cos_dec; - let pm_dec_rad_yr = motion.pm_dec.to::>().value(); + let pm_ra_rad_yr = motion + .pm_ra_cos_dec + .to::>() + .value() + / cos_dec; + let pm_dec_rad_yr = motion.pm_dec.to::>().value(); let v_alpha_au_yr = pm_ra_rad_yr * r_au * cos_d; let v_delta_au_yr = pm_dec_rad_yr * r_au; @@ -334,7 +338,7 @@ pub fn propagate_space_motion( // Radial velocity in AU/yr (positive = away). let v_r_au_yr = motion .radial_velocity - .to::>() + .to::>() .value(); // BCRS velocity vector in AU/yr. @@ -392,7 +396,7 @@ mod tests { }; use crate::qtty::{AstronomicalUnit, Degrees}; - type DegreesPerYear = crate::qtty::Quantity>; + type DegreesPerJulianYear = crate::qtty::Quantity>; #[test] fn test_proper_motion_linear_shift_mu_alpha() { @@ -404,9 +408,9 @@ mod tests { ); // Proper motion: 0.01°/year in RA, -0.005°/year in DEC - let mu = ProperMotion::from_mu_alpha::( - DegreesPerYear::new(0.01), - DegreesPerYear::new(-0.005), + let mu = ProperMotion::from_mu_alpha::( + DegreesPerJulianYear::new(0.01), + DegreesPerJulianYear::new(-0.005), ); // Target epoch: 50 years after J2000 @@ -445,9 +449,9 @@ mod tests { ); // µα = 0.01 deg/yr, so µα⋆ = µα cos(dec) = 0.005 deg/yr. - let mu = ProperMotion::from_mu_alpha_star::( - DegreesPerYear::new(0.005), - DegreesPerYear::new(0.0), + let mu = ProperMotion::from_mu_alpha_star::( + DegreesPerJulianYear::new(0.005), + DegreesPerJulianYear::new(0.0), ); let jy = JULIAN_YEAR.to::(); @@ -471,9 +475,9 @@ mod tests { Degrees::new(90.0), 1.0, ); - let mu = ProperMotion::from_mu_alpha_star::( - DegreesPerYear::new(0.01), - DegreesPerYear::new(0.0), + let mu = ProperMotion::from_mu_alpha_star::( + DegreesPerJulianYear::new(0.01), + DegreesPerJulianYear::new(0.0), ); let jy = JULIAN_YEAR.to::(); let jd_future = crate::time::JulianDate::new((crate::J2000.raw() + jy).value()); diff --git a/src/bodies/stars.rs b/src/bodies/stars.rs index 28141e2d..2d45a1d0 100644 --- a/src/bodies/stars.rs +++ b/src/bodies/stars.rs @@ -251,8 +251,9 @@ mod tests { use crate::targets::Trackable; use crate::J2000; - type DegreesPerYear = - crate::qtty::Quantity>; + type DegreesPerJulianYear = crate::qtty::Quantity< + crate::qtty::Per, + >; fn make_pos() -> position::EquatorialMeanJ2000 { // new_unchecked(polar=dec, azimuth=ra, distance) @@ -307,8 +308,8 @@ mod tests { #[test] fn has_full_space_motion_true_with_all_fields() { let pm = ProperMotion { - pm_ra: DegreesPerYear::new(1e-6), - pm_dec: DegreesPerYear::new(1e-6), + pm_ra: DegreesPerJulianYear::new(1e-6), + pm_dec: DegreesPerJulianYear::new(1e-6), ra_convention: RaProperMotionConvention::MuAlphaStar, }; let coord = CoordinateWithPM::new(make_pos(), J2000, pm); @@ -352,8 +353,8 @@ mod tests { #[test] fn position_at_with_pm_only_returns_ok() { let pm = ProperMotion { - pm_ra: DegreesPerYear::new(1e-6), - pm_dec: DegreesPerYear::new(1e-6), + pm_ra: DegreesPerJulianYear::new(1e-6), + pm_dec: DegreesPerJulianYear::new(1e-6), ra_convention: RaProperMotionConvention::MuAlphaStar, }; let coord = CoordinateWithPM::new(make_pos(), J2000, pm); diff --git a/src/catalogs/gaia/dr3.rs b/src/catalogs/gaia/dr3.rs index 37f9eb49..1710c63c 100644 --- a/src/catalogs/gaia/dr3.rs +++ b/src/catalogs/gaia/dr3.rs @@ -7,8 +7,14 @@ use super::error::{GaiaDr3Error, Result}; use super::photometry::{PhotonFlux, StellarPhotometryModel}; use super::quality::GaiaDr3QualityFlags; use super::raw::GaiaDr3RawSourceRow; -use crate::qtty::{Degrees, KmPerSeconds, MilliArcseconds}; -use crate::time::{JulianDate, J2000}; +use crate::astro::proper_motion::ProperMotion; +use crate::qtty::{ + angular_rate::AngularRate, unit, Degrees, JulianYears, KmPerSeconds, MilliArcsecond, + MilliArcseconds, +}; + +/// Gaia proper-motion rate in milliarcseconds per Julian year. +type GaiaProperMotionRate = AngularRate; /// Gaia source identifier. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -26,72 +32,17 @@ impl GaiaSourceId { } } -/// Julian-year epoch. -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] -pub struct JulianYear(f64); - -impl JulianYear { - /// Build a finite Julian-year epoch. - pub fn new(value: f64) -> Result { - ensure_finite("ref_epoch_jyr", value)?; - Ok(Self(value)) - } - - /// Return the epoch in Julian years. - pub const fn value(self) -> f64 { - self.0 - } - - /// Convert the Julian year to a TT Julian Date using 365.25-day years from J2000.0. - pub fn to_julian_date(self) -> JulianDate { - JulianDate::new(J2000.raw().value() + (self.0 - 2000.0) * 365.25) - } -} - -/// Proper-motion component in milliarcseconds per Julian year. -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] -pub struct MilliArcsecondsPerYear(f64); - -impl MilliArcsecondsPerYear { - /// Build a finite proper-motion component. - pub fn new(value: f64) -> Result { - ensure_finite("proper_motion_mas_per_yr", value)?; - Ok(Self(value)) - } - - /// Return the component in mas/yr. - pub const fn value(self) -> f64 { - self.0 - } -} - -/// Gaia proper motion, including the catalogue `pmra = dRA/dt * cos(dec)`. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct ProperMotion { - /// Proper motion in right ascension times cos(dec), mas/yr. - pub pmra_cosdec: MilliArcsecondsPerYear, - /// Proper motion in declination, mas/yr. - pub pmdec: MilliArcsecondsPerYear, -} - -impl ProperMotion { - /// Build a Gaia proper-motion pair. - pub fn new(pmra_mas_per_yr: f64, pmdec_mas_per_yr: f64) -> Result { - Ok(Self { - pmra_cosdec: MilliArcsecondsPerYear::new(pmra_mas_per_yr)?, - pmdec: MilliArcsecondsPerYear::new(pmdec_mas_per_yr)?, - }) - } -} - /// Validated Gaia DR3 astrometry. #[derive(Debug, Clone)] pub struct GaiaDr3Astrometry { /// ICRS spherical direction at [`Self::epoch`]. pub direction: crate::coordinates::spherical::direction::ICRS, - /// Gaia reference epoch. - pub epoch: JulianYear, + /// Gaia reference epoch in Julian years (`ref_epoch_jyr`). + pub epoch: JulianYears, /// Proper motion, when both Gaia components are present. + /// + /// Gaia publishes `pmra` as `µα⋆ = µα cos(δ)` and `pmdec` as `µδ`, both as + /// angular rates per Julian year. pub proper_motion: Option, /// Annual trigonometric parallax. pub parallax: Option, @@ -137,7 +88,10 @@ impl TryFrom for GaiaDr3Source { } let proper_motion = match (row.pmra_mas_per_yr, row.pmdec_mas_per_yr) { - (Some(pmra), Some(pmdec)) => Some(ProperMotion::new(pmra, pmdec)?), + (Some(pmra), Some(pmdec)) => Some(ProperMotion::from_mu_alpha_star( + gaia_proper_motion_rate("pmra", pmra)?, + gaia_proper_motion_rate("pmdec", pmdec)?, + )), (None, None) => None, (Some(_), None) => return Err(GaiaDr3Error::MissingRequiredField { field: "pmdec" }), (None, Some(_)) => return Err(GaiaDr3Error::MissingRequiredField { field: "pmra" }), @@ -157,7 +111,7 @@ impl TryFrom for GaiaDr3Source { >::new( Degrees::new(row.ra_deg), Degrees::new(row.dec_deg) ), - epoch: JulianYear::new(row.ref_epoch_jyr)?, + epoch: julian_year_epoch("ref_epoch_jyr", row.ref_epoch_jyr)?, proper_motion, parallax: validate_optional_finite("parallax_mas", row.parallax_mas)? .map(MilliArcseconds::new), @@ -180,8 +134,8 @@ pub struct PassbandIntegratedStellarSource { pub source_id: u64, /// ICRS direction of the source. pub direction: crate::coordinates::spherical::direction::ICRS, - /// Source reference epoch. - pub epoch: JulianYear, + /// Source reference epoch in Julian years. + pub epoch: JulianYears, /// Passband-integrated photon flux. pub photon_flux: PhotonFlux, /// Photometric model used to produce [`Self::photon_flux`]. @@ -205,6 +159,16 @@ impl PassbandIntegratedStellarSource { } } +fn julian_year_epoch(field: &'static str, value: f64) -> Result { + ensure_finite(field, value)?; + Ok(JulianYears::new(value)) +} + +fn gaia_proper_motion_rate(field: &'static str, value: f64) -> Result { + ensure_finite(field, value)?; + Ok(GaiaProperMotionRate::new(value)) +} + fn validate_optional_finite(field: &'static str, value: Option) -> Result> { if let Some(value) = value { ensure_finite(field, value)?; diff --git a/src/catalogs/gaia/mod.rs b/src/catalogs/gaia/mod.rs index 7ecc757e..c33656ad 100644 --- a/src/catalogs/gaia/mod.rs +++ b/src/catalogs/gaia/mod.rs @@ -14,8 +14,8 @@ pub mod quality; pub mod raw; pub use dr3::{ - GaiaDr3Astrometry, GaiaDr3Photometry, GaiaDr3Source, GaiaSourceId, JulianYear, - MilliArcsecondsPerYear, PassbandIntegratedStellarSource, ProperMotion, + GaiaDr3Astrometry, GaiaDr3Photometry, GaiaDr3Source, GaiaSourceId, + PassbandIntegratedStellarSource, }; pub use error::{GaiaDr3Error, Result}; pub use photometry::{ @@ -29,6 +29,11 @@ pub use raw::{parse_gaia_dr3_csv_chunk, GaiaDr3RawSourceRow}; #[cfg(test)] mod tests { use super::*; + use crate::astro::proper_motion::{ProperMotion, RaProperMotionConvention}; + use crate::qtty::{angular_rate::AngularRate, unit, MilliArcsecond}; + + type GaiaProperMotionRate = AngularRate; + type DegreesPerJulianYear = AngularRate; fn raw_row() -> GaiaDr3RawSourceRow { GaiaDr3RawSourceRow { @@ -53,15 +58,12 @@ mod tests { assert_eq!(source.source_id.value(), 42); assert_eq!(source.astrometry.epoch.value(), 2016.0); - assert_eq!( - source - .astrometry - .proper_motion - .expect("proper motion") - .pmra_cosdec - .value(), - 1.5 - ); + let pm = source.astrometry.proper_motion.expect("proper motion"); + assert_eq!(pm.ra_convention, RaProperMotionConvention::MuAlphaStar); + let pmra_mas: GaiaProperMotionRate = pm.pm_ra.to(); + let pmdec_mas: GaiaProperMotionRate = pm.pm_dec.to(); + assert!((pmra_mas.value() - 1.5).abs() < 1e-12); + assert!((pmdec_mas.value() + 2.5).abs() < 1e-12); assert_eq!(source.astrometry.parallax.expect("parallax").value(), 3.0); assert_eq!( source @@ -105,6 +107,71 @@ mod tests { let _: crate::coordinates::spherical::direction::ICRS = source.astrometry.direction; } + #[test] + fn ref_epoch_jyr_converts_to_julian_years() { + let source = GaiaDr3Source::try_from(raw_row()).expect("typed source"); + + assert_eq!(source.astrometry.epoch.value(), 2016.0); + } + + #[test] + fn non_finite_ref_epoch_jyr_is_rejected() { + let mut row = raw_row(); + row.ref_epoch_jyr = f64::NAN; + assert!(matches!( + GaiaDr3Source::try_from(row), + Err(GaiaDr3Error::NonFinite { + field: "ref_epoch_jyr", + .. + }) + )); + } + + #[test] + fn proper_motion_uses_mu_alpha_star_convention() { + let source = GaiaDr3Source::try_from(raw_row()).expect("typed source"); + let pm = source.astrometry.proper_motion.expect("proper motion"); + + assert_eq!(pm.ra_convention, RaProperMotionConvention::MuAlphaStar); + } + + #[test] + fn missing_one_proper_motion_component_is_rejected() { + let mut row = raw_row(); + row.pmdec_mas_per_yr = None; + assert!(matches!( + GaiaDr3Source::try_from(row), + Err(GaiaDr3Error::MissingRequiredField { field: "pmdec" }) + )); + + let mut row = raw_row(); + row.pmra_mas_per_yr = None; + assert!(matches!( + GaiaDr3Source::try_from(row), + Err(GaiaDr3Error::MissingRequiredField { field: "pmra" }) + )); + } + + #[test] + fn gaia_proper_motion_rate_converts_to_degrees_per_julian_year() { + let rate: GaiaProperMotionRate = GaiaProperMotionRate::new(3_600_000.0); + let deg_per_jy: DegreesPerJulianYear = rate.to(); + assert!((deg_per_jy.value() - 1.0).abs() < 1e-12); + } + + #[test] + fn finite_pmra_pmdec_build_domain_proper_motion() { + let pm = ProperMotion::from_mu_alpha_star( + GaiaProperMotionRate::new(1.5), + GaiaProperMotionRate::new(-2.5), + ); + assert_eq!(pm.ra_convention, RaProperMotionConvention::MuAlphaStar); + let pmra_mas: GaiaProperMotionRate = pm.pm_ra.to(); + let pmdec_mas: GaiaProperMotionRate = pm.pm_dec.to(); + assert!((pmra_mas.value() - 1.5).abs() < 1e-12); + assert!((pmdec_mas.value() + 2.5).abs() < 1e-12); + } + #[test] fn epoch_is_preserved() { let source = GaiaDr3Source::try_from(raw_row()).expect("typed source"); diff --git a/src/catalogs/gaia/photometry.rs b/src/catalogs/gaia/photometry.rs index 7d87c2cb..683d29ce 100644 --- a/src/catalogs/gaia/photometry.rs +++ b/src/catalogs/gaia/photometry.rs @@ -8,9 +8,9 @@ //! returns integrated photon flux in `photons m^-2 s^-1`. use super::error::{GaiaDr3Error, Result}; +use crate::qtty::velocity::C; const PLANCK_J_S: f64 = 6.626_070_15e-34; -const SPEED_OF_LIGHT_M_S: f64 = 299_792_458.0; /// Wavelength in nanometers. #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] @@ -208,6 +208,8 @@ pub fn integrate_photon_flux( ) -> Result { band.validate()?; + let c_m_s = C.value(); + let min_nm = band.min_wavelength.value(); let max_nm = band.max_wavelength.value(); let mut wavelengths = Vec::with_capacity(spectrum.samples.len() + 2); @@ -239,8 +241,8 @@ pub fn integrate_photon_flux( .ok_or(GaiaDr3Error::InsufficientBandCoverage { name: band.name })?; let l0_m = l0_nm * 1.0e-9; let l1_m = l1_nm * 1.0e-9; - let y0 = (f0 * 1.0e9) * l0_m / (PLANCK_J_S * SPEED_OF_LIGHT_M_S); - let y1 = (f1 * 1.0e9) * l1_m / (PLANCK_J_S * SPEED_OF_LIGHT_M_S); + let y0 = (f0 * 1.0e9) * l0_m / (PLANCK_J_S * c_m_s); + let y1 = (f1 * 1.0e9) * l1_m / (PLANCK_J_S * c_m_s); total += 0.5 * (y0 + y1) * (l1_m - l0_m); } diff --git a/src/targets/target.rs b/src/targets/target.rs index 53309452..cb6a7b72 100644 --- a/src/targets/target.rs +++ b/src/targets/target.rs @@ -137,8 +137,10 @@ mod tests { type MilliArcsecondPerDay = crate::qtty::Per; type MilliArcsecondsPerDay = crate::qtty::Quantity; - type DegreesPerYear = - crate::qtty::angular_rate::AngularRate; + type DegreesPerJulianYear = crate::qtty::angular_rate::AngularRate< + crate::qtty::unit::Degree, + crate::qtty::unit::JulianYear, + >; #[test] fn test_target_new() { @@ -249,8 +251,8 @@ mod tests { let retrieved_pm = target.get_proper_motion(); assert!(retrieved_pm.is_some()); if let Some(pm) = retrieved_pm { - assert_eq!(pm.pm_ra, DegreesPerYear::new(0.0020291249999999997)); - assert_eq!(pm.pm_dec, DegreesPerYear::new(0.001217475)); + assert_eq!(pm.pm_ra, DegreesPerJulianYear::new(0.002029166666666667)); + assert_eq!(pm.pm_dec, DegreesPerJulianYear::new(0.0012175)); assert_eq!(pm.ra_convention, RaProperMotionConvention::MuAlphaStar); } @@ -307,8 +309,8 @@ mod tests { // Check that proper motion was preserved assert!(target.proper_motion.is_some()); if let Some(pm) = target.get_proper_motion() { - assert_eq!(pm.pm_ra, DegreesPerYear::new(0.0025364062499999996)); - assert_eq!(pm.pm_dec, DegreesPerYear::new(0.0015218437499999998)); + assert_eq!(pm.pm_ra, DegreesPerJulianYear::new(0.0025364583333333333)); + assert_eq!(pm.pm_dec, DegreesPerJulianYear::new(0.001521875)); assert_eq!(pm.ra_convention, RaProperMotionConvention::MuAlphaStar); } } @@ -395,8 +397,8 @@ mod tests { assert!(target.proper_motion.is_some()); if let Some(pm) = target.get_proper_motion() { - assert_eq!(pm.pm_ra, DegreesPerYear::new(0.0)); - assert_eq!(pm.pm_dec, DegreesPerYear::new(0.0)); + assert_eq!(pm.pm_ra, DegreesPerJulianYear::new(0.0)); + assert_eq!(pm.pm_dec, DegreesPerJulianYear::new(0.0)); assert_eq!(pm.ra_convention, RaProperMotionConvention::MuAlphaStar); } } From 96c647bd3730062591d3c01b46f41afb76f946e3 Mon Sep 17 00:00:00 2001 From: VPRamon Date: Fri, 3 Jul 2026 23:10:59 +0200 Subject: [PATCH 5/6] Use qtty Nanometers in Gaia photometry instead of a local wrapper. Replace the Gaia-specific Nanometers newtype with crate::qtty::Nanometers while keeping catalogue validation in a small helper. --- src/catalogs/gaia/mod.rs | 6 ++--- src/catalogs/gaia/photometry.rs | 48 +++++++++------------------------ 2 files changed, 16 insertions(+), 38 deletions(-) diff --git a/src/catalogs/gaia/mod.rs b/src/catalogs/gaia/mod.rs index c33656ad..78b64b26 100644 --- a/src/catalogs/gaia/mod.rs +++ b/src/catalogs/gaia/mod.rs @@ -13,15 +13,15 @@ pub mod photometry; pub mod quality; pub mod raw; +pub use crate::qtty::Nanometers; pub use dr3::{ GaiaDr3Astrometry, GaiaDr3Photometry, GaiaDr3Source, GaiaSourceId, PassbandIntegratedStellarSource, }; pub use error::{GaiaDr3Error, Result}; pub use photometry::{ - integrate_photon_flux, GaiaXpSampledSpectrum, Nanometers, PhotonFlux, SpectralBand, - SpectralFluxDensity, SpectralFluxSample, StellarPhotometryModel, - GAIA_XP_STELLAR_RADIANCE_330_650_NM, + integrate_photon_flux, GaiaXpSampledSpectrum, PhotonFlux, SpectralBand, SpectralFluxDensity, + SpectralFluxSample, StellarPhotometryModel, GAIA_XP_STELLAR_RADIANCE_330_650_NM, }; pub use quality::GaiaDr3QualityFlags; pub use raw::{parse_gaia_dr3_csv_chunk, GaiaDr3RawSourceRow}; diff --git a/src/catalogs/gaia/photometry.rs b/src/catalogs/gaia/photometry.rs index 683d29ce..a8e39740 100644 --- a/src/catalogs/gaia/photometry.rs +++ b/src/catalogs/gaia/photometry.rs @@ -9,40 +9,18 @@ use super::error::{GaiaDr3Error, Result}; use crate::qtty::velocity::C; +use crate::qtty::Nanometers; const PLANCK_J_S: f64 = 6.626_070_15e-34; -/// Wavelength in nanometers. -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] -pub struct Nanometers(f64); - -impl Nanometers { - /// Build a positive finite wavelength. - pub fn new(value: f64) -> Result { - if !value.is_finite() { - return Err(GaiaDr3Error::NonFinite { - field: "wavelength_nm", - value, - }); - } - if value <= 0.0 { - return Err(GaiaDr3Error::NotPositive { - field: "wavelength_nm", - value, - }); - } - Ok(Self(value)) +fn wavelength_nm(field: &'static str, value: f64) -> Result { + if !value.is_finite() { + return Err(GaiaDr3Error::NonFinite { field, value }); } - - /// Return the wavelength in nanometers. - pub const fn value(self) -> f64 { - self.0 - } - - /// Return the wavelength in meters. - pub fn meters(self) -> f64 { - self.0 * 1.0e-9 + if value <= 0.0 { + return Err(GaiaDr3Error::NotPositive { field, value }); } + Ok(Nanometers::new(value)) } /// Spectral flux density in `W m^-2 nm^-1`. @@ -122,8 +100,8 @@ impl SpectralBand { pub fn new(name: &'static str, min_wavelength_nm: f64, max_wavelength_nm: f64) -> Result { let band = Self { name, - min_wavelength: Nanometers::new(min_wavelength_nm)?, - max_wavelength: Nanometers::new(max_wavelength_nm)?, + min_wavelength: wavelength_nm("min_wavelength_nm", min_wavelength_nm)?, + max_wavelength: wavelength_nm("max_wavelength_nm", max_wavelength_nm)?, }; band.validate()?; Ok(band) @@ -140,8 +118,8 @@ impl SpectralBand { /// Gaia XP starlight production band, 330-650 nm. pub const GAIA_XP_STELLAR_RADIANCE_330_650_NM: SpectralBand = SpectralBand { name: "Gaia XP stellar radiance 330-650 nm", - min_wavelength: Nanometers(330.0), - max_wavelength: Nanometers(650.0), + min_wavelength: Nanometers::new(330.0), + max_wavelength: Nanometers::new(650.0), }; /// One sampled spectral-flux-density point. @@ -155,9 +133,9 @@ pub struct SpectralFluxSample { impl SpectralFluxSample { /// Build a validated sample from `nm` and `W m^-2 nm^-1`. - pub fn new(wavelength_nm: f64, flux_density_w_m2_nm: f64) -> Result { + pub fn new(wavelength_nm_value: f64, flux_density_w_m2_nm: f64) -> Result { Ok(Self { - wavelength: Nanometers::new(wavelength_nm)?, + wavelength: wavelength_nm("wavelength_nm", wavelength_nm_value)?, flux_density: SpectralFluxDensity::new_w_m2_nm(flux_density_w_m2_nm)?, }) } From 8a8d1cb9036fcc4f918ea9c77efbb0dad0348d28 Mon Sep 17 00:00:00 2001 From: VPRamon Date: Fri, 3 Jul 2026 23:12:31 +0200 Subject: [PATCH 6/6] Bump version to 0.11.0 for release. Promote the unreleased changelog and align workspace crate versions in Cargo.toml and Cargo.lock. --- CHANGELOG.md | 9 +++++++++ Cargo.lock | 4 ++-- Cargo.toml | 2 +- siderust-ffi/Cargo.toml | 4 ++-- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e60c35..041d3605 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.0] - 2026-07-03 + ### Added - Add typed tabulated Cartesian ephemerides with cubic Hermite interpolation, @@ -37,6 +39,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Document the new HEALPix and starlight modules with scientific scope, technical scope, and primary references, following the workspace `missing_docs = "deny"` policy. +- Reuse canonical `qtty` Julian-year quantities, angular rates, and + `astro::proper_motion::ProperMotion` in Gaia DR3 astrometry instead of local + unit wrappers. +- Align proper-motion propagation and `StarSpaceMotion` with Julian-year catalogue + conventions (Gaia/Hipparcos). +- Reuse `qtty::velocity::C` and `qtty::Nanometers` in Gaia photometry instead of + local physical-constant and wavelength wrappers. ## [0.10.1] - 2026-06-20 diff --git a/Cargo.lock b/Cargo.lock index 3f97a720..18e6173f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1665,7 +1665,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "siderust" -version = "0.10.1" +version = "0.11.0" dependencies = [ "affn", "approx", @@ -1710,7 +1710,7 @@ dependencies = [ [[package]] name = "siderust-ffi" -version = "0.10.1" +version = "0.11.0" dependencies = [ "affn", "cbindgen", diff --git a/Cargo.toml b/Cargo.toml index d915b689..20afb49b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ broken_intra_doc_links = "deny" [package] name = "siderust" -version = "0.10.1" +version = "0.11.0" edition = "2021" authors = ["VPRamon "] description = "High-precision astronomy and satellite mechanics in Rust." diff --git a/siderust-ffi/Cargo.toml b/siderust-ffi/Cargo.toml index 5b428596..c772856e 100644 --- a/siderust-ffi/Cargo.toml +++ b/siderust-ffi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "siderust-ffi" -version = "0.10.1" +version = "0.11.0" edition = "2021" authors = ["VPRamon "] description = "C FFI bindings for siderust, high-precision astronomy library." @@ -19,7 +19,7 @@ runtime-data = ["siderust/runtime-data", "dep:siderust-archive", "siderust-archi [dependencies] -siderust = { version = "0.10.1", path = ".." } +siderust = { version = "0.11.0", path = ".." } siderust-archive = { version = "0.1", optional = true, features = ["jpl"] } keplerian = "0.2" principia = { version = "0.2", features = ["alloc"] }