From adec33708f626d38c0aa2d84c7b8a78a9ca9eff2 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 28 Mar 2026 13:42:57 +0100 Subject: [PATCH] [feat](trx-rs): add ham sat pass predictions; rename SAT tab - Rename "Weather Satellites" sub-tab to "SAT" - Add "Predictions" view: next 24 h flyby table for 13 ham sats (ISS, AO-91, AO-92, SO-50, AO-73, JO-97, PO-101, LilacSat-2, CAS-4B, EO-88, RS-44, SALSAT, GREENCUBE) - trx-core/geo: add PassPrediction, HAM_SATS, compute_upcoming_passes(), find_passes_for_sat(), compute_az_el() helpers; spawn_tle_refresh_task now also fetches CelesTrak amateur group on startup and every 24 h - trx-frontend-http: add GET /sat_passes endpoint - app.js: locator tooltips now accumulate all receivers per station via remotes Set; _detailPassesRigFilter checks the Set Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Stan Grams --- .../trx-frontend-http/assets/web/app.js | 21 +- .../trx-frontend-http/assets/web/index.html | 17 +- .../assets/web/plugins/wxsat.js | 103 ++++- .../trx-frontend-http/assets/web/style.css | 15 +- .../trx-frontend/trx-frontend-http/src/api.rs | 38 ++ src/trx-core/src/geo.rs | 353 ++++++++++++++++-- 6 files changed, 499 insertions(+), 48 deletions(-) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index 037ec33..a6606b3 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -7002,10 +7002,16 @@ function buildDecodeLocatorTooltipHtml(grid, entry, type) { Number.isFinite(detail?.dt_s) ? `dt ${Number(detail.dt_s).toFixed(2)}` : null, escapeMapHtml(freq), ].filter(Boolean).join(" · "); - const rxLabel = _receiverLabel(detail?.remote); - const rxHtml = rxLabel - ? `
${escapeMapHtml(rxLabel)}
` - : ""; + const remoteIds = detail?.remotes instanceof Set && detail.remotes.size > 0 + ? Array.from(detail.remotes) + : (detail?.remote ? [detail.remote] : []); + const rxHtml = remoteIds + .map(rid => { + const label = _receiverLabel(rid); + return label ? `
${escapeMapHtml(label)}
` : ""; + }) + .filter(Boolean) + .join(""); const message = detail?.message ? `
${escapeMapHtml(String(detail.message))}
` : ""; @@ -7113,6 +7119,7 @@ function _locatorEntryVisibleOnMap(entry) { function _detailPassesRigFilter(detail) { if (!mapRigFilter) return true; + if (detail?.remotes instanceof Set) return detail.remotes.has(mapRigFilter); return detail?.remote === mapRigFilter; } @@ -7603,6 +7610,7 @@ window.mapAddLocator = function(message, grids, type = "ft8", station = null, de freq_hz: Number.isFinite(details?.freq_hz) ? Number(details.freq_hz) : null, message: String(details?.message || message || "").trim() || null, remote: msgRigId || null, + remotes: new Set(msgRigId ? [msgRigId] : []), }; const detailKey = detailStationId || `${targetId || "decode"}:${detailEntry.message || "decode"}:${detailEntry.ts_ms || Date.now()}`; const key = `${markerType}:${grid}`; @@ -7614,7 +7622,10 @@ window.mapAddLocator = function(message, grids, type = "ft8", station = null, de ? new Map(existing.stationDetails) : new Map(); } - existing.allStationDetails.set(detailKey, { ...detailEntry }); + const prevDetail = existing.allStationDetails.get(detailKey); + const mergedRemotes = prevDetail?.remotes instanceof Set ? new Set(prevDetail.remotes) : new Set(); + if (msgRigId) mergedRemotes.add(msgRigId); + existing.allStationDetails.set(detailKey, { ...detailEntry, remotes: mergedRemotes }); existing.sourceType = markerType; if (msgRigId) { if (!existing.rigIds) existing.rigIds = new Set(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 0c3c43e..ea2e5f5 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -515,7 +515,7 @@ - +
@@ -811,6 +811,7 @@
+
@@ -861,6 +862,18 @@
No images yet
+ +
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/wxsat.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/wxsat.js index 927a24d..243dc54 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/wxsat.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/wxsat.js @@ -1,11 +1,13 @@ -// --- Weather Satellite Decoder Plugin --- +// --- SAT Plugin --- // Live view: decoder state, latest image card // History view: filterable table of all decoded images +// Predictions view: next 24 h passes for ham satellites // ── DOM references ────────────────────────────────────────────────── const wxsatStatus = document.getElementById("wxsat-status"); const wxsatLiveView = document.getElementById("wxsat-live-view"); const wxsatHistoryView = document.getElementById("wxsat-history-view"); +const wxsatPredictionsView = document.getElementById("wxsat-predictions-view"); const wxsatLiveLatest = document.getElementById("wxsat-live-latest"); const wxsatHistoryList = document.getElementById("wxsat-history-list"); const wxsatHistoryCount = document.getElementById("wxsat-history-count"); @@ -19,7 +21,7 @@ const wxsatLrptState = document.getElementById("wxsat-lrpt-state"); let wxsatImageHistory = []; const WXSAT_MAX_IMAGES = 100; let wxsatFilterText = ""; -let wxsatActiveView = "live"; // "live" | "history" +let wxsatActiveView = "live"; // "live" | "history" | "predictions" // ── UI scheduler helper ───────────────────────────────────────────── function scheduleWxsatUi(key, job) { @@ -33,24 +35,26 @@ function scheduleWxsatUi(key, job) { // ── View switching ────────────────────────────────────────────────── const wxsatViewLiveBtn = document.getElementById("wxsat-view-live"); const wxsatViewHistoryBtn = document.getElementById("wxsat-view-history"); +const wxsatViewPredictionsBtn = document.getElementById("wxsat-view-predictions"); function switchWxsatView(view) { wxsatActiveView = view; if (wxsatLiveView) wxsatLiveView.style.display = view === "live" ? "" : "none"; if (wxsatHistoryView) wxsatHistoryView.style.display = view === "history" ? "" : "none"; - if (wxsatViewLiveBtn) { - wxsatViewLiveBtn.classList.toggle("wxsat-view-active", view === "live"); - } - if (wxsatViewHistoryBtn) { - wxsatViewHistoryBtn.classList.toggle("wxsat-view-active", view === "history"); - } + if (wxsatPredictionsView) wxsatPredictionsView.style.display = view === "predictions" ? "" : "none"; + if (wxsatViewLiveBtn) wxsatViewLiveBtn.classList.toggle("wxsat-view-active", view === "live"); + if (wxsatViewHistoryBtn) wxsatViewHistoryBtn.classList.toggle("wxsat-view-active", view === "history"); + if (wxsatViewPredictionsBtn) wxsatViewPredictionsBtn.classList.toggle("wxsat-view-active", view === "predictions"); if (view === "history") { renderWxsatHistoryTable(); + } else if (view === "predictions") { + loadSatPredictions(); } } wxsatViewLiveBtn?.addEventListener("click", () => switchWxsatView("live")); wxsatViewHistoryBtn?.addEventListener("click", () => switchWxsatView("history")); +wxsatViewPredictionsBtn?.addEventListener("click", () => switchWxsatView("predictions")); // ── Live view: decoder state ──────────────────────────────────────── // Updated from app.js render() via window.updateWxsatLiveState @@ -284,6 +288,89 @@ document } }); +// ── Predictions view ──────────────────────────────────────────────── + +function azToCardinal(deg) { + const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; + return dirs[Math.round(deg / 45) % 8]; +} + +function formatPredTime(ms) { + const d = new Date(ms); + const now = new Date(); + const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const day = d.getUTCDay() !== now.getUTCDay() + ? dayNames[d.getUTCDay()] + " " + : ""; + const hh = String(d.getUTCHours()).padStart(2, "0"); + const mm = String(d.getUTCMinutes()).padStart(2, "0"); + return `${day}${hh}:${mm}`; +} + +function formatPredDuration(s) { + if (s >= 60) return `${Math.round(s / 60)} min`; + return `${s}s`; +} + +function renderSatPredictions(passes, error) { + const list = document.getElementById("sat-pred-list"); + const status = document.getElementById("sat-pred-status"); + if (!list) return; + + if (error) { + list.innerHTML = ""; + if (status) status.textContent = error; + return; + } + + if (!Array.isArray(passes) || passes.length === 0) { + list.innerHTML = ""; + if (status) status.textContent = "No passes found in the next 24 hours."; + return; + } + + const fragment = document.createDocumentFragment(); + for (const pass of passes) { + const row = document.createElement("div"); + row.className = "sat-pred-row"; + const elClass = pass.max_elevation_deg >= 45 + ? "sat-pred-el-high" + : pass.max_elevation_deg >= 10 + ? "sat-pred-el-mid" + : "sat-pred-el-low"; + const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`; + row.innerHTML = [ + `${formatPredTime(pass.aos_ms)}`, + `${pass.satellite}`, + `${pass.max_elevation_deg.toFixed(1)}°`, + `${formatPredDuration(pass.duration_s)}`, + `${dir}`, + ].join(""); + 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`; +} + +async function loadSatPredictions() { + const status = document.getElementById("sat-pred-status"); + const list = document.getElementById("sat-pred-list"); + if (status) status.textContent = "Loading predictions\u2026"; + if (list) list.innerHTML = ""; + try { + const resp = await fetch("/sat_passes"); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + if (data.error) { + renderSatPredictions([], data.error); + } else { + renderSatPredictions(data.passes || []); + } + } catch (e) { + renderSatPredictions([], `Failed to load predictions: ${e.message}`); + } +} + // ── Navigate to map centered on satellite image bounds ────────────── window.wxsatShowOnMap = function (south, west, north, east) { // Enable wxsat filter if not active diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index 4a54e1e..17e8cad 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -4538,7 +4538,7 @@ button:focus-visible, input:focus-visible, select:focus-visible { width: 100%; } } -/* ── Weather Satellite panel ────────────────────────────────────────── */ +/* ── SAT panel ──────────────────────────────────────────────────────── */ .wxsat-view-bar { display: flex; gap: 0; margin-bottom: 0.75rem; border-bottom: 1px solid var(--border); } .wxsat-view-btn { flex-shrink: 0; background: transparent; border: none; border-bottom: 2px solid transparent; border-radius: 0; padding: 0.3rem 0.9rem; color: var(--text-muted); cursor: pointer; font-size: 0.82rem; } .wxsat-view-active { border-bottom-color: var(--accent-green); color: var(--accent-green); font-weight: 600; } @@ -4561,7 +4561,20 @@ button:focus-visible, input:focus-visible, select:focus-visible { .wxsat-latest-card { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 0.4rem; padding: 0.6rem 0.75rem; } .wxsat-latest-card .wxsat-latest-title { font-size: 0.82rem; font-weight: 600; margin-bottom: 0.25rem; } .wxsat-latest-card .wxsat-latest-meta { font-size: 0.78rem; color: var(--text-muted); } +.sat-pred-header { display: grid; grid-template-columns: 6rem 1fr 4.5rem 5rem 6rem; gap: 0.25rem; padding: 0.25rem 0.4rem; font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.03em; border-bottom: 1px solid var(--border); } +.sat-pred-row { display: grid; grid-template-columns: 6rem 1fr 4.5rem 5rem 6rem; gap: 0.25rem; padding: 0.35rem 0.4rem; font-size: 0.82rem; border-bottom: 1px solid var(--border-faint, rgba(255,255,255,0.04)); } +.sat-pred-row:hover { background: var(--bg-hover, rgba(255,255,255,0.02)); } +.sat-pred-col-time { font-variant-numeric: tabular-nums; color: var(--text-muted); } +.sat-pred-col-sat { font-weight: 500; } +.sat-pred-col-el { font-variant-numeric: tabular-nums; text-align: right; } +.sat-pred-col-dur { font-variant-numeric: tabular-nums; color: var(--text-muted); } +.sat-pred-col-dir { color: var(--text-muted); } +.sat-pred-el-high { color: var(--accent-green); font-weight: 600; } +.sat-pred-el-mid { color: #f0a020; } +.sat-pred-el-low { color: var(--text-muted); } @media (max-width: 600px) { .wxsat-live-grid { grid-template-columns: 1fr; } .wxsat-history-header, .wxsat-history-row { grid-template-columns: 5rem 4rem 6rem 4rem 3.5rem 1fr; font-size: 0.75rem; } + .sat-pred-header, .sat-pred-row { grid-template-columns: 5.5rem 1fr 4rem 4.5rem; font-size: 0.75rem; } + .sat-pred-col-dir { display: none; } } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs index b49d610..7000e2f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs @@ -1371,6 +1371,43 @@ pub async fn clear_lrpt_decode( .await } +#[derive(serde::Serialize)] +struct SatPassesResponse { + passes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +/// Return predicted passes for all known amateur 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. +#[get("/sat_passes")] +pub async fn sat_passes(state: web::Data>) -> impl Responder { + let rig_state = state.get_ref().borrow().clone(); + let lat = rig_state.server_latitude; + let lon = rig_state.server_longitude; + + let (Some(lat), Some(lon)) = (lat, lon) else { + return web::Json(SatPassesResponse { + passes: vec![], + error: Some("No station location configured".to_string()), + }); + }; + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .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); + web::Json(SatPassesResponse { + passes, + error: None, + }) +} + #[post("/clear_ft8_decode")] pub async fn clear_ft8_decode( query: web::Query, @@ -2087,6 +2124,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(toggle_wspr_decode) .service(toggle_wxsat_decode) .service(toggle_lrpt_decode) + .service(sat_passes) .service(clear_ais_decode) .service(clear_vdes_decode) .service(clear_aprs_decode) diff --git a/src/trx-core/src/geo.rs b/src/trx-core/src/geo.rs index d3c53e8..b4fdc02 100644 --- a/src/trx-core/src/geo.rs +++ b/src/trx-core/src/geo.rs @@ -24,6 +24,10 @@ const EARTH_RADIUS_KM: f64 = 6371.0; const CELESTRAK_WEATHER_URL: &str = "https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=tle"; +/// CelesTrak amateur satellite TLE endpoint. +const CELESTRAK_HAM_URL: &str = + "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=tle"; + /// How often to refresh TLEs after the initial fetch (24 hours). const TLE_REFRESH_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); @@ -47,6 +51,44 @@ pub struct PassGeo { pub ground_track: Vec, } +/// A predicted satellite pass over the observer's location. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PassPrediction { + /// Satellite display name. + pub satellite: String, + /// NORAD catalog number. + pub norad_id: u32, + /// Acquisition of Signal: UTC timestamp in milliseconds. + pub aos_ms: i64, + /// Loss of Signal: UTC timestamp in milliseconds. + pub los_ms: i64, + /// Maximum elevation angle in degrees above horizon. + pub max_elevation_deg: f64, + /// Azimuth at AOS in degrees (0 = N, 90 = E). + pub azimuth_aos_deg: f64, + /// Azimuth at LOS in degrees. + pub azimuth_los_deg: f64, + /// Pass duration in seconds. + pub duration_s: u64, +} + +/// Well-known amateur satellites: (display name, NORAD ID). +const HAM_SATS: &[(&str, u32)] = &[ + ("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 { let upper = name.to_uppercase(); @@ -143,15 +185,13 @@ fn parse_tle_response(body: &str) -> HashMap { result } -/// Fetch fresh TLE data from CelesTrak and update the global store. -/// -/// Returns the number of TLEs loaded, or an error description. -pub async fn refresh_tles_from_celestrak() -> Result { +/// Fetch TLEs from a CelesTrak URL and merge them into the global store. +async fn fetch_and_merge_tles(url: &str) -> Result { let response = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build() .map_err(|e| format!("HTTP client error: {e}"))? - .get(CELESTRAK_WEATHER_URL) + .get(url) .send() .await .map_err(|e| format!("CelesTrak fetch failed: {e}"))?; @@ -173,17 +213,33 @@ pub async fn refresh_tles_from_celestrak() -> Result { } match TLE_STORE.write() { - Ok(mut guard) => *guard = Some(tles), + Ok(mut guard) => { + if let Some(store) = guard.as_mut() { + store.extend(tles); + } else { + *guard = Some(tles); + } + } Err(e) => { - // Recover from poisoned lock let mut guard = e.into_inner(); - *guard = Some(parse_tle_response(&body)); + if let Some(store) = guard.as_mut() { + store.extend(tles); + } else { + *guard = Some(tles); + } } } Ok(count) } +/// Fetch fresh TLE data from CelesTrak and update the global store. +/// +/// Returns the number of TLEs loaded, or an error description. +pub async fn refresh_tles_from_celestrak() -> Result { + fetch_and_merge_tles(CELESTRAK_WEATHER_URL).await +} + /// Spawn a background task that fetches TLEs from CelesTrak on start and /// then refreshes once per day. /// @@ -191,10 +247,20 @@ pub async fn refresh_tles_from_celestrak() -> Result { /// do not stop the periodic refresh — hardcoded fallback TLEs remain usable. pub fn spawn_tle_refresh_task() { tokio::spawn(async { - // Initial fetch at startup. - match refresh_tles_from_celestrak().await { - Ok(n) => tracing::info!("TLE refresh: loaded {n} satellite TLEs from CelesTrak"), - Err(e) => tracing::warn!("TLE refresh: initial fetch failed ({e}), using hardcoded TLEs"), + // Initial fetch at startup: weather + amateur satellites. + match fetch_and_merge_tles(CELESTRAK_WEATHER_URL).await { + Ok(n) => { + tracing::info!("TLE refresh: loaded {n} weather satellite TLEs from CelesTrak") + } + Err(e) => { + tracing::warn!("TLE refresh: weather fetch failed ({e}), using hardcoded TLEs") + } + } + match fetch_and_merge_tles(CELESTRAK_HAM_URL).await { + Ok(n) => { + tracing::info!("TLE refresh: loaded {n} amateur satellite TLEs from CelesTrak") + } + Err(e) => tracing::warn!("TLE refresh: amateur fetch failed ({e})"), } // Periodic refresh every 24 hours. @@ -204,14 +270,219 @@ pub fn spawn_tle_refresh_task() { loop { interval.tick().await; - match refresh_tles_from_celestrak().await { - Ok(n) => tracing::info!("TLE refresh: updated {n} satellite TLEs from CelesTrak"), - Err(e) => tracing::warn!("TLE refresh: fetch failed ({e}), keeping previous TLEs"), + match fetch_and_merge_tles(CELESTRAK_WEATHER_URL).await { + Ok(n) => { + tracing::info!("TLE refresh: updated {n} weather satellite TLEs from CelesTrak") + } + Err(e) => { + tracing::warn!("TLE refresh: weather fetch failed ({e}), keeping previous TLEs") + } + } + match fetch_and_merge_tles(CELESTRAK_HAM_URL).await { + Ok(n) => { + tracing::info!("TLE refresh: updated {n} amateur satellite TLEs from CelesTrak") + } + Err(e) => { + tracing::warn!("TLE refresh: amateur fetch failed ({e}), keeping previous TLEs") + } } } }); } +// --------------------------------------------------------------------------- +// Pass prediction +// --------------------------------------------------------------------------- + +/// Convert geodetic lat/lon (degrees) to ECEF position (km, spherical). +fn latlon_to_ecef(lat_deg: f64, lon_deg: f64) -> [f64; 3] { + let lat = lat_deg * PI / 180.0; + let lon = lon_deg * PI / 180.0; + [ + EARTH_RADIUS_KM * lat.cos() * lon.cos(), + EARTH_RADIUS_KM * lat.cos() * lon.sin(), + EARTH_RADIUS_KM * lat.sin(), + ] +} + +/// Convert ECI position (km) to ECEF using GMST rotation. +fn eci_to_ecef(x: f64, y: f64, z: f64, time_ms: i64) -> [f64; 3] { + let gmst = gmst_from_ms(time_ms); + [ + x * gmst.cos() + y * gmst.sin(), + -x * gmst.sin() + y * gmst.cos(), + z, + ] +} + +/// Compute elevation and azimuth from observer to satellite. +/// +/// Returns `(elevation_deg, azimuth_deg)` where elevation is degrees above the +/// horizon and azimuth is clockwise degrees from north. +fn compute_az_el( + sat_ecef: [f64; 3], + obs_ecef: [f64; 3], + obs_lat_rad: f64, + obs_lon_rad: f64, +) -> (f64, f64) { + let dx = sat_ecef[0] - obs_ecef[0]; + let dy = sat_ecef[1] - obs_ecef[1]; + let dz = sat_ecef[2] - obs_ecef[2]; + + // Transform delta to local East-North-Up frame. + let east = -obs_lon_rad.sin() * dx + obs_lon_rad.cos() * dy; + let north = -obs_lat_rad.sin() * obs_lon_rad.cos() * dx + - obs_lat_rad.sin() * obs_lon_rad.sin() * dy + + obs_lat_rad.cos() * dz; + let up = obs_lat_rad.cos() * obs_lon_rad.cos() * dx + + obs_lat_rad.cos() * obs_lon_rad.sin() * dy + + obs_lat_rad.sin() * dz; + + let horiz = (east * east + north * north).sqrt(); + let el_deg = up.atan2(horiz) * 180.0 / PI; + let az_deg = east.atan2(north).to_degrees().rem_euclid(360.0); + + (el_deg, az_deg) +} + +/// Scan for passes of one satellite over a time window. +fn find_passes_for_sat( + name: &str, + norad_id: u32, + line1: &str, + line2: &str, + obs_lat: f64, + obs_lon: f64, + start_ms: i64, + window_ms: i64, +) -> Vec { + let elements = + match Elements::from_tle(Some(name.to_string()), line1.as_bytes(), line2.as_bytes()) { + Ok(e) => e, + Err(_) => return vec![], + }; + let constants = match Constants::from_elements(&elements) { + Ok(c) => c, + Err(_) => return vec![], + }; + let epoch_ms = elements_epoch_ms(&elements); + + let obs_ecef = latlon_to_ecef(obs_lat, obs_lon); + let obs_lat_rad = obs_lat * PI / 180.0; + let obs_lon_rad = obs_lon * PI / 180.0; + + // 30-second scan step; fine enough for pass detection. + let step_ms = 30_000_i64; + let n_steps = (window_ms / step_ms) as usize + 2; + + let mut passes = Vec::new(); + let mut in_pass = false; + let mut aos_ms = 0_i64; + let mut aos_az = 0.0_f64; + let mut max_el = 0.0_f64; + let mut prev_az = 0.0_f64; + + for i in 0..n_steps { + let t_ms = start_ms + i as i64 * step_ms; + if t_ms > start_ms + window_ms { + break; + } + let minutes = (t_ms - epoch_ms) as f64 / 60_000.0; + let pred = match constants.propagate(MinutesSinceEpoch(minutes)) { + Ok(p) => p, + Err(_) => continue, + }; + let sat_ecef = eci_to_ecef(pred.position[0], pred.position[1], pred.position[2], t_ms); + let (el, az) = compute_az_el(sat_ecef, obs_ecef, obs_lat_rad, obs_lon_rad); + + if el > 0.0 { + if !in_pass { + in_pass = true; + aos_ms = t_ms; + aos_az = az; + max_el = el; + } else if el > max_el { + max_el = el; + } + } else if in_pass { + // LOS occurred between previous step and this step. + passes.push(PassPrediction { + satellite: name.to_string(), + norad_id, + aos_ms, + los_ms: t_ms, + max_elevation_deg: (max_el * 10.0).round() / 10.0, + azimuth_aos_deg: (aos_az * 10.0).round() / 10.0, + azimuth_los_deg: (prev_az * 10.0).round() / 10.0, + duration_s: ((t_ms - aos_ms) / 1000) as u64, + }); + in_pass = false; + max_el = 0.0; + } + prev_az = az; + } + + // Pass in progress at end of window. + if in_pass { + passes.push(PassPrediction { + satellite: name.to_string(), + norad_id, + aos_ms, + los_ms: start_ms + window_ms, + max_elevation_deg: (max_el * 10.0).round() / 10.0, + azimuth_aos_deg: (aos_az * 10.0).round() / 10.0, + azimuth_los_deg: (prev_az * 10.0).round() / 10.0, + duration_s: ((start_ms + window_ms - aos_ms) / 1000) as u64, + }); + } + + passes +} + +/// Compute upcoming passes for all known amateur satellites over the next +/// `window_ms` milliseconds, starting from `start_ms`. +/// +/// Satellites without TLE data in the store are silently skipped. +/// Results are sorted by AOS time. +pub fn compute_upcoming_passes( + station_lat: f64, + station_lon: f64, + start_ms: i64, + window_ms: i64, +) -> Vec { + let guard = match TLE_STORE.read() { + Ok(g) => g, + Err(e) => e.into_inner(), + }; + + let mut all_passes = Vec::new(); + + for &(name, norad_id) in HAM_SATS { + let tle = guard + .as_ref() + .and_then(|s| s.get(&norad_id)) + .cloned() + .or_else(|| hardcoded_tle(norad_id).map(|(l1, l2)| (l1.to_string(), l2.to_string()))); + + if let Some((line1, line2)) = tle { + let passes = find_passes_for_sat( + name, + norad_id, + &line1, + &line2, + station_lat, + station_lon, + start_ms, + window_ms, + ); + all_passes.extend(passes); + } + } + + all_passes.sort_by_key(|p| p.aos_ms); + all_passes +} + /// Compute geographic bounds and ground track for a satellite pass. /// /// Returns `None` if the satellite is unknown or propagation fails. @@ -228,7 +499,8 @@ pub fn compute_pass_geo( Some(satellite.to_string()), line1.as_bytes(), line2.as_bytes(), - ).ok()?; + ) + .ok()?; let constants = Constants::from_elements(&elements).ok()?; @@ -249,7 +521,9 @@ pub fn compute_pass_geo( let t_ms = pass_start_ms + (i as i64 * duration_ms / (n_points as i64 - 1).max(1)); let minutes_since_epoch = (t_ms - epoch_ms) as f64 / 60_000.0; - let prediction = constants.propagate(MinutesSinceEpoch(minutes_since_epoch)).ok()?; + let prediction = constants + .propagate(MinutesSinceEpoch(minutes_since_epoch)) + .ok()?; // Convert ECI position to geodetic lat/lon let (lat, lon) = eci_to_geodetic( @@ -338,12 +612,7 @@ pub fn estimate_pass_geo_from_station( /// `x`, `y`, `z` are in km (as returned by sgp4). `time_ms` is the UTC /// timestamp used to compute GMST for the ECI→ECEF rotation. fn eci_to_geodetic(x: f64, y: f64, z: f64, time_ms: i64) -> (f64, f64) { - let gmst = gmst_from_ms(time_ms); - - // Rotate ECI → ECEF - let ecef_x = x * gmst.cos() + y * gmst.sin(); - let ecef_y = -x * gmst.sin() + y * gmst.cos(); - let ecef_z = z; + let [ecef_x, ecef_y, ecef_z] = eci_to_ecef(x, y, z, time_ms); // Geodetic latitude (simple spherical approximation, sufficient for overlays) let r_xy = (ecef_x * ecef_x + ecef_y * ecef_y).sqrt(); @@ -363,8 +632,7 @@ fn gmst_from_ms(time_ms: i64) -> f64 { let t = (jd - 2_451_545.0) / 36_525.0; // GMST in degrees (IAU formula) - let gmst_deg = 280.46061837 + 360.98564736629 * (jd - 2_451_545.0) - + 0.000387933 * t * t + let gmst_deg = 280.46061837 + 360.98564736629 * (jd - 2_451_545.0) + 0.000387933 * t * t - t * t * t / 38_710_000.0; (gmst_deg % 360.0) * PI / 180.0 @@ -406,20 +674,29 @@ mod tests { fn test_km_to_deg_lat() { // ~111 km per degree of latitude let deg = km_to_deg_lat(111.0); - assert!((deg - 1.0).abs() < 0.05, "111 km should be ~1 degree, got {deg}"); + assert!( + (deg - 1.0).abs() < 0.05, + "111 km should be ~1 degree, got {deg}" + ); } #[test] fn test_km_to_deg_lon_equator() { let deg = km_to_deg_lon(111.0, 0.0); - assert!((deg - 1.0).abs() < 0.05, "111 km at equator should be ~1 degree, got {deg}"); + assert!( + (deg - 1.0).abs() < 0.05, + "111 km at equator should be ~1 degree, got {deg}" + ); } #[test] fn test_km_to_deg_lon_high_lat() { // At 60°, cos(60°) = 0.5, so 111 km ≈ 2 degrees let deg = km_to_deg_lon(111.0, 60.0); - assert!((deg - 2.0).abs() < 0.1, "111 km at 60° should be ~2 degrees, got {deg}"); + assert!( + (deg - 2.0).abs() < 0.1, + "111 km at 60° should be ~2 degrees, got {deg}" + ); } #[test] @@ -482,11 +759,17 @@ NOAA 19 let result = compute_pass_geo("NOAA-19", start, end, Some(48.0), Some(11.0)); assert!(result.is_some(), "Should produce geo for NOAA-19"); let geo = result.unwrap(); - assert!(geo.ground_track.len() >= 3, "Should have at least 3 track points"); + assert!( + geo.ground_track.len() >= 3, + "Should have at least 3 track points" + ); assert!(geo.bounds[0] < geo.bounds[2], "south < north"); // Bounds should cover a reasonable area let lat_span = geo.bounds[2] - geo.bounds[0]; - assert!(lat_span > 10.0, "Pass should span >10 deg lat, got {lat_span}"); + assert!( + lat_span > 10.0, + "Pass should span >10 deg lat, got {lat_span}" + ); } #[test] @@ -517,7 +800,13 @@ NOAA 19 .unwrap(); let ms = elements_epoch_ms(&elements); // Should be in the year 2026 range (approx 1.77e12) - assert!(ms > 1_700_000_000_000, "Epoch should be after 2023, got {ms}"); - assert!(ms < 1_900_000_000_000, "Epoch should be before 2030, got {ms}"); + assert!( + ms > 1_700_000_000_000, + "Epoch should be after 2023, got {ms}" + ); + assert!( + ms < 1_900_000_000_000, + "Epoch should be before 2030, got {ms}" + ); } }