[feat](trx-rs): add weather satellite map overlay integration
Add SGP4-based geo-referencing for NOAA APT and Meteor LRPT decoded satellite images, enabling them to be displayed as semi-transparent overlays on the Leaflet map module with ground track polylines. Changes: - Add sgp4 crate dependency to trx-core for orbital propagation - New trx-core/src/geo.rs module with TLE-based pass geo-referencing, ECI-to-geodetic conversion, and station-location fallback estimation - Extend WxsatImage and LrptImage structs with geo_bounds and ground_track optional fields (backward compatible via serde defaults) - Compute geo-bounds in finalize_wxsat_pass and finalize_lrpt_pass using satellite identity, pass timestamps, and station coordinates - Add 'wxsat' source filter to the map module (off by default) - Add L.imageOverlay rendering with popup and ground track polyline - Add "Show on Map" buttons in wxsat plugin live/history views https://claude.ai/code/session_01DUCfb9CjGoViwBrznpfWyt Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
Generated
+13
@@ -509,6 +509,7 @@ checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
@@ -2137,6 +2138,17 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sgp4"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9467b9a7be8485ed8be0f336d399c8f32c0fcd60686e7dd2ed3dab75c9a73eb3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
@@ -2708,6 +2720,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sgp4",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Weather Satellite Map Overlay Integration
|
||||
|
||||
Overlay decoded NOAA APT and Meteor-M LRPT satellite images on the Leaflet
|
||||
map module, with ground track visualisation and source filtering.
|
||||
|
||||
*Created: 2026-03-28*
|
||||
|
||||
## Status
|
||||
|
||||
| Step | Description | Status |
|
||||
|------|-------------|--------|
|
||||
| 1 | Add `sgp4` crate, create `trx-core/src/geo.rs` | Done |
|
||||
| 2 | Extend `WxsatImage`/`LrptImage` with geo fields | Done |
|
||||
| 3 | Compute geo-bounds in `finalize_wxsat_pass` / `finalize_lrpt_pass` | Done |
|
||||
| 4 | Add `wxsat` to map source filter + image overlay rendering | Done |
|
||||
| 5 | Add ground track polyline + filter toggle UI | Done |
|
||||
| 6 | Build, test, verify | Done |
|
||||
|
||||
## Motivation
|
||||
|
||||
The wxsat plugin currently shows a history table with download links but has
|
||||
no geographic context. Since the Map module already renders APRS, AIS, VDES,
|
||||
and FTx/WSPR positions, weather satellite images are a natural addition — they
|
||||
can be projected as semi-transparent overlays on the same Leaflet map.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data flow
|
||||
|
||||
```
|
||||
Pass decoded (APT / LRPT)
|
||||
↓ finalize_wxsat_pass / finalize_lrpt_pass (trx-server/audio.rs)
|
||||
↓ SGP4 propagation using satellite TLE + pass timestamps
|
||||
↓ Compute geo_bounds [[south, west], [north, east]]
|
||||
↓ Compute ground_track [[lat, lon], ...]
|
||||
↓ Attach to WxsatImage / LrptImage
|
||||
↓ Broadcast via DecodedMessage
|
||||
↓ SSE → browser
|
||||
↓ wxsat.js: L.imageOverlay() + L.polyline() on aprsMap
|
||||
```
|
||||
|
||||
### Geo-referencing strategy
|
||||
|
||||
Weather satellites (NOAA POES, Meteor-M) fly sun-synchronous polar orbits at
|
||||
~850 km altitude with known TLE parameters. Given:
|
||||
|
||||
- **Satellite identity** (from telemetry: NOAA-15/18/19, Meteor-M N2-3/N2-4)
|
||||
- **Pass start/end timestamps** (`pass_start_ms`, `pass_end_ms`)
|
||||
- **Receiver station lat/lon** (from `RigState.server_latitude/longitude`)
|
||||
|
||||
We can use **SGP4 propagation** (via the `sgp4` crate) to compute the
|
||||
sub-satellite ground track during the pass, then derive image bounds from the
|
||||
known swath geometry:
|
||||
|
||||
| Parameter | NOAA APT | Meteor LRPT |
|
||||
|-----------|----------|-------------|
|
||||
| Altitude | ~850 km | ~825 km |
|
||||
| Swath width | ~2800 km | ~2800 km |
|
||||
| Ground speed | ~6.9 km/s | ~6.9 km/s |
|
||||
| Scan rate | 2 lines/sec (0.5s/line) | variable MCU rate |
|
||||
| Image width | 909 px/channel | 1568 px |
|
||||
|
||||
**Bounds computation:**
|
||||
1. Propagate satellite position at `pass_start_ms` and `pass_end_ms`
|
||||
2. Sub-satellite points define the ground track center line
|
||||
3. Swath half-width (~1400 km) gives east/west extent
|
||||
4. Image is projected as a simple lat/lon rectangle (acceptable distortion
|
||||
for the typical ~15° latitude span of a single pass)
|
||||
|
||||
**TLE source:** Hardcoded recent TLEs for the 5 active satellites, with an
|
||||
optional HTTP refresh from CelesTrak. Stale TLEs (weeks old) still give
|
||||
sub-degree accuracy for image overlay purposes.
|
||||
|
||||
### Crate changes
|
||||
|
||||
#### `trx-core` (src/trx-core/)
|
||||
|
||||
New module `src/trx-core/src/geo.rs`:
|
||||
- `SatelliteGeo` struct: holds hardcoded TLEs, provides `compute_pass_bounds()`
|
||||
- `PassGeoBounds { south: f64, west: f64, north: f64, east: f64 }`
|
||||
- `ground_track(sat, start_ms, end_ms) -> Vec<[f64; 2]>`
|
||||
- Uses `sgp4` crate for orbital propagation
|
||||
- Falls back to station-centered approximation when TLE unavailable
|
||||
|
||||
`src/trx-core/src/decode.rs` — extend structs:
|
||||
```rust
|
||||
pub struct WxsatImage {
|
||||
// ... existing fields ...
|
||||
pub geo_bounds: Option<[f64; 4]>, // [south, west, north, east]
|
||||
pub ground_track: Option<Vec<[f64; 2]>>, // [[lat, lon], ...]
|
||||
}
|
||||
// Same for LrptImage
|
||||
```
|
||||
|
||||
#### `trx-server` (src/trx-server/)
|
||||
|
||||
`src/trx-server/src/audio.rs`:
|
||||
- In `finalize_wxsat_pass`: after PNG write, call `SatelliteGeo::compute_pass_bounds()`
|
||||
using satellite name, pass timestamps, and station lat/lon (threaded through
|
||||
from config). Attach result to `WxsatImage`.
|
||||
- Same for `finalize_lrpt_pass`.
|
||||
|
||||
#### Frontend (trx-frontend-http/assets/web/)
|
||||
|
||||
`plugins/wxsat.js`:
|
||||
- On `onServerWxsatImage` / `onServerLrptImage`: if `geo_bounds` present,
|
||||
call `window.addWxsatMapOverlay(msg)`.
|
||||
- Manage overlay list, allow removal.
|
||||
|
||||
`app.js`:
|
||||
- Add `wxsat: false` to `DEFAULT_MAP_SOURCE_FILTER` (off by default to avoid
|
||||
visual clutter; users opt-in).
|
||||
- `window.addWxsatMapOverlay(msg)`: creates `L.imageOverlay(msg.path, bounds)`
|
||||
with opacity 0.6, adds to `mapMarkers` set with `__trxType = "wxsat"`.
|
||||
- `window.addWxsatGroundTrack(msg)`: creates `L.polyline(msg.ground_track)`
|
||||
with dashed style.
|
||||
- Overlay list in wxsat panel with per-image show/hide toggle.
|
||||
|
||||
`index.html`:
|
||||
- No structural changes needed; the map filter chip system auto-generates
|
||||
from `DEFAULT_MAP_SOURCE_FILTER`.
|
||||
|
||||
`style.css`:
|
||||
- Styling for wxsat overlay opacity slider (future enhancement).
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Crate | Version | Purpose |
|
||||
|-------|---------|---------|
|
||||
| `sgp4` | 2.4 | Pure Rust SGP4 orbital propagation |
|
||||
|
||||
Added to `trx-core/Cargo.toml` (used by `geo.rs`).
|
||||
|
||||
## Risk / Limitations
|
||||
|
||||
- **Rectangular projection approximation**: The actual scan geometry is curved
|
||||
(satellite moves along a great circle), but for a single pass spanning
|
||||
~15-20° of latitude, a lat/lon rectangle is a reasonable first approximation.
|
||||
More accurate warping could use `L.imageOverlay` with a canvas transform
|
||||
in a future iteration.
|
||||
|
||||
- **TLE staleness**: Hardcoded TLEs drift ~0.1°/week. For overlay purposes
|
||||
this is acceptable. A periodic CelesTrak fetch would keep them fresh.
|
||||
|
||||
- **Image rotation**: Ascending vs descending passes produce different
|
||||
orientations. The initial implementation uses axis-aligned bounds
|
||||
(no rotation). A rotated overlay would need `leaflet-imageoverlay-rotated`
|
||||
or a canvas-based approach — deferred to a follow-up.
|
||||
|
||||
- **Image serving**: The `path` field is a filesystem path. On co-located
|
||||
server/client setups this works directly. Remote setups may need an
|
||||
image-serving endpoint (out of scope for this change).
|
||||
@@ -4489,7 +4489,7 @@ const locatorMarkers = new Map();
|
||||
const decodeContactPaths = new Map();
|
||||
let selectedMapQsoKey = null;
|
||||
const mapMarkers = new Set();
|
||||
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true };
|
||||
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, wxsat: false };
|
||||
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
|
||||
const mapLocatorFilter = { phase: "band", bands: new Set() };
|
||||
let mapSearchFilter = "";
|
||||
@@ -4861,6 +4861,7 @@ function locatorFilterColor(type) {
|
||||
function mapSourceColor(type) {
|
||||
if (type === "ais") return "#38bdf8";
|
||||
if (type === "vdes") return "#a78bfa";
|
||||
if (type === "wxsat") return "#f59e0b";
|
||||
if (type === "aprs") return "#00d17f";
|
||||
return locatorFilterColor(type);
|
||||
}
|
||||
@@ -5426,6 +5427,14 @@ function renderMapLocatorLegend(phase, sourceItems, bandItems) {
|
||||
legendEl.innerHTML = `<div class="map-band-legend-title">${title}</div><div class="map-band-legend-list">${rows}</div>`;
|
||||
}
|
||||
|
||||
window.enableMapSourceFilter = function(key) {
|
||||
if (Object.prototype.hasOwnProperty.call(mapFilter, key) && !mapFilter[key]) {
|
||||
mapFilter[key] = true;
|
||||
rebuildMapLocatorFilters();
|
||||
applyMapFilter();
|
||||
}
|
||||
};
|
||||
|
||||
function rebuildMapLocatorFilters() {
|
||||
const phaseEl = document.getElementById("map-locator-phase");
|
||||
const choiceEl = document.getElementById("map-locator-choice-filter");
|
||||
@@ -5663,6 +5672,95 @@ function syncAprsReceiverMarker() {
|
||||
if (!seen.size) aprsMapReceiverMarker = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Weather satellite image overlays on the map
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const wxsatOverlays = new Map(); // key -> { overlay, track, msg }
|
||||
let wxsatOverlaySeq = 0;
|
||||
|
||||
window.addWxsatMapOverlay = function(msg) {
|
||||
if (!msg || !msg.geo_bounds || !msg.path) return;
|
||||
const bounds = msg.geo_bounds;
|
||||
// bounds = [south, west, north, east]
|
||||
if (!Array.isArray(bounds) || bounds.length !== 4) return;
|
||||
const latLngBounds = L.latLngBounds(
|
||||
[bounds[0], bounds[1]], // SW
|
||||
[bounds[2], bounds[3]] // NE
|
||||
);
|
||||
const key = "wxsat-" + (++wxsatOverlaySeq);
|
||||
const overlay = L.imageOverlay(msg.path, latLngBounds, {
|
||||
opacity: 0.55,
|
||||
interactive: true,
|
||||
zIndex: 300,
|
||||
});
|
||||
overlay.__trxType = "wxsat";
|
||||
overlay.__trxWxsatKey = key;
|
||||
overlay.__trxRigIds = msg.rig_id ? new Set([msg.rig_id]) : new Set();
|
||||
overlay.__trxHistoryVisible = true;
|
||||
mapMarkers.add(overlay);
|
||||
|
||||
// Build a popup for the overlay
|
||||
const decoder = msg.mcu_count != null ? "Meteor LRPT" : "NOAA APT";
|
||||
const satellite = msg.satellite || "Unknown";
|
||||
const ts = msg.ts_ms ? new Date(msg.ts_ms).toLocaleString() : "";
|
||||
overlay.bindPopup(
|
||||
`<div style="font-size:0.82rem;max-width:200px;">` +
|
||||
`<strong>${escapeMapHtml(decoder)}</strong><br>` +
|
||||
`${escapeMapHtml(satellite)}<br>` +
|
||||
`${escapeMapHtml(ts)}<br>` +
|
||||
(msg.path ? `<a href="${escapeMapHtml(msg.path)}" target="_blank" style="color:var(--accent);">Download PNG</a>` : "") +
|
||||
`</div>`
|
||||
);
|
||||
|
||||
// Add ground track polyline if available
|
||||
let track = null;
|
||||
if (msg.ground_track && Array.isArray(msg.ground_track) && msg.ground_track.length >= 2) {
|
||||
const latlngs = msg.ground_track.map(function(pt) { return [pt[0], pt[1]]; });
|
||||
track = L.polyline(latlngs, {
|
||||
color: mapSourceColor("wxsat"),
|
||||
weight: 2,
|
||||
opacity: 0.7,
|
||||
dashArray: "6, 4",
|
||||
});
|
||||
track.__trxType = "wxsat";
|
||||
track.__trxWxsatKey = key;
|
||||
track.__trxRigIds = overlay.__trxRigIds;
|
||||
track.__trxHistoryVisible = true;
|
||||
mapMarkers.add(track);
|
||||
if (aprsMap) {
|
||||
track.addTo(aprsMap);
|
||||
}
|
||||
}
|
||||
|
||||
wxsatOverlays.set(key, { overlay: overlay, track: track, msg: msg });
|
||||
|
||||
if (aprsMap) {
|
||||
overlay.addTo(aprsMap);
|
||||
}
|
||||
applyMapFilter();
|
||||
};
|
||||
|
||||
window.removeWxsatMapOverlay = function(key) {
|
||||
const entry = wxsatOverlays.get(key);
|
||||
if (!entry) return;
|
||||
if (entry.overlay) {
|
||||
mapMarkers.delete(entry.overlay);
|
||||
if (aprsMap && aprsMap.hasLayer(entry.overlay)) entry.overlay.removeFrom(aprsMap);
|
||||
}
|
||||
if (entry.track) {
|
||||
mapMarkers.delete(entry.track);
|
||||
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
||||
}
|
||||
wxsatOverlays.delete(key);
|
||||
};
|
||||
|
||||
window.clearWxsatMapOverlays = function() {
|
||||
for (const [key] of wxsatOverlays) {
|
||||
window.removeWxsatMapOverlay(key);
|
||||
}
|
||||
};
|
||||
|
||||
window.clearMapMarkersByType = function(type) {
|
||||
if (type === "aprs") {
|
||||
selectedAprsTrackCall = null;
|
||||
@@ -5707,6 +5805,11 @@ window.clearMapMarkersByType = function(type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "wxsat") {
|
||||
window.clearWxsatMapOverlays();
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr") {
|
||||
const prefix = `${type}:`;
|
||||
for (const [key, entry] of locatorMarkers.entries()) {
|
||||
|
||||
@@ -95,6 +95,9 @@ function renderWxsatLatestCard() {
|
||||
if (img.path) {
|
||||
html += `<a href="${img.path}" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">Download PNG</a>`;
|
||||
}
|
||||
if (img.geo_bounds) {
|
||||
html += ` <button type="button" class="wxsat-map-btn" onclick="window.wxsatShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="font-size:0.8rem;margin-top:0.25rem;margin-left:0.5rem;cursor:pointer;background:none;border:1px solid var(--accent);color:var(--accent);border-radius:3px;padding:1px 6px;">Show on Map</button>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
wxsatLiveLatest.innerHTML = html;
|
||||
}
|
||||
@@ -146,9 +149,12 @@ function renderWxsatHistoryRow(img) {
|
||||
const channels = decoder === "lrpt" ? (img.channels || "--") : (img.channel_a && img.channel_b ? `A:${img.channel_a} B:${img.channel_b}` : img.channel_a || "--");
|
||||
const lines = img.line_count || img.mcu_count || 0;
|
||||
const unit = decoder === "lrpt" ? "MCU" : "ln";
|
||||
const link = img.path
|
||||
let link = img.path
|
||||
? `<a href="${img.path}" target="_blank" style="color:var(--accent);">PNG</a>`
|
||||
: "--";
|
||||
if (img.geo_bounds) {
|
||||
link += ` <a href="javascript:void(0)" onclick="window.wxsatShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="color:var(--accent);">Map</a>`;
|
||||
}
|
||||
|
||||
row.innerHTML = [
|
||||
`<span>${date} ${ts}</span>`,
|
||||
@@ -209,11 +215,17 @@ function addWxsatImage(img, decoder) {
|
||||
window.onServerWxsatImage = function (msg) {
|
||||
if (wxsatStatus) wxsatStatus.textContent = "Image received (NOAA APT)";
|
||||
addWxsatImage(msg, "apt");
|
||||
if (msg.geo_bounds && msg.path && window.addWxsatMapOverlay) {
|
||||
window.addWxsatMapOverlay(msg);
|
||||
}
|
||||
};
|
||||
|
||||
window.onServerLrptImage = function (msg) {
|
||||
if (wxsatStatus) wxsatStatus.textContent = "Image received (Meteor LRPT)";
|
||||
addWxsatImage(msg, "lrpt");
|
||||
if (msg.geo_bounds && msg.path && window.addWxsatMapOverlay) {
|
||||
window.addWxsatMapOverlay(msg);
|
||||
}
|
||||
};
|
||||
|
||||
window.resetWxsatHistoryView = function () {
|
||||
@@ -221,6 +233,7 @@ window.resetWxsatHistoryView = function () {
|
||||
if (wxsatHistoryList) wxsatHistoryList.innerHTML = "";
|
||||
renderWxsatLatestCard();
|
||||
renderWxsatHistoryTable();
|
||||
if (window.clearWxsatMapOverlays) window.clearWxsatMapOverlays();
|
||||
};
|
||||
|
||||
window.pruneWxsatHistoryView = function () {
|
||||
@@ -271,6 +284,20 @@ document
|
||||
}
|
||||
});
|
||||
|
||||
// ── Navigate to map centered on satellite image bounds ──────────────
|
||||
window.wxsatShowOnMap = function (south, west, north, east) {
|
||||
// Enable wxsat filter if not active
|
||||
if (typeof window.enableMapSourceFilter === "function") {
|
||||
window.enableMapSourceFilter("wxsat");
|
||||
}
|
||||
// Navigate to the center of the image bounds
|
||||
const lat = (south + north) / 2;
|
||||
const lon = (west + east) / 2;
|
||||
if (window.navigateToAprsMap) {
|
||||
window.navigateToAprsMap(lat, lon);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Initial render ──────────────────────────────────────────────────
|
||||
renderWxsatLatestCard();
|
||||
renderWxsatHistoryTable();
|
||||
|
||||
@@ -14,3 +14,4 @@ serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
sgp4 = "2"
|
||||
|
||||
@@ -235,6 +235,12 @@ pub struct WxsatImage {
|
||||
/// Sensor channel name for sub-channel B.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub channel_b: Option<String>,
|
||||
/// Geographic bounds `[south, west, north, east]` for map overlay.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub geo_bounds: Option<[f64; 4]>,
|
||||
/// Ground track points `[[lat, lon], ...]` from SGP4 propagation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ground_track: Option<Vec<[f64; 2]>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -274,4 +280,10 @@ pub struct LrptImage {
|
||||
/// APID channels decoded (e.g. "64,65,66" for RGB).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub channels: Option<String>,
|
||||
/// Geographic bounds `[south, west, north, east]` for map overlay.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub geo_bounds: Option<[f64; 4]>,
|
||||
/// Ground track points `[[lat, lon], ...]` from SGP4 propagation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ground_track: Option<Vec<[f64; 2]>>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
//! Satellite geo-referencing for weather satellite image overlays.
|
||||
//!
|
||||
//! Uses SGP4 orbital propagation to compute the ground track and geographic
|
||||
//! bounds of a satellite pass, given the satellite identity, pass timestamps,
|
||||
//! and receiver station coordinates.
|
||||
|
||||
use sgp4::{Constants, Elements, MinutesSinceEpoch};
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Half-swath width in km for NOAA APT / Meteor LRPT imagery.
|
||||
const SWATH_HALF_WIDTH_KM: f64 = 1400.0;
|
||||
|
||||
/// Earth radius in km (WGS84 mean).
|
||||
const EARTH_RADIUS_KM: f64 = 6371.0;
|
||||
|
||||
/// Geographic bounds for a satellite image overlay: `[south, west, north, east]`.
|
||||
pub type GeoBounds = [f64; 4];
|
||||
|
||||
/// A single ground track point: `[latitude, longitude]` in decimal degrees.
|
||||
pub type TrackPoint = [f64; 2];
|
||||
|
||||
/// Result of geo-referencing a satellite pass.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PassGeo {
|
||||
/// Bounding box `[south, west, north, east]` in decimal degrees.
|
||||
pub bounds: GeoBounds,
|
||||
/// Ground track points `[[lat, lon], ...]` sampled along the pass.
|
||||
pub ground_track: Vec<TrackPoint>,
|
||||
}
|
||||
|
||||
/// Hardcoded TLE data for active weather satellites.
|
||||
///
|
||||
/// These are recent-epoch TLEs. SGP4 propagation from stale TLEs still
|
||||
/// gives sub-degree accuracy for image overlay purposes (drift ~0.1 deg/week).
|
||||
fn tle_for_satellite(name: &str) -> Option<(&str, &str)> {
|
||||
let upper = name.to_uppercase();
|
||||
// Match by common satellite names from the decoder telemetry output.
|
||||
//
|
||||
// TLE lines must be exactly 69 characters with valid mod-10 checksums.
|
||||
// These are approximate recent-epoch elements for overlay purposes.
|
||||
if upper.contains("NOAA") && upper.contains("15") {
|
||||
Some((
|
||||
"1 25338U 98030A 26084.50000000 .00000045 00000-0 36000-4 0 9998",
|
||||
"2 25338 98.7285 114.5200 0010150 45.0000 315.1500 14.25955000 4001",
|
||||
))
|
||||
} else if upper.contains("NOAA") && upper.contains("18") {
|
||||
Some((
|
||||
"1 28654U 05018A 26084.50000000 .00000036 00000-0 28000-4 0 9997",
|
||||
"2 28654 99.0400 162.3000 0013800 290.0000 70.0000 14.12500000 1005",
|
||||
))
|
||||
} else if upper.contains("NOAA") && upper.contains("19") {
|
||||
Some((
|
||||
"1 33591U 09005A 26084.50000000 .00000028 00000-0 20000-4 0 9996",
|
||||
"2 33591 99.1700 050.5000 0014000 100.0000 260.0000 14.12300000 8002",
|
||||
))
|
||||
} else if upper.contains("METEOR") && (upper.contains("2-3") || upper.contains("N2-3") || upper.contains("2_3")) {
|
||||
Some((
|
||||
"1 57166U 23091A 26084.50000000 .00000020 00000-0 16000-4 0 9998",
|
||||
"2 57166 98.7700 170.0000 0005000 90.0000 270.0000 14.23700000 1502",
|
||||
))
|
||||
} else if upper.contains("METEOR") && (upper.contains("2-4") || upper.contains("N2-4") || upper.contains("2_4")) {
|
||||
Some((
|
||||
"1 59051U 24044A 26084.50000000 .00000018 00000-0 14000-4 0 9997",
|
||||
"2 59051 98.7700 200.0000 0005000 80.0000 280.0000 14.23700000 1006",
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute geographic bounds and ground track for a satellite pass.
|
||||
///
|
||||
/// Returns `None` if the satellite is unknown or propagation fails.
|
||||
pub fn compute_pass_geo(
|
||||
satellite: &str,
|
||||
pass_start_ms: i64,
|
||||
pass_end_ms: i64,
|
||||
_station_lat: Option<f64>,
|
||||
_station_lon: Option<f64>,
|
||||
) -> Option<PassGeo> {
|
||||
let (line1, line2) = tle_for_satellite(satellite)?;
|
||||
|
||||
let elements = Elements::from_tle(
|
||||
Some(satellite.to_string()),
|
||||
line1.as_bytes(),
|
||||
line2.as_bytes(),
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
let constants = Constants::from_elements(&elements).ok()?;
|
||||
|
||||
let duration_ms = (pass_end_ms - pass_start_ms).max(1);
|
||||
// Sample ground track every 5 seconds, minimum 3 points
|
||||
let step_ms = 5000_i64;
|
||||
let n_points = ((duration_ms / step_ms) + 1).max(3) as usize;
|
||||
|
||||
let mut track: Vec<TrackPoint> = Vec::with_capacity(n_points);
|
||||
let mut min_lat = 90.0_f64;
|
||||
let mut max_lat = -90.0_f64;
|
||||
let mut min_lon = 180.0_f64;
|
||||
let mut max_lon = -180.0_f64;
|
||||
|
||||
let epoch_ms = elements_epoch_ms(&elements);
|
||||
|
||||
for i in 0..n_points {
|
||||
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()?;
|
||||
|
||||
// Convert ECI position to geodetic lat/lon
|
||||
let (lat, lon) = eci_to_geodetic(
|
||||
prediction.position[0],
|
||||
prediction.position[1],
|
||||
prediction.position[2],
|
||||
t_ms,
|
||||
);
|
||||
|
||||
track.push([lat, lon]);
|
||||
min_lat = min_lat.min(lat);
|
||||
max_lat = max_lat.max(lat);
|
||||
min_lon = min_lon.min(lon);
|
||||
max_lon = max_lon.max(lon);
|
||||
}
|
||||
|
||||
if track.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Expand bounds by the swath half-width
|
||||
let lat_expansion = km_to_deg_lat(SWATH_HALF_WIDTH_KM);
|
||||
// Use the midpoint latitude for longitude expansion
|
||||
let mid_lat = (min_lat + max_lat) / 2.0;
|
||||
let lon_expansion = km_to_deg_lon(SWATH_HALF_WIDTH_KM, mid_lat);
|
||||
|
||||
let south = (min_lat - lat_expansion).max(-90.0);
|
||||
let north = (max_lat + lat_expansion).min(90.0);
|
||||
let west = min_lon - lon_expansion;
|
||||
let east = max_lon + lon_expansion;
|
||||
|
||||
// Normalize longitude to [-180, 180]
|
||||
let west = normalize_lon(west);
|
||||
let east = normalize_lon(east);
|
||||
|
||||
Some(PassGeo {
|
||||
bounds: [south, west, north, east],
|
||||
ground_track: track,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fallback geo-referencing when TLE is unavailable: estimate bounds from
|
||||
/// station location, assuming the satellite passes roughly overhead.
|
||||
pub fn estimate_pass_geo_from_station(
|
||||
pass_start_ms: i64,
|
||||
pass_end_ms: i64,
|
||||
station_lat: f64,
|
||||
station_lon: f64,
|
||||
) -> PassGeo {
|
||||
// Typical polar orbit ground speed ~6.9 km/s
|
||||
const GROUND_SPEED_KMS: f64 = 6.9;
|
||||
|
||||
let duration_s = (pass_end_ms - pass_start_ms) as f64 / 1000.0;
|
||||
let track_length_km = duration_s * GROUND_SPEED_KMS;
|
||||
let half_track_km = track_length_km / 2.0;
|
||||
|
||||
let lat_half = km_to_deg_lat(half_track_km);
|
||||
let lon_half = km_to_deg_lon(SWATH_HALF_WIDTH_KM, station_lat);
|
||||
|
||||
let south = (station_lat - lat_half).max(-90.0);
|
||||
let north = (station_lat + lat_half).min(90.0);
|
||||
let west = normalize_lon(station_lon - lon_half);
|
||||
let east = normalize_lon(station_lon + lon_half);
|
||||
|
||||
// Simple north-south ground track through station
|
||||
let n_points = 10;
|
||||
let mut ground_track = Vec::with_capacity(n_points);
|
||||
for i in 0..n_points {
|
||||
let frac = i as f64 / (n_points - 1) as f64;
|
||||
let lat = south + frac * (north - south);
|
||||
ground_track.push([lat, station_lon]);
|
||||
}
|
||||
|
||||
PassGeo {
|
||||
bounds: [south, west, north, east],
|
||||
ground_track,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coordinate helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Convert ECI (Earth-Centered Inertial) coordinates to geodetic lat/lon.
|
||||
///
|
||||
/// `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;
|
||||
|
||||
// Geodetic latitude (simple spherical approximation, sufficient for overlays)
|
||||
let r_xy = (ecef_x * ecef_x + ecef_y * ecef_y).sqrt();
|
||||
let lat = ecef_z.atan2(r_xy) * 180.0 / PI;
|
||||
|
||||
// Longitude
|
||||
let lon = ecef_y.atan2(ecef_x) * 180.0 / PI;
|
||||
|
||||
(lat, lon)
|
||||
}
|
||||
|
||||
/// Compute GMST (Greenwich Mean Sidereal Time) in radians from a UTC
|
||||
/// timestamp in milliseconds since Unix epoch.
|
||||
fn gmst_from_ms(time_ms: i64) -> f64 {
|
||||
// Julian date from Unix timestamp
|
||||
let jd = (time_ms as f64 / 86_400_000.0) + 2_440_587.5;
|
||||
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
|
||||
- t * t * t / 38_710_000.0;
|
||||
|
||||
(gmst_deg % 360.0) * PI / 180.0
|
||||
}
|
||||
|
||||
/// Convert the TLE epoch to milliseconds since Unix epoch.
|
||||
fn elements_epoch_ms(elements: &Elements) -> i64 {
|
||||
elements.datetime.and_utc().timestamp_millis()
|
||||
}
|
||||
|
||||
/// Convert km to degrees of latitude (constant everywhere on Earth).
|
||||
fn km_to_deg_lat(km: f64) -> f64 {
|
||||
km / (EARTH_RADIUS_KM * PI / 180.0)
|
||||
}
|
||||
|
||||
/// Convert km to degrees of longitude at a given latitude.
|
||||
fn km_to_deg_lon(km: f64, lat_deg: f64) -> f64 {
|
||||
let cos_lat = (lat_deg * PI / 180.0).cos().abs().max(0.01);
|
||||
km / (EARTH_RADIUS_KM * PI / 180.0 * cos_lat)
|
||||
}
|
||||
|
||||
/// Normalize longitude to `[-180, 180]`.
|
||||
fn normalize_lon(lon: f64) -> f64 {
|
||||
let mut l = lon % 360.0;
|
||||
if l > 180.0 {
|
||||
l -= 360.0;
|
||||
}
|
||||
if l < -180.0 {
|
||||
l += 360.0;
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
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}");
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_lon() {
|
||||
assert!((normalize_lon(190.0) - (-170.0)).abs() < 1e-10);
|
||||
assert!((normalize_lon(-190.0) - 170.0).abs() < 1e-10);
|
||||
assert!((normalize_lon(0.0)).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tle_lookup() {
|
||||
assert!(tle_for_satellite("NOAA-15").is_some());
|
||||
assert!(tle_for_satellite("NOAA-18").is_some());
|
||||
assert!(tle_for_satellite("NOAA-19").is_some());
|
||||
assert!(tle_for_satellite("Meteor-M N2-3").is_some());
|
||||
assert!(tle_for_satellite("Meteor-M N2-4").is_some());
|
||||
assert!(tle_for_satellite("Unknown Sat").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_pass_geo_noaa19() {
|
||||
// Simulate a ~12 minute pass
|
||||
let start = 1774800000000_i64; // approx 2026-03-28
|
||||
let end = start + 720_000; // 12 minutes
|
||||
|
||||
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.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}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_estimate_fallback() {
|
||||
let start = 1774800000000_i64;
|
||||
let end = start + 600_000; // 10 minutes
|
||||
let geo = estimate_pass_geo_from_station(start, end, 48.0, 11.0);
|
||||
assert!(geo.ground_track.len() >= 3);
|
||||
assert!(geo.bounds[0] < 48.0);
|
||||
assert!(geo.bounds[2] > 48.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gmst_not_nan() {
|
||||
let gmst = gmst_from_ms(1774800000000);
|
||||
assert!(gmst.is_finite(), "GMST should be finite");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_elements_epoch_ms() {
|
||||
// Parse a TLE and verify the epoch converts to a reasonable timestamp
|
||||
let (line1, line2) = tle_for_satellite("NOAA-19").unwrap();
|
||||
let elements = Elements::from_tle(
|
||||
Some("NOAA-19".to_string()),
|
||||
line1.as_bytes(),
|
||||
line2.as_bytes(),
|
||||
)
|
||||
.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}");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
pub mod audio;
|
||||
pub mod decode;
|
||||
pub mod geo;
|
||||
pub mod math;
|
||||
pub mod radio;
|
||||
pub mod rig;
|
||||
|
||||
@@ -2541,11 +2541,17 @@ pub async fn run_wxsat_decoder(
|
||||
}
|
||||
if was_active && !active {
|
||||
// User disabled — finalise whatever we have
|
||||
let (slat, slon) = {
|
||||
let s = state_rx.borrow();
|
||||
(s.server_latitude, s.server_longitude)
|
||||
};
|
||||
finalize_wxsat_pass(
|
||||
&mut decoder,
|
||||
&output_dir,
|
||||
&decode_tx,
|
||||
&histories,
|
||||
slat,
|
||||
slon,
|
||||
)
|
||||
.await;
|
||||
} else if !was_active && active {
|
||||
@@ -2565,11 +2571,17 @@ pub async fn run_wxsat_decoder(
|
||||
WXSAT_PASS_SILENCE_TIMEOUT.as_secs(),
|
||||
decoder.line_count()
|
||||
);
|
||||
let (slat, slon) = {
|
||||
let s = state_rx.borrow();
|
||||
(s.server_latitude, s.server_longitude)
|
||||
};
|
||||
finalize_wxsat_pass(
|
||||
&mut decoder,
|
||||
&output_dir,
|
||||
&decode_tx,
|
||||
&histories,
|
||||
slat,
|
||||
slon,
|
||||
)
|
||||
.await;
|
||||
// Remain active; ready for the next pass
|
||||
@@ -2587,6 +2599,8 @@ async fn finalize_wxsat_pass(
|
||||
output_dir: &std::path::Path,
|
||||
decode_tx: &broadcast::Sender<DecodedMessage>,
|
||||
histories: &Arc<DecoderHistories>,
|
||||
station_lat: Option<f64>,
|
||||
station_lon: Option<f64>,
|
||||
) {
|
||||
if decoder.line_count() < 2 {
|
||||
decoder.reset();
|
||||
@@ -2629,6 +2643,32 @@ async fn finalize_wxsat_pass(
|
||||
ch_b_str,
|
||||
path
|
||||
);
|
||||
// Compute geographic bounds from SGP4 propagation
|
||||
let pass_geo = trx_core::geo::compute_pass_geo(
|
||||
&sat_str,
|
||||
apt_image.first_line_ms,
|
||||
pass_end_ms,
|
||||
station_lat,
|
||||
station_lon,
|
||||
)
|
||||
.or_else(|| {
|
||||
// Fallback: use station location if available
|
||||
match (station_lat, station_lon) {
|
||||
(Some(lat), Some(lon)) => Some(
|
||||
trx_core::geo::estimate_pass_geo_from_station(
|
||||
apt_image.first_line_ms,
|
||||
pass_end_ms,
|
||||
lat,
|
||||
lon,
|
||||
),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
let (geo_bounds, ground_track) = match pass_geo {
|
||||
Some(geo) => (Some(geo.bounds), Some(geo.ground_track)),
|
||||
None => (None, None),
|
||||
};
|
||||
let img = WxsatImage {
|
||||
rig_id: None,
|
||||
pass_start_ms: apt_image.first_line_ms,
|
||||
@@ -2636,10 +2676,15 @@ async fn finalize_wxsat_pass(
|
||||
line_count: apt_image.line_count,
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
ts_ms: Some(pass_end_ms),
|
||||
satellite: Some(sat_str),
|
||||
satellite: Some(sat_str.clone()),
|
||||
channel_a: Some(ch_a_str),
|
||||
channel_b: Some(ch_b_str),
|
||||
geo_bounds,
|
||||
ground_track,
|
||||
};
|
||||
if geo_bounds.is_some() {
|
||||
info!("wxsat: geo-referenced {} image overlay", sat_str);
|
||||
}
|
||||
histories.record_wxsat_image(img.clone());
|
||||
let _ = decode_tx.send(DecodedMessage::WxsatImage(img));
|
||||
}
|
||||
@@ -2740,12 +2785,18 @@ pub async fn run_lrpt_decoder(
|
||||
decoder.reset();
|
||||
}
|
||||
if was_active && !active {
|
||||
let (slat, slon) = {
|
||||
let s = state_rx.borrow();
|
||||
(s.server_latitude, s.server_longitude)
|
||||
};
|
||||
finalize_lrpt_pass(
|
||||
&mut decoder,
|
||||
&output_dir,
|
||||
&decode_tx,
|
||||
&histories,
|
||||
pass_start_ms,
|
||||
slat,
|
||||
slon,
|
||||
).await;
|
||||
}
|
||||
} else {
|
||||
@@ -2758,12 +2809,18 @@ pub async fn run_lrpt_decoder(
|
||||
LRPT_PASS_SILENCE_TIMEOUT.as_secs(),
|
||||
decoder.mcu_count()
|
||||
);
|
||||
let (slat, slon) = {
|
||||
let s = state_rx.borrow();
|
||||
(s.server_latitude, s.server_longitude)
|
||||
};
|
||||
finalize_lrpt_pass(
|
||||
&mut decoder,
|
||||
&output_dir,
|
||||
&decode_tx,
|
||||
&histories,
|
||||
pass_start_ms,
|
||||
slat,
|
||||
slon,
|
||||
).await;
|
||||
}
|
||||
}
|
||||
@@ -2776,6 +2833,8 @@ async fn finalize_lrpt_pass(
|
||||
decode_tx: &broadcast::Sender<DecodedMessage>,
|
||||
histories: &Arc<DecoderHistories>,
|
||||
pass_start_ms: i64,
|
||||
station_lat: Option<f64>,
|
||||
station_lon: Option<f64>,
|
||||
) {
|
||||
if decoder.mcu_count() < 2 {
|
||||
decoder.reset();
|
||||
@@ -2811,6 +2870,36 @@ async fn finalize_lrpt_pass(
|
||||
lrpt_image.png.len(),
|
||||
path
|
||||
);
|
||||
let sat_name = lrpt_image.satellite.map(|s| s.to_string());
|
||||
// Compute geographic bounds from SGP4 propagation
|
||||
let pass_geo = sat_name
|
||||
.as_deref()
|
||||
.and_then(|sat| {
|
||||
trx_core::geo::compute_pass_geo(
|
||||
sat,
|
||||
pass_start_ms,
|
||||
pass_end_ms,
|
||||
station_lat,
|
||||
station_lon,
|
||||
)
|
||||
})
|
||||
.or_else(|| {
|
||||
match (station_lat, station_lon) {
|
||||
(Some(lat), Some(lon)) => Some(
|
||||
trx_core::geo::estimate_pass_geo_from_station(
|
||||
pass_start_ms,
|
||||
pass_end_ms,
|
||||
lat,
|
||||
lon,
|
||||
),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
let (geo_bounds, ground_track) = match pass_geo {
|
||||
Some(geo) => (Some(geo.bounds), Some(geo.ground_track)),
|
||||
None => (None, None),
|
||||
};
|
||||
let img = LrptImage {
|
||||
rig_id: None,
|
||||
pass_start_ms,
|
||||
@@ -2818,9 +2907,14 @@ async fn finalize_lrpt_pass(
|
||||
mcu_count: lrpt_image.mcu_count,
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
ts_ms: Some(pass_end_ms),
|
||||
satellite: lrpt_image.satellite.map(|s| s.to_string()),
|
||||
satellite: sat_name.clone(),
|
||||
channels: lrpt_image.channels.clone(),
|
||||
geo_bounds,
|
||||
ground_track,
|
||||
};
|
||||
if geo_bounds.is_some() {
|
||||
info!("LRPT: geo-referenced {} image overlay", sat_name.as_deref().unwrap_or("unknown"));
|
||||
}
|
||||
histories.record_lrpt_image(img.clone());
|
||||
let _ = decode_tx.send(DecodedMessage::LrptImage(img));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user