use std::f64::consts::PI;
use nalgebra::UnitQuaternion;
use starfield::Equatorial;
use starfield::time::Time;
use crate::geom::sphere::xyz_to_radec;
use crate::refinement::ObserverState;
use crate::solver::SkyRegion;
pub trait PointingSource: Send + Sync {
fn current_region(&self, t: &Time) -> SkyRegion;
fn observer_state(&self, _t: &Time) -> Option<ObserverState> {
None
}
}
#[derive(Debug, Clone, Copy)]
pub struct BcrsState {
pub position_au: [f64; 3],
pub velocity_au_per_day: [f64; 3],
}
pub trait EphemerisSource: Send + Sync {
fn state_at(&self, t: &Time) -> Option<BcrsState>;
}
pub trait AttitudeSource: Send + Sync {
fn quaternion_at(&self, t: &Time) -> Option<UnitQuaternion<f64>>;
}
pub struct SpacecraftBoresight<E: EphemerisSource, A: AttitudeSource> {
pub ephemeris: E,
pub attitude: A,
pub detector_half_angle_rad: f64,
pub fov_padding_rad: f64,
pub boresight_body: [f64; 3],
}
impl<E: EphemerisSource, A: AttitudeSource> SpacecraftBoresight<E, A> {
pub fn new(
ephemeris: E,
attitude: A,
detector_half_angle_rad: f64,
fov_padding_rad: f64,
) -> Self {
Self {
ephemeris,
attitude,
detector_half_angle_rad,
fov_padding_rad,
boresight_body: [0.0, 0.0, 1.0],
}
}
pub fn with_boresight(mut self, body: [f64; 3]) -> Self {
let n = (body[0] * body[0] + body[1] * body[1] + body[2] * body[2]).sqrt();
debug_assert!(n > 0.0, "boresight_body must be a non-zero vector");
let inv = if n > 0.0 { 1.0 / n } else { 1.0 };
self.boresight_body = [body[0] * inv, body[1] * inv, body[2] * inv];
self
}
}
impl<E: EphemerisSource, A: AttitudeSource> PointingSource for SpacecraftBoresight<E, A> {
fn current_region(&self, t: &Time) -> SkyRegion {
let q = match self.attitude.quaternion_at(t) {
Some(q) => q,
None => {
return SkyRegion::from_radians(Equatorial::new(0.0, 0.0), PI);
}
};
let body = nalgebra::Vector3::new(
self.boresight_body[0],
self.boresight_body[1],
self.boresight_body[2],
);
let inertial = q * body;
let (ra, dec) = xyz_to_radec([inertial.x, inertial.y, inertial.z]);
SkyRegion::from_radians(
Equatorial::new(ra, dec),
self.detector_half_angle_rad + self.fov_padding_rad,
)
}
fn observer_state(&self, t: &Time) -> Option<ObserverState> {
let s = self.ephemeris.state_at(t)?;
Some(ObserverState::Barycentric {
position_au: s.position_au,
velocity_au_per_day: s.velocity_au_per_day,
})
}
}
pub struct GroundStation {
pub latitude_rad: f64,
pub longitude_rad: f64,
pub min_altitude_rad: f64,
}
impl GroundStation {
pub fn from_degrees(latitude_deg: f64, longitude_deg: f64, min_altitude_deg: f64) -> Self {
debug_assert!(
(-90.0..=90.0).contains(&latitude_deg),
"latitude must be in -90..=90, got {latitude_deg}"
);
debug_assert!(
(0.0..=90.0).contains(&min_altitude_deg),
"min_altitude_deg must be in 0..=90, got {min_altitude_deg}"
);
Self {
latitude_rad: latitude_deg.to_radians().clamp(-PI / 2.0, PI / 2.0),
longitude_rad: longitude_deg.to_radians(),
min_altitude_rad: min_altitude_deg.to_radians().clamp(0.0, PI / 2.0),
}
}
pub fn zenith(&self, time: &Time) -> (f64, f64) {
let gast_hours = time.gast();
let lst_hours = (gast_hours + self.longitude_rad * 12.0 / PI).rem_euclid(24.0);
let zenith_ra = lst_hours * PI / 12.0;
let zenith_dec = self.latitude_rad;
(zenith_ra, zenith_dec)
}
}
impl PointingSource for GroundStation {
fn current_region(&self, t: &Time) -> SkyRegion {
let (ra, dec) = self.zenith(t);
let radius = (PI / 2.0) - self.min_altitude_rad;
SkyRegion::from_radians(Equatorial::new(ra, dec), radius)
}
}
pub struct StaticRegion(pub SkyRegion);
impl PointingSource for StaticRegion {
fn current_region(&self, _t: &Time) -> SkyRegion {
self.0.clone()
}
}
pub struct StaticAttitude(pub UnitQuaternion<f64>);
impl AttitudeSource for StaticAttitude {
fn quaternion_at(&self, _t: &Time) -> Option<UnitQuaternion<f64>> {
Some(self.0)
}
}
pub struct StaticEphemeris(pub BcrsState);
impl EphemerisSource for StaticEphemeris {
fn state_at(&self, _t: &Time) -> Option<BcrsState> {
Some(self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use nalgebra::Vector3;
use starfield::time::Timescale;
fn now() -> Time {
let ts = Timescale::default();
ts.tt_jd(2_460_000.5, None)
}
#[test]
fn static_region_returns_supplied_region() {
let sky = SkyRegion::from_radians(Equatorial::new(1.5, -0.3), 0.1);
let src = StaticRegion(sky.clone());
let returned = src.current_region(&now());
assert!((returned.center.ra - sky.center.ra).abs() < 1e-15);
assert!((returned.center.dec - sky.center.dec).abs() < 1e-15);
assert!((returned.radius_rad - sky.radius_rad).abs() < 1e-15);
}
#[test]
fn static_region_has_no_observer_state() {
let src = StaticRegion(SkyRegion::from_radians(Equatorial::new(0.0, 0.0), 0.1));
assert!(src.observer_state(&now()).is_none());
}
#[test]
fn ground_station_zenith_dec_equals_latitude() {
let station = GroundStation::from_degrees(34.0, -118.0, 20.0);
let (_ra, dec) = station.zenith(&now());
assert!((dec - 34.0_f64.to_radians()).abs() < 1e-12);
}
#[test]
fn ground_station_radius_matches_min_altitude() {
let station = GroundStation::from_degrees(0.0, 0.0, 30.0);
let region = station.current_region(&now());
let expected = (PI / 2.0) - 30.0_f64.to_radians();
assert!((region.radius_rad - expected).abs() < 1e-12);
}
#[test]
fn ground_station_lst_advances_with_time() {
let ts = Timescale::default();
let t0 = ts.tt_jd(2_460_000.5, None);
let t1 = ts.tt_jd(2_460_001.5 - 0.0027378, None); let station = GroundStation::from_degrees(0.0, 0.0, 30.0);
let (ra0, _) = station.zenith(&t0);
let (ra1, _) = station.zenith(&t1);
let tau = 2.0 * PI;
let raw = (ra0 - ra1).rem_euclid(tau);
let diff = raw.min(tau - raw);
assert!(diff < 0.05, "zenith RA wrap mismatch: {diff} rad");
}
#[test]
fn ground_station_longitude_shifts_zenith_ra_eastward() {
let ts = Timescale::default();
let t = ts.tt_jd(2_460_000.5, None);
let west = GroundStation::from_degrees(0.0, 0.0, 30.0);
let east = GroundStation::from_degrees(0.0, 90.0, 30.0);
let (ra_west, _) = west.zenith(&t);
let (ra_east, _) = east.zenith(&t);
let tau = 2.0 * PI;
let lead = (ra_east - ra_west).rem_euclid(tau);
assert!(
(lead - PI / 2.0).abs() < 1e-9,
"expected ~π/2 east lead, got {lead}"
);
}
#[test]
fn ground_station_observer_state_is_none_v1() {
let station = GroundStation::from_degrees(0.0, 0.0, 30.0);
assert!(station.observer_state(&now()).is_none());
}
#[test]
fn spacecraft_identity_quaternion_points_at_north_pole() {
let attitude = StaticAttitude(UnitQuaternion::identity());
let ephem = StaticEphemeris(BcrsState {
position_au: [1.0, 0.0, 0.0],
velocity_au_per_day: [0.0, 0.017, 0.0],
});
let sat = SpacecraftBoresight::new(ephem, attitude, 0.05, 0.05);
let region = sat.current_region(&now());
assert!((region.center.dec - PI / 2.0).abs() < 1e-12);
assert!((region.radius_rad - 0.10).abs() < 1e-12);
}
#[test]
fn spacecraft_quaternion_rotates_boresight() {
let q = UnitQuaternion::from_axis_angle(&Vector3::y_axis(), PI / 2.0);
let attitude = StaticAttitude(q);
let ephem = StaticEphemeris(BcrsState {
position_au: [0.0; 3],
velocity_au_per_day: [0.0; 3],
});
let sat = SpacecraftBoresight::new(ephem, attitude, 0.01, 0.0);
let region = sat.current_region(&now());
assert!(region.center.ra.abs() < 1e-12 || (region.center.ra - 2.0 * PI).abs() < 1e-12);
assert!(region.center.dec.abs() < 1e-12);
}
#[test]
fn spacecraft_quaternion_x_axis_90_takes_z_to_minus_y() {
let q = UnitQuaternion::from_axis_angle(&Vector3::x_axis(), PI / 2.0);
let attitude = StaticAttitude(q);
let ephem = StaticEphemeris(BcrsState {
position_au: [0.0; 3],
velocity_au_per_day: [0.0; 3],
});
let sat = SpacecraftBoresight::new(ephem, attitude, 0.01, 0.0);
let region = sat.current_region(&now());
let expected_ra = 3.0 * PI / 2.0;
assert!(
(region.center.ra - expected_ra).abs() < 1e-12,
"expected RA {expected_ra}, got {}",
region.center.ra
);
assert!(region.center.dec.abs() < 1e-12);
}
#[test]
fn spacecraft_with_boresight_normalizes_input() {
let q = UnitQuaternion::identity();
let attitude = StaticAttitude(q);
let ephem = StaticEphemeris(BcrsState {
position_au: [0.0; 3],
velocity_au_per_day: [0.0; 3],
});
let sat =
SpacecraftBoresight::new(ephem, attitude, 0.01, 0.0).with_boresight([5.0, 0.0, 0.0]);
let region = sat.current_region(&now());
assert!(region.center.ra.abs() < 1e-12 || (region.center.ra - 2.0 * PI).abs() < 1e-12);
assert!(region.center.dec.abs() < 1e-12);
}
#[test]
fn spacecraft_observer_state_passes_through_ephemeris() {
let state = BcrsState {
position_au: [1.5, -0.2, 0.7],
velocity_au_per_day: [0.001, 0.017, -0.003],
};
let attitude = StaticAttitude(UnitQuaternion::identity());
let sat = SpacecraftBoresight::new(StaticEphemeris(state), attitude, 0.05, 0.05);
match sat.observer_state(&now()) {
Some(ObserverState::Barycentric {
position_au,
velocity_au_per_day,
}) => {
assert_eq!(position_au, state.position_au);
assert_eq!(velocity_au_per_day, state.velocity_au_per_day);
}
other => panic!("expected Barycentric state, got {other:?}"),
}
}
struct NoneAttitude;
impl AttitudeSource for NoneAttitude {
fn quaternion_at(&self, _t: &Time) -> Option<UnitQuaternion<f64>> {
None
}
}
#[test]
fn spacecraft_no_attitude_falls_back_to_full_sky() {
let ephem = StaticEphemeris(BcrsState {
position_au: [0.0; 3],
velocity_au_per_day: [0.0; 3],
});
let sat = SpacecraftBoresight::new(ephem, NoneAttitude, 0.01, 0.01);
let region = sat.current_region(&now());
assert!(region.radius_rad >= PI - 1e-12);
}
}