diff --git a/client/src/components/Aircraft.vue b/client/src/components/Aircraft.vue index 60bc14d..341164f 100644 --- a/client/src/components/Aircraft.vue +++ b/client/src/components/Aircraft.vue @@ -1,6 +1,7 @@ @@ -98,7 +232,12 @@ onUnmounted(() => { user-select: none; -webkit-user-select: none; } -.tp-name { margin: 0 24px 0 0; color: #ffaa00; font-size: 16px; } + +.tp-name { + margin: 0 24px 0 0; + color: #ffaa00; + font-size: 16px; +} .tp-close { position: absolute; @@ -117,7 +256,10 @@ onUnmounted(() => { justify-content: center; transition: background 0.2s; } -.tp-close:hover { background: rgba(255,255,255,0.35); } + +.tp-close:hover { + background: rgba(255,255,255,0.35); +} .tp-meta { font-size: 11px; @@ -129,15 +271,36 @@ onUnmounted(() => { border-bottom: 1px solid rgba(255,170,0,0.2); } -.flex-c { display: flex; flex-direction: column; gap: 3px; } -.align-x-end { align-items: flex-end; } +.flex-c { + display: flex; + flex-direction: column; + gap: 3px; +} -.tp-body { display: flex; gap: 16px; padding: 12px 16px; } +.align-x-end { + align-items: flex-end; +} -.tp-gauges { flex: 1; } +.tp-body { + display: flex; + gap: 16px; + padding: 12px 16px; +} -.tp-gauge-wrap { margin-bottom: 12px; } -.tp-gauge-label { color: #888; font-size: 11px; font-weight: bold; margin-bottom: 3px; } +.tp-gauges { + flex: 1; +} + +.tp-gauge-wrap { + margin-bottom: 12px; +} + +.tp-gauge-label { + color: #888; + font-size: 11px; + font-weight: bold; + margin-bottom: 3px; +} .tp-gauge { padding: 6px; @@ -148,10 +311,24 @@ onUnmounted(() => { align-items: flex-end; justify-content: center; gap: 4px; + cursor: pointer; } -.tp-gauge-val { font-size: 20px; font-weight: bold; color: #ffaa00; } -.tp-gauge-unit { font-size: 11px; color: #ffaa00; margin-bottom: 0.75em; } +.tp-gauge:hover { + border-color: #fff; +} + +.tp-gauge-val { + font-size: 20px; + font-weight: bold; + color: #ffaa00; +} + +.tp-gauge-unit { + font-size: 11px; + color: #ffaa00; + margin-bottom: 0.25em; +} .tp-fields { width: 100%; @@ -159,98 +336,138 @@ onUnmounted(() => { padding: 0 16px 12px; border-collapse: collapse; } -.tp-fields td { padding: 2px 4px; color: #888; } -.tp-fields td:last-child { color: #ccc; text-align: right; } -.ok { color: #3fdb6d !important; } -.bad { color: #e0475a !important; } +.tp-fields td { + padding: 2px 4px; + color: #888; +} + +.tp-fields td:last-child { + color: #ccc; + text-align: right; +} + +.ok { + color: #3fdb6d !important; +} + +.bad { + color: #e0475a !important; +} diff --git a/server/src/sats.mjs b/server/src/sats.mjs index 8881fd2..366e5db 100644 --- a/server/src/sats.mjs +++ b/server/src/sats.mjs @@ -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), + }; + }); +}; diff --git a/server/src/server.mjs b/server/src/server.mjs index b5d8805..980f782 100644 --- a/server/src/server.mjs +++ b/server/src/server.mjs @@ -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 ─────────────────────────────────────────────────── diff --git a/server/src/radiosonde.mjs b/server/src/sonde.mjs similarity index 99% rename from server/src/radiosonde.mjs rename to server/src/sonde.mjs index df52cfe..b1b73cc 100644 --- a/server/src/radiosonde.mjs +++ b/server/src/sonde.mjs @@ -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