[fix](trx-rs): don't use fabricated TLEs for satellite pass predictions
Hardcoded fallback TLEs had approximate orbital elements (round numbers for RAAN, arg of perigee, mean anomaly) producing pass times hours off. Return empty predictions with a clear error when CelesTrak data is not yet available. Add TLE source and satellite count to the API response. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -292,6 +292,7 @@ document
|
||||
let satPredData = [];
|
||||
let satPredFilterText = "";
|
||||
let satPredMinEl = 0;
|
||||
let satPredSatCount = 0;
|
||||
const satPredFilterInput = document.getElementById("sat-pred-filter");
|
||||
const satPredMinElSelect = document.getElementById("sat-pred-min-el");
|
||||
|
||||
@@ -375,7 +376,11 @@ function renderSatPredictions(passes, error) {
|
||||
fragment.appendChild(row);
|
||||
}
|
||||
list.replaceChildren(fragment);
|
||||
if (status) status.textContent = `${passes.length} pass${passes.length === 1 ? "" : "es"} in the next 24 h · times in UTC`;
|
||||
if (status) {
|
||||
let text = `${passes.length} pass${passes.length === 1 ? "" : "es"} in the next 24 h · times in UTC`;
|
||||
if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`;
|
||||
status.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSatPredictions() {
|
||||
@@ -387,6 +392,7 @@ async function loadSatPredictions() {
|
||||
const resp = await fetch("/sat_passes");
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
satPredSatCount = data.satellite_count || 0;
|
||||
if (data.error) {
|
||||
satPredData = [];
|
||||
renderSatPredictions([], data.error);
|
||||
|
||||
@@ -1376,12 +1376,17 @@ struct SatPassesResponse {
|
||||
passes: Vec<trx_core::geo::PassPrediction>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
/// Number of satellites evaluated for predictions.
|
||||
satellite_count: usize,
|
||||
/// Source of the TLE data used: "celestrak" or "unavailable".
|
||||
tle_source: trx_core::geo::TleSource,
|
||||
}
|
||||
|
||||
/// Return predicted passes for all known amateur satellites over the next 24 h.
|
||||
/// Return predicted passes for all known satellites over the next 24 h.
|
||||
///
|
||||
/// Requires the server station location to be configured. Returns an empty
|
||||
/// `passes` array with an `error` field if the location is missing.
|
||||
/// `passes` array with an `error` field if the location is missing or TLE
|
||||
/// data has not been fetched yet.
|
||||
#[get("/sat_passes")]
|
||||
pub async fn sat_passes(state: web::Data<watch::Receiver<RigState>>) -> impl Responder {
|
||||
let rig_state = state.get_ref().borrow().clone();
|
||||
@@ -1392,6 +1397,8 @@ pub async fn sat_passes(state: web::Data<watch::Receiver<RigState>>) -> impl Res
|
||||
return web::Json(SatPassesResponse {
|
||||
passes: vec![],
|
||||
error: Some("No station location configured".to_string()),
|
||||
satellite_count: 0,
|
||||
tle_source: trx_core::geo::TleSource::Unavailable,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1401,10 +1408,18 @@ pub async fn sat_passes(state: web::Data<watch::Receiver<RigState>>) -> impl Res
|
||||
.as_millis() as i64;
|
||||
let window_ms = 24 * 60 * 60 * 1000_i64;
|
||||
|
||||
let passes = trx_core::geo::compute_upcoming_passes(lat, lon, now_ms, window_ms);
|
||||
let result = trx_core::geo::compute_upcoming_passes(lat, lon, now_ms, window_ms);
|
||||
let error = match result.tle_source {
|
||||
trx_core::geo::TleSource::Unavailable => {
|
||||
Some("TLE data not yet available — waiting for CelesTrak fetch".to_string())
|
||||
}
|
||||
trx_core::geo::TleSource::Celestrak => None,
|
||||
};
|
||||
web::Json(SatPassesResponse {
|
||||
passes,
|
||||
error: None,
|
||||
passes: result.passes,
|
||||
error,
|
||||
satellite_count: result.satellite_count,
|
||||
tle_source: result.tle_source,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+102
-48
@@ -14,6 +14,27 @@ use std::f64::consts::PI;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Result of computing upcoming passes, including metadata about TLE source.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct PassPredictionResult {
|
||||
/// Predicted passes sorted by AOS time.
|
||||
pub passes: Vec<PassPrediction>,
|
||||
/// Number of satellites evaluated.
|
||||
pub satellite_count: usize,
|
||||
/// Whether predictions are based on live CelesTrak TLE data.
|
||||
pub tle_source: TleSource,
|
||||
}
|
||||
|
||||
/// Indicates the origin of the TLE data used for predictions.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TleSource {
|
||||
/// Live TLE data fetched from CelesTrak.
|
||||
Celestrak,
|
||||
/// No TLE data available yet (CelesTrak fetch pending or failed).
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Half-swath width in km for NOAA APT / Meteor LRPT imagery.
|
||||
const SWATH_HALF_WIDTH_KM: f64 = 1400.0;
|
||||
|
||||
@@ -80,30 +101,6 @@ pub struct PassPrediction {
|
||||
pub duration_s: u64,
|
||||
}
|
||||
|
||||
/// Satellites included in pass predictions: weather + amateur.
|
||||
const PREDICTION_SATS: &[(&str, u32)] = &[
|
||||
// Weather satellites (TLEs from CelesTrak weather group)
|
||||
("NOAA-15", 25338),
|
||||
("NOAA-18", 28654),
|
||||
("NOAA-19", 33591),
|
||||
("Meteor-M N2-3", 57166),
|
||||
("Meteor-M N2-4", 59051),
|
||||
// Amateur satellites (TLEs from CelesTrak amateur group)
|
||||
("ISS (ARISS)", 25544),
|
||||
("AO-91 (RadFxSat)", 43017),
|
||||
("AO-92 (Fox-1D)", 43137),
|
||||
("SO-50", 27607),
|
||||
("AO-73 (FUNcube-1)", 39444),
|
||||
("JO-97 (JY1SAT)", 43803),
|
||||
("PO-101 (Diwata-2B)", 43678),
|
||||
("LilacSat-2", 40908),
|
||||
("CAS-4B", 42759),
|
||||
("EO-88 (Nayif-1)", 42017),
|
||||
("RS-44 (Dosaaf-85)", 44909),
|
||||
("SALSAT", 46926),
|
||||
("GREENCUBE (IO-117)", 52765),
|
||||
];
|
||||
|
||||
/// Map satellite name patterns to NORAD catalog numbers.
|
||||
fn norad_id_for_satellite(name: &str) -> Option<u32> {
|
||||
let upper = name.to_uppercase();
|
||||
@@ -328,7 +325,10 @@ fn latlon_to_ecef(lat_deg: f64, lon_deg: f64) -> [f64; 3] {
|
||||
]
|
||||
}
|
||||
|
||||
/// Convert ECI position (km) to ECEF using GMST rotation.
|
||||
/// Convert ECI (TEME) position (km) to ECEF using sidereal time rotation.
|
||||
///
|
||||
/// Uses the sgp4 crate's IAU sidereal time for consistency with the
|
||||
/// propagator's reference frame.
|
||||
fn eci_to_ecef(x: f64, y: f64, z: f64, time_ms: i64) -> [f64; 3] {
|
||||
let gmst = gmst_from_ms(time_ms);
|
||||
[
|
||||
@@ -466,23 +466,24 @@ fn find_passes_for_sat(
|
||||
/// `window_ms` milliseconds, starting from `start_ms`.
|
||||
///
|
||||
/// Iterates over every satellite fetched from CelesTrak (weather + amateur).
|
||||
/// Falls back to hardcoded weather-satellite TLEs when the store is empty.
|
||||
/// Returns [`TleSource::Unavailable`] when CelesTrak data has not been
|
||||
/// fetched yet — the hardcoded fallback TLEs use approximate orbital
|
||||
/// elements and are NOT suitable for pass-time predictions.
|
||||
/// Results are sorted by AOS time.
|
||||
pub fn compute_upcoming_passes(
|
||||
station_lat: f64,
|
||||
station_lon: f64,
|
||||
start_ms: i64,
|
||||
window_ms: i64,
|
||||
) -> Vec<PassPrediction> {
|
||||
) -> PassPredictionResult {
|
||||
let guard = match TLE_STORE.read() {
|
||||
Ok(g) => g,
|
||||
Err(e) => e.into_inner(),
|
||||
};
|
||||
|
||||
let mut all_passes = Vec::new();
|
||||
|
||||
if let Some(store) = guard.as_ref() {
|
||||
// Use all satellites in the dynamic store.
|
||||
let satellite_count = store.len();
|
||||
let mut all_passes = Vec::new();
|
||||
for (&norad_id, entry) in store {
|
||||
let passes = find_passes_for_sat(
|
||||
&entry.name,
|
||||
@@ -496,27 +497,22 @@ pub fn compute_upcoming_passes(
|
||||
);
|
||||
all_passes.extend(passes);
|
||||
}
|
||||
all_passes.sort_by_key(|p| p.aos_ms);
|
||||
PassPredictionResult {
|
||||
passes: all_passes,
|
||||
satellite_count,
|
||||
tle_source: TleSource::Celestrak,
|
||||
}
|
||||
} else {
|
||||
// Fallback: hardcoded weather satellite TLEs only.
|
||||
for &(name, norad_id) in PREDICTION_SATS {
|
||||
if let Some((l1, l2)) = hardcoded_tle(norad_id) {
|
||||
let passes = find_passes_for_sat(
|
||||
name,
|
||||
norad_id,
|
||||
l1,
|
||||
l2,
|
||||
station_lat,
|
||||
station_lon,
|
||||
start_ms,
|
||||
window_ms,
|
||||
);
|
||||
all_passes.extend(passes);
|
||||
}
|
||||
// No CelesTrak data available — don't use hardcoded TLEs for
|
||||
// predictions because their orbital elements are approximate
|
||||
// and produce pass times that are hours off.
|
||||
PassPredictionResult {
|
||||
passes: vec![],
|
||||
satellite_count: 0,
|
||||
tle_source: TleSource::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
all_passes.sort_by_key(|p| p.aos_ms);
|
||||
all_passes
|
||||
}
|
||||
|
||||
/// Compute geographic bounds and ground track for a satellite pass.
|
||||
@@ -826,6 +822,64 @@ NOAA 19
|
||||
assert!(gmst.is_finite(), "GMST should be finite");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gmst_vs_sgp4_sidereal_time() {
|
||||
// Compare our GMST with sgp4 crate's IAU sidereal time
|
||||
let time_ms = 1774800000000_i64; // 2026-03-28
|
||||
let our_gmst = gmst_from_ms(time_ms);
|
||||
|
||||
let dt = sgp4::chrono::DateTime::from_timestamp_millis(time_ms).unwrap();
|
||||
let epoch = sgp4::julian_years_since_j2000(&dt.naive_utc());
|
||||
let sgp4_gmst = sgp4::iau_epoch_to_sidereal_time(epoch);
|
||||
|
||||
let diff_deg = (our_gmst - sgp4_gmst).abs() * 180.0 / PI;
|
||||
assert!(
|
||||
diff_deg < 1.0,
|
||||
"GMST mismatch: ours={:.4}° sgp4={:.4}° diff={:.4}°",
|
||||
our_gmst * 180.0 / PI,
|
||||
sgp4_gmst * 180.0 / PI,
|
||||
diff_deg
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_noaa19_pass_sanity() {
|
||||
// NOAA-19: sun-sync polar orbit at ~870 km, ~102 min period.
|
||||
// From Munich (~48°N, 11°E) expect 4-8 passes per 24 h,
|
||||
// each lasting 30 s – 16 min with sensible elevations.
|
||||
let start = 1774800000000_i64; // 2026-03-28
|
||||
let window = 24 * 60 * 60 * 1000_i64;
|
||||
let (l1, l2) = hardcoded_tle(33591).unwrap();
|
||||
let passes = find_passes_for_sat("NOAA 19", 33591, l1, l2, 48.0, 11.0, start, window);
|
||||
assert!(
|
||||
passes.len() >= 2 && passes.len() <= 10,
|
||||
"Expected 2-10 passes for NOAA-19 in 24h, got {}",
|
||||
passes.len()
|
||||
);
|
||||
for p in &passes {
|
||||
assert!(
|
||||
p.duration_s >= 30 && p.duration_s <= 1200,
|
||||
"Pass duration should be 30s-20min, got {}s",
|
||||
p.duration_s
|
||||
);
|
||||
assert!(
|
||||
p.max_elevation_deg > 0.0 && p.max_elevation_deg <= 90.0,
|
||||
"Max elevation should be 0-90°, got {}",
|
||||
p.max_elevation_deg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_upcoming_passes_no_store() {
|
||||
// With empty TLE store, should return unavailable source, not
|
||||
// fabricated predictions from hardcoded TLEs.
|
||||
let result = compute_upcoming_passes(48.0, 11.0, 1774800000000, 86_400_000);
|
||||
assert!(matches!(result.tle_source, TleSource::Unavailable));
|
||||
assert!(result.passes.is_empty());
|
||||
assert_eq!(result.satellite_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_elements_epoch_ms() {
|
||||
// Parse a TLE and verify the epoch converts to a reasonable timestamp
|
||||
|
||||
Reference in New Issue
Block a user