Radiosonde + sat track updates
This commit is contained in:
@@ -1,59 +1,234 @@
|
||||
import {cfg} from './config.mjs';
|
||||
import {isEqual} from '@ztimson/utils';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
|
||||
const HISTORY_LIMIT = 500;
|
||||
const POLL_MS = 5_000;
|
||||
const TTL_MS = 3 * 60_000;
|
||||
const MAX_PREDICTION_DT = 30;
|
||||
const MAX_AZ_ERROR = 35;
|
||||
const MAX_EL_ERROR = 20;
|
||||
const MAX_GROUND_DISTANCE_KM = 1000;
|
||||
const MAX_FREQ_ERROR_KHZ = 15;
|
||||
|
||||
const satellites = new Map();
|
||||
|
||||
function normalizeAzimuth(az) {
|
||||
return ((az % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
function circularDistance(a, b) {
|
||||
const diff = Math.abs(normalizeAzimuth(a) - normalizeAzimuth(b));
|
||||
return Math.min(diff, 360 - diff);
|
||||
}
|
||||
|
||||
function circularDelta(from, to) {
|
||||
let delta = normalizeAzimuth(to) - normalizeAzimuth(from);
|
||||
if (delta > 180) delta -= 360;
|
||||
if (delta < -180) delta += 360;
|
||||
return delta;
|
||||
}
|
||||
|
||||
function haversineDistance(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371;
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1 * Math.PI / 180) *
|
||||
Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
function calculateBearing(lat1, lon1, lat2, lon2) {
|
||||
const φ1 = lat1 * Math.PI / 180;
|
||||
const φ2 = lat2 * Math.PI / 180;
|
||||
const λ = (lon2 - lon1) * Math.PI / 180;
|
||||
const y = Math.sin(λ) * Math.cos(φ2);
|
||||
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(λ);
|
||||
return normalizeAzimuth(Math.atan2(y, x) * 180 / Math.PI);
|
||||
}
|
||||
|
||||
function parseWm(raw) {
|
||||
const f = raw.replace(/<[^>]+>/g, '').split(',').map(s => s.trim());
|
||||
const [latitude, longitude] = f[15].split('/').map(s => parseFloat(s));
|
||||
const [az, el] = f[16].split('/').map(s => parseFloat(s));
|
||||
const [latitude, longitude] = (f[15] || '').split('/').map(s => parseFloat(s));
|
||||
const [az, el] = (f[16] || '').split('/').map(s => parseFloat(s));
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
freqMHz: parseFloat(f[3]),
|
||||
satellite: f[14],
|
||||
latitude, longitude,
|
||||
az, el,
|
||||
latitude,
|
||||
longitude,
|
||||
az: normalizeAzimuth(az),
|
||||
el,
|
||||
packetRssi: parseFloat(f[21]),
|
||||
packetSnr: parseFloat(f[22]),
|
||||
freqError: parseFloat(f[23]),
|
||||
crcOk: !/CRC ERROR/i.test(f[24]),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const satellites = new Map();
|
||||
function createTrack(reading) {
|
||||
const id = randomUUID();
|
||||
const track = {
|
||||
id,
|
||||
name: reading.satellite,
|
||||
history: [],
|
||||
lastSeen: reading.timestamp,
|
||||
state: {
|
||||
az: reading.az,
|
||||
el: reading.el,
|
||||
latitude: reading.latitude,
|
||||
longitude: reading.longitude,
|
||||
freqError: reading.freqError,
|
||||
azVelocity: 0,
|
||||
elVelocity: 0,
|
||||
latitudeVelocity: 0,
|
||||
longitudeVelocity: 0,
|
||||
groundSpeedKmh: 0,
|
||||
heading: null,
|
||||
freqErrorVelocity: 0,
|
||||
confidence: 0.25,
|
||||
},
|
||||
};
|
||||
|
||||
track.history.push(reading);
|
||||
satellites.set(id, track);
|
||||
return track;
|
||||
}
|
||||
|
||||
function predictTrack(track, timestamp) {
|
||||
const dtRaw = (timestamp - track.lastSeen) / 1000;
|
||||
const dt = Math.max(0, Math.min(dtRaw, MAX_PREDICTION_DT));
|
||||
const state = track.state;
|
||||
return {
|
||||
az: normalizeAzimuth(state.az + state.azVelocity * dt),
|
||||
el: state.el + state.elVelocity * dt,
|
||||
latitude: state.latitude + state.latitudeVelocity * dt,
|
||||
longitude: state.longitude + state.longitudeVelocity * dt,
|
||||
freqError: state.freqError + state.freqErrorVelocity * dt,
|
||||
dt,
|
||||
};
|
||||
}
|
||||
|
||||
function scoreTrack(track, reading) {
|
||||
if(!track || !reading) return Infinity;
|
||||
const dt = (reading.timestamp - track.lastSeen) / 1000;
|
||||
if(dt <= 0 || dt > MAX_PREDICTION_DT) return Infinity;
|
||||
if(track.name !== reading.satellite) return Infinity;
|
||||
|
||||
const predicted = predictTrack(track, reading.timestamp);
|
||||
const azError = circularDistance(predicted.az, reading.az);
|
||||
const elError = Math.abs(predicted.el - reading.el);
|
||||
if(azError > MAX_AZ_ERROR || elError > MAX_EL_ERROR) return Infinity;
|
||||
|
||||
let groundDistance = 0;
|
||||
if(Number.isFinite(reading.latitude) && Number.isFinite(reading.longitude) && Number.isFinite(predicted.latitude) && Number.isFinite(predicted.longitude)) {
|
||||
groundDistance = haversineDistance(predicted.latitude, predicted.longitude, reading.latitude, reading.longitude);
|
||||
if(groundDistance > MAX_GROUND_DISTANCE_KM) return Infinity;
|
||||
}
|
||||
|
||||
let freqErrorDifference = 0;
|
||||
if(Number.isFinite(reading.freqError) && Number.isFinite(predicted.freqError)) {
|
||||
freqErrorDifference = Math.abs(reading.freqError - predicted.freqError);
|
||||
if(freqErrorDifference > MAX_FREQ_ERROR_KHZ) return Infinity;
|
||||
}
|
||||
|
||||
return (azError * 4 + elError * 5 + groundDistance * 0.03 + freqErrorDifference * 0.15); // Lower = better
|
||||
}
|
||||
|
||||
function updateTrack(track, reading) {
|
||||
const previous = track.history.at(-1);
|
||||
|
||||
if (!previous) {
|
||||
track.history.push(reading);
|
||||
track.lastSeen = reading.timestamp;
|
||||
return;
|
||||
}
|
||||
|
||||
const dt = (reading.timestamp - previous.timestamp) / 1000;
|
||||
if(dt <= 0) return;
|
||||
|
||||
const state = track.state;
|
||||
const azVelocity = circularDelta(previous.az, reading.az) / dt;
|
||||
const elVelocity = (reading.el - previous.el) / dt;
|
||||
const latitudeVelocity = (reading.latitude - previous.latitude) / dt;
|
||||
const longitudeVelocity = (reading.longitude - previous.longitude) / dt;
|
||||
const distanceKm = haversineDistance(previous.latitude, previous.longitude, reading.latitude, reading.longitude);
|
||||
const groundSpeedKmh = distanceKm / dt * 3600;
|
||||
const heading = distanceKm > 0.01 ? calculateBearing(previous.latitude, previous.longitude, reading.latitude, reading.longitude) : state.heading;
|
||||
const freqErrorVelocity = Number.isFinite(reading.freqError) && Number.isFinite(previous.freqError) ? (reading.freqError - previous.freqError) / dt : state.freqErrorVelocity;
|
||||
const SMOOTHING = 0.35;
|
||||
|
||||
state.azVelocity = state.azVelocity * (1 - SMOOTHING) + azVelocity * SMOOTHING;
|
||||
state.elVelocity = state.elVelocity * (1 - SMOOTHING) + elVelocity * SMOOTHING;
|
||||
state.latitudeVelocity = state.latitudeVelocity * (1 - SMOOTHING) + latitudeVelocity * SMOOTHING;
|
||||
state.longitudeVelocity = state.longitudeVelocity * (1 - SMOOTHING) + longitudeVelocity * SMOOTHING;
|
||||
state.freqErrorVelocity = state.freqErrorVelocity * (1 - SMOOTHING) + freqErrorVelocity * SMOOTHING;
|
||||
state.az = reading.az;
|
||||
state.el = reading.el;
|
||||
state.latitude = reading.latitude;
|
||||
state.longitude = reading.longitude;
|
||||
state.freqError = reading.freqError;
|
||||
state.groundSpeedKmh = groundSpeedKmh;
|
||||
state.heading = heading;
|
||||
|
||||
const prediction = predictTrack(track, reading.timestamp);
|
||||
const predictionError = circularDistance(prediction.az, reading.az) + Math.abs(prediction.el - reading.el);
|
||||
|
||||
if(predictionError < 5) {
|
||||
state.confidence = Math.min(1, state.confidence + 0.08);
|
||||
} else if(predictionError < 15) {
|
||||
state.confidence = Math.min(1, state.confidence + 0.03);
|
||||
} else {
|
||||
state.confidence = Math.max(0, state.confidence - 0.08);
|
||||
}
|
||||
|
||||
track.lastSeen = reading.timestamp;
|
||||
if(!isEqual(track.history.at(-1), reading)) {
|
||||
track.history.push(reading);
|
||||
if(track.history.length > HISTORY_LIMIT) track.history.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function pruneStale() {
|
||||
const now = Date.now();
|
||||
for (const [name, bucket] of satellites) {
|
||||
if (now - bucket.lastSeen > TTL_MS) satellites.delete(name);
|
||||
for(const [id, track] of satellites) {
|
||||
if(now - track.lastSeen > TTL_MS) satellites.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function findBestTrack(reading) {
|
||||
let bestTrack = null;
|
||||
let bestScore = Infinity;
|
||||
for(const track of satellites.values()) {
|
||||
const score = scoreTrack(track, reading);
|
||||
if(score < bestScore) {
|
||||
bestScore = score;
|
||||
bestTrack = track;
|
||||
}
|
||||
}
|
||||
return {track: bestTrack, score: bestScore,};
|
||||
}
|
||||
|
||||
export async function pollTinyGS() {
|
||||
const { TINYGS_URL, TINYGS_AUTH } = cfg();
|
||||
const {TINYGS_URL, TINYGS_AUTH} = cfg();
|
||||
try {
|
||||
const raw = await fetch(TINYGS_URL + '/wm', {
|
||||
headers: TINYGS_AUTH ? {Authorization: `Basic ${TINYGS_AUTH}`} : {}
|
||||
headers: TINYGS_AUTH ? {Authorization: `Basic ${TINYGS_AUTH}`} : {},
|
||||
}).then(r => r.text());
|
||||
const reading = parseWm(raw);
|
||||
if (!reading.satellite || reading.satellite === '-') return;
|
||||
|
||||
const key = reading.satellite + '-' + reading.freqMHz;
|
||||
let bucket = satellites.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { history: [], lastSeen: 0 };
|
||||
satellites.set(key, bucket);
|
||||
}
|
||||
|
||||
bucket.lastSeen = reading.timestamp;
|
||||
if (!isEqual(bucket.history.at(-1), reading)) {
|
||||
bucket.history.push(reading);
|
||||
if (bucket.history.length > HISTORY_LIMIT) bucket.history.shift();
|
||||
}
|
||||
if(!reading.satellite || reading.satellite === '-') return;
|
||||
|
||||
pruneStale();
|
||||
const {track, score,} = findBestTrack(reading);
|
||||
if(!track || !Number.isFinite(score)) {
|
||||
createTrack(reading);
|
||||
return;
|
||||
}
|
||||
|
||||
updateTrack(track, reading);
|
||||
} catch (err) {
|
||||
console.error('[tinygs] poll failed', err);
|
||||
}
|
||||
@@ -63,8 +238,20 @@ setInterval(pollTinyGS, POLL_MS);
|
||||
|
||||
export const getTinyGSData = () => {
|
||||
pruneStale();
|
||||
return Array.from(satellites.entries()).map(([name, bucket]) => ({
|
||||
...bucket.history.at(-1),
|
||||
history: bucket.history.slice(0, -1),
|
||||
}));
|
||||
}
|
||||
return Array.from(satellites.values()).map(track => {
|
||||
const latest = track.history.at(-1);
|
||||
return {
|
||||
id: track.id,
|
||||
satellite: track.name,
|
||||
...latest,
|
||||
altitudeKm: null,
|
||||
velocity: {
|
||||
v: track.state.groundSpeedKmh,
|
||||
az: track.state.azVelocity,
|
||||
el: track.state.elVelocity,
|
||||
},
|
||||
heading: track.state.heading,
|
||||
history: track.history.slice(0, -1),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
|
||||
import {getTinyGSData} from './sats.mjs';
|
||||
import {getSpaceWeather} from './space.mjs';
|
||||
import {Aurora} from './aurora.mjs';
|
||||
import {getSondes} from './radiosonde.mjs';
|
||||
import {getSondes} from './sonde.mjs';
|
||||
|
||||
// ── Uncaught error handlers ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ export async function getSondes() {
|
||||
const t = sonde.latest_telem ?? {};
|
||||
|
||||
const history = (sonde.path ?? []).map(([latitude, longitude, altitude]) => ({
|
||||
id,
|
||||
latitude,
|
||||
longitude,
|
||||
altitude
|
||||
Reference in New Issue
Block a user