use crate::astro::constants::units::{DEG_TO_RAD, RAD_TO_DEG};
use crate::astro::math::vec3;
use crate::validate;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum ObservationError {
#[error("invalid observation input {field}: {reason}")]
InvalidInput {
field: &'static str,
reason: &'static str,
},
}
fn invalid(field: &'static str, reason: &'static str) -> ObservationError {
ObservationError::InvalidInput { field, reason }
}
fn map_field(error: validate::FieldError) -> ObservationError {
invalid(error.field(), error.reason())
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SurfacePoint {
pub latitude_deg: f64,
pub longitude_deg: f64,
}
fn sub_point(vec_ecef: [f64; 3], field: &'static str) -> Result<SurfacePoint, ObservationError> {
validate::finite_vec3(vec_ecef, field).map_err(map_field)?;
let [x, y, z] = vec_ecef;
let horizontal = (x * x + y * y).sqrt();
if horizontal == 0.0 && z == 0.0 {
return Err(invalid(field, "zero vector"));
}
let latitude_deg = z.atan2(horizontal) * RAD_TO_DEG;
let longitude_deg = y.atan2(x) * RAD_TO_DEG;
Ok(SurfacePoint {
latitude_deg,
longitude_deg,
})
}
pub fn sub_solar_point(sun_ecef: [f64; 3]) -> Result<SurfacePoint, ObservationError> {
sub_point(sun_ecef, "sun_ecef")
}
pub fn terminator_latitude_deg(
sub_solar: SurfacePoint,
longitude_deg: f64,
) -> Result<f64, ObservationError> {
let delta = validate::finite(sub_solar.latitude_deg, "sub_solar.latitude_deg")
.map_err(map_field)?
.to_radians();
let lambda_s = validate::finite(sub_solar.longitude_deg, "sub_solar.longitude_deg")
.map_err(map_field)?
.to_radians();
let lon = validate::finite(longitude_deg, "longitude_deg")
.map_err(map_field)?
.to_radians();
let tan_delta = delta.tan();
let cos_dlon = (lon - lambda_s).cos();
const QUADRATURE_EPS: f64 = 1e-9;
if cos_dlon.abs() < QUADRATURE_EPS {
return Ok(0.0);
}
let lat = (-cos_dlon / tan_delta).atan();
Ok(lat * RAD_TO_DEG)
}
pub fn parallactic_angle_deg(
observer_latitude_deg: f64,
hour_angle_deg: f64,
declination_deg: f64,
) -> Result<f64, ObservationError> {
let phi = validate::finite(observer_latitude_deg, "observer_latitude_deg")
.map_err(map_field)?
.to_radians();
let h = validate::finite(hour_angle_deg, "hour_angle_deg")
.map_err(map_field)?
.to_radians();
let dec = validate::finite(declination_deg, "declination_deg")
.map_err(map_field)?
.to_radians();
let numerator = h.sin();
let denominator = phi.tan() * dec.cos() - dec.sin() * h.cos();
Ok(numerator.atan2(denominator) * RAD_TO_DEG)
}
pub fn satellite_visual_magnitude(
range_km: f64,
phase_angle_deg: f64,
standard_magnitude: f64,
reference_range_km: f64,
) -> Result<f64, ObservationError> {
let range_km = validate::finite_positive(range_km, "range_km").map_err(map_field)?;
let reference_range_km =
validate::finite_positive(reference_range_km, "reference_range_km").map_err(map_field)?;
let standard_magnitude =
validate::finite(standard_magnitude, "standard_magnitude").map_err(map_field)?;
let phase_angle_deg =
validate::finite(phase_angle_deg, "phase_angle_deg").map_err(map_field)?;
let phi = phase_angle_deg.clamp(0.0, 180.0) * DEG_TO_RAD;
let pi = std::f64::consts::PI;
let phase = ((pi - phi) * phi.cos() + phi.sin()) / pi;
let distance_term = 5.0 * (range_km / reference_range_km).log10();
let phase_term = -2.5 * phase.log10();
Ok(standard_magnitude + distance_term + phase_term)
}
pub fn sub_observer_point(
observer_from_body: [f64; 3],
pole_ra_deg: f64,
pole_dec_deg: f64,
prime_meridian_deg: f64,
) -> Result<SurfacePoint, ObservationError> {
validate::finite_vec3(observer_from_body, "observer_from_body").map_err(map_field)?;
if vec3::norm3(observer_from_body) == 0.0 {
return Err(invalid("observer_from_body", "zero vector"));
}
let alpha0 = validate::finite(pole_ra_deg, "pole_ra_deg")
.map_err(map_field)?
.to_radians();
let delta0 = validate::finite(pole_dec_deg, "pole_dec_deg")
.map_err(map_field)?
.to_radians();
let w = validate::finite(prime_meridian_deg, "prime_meridian_deg")
.map_err(map_field)?
.to_radians();
let half_pi = std::f64::consts::FRAC_PI_2;
let body_fixed = rot_z(
rot_x(
rot_z(observer_from_body, half_pi + alpha0),
half_pi - delta0,
),
w,
);
sub_point(body_fixed, "observer_from_body")
}
#[inline]
fn rot_z(v: [f64; 3], theta: f64) -> [f64; 3] {
let (s, c) = theta.sin_cos();
[c * v[0] + s * v[1], -s * v[0] + c * v[1], v[2]]
}
#[inline]
fn rot_x(v: [f64; 3], theta: f64) -> [f64; 3] {
let (s, c) = theta.sin_cos();
[v[0], c * v[1] + s * v[2], -s * v[1] + c * v[2]]
}
#[cfg(test)]
mod tests {
use super::*;
const AU_KM: f64 = 149_597_870.7;
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
#[test]
fn sub_solar_point_reads_declination_and_meridian() {
let delta = 23.44_f64.to_radians();
let sun = [AU_KM * delta.cos(), 0.0, AU_KM * delta.sin()];
let point = sub_solar_point(sun).expect("valid sun vector");
assert!(close(point.latitude_deg, 23.44, 1e-9));
assert!(close(point.longitude_deg, 0.0, 1e-9));
let sun_east = [0.0, AU_KM * delta.cos(), AU_KM * delta.sin()];
let east = sub_solar_point(sun_east).expect("valid sun vector");
assert!(close(east.longitude_deg, 90.0, 1e-9));
}
#[test]
fn sub_solar_point_rejects_zero_vector() {
let err = sub_solar_point([0.0, 0.0, 0.0]).unwrap_err();
assert_eq!(
err,
ObservationError::InvalidInput {
field: "sun_ecef",
reason: "zero vector"
}
);
}
#[test]
fn terminator_matches_polar_circles_at_solstice() {
let sub_solar = SurfacePoint {
latitude_deg: 23.44,
longitude_deg: 0.0,
};
let noon = terminator_latitude_deg(sub_solar, 0.0).expect("valid");
let midnight = terminator_latitude_deg(sub_solar, 180.0).expect("valid");
assert!(close(noon, -66.56, 1e-9), "noon terminator {noon}");
assert!(
close(midnight, 66.56, 1e-9),
"midnight terminator {midnight}"
);
let quad = terminator_latitude_deg(sub_solar, 90.0).expect("valid");
assert!(close(quad, 0.0, 1e-9), "quadrature terminator {quad}");
}
#[test]
fn terminator_equinox_quadrature_crosses_equator() {
let sub_solar = SurfacePoint {
latitude_deg: 0.0,
longitude_deg: 0.0,
};
let east_quad = terminator_latitude_deg(sub_solar, 90.0).expect("valid");
let west_quad = terminator_latitude_deg(sub_solar, -90.0).expect("valid");
assert!(close(east_quad, 0.0, 1e-9), "east quadrature {east_quad}");
assert!(close(west_quad, 0.0, 1e-9), "west quadrature {west_quad}");
let noon = terminator_latitude_deg(sub_solar, 0.0).expect("valid");
let midnight = terminator_latitude_deg(sub_solar, 180.0).expect("valid");
assert!(close(noon.abs(), 90.0, 1e-9), "noon poleward limit {noon}");
assert!(
close(midnight.abs(), 90.0, 1e-9),
"midnight poleward limit {midnight}"
);
}
#[test]
fn terminator_near_equinox_quadrature_crosses_equator() {
let sub_solar = SurfacePoint {
latitude_deg: 1.0e-4,
longitude_deg: 30.0,
};
let quad = terminator_latitude_deg(sub_solar, 120.0).expect("valid");
assert!(close(quad, 0.0, 1e-6), "near-equinox quadrature {quad}");
}
#[test]
fn terminator_offset_subsolar_longitude() {
let sub_solar = SurfacePoint {
latitude_deg: 10.0,
longitude_deg: 30.0,
};
let lat = terminator_latitude_deg(sub_solar, 100.0).expect("valid");
assert!(close(lat, -62.726_830_443_196_36, 1e-9), "terminator {lat}");
}
#[test]
fn parallactic_angle_zero_on_meridian() {
let q = parallactic_angle_deg(45.0, 0.0, 10.0).expect("valid");
assert!(close(q, 0.0, 1e-12), "meridian parallactic {q}");
}
#[test]
fn parallactic_angle_known_values() {
let q = parallactic_angle_deg(45.0, 45.0, 0.0).expect("valid");
assert!(close(q, 35.264_389_682_754_654, 1e-9), "{q}");
let q2 = parallactic_angle_deg(40.0, 30.0, 20.0).expect("valid");
assert!(close(q2, 45.444_731_720_286_68, 1e-9), "{q2}");
let west = parallactic_angle_deg(40.0, 30.0, 20.0).expect("valid");
let east = parallactic_angle_deg(40.0, -30.0, 20.0).expect("valid");
assert!(close(west, -east, 1e-9), "antisymmetry {west} {east}");
}
#[test]
fn visual_magnitude_distance_and_phase_terms() {
let base = satellite_visual_magnitude(1000.0, 0.0, 0.0, 1000.0).expect("valid");
assert!(close(base, 0.0, 1e-12), "base {base}");
let farther = satellite_visual_magnitude(2000.0, 0.0, 0.0, 1000.0).expect("valid");
assert!(
close(farther, 1.505_149_978_319_906, 1e-9),
"farther {farther}"
);
let quarter = satellite_visual_magnitude(1000.0, 90.0, 0.0, 1000.0).expect("valid");
assert!(
close(quarter, 1.242_874_681_735_334_7, 1e-9),
"quarter {quarter}"
);
}
#[test]
fn visual_magnitude_phase_is_monotonic_and_clamped() {
let full = satellite_visual_magnitude(1000.0, 0.0, -1.3, 1000.0).expect("valid");
let half = satellite_visual_magnitude(1000.0, 90.0, -1.3, 1000.0).expect("valid");
let thin = satellite_visual_magnitude(1000.0, 150.0, -1.3, 1000.0).expect("valid");
assert!(full < half && half < thin, "{full} {half} {thin}");
let clamped = satellite_visual_magnitude(1000.0, 200.0, -1.3, 1000.0).expect("valid");
let at_180 = satellite_visual_magnitude(1000.0, 180.0, -1.3, 1000.0).expect("valid");
assert!(close(clamped, at_180, 1e-12), "clamp {clamped} {at_180}");
}
#[test]
fn visual_magnitude_rejects_nonpositive_range() {
let err = satellite_visual_magnitude(0.0, 0.0, 0.0, 1000.0).unwrap_err();
assert_eq!(
err,
ObservationError::InvalidInput {
field: "range_km",
reason: "not positive"
}
);
}
#[test]
fn sub_observer_point_pole_along_z() {
let on_pm = sub_observer_point([0.0, 1.0, 0.0], 0.0, 90.0, 0.0).expect("valid");
assert!(
close(on_pm.latitude_deg, 0.0, 1e-9),
"lat {}",
on_pm.latitude_deg
);
assert!(
close(on_pm.longitude_deg, 0.0, 1e-9),
"lon {}",
on_pm.longitude_deg
);
let polar = sub_observer_point([0.0, 0.0, 1.0], 0.0, 90.0, 0.0).expect("valid");
assert!(
close(polar.latitude_deg, 90.0, 1e-9),
"polar lat {}",
polar.latitude_deg
);
let before = sub_observer_point([1.0, 0.0, 0.0], 0.0, 90.0, 0.0).expect("valid");
assert!(
close(before.longitude_deg, -90.0, 1e-9),
"lon {}",
before.longitude_deg
);
}
#[test]
fn sub_observer_point_prime_meridian_rotation() {
let point = sub_observer_point([0.0, 1.0, 0.0], 0.0, 90.0, 90.0).expect("valid");
assert!(
close(point.longitude_deg, -90.0, 1e-9),
"lon {}",
point.longitude_deg
);
}
#[test]
fn sub_observer_point_iau_mars_orientation() {
let point = sub_observer_point([1.0, 0.0, 0.0], 317.68, 52.89, 176.0).expect("valid");
assert!(
close(point.latitude_deg, 26.494_542_592_970_532, 1e-9),
"lat {}",
point.latitude_deg
);
assert!(
close(point.longitude_deg, 142.788_019_764_132_46, 1e-9),
"lon {}",
point.longitude_deg
);
}
#[test]
fn sub_observer_point_rejects_zero_vector() {
let err = sub_observer_point([0.0, 0.0, 0.0], 0.0, 90.0, 0.0).unwrap_err();
assert_eq!(
err,
ObservationError::InvalidInput {
field: "observer_from_body",
reason: "zero vector"
}
);
}
}