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.set_callbacks(_progress_callback, _scheduler_event_callback)
try:
result = scheduler.enable(
lat=lat,
lon=lon,
@@ -574,6 +575,12 @@ def enable_schedule():
gain=gain_val,
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})

View File

@@ -514,6 +514,60 @@
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 ===== */
.wxsat-gallery-panel {
flex: 1;

View File

@@ -1,7 +1,7 @@
/**
* Weather Satellite Mode
* 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() {
@@ -16,6 +16,8 @@ const WeatherSat = (function() {
let schedulerEnabled = false;
let groundMap = null;
let groundTrackLayer = null;
let groundOverlayLayer = null;
let satCrosshairMarker = null;
let observerMarker = null;
let consoleEntries = [];
let consoleCollapsed = false;
@@ -498,7 +500,12 @@ const WeatherSat = (function() {
}
if (!storedLat || !storedLon) {
passes = [];
selectedPassIndex = -1;
renderPasses([]);
renderTimeline([]);
updateCountdownFromPasses();
updateGroundTrack(null);
return;
}
@@ -517,6 +524,8 @@ const WeatherSat = (function() {
// and ground track reflect the current list after every refresh.
if (passes.length > 0) {
selectPass(0);
} else {
updateGroundTrack(null);
}
}
} catch (err) {
@@ -757,6 +766,7 @@ const WeatherSat = (function() {
zoom: 2,
zoomControl: false,
attributionControl: false,
crs: L.CRS.EPSG3857, // Web Mercator projection
});
// Check tile provider from settings
@@ -771,6 +781,14 @@ const WeatherSat = (function() {
L.tileLayer(tileUrl, { maxZoom: 10 }).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
setTimeout(() => { if (groundMap) groundMap.invalidateSize(); }, 200);
@@ -783,9 +801,16 @@ const WeatherSat = (function() {
if (!groundMap || !groundTrackLayer) return;
groundTrackLayer.clearLayers();
if (!pass) {
updateSatelliteCrosshair(null);
return;
}
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';
@@ -827,6 +852,127 @@ const WeatherSat = (function() {
if (!isNaN(lat) && !isNaN(lon)) bounds.extend([lat, lon]);
groundMap.fitBounds(bounds, { padding: [20, 20] });
} 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
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;
updateSchedulerUI(data);
startStream();
showNotification('Weather Sat', `Auto-scheduler enabled (${data.scheduled_count || 0} passes)`);
} catch (err) {
console.error('Failed to enable scheduler:', err);
schedulerEnabled = false;
updateSchedulerUI({ enabled: false, scheduled_count: 0 });
showNotification('Weather Sat', 'Failed to enable auto-scheduler');
}
}
@@ -1070,7 +1233,11 @@ const WeatherSat = (function() {
*/
async function disableScheduler() {
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;
updateSchedulerUI({ enabled: false });
if (!isRunning) stopStream();
@@ -1086,6 +1253,7 @@ const WeatherSat = (function() {
async function checkSchedulerStatus() {
try {
const response = await fetch('/weather-sat/schedule/status');
if (!response.ok) return;
const data = await response.json();
schedulerEnabled = data.enabled;
updateSchedulerUI(data);

View File

@@ -2866,7 +2866,8 @@
</div>
<div class="wxsat-map-container">
<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 id="wxsatGroundMap" class="wxsat-ground-map"></div>
</div>

View File

@@ -16,6 +16,7 @@ from utils.weather_sat_scheduler import (
WeatherSatScheduler,
ScheduledPass,
get_weather_sat_scheduler,
_parse_utc_iso,
)
@@ -327,7 +328,7 @@ class TestWeatherSatScheduler:
assert len(passes) == 1
assert passes[0]['id'] == 'NOAA-18_202401011200'
@patch('utils.weather_sat_scheduler.predict_passes')
@patch('utils.weather_sat_predict.predict_passes')
@patch('threading.Timer')
def test_refresh_passes(self, mock_timer, mock_predict):
"""_refresh_passes() should schedule future passes."""
@@ -361,7 +362,7 @@ class TestWeatherSatScheduler:
assert scheduler._passes[0].satellite == 'NOAA-18'
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):
"""_refresh_passes() should skip passes that already started."""
now = datetime.now(timezone.utc)
@@ -389,7 +390,42 @@ class TestWeatherSatScheduler:
# Should not schedule past passes
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):
"""_refresh_passes() should do nothing when disabled."""
scheduler = WeatherSatScheduler()
@@ -399,7 +435,7 @@ class TestWeatherSatScheduler:
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):
"""_refresh_passes() should handle prediction errors."""
mock_predict.side_effect = Exception('TLE error')
@@ -716,10 +752,28 @@ class TestSchedulerConfiguration:
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:
"""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('threading.Timer')
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
import threading
import time
import uuid
from datetime import datetime, timezone, timedelta
from typing import Any, Callable
@@ -49,19 +48,11 @@ class ScheduledPass:
@property
def start_dt(self) -> datetime:
dt = datetime.fromisoformat(self.start_time)
if dt.tzinfo is None:
# Naive datetime - assume UTC
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
return _parse_utc_iso(self.start_time)
@property
def end_dt(self) -> datetime:
dt = datetime.fromisoformat(self.end_time)
if dt.tzinfo is None:
# Naive datetime - assume UTC
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
return _parse_utc_iso(self.end_time)
def to_dict(self) -> dict[str, Any]:
return {
@@ -243,19 +234,28 @@ class WeatherSatScheduler:
buffer = WEATHER_SAT_CAPTURE_BUFFER_SECONDS
for pass_data in passes:
try:
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
if sp.start_dt - timedelta(seconds=buffer) <= now:
capture_start = start_dt - timedelta(seconds=buffer)
capture_end = end_dt + timedelta(seconds=buffer)
# Skip passes that are already over
if capture_end <= now:
continue
# Check if already in history
if any(h.id == sp.id for h in history):
continue
# Schedule capture timer
delay = (sp.start_dt - timedelta(seconds=buffer) - now).total_seconds()
if delay > 0:
# Schedule capture timer. If we're already inside the capture
# window, trigger immediately instead of skipping the pass.
delay = max(0.0, (capture_start - now).total_seconds())
sp._timer = threading.Timer(delay, self._execute_capture, args=[sp])
sp._timer.daemon = True
sp._timer.start()
@@ -381,6 +381,26 @@ class WeatherSatScheduler:
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
_scheduler: WeatherSatScheduler | None = None
_scheduler_lock = threading.Lock()