mirror of
https://github.com/smittix/intercept.git
synced 2026-04-23 22:30:00 -07:00
Fix weather sat auto-scheduler and Mercator tracking
This commit is contained in:
@@ -563,19 +563,26 @@ def enable_schedule():
|
||||
'message': 'Invalid parameter value'
|
||||
}), 400
|
||||
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||
|
||||
result = scheduler.enable(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
min_elevation=min_elev,
|
||||
device=device,
|
||||
gain=gain_val,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
|
||||
return jsonify({'status': 'ok', **result})
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||
|
||||
try:
|
||||
result = scheduler.enable(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
min_elevation=min_elev,
|
||||
device=device,
|
||||
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})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/schedule/disable', methods=['POST'])
|
||||
|
||||
@@ -509,10 +509,64 @@
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.wxsat-ground-map {
|
||||
height: 200px;
|
||||
background: var(--bg-primary, #0d1117);
|
||||
}
|
||||
.wxsat-ground-map {
|
||||
height: 200px;
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
* NOAA APT and Meteor LRPT decoder interface with auto-scheduler,
|
||||
* polar plot, mercator map, countdown, and timeline.
|
||||
*/
|
||||
|
||||
const WeatherSat = (function() {
|
||||
@@ -13,10 +13,12 @@ const WeatherSat = (function() {
|
||||
let selectedPassIndex = -1;
|
||||
let currentSatellite = null;
|
||||
let countdownInterval = null;
|
||||
let schedulerEnabled = false;
|
||||
let groundMap = null;
|
||||
let groundTrackLayer = null;
|
||||
let observerMarker = null;
|
||||
let schedulerEnabled = false;
|
||||
let groundMap = null;
|
||||
let groundTrackLayer = null;
|
||||
let groundOverlayLayer = null;
|
||||
let satCrosshairMarker = null;
|
||||
let observerMarker = null;
|
||||
let consoleEntries = [];
|
||||
let consoleCollapsed = false;
|
||||
let currentPhase = 'idle';
|
||||
@@ -40,7 +42,7 @@ const WeatherSat = (function() {
|
||||
/**
|
||||
* Load observer location into input fields
|
||||
*/
|
||||
function loadLocationInputs() {
|
||||
function loadLocationInputs() {
|
||||
const latInput = document.getElementById('wxsatObsLat');
|
||||
const lonInput = document.getElementById('wxsatObsLon');
|
||||
|
||||
@@ -484,7 +486,7 @@ const WeatherSat = (function() {
|
||||
/**
|
||||
* Load pass predictions (with trajectory + ground track)
|
||||
*/
|
||||
async function loadPasses() {
|
||||
async function loadPasses() {
|
||||
let storedLat, storedLon;
|
||||
|
||||
// Use ObserverLocation if available, otherwise fall back to localStorage
|
||||
@@ -497,31 +499,38 @@ const WeatherSat = (function() {
|
||||
storedLon = localStorage.getItem('observerLon');
|
||||
}
|
||||
|
||||
if (!storedLat || !storedLon) {
|
||||
renderPasses([]);
|
||||
return;
|
||||
}
|
||||
if (!storedLat || !storedLon) {
|
||||
passes = [];
|
||||
selectedPassIndex = -1;
|
||||
renderPasses([]);
|
||||
renderTimeline([]);
|
||||
updateCountdownFromPasses();
|
||||
updateGroundTrack(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15&trajectory=true&ground_track=true`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'ok') {
|
||||
passes = data.passes || [];
|
||||
selectedPassIndex = -1;
|
||||
renderPasses(passes);
|
||||
renderTimeline(passes);
|
||||
updateCountdownFromPasses();
|
||||
// Always select the first upcoming pass so the polar plot
|
||||
// and ground track reflect the current list after every refresh.
|
||||
if (passes.length > 0) {
|
||||
selectPass(0);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load passes:', err);
|
||||
}
|
||||
if (data.status === 'ok') {
|
||||
passes = data.passes || [];
|
||||
selectedPassIndex = -1;
|
||||
renderPasses(passes);
|
||||
renderTimeline(passes);
|
||||
updateCountdownFromPasses();
|
||||
// Always select the first upcoming pass so the polar plot
|
||||
// and ground track reflect the current list after every refresh.
|
||||
if (passes.length > 0) {
|
||||
selectPass(0);
|
||||
} else {
|
||||
updateGroundTrack(null);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load passes:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -747,17 +756,18 @@ const WeatherSat = (function() {
|
||||
/**
|
||||
* Initialize Leaflet ground track map
|
||||
*/
|
||||
function initGroundMap() {
|
||||
const container = document.getElementById('wxsatGroundMap');
|
||||
if (!container || groundMap) return;
|
||||
if (typeof L === 'undefined') return;
|
||||
|
||||
groundMap = L.map(container, {
|
||||
center: [20, 0],
|
||||
zoom: 2,
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
});
|
||||
function initGroundMap() {
|
||||
const container = document.getElementById('wxsatGroundMap');
|
||||
if (!container || groundMap) return;
|
||||
if (typeof L === 'undefined') return;
|
||||
|
||||
groundMap = L.map(container, {
|
||||
center: [20, 0],
|
||||
zoom: 2,
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
crs: L.CRS.EPSG3857, // Web Mercator projection
|
||||
});
|
||||
|
||||
// Check tile provider from settings
|
||||
let tileUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
||||
@@ -768,24 +778,39 @@ const WeatherSat = (function() {
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
L.tileLayer(tileUrl, { maxZoom: 10 }).addTo(groundMap);
|
||||
|
||||
groundTrackLayer = L.layerGroup().addTo(groundMap);
|
||||
|
||||
// Delayed invalidation to fix sizing
|
||||
setTimeout(() => { if (groundMap) groundMap.invalidateSize(); }, 200);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update ground track on the map
|
||||
*/
|
||||
function updateGroundTrack(pass) {
|
||||
if (!groundMap || !groundTrackLayer) return;
|
||||
|
||||
groundTrackLayer.clearLayers();
|
||||
|
||||
const track = pass.groundTrack;
|
||||
if (!track || track.length === 0) return;
|
||||
function updateGroundTrack(pass) {
|
||||
if (!groundMap || !groundTrackLayer) return;
|
||||
|
||||
groundTrackLayer.clearLayers();
|
||||
if (!pass) {
|
||||
updateSatelliteCrosshair(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const track = pass.groundTrack;
|
||||
if (!track || track.length === 0) {
|
||||
updateSatelliteCrosshair(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff';
|
||||
|
||||
@@ -821,13 +846,134 @@ const WeatherSat = (function() {
|
||||
}).addTo(groundTrackLayer);
|
||||
}
|
||||
|
||||
// Fit bounds
|
||||
try {
|
||||
const bounds = L.latLngBounds(latlngs);
|
||||
if (!isNaN(lat) && !isNaN(lon)) bounds.extend([lat, lon]);
|
||||
groundMap.fitBounds(bounds, { padding: [20, 20] });
|
||||
} catch (e) {}
|
||||
}
|
||||
// Fit bounds
|
||||
try {
|
||||
const bounds = L.latLngBounds(latlngs);
|
||||
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);
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Countdown
|
||||
@@ -930,9 +1076,11 @@ const WeatherSat = (function() {
|
||||
});
|
||||
}
|
||||
|
||||
// Keep timeline cursor in sync
|
||||
updateTimelineCursor();
|
||||
}
|
||||
// Keep timeline cursor in sync
|
||||
updateTimelineCursor();
|
||||
// Keep selected satellite marker synchronized with time progression.
|
||||
updateSatelliteCrosshair(getSelectedPass());
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Timeline
|
||||
@@ -1017,7 +1165,7 @@ const WeatherSat = (function() {
|
||||
/**
|
||||
* Enable auto-scheduler
|
||||
*/
|
||||
async function enableScheduler() {
|
||||
async function enableScheduler() {
|
||||
let lat, lon;
|
||||
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||
const shared = ObserverLocation.getShared();
|
||||
@@ -1041,41 +1189,60 @@ const WeatherSat = (function() {
|
||||
const gainInput = document.getElementById('weatherSatGain');
|
||||
const biasTInput = document.getElementById('weatherSatBiasT');
|
||||
|
||||
try {
|
||||
const response = await fetch('/weather-sat/schedule/enable', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
try {
|
||||
const response = await fetch('/weather-sat/schedule/enable', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
device: parseInt(deviceSelect?.value || '0', 10),
|
||||
gain: parseFloat(gainInput?.value || '40'),
|
||||
bias_t: biasTInput?.checked || false,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
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);
|
||||
showNotification('Weather Sat', 'Failed to enable auto-scheduler');
|
||||
}
|
||||
}
|
||||
bias_t: biasTInput?.checked || false,
|
||||
}),
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable auto-scheduler
|
||||
*/
|
||||
async function disableScheduler() {
|
||||
try {
|
||||
await fetch('/weather-sat/schedule/disable', { method: 'POST' });
|
||||
schedulerEnabled = false;
|
||||
updateSchedulerUI({ enabled: false });
|
||||
if (!isRunning) stopStream();
|
||||
showNotification('Weather Sat', 'Auto-scheduler disabled');
|
||||
} catch (err) {
|
||||
async function disableScheduler() {
|
||||
try {
|
||||
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();
|
||||
showNotification('Weather Sat', 'Auto-scheduler disabled');
|
||||
} catch (err) {
|
||||
console.error('Failed to disable scheduler:', err);
|
||||
}
|
||||
}
|
||||
@@ -1083,14 +1250,15 @@ const WeatherSat = (function() {
|
||||
/**
|
||||
* Check current scheduler status
|
||||
*/
|
||||
async function checkSchedulerStatus() {
|
||||
try {
|
||||
const response = await fetch('/weather-sat/schedule/status');
|
||||
const data = await response.json();
|
||||
schedulerEnabled = data.enabled;
|
||||
updateSchedulerUI(data);
|
||||
if (schedulerEnabled) startStream();
|
||||
} catch (err) {
|
||||
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);
|
||||
if (schedulerEnabled) startStream();
|
||||
} catch (err) {
|
||||
// Scheduler endpoint may not exist yet
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -4,13 +4,12 @@ Automatically captures satellite passes based on predicted pass times.
|
||||
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
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any, Callable
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.weather_sat import get_weather_sat_decoder, WEATHER_SATELLITES, CaptureProgress
|
||||
@@ -28,7 +27,7 @@ except ImportError:
|
||||
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = 30
|
||||
|
||||
|
||||
class ScheduledPass:
|
||||
class ScheduledPass:
|
||||
"""A pass scheduled for automatic capture."""
|
||||
|
||||
def __init__(self, pass_data: dict[str, Any]):
|
||||
@@ -47,21 +46,13 @@ class ScheduledPass:
|
||||
self._timer: threading.Timer | None = None
|
||||
self._stop_timer: threading.Timer | None = None
|
||||
|
||||
@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)
|
||||
|
||||
@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)
|
||||
@property
|
||||
def start_dt(self) -> datetime:
|
||||
return _parse_utc_iso(self.start_time)
|
||||
|
||||
@property
|
||||
def end_dt(self) -> datetime:
|
||||
return _parse_utc_iso(self.end_time)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -80,7 +71,7 @@ class ScheduledPass:
|
||||
}
|
||||
|
||||
|
||||
class WeatherSatScheduler:
|
||||
class WeatherSatScheduler:
|
||||
"""Auto-scheduler for weather satellite captures."""
|
||||
|
||||
def __init__(self):
|
||||
@@ -209,10 +200,10 @@ class WeatherSatScheduler:
|
||||
with self._lock:
|
||||
return [p.to_dict() for p in self._passes]
|
||||
|
||||
def _refresh_passes(self) -> None:
|
||||
"""Recompute passes and schedule timers."""
|
||||
if not self._enabled:
|
||||
return
|
||||
def _refresh_passes(self) -> None:
|
||||
"""Recompute passes and schedule timers."""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
from utils.weather_sat_predict import predict_passes
|
||||
@@ -236,30 +227,39 @@ class WeatherSatScheduler:
|
||||
p._stop_timer.cancel()
|
||||
|
||||
# Keep completed/skipped for history, replace scheduled
|
||||
history = [p for p in self._passes if p.status in ('complete', 'skipped', 'capturing')]
|
||||
self._passes = history
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
buffer = WEATHER_SAT_CAPTURE_BUFFER_SECONDS
|
||||
|
||||
for pass_data in passes:
|
||||
sp = ScheduledPass(pass_data)
|
||||
|
||||
# Skip passes that already started
|
||||
if sp.start_dt - timedelta(seconds=buffer) <= 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:
|
||||
sp._timer = threading.Timer(delay, self._execute_capture, args=[sp])
|
||||
sp._timer.daemon = True
|
||||
sp._timer.start()
|
||||
self._passes.append(sp)
|
||||
history = [p for p in self._passes if p.status in ('complete', 'skipped', 'capturing')]
|
||||
self._passes = history
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
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
|
||||
|
||||
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. 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()
|
||||
self._passes.append(sp)
|
||||
|
||||
logger.info(
|
||||
f"Scheduler refreshed: {sum(1 for p in self._passes if p.status == 'scheduled')} "
|
||||
@@ -374,11 +374,31 @@ class WeatherSatScheduler:
|
||||
|
||||
def _emit_event(self, event: dict[str, Any]) -> None:
|
||||
"""Emit scheduler event to callback."""
|
||||
if self._event_callback:
|
||||
try:
|
||||
self._event_callback(event)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in scheduler event callback: {e}")
|
||||
if self._event_callback:
|
||||
try:
|
||||
self._event_callback(event)
|
||||
except Exception as 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
|
||||
|
||||
Reference in New Issue
Block a user