437 lines
17 KiB
Rust
437 lines
17 KiB
Rust
/// Solar System objects: planets, Moon, bright comets, and custom/TLE targets.
|
||
/// Planet positions use low-precision analytical series accurate to ~1' for dates near J2000.
|
||
use axum::{extract::State, Json};
|
||
use chrono::Utc;
|
||
use serde::{Deserialize, Serialize};
|
||
use std::f64::consts::PI;
|
||
|
||
use crate::astronomy::{
|
||
coords::{airmass, radec_to_altaz},
|
||
julian_date,
|
||
time::local_sidereal_time,
|
||
moon_position,
|
||
};
|
||
use crate::config::{LAT, LON};
|
||
use super::{AppError, AppState};
|
||
|
||
/// Propagate TLE to current position → (ra_deg, dec_deg, alt_deg, az_deg).
|
||
/// Uses sgp4 crate; returns None on parse or propagation error.
|
||
fn tle_position(line1: &str, line2: &str) -> Option<(f64, f64, f64, f64)> {
|
||
use sgp4::{Constants, Elements};
|
||
|
||
let elements = Elements::from_tle(
|
||
None,
|
||
line1.as_bytes(),
|
||
line2.as_bytes(),
|
||
).ok()?;
|
||
let constants = Constants::from_elements(&elements).ok()?;
|
||
|
||
// Minutes since TLE epoch
|
||
let now = Utc::now();
|
||
let epoch = chrono::DateTime::<Utc>::from_naive_utc_and_offset(elements.datetime, Utc);
|
||
let minutes = (now - epoch).num_seconds() as f64 / 60.0;
|
||
|
||
let prediction = constants.propagate(sgp4::MinutesSinceEpoch(minutes)).ok()?;
|
||
|
||
// ECI position in km (TEME frame)
|
||
let (x, y, z) = (prediction.position[0], prediction.position[1], prediction.position[2]);
|
||
|
||
// Convert ECI to RA/Dec (TEME ≈ J2000 for our purposes, error < 0.01°)
|
||
let r = (x * x + y * y + z * z).sqrt();
|
||
if r < 1.0 { return None; }
|
||
|
||
let ra_rad = y.atan2(x);
|
||
let dec_rad = (z / r).asin();
|
||
let ra_deg = ra_rad.to_degrees().rem_euclid(360.0);
|
||
let dec_deg = dec_rad.to_degrees();
|
||
|
||
// Convert to Alt/Az
|
||
let jd = julian_date(now);
|
||
let lst = local_sidereal_time(jd, LON);
|
||
let (alt, az) = radec_to_altaz(ra_deg, dec_deg, lst, LAT);
|
||
|
||
Some((ra_deg, dec_deg, alt, az))
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct SolarSystemObject {
|
||
pub id: String,
|
||
pub name: String,
|
||
pub obj_type: String, // planet, moon, asteroid, comet
|
||
pub ra_deg: f64,
|
||
pub dec_deg: f64,
|
||
pub ra_h: String,
|
||
pub dec_dms: String,
|
||
pub alt_deg: f64,
|
||
pub az_deg: f64,
|
||
pub airmass: f64,
|
||
pub mag_v: Option<f64>,
|
||
pub angular_size_arcsec: Option<f64>,
|
||
pub phase_pct: Option<f64>, // 0–100
|
||
pub distance_au: Option<f64>,
|
||
pub elongation_deg: Option<f64>, // from Sun
|
||
pub is_visible: bool, // alt > 15°
|
||
}
|
||
|
||
fn fmt_ra(ra: f64) -> String {
|
||
let total_sec = (ra / 15.0) * 3600.0;
|
||
let h = (total_sec / 3600.0) as u32;
|
||
let m = ((total_sec % 3600.0) / 60.0) as u32;
|
||
let s = (total_sec % 60.0) as u32;
|
||
format!("{:02}h {:02}m {:02}s", h, m, s)
|
||
}
|
||
|
||
fn fmt_dec(dec: f64) -> String {
|
||
let sign = if dec < 0.0 { "-" } else { "+" };
|
||
let abs = dec.abs();
|
||
let d = abs as u32;
|
||
let m = ((abs - d as f64) * 60.0) as u32;
|
||
let s = ((abs - d as f64) * 3600.0 % 60.0) as u32;
|
||
format!("{}{}° {:02}′ {:02}″", sign, d, m, s)
|
||
}
|
||
|
||
/// Low-precision planet positions (Jean Meeus, "Astronomical Algorithms", ch. 33).
|
||
/// Returns (ra_deg, dec_deg, distance_au, mag_v, phase_pct, angular_size_arcsec).
|
||
fn planet_position(name: &str, jd: f64) -> Option<(f64, f64, f64, f64, f64, f64)> {
|
||
// T = Julian centuries from J2000.0
|
||
let t = (jd - 2451545.0) / 36525.0;
|
||
|
||
// Sun's geometric mean longitude and anomaly (for elongation / phase)
|
||
let l0_sun = (280.46646 + 36000.76983 * t).rem_euclid(360.0);
|
||
let m_sun = (357.52911 + 35999.05029 * t - 0.0001537 * t * t).to_radians();
|
||
let c_sun = (1.914602 - 0.004817 * t - 0.000014 * t * t) * m_sun.sin()
|
||
+ (0.019993 - 0.000101 * t) * (2.0 * m_sun).sin()
|
||
+ 0.000289 * (3.0 * m_sun).sin();
|
||
let sun_lon = l0_sun + c_sun; // true longitude degrees
|
||
let sun_lon_rad = sun_lon.to_radians();
|
||
|
||
// For each planet: orbital elements at epoch J2000 with linear drift.
|
||
// Format: (L0, L1, a_AU, e0, e1, i0, i1, omega0, omega1, node0, node1)
|
||
// L = mean longitude, a = semi-major axis, e = eccentricity,
|
||
// i = inclination, omega = argument of perihelion, node = ascending node
|
||
let (l0, l1, a, e0, e1, inc0, inc1, peri0, peri1, node0, node1) = match name {
|
||
"Mercury" => (252.25032, 149472.67411, 0.38710, 0.20563, 0.000020, 7.00497, -0.00594, 77.45779, 0.15940, 48.33076, -0.12534),
|
||
"Venus" => (181.97980, 58517.81538, 0.72333, 0.00677, -0.000048, 3.39468, -0.00788, 131.56370, 0.05127, 76.67984, -0.27769),
|
||
"Mars" => (355.45332, 19140.30268, 1.52366, 0.09340, 0.000090, 1.84973, -0.00813, 336.04084, 0.44441, 49.55953, -0.29257),
|
||
"Jupiter" => (34.89973, 3034.74612, 5.20260, 0.04849, 0.000163, 1.30327, -0.00557, 14.72847, 0.21252, 100.29205, 0.13447),
|
||
"Saturn" => (50.07571, 1222.11494, 9.55491, 0.05551, -0.000346, 2.48888, 0.00449, 92.86136, 0.54479, 113.63998, -0.25015),
|
||
"Uranus" => (314.05500, 428.46952, 19.21845, 0.04630, -0.000027, 0.77320, -0.00180, 172.43404, 0.09175, 73.96980, 0.05717),
|
||
"Neptune" => (304.34866, 218.45945, 30.11039, 0.00899, 0.000006, 1.76995, 0.00022, 46.68158, 0.01367, 131.78406, -0.00762),
|
||
_ => return None,
|
||
};
|
||
|
||
let l = (l0 + l1 * t / 36525.0).rem_euclid(360.0).to_radians();
|
||
let e = e0 + e1 * t;
|
||
let inc = (inc0 + inc1 * t).to_radians();
|
||
let peri = (peri0 + peri1 * t).to_radians(); // longitude of perihelion
|
||
let node = (node0 + node1 * t).to_radians();
|
||
|
||
// Mean anomaly
|
||
let m = (l - peri).rem_euclid(2.0 * PI);
|
||
|
||
// Eccentric anomaly (Newton iteration)
|
||
let mut ea = m;
|
||
for _ in 0..10 {
|
||
ea = m + e * ea.sin();
|
||
}
|
||
|
||
// True anomaly
|
||
let nu = 2.0 * ((((1.0 + e) / (1.0 - e)).sqrt() * (ea / 2.0).tan()).atan());
|
||
|
||
// Heliocentric distance
|
||
let r = a * (1.0 - e * ea.cos());
|
||
|
||
// Heliocentric ecliptic coordinates
|
||
let lon_helio = (nu + peri - node).rem_euclid(2.0 * PI) + node;
|
||
let lat_helio = (lon_helio - node).sin() * inc.sin();
|
||
let lat_helio = lat_helio.asin();
|
||
let lon_helio = lon_helio;
|
||
|
||
// Convert to rectangular heliocentric
|
||
let x_h = r * lat_helio.cos() * lon_helio.cos();
|
||
let y_h = r * lat_helio.cos() * lon_helio.sin();
|
||
let z_h = r * lat_helio.sin();
|
||
|
||
// Earth's heliocentric rectangular coordinates (using Sun's geocentric coords reversed)
|
||
let r_earth = 1.000001018 * (1.0 - 0.0167086342 * ea.cos()); // rough
|
||
let l_earth = sun_lon_rad + PI;
|
||
let x_e = r_earth * l_earth.cos();
|
||
let y_e = r_earth * l_earth.sin();
|
||
|
||
// Geocentric coordinates
|
||
let dx = x_h - x_e;
|
||
let dy = y_h - y_e;
|
||
let dz = z_h;
|
||
|
||
// Geocentric ecliptic longitude/latitude
|
||
let lam = dy.atan2(dx);
|
||
let dist = (dx * dx + dy * dy + dz * dz).sqrt();
|
||
let beta = (dz / dist).asin();
|
||
|
||
// Convert ecliptic → equatorial (obliquity ~23.439°)
|
||
let eps = (23.439291 - 0.013004 * t).to_radians();
|
||
let ra = (lam.sin() * eps.cos() - beta.tan() * eps.sin()).atan2(lam.cos());
|
||
let ra_deg = ra.to_degrees().rem_euclid(360.0);
|
||
let dec_deg = (beta.sin() * eps.cos() + beta.cos() * eps.sin() * lam.sin()).asin().to_degrees();
|
||
|
||
// Phase angle
|
||
let phase_angle = ((r * r + dist * dist - r_earth * r_earth) / (2.0 * r * dist)).acos();
|
||
let phase_pct = (1.0 + phase_angle.cos()) / 2.0 * 100.0;
|
||
|
||
// Approximate magnitude (very rough)
|
||
let (h0, g_slope) = match name {
|
||
"Mercury" => (-0.36, 0.0),
|
||
"Venus" => (-4.34, 0.0),
|
||
"Mars" => (-1.51, 0.0),
|
||
"Jupiter" => (-9.25, 0.0),
|
||
"Saturn" => (-8.88, 0.0),
|
||
"Uranus" => (-7.19, 0.0),
|
||
"Neptune" => (-6.87, 0.0),
|
||
_ => (10.0, 0.0),
|
||
};
|
||
let mag = h0 + 5.0 * (r * dist).log10() - 2.5 * ((1.0 - g_slope) * (-3.33 * (phase_angle / 2.0).tan().powi(12)).exp() + g_slope * (-1.87 * (phase_angle / 2.0).tan().powi(6)).exp()).log10();
|
||
|
||
// Angular size (arcsec) — equatorial diameter at 1 AU
|
||
let diam_1au_arcsec = match name {
|
||
"Mercury" => 6.74,
|
||
"Venus" => 16.92,
|
||
"Mars" => 9.36,
|
||
"Jupiter" => 196.74,
|
||
"Saturn" => 165.6,
|
||
"Uranus" => 65.8,
|
||
"Neptune" => 62.2,
|
||
_ => 0.0,
|
||
};
|
||
let ang_size = diam_1au_arcsec / dist;
|
||
|
||
Some((ra_deg, dec_deg, dist, mag, phase_pct, ang_size))
|
||
}
|
||
|
||
fn sun_position(jd: f64) -> (f64, f64) {
|
||
let t = (jd - 2451545.0) / 36525.0;
|
||
let l0 = (280.46646 + 36000.76983 * t).rem_euclid(360.0);
|
||
let m = (357.52911 + 35999.05029 * t).to_radians();
|
||
let c = (1.914602 - 0.004817 * t) * m.sin()
|
||
+ 0.019993 * (2.0 * m).sin()
|
||
+ 0.000290 * (3.0 * m).sin();
|
||
let sun_lon = (l0 + c).to_radians();
|
||
let eps = (23.439291 - 0.013004 * t).to_radians();
|
||
let ra = (sun_lon.sin() * eps.cos()).atan2(sun_lon.cos());
|
||
let dec = (sun_lon.sin() * eps.sin()).asin();
|
||
(ra.to_degrees().rem_euclid(360.0), dec.to_degrees())
|
||
}
|
||
|
||
fn elongation(ra1: f64, dec1: f64, ra2: f64, dec2: f64) -> f64 {
|
||
let r1 = ra1.to_radians();
|
||
let d1 = dec1.to_radians();
|
||
let r2 = ra2.to_radians();
|
||
let d2 = dec2.to_radians();
|
||
let cos_sep = d1.sin() * d2.sin() + d1.cos() * d2.cos() * (r1 - r2).cos();
|
||
cos_sep.clamp(-1.0, 1.0).acos().to_degrees()
|
||
}
|
||
|
||
pub async fn get_solar_system(
|
||
State(_state): State<AppState>,
|
||
) -> Result<Json<serde_json::Value>, AppError> {
|
||
let now = Utc::now();
|
||
let jd = julian_date(now);
|
||
let lst = local_sidereal_time(jd, LON);
|
||
|
||
let (sun_ra, sun_dec) = sun_position(jd);
|
||
let (moon_ra, moon_dec) = moon_position(jd);
|
||
|
||
let planet_names = ["Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune"];
|
||
let mut objects: Vec<SolarSystemObject> = Vec::new();
|
||
|
||
// Moon
|
||
{
|
||
let (alt, az) = radec_to_altaz(moon_ra, moon_dec, lst, LAT);
|
||
let am = if alt > 0.0 { airmass(alt) } else { 99.0 };
|
||
objects.push(SolarSystemObject {
|
||
id: "moon".to_string(),
|
||
name: "Moon".to_string(),
|
||
obj_type: "moon".to_string(),
|
||
ra_deg: moon_ra,
|
||
dec_deg: moon_dec,
|
||
ra_h: fmt_ra(moon_ra),
|
||
dec_dms: fmt_dec(moon_dec),
|
||
alt_deg: (alt * 10.0).round() / 10.0,
|
||
az_deg: (az * 10.0).round() / 10.0,
|
||
airmass: (am * 100.0).round() / 100.0,
|
||
mag_v: Some(-12.7),
|
||
angular_size_arcsec: Some(1800.0),
|
||
phase_pct: None, // from tonight data
|
||
distance_au: None,
|
||
elongation_deg: Some((elongation(moon_ra, moon_dec, sun_ra, sun_dec) * 10.0).round() / 10.0),
|
||
is_visible: alt > 15.0,
|
||
});
|
||
}
|
||
|
||
// Sun
|
||
{
|
||
let (alt, az) = radec_to_altaz(sun_ra, sun_dec, lst, LAT);
|
||
let am = if alt > 0.0 { airmass(alt) } else { 99.0 };
|
||
objects.push(SolarSystemObject {
|
||
id: "sun".to_string(),
|
||
name: "Sun".to_string(),
|
||
obj_type: "star".to_string(),
|
||
ra_deg: sun_ra,
|
||
dec_deg: sun_dec,
|
||
ra_h: fmt_ra(sun_ra),
|
||
dec_dms: fmt_dec(sun_dec),
|
||
alt_deg: (alt * 10.0).round() / 10.0,
|
||
az_deg: (az * 10.0).round() / 10.0,
|
||
airmass: (am * 100.0).round() / 100.0,
|
||
mag_v: Some(-26.7),
|
||
angular_size_arcsec: Some(1919.0),
|
||
phase_pct: None,
|
||
distance_au: Some(1.0),
|
||
elongation_deg: Some(0.0),
|
||
is_visible: alt > 0.0,
|
||
});
|
||
}
|
||
|
||
// Planets
|
||
for name in &planet_names {
|
||
if let Some((ra, dec, dist, mag, phase, ang_size)) = planet_position(name, jd) {
|
||
let (alt, az) = radec_to_altaz(ra, dec, lst, LAT);
|
||
let am = if alt > 0.0 { airmass(alt) } else { 99.0 };
|
||
let elong = elongation(ra, dec, sun_ra, sun_dec);
|
||
objects.push(SolarSystemObject {
|
||
id: name.to_lowercase(),
|
||
name: name.to_string(),
|
||
obj_type: "planet".to_string(),
|
||
ra_deg: (ra * 1000.0).round() / 1000.0,
|
||
dec_deg: (dec * 1000.0).round() / 1000.0,
|
||
ra_h: fmt_ra(ra),
|
||
dec_dms: fmt_dec(dec),
|
||
alt_deg: (alt * 10.0).round() / 10.0,
|
||
az_deg: (az * 10.0).round() / 10.0,
|
||
airmass: (am * 100.0).round() / 100.0,
|
||
mag_v: Some((mag * 10.0).round() / 10.0),
|
||
angular_size_arcsec: Some((ang_size * 10.0).round() / 10.0),
|
||
phase_pct: Some((phase * 10.0).round() / 10.0),
|
||
distance_au: Some((dist * 1000.0).round() / 1000.0),
|
||
elongation_deg: Some((elong * 10.0).round() / 10.0),
|
||
is_visible: alt > 15.0,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Sort: visible first, then by altitude descending
|
||
objects.sort_by(|a, b| {
|
||
b.is_visible.cmp(&a.is_visible)
|
||
.then(b.alt_deg.partial_cmp(&a.alt_deg).unwrap_or(std::cmp::Ordering::Equal))
|
||
});
|
||
|
||
Ok(Json(serde_json::json!({
|
||
"computed_at": now.to_rfc3339(),
|
||
"objects": objects,
|
||
})))
|
||
}
|
||
|
||
/// Custom targets API
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct CustomTargetInput {
|
||
pub id: String,
|
||
pub name: String,
|
||
pub obj_type: Option<String>,
|
||
pub ra_deg: Option<f64>,
|
||
pub dec_deg: Option<f64>,
|
||
pub tle_line1: Option<String>,
|
||
pub tle_line2: Option<String>,
|
||
pub notes: Option<String>,
|
||
}
|
||
|
||
pub async fn list_custom_targets(
|
||
State(state): State<AppState>,
|
||
) -> Result<Json<serde_json::Value>, AppError> {
|
||
let rows = sqlx::query("SELECT * FROM custom_targets ORDER BY created_at DESC")
|
||
.fetch_all(&state.pool)
|
||
.await?;
|
||
|
||
use sqlx::Row;
|
||
let items: Vec<serde_json::Value> = rows.iter().map(|r| {
|
||
let ra: Option<f64> = r.try_get("ra_deg").unwrap_or_default();
|
||
let dec: Option<f64> = r.try_get("dec_deg").unwrap_or_default();
|
||
let has_tle = r.try_get::<Option<String>, _>("tle_line1").unwrap_or_default().is_some();
|
||
|
||
let mut obj = serde_json::json!({
|
||
"id": r.try_get::<String, _>("id").unwrap_or_default(),
|
||
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
||
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||
"ra_deg": ra,
|
||
"dec_deg": dec,
|
||
"notes": r.try_get::<Option<String>, _>("notes").unwrap_or_default(),
|
||
"has_tle": has_tle,
|
||
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
|
||
});
|
||
|
||
// Compute live position: prefer TLE propagation if available, else fixed RA/Dec
|
||
let tle1 = r.try_get::<Option<String>, _>("tle_line1").unwrap_or_default();
|
||
let tle2 = r.try_get::<Option<String>, _>("tle_line2").unwrap_or_default();
|
||
|
||
if let (Some(t1), Some(t2)) = (&tle1, &tle2) {
|
||
if let Some((ra, dec, alt, az)) = tle_position(t1, t2) {
|
||
obj["ra_deg"] = serde_json::json!((ra * 1000.0).round() / 1000.0);
|
||
obj["dec_deg"] = serde_json::json!((dec * 1000.0).round() / 1000.0);
|
||
obj["ra_h"] = serde_json::json!(fmt_ra(ra));
|
||
obj["dec_dms"] = serde_json::json!(fmt_dec(dec));
|
||
obj["alt_deg"] = serde_json::json!((alt * 10.0).round() / 10.0);
|
||
obj["az_deg"] = serde_json::json!((az * 10.0).round() / 10.0);
|
||
obj["tle_position_ok"] = serde_json::json!(true);
|
||
} else {
|
||
obj["tle_position_ok"] = serde_json::json!(false);
|
||
}
|
||
} else if let (Some(ra), Some(dec)) = (ra, dec) {
|
||
let jd = julian_date(Utc::now());
|
||
let lst = local_sidereal_time(jd, LON);
|
||
let (alt, az) = radec_to_altaz(ra, dec, lst, LAT);
|
||
obj["alt_deg"] = serde_json::json!((alt * 10.0).round() / 10.0);
|
||
obj["az_deg"] = serde_json::json!((az * 10.0).round() / 10.0);
|
||
obj["ra_h"] = serde_json::json!(fmt_ra(ra));
|
||
obj["dec_dms"] = serde_json::json!(fmt_dec(dec));
|
||
}
|
||
obj
|
||
}).collect();
|
||
|
||
Ok(Json(serde_json::json!({ "items": items })))
|
||
}
|
||
|
||
pub async fn create_custom_target(
|
||
State(state): State<AppState>,
|
||
Json(input): Json<CustomTargetInput>,
|
||
) -> Result<Json<serde_json::Value>, AppError> {
|
||
if input.id.trim().is_empty() || input.name.trim().is_empty() {
|
||
return Err(AppError::BadRequest("id and name are required".to_string()));
|
||
}
|
||
let obj_type = input.obj_type.unwrap_or_else(|| "custom".to_string());
|
||
sqlx::query(
|
||
"INSERT OR REPLACE INTO custom_targets (id, name, obj_type, ra_deg, dec_deg, tle_line1, tle_line2, notes)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||
)
|
||
.bind(&input.id)
|
||
.bind(&input.name)
|
||
.bind(&obj_type)
|
||
.bind(input.ra_deg)
|
||
.bind(input.dec_deg)
|
||
.bind(input.tle_line1.as_deref())
|
||
.bind(input.tle_line2.as_deref())
|
||
.bind(input.notes.as_deref())
|
||
.execute(&state.pool)
|
||
.await?;
|
||
|
||
Ok(Json(serde_json::json!({ "id": input.id, "status": "created" })))
|
||
}
|
||
|
||
pub async fn delete_custom_target(
|
||
State(state): State<AppState>,
|
||
axum::extract::Path(id): axum::extract::Path<String>,
|
||
) -> Result<Json<serde_json::Value>, AppError> {
|
||
sqlx::query("DELETE FROM custom_targets WHERE id = ?")
|
||
.bind(&id)
|
||
.execute(&state.pool)
|
||
.await?;
|
||
Ok(Json(serde_json::json!({ "id": id, "status": "deleted" })))
|
||
}
|