Fix weather satellite next-pass countdown timestamps

This commit is contained in:
Smittix
2026-02-19 12:12:12 +00:00
parent f7fad076c2
commit cd3ed9a03b
3 changed files with 85 additions and 18 deletions

View File

@@ -461,6 +461,26 @@ const WeatherSat = (function() {
return `${m}:${s.toString().padStart(2, '0')}`;
}
/**
* Parse pass timestamps, accepting legacy malformed UTC strings (+00:00Z).
*/
function parsePassDate(value) {
if (!value || typeof value !== 'string') return null;
let parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
// Backward-compatible cleanup for accidentally double-suffixed UTC timestamps.
parsed = new Date(value.replace(/\+00:00Z$/, 'Z'));
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
return null;
}
/**
* Load pass predictions (with trajectory + ground track)
*/
@@ -553,13 +573,15 @@ const WeatherSat = (function() {
const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt';
const timeStr = pass.startTime || '--';
const now = new Date();
const passStart = new Date(pass.startTimeISO);
const diffMs = passStart - now;
const diffMins = Math.floor(diffMs / 60000);
const passStart = parsePassDate(pass.startTimeISO);
const diffMs = passStart ? passStart - now : NaN;
const diffMins = Number.isFinite(diffMs) ? Math.floor(diffMs / 60000) : NaN;
const isSelected = idx === selectedPassIndex;
let countdown = '';
if (diffMs < 0) {
let countdown = '--';
if (!Number.isFinite(diffMs)) {
countdown = '--';
} else if (diffMs < 0) {
countdown = 'NOW';
} else if (diffMins < 60) {
countdown = `in ${diffMins}m`;
@@ -828,8 +850,11 @@ const WeatherSat = (function() {
let isActive = false;
for (const pass of passes) {
const start = new Date(pass.startTimeISO);
const end = new Date(pass.endTimeISO);
const start = parsePassDate(pass.startTimeISO);
const end = parsePassDate(pass.endTimeISO);
if (!start || !end) {
continue;
}
if (end > now) {
nextPass = pass;
isActive = start <= now;
@@ -858,7 +883,19 @@ const WeatherSat = (function() {
return;
}
const target = new Date(nextPass.startTimeISO);
const target = parsePassDate(nextPass.startTimeISO);
if (!target) {
if (daysEl) daysEl.textContent = '--';
if (hoursEl) hoursEl.textContent = '--';
if (minsEl) minsEl.textContent = '--';
if (secsEl) secsEl.textContent = '--';
if (satEl) satEl.textContent = '--';
if (detailEl) detailEl.textContent = 'Invalid pass time';
if (boxes) boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => {
b.classList.remove('imminent', 'active');
});
return;
}
let diffMs = target - now;
if (isActive) {
@@ -918,8 +955,9 @@ const WeatherSat = (function() {
const dayMs = 24 * 60 * 60 * 1000;
passList.forEach((pass, idx) => {
const start = new Date(pass.startTimeISO);
const end = new Date(pass.endTimeISO);
const start = parsePassDate(pass.startTimeISO);
const end = parsePassDate(pass.endTimeISO);
if (!start || !end) return;
const startPct = Math.max(0, Math.min(100, ((start - dayStart) / dayMs) * 100));
const endPct = Math.max(0, Math.min(100, ((end - dayStart) / dayMs) * 100));

View File

@@ -7,10 +7,10 @@ and ground track generation.
from __future__ import annotations
from datetime import datetime, timezone, timedelta
from unittest.mock import patch, MagicMock
from unittest.mock import MagicMock, patch
import pytest
from utils.weather_sat_predict import predict_passes
from utils.weather_sat_predict import _format_utc_iso, predict_passes
class TestPredictPasses:
@@ -673,3 +673,20 @@ class TestPassDataStructure:
with patch.dict('sys.modules', {'skyfield': None, 'skyfield.api': None}):
with pytest.raises((ImportError, AttributeError)):
predict_passes(lat=51.5, lon=-0.1)
class TestTimestampFormatting:
"""Tests for UTC timestamp serialization helpers."""
def test_format_utc_iso_from_aware_datetime(self):
"""Aware UTC datetimes should not get a duplicate UTC suffix."""
dt = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
value = _format_utc_iso(dt)
assert value == '2024-01-01T12:00:00Z'
assert '+00:00Z' not in value
def test_format_utc_iso_from_naive_datetime(self):
"""Naive datetimes should be treated as UTC and serialized consistently."""
dt = datetime(2024, 1, 1, 12, 0, 0)
value = _format_utc_iso(dt)
assert value == '2024-01-01T12:00:00Z'

View File

@@ -14,6 +14,15 @@ from utils.weather_sat import WEATHER_SATELLITES
logger = get_logger('intercept.weather_sat_predict')
def _format_utc_iso(dt: datetime.datetime) -> str:
"""Return an ISO8601 UTC timestamp with a single timezone designator."""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=datetime.timezone.utc)
else:
dt = dt.astimezone(datetime.timezone.utc)
return dt.isoformat().replace('+00:00', 'Z')
def predict_passes(
lat: float,
lon: float,
@@ -38,8 +47,9 @@ def predict_passes(
Raises:
ImportError: If skyfield is not installed.
"""
from skyfield.api import load, wgs84, EarthSatellite
from skyfield.almanac import find_discrete
from skyfield.api import EarthSatellite, load, wgs84
from data.satellites import TLE_SATELLITES
# Use live TLE cache from satellite module if available (refreshed from CelesTrak)
@@ -100,8 +110,10 @@ def predict_passes(
i += 1
continue
rise_dt = rise_time.utc_datetime()
set_dt = set_time.utc_datetime()
duration_seconds = (
set_time.utc_datetime() - rise_time.utc_datetime()
set_dt - rise_dt
).total_seconds()
duration_minutes = round(duration_seconds / 60, 1)
@@ -141,14 +153,14 @@ def predict_passes(
_, set_az, _ = set_topo.altaz()
pass_data: dict[str, Any] = {
'id': f"{sat_key}_{rise_time.utc_datetime().strftime('%Y%m%d%H%M%S')}",
'id': f"{sat_key}_{rise_dt.strftime('%Y%m%d%H%M%S')}",
'satellite': sat_key,
'name': sat_info['name'],
'frequency': sat_info['frequency'],
'mode': sat_info['mode'],
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
'startTimeISO': rise_time.utc_datetime().isoformat() + 'Z',
'endTimeISO': set_time.utc_datetime().isoformat() + 'Z',
'startTime': rise_dt.strftime('%Y-%m-%d %H:%M UTC'),
'startTimeISO': _format_utc_iso(rise_dt),
'endTimeISO': _format_utc_iso(set_dt),
'maxEl': round(max_el, 1),
'maxElAz': round(max_el_az, 1),
'riseAz': round(rise_az.degrees, 1),