Files
Astronome/backend/src/api/solar_system.rs
T
2026-04-09 23:23:31 +02:00

437 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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>, // 0100
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" })))
}