/// 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::::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, pub angular_size_arcsec: Option, pub phase_pct: Option, // 0–100 pub distance_au: Option, pub elongation_deg: Option, // 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, ) -> Result, 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 = 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, pub ra_deg: Option, pub dec_deg: Option, pub tle_line1: Option, pub tle_line2: Option, pub notes: Option, } pub async fn list_custom_targets( State(state): State, ) -> Result, 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 = rows.iter().map(|r| { let ra: Option = r.try_get("ra_deg").unwrap_or_default(); let dec: Option = r.try_get("dec_deg").unwrap_or_default(); let has_tle = r.try_get::, _>("tle_line1").unwrap_or_default().is_some(); let mut obj = serde_json::json!({ "id": r.try_get::("id").unwrap_or_default(), "name": r.try_get::("name").unwrap_or_default(), "obj_type": r.try_get::("obj_type").unwrap_or_default(), "ra_deg": ra, "dec_deg": dec, "notes": r.try_get::, _>("notes").unwrap_or_default(), "has_tle": has_tle, "created_at": r.try_get::("created_at").unwrap_or_default(), }); // Compute live position: prefer TLE propagation if available, else fixed RA/Dec let tle1 = r.try_get::, _>("tle_line1").unwrap_or_default(); let tle2 = r.try_get::, _>("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, Json(input): Json, ) -> Result, 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, axum::extract::Path(id): axum::extract::Path, ) -> Result, AppError> { sqlx::query("DELETE FROM custom_targets WHERE id = ?") .bind(&id) .execute(&state.pool) .await?; Ok(Json(serde_json::json!({ "id": id, "status": "deleted" }))) }