Fix weather sat auto-scheduler and Mercator tracking

This commit is contained in:
Smittix
2026-02-19 21:55:07 +00:00
parent 37ba12daaa
commit cfe03317c9
6 changed files with 482 additions and 178 deletions

View File

@@ -566,6 +566,7 @@ def enable_schedule():
scheduler = get_weather_sat_scheduler() scheduler = get_weather_sat_scheduler()
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
try:
result = scheduler.enable( result = scheduler.enable(
lat=lat, lat=lat,
lon=lon, lon=lon,
@@ -574,6 +575,12 @@ def enable_schedule():
gain=gain_val, gain=gain_val,
bias_t=bool(data.get('bias_t', False)), bias_t=bool(data.get('bias_t', False)),
) )
except Exception as e:
logger.exception("Failed to enable weather sat scheduler")
return jsonify({
'status': 'error',
'message': 'Failed to enable scheduler'
}), 500
return jsonify({'status': 'ok', **result}) return jsonify({'status': 'ok', **result})

View File

@@ -514,6 +514,60 @@
background: var(--bg-primary, #0d1117); background: var(--bg-primary, #0d1117);
} }
.wxsat-crosshair-icon {
background: transparent;
border: none;
}
.wxsat-crosshair-marker {
position: relative;
width: 26px;
height: 26px;
}
.wxsat-crosshair-h,
.wxsat-crosshair-v,
.wxsat-crosshair-ring,
.wxsat-crosshair-dot {
position: absolute;
display: block;
}
.wxsat-crosshair-h {
top: 50%;
left: 2px;
right: 2px;
height: 1px;
background: rgba(255, 76, 76, 0.9);
transform: translateY(-50%);
}
.wxsat-crosshair-v {
left: 50%;
top: 2px;
bottom: 2px;
width: 1px;
background: rgba(255, 76, 76, 0.9);
transform: translateX(-50%);
}
.wxsat-crosshair-ring {
inset: 5px;
border: 1px solid rgba(255, 76, 76, 0.95);
border-radius: 50%;
box-shadow: 0 0 8px rgba(255, 76, 76, 0.45);
}
.wxsat-crosshair-dot {
width: 4px;
height: 4px;
left: 50%;
top: 50%;
border-radius: 50%;
background: #ff4c4c;
transform: translate(-50%, -50%);
}
/* ===== Image Gallery Panel ===== */ /* ===== Image Gallery Panel ===== */
.wxsat-gallery-panel { .wxsat-gallery-panel {
flex: 1; flex: 1;

View File

@@ -1,7 +1,7 @@
/** /**
* Weather Satellite Mode * Weather Satellite Mode
* NOAA APT and Meteor LRPT decoder interface with auto-scheduler, * NOAA APT and Meteor LRPT decoder interface with auto-scheduler,
* polar plot, ground track map, countdown, and timeline. * polar plot, mercator map, countdown, and timeline.
*/ */
const WeatherSat = (function() { const WeatherSat = (function() {
@@ -16,6 +16,8 @@ const WeatherSat = (function() {
let schedulerEnabled = false; let schedulerEnabled = false;
let groundMap = null; let groundMap = null;
let groundTrackLayer = null; let groundTrackLayer = null;
let groundOverlayLayer = null;
let satCrosshairMarker = null;
let observerMarker = null; let observerMarker = null;
let consoleEntries = []; let consoleEntries = [];
let consoleCollapsed = false; let consoleCollapsed = false;
@@ -498,7 +500,12 @@ const WeatherSat = (function() {
} }
if (!storedLat || !storedLon) { if (!storedLat || !storedLon) {
passes = [];
selectedPassIndex = -1;
renderPasses([]); renderPasses([]);
renderTimeline([]);
updateCountdownFromPasses();
updateGroundTrack(null);
return; return;
} }
@@ -517,6 +524,8 @@ const WeatherSat = (function() {
// and ground track reflect the current list after every refresh. // and ground track reflect the current list after every refresh.
if (passes.length > 0) { if (passes.length > 0) {
selectPass(0); selectPass(0);
} else {
updateGroundTrack(null);
} }
} }
} catch (err) { } catch (err) {
@@ -757,6 +766,7 @@ const WeatherSat = (function() {
zoom: 2, zoom: 2,
zoomControl: false, zoomControl: false,
attributionControl: false, attributionControl: false,
crs: L.CRS.EPSG3857, // Web Mercator projection
}); });
// Check tile provider from settings // Check tile provider from settings
@@ -771,6 +781,14 @@ const WeatherSat = (function() {
L.tileLayer(tileUrl, { maxZoom: 10 }).addTo(groundMap); L.tileLayer(tileUrl, { maxZoom: 10 }).addTo(groundMap);
groundTrackLayer = L.layerGroup().addTo(groundMap); groundTrackLayer = L.layerGroup().addTo(groundMap);
groundOverlayLayer = L.layerGroup().addTo(groundMap);
const selected = getSelectedPass();
if (selected) {
updateGroundTrack(selected);
} else {
updateSatelliteCrosshair(null);
}
// Delayed invalidation to fix sizing // Delayed invalidation to fix sizing
setTimeout(() => { if (groundMap) groundMap.invalidateSize(); }, 200); setTimeout(() => { if (groundMap) groundMap.invalidateSize(); }, 200);
@@ -783,9 +801,16 @@ const WeatherSat = (function() {
if (!groundMap || !groundTrackLayer) return; if (!groundMap || !groundTrackLayer) return;
groundTrackLayer.clearLayers(); groundTrackLayer.clearLayers();
if (!pass) {
updateSatelliteCrosshair(null);
return;
}
const track = pass.groundTrack; const track = pass.groundTrack;
if (!track || track.length === 0) return; if (!track || track.length === 0) {
updateSatelliteCrosshair(null);
return;
}
const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff'; const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff';
@@ -827,6 +852,127 @@ const WeatherSat = (function() {
if (!isNaN(lat) && !isNaN(lon)) bounds.extend([lat, lon]); if (!isNaN(lat) && !isNaN(lon)) bounds.extend([lat, lon]);
groundMap.fitBounds(bounds, { padding: [20, 20] }); groundMap.fitBounds(bounds, { padding: [20, 20] });
} catch (e) {} } catch (e) {}
updateSatelliteCrosshair(pass);
}
function updateMercatorInfo(text) {
const infoEl = document.getElementById('wxsatMercatorInfo');
if (infoEl) infoEl.textContent = text || '--';
}
function clearSatelliteCrosshair() {
if (!groundOverlayLayer || !satCrosshairMarker) return;
groundOverlayLayer.removeLayer(satCrosshairMarker);
satCrosshairMarker = null;
}
function createSatelliteCrosshairIcon() {
return L.divIcon({
className: 'wxsat-crosshair-icon',
iconSize: [26, 26],
iconAnchor: [13, 13],
html: `
<div class="wxsat-crosshair-marker">
<span class="wxsat-crosshair-h"></span>
<span class="wxsat-crosshair-v"></span>
<span class="wxsat-crosshair-ring"></span>
<span class="wxsat-crosshair-dot"></span>
</div>
`,
});
}
function getSelectedPass() {
if (selectedPassIndex < 0 || selectedPassIndex >= passes.length) return null;
return passes[selectedPassIndex];
}
function getSatellitePositionForPass(pass, atTime = new Date()) {
const track = pass?.groundTrack;
if (!Array.isArray(track) || track.length === 0) return null;
const first = track[0];
if (track.length === 1) {
const lat = Number(first.lat);
const lon = Number(first.lon);
if (!isFinite(lat) || !isFinite(lon)) return null;
return { lat, lon };
}
const start = parsePassDate(pass.startTimeISO);
const end = parsePassDate(pass.endTimeISO);
let fraction = 0;
if (start && end && end > start) {
const totalMs = end.getTime() - start.getTime();
const elapsedMs = atTime.getTime() - start.getTime();
fraction = Math.max(0, Math.min(1, elapsedMs / totalMs));
}
const lastIndex = track.length - 1;
const idxFloat = fraction * lastIndex;
const idx0 = Math.floor(idxFloat);
const idx1 = Math.min(lastIndex, idx0 + 1);
const t = idxFloat - idx0;
const p0 = track[idx0];
const p1 = track[idx1];
const lat0 = Number(p0?.lat);
const lon0 = Number(p0?.lon);
const lat1 = Number(p1?.lat);
const lon1 = Number(p1?.lon);
if (!isFinite(lat0) || !isFinite(lon0) || !isFinite(lat1) || !isFinite(lon1)) {
return null;
}
return {
lat: lat0 + ((lat1 - lat0) * t),
lon: lon0 + ((lon1 - lon0) * t),
};
}
function updateSatelliteCrosshair(pass) {
if (!groundMap || !groundOverlayLayer || typeof L === 'undefined') return;
if (!pass) {
clearSatelliteCrosshair();
updateMercatorInfo('--');
return;
}
const position = getSatellitePositionForPass(pass);
if (!position) {
clearSatelliteCrosshair();
updateMercatorInfo(`${pass.name || pass.satellite || '--'} --`);
return;
}
const latlng = [position.lat, position.lon];
if (!satCrosshairMarker) {
satCrosshairMarker = L.marker(latlng, {
icon: createSatelliteCrosshairIcon(),
interactive: false,
keyboard: false,
zIndexOffset: 800,
}).addTo(groundOverlayLayer);
} else {
satCrosshairMarker.setLatLng(latlng);
}
const tooltipText = `${pass.name || pass.satellite || 'Satellite'} ${position.lat.toFixed(2)}°, ${position.lon.toFixed(2)}°`;
if (!satCrosshairMarker.getTooltip()) {
satCrosshairMarker.bindTooltip(tooltipText, {
direction: 'top',
offset: [0, -10],
opacity: 0.9,
});
} else {
satCrosshairMarker.setTooltipContent(tooltipText);
}
updateMercatorInfo(tooltipText);
} }
// ======================== // ========================
@@ -932,6 +1078,8 @@ const WeatherSat = (function() {
// Keep timeline cursor in sync // Keep timeline cursor in sync
updateTimelineCursor(); updateTimelineCursor();
// Keep selected satellite marker synchronized with time progression.
updateSatelliteCrosshair(getSelectedPass());
} }
// ======================== // ========================
@@ -1054,13 +1202,28 @@ const WeatherSat = (function() {
}), }),
}); });
const data = await response.json(); let data = {};
try {
data = await response.json();
} catch (err) {
data = {};
}
if (!response.ok || !data || data.enabled !== true) {
schedulerEnabled = false;
updateSchedulerUI({ enabled: false, scheduled_count: 0 });
showNotification('Weather Sat', data.message || 'Failed to enable auto-scheduler');
return;
}
schedulerEnabled = true; schedulerEnabled = true;
updateSchedulerUI(data); updateSchedulerUI(data);
startStream(); startStream();
showNotification('Weather Sat', `Auto-scheduler enabled (${data.scheduled_count || 0} passes)`); showNotification('Weather Sat', `Auto-scheduler enabled (${data.scheduled_count || 0} passes)`);
} catch (err) { } catch (err) {
console.error('Failed to enable scheduler:', err); console.error('Failed to enable scheduler:', err);
schedulerEnabled = false;
updateSchedulerUI({ enabled: false, scheduled_count: 0 });
showNotification('Weather Sat', 'Failed to enable auto-scheduler'); showNotification('Weather Sat', 'Failed to enable auto-scheduler');
} }
} }
@@ -1070,7 +1233,11 @@ const WeatherSat = (function() {
*/ */
async function disableScheduler() { async function disableScheduler() {
try { try {
await fetch('/weather-sat/schedule/disable', { method: 'POST' }); const response = await fetch('/weather-sat/schedule/disable', { method: 'POST' });
if (!response.ok) {
showNotification('Weather Sat', 'Failed to disable auto-scheduler');
return;
}
schedulerEnabled = false; schedulerEnabled = false;
updateSchedulerUI({ enabled: false }); updateSchedulerUI({ enabled: false });
if (!isRunning) stopStream(); if (!isRunning) stopStream();
@@ -1086,6 +1253,7 @@ const WeatherSat = (function() {
async function checkSchedulerStatus() { async function checkSchedulerStatus() {
try { try {
const response = await fetch('/weather-sat/schedule/status'); const response = await fetch('/weather-sat/schedule/status');
if (!response.ok) return;
const data = await response.json(); const data = await response.json();
schedulerEnabled = data.enabled; schedulerEnabled = data.enabled;
updateSchedulerUI(data); updateSchedulerUI(data);

View File

@@ -2866,7 +2866,8 @@
</div> </div>
<div class="wxsat-map-container"> <div class="wxsat-map-container">
<div class="wxsat-panel-header"> <div class="wxsat-panel-header">
<span class="wxsat-panel-title">Ground Track</span> <span class="wxsat-panel-title">Mercator Projection</span>
<span class="wxsat-panel-subtitle" id="wxsatMercatorInfo">--</span>
</div> </div>
<div id="wxsatGroundMap" class="wxsat-ground-map"></div> <div id="wxsatGroundMap" class="wxsat-ground-map"></div>
</div> </div>

View File

@@ -16,6 +16,7 @@ from utils.weather_sat_scheduler import (
WeatherSatScheduler, WeatherSatScheduler,
ScheduledPass, ScheduledPass,
get_weather_sat_scheduler, get_weather_sat_scheduler,
_parse_utc_iso,
) )
@@ -327,7 +328,7 @@ class TestWeatherSatScheduler:
assert len(passes) == 1 assert len(passes) == 1
assert passes[0]['id'] == 'NOAA-18_202401011200' assert passes[0]['id'] == 'NOAA-18_202401011200'
@patch('utils.weather_sat_scheduler.predict_passes') @patch('utils.weather_sat_predict.predict_passes')
@patch('threading.Timer') @patch('threading.Timer')
def test_refresh_passes(self, mock_timer, mock_predict): def test_refresh_passes(self, mock_timer, mock_predict):
"""_refresh_passes() should schedule future passes.""" """_refresh_passes() should schedule future passes."""
@@ -361,7 +362,7 @@ class TestWeatherSatScheduler:
assert scheduler._passes[0].satellite == 'NOAA-18' assert scheduler._passes[0].satellite == 'NOAA-18'
mock_timer_instance.start.assert_called() mock_timer_instance.start.assert_called()
@patch('utils.weather_sat_scheduler.predict_passes') @patch('utils.weather_sat_predict.predict_passes')
def test_refresh_passes_skip_past(self, mock_predict): def test_refresh_passes_skip_past(self, mock_predict):
"""_refresh_passes() should skip passes that already started.""" """_refresh_passes() should skip passes that already started."""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -389,7 +390,42 @@ class TestWeatherSatScheduler:
# Should not schedule past passes # Should not schedule past passes
assert len(scheduler._passes) == 0 assert len(scheduler._passes) == 0
@patch('utils.weather_sat_scheduler.predict_passes') @patch('utils.weather_sat_predict.predict_passes')
@patch('threading.Timer')
def test_refresh_passes_active_window_triggers_immediately(self, mock_timer, mock_predict):
"""_refresh_passes() should trigger immediately during an active pass window."""
now = datetime.now(timezone.utc)
active_pass = {
'id': 'NOAA-18_ACTIVE',
'satellite': 'NOAA-18',
'name': 'NOAA 18',
'frequency': 137.9125,
'mode': 'APT',
'startTimeISO': (now - timedelta(minutes=2)).isoformat(),
'endTimeISO': (now + timedelta(minutes=8)).isoformat(),
'maxEl': 45.0,
'duration': 10.0,
'quality': 'good',
}
mock_predict.return_value = [active_pass]
pass_timer = MagicMock()
refresh_timer = MagicMock()
mock_timer.side_effect = [pass_timer, refresh_timer]
scheduler = WeatherSatScheduler()
scheduler._enabled = True
scheduler._lat = 51.5
scheduler._lon = -0.1
scheduler._refresh_passes()
assert len(scheduler._passes) == 1
first_delay = mock_timer.call_args_list[0][0][0]
assert first_delay == pytest.approx(0.0, abs=0.01)
pass_timer.start.assert_called_once()
@patch('utils.weather_sat_predict.predict_passes')
def test_refresh_passes_disabled(self, mock_predict): def test_refresh_passes_disabled(self, mock_predict):
"""_refresh_passes() should do nothing when disabled.""" """_refresh_passes() should do nothing when disabled."""
scheduler = WeatherSatScheduler() scheduler = WeatherSatScheduler()
@@ -399,7 +435,7 @@ class TestWeatherSatScheduler:
mock_predict.assert_not_called() mock_predict.assert_not_called()
@patch('utils.weather_sat_scheduler.predict_passes') @patch('utils.weather_sat_predict.predict_passes')
def test_refresh_passes_error_handling(self, mock_predict): def test_refresh_passes_error_handling(self, mock_predict):
"""_refresh_passes() should handle prediction errors.""" """_refresh_passes() should handle prediction errors."""
mock_predict.side_effect = Exception('TLE error') mock_predict.side_effect = Exception('TLE error')
@@ -716,10 +752,28 @@ class TestSchedulerConfiguration:
assert WEATHER_SAT_CAPTURE_BUFFER_SECONDS >= 0 assert WEATHER_SAT_CAPTURE_BUFFER_SECONDS >= 0
class TestUtcIsoParsing:
"""Tests for UTC ISO timestamp parsing."""
def test_parse_utc_iso_with_z_suffix(self):
"""_parse_utc_iso should handle Z timestamps."""
dt = _parse_utc_iso('2026-02-19T12:34:56Z')
assert dt.tzinfo == timezone.utc
assert dt.hour == 12
assert dt.minute == 34
assert dt.second == 56
def test_parse_utc_iso_with_legacy_suffix(self):
"""_parse_utc_iso should handle legacy +00:00Z timestamps."""
dt = _parse_utc_iso('2026-02-19T12:34:56+00:00Z')
assert dt.tzinfo == timezone.utc
assert dt.hour == 12
class TestSchedulerIntegration: class TestSchedulerIntegration:
"""Integration tests for scheduler.""" """Integration tests for scheduler."""
@patch('utils.weather_sat_scheduler.predict_passes') @patch('utils.weather_sat_predict.predict_passes')
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder') @patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
@patch('threading.Timer') @patch('threading.Timer')
def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict): def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict):

View File

@@ -7,7 +7,6 @@ Uses threading.Timer for scheduling — no external dependencies required.
from __future__ import annotations from __future__ import annotations
import threading import threading
import time
import uuid import uuid
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Any, Callable from typing import Any, Callable
@@ -49,19 +48,11 @@ class ScheduledPass:
@property @property
def start_dt(self) -> datetime: def start_dt(self) -> datetime:
dt = datetime.fromisoformat(self.start_time) return _parse_utc_iso(self.start_time)
if dt.tzinfo is None:
# Naive datetime - assume UTC
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
@property @property
def end_dt(self) -> datetime: def end_dt(self) -> datetime:
dt = datetime.fromisoformat(self.end_time) return _parse_utc_iso(self.end_time)
if dt.tzinfo is None:
# Naive datetime - assume UTC
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
@@ -243,19 +234,28 @@ class WeatherSatScheduler:
buffer = WEATHER_SAT_CAPTURE_BUFFER_SECONDS buffer = WEATHER_SAT_CAPTURE_BUFFER_SECONDS
for pass_data in passes: for pass_data in passes:
try:
sp = ScheduledPass(pass_data) sp = ScheduledPass(pass_data)
start_dt = sp.start_dt
end_dt = sp.end_dt
except Exception as e:
logger.warning(f"Skipping invalid pass data: {e}")
continue
# Skip passes that already started capture_start = start_dt - timedelta(seconds=buffer)
if sp.start_dt - timedelta(seconds=buffer) <= now: capture_end = end_dt + timedelta(seconds=buffer)
# Skip passes that are already over
if capture_end <= now:
continue continue
# Check if already in history # Check if already in history
if any(h.id == sp.id for h in history): if any(h.id == sp.id for h in history):
continue continue
# Schedule capture timer # Schedule capture timer. If we're already inside the capture
delay = (sp.start_dt - timedelta(seconds=buffer) - now).total_seconds() # window, trigger immediately instead of skipping the pass.
if delay > 0: delay = max(0.0, (capture_start - now).total_seconds())
sp._timer = threading.Timer(delay, self._execute_capture, args=[sp]) sp._timer = threading.Timer(delay, self._execute_capture, args=[sp])
sp._timer.daemon = True sp._timer.daemon = True
sp._timer.start() sp._timer.start()
@@ -381,6 +381,26 @@ class WeatherSatScheduler:
logger.error(f"Error in scheduler event callback: {e}") logger.error(f"Error in scheduler event callback: {e}")
def _parse_utc_iso(value: str) -> datetime:
"""Parse UTC ISO8601 timestamp robustly across Python versions."""
if not value:
raise ValueError("missing timestamp")
text = str(value).strip()
# Backward compatibility for malformed legacy strings.
text = text.replace('+00:00Z', 'Z')
# Python <3.11 does not accept trailing 'Z' in fromisoformat.
if text.endswith('Z'):
text = text[:-1] + '+00:00'
dt = datetime.fromisoformat(text)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
else:
dt = dt.astimezone(timezone.utc)
return dt
# Singleton # Singleton
_scheduler: WeatherSatScheduler | None = None _scheduler: WeatherSatScheduler | None = None
_scheduler_lock = threading.Lock() _scheduler_lock = threading.Lock()