Fix weather satellite next-pass countdown timestamps

This commit is contained in:
Smittix
2026-02-19 12:12:12 +00:00
parent 1f3eaa4826
commit 12638e2b83
3 changed files with 85 additions and 18 deletions
+48 -10
View File
@@ -461,6 +461,26 @@ const WeatherSat = (function() {
return `${m}:${s.toString().padStart(2, '0')}`; 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) * Load pass predictions (with trajectory + ground track)
*/ */
@@ -553,13 +573,15 @@ const WeatherSat = (function() {
const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt'; const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt';
const timeStr = pass.startTime || '--'; const timeStr = pass.startTime || '--';
const now = new Date(); const now = new Date();
const passStart = new Date(pass.startTimeISO); const passStart = parsePassDate(pass.startTimeISO);
const diffMs = passStart - now; const diffMs = passStart ? passStart - now : NaN;
const diffMins = Math.floor(diffMs / 60000); const diffMins = Number.isFinite(diffMs) ? Math.floor(diffMs / 60000) : NaN;
const isSelected = idx === selectedPassIndex; const isSelected = idx === selectedPassIndex;
let countdown = ''; let countdown = '--';
if (diffMs < 0) { if (!Number.isFinite(diffMs)) {
countdown = '--';
} else if (diffMs < 0) {
countdown = 'NOW'; countdown = 'NOW';
} else if (diffMins < 60) { } else if (diffMins < 60) {
countdown = `in ${diffMins}m`; countdown = `in ${diffMins}m`;
@@ -828,8 +850,11 @@ const WeatherSat = (function() {
let isActive = false; let isActive = false;
for (const pass of passes) { for (const pass of passes) {
const start = new Date(pass.startTimeISO); const start = parsePassDate(pass.startTimeISO);
const end = new Date(pass.endTimeISO); const end = parsePassDate(pass.endTimeISO);
if (!start || !end) {
continue;
}
if (end > now) { if (end > now) {
nextPass = pass; nextPass = pass;
isActive = start <= now; isActive = start <= now;
@@ -858,7 +883,19 @@ const WeatherSat = (function() {
return; 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; let diffMs = target - now;
if (isActive) { if (isActive) {
@@ -918,8 +955,9 @@ const WeatherSat = (function() {
const dayMs = 24 * 60 * 60 * 1000; const dayMs = 24 * 60 * 60 * 1000;
passList.forEach((pass, idx) => { passList.forEach((pass, idx) => {
const start = new Date(pass.startTimeISO); const start = parsePassDate(pass.startTimeISO);
const end = new Date(pass.endTimeISO); const end = parsePassDate(pass.endTimeISO);
if (!start || !end) return;
const startPct = Math.max(0, Math.min(100, ((start - dayStart) / dayMs) * 100)); const startPct = Math.max(0, Math.min(100, ((start - dayStart) / dayMs) * 100));
const endPct = Math.max(0, Math.min(100, ((end - dayStart) / dayMs) * 100)); const endPct = Math.max(0, Math.min(100, ((end - dayStart) / dayMs) * 100));
+19 -2
View File
@@ -7,10 +7,10 @@ and ground track generation.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from unittest.mock import patch, MagicMock from unittest.mock import MagicMock, patch
import pytest import pytest
from utils.weather_sat_predict import predict_passes from utils.weather_sat_predict import _format_utc_iso, predict_passes
class TestPredictPasses: class TestPredictPasses:
@@ -673,3 +673,20 @@ class TestPassDataStructure:
with patch.dict('sys.modules', {'skyfield': None, 'skyfield.api': None}): with patch.dict('sys.modules', {'skyfield': None, 'skyfield.api': None}):
with pytest.raises((ImportError, AttributeError)): with pytest.raises((ImportError, AttributeError)):
predict_passes(lat=51.5, lon=-0.1) 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'
+18 -6
View File
@@ -14,6 +14,15 @@ from utils.weather_sat import WEATHER_SATELLITES
logger = get_logger('intercept.weather_sat_predict') 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( def predict_passes(
lat: float, lat: float,
lon: float, lon: float,
@@ -38,8 +47,9 @@ def predict_passes(
Raises: Raises:
ImportError: If skyfield is not installed. ImportError: If skyfield is not installed.
""" """
from skyfield.api import load, wgs84, EarthSatellite
from skyfield.almanac import find_discrete from skyfield.almanac import find_discrete
from skyfield.api import EarthSatellite, load, wgs84
from data.satellites import TLE_SATELLITES from data.satellites import TLE_SATELLITES
# Use live TLE cache from satellite module if available (refreshed from CelesTrak) # Use live TLE cache from satellite module if available (refreshed from CelesTrak)
@@ -100,8 +110,10 @@ def predict_passes(
i += 1 i += 1
continue continue
rise_dt = rise_time.utc_datetime()
set_dt = set_time.utc_datetime()
duration_seconds = ( duration_seconds = (
set_time.utc_datetime() - rise_time.utc_datetime() set_dt - rise_dt
).total_seconds() ).total_seconds()
duration_minutes = round(duration_seconds / 60, 1) duration_minutes = round(duration_seconds / 60, 1)
@@ -141,14 +153,14 @@ def predict_passes(
_, set_az, _ = set_topo.altaz() _, set_az, _ = set_topo.altaz()
pass_data: dict[str, Any] = { 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, 'satellite': sat_key,
'name': sat_info['name'], 'name': sat_info['name'],
'frequency': sat_info['frequency'], 'frequency': sat_info['frequency'],
'mode': sat_info['mode'], 'mode': sat_info['mode'],
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'), 'startTime': rise_dt.strftime('%Y-%m-%d %H:%M UTC'),
'startTimeISO': rise_time.utc_datetime().isoformat() + 'Z', 'startTimeISO': _format_utc_iso(rise_dt),
'endTimeISO': set_time.utc_datetime().isoformat() + 'Z', 'endTimeISO': _format_utc_iso(set_dt),
'maxEl': round(max_el, 1), 'maxEl': round(max_el, 1),
'maxElAz': round(max_el_az, 1), 'maxElAz': round(max_el_az, 1),
'riseAz': round(rise_az.degrees, 1), 'riseAz': round(rise_az.degrees, 1),