mirror of
https://github.com/smittix/intercept.git
synced 2026-07-09 10:08:12 -07:00
Fix weather satellite next-pass countdown timestamps
This commit is contained in:
@@ -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));
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user