[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:
Claude
2026-03-28 10:57:08 +00:00
committed by Stan Grams
parent c1b713a5b2
commit 560b6ec912
9 changed files with 757 additions and 4 deletions
+96 -2
View File
@@ -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));
}